Workstream 6: Admin-Panel — Taxonomie & Bereitstellung (Phase 4)

Platform-Admin-only Oberflächen und Domänenlogik:

- codes.ts erweitert um allradCode/normalizeCode/codesMatch (Allrad-Infix
  kanonisch; Suche importiert weiterhin expandNameQuery). Pure-Unit-Tests.
- slug.ts (Idempotenz-Key-Erzeugung) + Tests.
- audit.ts: writeAudit mit EINER Signatur und optionalem typisierten tx.
- provisioning.ts: createBrigadeWithFirstAdmin (Geocoding inline, argon2id,
  Audit brigade.create/user.create) + resetUserPassword (Audit user.reset).
- Zod-Validierung: merkmal/template/equipment-category/brigade (+ Tests).
- Server Actions (jede mit Guard als erster Anweisung, default-deny):
  merkmale (CRUD, Delete blockiert bei Referenz), proposals (promote/merge mit
  Typ-Kompatibilität), templates (Merkmale/Vorgabewerte/Aliasse), equipment-
  categories, brigades (Bereitstellung/Reset). Audit in allen Schreib-Actions.
- (admin)-Route-Group: Layout mit requirePlatformAdmin als erster Zeile,
  AdminNav, DataTable, loading/error; Seiten für Merkmale (+Editor), Vorschläge
  (Merge), Vorlagen (+Detail mit Merkmal-/Alias-Editor und Allrad-Hinweis),
  Geräte-Kategorien (+Detail), Wehren (Liste/neu/Detail mit Passwort-Reset),
  paginierter Audit-Viewer mit Filter. Jede Seite ruft zusätzlich den Guard.
- i18n: admin-Strings in zentraler de.ts.
- Playwright-Specs (deferred, nicht ausgeführt): admin-gating,
  admin-merkmal-proposal, admin-brigade-provision.

Schema NICHT neu definiert — nur importiert. codes.ts ist hier Eigentümer.

Offline-Verifikation: tsc --noEmit grün; eslint grün; vitest run grün
(119 passed, 7 DB-roundtrip skipped); next build Exit 0; drizzle-kit check ok.
DB-/Server-/Browser-abhängige Schritte deferred (kein Postgres/Server im
Sandbox).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-06-09 10:30:52 +02:00
parent 0a7173ef38
commit e97e16d254
49 changed files with 3676 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { brigadeProvisionSchema } from "./brigade";
const valid = {
name: "FF Musterdorf",
strasse: "Hauptstraße 1",
plz: "3100",
ort: "St. Pölten",
telefon: "+43 2742 12345",
adminEmail: "admin@ff-musterdorf.at",
adminName: "Max Muster",
};
describe("brigadeProvisionSchema", () => {
it("akzeptiert gültige Bereitstellungsdaten", () => {
expect(brigadeProvisionSchema.safeParse(valid).success).toBe(true);
});
it("verlangt einen Wehr-Namen", () => {
expect(
brigadeProvisionSchema.safeParse({ ...valid, name: "" }).success,
).toBe(false);
});
it("normalisiert die Admin-E-Mail auf Kleinbuchstaben", () => {
const r = brigadeProvisionSchema.parse({
...valid,
adminEmail: "ADMIN@FF.AT",
});
expect(r.adminEmail).toBe("admin@ff.at");
});
it("lehnt ungültige E-Mail ab", () => {
expect(
brigadeProvisionSchema.safeParse({ ...valid, adminEmail: "keine-mail" })
.success,
).toBe(false);
});
it("optionale Felder dürfen fehlen", () => {
const { email, wehrfuehrer, ...rest } = {
...valid,
email: undefined,
wehrfuehrer: undefined,
};
void email;
void wehrfuehrer;
expect(brigadeProvisionSchema.safeParse(rest).success).toBe(true);
});
});

View File

@@ -0,0 +1,40 @@
import { z } from "zod";
/**
* Zod-Schema für die Wehr-Bereitstellung (Admin-Workstream): legt Brigade +
* ersten wehr_admin an. ASCII-Property `wehrfuehrer`. E-Mail wird auf
* Kleinbuchstaben normalisiert.
*/
export const brigadeProvisionSchema = z.object({
name: z.string().trim().min(1, { message: "Wehr-Name ist Pflicht." }),
strasse: z.string().trim().min(1, { message: "Straße ist Pflicht." }),
plz: z.string().trim().min(1, { message: "PLZ ist Pflicht." }),
ort: z.string().trim().min(1, { message: "Ort ist Pflicht." }),
telefon: z.string().trim().min(1, { message: "Telefon ist Pflicht." }),
email: z
.string()
.trim()
.email({ message: "Ungültige E-Mail." })
.optional()
.or(z.literal("").transform(() => undefined)),
wehrfuehrer: z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
adminEmail: z
.string()
.trim()
.email({ message: "Ungültige Admin-E-Mail." })
.transform((v) => v.toLowerCase()),
adminName: z.string().trim().min(1, { message: "Admin-Name ist Pflicht." }),
});
export type BrigadeProvisionInput = z.infer<typeof brigadeProvisionSchema>;
/** Schema für Passwort-Reset eines (lokalen) Wehr-Benutzers. */
export const userResetSchema = z.object({
userId: z.string().uuid({ message: "Ungültige ID." }),
});
export type UserResetInput = z.infer<typeof userResetSchema>;

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import {
equipmentCategoryCreateSchema,
categoryMerkmalSchema,
} from "./equipment-category";
describe("equipmentCategoryCreateSchema", () => {
it("akzeptiert gültige Kategorie", () => {
expect(
equipmentCategoryCreateSchema.safeParse({ name: "Atemschutz" }).success,
).toBe(true);
});
it("lehnt leeren Namen ab", () => {
expect(equipmentCategoryCreateSchema.safeParse({ name: "" }).success).toBe(
false,
);
});
});
describe("categoryMerkmalSchema", () => {
it("akzeptiert merkmalId mit Reihenfolge", () => {
const r = categoryMerkmalSchema.safeParse({
merkmalId: "11111111-1111-1111-1111-111111111111",
reihenfolge: 2,
});
expect(r.success).toBe(true);
});
it("reihenfolge default 0", () => {
const r = categoryMerkmalSchema.parse({
merkmalId: "11111111-1111-1111-1111-111111111111",
});
expect(r.reihenfolge).toBe(0);
});
});

View File

@@ -0,0 +1,26 @@
import { z } from "zod";
import { uuidSchema } from "./common";
/**
* Zod-Schemas für Geräte-Kategorien und ihre zugeordneten Merkmale
* (Admin-Workstream).
*/
export const equipmentCategoryCreateSchema = z.object({
name: z.string().trim().min(1, { message: "Name ist Pflicht." }),
});
export const equipmentCategoryUpdateSchema =
equipmentCategoryCreateSchema.extend({ id: uuidSchema });
export const categoryMerkmalSchema = z.object({
merkmalId: uuidSchema,
reihenfolge: z.coerce.number().int().min(0).optional().default(0),
});
export type EquipmentCategoryCreateInput = z.infer<
typeof equipmentCategoryCreateSchema
>;
export type EquipmentCategoryUpdateInput = z.infer<
typeof equipmentCategoryUpdateSchema
>;
export type CategoryMerkmalInput = z.infer<typeof categoryMerkmalSchema>;

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { merkmalCreateSchema, merkmalUpdateSchema } from "./merkmal";
describe("merkmalCreateSchema", () => {
const base = {
name: "Löschwassertank",
typ: "number" as const,
geltungsbereich: "vehicle" as const,
einheit: "l",
};
it("akzeptiert ein number-Merkmal ohne Optionen", () => {
const r = merkmalCreateSchema.safeParse(base);
expect(r.success).toBe(true);
});
it("verlangt mindestens eine Option bei typ=enum", () => {
const r = merkmalCreateSchema.safeParse({
name: "Pumpentyp",
typ: "enum",
geltungsbereich: "vehicle",
optionen: [],
});
expect(r.success).toBe(false);
});
it("akzeptiert enum mit Optionen", () => {
const r = merkmalCreateSchema.safeParse({
name: "Pumpentyp",
typ: "enum",
geltungsbereich: "vehicle",
optionen: [{ wert: "fpn_10_1000", label: "FPN 10-1000" }],
});
expect(r.success).toBe(true);
});
it("lehnt leeren Namen ab", () => {
const r = merkmalCreateSchema.safeParse({ ...base, name: "" });
expect(r.success).toBe(false);
});
it("lehnt ungültigen Typ ab", () => {
const r = merkmalCreateSchema.safeParse({ ...base, typ: "datum" });
expect(r.success).toBe(false);
});
});
describe("merkmalUpdateSchema", () => {
it("verlangt eine id", () => {
const r = merkmalUpdateSchema.safeParse({
name: "X",
typ: "text",
geltungsbereich: "both",
});
expect(r.success).toBe(false);
});
it("akzeptiert vollständige Update-Daten", () => {
const r = merkmalUpdateSchema.safeParse({
id: "11111111-1111-1111-1111-111111111111",
name: "X",
typ: "text",
geltungsbereich: "both",
});
expect(r.success).toBe(true);
});
});

View File

@@ -0,0 +1,57 @@
import { z } from "zod";
import { merkmalTypEnum, geltungsbereichEnum } from "@/db/schema";
import { uuidSchema } from "./common";
/**
* Zod-Schemas für den Merkmal-Katalog (Admin-Workstream). Werte für `typ` und
* `geltungsbereich` werden aus den DB-Enums abgeleitet (Single Source of Truth).
*/
export const merkmalTypSchema = z.enum(merkmalTypEnum.enumValues);
export const geltungsbereichSchema = z.enum(geltungsbereichEnum.enumValues);
export const merkmalOptionSchema = z.object({
wert: z.string().trim().min(1, { message: "Wert ist Pflicht." }),
label: z.string().trim().min(1, { message: "Bezeichnung ist Pflicht." }),
});
const baseFields = {
name: z.string().trim().min(1, { message: "Name ist Pflicht." }),
typ: merkmalTypSchema,
geltungsbereich: geltungsbereichSchema,
einheit: z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
optionen: z.array(merkmalOptionSchema).optional().default([]),
};
/**
* Verfeinerung: Bei `typ === "enum"` ist mindestens eine Option Pflicht.
* Liefert klare deutsche Fehlermeldung.
*/
const enumRefinement = (
data: { typ: string; optionen: { wert: string; label: string }[] },
ctx: z.RefinementCtx,
): void => {
if (data.typ === "enum" && data.optionen.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["optionen"],
message: "Mindestens eine Option ist bei Typ „Auswahl“ erforderlich.",
});
}
};
export const merkmalCreateSchema = z
.object(baseFields)
.superRefine(enumRefinement);
export const merkmalUpdateSchema = z
.object({ id: uuidSchema, ...baseFields })
.superRefine(enumRefinement);
export type MerkmalCreateInput = z.infer<typeof merkmalCreateSchema>;
export type MerkmalUpdateInput = z.infer<typeof merkmalUpdateSchema>;
export type MerkmalOptionInput = z.infer<typeof merkmalOptionSchema>;

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import {
templateCreateSchema,
templateMerkmalSchema,
aliasSchema,
} from "./template";
describe("templateCreateSchema", () => {
it("akzeptiert gültige Vorlage", () => {
const r = templateCreateSchema.safeParse({
code: "HLF 3",
name: "Hilfeleistungsfahrzeug 3",
});
expect(r.success).toBe(true);
});
it("verlangt Code und Name", () => {
expect(templateCreateSchema.safeParse({ code: "", name: "" }).success).toBe(
false,
);
});
});
describe("templateMerkmalSchema — typisierter Vorgabewert", () => {
it("akzeptiert num-Vorgabewert", () => {
const r = templateMerkmalSchema.safeParse({
merkmalId: "11111111-1111-1111-1111-111111111111",
vorgabewertNum: 2000,
pflicht: true,
});
expect(r.success).toBe(true);
});
it("akzeptiert leere Vorgabewerte", () => {
const r = templateMerkmalSchema.safeParse({
merkmalId: "11111111-1111-1111-1111-111111111111",
});
expect(r.success).toBe(true);
});
it("lehnt fehlende merkmalId ab", () => {
expect(templateMerkmalSchema.safeParse({ vorgabewertNum: 1 }).success).toBe(
false,
);
});
});
describe("aliasSchema", () => {
it("akzeptiert bestätigten Alias", () => {
const r = aliasSchema.safeParse({
templateId: "11111111-1111-1111-1111-111111111111",
alias: "RLFA 2000",
bestaetigt: true,
});
expect(r.success).toBe(true);
});
it("bestaetigt default false", () => {
const r = aliasSchema.parse({
templateId: "11111111-1111-1111-1111-111111111111",
alias: "LF",
});
expect(r.bestaetigt).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
import { uuidSchema } from "./common";
/**
* Zod-Schemas für Fahrzeug-Vorlagen, ihre (typisierten) Vorgabe-Merkmale und
* Aliasse (Admin-Workstream). Vorgabewerte als drei getrennte typisierte Felder
* `vorgabewertNum/_Text/_Bool` (DB-WS-Entscheidung).
*/
export const templateCreateSchema = z.object({
code: z.string().trim().min(1, { message: "Code ist Pflicht." }),
name: z.string().trim().min(1, { message: "Name ist Pflicht." }),
beschreibung: z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
});
export const templateUpdateSchema = templateCreateSchema.extend({
id: uuidSchema,
});
export const templateMerkmalSchema = z.object({
merkmalId: uuidSchema,
vorgabewertNum: z.number().nullable().optional(),
vorgabewertText: z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
vorgabewertBool: z.boolean().nullable().optional(),
pflicht: z.boolean().optional().default(false),
reihenfolge: z.coerce.number().int().min(0).optional().default(0),
});
export const aliasSchema = z.object({
templateId: uuidSchema,
alias: z.string().trim().min(1, { message: "Alias ist Pflicht." }),
bestaetigt: z.boolean().optional().default(false),
});
export type TemplateCreateInput = z.infer<typeof templateCreateSchema>;
export type TemplateUpdateInput = z.infer<typeof templateUpdateSchema>;
export type TemplateMerkmalInput = z.infer<typeof templateMerkmalSchema>;
export type AliasInput = z.infer<typeof aliasSchema>;