Improve ISAC libraries, roles, and public profiles

This commit is contained in:
JB
2026-07-27 18:54:46 -04:00
parent fe4acec191
commit 6e12b2f3b7
40 changed files with 1196 additions and 271 deletions
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import {
agentProfileHref,
displayNameKey,
newAgentDisplayName,
publicProfileSlugBase
} from './agents.js';
describe('display names', () => {
it('uses one normalized key for uniqueness checks', () => {
expect(displayNameKey('Aewyn')).toBe(displayNameKey('Aewyn'));
expect(displayNameKey('E\u0301lite')).toBe(displayNameKey('Élite'));
});
it('creates readable short-UID placeholders', () => {
expect(newAgentDisplayName(47)).toBe('Agent 47');
});
});
describe('publicProfileSlugBase', () => {
it('creates readable normalized slugs', () => {
expect(publicProfileSlugBase(' Aewyn Prime ', '12345678-0000-4000-8000-000000000000'))
.toBe('aewyn-prime');
expect(publicProfileSlugBase('Élite 装備', '12345678-0000-4000-8000-000000000000'))
.toBe('élite-装備');
});
it('uses a stable fallback when a name has no letters or numbers', () => {
expect(publicProfileSlugBase('⚡️', '12345678-0000-4000-8000-000000000000'))
.toBe('agent-12345678');
});
it('keeps UUID-shaped display names out of the legacy UUID namespace', () => {
expect(publicProfileSlugBase(
'57a1160e-632f-4695-927f-7df03e20753f',
'12345678-0000-4000-8000-000000000000'
)).toBe('agent-57a1160e-632f-4695-927f-7df03e20753f');
});
});
describe('agentProfileHref', () => {
it('prefers the public slug and safely encodes it', () => {
expect(agentProfileHref({ id: 'uuid', publicSlug: 'élite 装備' }, 'profile-comments'))
.toBe('/agents/%C3%A9lite%20%E8%A3%85%E5%82%99#profile-comments');
});
it('falls back to the UUID for unmigrated records', () => {
expect(agentProfileHref({ id: 'uuid' })).toBe('/agents/uuid');
});
});
+36
View File
@@ -0,0 +1,36 @@
export type AgentProfileReference = {
id: string;
publicSlug?: string | null;
};
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function displayNameKey(displayName: string): string {
return displayName.normalize('NFKC').toLocaleLowerCase('en-US');
}
export function newAgentDisplayName(sequence: number): string {
return `Agent ${Math.max(1, Math.trunc(sequence))}`;
}
export function publicProfileSlugBase(displayName: string, userId: string): string {
const normalized = displayName
.normalize('NFKC')
.toLocaleLowerCase('en-US')
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
.replace(/-+$/g, '');
const fallback = `agent-${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
if (!/[\p{L}\p{N}]/u.test(normalized)) return fallback;
return UUID_PATTERN.test(normalized) ? `agent-${normalized}` : normalized;
}
export function agentProfileHref(
agent: AgentProfileReference,
fragment = ''
): string {
const identifier = agent.publicSlug || agent.id;
const suffix = fragment ? `#${fragment.replace(/^#/, '')}` : '';
return `/agents/${encodeURIComponent(identifier)}${suffix}`;
}
+4 -2
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { agentProfileHref } from '$lib/agents';
let {
agent,
size = 'default',
@@ -6,7 +8,7 @@
showId = false,
linked = true
}: {
agent: { id: string; displayName: string; avatarUrl: string };
agent: { id: string; displayName: string; avatarUrl: string; publicSlug?: string | null };
size?: 'compact' | 'default' | 'large';
meta?: string;
showId?: boolean;
@@ -15,7 +17,7 @@
</script>
{#if linked}
<a class="agent-identity" class:compact={size === 'compact'} class:large={size === 'large'} href={`/agents/${encodeURIComponent(agent.id)}`}>
<a class="agent-identity" class:compact={size === 'compact'} class:large={size === 'large'} href={agentProfileHref(agent)}>
<img src={agent.avatarUrl} alt="" />
<span>
<strong>{agent.displayName}</strong>
+10 -3
View File
@@ -13,7 +13,7 @@
} = $props();
</script>
<article class="database-item-card">
<article class="database-item-card" class:talent-card={item.variant === 'talent'}>
<header>
<div class="database-item-icon">
{#if item.gameIcon}<GameIcon name={item.gameIcon} decorative/>{:else}<ItemIcon src={item.iconUrl} alt=""/>{/if}
@@ -41,8 +41,15 @@
{/each}
</div>
{/if}
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
{#if item.variant === 'talent'}
<div class="database-talent-tiers">
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
</div>
{:else}
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
{/if}
{#if item.afterLines?.length}
<div class="database-item-lines after-description">{#each item.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/if}
+39 -19
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { tick } from 'svelte';
import DatabaseItemCard from './DatabaseItemCard.svelte';
import ItemIcon from './ItemIcon.svelte';
import DatabaseFacts from './DatabaseFacts.svelte';
import type { DatabaseRelatedItem } from '$lib/database/items';
let {
@@ -13,14 +14,45 @@
emptyLabel?: string;
} = $props();
let selected = $state<DatabaseRelatedItem | null>(null);
let trigger = $state<HTMLButtonElement | null>(null);
let closeButton = $state<HTMLButtonElement | null>(null);
let dialog = $state<HTMLDivElement | null>(null);
const openPreview = async (item: DatabaseRelatedItem, button: HTMLButtonElement) => {
selected = item;
trigger = button;
await tick();
closeButton?.focus();
};
const closePreview = async () => {
const previousTrigger = trigger;
selected = null;
trigger = null;
await tick();
previousTrigger?.focus();
};
const trapDialogFocus = (event: KeyboardEvent) => {
if (event.key !== 'Tab' || !dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),[tabindex]:not([tabindex="-1"])')]
.filter((element) => !element.hasAttribute('hidden'));
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
</script>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape') selected = null; }}/>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape' && selected) void closePreview(); }}/>
<div class="item-icon-rail" aria-label={`${label}: ${items.length ? items.map((item) => item.name).join(', ') : emptyLabel}`}>
{#if items.length}
{#each items as item}
<button type="button" title={`View ${item.name}`} aria-label={`View ${item.name} details`} onclick={() => selected = item}>
<button type="button" title={`View ${item.name}`} aria-label={`View ${item.name} details`} aria-haspopup={item.preview ? 'dialog' : undefined} onclick={(event) => void openPreview(item, event.currentTarget)}>
<ItemIcon src={item.iconUrl} alt=""/>
</button>
{/each}
@@ -30,22 +62,10 @@
</div>
{#if selected?.preview}
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) selected = null; }}>
<div class="database-preview-dialog" role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`}>
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" aria-label="Close details" onclick={() => selected = null}>×</button></header>
<article class="database-item-card">
<header>
<div class="database-item-icon"><ItemIcon src={selected.preview.iconUrl} alt=""/></div>
<div><small>{selected.preview.kicker}</small><h2>{selected.preview.name}</h2></div>
</header>
{#if selected.preview.badges?.length}<div class="database-item-badges">{#each selected.preview.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
<DatabaseFacts facts={selected.preview.facts} layout={selected.preview.factLayout}/>
{#if selected.preview.lines?.length}<div class="database-item-lines">{#each selected.preview.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>{/if}
{#if selected.preview.description}<p class:data-description={selected.preview.descriptionStyle === 'data'}>{selected.preview.description}</p>{/if}
{#if selected.preview.secondaryDescription}<p class="secondary-description" class:data-description={selected.preview.descriptionStyle === 'data'}>{selected.preview.secondaryDescription}</p>{/if}
{#if selected.preview.afterLines?.length}<div class="database-item-lines after-description">{#each selected.preview.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>{/if}
{#if selected.preview.actions?.length}<footer class="database-item-actions">{#each selected.preview.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>{/if}
</article>
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closePreview(); }}>
<div class="database-preview-dialog" bind:this={dialog} role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`} tabindex="-1" onkeydown={trapDialogFocus}>
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" bind:this={closeButton} aria-label="Close details" onclick={() => void closePreview()}>×</button></header>
<DatabaseItemCard item={selected.preview}/>
</div>
</div>
{/if}
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
let {
roles,
label = 'Assigned site roles',
compact = false
}: {
roles: string[];
label?: string;
compact?: boolean;
} = $props();
const visibleRoles = $derived(
[...new Set(roles.map((role) => role.trim()).filter(Boolean))]
);
</script>
{#if visibleRoles.length}
<ul class="role-badge-list" class:compact aria-label={label}>
{#each visibleRoles as role}
<li><span aria-hidden="true"></span>{role}</li>
{/each}
</ul>
{/if}
<style>
.role-badge-list{min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin:0;padding:0;list-style:none}
.role-badge-list li{min-width:0;min-height:27px;display:flex;align-items:center;gap:7px;padding:4px 8px;border:1px solid var(--shd-border);background:var(--shd-surface-raised);color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;line-height:1.25;letter-spacing:.65px;text-transform:uppercase;overflow-wrap:anywhere}
.role-badge-list li span{flex:none;width:5px;height:5px;border:1px solid var(--shd-orange);background:color-mix(in srgb,var(--shd-orange) 35%,transparent);transform:rotate(45deg)}
.role-badge-list.compact{gap:3px}.role-badge-list.compact li{min-height:23px;padding:3px 5px;gap:5px;letter-spacing:.55px}.role-badge-list.compact li span{width:4px;height:4px}
@media(max-width:600px){.role-badge-list:not(.compact) li{min-height:30px}}
</style>
+2 -1
View File
@@ -15,8 +15,9 @@ export type DatabaseItem = {
id: string;
name: string;
kicker: string;
variant?: 'talent';
iconUrl?: string;
gameIcon?: string;
gameIcon?: GameIconName;
headerBadge?: string;
headerMetaIcon?: GameIconName;
badges?: DatabaseBadge[];
+40
View File
@@ -0,0 +1,40 @@
import { connect } from '@tursodatabase/database';
import { describe, expect, it } from 'vitest';
import {
createAvailableDisplayName,
displayNameIsAvailable
} from './agent-profiles.js';
import { displayNameKey } from '../agents.js';
describe('public profile identity allocation', () => {
it('allocates the next available Agent number', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE user_profiles (
user_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
display_name_key TEXT UNIQUE,
public_slug TEXT
)`);
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key) VALUES(?,?,?)`))
.run('existing', 'Agent 47', displayNameKey('Agent 47'));
expect(await createAvailableDisplayName(database, 'Agent', 'new-user', true)).toBe('Agent 48');
});
it('compares display names through the normalized Unicode key', async () => {
const database = await connect(':memory:');
await database.exec(`CREATE TABLE user_profiles (
user_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
display_name_key TEXT UNIQUE,
public_slug TEXT
)`);
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key) VALUES(?,?,?)`))
.run('existing', 'Élite', displayNameKey('Élite'));
expect(await displayNameIsAvailable(database, 'E\u0301lite')).toBe(false);
expect(await displayNameIsAvailable(database, 'Élite', 'existing')).toBe(true);
});
});
+90
View File
@@ -0,0 +1,90 @@
import type { Database } from '@tursodatabase/database';
import {
displayNameKey,
newAgentDisplayName,
publicProfileSlugBase
} from '$lib/agents';
export async function displayNameIsAvailable(
database: Database,
displayName: string,
exceptUserId = ''
): Promise<boolean> {
const row = await (await database.prepare(
'SELECT user_id FROM user_profiles WHERE display_name_key=? AND user_id<>?'
)).get(displayNameKey(displayName), exceptUserId);
return !row;
}
export async function createAvailableDisplayName(
database: Database,
preferredName: string,
userId: string,
placeholder = false
): Promise<string> {
if (!placeholder && await displayNameIsAvailable(database, preferredName)) return preferredName;
if (placeholder) {
const names = await (await database.prepare(
"SELECT display_name FROM user_profiles WHERE display_name LIKE 'Agent %'"
)).all();
let sequence = names.reduce((highest, row) => {
const match = /^Agent ([1-9]\d*)$/.exec(String(row.display_name));
return match ? Math.max(highest, Number(match[1])) : highest;
}, 0) + 1;
while (!(await displayNameIsAvailable(database, newAgentDisplayName(sequence)))) sequence += 1;
return newAgentDisplayName(sequence);
}
for (let suffixLength = 5; suffixLength <= 12; suffixLength += 1) {
const candidate = `${preferredName} ${userId.replaceAll('-', '').slice(0, suffixLength).toLowerCase()}`;
if (await displayNameIsAvailable(database, candidate)) return candidate;
}
throw new Error('Unable to allocate a unique display name.');
}
export async function createPublicProfileSlug(
database: Database,
displayName: string,
userId: string
): Promise<string> {
const base = publicProfileSlugBase(displayName, userId);
const existing = await (await database.prepare(
'SELECT user_id FROM user_profiles WHERE public_slug=?'
)).get(base);
if (!existing || String(existing.user_id) === userId) return base;
return `${base.slice(0, 55).replace(/-+$/g, '')}~${userId.replaceAll('-', '').slice(0, 8).toLowerCase()}`;
}
export async function insertUserProfile(
database: Database,
userId: string,
preferredName: string,
updatedAt: string,
options: { placeholder?: boolean; resolveCollision?: boolean } = {}
): Promise<{ displayName: string; publicSlug: string }> {
const displayName = options.placeholder || options.resolveCollision
? await createAvailableDisplayName(database, preferredName, userId, Boolean(options.placeholder))
: preferredName;
const publicSlug = await createPublicProfileSlug(database, displayName, userId);
await (await database.prepare(`INSERT INTO user_profiles
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)`))
.run(userId, displayName, displayNameKey(displayName), publicSlug, updatedAt);
return { displayName, publicSlug };
}
export async function resolveAgentProfile(
database: Database,
identifier: string
): Promise<{ id: string; publicSlug: string; displayName: string } | null> {
const row = await (await database.prepare(`SELECT users.id,
COALESCE(profile.public_slug,users.id) AS public_slug,
COALESCE(profile.display_name,users.username) AS display_name
FROM users LEFT JOIN user_profiles profile ON profile.user_id=users.id
WHERE users.id=? OR profile.public_slug=?
ORDER BY CASE WHEN profile.public_slug=? THEN 0 ELSE 1 END
LIMIT 1`)).get(identifier, identifier, identifier);
return row ? {
id: String(row.id),
publicSlug: String(row.public_slug),
displayName: String(row.display_name)
} : null;
}
+6 -3
View File
@@ -1,6 +1,7 @@
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { deleteUploadedAvatar } from '$lib/server/avatars';
import { displayNameKey } from '$lib/agents';
export async function softDeleteAccount(
database: Database,
@@ -18,6 +19,7 @@ export async function softDeleteAccount(
if (!user || user.deleted_at) return false;
const now = new Date().toISOString();
const deletedUsername = `deleted-agent-${userId.slice(0, 12)}`;
const deletedDisplayName = `Deleted Agent ${userId.replaceAll('-', '').slice(0, 8)}`;
await (await database.prepare(
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)'
@@ -26,15 +28,16 @@ export async function softDeleteAccount(
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
)).run(deletedUsername, userId);
await (await database.prepare(
`INSERT INTO user_profiles(user_id,display_name,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,0,?,?)
`INSERT INTO user_profiles(user_id,display_name,display_name_key,bio_markdown,is_public,bio_updated_at,updated_at)
VALUES(?,?,?,?,0,?,?)
ON CONFLICT(user_id) DO UPDATE SET
display_name=excluded.display_name,
display_name_key=excluded.display_name_key,
bio_markdown='',
is_public=0,
bio_updated_at=excluded.bio_updated_at,
updated_at=excluded.updated_at`
)).run(userId, 'Deleted Agent', '', now, now);
)).run(userId, deletedDisplayName, displayNameKey(deletedDisplayName), '', now, now);
await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')).run(userId);
+3
View File
@@ -12,6 +12,7 @@ export type AuthUser = {
username: string;
loginUsername: string | null;
displayName: string;
publicSlug: string | null;
role: string;
discordLinked: boolean;
avatarUrl: string;
@@ -111,6 +112,7 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
const database = await getDatabase();
const statement = await database.prepare(`
SELECT users.id, users.username, users.login_username, COALESCE(user_profiles.display_name,users.username) AS display_name,
user_profiles.public_slug,
users.role, users.discord_id, user_avatars.source AS avatar_source,
user_avatars.value AS avatar_value, user_avatars.updated_at AS avatar_updated_at,
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
@@ -147,6 +149,7 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
username: String(user.username),
loginUsername: typeof user.login_username === 'string' ? user.login_username : null,
displayName: String(user.display_name),
publicSlug: typeof user.public_slug === 'string' ? user.public_slug : null,
role: String(user.role),
discordLinked: typeof user.discord_id === 'string',
avatarUrl: avatarUrl(user.avatar_source, user.avatar_value, String(user.id), user.avatar_updated_at)
+139
View File
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() }));
vi.mock('node:fs/promises', async (importOriginal) => ({
...await importOriginal<typeof import('node:fs/promises')>(),
rm: rmMock
}));
import { GAME_ICON_TYPES } from './game-data-icons';
import { ManagedImageDeletionError, deleteManagedImage } from './managed-images';
type Row = Record<string, unknown>;
class FakeDatabase {
images = new Map<string, Row>();
icons = new Map<string, Row>();
executed: string[] = [];
prepare(sql: string) {
return Promise.resolve({
get: async (...args: unknown[]) => {
if (sql.startsWith('SELECT * FROM managed_images')) return this.images.get(String(args[0]));
if (sql.startsWith('SELECT * FROM game_data_icons')) {
return this.icons.get(`${String(args[0])}:${String(args[1])}`);
}
throw new Error(`Unexpected get: ${sql}`);
},
run: async (...args: unknown[]) => {
if (sql.startsWith('DELETE FROM game_data_icons')) {
const key = `${String(args[0])}:${String(args[1])}`;
const row = this.icons.get(key);
if (row?.storage_key === args[2]) this.icons.delete(key);
return;
}
if (sql.startsWith('DELETE FROM managed_images')) {
const row = this.images.get(String(args[0]));
if (row?.storage_key === args[1]) this.images.delete(String(args[0]));
return;
}
throw new Error(`Unexpected run: ${sql}`);
}
});
}
async exec(sql: string) {
this.executed.push(sql);
}
}
const imageId = '11111111-1111-4111-8111-111111111111';
const sourceId = '22222222-2222-4222-8222-222222222222';
describe('deleteManagedImage', () => {
beforeEach(() => rmMock.mockReset().mockResolvedValue(undefined));
it.each(GAME_ICON_TYPES)('detaches and deletes an assigned %s image', async (sourceType) => {
const database = new FakeDatabase();
const storageKey = `${sourceType}-${sourceId}.webp`;
database.images.set(imageId, {
id: imageId, storage_key: storageKey,
source_entity_type: sourceType, source_entity_id: sourceId
});
database.icons.set(`${sourceType}:${sourceId}`, {
entity_type: sourceType, entity_id: sourceId, storage_key: storageKey
});
const deleted = await deleteManagedImage(database as never, imageId);
expect(deleted?.sourceIcon).toMatchObject({ entity_type: sourceType, entity_id: sourceId });
expect(database.images.has(imageId)).toBe(false);
expect(database.icons.has(`${sourceType}:${sourceId}`)).toBe(false);
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
expect(rmMock).toHaveBeenCalledOnce();
});
it('deletes a standalone library image', async () => {
const database = new FakeDatabase();
database.images.set(imageId, {
id: imageId, storage_key: `library-${imageId}.webp`,
source_entity_type: null, source_entity_id: null
});
const deleted = await deleteManagedImage(database as never, imageId);
expect(deleted?.sourceIcon).toBeNull();
expect(database.images.has(imageId)).toBe(false);
expect(database.icons.size).toBe(0);
expect(rmMock).toHaveBeenCalledOnce();
});
it('deletes an orphaned linked image without touching another assignment', async () => {
const database = new FakeDatabase();
database.images.set(imageId, {
id: imageId, storage_key: `talent-${sourceId}.webp`,
source_entity_type: 'talent', source_entity_id: sourceId
});
const deleted = await deleteManagedImage(database as never, imageId);
expect(deleted?.sourceIcon).toBeNull();
expect(database.images.has(imageId)).toBe(false);
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
});
it('fails closed when the library and source rows reference different files', async () => {
const database = new FakeDatabase();
database.images.set(imageId, {
id: imageId, storage_key: `talent-${sourceId}.webp`,
source_entity_type: 'talent', source_entity_id: sourceId
});
database.icons.set(`talent:${sourceId}`, {
entity_type: 'talent', entity_id: sourceId,
storage_key: 'talent-33333333-3333-4333-8333-333333333333.webp'
});
await expect(deleteManagedImage(database as never, imageId)).rejects.toThrow(ManagedImageDeletionError);
expect(database.images.has(imageId)).toBe(true);
expect(database.icons.has(`talent:${sourceId}`)).toBe(true);
expect(database.executed).toEqual([]);
expect(rmMock).not.toHaveBeenCalled();
});
it.each([
{ source_entity_type: 'talent', source_entity_id: null },
{ source_entity_type: null, source_entity_id: sourceId },
{ source_entity_type: 'unknown', source_entity_id: sourceId }
])('rejects an invalid source relationship: $source_entity_type / $source_entity_id', async (source) => {
const database = new FakeDatabase();
database.images.set(imageId, {
id: imageId, storage_key: `library-${imageId}.webp`, ...source
});
await expect(deleteManagedImage(database as never, imageId)).rejects.toThrow(ManagedImageDeletionError);
expect(database.images.has(imageId)).toBe(true);
expect(database.executed).toEqual([]);
expect(rmMock).not.toHaveBeenCalled();
});
});
+41 -1
View File
@@ -1,7 +1,8 @@
import type { Database } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { GAME_ICON_TYPES, type GameDataIconType } from './game-data-icons';
import { BackgroundRemovalError, removeBackgroundPreview } from './remove-background';
export const MANAGED_IMAGE_CATEGORIES = [
@@ -14,6 +15,7 @@ const ID_PATTERN = /^[0-9a-f-]{36}$/i;
const KEY_PATTERN = /^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i;
export class ManagedImageValidationError extends Error {}
export class ManagedImageDeletionError extends Error {}
export function managedImageStorageDirectory() {
return path.resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || path.join(process.cwd(), 'data', 'game-icons'));
@@ -102,6 +104,44 @@ export async function saveManagedImageFile(id: string, output: Buffer) {
return key;
}
export async function deleteManagedImage(database: Database, id: string) {
const image = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id);
if (!image) return null;
if (typeof image.storage_key !== 'string') throw new ManagedImageDeletionError('Managed image is missing its storage key.');
const sourceType = typeof image.source_entity_type === 'string' ? image.source_entity_type : null;
const sourceId = typeof image.source_entity_id === 'string' ? image.source_entity_id : null;
if (Boolean(sourceType) !== Boolean(sourceId)) {
throw new ManagedImageDeletionError('This image has an incomplete source assignment and cannot be deleted safely.');
}
if (sourceType && !GAME_ICON_TYPES.includes(sourceType as GameDataIconType)) {
throw new ManagedImageDeletionError('This image has an unsupported source assignment and cannot be deleted safely.');
}
const sourceIcon = sourceType && sourceId
? await (await database.prepare('SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?')).get(sourceType, sourceId) ?? null
: null;
if (sourceIcon && sourceIcon.storage_key !== image.storage_key) {
throw new ManagedImageDeletionError('The library image and its source assignment reference different files. Resolve the assignment before deleting.');
}
await database.exec('BEGIN');
try {
if (sourceIcon && sourceType && sourceId) {
await (await database.prepare(
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?'
)).run(sourceType, sourceId, image.storage_key);
}
await (await database.prepare('DELETE FROM managed_images WHERE id=? AND storage_key=?')).run(id, image.storage_key);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
}
await rm(storagePath(image.storage_key), { force: true });
return { image, sourceIcon };
}
export async function readManagedImage(database: Database, id: string) {
if (!ID_PATTERN.test(id)) return null;
const row = await (await database.prepare('SELECT storage_key FROM managed_images WHERE id=?')).get(id);
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { notificationOrderBy, parseNotificationSort, parseNotificationState } from './notification-sort';
describe('notification sorting', () => {
it('defaults unknown and missing filters to unread oldest-first', () => {
expect(parseNotificationState('unexpected')).toBe('unread');
expect(parseNotificationState(null)).toBe('unread');
expect(parseNotificationSort('unexpected')).toBe('oldest');
expect(parseNotificationSort(null)).toBe('oldest');
});
it('maps newest and oldest to chronological timestamp directions', () => {
expect(notificationOrderBy('newest')).toContain('unixepoch(n.created_at) DESC');
expect(notificationOrderBy('oldest')).toContain('unixepoch(n.created_at) ASC');
});
it('keeps unread messages first and sorts each state newest first', () => {
const expression = notificationOrderBy('unread');
expect(expression).toContain('CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END');
expect(expression).toContain('unixepoch(n.created_at) DESC');
});
});
+27
View File
@@ -0,0 +1,27 @@
export const NOTIFICATION_SORTS = ['newest', 'oldest', 'unread'] as const;
export const NOTIFICATION_STATES = ['all', 'unread', 'read'] as const;
export type NotificationSort = (typeof NOTIFICATION_SORTS)[number];
export type NotificationState = (typeof NOTIFICATION_STATES)[number];
export function parseNotificationSort(value: unknown): NotificationSort {
const requested = String(value ?? '');
return NOTIFICATION_SORTS.includes(requested as NotificationSort)
? requested as NotificationSort
: 'oldest';
}
export function parseNotificationState(value: unknown): NotificationState {
const requested = String(value ?? '');
return NOTIFICATION_STATES.includes(requested as NotificationState)
? requested as NotificationState
: 'unread';
}
export function notificationOrderBy(sort: NotificationSort) {
return {
newest: 'unixepoch(n.created_at) DESC, n.created_at DESC, n.id DESC',
oldest: 'unixepoch(n.created_at) ASC, n.created_at ASC, n.id ASC',
unread: 'CASE WHEN n.read_at IS NULL THEN 0 ELSE 1 END, unixepoch(n.created_at) DESC, n.created_at DESC, n.id DESC'
}[sort];
}