import { randomUUID } from "node:crypto"; import availability from "../src/game/generated/dungeonAvailability.json" with { type: "json" }; import policy from "../shared/dungeon-report-policy.json" with { type: "json" }; export const REPORT_DUNGEON_IDS = new Set(["forsaken-abbey", ...Object.keys(availability.enabled)]); export function createDungeonReportRepository(database, { isGameMaster, ErrorType }) { database.exec(` CREATE TABLE IF NOT EXISTS dungeon_reports ( id TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL, comment TEXT NOT NULL CHECK (length(comment) BETWEEN 1 AND 500), status TEXT NOT NULL CHECK (status IN ('Submitted', 'In-Progress', 'Testing Fixes', 'Closed')), submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL, submitted_by_username TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, updated_by_username TEXT, revision INTEGER NOT NULL DEFAULT 0 ) STRICT; CREATE INDEX IF NOT EXISTS dungeon_reports_dungeon_status_idx ON dungeon_reports(dungeon_id, status); CREATE TABLE IF NOT EXISTS boss_testing_reviews ( boss_key TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL, boss_name TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('ready', 'needs-testing', 'not-ready')), reviewed_by TEXT NOT NULL, reviewed_at INTEGER NOT NULL ) STRICT; `); const columns = new Set(database.prepare("PRAGMA table_info(dungeon_reports)").all().map((column) => column.name)); if (!columns.has("boss_key")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_key TEXT"); if (!columns.has("boss_name")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_name TEXT"); const insert = database.prepare(` INSERT INTO dungeon_reports ( id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at, boss_key, boss_name ) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?, ?, ?) `); const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?"); const list = database.prepare(` SELECT * FROM dungeon_reports WHERE (? IS NULL OR status = ?) AND (? IS NULL OR dungeon_id = ?) ORDER BY created_at DESC, id ASC `); const counts = database.prepare(` SELECT dungeon_id, COUNT(*) AS count FROM dungeon_reports WHERE status != 'Closed' GROUP BY dungeon_id `); const update = database.prepare(` UPDATE dungeon_reports SET status = ?, updated_at = ?, updated_by_username = ?, revision = revision + 1 WHERE id = ? AND revision = ? RETURNING * `); function validateDungeon(id) { if (!REPORT_DUNGEON_IDS.has(id)) throw new ErrorType("Choose a valid dungeon.", 400, "invalid_dungeon"); } function validateStatus(status) { if (!policy.statuses.includes(status)) throw new ErrorType("Choose a valid report status.", 400, "invalid_report_status"); } function validateBoss(input) { validateDungeon(input?.dungeonId); if (typeof input.bossKey !== "string" || !input.bossKey.startsWith(`${input.dungeonId}/`) || input.bossKey.length <= input.dungeonId.length + 1 || input.bossKey.length > 256 || typeof input.bossName !== "string" || !input.bossName.trim() || input.bossName.length > 128) { throw new ErrorType("Choose a valid boss.", 400, "invalid_boss"); } } function publicReport(row) { return { id: row.id, dungeonId: row.dungeon_id, comment: row.comment, status: row.status, submittedBy: row.submitted_by_username, createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), updatedBy: row.updated_by_username, revision: Number(row.revision), ...(row.boss_key ? { bossKey: row.boss_key, bossName: row.boss_name } : {}), }; } return { listBossTestingReviews() { return { reviews: database.prepare("SELECT * FROM boss_testing_reviews ORDER BY boss_key").all().map((row) => ({ bossKey: row.boss_key, dungeonId: row.dungeon_id, bossName: row.boss_name, status: row.status, reviewedBy: row.reviewed_by, reviewedAt: Number(row.reviewed_at), })) }; }, saveBossTestingReview(account, input) { validateBoss(input); if (!["ready", "needs-testing", "not-ready"].includes(input.status)) { throw new ErrorType("Choose a valid boss readiness status.", 400, "invalid_boss_status"); } const review = { bossKey: input.bossKey, dungeonId: input.dungeonId, bossName: input.bossName.trim(), status: input.status, reviewedBy: account?.username ?? "Guest player", reviewedAt: Date.now() }; database.prepare(`INSERT INTO boss_testing_reviews VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(boss_key) DO UPDATE SET boss_name = excluded.boss_name, status = excluded.status, reviewed_by = excluded.reviewed_by, reviewed_at = excluded.reviewed_at`).run( review.bossKey, review.dungeonId, review.bossName, review.status, review.reviewedBy, review.reviewedAt); return review; }, listDungeonReports({ status = null, dungeonId = null } = {}) { if (status !== null) validateStatus(status); if (dungeonId !== null) validateDungeon(dungeonId); return { reports: list.all(status, status, dungeonId, dungeonId).map(publicReport), openCounts: Object.fromEntries(counts.all().map((row) => [row.dungeon_id, Number(row.count)])), }; }, submitDungeonReport(account, input) { validateDungeon(input?.dungeonId); if (input?.bossKey !== undefined || input?.bossName !== undefined) validateBoss(input); if (typeof input?.comment !== "string" || !input.comment.trim() || input.comment.length > policy.commentMaxLength) { throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment"); } const id = randomUUID(); const now = Date.now(); insert.run(id, input.dungeonId, input.comment.replace(/\r\n?/g, "\n").trim(), account?.id ?? null, account?.username ?? "Guest player", now, now, input.bossKey ?? null, input.bossName?.trim() ?? null); return publicReport(byId.get(id)); }, updateDungeonReport(account, id, input) { if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required"); validateStatus(input?.status); if (!Number.isInteger(input?.expectedRevision) || input.expectedRevision < 0) { throw new ErrorType("A valid expected revision is required.", 400, "invalid_revision"); } if (!byId.get(id)) throw new ErrorType("Report not found.", 404, "report_not_found"); const row = update.get(input.status, Date.now(), account.username, id, input.expectedRevision); if (!row) throw new ErrorType("This report has changed. Refresh reports and try again.", 409, "report_conflict"); return publicReport(row); }, }; }