Improve ISAC libraries, roles, and public profiles
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user