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:
50
src/lib/validation/brigade.test.ts
Normal file
50
src/lib/validation/brigade.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
src/lib/validation/brigade.ts
Normal file
40
src/lib/validation/brigade.ts
Normal 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>;
|
||||
36
src/lib/validation/equipment-category.test.ts
Normal file
36
src/lib/validation/equipment-category.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
26
src/lib/validation/equipment-category.ts
Normal file
26
src/lib/validation/equipment-category.ts
Normal 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>;
|
||||
67
src/lib/validation/merkmal.test.ts
Normal file
67
src/lib/validation/merkmal.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
57
src/lib/validation/merkmal.ts
Normal file
57
src/lib/validation/merkmal.ts
Normal 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>;
|
||||
65
src/lib/validation/template.test.ts
Normal file
65
src/lib/validation/template.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
src/lib/validation/template.ts
Normal file
46
src/lib/validation/template.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user