import { db } from './database.js' import { config } from '../config.js' export type ServerUserRole = 'user' | 'admin' export interface PersistedUser { id: string nickname: string avatarUrl: string | null passwordHash: string | null role: ServerUserRole createdAt: number updatedAt: number lastSeenAt: number } interface UserRow { id: string nickname: string avatar_url: string | null password_hash: string | null role: ServerUserRole created_at: number updated_at: number last_seen_at: number } interface PlatformAuthRenameRow { platform: string cookie_encrypted: string nickname_snapshot: string | null vip_type: number created_at: number updated_at: number } interface PermanentRoomMembershipRenameRow { room_id: string joined_at: number last_seen_at: number } export type RenameUserResult = | { success: true; user: PersistedUser } | { success: false; reason: 'not_found' | 'conflict' } function toUser(row: UserRow): PersistedUser { return { id: row.id, nickname: row.nickname, avatarUrl: row.avatar_url, passwordHash: row.password_hash, role: row.role, createdAt: row.created_at, updatedAt: row.updated_at, lastSeenAt: row.last_seen_at, } } const selectUser = db.prepare('SELECT * FROM users WHERE id = ?') const selectUserCaseInsensitive = db.prepare('SELECT * FROM users WHERE id = ? COLLATE NOCASE LIMIT 1') const insertUser = db.prepare(` INSERT INTO users (id, nickname, avatar_url, password_hash, role, created_at, updated_at, last_seen_at) VALUES (@id, @nickname, NULL, NULL, @role, @now, @now, @now) `) const touchUser = db.prepare('UPDATE users SET last_seen_at = ?, updated_at = ? WHERE id = ?') const updateProfile = db.prepare( 'UPDATE users SET nickname = COALESCE(?, nickname), avatar_url = COALESCE(?, avatar_url), updated_at = ? WHERE id = ?', ) const setPasswordHash = db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?') const setRole = db.prepare('UPDATE users SET role = ?, updated_at = ? WHERE id = ?') const listUsers = db.prepare<[], UserRow>('SELECT * FROM users ORDER BY created_at DESC') const deleteUser = db.prepare('DELETE FROM users WHERE id = ?') const insertRenamedUser = db.prepare(` INSERT INTO users (id, nickname, avatar_url, password_hash, role, created_at, updated_at, last_seen_at) VALUES (@id, @nickname, @avatarUrl, @passwordHash, @role, @createdAt, @now, @now) `) const selectPlatformAuthForRename = db.prepare<[string], PlatformAuthRenameRow>(` SELECT platform, cookie_encrypted, nickname_snapshot, vip_type, created_at, updated_at FROM platform_auth WHERE user_id = ? ORDER BY updated_at DESC `) const deletePlatformAuthForRename = db.prepare('DELETE FROM platform_auth WHERE user_id = ?') const selectPermanentRoomMembershipsForRename = db.prepare<[string], PermanentRoomMembershipRenameRow>(` SELECT room_id, joined_at, last_seen_at FROM permanent_room_members WHERE user_id = ? `) const insertRenamedPermanentRoomMembership = db.prepare(` INSERT INTO permanent_room_members (room_id, user_id, joined_at, last_seen_at) VALUES (@roomId, @userId, @joinedAt, @lastSeenAt) ON CONFLICT(room_id, user_id) DO UPDATE SET joined_at = MIN(permanent_room_members.joined_at, excluded.joined_at), last_seen_at = MAX(permanent_room_members.last_seen_at, excluded.last_seen_at) `) const insertRenamedPlatformAuth = db.prepare(` INSERT INTO platform_auth ( id, user_id, platform, cookie_encrypted, nickname_snapshot, vip_type, created_at, updated_at ) VALUES (@id, @userId, @platform, @cookie, @nickname, @vipType, @createdAt, @updatedAt) `) const renameUser = db.transaction((oldUserId: string, newUserId: string): RenameUserResult => { const current = selectUser.get(oldUserId) if (!current) return { success: false, reason: 'not_found' } if (oldUserId === newUserId) return { success: true, user: toUser(current) } const conflict = selectUserCaseInsensitive.get(newUserId) if (conflict && conflict.id !== oldUserId) return { success: false, reason: 'conflict' } const authRows = selectPlatformAuthForRename.all(oldUserId) const roomMemberships = selectPermanentRoomMembershipsForRename.all(oldUserId) const now = Date.now() insertRenamedUser.run({ id: newUserId, nickname: current.nickname, avatarUrl: current.avatar_url, passwordHash: current.password_hash, role: current.role, createdAt: current.created_at, now, }) deletePlatformAuthForRename.run(oldUserId) const seenPlatforms = new Set() for (const row of authRows) { if (seenPlatforms.has(row.platform)) continue seenPlatforms.add(row.platform) insertRenamedPlatformAuth.run({ id: `account:${newUserId}:${row.platform}`, userId: newUserId, platform: row.platform, cookie: row.cookie_encrypted, nickname: row.nickname_snapshot, vipType: row.vip_type, createdAt: row.created_at, updatedAt: Math.max(row.updated_at, now), }) } for (const membership of roomMemberships) { insertRenamedPermanentRoomMembership.run({ roomId: membership.room_id, userId: newUserId, joinedAt: membership.joined_at, lastSeenAt: membership.last_seen_at, }) } deleteUser.run(oldUserId) return { success: true, user: toUser(selectUser.get(newUserId)!) } }) export const userRepo = { get(userId: string): PersistedUser | null { const row = selectUser.get(userId) return row ? toUser(row) : null }, touch(userId: string): PersistedUser | null { const existing = this.get(userId) if (!existing) return null const now = Date.now() touchUser.run(now, now, userId) if (config.serverAdminIds.has(userId) && existing.role !== 'admin') { setRole.run('admin', now, userId) } return this.get(userId) }, ensure(userId: string, defaults?: { nickname?: string }): PersistedUser { const existing = this.touch(userId) const now = Date.now() if (existing) return existing insertUser.run({ id: userId, nickname: defaults?.nickname?.trim() ?? '', role: config.serverAdminIds.has(userId) ? 'admin' : 'user', now, }) return this.get(userId)! }, updateProfile(userId: string, data: { nickname?: string; avatarUrl?: string }): PersistedUser { this.ensure(userId) updateProfile.run(data.nickname?.trim() || null, data.avatarUrl ?? null, Date.now(), userId) return this.get(userId)! }, setPasswordHash(userId: string, passwordHash: string): PersistedUser { this.ensure(userId) setPasswordHash.run(passwordHash, Date.now(), userId) return this.get(userId)! }, list(): PersistedUser[] { return listUsers.all().map(toUser) }, delete(userId: string): boolean { return deleteUser.run(userId).changes > 0 }, rename(oldUserId: string, newUserId: string): RenameUserResult { return renameUser(oldUserId, newUserId) }, isServerAdmin(userId: string): boolean { return config.serverAdminIds.has(userId) || this.get(userId)?.role === 'admin' }, }