Files
Homelab-manager/server/src/routes/generator.ts
T
bobbanandClaude Sonnet 5.5 3035d7fc08 Make the Generator's server-name lists editable, add themes, import names from Skatteverket
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>
2026-10-03 01:53:31 +02:00

129 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) });
}
}));