import { type SQL, sql } from "drizzle-orm"; import type { ResultSetHeader } from "mysql2"; import type { Db } from "@/lib/db"; const VALID_KEY = /^[a-z][a-z0-9_]{0,63}$/; export const RANK_GENERAL_FIELDS = new Set([ "rank_name", "hidden_rank", "badge", "job_description", "staff_color", "staff_background", "level", "room_effect", "log_commands", "prefix", "prefix_color", "auto_credits_amount", "auto_pixels_amount", "auto_gotw_amount", "auto_points_amount", ]); export interface EmulatorRankSummary { id: number; rank_name: string; badge: string; level: number; prefix: string; prefix_color: string; hidden_rank: string; } export interface EmulatorRankForEdit extends EmulatorRankSummary { job_description: string; staff_color: string; staff_background: string; log_commands: string; room_effect: number; auto_credits_amount: number; auto_pixels_amount: number; auto_gotw_amount: number; auto_points_amount: number; permissions: Record; permissionMaxValues: Record; } /** Surfaces used by rank helpers, including database transaction handles. */ type SqlExecutor = Pick; type RawDb = Pick; async function rawRows(db: SqlExecutor, query: SQL | string): Promise { const [rows] = await db.execute(query); return rows as T; } async function rawExec(db: SqlExecutor, query: SQL | string): Promise { const [result] = await db.execute(query); return (result as ResultSetHeader).affectedRows; } export function getRankPermissionColumn(rankId: number) { if (!Number.isInteger(rankId) || rankId <= 0 || rankId > 255) { throw new Error("Invalid rank id"); } return `rank_${rankId}`; } export function toPermissionValue(value: string | number) { const parsed = typeof value === "number" ? value : Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 0) return 0; return Math.min(Math.trunc(parsed), 255); } export function splitRankUpdateFields(fields: Record) { const rankFields: Record = {}; const permissionFields: Record = {}; for (const [key, value] of Object.entries(fields)) { if (key === "id" || !VALID_KEY.test(key)) continue; if (RANK_GENERAL_FIELDS.has(key)) { rankFields[key] = value; } else { permissionFields[key] = toPermissionValue(value); } } return { rankFields, permissionFields }; } export async function fetchEmulatorRankSummaries( db: RawDb, ): Promise { const rows = await rawRows[]>( db, sql` SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank FROM permission_ranks ORDER BY id ASC `, ); return rows.map((row) => ({ id: Number(row.id), rank_name: String(row.rank_name ?? ""), badge: String(row.badge ?? ""), level: Number(row.level ?? 1), prefix: String(row.prefix ?? ""), prefix_color: String(row.prefix_color ?? ""), hidden_rank: String(row.hidden_rank ?? "0"), })); } export async function fetchEmulatorRankForEdit( db: RawDb, rankId: number, ): Promise { const rankRows = await rawRows[]>( db, sql` SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank, job_description, staff_color, staff_background, log_commands, room_effect, auto_credits_amount, auto_pixels_amount, auto_gotw_amount, auto_points_amount FROM permission_ranks WHERE id = ${rankId} `, ); if (rankRows.length === 0) return null; const rankColumn = getRankPermissionColumn(rankId); const permissions: Record = {}; const permissionMaxValues: Record = {}; const definitionColumns = await getTableColumns(db, "permission_definitions"); if (definitionColumns.has(rankColumn)) { const permissionRows = await rawRows< { permission_key: string; max_value: number; value: number | string | null; }[] >( db, sql` SELECT permission_key, max_value, ${sql.raw(quoteIdentifier(rankColumn))} AS value FROM permission_definitions ORDER BY permission_key ASC `, ); for (const permission of permissionRows) { permissions[permission.permission_key] = String(permission.value ?? 0); permissionMaxValues[permission.permission_key] = Number( permission.max_value ?? 1, ); } } else { const legacyRows = await rawRows[]>( db, sql`SELECT * FROM permissions WHERE id = ${rankId} LIMIT 1`, ); for (const [key, value] of Object.entries(legacyRows[0] ?? {})) { if ( key === "id" || RANK_GENERAL_FIELDS.has(key) || !VALID_KEY.test(key) ) { continue; } const permissionValue = typeof value === "string" || typeof value === "number" ? toPermissionValue(value) : 0; permissions[key] = String(permissionValue); permissionMaxValues[key] = Math.max(1, permissionValue); } } const row = rankRows[0]; return { id: Number(row.id), rank_name: String(row.rank_name ?? ""), badge: String(row.badge ?? ""), level: Number(row.level ?? 1), prefix: String(row.prefix ?? ""), prefix_color: String(row.prefix_color ?? ""), hidden_rank: String(row.hidden_rank ?? "0"), job_description: String(row.job_description ?? ""), staff_color: String(row.staff_color ?? ""), staff_background: String(row.staff_background ?? ""), log_commands: String(row.log_commands ?? "0"), room_effect: Number(row.room_effect ?? 0), auto_credits_amount: Number(row.auto_credits_amount ?? 0), auto_pixels_amount: Number(row.auto_pixels_amount ?? 0), auto_gotw_amount: Number(row.auto_gotw_amount ?? 0), auto_points_amount: Number(row.auto_points_amount ?? 0), permissions, permissionMaxValues, }; } export async function createEmulatorRank( db: RawDb, data: { rank_name: string; level: number }, ): Promise { const id = await prepareEmulatorRankCreation(db); await db.transaction((tx) => createEmulatorRankInTransaction(tx, id, data)); return id; } /** * Prepare the dynamic permission column before opening the coupled DML * transaction. MySQL ALTER TABLE commits implicitly, so this schema-only step * is deliberately kept outside the atomic rank/ACL/audit unit. */ export async function prepareEmulatorRankCreation( db: SqlExecutor, ): Promise { const rows = await rawRows<{ next_id: number }[]>( db, sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`, ); const id = Number(rows[0]?.next_id ?? 1); await ensurePermissionRankColumn(db, id); return id; } export async function createEmulatorRankInTransaction( db: SqlExecutor, id: number, data: { rank_name: string; level: number }, ): Promise { await resetPermissionRankColumn(db, id); await rawExec( db, sql` INSERT INTO permission_ranks (id, rank_name, level) VALUES (${id}, ${data.rank_name}, ${data.level}) `, ); await rawExec( db, sql` INSERT INTO permissions (id, rank_name, level) VALUES (${id}, ${data.rank_name}, ${data.level}) ON DUPLICATE KEY UPDATE rank_name = VALUES(rank_name), level = VALUES(level) `, ); } export async function deleteEmulatorRank(db: RawDb, rankId: number) { await db.transaction((tx) => deleteEmulatorRankInTransaction(tx, rankId)); } export async function deleteEmulatorRankInTransaction( db: SqlExecutor, rankId: number, ): Promise { await resetPermissionRankColumn(db, rankId); await rawExec(db, sql`DELETE FROM permission_ranks WHERE id = ${rankId}`); await rawExec(db, sql`DELETE FROM permissions WHERE id = ${rankId}`); } export async function updateEmulatorRank( db: SqlExecutor, rankId: number, fields: Record, ) { const { rankFields, permissionFields } = splitRankUpdateFields(fields); if (Object.keys(rankFields).length > 0) { await updateTableFields(db, "permission_ranks", rankId, rankFields); await updateLegacyPermissionFields(db, rankId, rankFields); } if (Object.keys(permissionFields).length > 0) { await updatePermissionDefinitionValues(db, rankId, permissionFields); await updateLegacyPermissionFields(db, rankId, permissionFields); } } async function updateTableFields( db: SqlExecutor, table: string, rankId: number, fields: Record, ) { const assignments = Object.entries(fields).map( ([key, value]) => sql`${sql.raw(quoteIdentifier(key))} = ${value}`, ); return rawExec( db, sql` UPDATE ${sql.raw(quoteIdentifier(table))} SET ${sql.join(assignments, sql`, `)} WHERE id = ${rankId} `, ); } async function updatePermissionDefinitionValues( db: SqlExecutor, rankId: number, permissionFields: Record, ) { const rankColumn = getRankPermissionColumn(rankId); const entries = Object.entries(permissionFields); const cases = entries.map( ([key, value]) => sql`WHEN ${key} THEN LEAST(${value}, max_value)`, ); const keys = entries.map(([key]) => key); return rawExec( db, sql` UPDATE permission_definitions SET ${sql.raw(quoteIdentifier(rankColumn))} = CASE permission_key ${sql.join(cases, sql` `)} ELSE ${sql.raw(quoteIdentifier(rankColumn))} END WHERE permission_key IN (${sql.join(keys, sql`, `)}) `, ); } async function updateLegacyPermissionFields( db: SqlExecutor, rankId: number, fields: Record, ) { const columns = await getTableColumns(db, "permissions"); const existingFields = Object.fromEntries( Object.entries(fields).filter(([key]) => columns.has(key)), ); if (Object.keys(existingFields).length === 0) return 0; return updateTableFields(db, "permissions", rankId, existingFields); } async function ensurePermissionRankColumn(db: SqlExecutor, rankId: number) { const rankColumn = getRankPermissionColumn(rankId); const exists = await rawRows<{ c: number }[]>( db, sql` SELECT COUNT(*) AS c FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'permission_definitions' AND column_name = ${rankColumn} `, ); if (Number(exists[0]?.c ?? 0) > 0) return; await rawExec( db, sql` ALTER TABLE permission_definitions ADD COLUMN ${sql.raw(quoteIdentifier(rankColumn))} TINYINT UNSIGNED NOT NULL DEFAULT 0 `, ); } async function resetPermissionRankColumn(db: SqlExecutor, rankId: number) { const rankColumn = getRankPermissionColumn(rankId); await rawExec( db, sql` UPDATE permission_definitions SET ${sql.raw(quoteIdentifier(rankColumn))} = 0 `, ); } async function getTableColumns(db: SqlExecutor, table: string) { const rows = await rawRows<{ column_name: string }[]>( db, sql` SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ${table} `, ); return new Set(rows.map((row) => row.column_name)); } function quoteIdentifier(identifier: string) { if (!VALID_KEY.test(identifier)) throw new Error("Invalid identifier"); return `\`${identifier}\``; }