import { Router } from "express"; import { z } from "zod"; import { requireAuth, requireRole } from "../auth/middleware.js"; import { recordAudit } from "../services/audit.js"; import { getSettings, updateSettings } from "../services/settingsStore.js"; import { DEFAULT_THEME_IDS, InvalidThemesError, MAX_NAME_LENGTH, cleanThemes, defaultThemes, importSkatteverketNames, readStoredThemes, type NameTheme, type NameThemeSource, } from "../services/nameThemes.js"; import { asyncHandler } from "../utils/asyncHandler.js"; export const generatorRouter = Router(); generatorRouter.use(requireAuth); async function currentThemes(): Promise { return readStoredThemes((await getSettings()).nameGenerator.themes); } // Read by everyone signed in — the Generator page needs the lists to pick from. Editing them is a Settings matter. generatorRouter.get("/themes", asyncHandler(async (_req, res) => { res.json({ themes: await currentThemes(), builtinIds: DEFAULT_THEME_IDS }); })); generatorRouter.get("/themes/defaults", requireRole("admin"), (_req, res) => { res.json({ themes: defaultThemes() }); }); const sourceSchema = z.object({ kind: z.literal("skatteverket"), sex: z.enum(["girls", "boys"]), years: z.array(z.number().int()).max(10), count: z.number().int(), importedAt: z.string().max(40), }); const saveSchema = z.object({ themes: z .array( z.object({ id: z.string().max(40).optional(), label: z.string().max(200), names: z.array(z.string().max(MAX_NAME_LENGTH * 3)).max(10000), lastImport: sourceSchema.optional(), }), ) .max(100), }); /** A short, readable account of what an edit changed, for the audit log. */ function describeThemeChanges(before: NameTheme[], after: NameTheme[]) { const beforeById = new Map(before.map((t) => [t.id, t])); const afterIds = new Set(after.map((t) => t.id)); const created = after.filter((t) => !beforeById.has(t.id)).map((t) => `${t.label} (${t.names.length} names)`); const deleted = before.filter((t) => !afterIds.has(t.id)).map((t) => t.label); const edited: { list: string; renamedFrom?: string; added: number; removed: number }[] = []; for (const t of after) { const old = beforeById.get(t.id); if (!old) continue; const had = new Set(old.names); const has = new Set(t.names); const added = t.names.filter((n) => !had.has(n)).length; const removed = old.names.filter((n) => !has.has(n)).length; if (added || removed || old.label !== t.label) edited.push({ list: t.label, ...(old.label !== t.label ? { renamedFrom: old.label } : {}), added, removed }); } return { created, deleted, edited }; } generatorRouter.put("/themes", requireRole("admin"), asyncHandler(async (req, res) => { const parsed = saveSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "That doesn't look like a set of name lists.", details: parsed.error.flatten() }); let themes: NameTheme[]; try { themes = cleanThemes(parsed.data.themes); } catch (err) { if (err instanceof InvalidThemesError) return res.status(400).json({ error: "invalid_names", message: err.message, invalid: err.invalid }); throw err; } const before = await currentThemes(); const changes = describeThemeChanges(before, themes); await updateSettings({ nameGenerator: { themes } }); if (changes.created.length || changes.deleted.length || changes.edited.length) { await recordAudit({ actor: req.currentUser!, category: "settings", action: "update_name_lists", targetType: "name_generator", detail: changes, }); } res.json({ themes }); })); const importSchema = z.object({ sex: z.enum(["girls", "boys"]), years: z.number().int().min(1).max(5), count: z.number().int().min(10).max(500), }); // Only fetches and returns a preview — nothing is stored until the editor's own Save, so an import can be looked at (and // thrown away) first. The address is fixed; none of the request's values go into it unchecked. generatorRouter.post("/themes/import", requireRole("admin"), asyncHandler(async (req, res) => { const parsed = importSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Pick girls or boys, 1–5 years and 10–500 names.", details: parsed.error.flatten() }); try { const result = await importSkatteverketNames(parsed.data.sex, parsed.data.years, parsed.data.count); const source: NameThemeSource = { kind: "skatteverket", sex: parsed.data.sex, years: result.years, count: parsed.data.count, importedAt: new Date().toISOString(), }; res.json({ names: result.names, source, missingYears: result.missingYears, skipped: result.skipped }); } catch (err) { res.status(502).json({ error: "import_failed", message: err instanceof Error ? err.message : String(err) }); } }));