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); `); const insert = database.prepare(` INSERT INTO dungeon_reports ( id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at ) 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 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), }; } return { 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 (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); 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); }, }; }