/** Role resolution by linked Discord ID (docs/14-admin.md §2). Computed per request. */ import type { Database } from 'bun:sqlite'; export type Role = 'user' | 'admin' | 'superadmin'; export class Roles { constructor( private db: Database, private superadminDiscordIds: string[], ) {} discordIdOf(userId: string): string | null { return ( this.db .query<{ provider_user_id: string }, [string]>("SELECT provider_user_id FROM oauth_accounts WHERE user_id = ? AND provider = 'discord'") .get(userId)?.provider_user_id ?? null ); } roleOfDiscord(discordId: string | null): Role { if (!discordId) return 'user'; if (this.superadminDiscordIds.includes(discordId)) return 'superadmin'; const row = this.db.query<{ n: number }, [string]>('SELECT COUNT(*) AS n FROM admins WHERE discord_id = ?').get(discordId); return (row?.n ?? 0) > 0 ? 'admin' : 'user'; } roleOf(userId: string | null): Role { return userId ? this.roleOfDiscord(this.discordIdOf(userId)) : 'user'; } isSuperadminDiscord(discordId: string): boolean { return this.superadminDiscordIds.includes(discordId); } listAdmins(): { discordId: string; note: string | null; addedBy: string | null; addedAt: number; userId: string | null; nickname: string | null; fixed: boolean }[] { const rows = this.db .query<{ discord_id: string; note: string | null; added_by: string | null; added_at: number; user_id: string | null; nickname: string | null }, []>( `SELECT a.discord_id, a.note, a.added_by, a.added_at, o.user_id, u.nickname FROM admins a LEFT JOIN oauth_accounts o ON o.provider = 'discord' AND o.provider_user_id = a.discord_id LEFT JOIN users u ON u.id = o.user_id ORDER BY a.added_at`, ) .all() .map((r) => ({ discordId: r.discord_id, note: r.note, addedBy: r.added_by, addedAt: r.added_at, userId: r.user_id, nickname: r.nickname, fixed: false })); const fixed = this.superadminDiscordIds.map((d) => { const u = this.db .query<{ user_id: string; nickname: string }, [string]>( "SELECT o.user_id, u.nickname FROM oauth_accounts o JOIN users u ON u.id = o.user_id WHERE o.provider = 'discord' AND o.provider_user_id = ?", ) .get(d); return { discordId: d, note: '슈퍼어드민(설정 고정)', addedBy: null, addedAt: 0, userId: u?.user_id ?? null, nickname: u?.nickname ?? null, fixed: true }; }); return [...fixed, ...rows]; } addAdmin(discordId: string, note: string | null, by: string, now = Date.now()): boolean { return this.db.query('INSERT OR IGNORE INTO admins (discord_id, note, added_by, added_at) VALUES (?, ?, ?, ?)').run(discordId, note, by, now).changes > 0; } removeAdmin(discordId: string): boolean { return this.db.query('DELETE FROM admins WHERE discord_id = ?').run(discordId).changes > 0; } } export class Audit { constructor(private db: Database) {} log(actorId: string, action: string, target: string | null, before: unknown, after: unknown, now = Date.now()): void { this.db .query('INSERT INTO admin_audit (actor_id, action, target, before_json, after_json, at) VALUES (?, ?, ?, ?, ?, ?)') .run(actorId, action, target, before === undefined ? null : JSON.stringify(before), after === undefined ? null : JSON.stringify(after), now); } list(limit: number, offset: number) { return this.db .query<{ id: number; actor_id: string; nickname: string | null; action: string; target: string | null; before_json: string | null; after_json: string | null; at: number }, [number, number]>( 'SELECT a.*, u.nickname FROM admin_audit a LEFT JOIN users u ON u.id = a.actor_id ORDER BY a.id DESC LIMIT ? OFFSET ?', ) .all(limit, offset) .map((r) => ({ id: r.id, actorId: r.actor_id, actor: r.nickname, action: r.action, target: r.target, before: r.before_json ? JSON.parse(r.before_json) : null, after: r.after_json ? JSON.parse(r.after_json) : null, at: r.at, })); } }