import type { Database } from 'bun:sqlite'; import { ulid } from 'ulid'; import type { PublicUser } from '@bg/shared'; export interface UserRow { id: string; kind: 'guest' | 'member'; nickname: string; avatar_url: string | null; use_avatar: number; settings_json: string; nick_changed_at: number | null; nick_change_count: number; created_at: number; last_seen_at: number; deleted_at: number | null; banned_at: number | null; banned_reason: string | null; } export const NICK_CHANGES_PER_DAY = 10; export class Users { constructor(private db: Database) {} get(id: string): UserRow | null { return this.db.query('SELECT * FROM users WHERE id = ? AND deleted_at IS NULL').get(id); } createGuest(nickname: string, now = Date.now()): UserRow { const id = ulid(now); this.db .query('INSERT INTO users (id, kind, nickname, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)') .run(id, 'guest', nickname, now, now); return this.get(id)!; } findByOauth(provider: string, providerUserId: string): UserRow | null { return this.db .query( `SELECT u.* FROM users u JOIN oauth_accounts o ON o.user_id = u.id WHERE o.provider = ? AND o.provider_user_id = ? AND u.deleted_at IS NULL`, ) .get(provider, providerUserId); } createMember(nickname: string, avatarUrl: string | null, now = Date.now()): UserRow { const id = ulid(now); this.db .query('INSERT INTO users (id, kind, nickname, avatar_url, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?)') .run(id, 'member', nickname, avatarUrl, now, now); return this.get(id)!; } linkOauth(userId: string, provider: string, providerUserId: string, username: string | null, now = Date.now()): void { this.db .query('INSERT INTO oauth_accounts (provider, provider_user_id, user_id, username, created_at) VALUES (?, ?, ?, ?, ?)') .run(provider, providerUserId, userId, username, now); } promoteToMember(userId: string, avatarUrl: string | null): void { this.db.query("UPDATE users SET kind = 'member', avatar_url = ? WHERE id = ?").run(avatarUrl, userId); } setAvatar(userId: string, avatarUrl: string | null): void { this.db.query('UPDATE users SET avatar_url = ? WHERE id = ?').run(avatarUrl, userId); } /** Moves stats from a guest into a member account and deletes the guest (docs/05 §4). */ mergeGuestInto(guestId: string, memberId: string): void { this.db.transaction(() => { const rows = this.db .query<{ game_id: string; played: number; wins: number; draws: number }, [string]>( 'SELECT game_id, played, wins, draws FROM user_stats WHERE user_id = ?', ) .all(guestId); for (const r of rows) { this.db .query( `INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + excluded.played, wins = wins + excluded.wins, draws = draws + excluded.draws`, ) .run(memberId, r.game_id, r.played, r.wins, r.draws); } this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(guestId); this.db.query('UPDATE game_players SET user_id = ? WHERE user_id = ?').run(memberId, guestId); this.db.query('DELETE FROM sessions WHERE user_id = ?').run(guestId); this.db.query('DELETE FROM users WHERE id = ?').run(guestId); })(); } /** Returns an error message or null. Enforces the daily change limit. */ changeNickname(userId: string, nickname: string, now = Date.now()): string | null { const u = this.get(userId); if (!u) return '사용자를 찾을 수 없어요.'; if (u.nickname === nickname) return null; const sameDay = u.nick_changed_at !== null && now - u.nick_changed_at < 24 * 3600_000; const count = sameDay ? u.nick_change_count : 0; if (count >= NICK_CHANGES_PER_DAY) return '닉네임은 하루에 10번까지 바꿀 수 있어요.'; this.db .query('UPDATE users SET nickname = ?, nick_changed_at = ?, nick_change_count = ? WHERE id = ?') .run(nickname, sameDay ? u.nick_changed_at : now, count + 1, userId); return null; } updateSettings(userId: string, patch: { useAvatar?: boolean; settings?: Record }): void { if (patch.useAvatar !== undefined) { this.db.query('UPDATE users SET use_avatar = ? WHERE id = ?').run(patch.useAvatar ? 1 : 0, userId); } if (patch.settings) { this.db.query('UPDATE users SET settings_json = ? WHERE id = ?').run(JSON.stringify(patch.settings), userId); } } /** Admin rename: ignores the daily limit. */ forceNickname(userId: string, nickname: string): void { this.db.query('UPDATE users SET nickname = ? WHERE id = ?').run(nickname, userId); } setBan(userId: string, reason: string | null, now = Date.now()): void { if (reason === null) this.db.query('UPDATE users SET banned_at = NULL, banned_reason = NULL WHERE id = ?').run(userId); else this.db.query('UPDATE users SET banned_at = ?, banned_reason = ? WHERE id = ?').run(now, reason, userId); } search(q: string, limit: number, offset: number) { const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`; const rows = this.db .query< UserRow & { discord_id: string | null; discord_name: string | null; played: number; wins: number; draws: number }, [string, string, string, string, number, number] >( `SELECT u.*, o.provider_user_id AS discord_id, o.username AS discord_name, COALESCE(s.played, 0) AS played, COALESCE(s.wins, 0) AS wins, COALESCE(s.draws, 0) AS draws FROM users u LEFT JOIN oauth_accounts o ON o.user_id = u.id AND o.provider = 'discord' LEFT JOIN (SELECT user_id, SUM(played) AS played, SUM(wins) AS wins, SUM(draws) AS draws FROM user_stats GROUP BY user_id) s ON s.user_id = u.id WHERE u.deleted_at IS NULL AND (? = '' OR u.nickname LIKE ? ESCAPE '\\' OR u.id = ? OR o.provider_user_id = ?) ORDER BY u.last_seen_at DESC LIMIT ? OFFSET ?`, ) .all(q, like, q, q, limit, offset); const total = this.db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM users WHERE deleted_at IS NULL').get()!.n; return { rows, total }; } counts(): { total: number; guests: number; members: number; banned: number } { return this.db .query<{ total: number; guests: number; members: number; banned: number }, []>( `SELECT COUNT(*) AS total, SUM(kind = 'guest') AS guests, SUM(kind = 'member') AS members, SUM(banned_at IS NOT NULL) AS banned FROM users WHERE deleted_at IS NULL`, ) .get()!; } touch(userId: string, now = Date.now()): void { this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId); } /** Guests not seen since `before`, oldest first. */ idleGuests(before: number, limit = 500): string[] { return this.db .query<{ id: string }, [number, number]>( "SELECT id FROM users WHERE kind = 'guest' AND deleted_at IS NULL AND last_seen_at < ? ORDER BY last_seen_at LIMIT ?", ) .all(before, limit) .map((r) => r.id); } /** * Removes a guest account for good (logout, or idle for a day): the user row, its stats and sessions. * Past games keep the seat (shown as an unknown player). Returns false for non-guests. */ deleteGuest(userId: string): boolean { return this.db.transaction(() => { const row = this.db.query<{ kind: string }, [string]>('SELECT kind FROM users WHERE id = ?').get(userId); if (row?.kind !== 'guest') return false; this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(userId); this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId); this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId); this.db.query('DELETE FROM users WHERE id = ?').run(userId); return true; })(); } softDelete(userId: string, now = Date.now()): void { this.db.transaction(() => { this.db .query("UPDATE users SET deleted_at = ?, nickname = '탈퇴한 사용자', avatar_url = NULL WHERE id = ?") .run(now, userId); this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId); this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId); })(); } stats(userId: string): { gameId: string; played: number; wins: number; draws: number }[] { return this.db .query<{ gameId: string; played: number; wins: number; draws: number }, [string]>( 'SELECT game_id AS gameId, played, wins, draws FROM user_stats WHERE user_id = ? ORDER BY played DESC', ) .all(userId); } } export function toPublicUser(u: UserRow): PublicUser { return { id: u.id, nickname: u.nickname, avatar: u.use_avatar && u.avatar_url ? u.avatar_url : null, kind: u.kind, }; }