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
+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];
}