Name lists now live in settings instead of the web bundle. Admins edit them under Settings -> Names (add/remove names, create/rename/delete lists, reset a built-in list). Names are folded to hostname-safe form (a-z, 0-9, hyphen) and validated on the server; saves are audited. Adds Swedish boy names, Pixar, Norse mythology and Astrid Lindgren lists, and lengthens the Swedish girl and Disney lists. "Mixed" is now every list with each name counted once. Admins can preview and import the most common Swedish names from Skatteverket's open "Namn pa nyfodda" data (girls or boys, latest 1-5 full years); nothing is stored until the editor is saved. The old comment that credited SCB statistics is gone: SCB stopped publishing name statistics after 2023. Privacy page, README and ROLES updated for the new outbound call and page. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
129 lines
4.9 KiB
TypeScript
129 lines
4.9 KiB
TypeScript
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<NameTheme[]> {
|
||
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) });
|
||
}
|
||
}));
|