Improve ISAC libraries, roles, and public profiles
This commit is contained in:
@@ -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