From 5cda09c411fe6ac9dafc4fee5e8910b223e5baca Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 9 Jun 2026 11:06:17 +0200 Subject: [PATCH] =?UTF-8?q?Workstream=207:=20Wehr-Bereich=20=E2=80=94=20Fu?= =?UTF-8?q?hrpark=20&=20Benutzer=20(Phase=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementiert den auf die eigene brigadeId beschränkten Wehr-Bereich: Profil (inkl. Inline-Geocoding via geocodeAddress), Fuhrpark (Fahrzeug per Vorlage oder frei, typisierter Merkmal-Editor), Geräte (Kategorie, Werte, Zuordnung Fahrzeug/„im Gerätehaus") und Benutzerkonten (wehr_admin/wehr_read). - Schema importiert (nicht neu definiert); ASCII-Property wehrfuehrer. - Default-deny dreifach: Layout-Guard requireWehrAdmin() + jede Server Action beginnt mit requireWehrAdmin(); fremde Entities -> notFound() (404). - Validierung an der Grenze (Zod): buildMerkmalValuesSchema validiert Werte typgerecht gegen die serverseitig aufgelösten Definitionen; Rolle auf wehr_admin|wehr_read beschränkt (platform_admin abgelehnt). - upsertMerkmalValues delete-then-insert mit typisierter Drizzle-Tx (kein any); boolean false/num 0 gelten als gesetzt. - argon2id-Einmalpasswort beim Benutzeranlegen; Selbst-Deaktivierung verhindert. - Audit vollständig: brigade.profile_update, vehicle.create/update/delete/status, equipment.create/update/delete/status, user.create/deactivate. - Vorgabewerte aus drei typisierten Spalten (vorgabewert_num/_text/_bool). - i18n via zentraler de.ts; loading/empty/error-konforme Listen. Tests: 22 neue Unit-Tests (vehicle/equipment/brigade-user-Validierung, upsertMerkmalValues) grün; Playwright-Specs verwaltung-fuhrpark + -scoping geschrieben (deferred: kein Server/DB in der Sandbox). Verifikation offline: tsc --noEmit clean, eslint clean, vitest 147 passed, next build exit 0 (alle /verwaltung/*-Routen), drizzle-kit check ohne Drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/(app)/verwaltung/benutzer/page.tsx | 77 +++++++ .../fahrzeuge/[id]/VehicleControls.tsx | 93 +++++++++ .../(app)/verwaltung/fahrzeuge/[id]/page.tsx | 50 +++++ .../(app)/verwaltung/fahrzeuge/neu/page.tsx | 23 ++ src/app/(app)/verwaltung/fahrzeuge/page.tsx | 55 +++++ .../geraete/[id]/EquipmentControls.tsx | 89 ++++++++ .../(app)/verwaltung/geraete/[id]/page.tsx | 60 ++++++ src/app/(app)/verwaltung/geraete/neu/page.tsx | 28 +++ src/app/(app)/verwaltung/geraete/page.tsx | 60 ++++++ src/app/(app)/verwaltung/layout.tsx | 25 +++ src/app/(app)/verwaltung/profil/page.tsx | 33 +++ .../verwaltung/BrigadeProfileForm.tsx | 124 +++++++++++ src/components/verwaltung/BrigadeUserForm.tsx | 138 ++++++++++++ src/components/verwaltung/EquipmentForm.tsx | 184 ++++++++++++++++ .../verwaltung/MerkmalValueEditor.tsx | 160 ++++++++++++++ src/components/verwaltung/TemplatePicker.tsx | 44 ++++ src/components/verwaltung/VehicleForm.tsx | 169 +++++++++++++++ src/components/verwaltung/VerwaltungNav.tsx | 52 +++++ src/lib/i18n/de.ts | 57 +++++ src/lib/merkmale/types.ts | 49 +++++ .../validation/__tests__/brigade-user.test.ts | 37 ++++ .../validation/__tests__/equipment.test.ts | 34 +++ src/lib/validation/__tests__/vehicle.test.ts | 111 ++++++++++ src/lib/validation/brigade-user.ts | 35 ++++ src/lib/validation/brigade.ts | 34 +++ src/lib/validation/equipment.ts | 45 ++++ src/lib/validation/vehicle.ts | 162 ++++++++++++++ src/server/actions/brigade-users.ts | 131 ++++++++++++ src/server/actions/brigade.ts | 66 ++++++ src/server/actions/equipment.ts | 197 ++++++++++++++++++ src/server/actions/vehicles.ts | 191 +++++++++++++++++ src/server/data/brigade-users.ts | 34 +++ src/server/data/equipment.ts | 69 ++++++ src/server/data/merkmale.ts | 149 +++++++++++++ src/server/data/vehicles.ts | 88 ++++++++ .../merkmale/__tests__/upsertValues.test.ts | 68 ++++++ src/server/merkmale/upsertValues.ts | 50 +++++ tests/e2e/verwaltung-fuhrpark.spec.ts | 63 ++++++ tests/e2e/verwaltung-scoping.spec.ts | 67 ++++++ 39 files changed, 3201 insertions(+) create mode 100644 src/app/(app)/verwaltung/benutzer/page.tsx create mode 100644 src/app/(app)/verwaltung/fahrzeuge/[id]/VehicleControls.tsx create mode 100644 src/app/(app)/verwaltung/fahrzeuge/[id]/page.tsx create mode 100644 src/app/(app)/verwaltung/fahrzeuge/neu/page.tsx create mode 100644 src/app/(app)/verwaltung/fahrzeuge/page.tsx create mode 100644 src/app/(app)/verwaltung/geraete/[id]/EquipmentControls.tsx create mode 100644 src/app/(app)/verwaltung/geraete/[id]/page.tsx create mode 100644 src/app/(app)/verwaltung/geraete/neu/page.tsx create mode 100644 src/app/(app)/verwaltung/geraete/page.tsx create mode 100644 src/app/(app)/verwaltung/layout.tsx create mode 100644 src/app/(app)/verwaltung/profil/page.tsx create mode 100644 src/components/verwaltung/BrigadeProfileForm.tsx create mode 100644 src/components/verwaltung/BrigadeUserForm.tsx create mode 100644 src/components/verwaltung/EquipmentForm.tsx create mode 100644 src/components/verwaltung/MerkmalValueEditor.tsx create mode 100644 src/components/verwaltung/TemplatePicker.tsx create mode 100644 src/components/verwaltung/VehicleForm.tsx create mode 100644 src/components/verwaltung/VerwaltungNav.tsx create mode 100644 src/lib/merkmale/types.ts create mode 100644 src/lib/validation/__tests__/brigade-user.test.ts create mode 100644 src/lib/validation/__tests__/equipment.test.ts create mode 100644 src/lib/validation/__tests__/vehicle.test.ts create mode 100644 src/lib/validation/brigade-user.ts create mode 100644 src/lib/validation/equipment.ts create mode 100644 src/lib/validation/vehicle.ts create mode 100644 src/server/actions/brigade-users.ts create mode 100644 src/server/actions/brigade.ts create mode 100644 src/server/actions/equipment.ts create mode 100644 src/server/actions/vehicles.ts create mode 100644 src/server/data/brigade-users.ts create mode 100644 src/server/data/equipment.ts create mode 100644 src/server/data/merkmale.ts create mode 100644 src/server/data/vehicles.ts create mode 100644 src/server/merkmale/__tests__/upsertValues.test.ts create mode 100644 src/server/merkmale/upsertValues.ts create mode 100644 tests/e2e/verwaltung-fuhrpark.spec.ts create mode 100644 tests/e2e/verwaltung-scoping.spec.ts diff --git a/src/app/(app)/verwaltung/benutzer/page.tsx b/src/app/(app)/verwaltung/benutzer/page.tsx new file mode 100644 index 0000000..54ade62 --- /dev/null +++ b/src/app/(app)/verwaltung/benutzer/page.tsx @@ -0,0 +1,77 @@ +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { listUsersForBrigade } from "@/server/data/brigade-users"; +import { de } from "@/lib/i18n/de"; +import { Badge } from "@/components/ui/badge"; +import { + BrigadeUserForm, + DeactivateUserButton, +} from "@/components/verwaltung/BrigadeUserForm"; + +const ROLLE_LABEL: Record = { + wehr_admin: de.verwaltung.rolleAdmin, + wehr_read: de.verwaltung.rolleRead, + platform_admin: "Plattform-Admin", +}; + +export default async function BenutzerPage() { + const s = await requireWehrAdmin(); + const users = await listUsersForBrigade(s.user.brigadeId); + + return ( +
+

+ {de.verwaltung.navBenutzer} +

+ + + + {users.length === 0 ? ( +

+ {de.verwaltung.keineBenutzer} +

+ ) : ( +
    + {users.map((u) => ( +
  • +
    +

    + {u.name} + {u.id === s.user.id ? ( + (Sie) + ) : null} +

    +

    {u.email}

    +
    +
    + {ROLLE_LABEL[u.rolle] ?? u.rolle} + + {u.authTyp === "local" + ? de.verwaltung.authLokal + : de.verwaltung.authAuthentik} + + + {u.aktiv ? de.verwaltung.aktiv : de.verwaltung.inaktiv} + + {u.aktiv ? ( + + ) : null} +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/(app)/verwaltung/fahrzeuge/[id]/VehicleControls.tsx b/src/app/(app)/verwaltung/fahrzeuge/[id]/VehicleControls.tsx new file mode 100644 index 0000000..b0fb1f0 --- /dev/null +++ b/src/app/(app)/verwaltung/fahrzeuge/[id]/VehicleControls.tsx @@ -0,0 +1,93 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { de } from "@/lib/i18n/de"; +import { assetStatusEnum } from "@/db/schema"; +import { + setVehicleStatus, + deleteVehicle, +} from "@/server/actions/vehicles"; + +type Status = (typeof assetStatusEnum.enumValues)[number]; + +const STATUS_LABEL: Record = { + einsatzbereit: de.status.einsatzbereit, + wartung: de.status.wartung, + ausser_dienst: de.status.ausser_dienst, +}; + +/** + * Status-Umschaltung + Löschen eines Fahrzeugs (eigene Wehr). Beide rufen + * geschützte Server-Actions; Scope-Prüfung erfolgt serverseitig. + */ +export function VehicleControls({ + vehicleId, + status, +}: { + vehicleId: string; + status: Status; +}) { + const router = useRouter(); + const [pending, startTransition] = React.useTransition(); + const [error, setError] = React.useState(null); + + function onStatus(next: Status) { + setError(null); + startTransition(async () => { + const res = await setVehicleStatus({ id: vehicleId, status: next }); + if (!res.ok) { + setError(res.error); + return; + } + router.refresh(); + }); + } + + function onDelete() { + if (!window.confirm(de.verwaltung.loeschenBestaetigen)) return; + setError(null); + startTransition(async () => { + const res = await deleteVehicle({ id: vehicleId }); + if (!res.ok) { + setError(res.error); + return; + } + router.push("/verwaltung/fahrzeuge"); + router.refresh(); + }); + } + + return ( +
+ + + + + {error ? {error} : null} +
+ ); +} diff --git a/src/app/(app)/verwaltung/fahrzeuge/[id]/page.tsx b/src/app/(app)/verwaltung/fahrzeuge/[id]/page.tsx new file mode 100644 index 0000000..61a99f1 --- /dev/null +++ b/src/app/(app)/verwaltung/fahrzeuge/[id]/page.tsx @@ -0,0 +1,50 @@ +import { notFound } from "next/navigation"; +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { getVehicleForBrigade } from "@/server/data/vehicles"; +import { + getMerkmaleForTemplate, + getMerkmalValuesForEntity, +} from "@/server/data/merkmale"; +import { de } from "@/lib/i18n/de"; +import { VehicleForm } from "@/components/verwaltung/VehicleForm"; +import { VehicleControls } from "./VehicleControls"; + +export default async function FahrzeugBearbeitenPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const s = await requireWehrAdmin(); + const { id } = await params; + + // Scoping: fremde/nicht existente Fahrzeuge -> 404 (kein Daten-Leak). + const vehicle = await getVehicleForBrigade(id, s.user.brigadeId); + if (!vehicle) notFound(); + + const defs = vehicle.templateId + ? await getMerkmaleForTemplate(vehicle.templateId) + : []; + const werte = await getMerkmalValuesForEntity("vehicle", vehicle.id); + + return ( +
+

+ {de.verwaltung.fahrzeugBearbeiten} +

+ + + + +
+ ); +} diff --git a/src/app/(app)/verwaltung/fahrzeuge/neu/page.tsx b/src/app/(app)/verwaltung/fahrzeuge/neu/page.tsx new file mode 100644 index 0000000..bf8271f --- /dev/null +++ b/src/app/(app)/verwaltung/fahrzeuge/neu/page.tsx @@ -0,0 +1,23 @@ +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { listTemplates } from "@/server/data/vehicles"; +import { getTemplateMerkmaleAction } from "@/server/actions/vehicles"; +import { de } from "@/lib/i18n/de"; +import { VehicleForm } from "@/components/verwaltung/VehicleForm"; + +export default async function FahrzeugNeuPage() { + await requireWehrAdmin(); + const templates = await listTemplates(); + + return ( +
+

+ {de.verwaltung.fahrzeugAnlegen} +

+ +
+ ); +} diff --git a/src/app/(app)/verwaltung/fahrzeuge/page.tsx b/src/app/(app)/verwaltung/fahrzeuge/page.tsx new file mode 100644 index 0000000..b7e94f9 --- /dev/null +++ b/src/app/(app)/verwaltung/fahrzeuge/page.tsx @@ -0,0 +1,55 @@ +import Link from "next/link"; +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { listVehiclesForBrigade } from "@/server/data/vehicles"; +import { de } from "@/lib/i18n/de"; +import { Button } from "@/components/ui/button"; +import { StatusBadge } from "@/components/ui/badge"; + +export default async function FahrzeugeListePage() { + const s = await requireWehrAdmin(); + const items = await listVehiclesForBrigade(s.user.brigadeId); + + return ( +
+
+

+ {de.verwaltung.navFahrzeuge} +

+ +
+ + {items.length === 0 ? ( +

+ {de.verwaltung.keineFahrzeuge} +

+ ) : ( +
    + {items.map((v) => ( +
  • +
    + + {v.name} + +

    + {v.funkrufname ?? de.search.keinFunkrufname} + {v.templateName ? ` · ${v.templateName}` : ""} +

    +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/(app)/verwaltung/geraete/[id]/EquipmentControls.tsx b/src/app/(app)/verwaltung/geraete/[id]/EquipmentControls.tsx new file mode 100644 index 0000000..299566d --- /dev/null +++ b/src/app/(app)/verwaltung/geraete/[id]/EquipmentControls.tsx @@ -0,0 +1,89 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { de } from "@/lib/i18n/de"; +import { assetStatusEnum } from "@/db/schema"; +import { + setEquipmentStatus, + deleteEquipment, +} from "@/server/actions/equipment"; + +type Status = (typeof assetStatusEnum.enumValues)[number]; + +const STATUS_LABEL: Record = { + einsatzbereit: de.status.einsatzbereit, + wartung: de.status.wartung, + ausser_dienst: de.status.ausser_dienst, +}; + +export function EquipmentControls({ + equipmentId, + status, +}: { + equipmentId: string; + status: Status; +}) { + const router = useRouter(); + const [pending, startTransition] = React.useTransition(); + const [error, setError] = React.useState(null); + + function onStatus(next: Status) { + setError(null); + startTransition(async () => { + const res = await setEquipmentStatus({ id: equipmentId, status: next }); + if (!res.ok) { + setError(res.error); + return; + } + router.refresh(); + }); + } + + function onDelete() { + if (!window.confirm(de.verwaltung.loeschenBestaetigen)) return; + setError(null); + startTransition(async () => { + const res = await deleteEquipment({ id: equipmentId }); + if (!res.ok) { + setError(res.error); + return; + } + router.push("/verwaltung/geraete"); + router.refresh(); + }); + } + + return ( +
+ + + + + {error ? {error} : null} +
+ ); +} diff --git a/src/app/(app)/verwaltung/geraete/[id]/page.tsx b/src/app/(app)/verwaltung/geraete/[id]/page.tsx new file mode 100644 index 0000000..1b8633c --- /dev/null +++ b/src/app/(app)/verwaltung/geraete/[id]/page.tsx @@ -0,0 +1,60 @@ +import { notFound } from "next/navigation"; +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { + getEquipmentForBrigade, + listCategories, +} from "@/server/data/equipment"; +import { listVehiclesForBrigade } from "@/server/data/vehicles"; +import { + getMerkmaleForCategory, + getMerkmalValuesForEntity, +} from "@/server/data/merkmale"; +import { getCategoryMerkmaleAction } from "@/server/actions/equipment"; +import { de } from "@/lib/i18n/de"; +import { EquipmentForm } from "@/components/verwaltung/EquipmentForm"; +import { EquipmentControls } from "./EquipmentControls"; + +export default async function GeraetBearbeitenPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const s = await requireWehrAdmin(); + const { id } = await params; + + // Scoping: fremde/nicht existente Geräte -> 404. + const item = await getEquipmentForBrigade(id, s.user.brigadeId); + if (!item) notFound(); + + const [categories, vehicles, defs, werte] = await Promise.all([ + listCategories(), + listVehiclesForBrigade(s.user.brigadeId), + getMerkmaleForCategory(item.categoryId), + getMerkmalValuesForEntity("equipment", item.id), + ]); + + return ( +
+

+ {de.verwaltung.geraetBearbeiten} +

+ + + + ({ id: v.id, name: v.name }))} + initial={{ + name: item.name, + categoryId: item.categoryId, + vehicleId: item.vehicleId ?? "", + }} + definitionen={defs} + vorhandeneWerte={werte} + loadCategoryMerkmale={getCategoryMerkmaleAction} + /> +
+ ); +} diff --git a/src/app/(app)/verwaltung/geraete/neu/page.tsx b/src/app/(app)/verwaltung/geraete/neu/page.tsx new file mode 100644 index 0000000..88d2188 --- /dev/null +++ b/src/app/(app)/verwaltung/geraete/neu/page.tsx @@ -0,0 +1,28 @@ +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { listCategories } from "@/server/data/equipment"; +import { listVehiclesForBrigade } from "@/server/data/vehicles"; +import { getCategoryMerkmaleAction } from "@/server/actions/equipment"; +import { de } from "@/lib/i18n/de"; +import { EquipmentForm } from "@/components/verwaltung/EquipmentForm"; + +export default async function GeraetNeuPage() { + const s = await requireWehrAdmin(); + const [categories, vehicles] = await Promise.all([ + listCategories(), + listVehiclesForBrigade(s.user.brigadeId), + ]); + + return ( +
+

+ {de.verwaltung.geraetAnlegen} +

+ ({ id: v.id, name: v.name }))} + loadCategoryMerkmale={getCategoryMerkmaleAction} + /> +
+ ); +} diff --git a/src/app/(app)/verwaltung/geraete/page.tsx b/src/app/(app)/verwaltung/geraete/page.tsx new file mode 100644 index 0000000..c7ff319 --- /dev/null +++ b/src/app/(app)/verwaltung/geraete/page.tsx @@ -0,0 +1,60 @@ +import Link from "next/link"; +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { listEquipmentForBrigade } from "@/server/data/equipment"; +import { de } from "@/lib/i18n/de"; +import { Button } from "@/components/ui/button"; +import { StatusBadge, Badge } from "@/components/ui/badge"; + +export default async function GeraeteListePage() { + const s = await requireWehrAdmin(); + const items = await listEquipmentForBrigade(s.user.brigadeId); + + return ( +
+
+

+ {de.verwaltung.navGeraete} +

+ +
+ + {items.length === 0 ? ( +

+ {de.verwaltung.keineGeraete} +

+ ) : ( +
    + {items.map((e) => ( +
  • +
    + + {e.name} + +

    + {e.categoryName} ·{" "} + {e.vehicleName ?? de.verwaltung.imGeraetehaus} +

    +
    +
    + {e.vehicleId ? null : ( + {de.verwaltung.imGeraetehaus} + )} + +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/(app)/verwaltung/layout.tsx b/src/app/(app)/verwaltung/layout.tsx new file mode 100644 index 0000000..7917070 --- /dev/null +++ b/src/app/(app)/verwaltung/layout.tsx @@ -0,0 +1,25 @@ +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { VerwaltungNav } from "@/components/verwaltung/VerwaltungNav"; + +/** + * Route-Group-Layout des Wehr-Bereichs. + * + * GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1+2): Der + * serverseitige Guard `requireWehrAdmin()` ist die ALLERERSTE Anweisung. Er + * leitet anonyme Aufrufe auf /login (redirect) um und verweigert allen außer + * `wehr_admin` (auch `wehr_read`) mit forbidden() -> 403. Jede Server Action + * wiederholt den Guard zusätzlich (Verteidigung in der Tiefe). + */ +export default async function VerwaltungLayout({ + children, +}: { + children: React.ReactNode; +}) { + await requireWehrAdmin(); + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/app/(app)/verwaltung/profil/page.tsx b/src/app/(app)/verwaltung/profil/page.tsx new file mode 100644 index 0000000..c221cd4 --- /dev/null +++ b/src/app/(app)/verwaltung/profil/page.tsx @@ -0,0 +1,33 @@ +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { getBrigade } from "@/server/data/vehicles"; +import { de } from "@/lib/i18n/de"; +import { BrigadeProfileForm } from "@/components/verwaltung/BrigadeProfileForm"; + +export default async function ProfilPage() { + const s = await requireWehrAdmin(); + const b = await getBrigade(s.user.brigadeId); + + return ( +
+
+

+ {de.verwaltung.profilTitel} +

+ {b ? ( +

{b.name}

+ ) : null} +
+ +
+ ); +} diff --git a/src/components/verwaltung/BrigadeProfileForm.tsx b/src/components/verwaltung/BrigadeProfileForm.tsx new file mode 100644 index 0000000..bfa0e85 --- /dev/null +++ b/src/components/verwaltung/BrigadeProfileForm.tsx @@ -0,0 +1,124 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { de } from "@/lib/i18n/de"; +import { updateBrigadeProfile } from "@/server/actions/brigade"; + +export interface BrigadeProfileInitial { + strasse: string; + plz: string; + ort: string; + telefon: string; + email: string; + wehrfuehrer: string; + funkrufnameSchema: string; +} + +/** + * Profilformular der eigenen Wehr. Speichert Stamm-/Kontaktdaten; die Server- + * Action geokodiert die Adresse inline. Schlägt das Geocoding fehl, werden die + * Daten dennoch gespeichert und ein Warnhinweis angezeigt. + */ +export function BrigadeProfileForm({ + initial, +}: { + initial: BrigadeProfileInitial; +}) { + const router = useRouter(); + const [error, setError] = React.useState(null); + const [info, setInfo] = React.useState<{ warnung: boolean } | null>(null); + const [pending, startTransition] = React.useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setInfo(null); + const fd = new FormData(e.currentTarget); + const payload = { + strasse: String(fd.get("strasse") ?? ""), + plz: String(fd.get("plz") ?? ""), + ort: String(fd.get("ort") ?? ""), + telefon: String(fd.get("telefon") ?? ""), + email: String(fd.get("email") ?? ""), + wehrfuehrer: String(fd.get("wehrfuehrer") ?? ""), + funkrufnameSchema: String(fd.get("funkrufnameSchema") ?? ""), + }; + startTransition(async () => { + const res = await updateBrigadeProfile(payload); + if (!res.ok) { + setError(res.error); + return; + } + setInfo({ warnung: res.geocodeWarnung }); + router.refresh(); + }); + } + + return ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + {info ? ( +

+ {info.warnung + ? de.verwaltung.geocodeWarnung + : `${de.verwaltung.profilGespeichert} ${de.verwaltung.geocodeOk}`} +

+ ) : null} + + +
+ ); +} diff --git a/src/components/verwaltung/BrigadeUserForm.tsx b/src/components/verwaltung/BrigadeUserForm.tsx new file mode 100644 index 0000000..8c4faac --- /dev/null +++ b/src/components/verwaltung/BrigadeUserForm.tsx @@ -0,0 +1,138 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { de } from "@/lib/i18n/de"; +import { createBrigadeUser, deactivateBrigadeUser } from "@/server/actions/brigade-users"; + +/** + * Formular zum Anlegen eines Wehr-Benutzers (lokales Konto). Die Rolle ist auf + * Wehr-Admin/Lesend beschränkt. Nach Erfolg wird das Einmal-Passwort genau + * einmal angezeigt. + */ +export function BrigadeUserForm() { + const router = useRouter(); + const [error, setError] = React.useState(null); + const [tempPassword, setTempPassword] = React.useState(null); + const [pending, startTransition] = React.useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const form = e.currentTarget; + const fd = new FormData(form); + const payload = { + email: String(fd.get("email") ?? ""), + name: String(fd.get("name") ?? ""), + rolle: String(fd.get("rolle") ?? "wehr_read"), + }; + startTransition(async () => { + const res = await createBrigadeUser(payload); + if (!res.ok) { + setError(res.error); + return; + } + setTempPassword(res.tempPassword); + form.reset(); + router.refresh(); + }); + } + + return ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + + {tempPassword ? ( +
+

+ {de.verwaltung.tempPasswort} +

+ + {tempPassword} + +
+ ) : null} + + +
+ ); +} + +/** + * Knopf zum Deaktivieren eines Benutzers. Eigener Client-Knopf (Bestätigung + + * Transition). Selbst-Deaktivierung verhindert die Server-Action zusätzlich. + */ +export function DeactivateUserButton({ + userId, + disabled, +}: { + userId: string; + disabled?: boolean; +}) { + const router = useRouter(); + const [pending, startTransition] = React.useTransition(); + const [error, setError] = React.useState(null); + + function onClick() { + if (!window.confirm(de.verwaltung.loeschenBestaetigen)) return; + setError(null); + startTransition(async () => { + const res = await deactivateBrigadeUser({ userId }); + if (!res.ok) { + setError(res.error); + return; + } + router.refresh(); + }); + } + + return ( + + + {error ? {error} : null} + + ); +} diff --git a/src/components/verwaltung/EquipmentForm.tsx b/src/components/verwaltung/EquipmentForm.tsx new file mode 100644 index 0000000..9727b56 --- /dev/null +++ b/src/components/verwaltung/EquipmentForm.tsx @@ -0,0 +1,184 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { de } from "@/lib/i18n/de"; +import { + MerkmalValueEditor, + initMerkmalWerte, + werteToList, + type MerkmalWerteState, +} from "./MerkmalValueEditor"; +import type { + MerkmalDefinition, + MerkmalValueInput, +} from "@/lib/merkmale/types"; +import { createEquipment, updateEquipment } from "@/server/actions/equipment"; + +export interface CategoryOption { + id: string; + name: string; +} +export interface VehicleOption { + id: string; + name: string; +} + +interface CreateProps { + mode: "create"; + categories: CategoryOption[]; + vehicles: VehicleOption[]; + loadCategoryMerkmale: (categoryId: string) => Promise; +} + +interface EditProps { + mode: "edit"; + equipmentId: string; + categories: CategoryOption[]; + vehicles: VehicleOption[]; + initial: { name: string; categoryId: string; vehicleId: string }; + definitionen: MerkmalDefinition[]; + vorhandeneWerte: MerkmalValueInput[]; + loadCategoryMerkmale: (categoryId: string) => Promise; +} + +type Props = CreateProps | EditProps; + +export function EquipmentForm(props: Props) { + const router = useRouter(); + const [error, setError] = React.useState(null); + const [pending, startTransition] = React.useTransition(); + + const [categoryId, setCategoryId] = React.useState( + props.mode === "edit" ? props.initial.categoryId : "", + ); + const [vehicleId, setVehicleId] = React.useState( + props.mode === "edit" ? props.initial.vehicleId : "", + ); + const [defs, setDefs] = React.useState( + props.mode === "edit" ? props.definitionen : [], + ); + const [werte, setWerte] = React.useState( + props.mode === "edit" + ? initMerkmalWerte(props.definitionen, props.vorhandeneWerte) + : {}, + ); + + async function onCategoryChange(id: string) { + setCategoryId(id); + if (!id) { + setDefs([]); + setWerte({}); + return; + } + const loaded = await props.loadCategoryMerkmale(id); + setDefs(loaded); + setWerte(initMerkmalWerte(loaded)); + } + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const fd = new FormData(e.currentTarget); + const base = { + name: String(fd.get("name") ?? ""), + categoryId, + vehicleId: vehicleId || undefined, + }; + const liste = werteToList(werte); + + startTransition(async () => { + const res = + props.mode === "create" + ? await createEquipment(base, liste) + : await updateEquipment({ ...base, id: props.equipmentId }, liste); + if (!res.ok) { + setError(res.error); + return; + } + router.push("/verwaltung/geraete"); + router.refresh(); + }); + } + + const initialName = props.mode === "edit" ? props.initial.name : ""; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {de.verwaltung.merkmale} + + +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+ ); +} diff --git a/src/components/verwaltung/MerkmalValueEditor.tsx b/src/components/verwaltung/MerkmalValueEditor.tsx new file mode 100644 index 0000000..92ada7a --- /dev/null +++ b/src/components/verwaltung/MerkmalValueEditor.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { de } from "@/lib/i18n/de"; +import type { + MerkmalDefinition, + MerkmalValueInput, +} from "@/lib/merkmale/types"; + +export type MerkmalWerteState = Record; + +/** + * Leitet den initialen Editor-Zustand aus Definitionen + bereits gespeicherten + * Werten ab. Vorgabewerte werden typgerecht aus den drei Spalten gelesen, falls + * kein gespeicherter Wert existiert. + */ +export function initMerkmalWerte( + defs: MerkmalDefinition[], + vorhanden: MerkmalValueInput[] = [], +): MerkmalWerteState { + const byId = new Map(vorhanden.map((v) => [v.merkmalId, v])); + const state: MerkmalWerteState = {}; + for (const d of defs) { + const existing = byId.get(d.merkmalId); + state[d.merkmalId] = existing ?? { + merkmalId: d.merkmalId, + num: d.vorgabeNum, + text: d.vorgabeText, + bool: d.vorgabeBool, + }; + } + return state; +} + +interface Props { + definitionen: MerkmalDefinition[]; + werte: MerkmalWerteState; + onChange: (next: MerkmalWerteState) => void; +} + +/** + * Typisierter Merkmal-Editor: rendert je Merkmal das passende Eingabefeld + * (Zahl/Auswahl/Schalter/Text). Pflichtmerkmale sind markiert. Der Editor ist + * kontrolliert; die Server-Action validiert die Werte erneut (Default-deny). + */ +export function MerkmalValueEditor({ definitionen, werte, onChange }: Props) { + if (definitionen.length === 0) { + return ( +

{de.verwaltung.keineMerkmale}

+ ); + } + + function update(merkmalId: string, patch: Partial) { + onChange({ + ...werte, + [merkmalId]: { ...werte[merkmalId], merkmalId, ...patch }, + }); + } + + return ( +
+ {definitionen.map((d) => { + const v = werte[d.merkmalId] ?? { merkmalId: d.merkmalId }; + const fieldId = `merkmal-${d.merkmalId}`; + return ( +
+ + + {d.typ === "number" ? ( + + update(d.merkmalId, { + num: e.target.value === "" ? null : Number(e.target.value), + }) + } + /> + ) : d.typ === "boolean" ? ( + + ) : d.typ === "enum" ? ( + + ) : ( + + update(d.merkmalId, { + text: e.target.value === "" ? null : e.target.value, + }) + } + /> + )} +
+ ); + })} +
+ ); +} + +/** Wandelt den Editor-Zustand in die Liste der Server-Eingaben um. */ +export function werteToList(state: MerkmalWerteState): MerkmalValueInput[] { + return Object.values(state); +} diff --git a/src/components/verwaltung/TemplatePicker.tsx b/src/components/verwaltung/TemplatePicker.tsx new file mode 100644 index 0000000..0688fef --- /dev/null +++ b/src/components/verwaltung/TemplatePicker.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { de } from "@/lib/i18n/de"; + +export interface TemplateOption { + id: string; + code: string; + name: string; +} + +interface Props { + templates: TemplateOption[]; + value: string; + onChange: (id: string) => void; + disabled?: boolean; +} + +/** + * Auswahl einer Fahrzeug-Vorlage (oder „frei"). Reine Präsentation; das + * Nachladen der Merkmale übernimmt das Formular. + */ +export function TemplatePicker({ templates, value, onChange, disabled }: Props) { + return ( +
+ + +
+ ); +} diff --git a/src/components/verwaltung/VehicleForm.tsx b/src/components/verwaltung/VehicleForm.tsx new file mode 100644 index 0000000..8ec4b6b --- /dev/null +++ b/src/components/verwaltung/VehicleForm.tsx @@ -0,0 +1,169 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { de } from "@/lib/i18n/de"; +import { + MerkmalValueEditor, + initMerkmalWerte, + werteToList, + type MerkmalWerteState, +} from "./MerkmalValueEditor"; +import { TemplatePicker } from "./TemplatePicker"; +import type { + MerkmalDefinition, + MerkmalValueInput, +} from "@/lib/merkmale/types"; +import { createVehicle, updateVehicle } from "@/server/actions/vehicles"; + +export interface VehicleFormTemplate { + id: string; + code: string; + name: string; +} + +interface CreateProps { + mode: "create"; + templates: VehicleFormTemplate[]; + /** Lädt die Merkmal-Definitionen einer Vorlage (geschützte Server-Action). */ + loadTemplateMerkmale: (templateId: string) => Promise; +} + +interface EditProps { + mode: "edit"; + vehicleId: string; + initial: { + name: string; + funkrufname: string; + notiz: string; + }; + definitionen: MerkmalDefinition[]; + vorhandeneWerte: MerkmalValueInput[]; +} + +type Props = CreateProps | EditProps; + +export function VehicleForm(props: Props) { + const router = useRouter(); + const [error, setError] = React.useState(null); + const [pending, startTransition] = React.useTransition(); + + const [templateId, setTemplateId] = React.useState(""); + const [defs, setDefs] = React.useState( + props.mode === "edit" ? props.definitionen : [], + ); + const [werte, setWerte] = React.useState( + props.mode === "edit" + ? initMerkmalWerte(props.definitionen, props.vorhandeneWerte) + : {}, + ); + + async function onTemplateChange(id: string) { + setTemplateId(id); + if (props.mode !== "create") return; + if (!id) { + setDefs([]); + setWerte({}); + return; + } + const loaded = await props.loadTemplateMerkmale(id); + setDefs(loaded); + setWerte(initMerkmalWerte(loaded)); + } + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const fd = new FormData(e.currentTarget); + const base = { + name: String(fd.get("name") ?? ""), + funkrufname: String(fd.get("funkrufname") ?? ""), + notiz: String(fd.get("notiz") ?? ""), + }; + const liste = werteToList(werte); + + startTransition(async () => { + const res = + props.mode === "create" + ? await createVehicle({ ...base, templateId: templateId || undefined }, liste) + : await updateVehicle({ ...base, id: props.vehicleId }, liste); + if (!res.ok) { + setError(res.error); + return; + } + router.push("/verwaltung/fahrzeuge"); + router.refresh(); + }); + } + + const initial = props.mode === "edit" ? props.initial : undefined; + + return ( +
+ {props.mode === "create" ? ( + void onTemplateChange(id)} + /> + ) : null} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {de.verwaltung.merkmale} + + +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+ + ); +} diff --git a/src/components/verwaltung/VerwaltungNav.tsx b/src/components/verwaltung/VerwaltungNav.tsx new file mode 100644 index 0000000..a09d3d1 --- /dev/null +++ b/src/components/verwaltung/VerwaltungNav.tsx @@ -0,0 +1,52 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { de } from "@/lib/i18n/de"; + +/** + * Sub-Navigation des Wehr-Bereichs. Client-Komponente nur wegen `usePathname` + * (aktiver Zustand). Keine Geschäftslogik. Spiegelt die Admin-Navigation für + * ein konsistentes Erscheinungsbild. + */ +const ITEMS = [ + { href: "/verwaltung/profil", label: de.verwaltung.navProfil }, + { href: "/verwaltung/fahrzeuge", label: de.verwaltung.navFahrzeuge }, + { href: "/verwaltung/geraete", label: de.verwaltung.navGeraete }, + { href: "/verwaltung/benutzer", label: de.verwaltung.navBenutzer }, +] as const; + +export function VerwaltungNav() { + const pathname = usePathname(); + return ( + + ); +} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 43c7add..fe5a0b5 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -125,6 +125,63 @@ export const de = { zurueck: "Zurück", weiter: "Weiter", }, + verwaltung: { + titel: "Verwaltung", + navProfil: "Profil", + navFahrzeuge: "Fahrzeuge", + navGeraete: "Geräte", + navBenutzer: "Benutzer", + speichern: "Speichern", + abbrechen: "Abbrechen", + loeschen: "Löschen", + anlegen: "Anlegen", + bearbeiten: "Bearbeiten", + neu: "Neu", + name: "Name", + funkrufname: "Funkrufname", + notiz: "Notiz", + status: "Status", + kategorie: "Kategorie", + zuordnung: "Zuordnung", + imGeraetehaus: "im Gerätehaus", + vorlage: "Fahrzeug-Vorlage", + keineVorlage: "Ohne Vorlage (frei)", + vorlageWaehlen: "Vorlage wählen", + merkmale: "Merkmale", + keineMerkmale: "Für diese Auswahl sind keine Merkmale hinterlegt.", + fahrzeugAnlegen: "Fahrzeug anlegen", + fahrzeugBearbeiten: "Fahrzeug bearbeiten", + geraetAnlegen: "Gerät anlegen", + geraetBearbeiten: "Gerät bearbeiten", + keineFahrzeuge: "Noch keine Fahrzeuge erfasst.", + keineGeraete: "Noch keine Geräte erfasst.", + keineBenutzer: "Noch keine Benutzer erfasst.", + profilTitel: "Wehr-Profil", + profilGespeichert: "Profil gespeichert.", + geocodeOk: "Adresse geokodiert.", + geocodeWarnung: + "Adresse konnte nicht geokodiert werden. Daten wurden dennoch gespeichert.", + strasse: "Straße", + plz: "PLZ", + ort: "Ort", + email: "E-Mail", + telefon: "Telefon", + wehrfuehrer: "Wehrführer", + funkrufnameSchema: "Funkrufname-Schema", + rolle: "Rolle", + rolleAdmin: "Wehr-Admin", + rolleRead: "Lesend", + benutzerAnlegen: "Benutzer anlegen", + deaktivieren: "Deaktivieren", + aktiv: "aktiv", + inaktiv: "inaktiv", + authLokal: "lokal", + authAuthentik: "Authentik", + tempPasswort: + "Einmal-Passwort (nur jetzt sichtbar, bitte sicher übergeben):", + loeschenBestaetigen: "Wirklich löschen?", + pflichtfeld: "Pflichtfeld", + }, } as const; type Leaf = string; diff --git a/src/lib/merkmale/types.ts b/src/lib/merkmale/types.ts new file mode 100644 index 0000000..4a867bd --- /dev/null +++ b/src/lib/merkmale/types.ts @@ -0,0 +1,49 @@ +/** + * Geteilte Typen für den typisierten Merkmal-Wert-Editor (Workstream 7). + * + * REIN: keine DB-/IO-Abhängigkeit, damit Validierung und Editor ohne laufendes + * Postgres testbar sind. Die DB-Schreibseite (`upsertMerkmalValues`) konsumiert + * `MerkmalValueInput`. + */ + +import type { merkmalTypEnum } from "@/db/schema"; + +/** Fachlicher Merkmal-Typ (Single Source of Truth: DB-Enum `merkmal_typ`). */ +export type MerkmalTyp = (typeof merkmalTypEnum.enumValues)[number]; + +/** Eine Auswahloption eines `enum`-Merkmals. */ +export interface MerkmalOption { + wert: string; + label: string; +} + +/** + * Ein zur Bearbeitung angebotenes Merkmal (aus Vorlage oder Kategorie + * aufgelöst). `pflicht` markiert Vorlagen-Pflichtfelder. `vorgabe*` sind die + * typgerecht vorbefüllten Standardwerte (drei Spalten, exakt einer passt zum + * `typ`). + */ +export interface MerkmalDefinition { + merkmalId: string; + name: string; + typ: MerkmalTyp; + einheit: string | null; + pflicht: boolean; + reihenfolge: number; + optionen: MerkmalOption[]; + vorgabeNum: number | null; + vorgabeText: string | null; + vorgabeBool: boolean | null; +} + +/** + * Ein einzelner, an der Server-Grenze validierter Merkmal-Wert. Genau eine der + * Wertspalten ist (typabhängig) gesetzt; alle `null`/leer => der Wert wird + * beim Upsert gelöscht (kein Eintrag). + */ +export interface MerkmalValueInput { + merkmalId: string; + num?: number | null; + text?: string | null; + bool?: boolean | null; +} diff --git a/src/lib/validation/__tests__/brigade-user.test.ts b/src/lib/validation/__tests__/brigade-user.test.ts new file mode 100644 index 0000000..a3f6b2f --- /dev/null +++ b/src/lib/validation/__tests__/brigade-user.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { brigadeUserCreateSchema, brigadeUserDeactivateSchema } from "../brigade-user"; + +describe("brigadeUserCreateSchema", () => { + const valid = { + email: "Neu@FF.at", + name: "Neue Person", + rolle: "wehr_read", + }; + + it("akzeptiert wehr_admin und wehr_read und normalisiert die E-Mail", () => { + const r = brigadeUserCreateSchema.safeParse(valid); + expect(r.success).toBe(true); + if (r.success) expect(r.data.email).toBe("neu@ff.at"); + expect(brigadeUserCreateSchema.safeParse({ ...valid, rolle: "wehr_admin" }).success).toBe(true); + }); + + it("lehnt platform_admin ab (nicht zuweisbar durch Wehr-Admin)", () => { + expect( + brigadeUserCreateSchema.safeParse({ ...valid, rolle: "platform_admin" }).success, + ).toBe(false); + }); + + it("verlangt Name und gültige E-Mail", () => { + expect(brigadeUserCreateSchema.safeParse({ ...valid, name: "" }).success).toBe(false); + expect(brigadeUserCreateSchema.safeParse({ ...valid, email: "keine-mail" }).success).toBe(false); + }); +}); + +describe("brigadeUserDeactivateSchema", () => { + it("verlangt eine gültige UUID", () => { + expect( + brigadeUserDeactivateSchema.safeParse({ userId: "11111111-1111-1111-1111-111111111111" }).success, + ).toBe(true); + expect(brigadeUserDeactivateSchema.safeParse({ userId: "x" }).success).toBe(false); + }); +}); diff --git a/src/lib/validation/__tests__/equipment.test.ts b/src/lib/validation/__tests__/equipment.test.ts new file mode 100644 index 0000000..05ca38b --- /dev/null +++ b/src/lib/validation/__tests__/equipment.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { + equipmentBaseSchema, + equipmentStatusSchema, +} from "../equipment"; + +const CAT = "11111111-1111-1111-1111-111111111111"; +const VEH = "22222222-2222-2222-2222-222222222222"; + +describe("equipmentBaseSchema", () => { + it("verlangt Name und Kategorie", () => { + expect(equipmentBaseSchema.safeParse({ name: "", categoryId: CAT }).success).toBe(false); + expect(equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: "x" }).success).toBe(false); + }); + + it("erlaubt leere vehicleId (im Gerätehaus) -> undefined", () => { + const r = equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: CAT, vehicleId: "" }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.vehicleId).toBeUndefined(); + }); + + it("akzeptiert eine gültige vehicleId (Zuordnung)", () => { + const r = equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: CAT, vehicleId: VEH }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.vehicleId).toBe(VEH); + }); +}); + +describe("equipmentStatusSchema", () => { + it("akzeptiert nur asset_status-Werte", () => { + expect(equipmentStatusSchema.safeParse({ id: CAT, status: "ausser_dienst" }).success).toBe(true); + expect(equipmentStatusSchema.safeParse({ id: CAT, status: "weg" }).success).toBe(false); + }); +}); diff --git a/src/lib/validation/__tests__/vehicle.test.ts b/src/lib/validation/__tests__/vehicle.test.ts new file mode 100644 index 0000000..b653b8b --- /dev/null +++ b/src/lib/validation/__tests__/vehicle.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { + vehicleBaseSchema, + vehicleStatusSchema, + buildMerkmalValuesSchema, +} from "../vehicle"; +import type { MerkmalDefinition } from "@/lib/merkmale/types"; + +const def = (over: Partial): MerkmalDefinition => ({ + merkmalId: "11111111-1111-1111-1111-111111111111", + name: "Merkmal", + typ: "text", + einheit: null, + pflicht: false, + reihenfolge: 0, + optionen: [], + vorgabeNum: null, + vorgabeText: null, + vorgabeBool: null, + ...over, +}); + +describe("vehicleBaseSchema", () => { + it("verlangt einen Namen", () => { + const r = vehicleBaseSchema.safeParse({ name: "", funkrufname: "", notiz: "" }); + expect(r.success).toBe(false); + }); + + it("akzeptiert Name mit leeren Optionalfeldern (-> undefined)", () => { + const r = vehicleBaseSchema.safeParse({ + name: "HLF 2 Musterdorf", + funkrufname: "", + notiz: "", + templateId: "", + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.funkrufname).toBeUndefined(); + expect(r.data.templateId).toBeUndefined(); + } + }); + + it("akzeptiert eine gültige templateId (uuid)", () => { + const r = vehicleBaseSchema.safeParse({ + name: "HLF 2", + templateId: "22222222-2222-2222-2222-222222222222", + }); + expect(r.success).toBe(true); + }); +}); + +describe("vehicleStatusSchema", () => { + it("akzeptiert nur asset_status-Werte", () => { + expect(vehicleStatusSchema.safeParse({ id: "33333333-3333-3333-3333-333333333333", status: "wartung" }).success).toBe(true); + expect(vehicleStatusSchema.safeParse({ id: "33333333-3333-3333-3333-333333333333", status: "kaputt" }).success).toBe(false); + }); +}); + +describe("buildMerkmalValuesSchema", () => { + it("validiert number-Merkmale typgerecht und coerced Strings", () => { + const schema = buildMerkmalValuesSchema([ + def({ typ: "number", name: "Löschwassertank", einheit: "l" }), + ]); + const r = schema.safeParse([ + { merkmalId: def({}).merkmalId, num: "2000" }, + ]); + expect(r.success).toBe(true); + if (r.success) expect(r.data[0]?.num).toBe(2000); + }); + + it("lehnt nicht-numerische Eingabe für number ab", () => { + const schema = buildMerkmalValuesSchema([def({ typ: "number" })]); + const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: "abc" }]); + expect(r.success).toBe(false); + }); + + it("erzwingt Pflichtmerkmale (number ohne Wert -> Fehler)", () => { + const schema = buildMerkmalValuesSchema([def({ typ: "number", pflicht: true })]); + const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: null }]); + expect(r.success).toBe(false); + }); + + it("erlaubt optionale Merkmale ohne Wert", () => { + const schema = buildMerkmalValuesSchema([def({ typ: "number", pflicht: false })]); + const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: null }]); + expect(r.success).toBe(true); + }); + + it("beschränkt enum-Werte auf erlaubte Optionen", () => { + const schema = buildMerkmalValuesSchema([ + def({ typ: "enum", optionen: [{ wert: "TS", label: "Tragkraftspritze" }] }), + ]); + expect(schema.safeParse([{ merkmalId: def({}).merkmalId, text: "TS" }]).success).toBe(true); + expect(schema.safeParse([{ merkmalId: def({}).merkmalId, text: "XX" }]).success).toBe(false); + }); + + it("coerced boolean-Merkmale", () => { + const schema = buildMerkmalValuesSchema([def({ typ: "boolean", name: "Allradantrieb" })]); + const r = schema.safeParse([{ merkmalId: def({}).merkmalId, bool: true }]); + expect(r.success).toBe(true); + if (r.success) expect(r.data[0]?.bool).toBe(true); + }); + + it("ignoriert Werte zu unbekannten Merkmalen (nur erlaubte merkmalIds)", () => { + const schema = buildMerkmalValuesSchema([def({ typ: "text" })]); + const r = schema.safeParse([ + { merkmalId: "99999999-9999-9999-9999-999999999999", text: "hallo" }, + ]); + expect(r.success).toBe(false); + }); +}); diff --git a/src/lib/validation/brigade-user.ts b/src/lib/validation/brigade-user.ts new file mode 100644 index 0000000..c9444ee --- /dev/null +++ b/src/lib/validation/brigade-user.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { uuidSchema } from "./common"; + +/** + * Zod-Schemas für die Benutzerverwaltung im Wehr-Bereich (Workstream 7). + * + * WICHTIG (Sicherheit): Die Rolle ist auf `wehr_admin | wehr_read` beschränkt. + * Ein Wehr-Admin darf NIEMALS `platform_admin` vergeben — Zod lehnt das an der + * Grenze ab (Querschnittsstandard 4, Verteidigung in der Tiefe zusätzlich zum + * serverseitigen Scope-Guard). + */ + +export const brigadeUserRoleSchema = z.enum(["wehr_admin", "wehr_read"], { + errorMap: () => ({ message: "Unzulässige Rolle." }), +}); + +export const brigadeUserCreateSchema = z.object({ + email: z + .string() + .trim() + .email({ message: "Ungültige E-Mail." }) + .transform((v) => v.toLowerCase()), + name: z.string().trim().min(1, { message: "Name ist Pflicht." }), + rolle: brigadeUserRoleSchema, +}); + +export type BrigadeUserCreateInput = z.infer; + +export const brigadeUserDeactivateSchema = z.object({ + userId: uuidSchema, +}); + +export type BrigadeUserDeactivateInput = z.infer< + typeof brigadeUserDeactivateSchema +>; diff --git a/src/lib/validation/brigade.ts b/src/lib/validation/brigade.ts index caf926f..bedd77e 100644 --- a/src/lib/validation/brigade.ts +++ b/src/lib/validation/brigade.ts @@ -38,3 +38,37 @@ export const userResetSchema = z.object({ }); export type UserResetInput = z.infer; + +/** + * Schema für die Profil-Bearbeitung durch den Wehr-Admin der EIGENEN Wehr + * (Workstream 7). Kein `name`/keine Anlage — nur Stamm-/Kontaktdaten. Die + * `brigadeId` kommt IMMER serverseitig aus der Session, nie aus dem Input. + */ +export const brigadeProfileSchema = z.object({ + 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() + .optional() + .transform((v) => (v === "" ? undefined : v)), + 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)), + funkrufnameSchema: z + .string() + .trim() + .optional() + .transform((v) => (v === "" ? undefined : v)), +}); + +export type BrigadeProfileInput = z.infer; diff --git a/src/lib/validation/equipment.ts b/src/lib/validation/equipment.ts new file mode 100644 index 0000000..96f1ad6 --- /dev/null +++ b/src/lib/validation/equipment.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; +import { assetStatusEnum } from "@/db/schema"; +import { uuidSchema, optionalText } from "./common"; + +/** + * Zod-Schemas für Geräte/Beladung (Workstream 7). `vehicleId = undefined` + * bedeutet „im Gerätehaus" (DB-Spalte NULL). Die Zuordnung darf serverseitig + * nur auf ein Fahrzeug DERSELBEN Wehr zeigen (Scope-Prüfung in der Action). + * `status` über das DB-Enum `asset_status` (Single Source of Truth). + */ + +export const assetStatusSchema = z.enum(assetStatusEnum.enumValues); + +const optionalUuid = z + .string() + .trim() + .optional() + .transform((v) => (v === undefined || v === "" ? undefined : v)) + .refine((v) => v === undefined || uuidSchema.safeParse(v).success, { + message: "Ungültige ID.", + }); + +export const equipmentBaseSchema = z.object({ + name: z.string().trim().min(1, { message: "Gerätename ist Pflicht." }), + categoryId: uuidSchema, + vehicleId: optionalUuid, + notiz: optionalText, +}); + +export type EquipmentBaseInput = z.infer; + +export const equipmentCreateSchema = equipmentBaseSchema; +export const equipmentUpdateSchema = z.object({ + id: uuidSchema, + ...equipmentBaseSchema.shape, +}); +export type EquipmentUpdateInput = z.infer; + +export const equipmentStatusSchema = z.object({ + id: uuidSchema, + status: assetStatusSchema, +}); +export type EquipmentStatusInput = z.infer; + +export const equipmentIdSchema = z.object({ id: uuidSchema }); diff --git a/src/lib/validation/vehicle.ts b/src/lib/validation/vehicle.ts new file mode 100644 index 0000000..f7c292e --- /dev/null +++ b/src/lib/validation/vehicle.ts @@ -0,0 +1,162 @@ +import { z } from "zod"; +import { assetStatusEnum } from "@/db/schema"; +import { uuidSchema, optionalText } from "./common"; +import type { MerkmalDefinition } from "@/lib/merkmale/types"; + +/** + * Zod-Schemas für Fahrzeuge (Workstream 7). `funkrufname` ist eine SPALTE + * (kein Merkmal). `status` über das DB-Enum `asset_status` (Single Source of + * Truth). Leere Optionalfelder werden zu `undefined` normalisiert. + */ + +export const assetStatusSchema = z.enum(assetStatusEnum.enumValues); + +const optionalUuid = z + .string() + .trim() + .optional() + .transform((v) => (v === undefined || v === "" ? undefined : v)) + .refine((v) => v === undefined || uuidSchema.safeParse(v).success, { + message: "Ungültige ID.", + }); + +export const vehicleBaseSchema = z.object({ + name: z.string().trim().min(1, { message: "Fahrzeugname ist Pflicht." }), + funkrufname: optionalText, + notiz: optionalText, + templateId: optionalUuid, +}); + +export type VehicleBaseInput = z.infer; + +export const vehicleCreateSchema = vehicleBaseSchema; +export const vehicleUpdateSchema = z.object({ + id: uuidSchema, + ...vehicleBaseSchema.shape, +}); +export type VehicleUpdateInput = z.infer; + +export const vehicleStatusSchema = z.object({ + id: uuidSchema, + status: assetStatusSchema, +}); +export type VehicleStatusInput = z.infer; + +export const vehicleIdSchema = z.object({ id: uuidSchema }); + +/** + * Baut ein typgerechtes Zod-Schema für eine Liste von Merkmal-Werten, abgeleitet + * aus den angebotenen `MerkmalDefinition`s (Vorlage/Kategorie). Pflichtmerkmale + * müssen einen Wert haben; `enum`-Werte sind auf die Optionen beschränkt; + * Zahlen werden gecoerced. Werte zu nicht angebotenen `merkmalId`s sind + * unzulässig (kein Schmuggeln fremder Merkmale). + * + * REIN: nimmt Definitionen als Parameter, damit es ohne DB testbar ist. + */ +export function buildMerkmalValuesSchema(definitionen: MerkmalDefinition[]) { + const byId = new Map(definitionen.map((d) => [d.merkmalId, d])); + + const single = z + .object({ + merkmalId: uuidSchema, + num: z.unknown().optional(), + text: z.unknown().optional(), + bool: z.unknown().optional(), + }) + .superRefine((raw, ctx) => { + const def = byId.get(raw.merkmalId); + if (!def) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["merkmalId"], + message: "Unbekanntes Merkmal.", + }); + return; + } + + if (def.typ === "number") { + const v = parseNumber(raw.num); + if (v === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["num"], + message: `„${def.name}“ muss eine Zahl sein.`, + }); + } else if (v === null && def.pflicht) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["num"], + message: `„${def.name}“ ist Pflicht.`, + }); + } + return; + } + + if (def.typ === "boolean") { + const v = parseBool(raw.bool); + if (def.pflicht && v == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["bool"], + message: `„${def.name}“ ist Pflicht.`, + }); + } + return; + } + + // text | enum + const text = parseText(raw.text); + if (def.pflicht && (text == null || text === "")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["text"], + message: `„${def.name}“ ist Pflicht.`, + }); + return; + } + if (def.typ === "enum" && text != null && text !== "") { + const erlaubt = def.optionen.map((o) => o.wert); + if (!erlaubt.includes(text)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["text"], + message: `Ungültige Auswahl für „${def.name}“.`, + }); + } + } + }) + .transform((raw) => { + const def = byId.get(raw.merkmalId)!; + if (def.typ === "number") { + const v = parseNumber(raw.num); + return { merkmalId: raw.merkmalId, num: v ?? null }; + } + if (def.typ === "boolean") { + return { merkmalId: raw.merkmalId, bool: parseBool(raw.bool) }; + } + const text = parseText(raw.text); + return { merkmalId: raw.merkmalId, text: text ?? null }; + }); + + return z.array(single); +} + +/** `undefined` = ungültig (NaN), `null` = leer, sonst die Zahl. */ +function parseNumber(v: unknown): number | null | undefined { + if (v == null || v === "") return null; + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) ? n : undefined; +} + +function parseBool(v: unknown): boolean | null { + if (v == null || v === "") return null; + if (typeof v === "boolean") return v; + if (v === "true") return true; + if (v === "false") return false; + return null; +} + +function parseText(v: unknown): string | null { + if (v == null) return null; + return typeof v === "string" ? v : String(v); +} diff --git a/src/server/actions/brigade-users.ts b/src/server/actions/brigade-users.ts new file mode 100644 index 0000000..3ea225b --- /dev/null +++ b/src/server/actions/brigade-users.ts @@ -0,0 +1,131 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { hashPassword } from "@/lib/auth/password"; +import { generateTempPassword } from "@/lib/admin/provisioning"; +import { writeAudit } from "@/lib/audit"; +import { + brigadeUserCreateSchema, + brigadeUserDeactivateSchema, +} from "@/lib/validation/brigade-user"; + +export type CreateUserResult = + | { ok: true; tempPassword: string } + | { ok: false; error: string }; + +export type DeactivateResult = + | { ok: true } + | { ok: false; error: string }; + +/** + * Legt einen Benutzer (lokales Konto) der EIGENEN Wehr an. Rolle ist per Zod auf + * `wehr_admin|wehr_read` beschränkt (platform_admin wird abgelehnt). Passwort + * via argon2id (OWASP-Minima). `brigadeId`/`erstelltVon` kommen IMMER aus der + * Session. Audit `user.create`. Liefert das Einmal-Passwort genau einmal. + */ +export async function createBrigadeUser( + input: unknown, +): Promise { + const s = await requireWehrAdmin(); + const parsed = brigadeUserCreateSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe.", + }; + } + const d = parsed.data; + const temp = generateTempPassword(); + const hash = await hashPassword(temp); + + try { + await db.transaction(async (tx) => { + const [u] = await tx + .insert(users) + .values({ + brigadeId: s.user.brigadeId, + rolle: d.rolle, + authTyp: "local", + email: d.email, + name: d.name, + passwortHash: hash, + aktiv: true, + erstelltVon: s.user.id, + }) + .returning({ id: users.id }); + if (!u) throw new Error("Benutzer konnte nicht angelegt werden."); + await writeAudit( + s.user.id, + "user.create", + "user", + u.id, + { rolle: d.rolle, authTyp: "local" }, + tx, + ); + }); + } catch (e) { + // Eindeutigkeitsverletzung (E-Mail bereits vergeben) o. Ä. + const msg = + e instanceof Error && /unique|duplicate|users_email/i.test(e.message) + ? "Diese E-Mail ist bereits vergeben." + : "Benutzer konnte nicht angelegt werden."; + return { ok: false, error: msg }; + } + + revalidatePath("/verwaltung/benutzer"); + return { ok: true, tempPassword: temp }; +} + +/** + * Deaktiviert einen Benutzer der EIGENEN Wehr. Selbst-Deaktivierung ist + * verboten (sonst sperrt sich ein Admin aus). Scope: nur Benutzer derselben + * Wehr. Audit `user.deactivate`. + */ +export async function deactivateBrigadeUser( + input: unknown, +): Promise { + const s = await requireWehrAdmin(); + const parsed = brigadeUserDeactivateSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: "Ungültige ID." }; + if (parsed.data.userId === s.user.id) { + return { ok: false, error: "Sie können sich nicht selbst deaktivieren." }; + } + + const [target] = await db + .select({ id: users.id }) + .from(users) + .where( + and( + eq(users.id, parsed.data.userId), + eq(users.brigadeId, s.user.brigadeId), + ), + ); + if (!target) return { ok: false, error: "Benutzer nicht gefunden." }; + + await db.transaction(async (tx) => { + await tx + .update(users) + .set({ aktiv: false }) + .where( + and( + eq(users.id, parsed.data.userId), + eq(users.brigadeId, s.user.brigadeId), + ), + ); + await writeAudit( + s.user.id, + "user.deactivate", + "user", + parsed.data.userId, + undefined, + tx, + ); + }); + + revalidatePath("/verwaltung/benutzer"); + return { ok: true }; +} diff --git a/src/server/actions/brigade.ts b/src/server/actions/brigade.ts new file mode 100644 index 0000000..d7d2ca7 --- /dev/null +++ b/src/server/actions/brigade.ts @@ -0,0 +1,66 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { brigades } from "@/db/schema"; +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { brigadeProfileSchema } from "@/lib/validation/brigade"; +import { geocodeAddress } from "@/lib/geo/nominatim"; +import { writeAudit } from "@/lib/audit"; + +export type ProfileActionResult = + | { ok: true; geocodeWarnung: boolean } + | { ok: false; error: string }; + +/** + * Aktualisiert das Profil der EIGENEN Wehr (Default-deny: Guard zuerst). + * `brigadeId` kommt IMMER aus der Session. Geocoding inline via `geocodeAddress` + * (lat/lng selbst geschrieben; kein zweiter Geo-Pfad). Audit + * `brigade.profile_update`. Nicht geokodierbar => Speichern trotzdem, Warnung + * an den Aufrufer (Querschnittsstandard 4/6). + */ +export async function updateBrigadeProfile( + input: unknown, +): Promise { + const s = await requireWehrAdmin(); + const parsed = brigadeProfileSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe.", + }; + } + const d = parsed.data; + const query = `${d.strasse}, ${d.plz} ${d.ort}, Österreich`; + const geo = await geocodeAddress(query); + const geocodeWarnung = geo.status !== "ok"; + + await db + .update(brigades) + .set({ + strasse: d.strasse, + plz: d.plz, + ort: d.ort, + telefon: d.telefon ?? null, + email: d.email ?? null, + wehrfuehrer: d.wehrfuehrer ?? null, + funkrufnameSchema: d.funkrufnameSchema ?? null, + geocodeQuery: query, + geocodedAt: new Date(), + ...(geo.status === "ok" + ? { lat: geo.coords.lat, lng: geo.coords.lng, geocodeStatus: "ok" } + : { geocodeStatus: geo.status }), + }) + .where(eq(brigades.id, s.user.brigadeId)); + + await writeAudit( + s.user.id, + "brigade.profile_update", + "brigade", + s.user.brigadeId, + { geocodeWarnung }, + ); + revalidatePath("/verwaltung/profil"); + return { ok: true, geocodeWarnung }; +} diff --git a/src/server/actions/equipment.ts b/src/server/actions/equipment.ts new file mode 100644 index 0000000..d5d7b79 --- /dev/null +++ b/src/server/actions/equipment.ts @@ -0,0 +1,197 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { notFound } from "next/navigation"; +import { db } from "@/db"; +import { equipment } from "@/db/schema"; +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { writeAudit } from "@/lib/audit"; +import { + equipmentCreateSchema, + equipmentUpdateSchema, + equipmentStatusSchema, + equipmentIdSchema, +} from "@/lib/validation/equipment"; +import { buildMerkmalValuesSchema } from "@/lib/validation/vehicle"; +import { getMerkmaleForCategory } from "@/server/data/merkmale"; +import { + getEquipmentForBrigade, +} from "@/server/data/equipment"; +import { vehicleBelongsToBrigade } from "@/server/data/vehicles"; +import { upsertMerkmalValues } from "@/server/merkmale/upsertValues"; +import type { MerkmalDefinition } from "@/lib/merkmale/types"; + +export type ActionResult = + | { ok: true; id: string } + | { ok: false; error: string }; + +/** + * Liefert die Merkmal-Definitionen einer Geräte-Kategorie (für die + * Editor-Vorbefüllung). Guard zuerst (default-deny, auch für Lesen). + */ +export async function getCategoryMerkmaleAction( + categoryId: string, +): Promise { + await requireWehrAdmin(); + if (!categoryId) return []; + return getMerkmaleForCategory(categoryId); +} + +/** + * Prüft, dass eine optional gewählte `vehicleId` zu EINEM Fahrzeug DERSELBEN + * Wehr gehört. `undefined` => „im Gerätehaus" (zulässig). Verhindert die + * Zuordnung zu fremden Fahrzeugen (Scoping). + */ +async function assertVehicleScope( + vehicleId: string | undefined, + brigadeId: string, +): Promise { + if (!vehicleId) return true; + return vehicleBelongsToBrigade(vehicleId, brigadeId); +} + +/** Legt ein Gerät der eigenen Wehr an (Guard zuerst, Audit equipment.create). */ +export async function createEquipment( + input: unknown, + rawWerte: unknown, +): Promise { + const s = await requireWehrAdmin(); + const parsed = equipmentCreateSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + const d = parsed.data; + if (!(await assertVehicleScope(d.vehicleId, s.user.brigadeId))) { + return { ok: false, error: "Fahrzeug nicht gefunden." }; + } + const defs = await getMerkmaleForCategory(d.categoryId); + const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []); + if (!werteParsed.success) { + return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." }; + } + + const id = await db.transaction(async (tx) => { + const [e] = await tx + .insert(equipment) + .values({ + brigadeId: s.user.brigadeId, + categoryId: d.categoryId, + vehicleId: d.vehicleId ?? null, + name: d.name, + }) + .returning({ id: equipment.id }); + if (!e) throw new Error("Gerät konnte nicht angelegt werden."); + await upsertMerkmalValues(tx, "equipment", e.id, werteParsed.data); + await writeAudit( + s.user.id, + "equipment.create", + "equipment", + e.id, + { categoryId: d.categoryId, zugeordnet: d.vehicleId != null }, + tx, + ); + return e.id; + }); + + revalidatePath("/verwaltung/geraete"); + return { ok: true, id }; +} + +/** Bearbeitet ein Gerät, NUR wenn es der eigenen Wehr gehört. */ +export async function updateEquipment( + input: unknown, + rawWerte: unknown, +): Promise { + const s = await requireWehrAdmin(); + const parsed = equipmentUpdateSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + const d = parsed.data; + const existing = await getEquipmentForBrigade(d.id, s.user.brigadeId); + if (!existing) return { ok: false, error: "Gerät nicht gefunden." }; + if (!(await assertVehicleScope(d.vehicleId, s.user.brigadeId))) { + return { ok: false, error: "Fahrzeug nicht gefunden." }; + } + const defs = await getMerkmaleForCategory(d.categoryId); + const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []); + if (!werteParsed.success) { + return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." }; + } + + await db.transaction(async (tx) => { + await tx + .update(equipment) + .set({ + name: d.name, + categoryId: d.categoryId, + vehicleId: d.vehicleId ?? null, + }) + .where( + and(eq(equipment.id, d.id), eq(equipment.brigadeId, s.user.brigadeId)), + ); + await upsertMerkmalValues(tx, "equipment", d.id, werteParsed.data); + await writeAudit(s.user.id, "equipment.update", "equipment", d.id, undefined, tx); + }); + + revalidatePath("/verwaltung/geraete"); + revalidatePath(`/verwaltung/geraete/${d.id}`); + return { ok: true, id: d.id }; +} + +/** Setzt den Status eines eigenen Geräts (Audit equipment.status). */ +export async function setEquipmentStatus(input: unknown): Promise { + const s = await requireWehrAdmin(); + const parsed = equipmentStatusSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: "Ungültige Eingabe." }; + const existing = await getEquipmentForBrigade(parsed.data.id, s.user.brigadeId); + if (!existing) return { ok: false, error: "Gerät nicht gefunden." }; + + await db.transaction(async (tx) => { + await tx + .update(equipment) + .set({ status: parsed.data.status }) + .where( + and( + eq(equipment.id, parsed.data.id), + eq(equipment.brigadeId, s.user.brigadeId), + ), + ); + await writeAudit( + s.user.id, + "equipment.status", + "equipment", + parsed.data.id, + { status: parsed.data.status }, + tx, + ); + }); + revalidatePath("/verwaltung/geraete"); + return { ok: true, id: parsed.data.id }; +} + +/** Löscht ein eigenes Gerät (Audit equipment.delete). */ +export async function deleteEquipment(input: unknown): Promise { + const s = await requireWehrAdmin(); + const parsed = equipmentIdSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: "Ungültige ID." }; + const existing = await getEquipmentForBrigade(parsed.data.id, s.user.brigadeId); + if (!existing) { + notFound(); + } + + await db.transaction(async (tx) => { + await tx + .delete(equipment) + .where( + and( + eq(equipment.id, parsed.data.id), + eq(equipment.brigadeId, s.user.brigadeId), + ), + ); + await writeAudit(s.user.id, "equipment.delete", "equipment", parsed.data.id, undefined, tx); + }); + revalidatePath("/verwaltung/geraete"); + return { ok: true, id: parsed.data.id }; +} diff --git a/src/server/actions/vehicles.ts b/src/server/actions/vehicles.ts new file mode 100644 index 0000000..4b75d15 --- /dev/null +++ b/src/server/actions/vehicles.ts @@ -0,0 +1,191 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { notFound } from "next/navigation"; +import { db } from "@/db"; +import { vehicles } from "@/db/schema"; +import { requireWehrAdmin } from "@/lib/auth/guards"; +import { writeAudit } from "@/lib/audit"; +import { + vehicleCreateSchema, + vehicleUpdateSchema, + vehicleStatusSchema, + vehicleIdSchema, + buildMerkmalValuesSchema, +} from "@/lib/validation/vehicle"; +import { + getMerkmaleForTemplate, +} from "@/server/data/merkmale"; +import { getVehicleForBrigade } from "@/server/data/vehicles"; +import { upsertMerkmalValues } from "@/server/merkmale/upsertValues"; +import type { MerkmalDefinition } from "@/lib/merkmale/types"; + +export type ActionResult = + | { ok: true; id: string } + | { ok: false; error: string }; + +/** + * Löst die für ein Fahrzeug erlaubten Merkmal-Definitionen serverseitig auf + * (NUR aus der Vorlage; ohne Vorlage keine Merkmale). Damit kann der Client + * keine fremden Merkmale schmuggeln — die Validierung baut ihr Schema NUR aus + * diesen Definitionen. + */ +async function vehicleMerkmalDefs( + templateId: string | undefined, +): Promise { + if (!templateId) return []; + return getMerkmaleForTemplate(templateId); +} + +/** + * Liefert die Merkmal-Definitionen einer Vorlage (für die Vorbefüllung des + * Editors im Anlage-Formular). Guard zuerst (default-deny), auch für Lesen. + */ +export async function getTemplateMerkmaleAction( + templateId: string, +): Promise { + await requireWehrAdmin(); + if (!templateId) return []; + return getMerkmaleForTemplate(templateId); +} + +/** Legt ein Fahrzeug der EIGENEN Wehr an (Guard zuerst, Audit vehicle.create). */ +export async function createVehicle( + input: unknown, + rawWerte: unknown, +): Promise { + const s = await requireWehrAdmin(); + const parsed = vehicleCreateSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + const d = parsed.data; + const defs = await vehicleMerkmalDefs(d.templateId); + const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []); + if (!werteParsed.success) { + return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." }; + } + + const id = await db.transaction(async (tx) => { + const [v] = await tx + .insert(vehicles) + .values({ + brigadeId: s.user.brigadeId, + templateId: d.templateId ?? null, + name: d.name, + funkrufname: d.funkrufname ?? null, + notiz: d.notiz ?? null, + }) + .returning({ id: vehicles.id }); + if (!v) throw new Error("Fahrzeug konnte nicht angelegt werden."); + await upsertMerkmalValues(tx, "vehicle", v.id, werteParsed.data); + await writeAudit( + s.user.id, + "vehicle.create", + "vehicle", + v.id, + { templateId: d.templateId ?? null }, + tx, + ); + return v.id; + }); + + revalidatePath("/verwaltung/fahrzeuge"); + return { ok: true, id }; +} + +/** Bearbeitet ein Fahrzeug, NUR wenn es der eigenen Wehr gehört. */ +export async function updateVehicle( + input: unknown, + rawWerte: unknown, +): Promise { + const s = await requireWehrAdmin(); + const parsed = vehicleUpdateSchema.safeParse(input); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." }; + } + const d = parsed.data; + const existing = await getVehicleForBrigade(d.id, s.user.brigadeId); + if (!existing) return { ok: false, error: "Fahrzeug nicht gefunden." }; + + // Vorlage ist nach Anlage fix: erlaubte Merkmale aus der GESPEICHERTEN Vorlage. + const defs = await vehicleMerkmalDefs(existing.templateId ?? undefined); + const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []); + if (!werteParsed.success) { + return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." }; + } + + await db.transaction(async (tx) => { + await tx + .update(vehicles) + .set({ + name: d.name, + funkrufname: d.funkrufname ?? null, + notiz: d.notiz ?? null, + }) + .where(and(eq(vehicles.id, d.id), eq(vehicles.brigadeId, s.user.brigadeId))); + await upsertMerkmalValues(tx, "vehicle", d.id, werteParsed.data); + await writeAudit(s.user.id, "vehicle.update", "vehicle", d.id, undefined, tx); + }); + + revalidatePath("/verwaltung/fahrzeuge"); + revalidatePath(`/verwaltung/fahrzeuge/${d.id}`); + return { ok: true, id: d.id }; +} + +/** Setzt den Status eines eigenen Fahrzeugs (Audit vehicle.status). */ +export async function setVehicleStatus(input: unknown): Promise { + const s = await requireWehrAdmin(); + const parsed = vehicleStatusSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: "Ungültige Eingabe." }; + const existing = await getVehicleForBrigade(parsed.data.id, s.user.brigadeId); + if (!existing) return { ok: false, error: "Fahrzeug nicht gefunden." }; + + await db.transaction(async (tx) => { + await tx + .update(vehicles) + .set({ status: parsed.data.status }) + .where( + and( + eq(vehicles.id, parsed.data.id), + eq(vehicles.brigadeId, s.user.brigadeId), + ), + ); + await writeAudit( + s.user.id, + "vehicle.status", + "vehicle", + parsed.data.id, + { status: parsed.data.status }, + tx, + ); + }); + revalidatePath("/verwaltung/fahrzeuge"); + return { ok: true, id: parsed.data.id }; +} + +/** Löscht ein eigenes Fahrzeug (Audit vehicle.delete). */ +export async function deleteVehicle(input: unknown): Promise { + const s = await requireWehrAdmin(); + const parsed = vehicleIdSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: "Ungültige ID." }; + const existing = await getVehicleForBrigade(parsed.data.id, s.user.brigadeId); + if (!existing) { + notFound(); + } + + await db.transaction(async (tx) => { + await tx + .delete(vehicles) + .where( + and( + eq(vehicles.id, parsed.data.id), + eq(vehicles.brigadeId, s.user.brigadeId), + ), + ); + await writeAudit(s.user.id, "vehicle.delete", "vehicle", parsed.data.id, undefined, tx); + }); + revalidatePath("/verwaltung/fahrzeuge"); + return { ok: true, id: parsed.data.id }; +} diff --git a/src/server/data/brigade-users.ts b/src/server/data/brigade-users.ts new file mode 100644 index 0000000..2a5a714 --- /dev/null +++ b/src/server/data/brigade-users.ts @@ -0,0 +1,34 @@ +import { asc, eq } from "drizzle-orm"; +import { db } from "@/db"; +import { users } from "@/db/schema"; + +/** + * Lesehelfer für die Benutzer EINER Wehr (Workstream 7). Scope kommt aus der + * Session. Passwort-Hashes werden NIE selektiert (kein Daten-Leak). + */ + +export interface BrigadeUserListItem { + id: string; + name: string; + email: string; + rolle: (typeof users.$inferSelect)["rolle"]; + authTyp: (typeof users.$inferSelect)["authTyp"]; + aktiv: boolean; +} + +export async function listUsersForBrigade( + brigadeId: string, +): Promise { + return db + .select({ + id: users.id, + name: users.name, + email: users.email, + rolle: users.rolle, + authTyp: users.authTyp, + aktiv: users.aktiv, + }) + .from(users) + .where(eq(users.brigadeId, brigadeId)) + .orderBy(asc(users.name)); +} diff --git a/src/server/data/equipment.ts b/src/server/data/equipment.ts new file mode 100644 index 0000000..22232d7 --- /dev/null +++ b/src/server/data/equipment.ts @@ -0,0 +1,69 @@ +import { and, asc, desc, eq } from "drizzle-orm"; +import { db } from "@/db"; +import { + equipment, + equipmentCategories, + vehicles, +} from "@/db/schema"; + +/** + * Lesehelfer für Geräte des Wehr-Bereichs (Workstream 7). Alle Helfer sind auf + * eine `brigadeId` beschränkt (Scope aus Session). `vehicleId IS NULL` = + * „im Gerätehaus". + */ + +export interface EquipmentListItem { + id: string; + name: string; + status: (typeof equipment.$inferSelect)["status"]; + categoryName: string; + vehicleId: string | null; + vehicleName: string | null; +} + +/** Liste der Geräte EINER Wehr mit Kategorie- und Zuordnungsnamen. */ +export async function listEquipmentForBrigade( + brigadeId: string, +): Promise { + return db + .select({ + id: equipment.id, + name: equipment.name, + status: equipment.status, + categoryName: equipmentCategories.name, + vehicleId: equipment.vehicleId, + vehicleName: vehicles.name, + }) + .from(equipment) + .innerJoin( + equipmentCategories, + eq(equipmentCategories.id, equipment.categoryId), + ) + .leftJoin(vehicles, eq(vehicles.id, equipment.vehicleId)) + .where(eq(equipment.brigadeId, brigadeId)) + .orderBy(desc(equipment.erstelltAm)); +} + +/** Lädt EIN Gerät scoped auf die Wehr (sonst `null` -> notFound). */ +export async function getEquipmentForBrigade( + equipmentId: string, + brigadeId: string, +): Promise { + const [e] = await db + .select() + .from(equipment) + .where( + and(eq(equipment.id, equipmentId), eq(equipment.brigadeId, brigadeId)), + ); + return e ?? null; +} + +/** Alle Geräte-Kategorien, geordnet — für die Auswahl im Formular. */ +export async function listCategories(): Promise< + { id: string; name: string }[] +> { + return db + .select({ id: equipmentCategories.id, name: equipmentCategories.name }) + .from(equipmentCategories) + .orderBy(asc(equipmentCategories.reihenfolge), asc(equipmentCategories.name)); +} diff --git a/src/server/data/merkmale.ts b/src/server/data/merkmale.ts new file mode 100644 index 0000000..8667106 --- /dev/null +++ b/src/server/data/merkmale.ts @@ -0,0 +1,149 @@ +import { and, asc, eq } from "drizzle-orm"; +import { db } from "@/db"; +import { + merkmale, + merkmalOptionen, + merkmalValues, + vehicleTemplateMerkmale, + equipmentCategoryMerkmale, +} from "@/db/schema"; +import type { + MerkmalDefinition, + MerkmalOption, + MerkmalValueInput, +} from "@/lib/merkmale/types"; + +/** + * Lesehelfer für Merkmal-Definitionen (Workstream 7). Lösen die Vorgabewerte aus + * den DREI typisierten Spalten (`vorgabewert_num/_text/_bool`) und laden je + * enum-Merkmal die Optionen. Genutzt von Fahrzeug-/Geräteformularen, um den + * typisierten Editor vorzubefüllen. + */ + +async function optionenFor( + merkmalIds: string[], +): Promise> { + const map = new Map(); + if (merkmalIds.length === 0) return map; + const rows = await db + .select({ + merkmalId: merkmalOptionen.merkmalId, + wert: merkmalOptionen.wert, + label: merkmalOptionen.label, + reihenfolge: merkmalOptionen.reihenfolge, + }) + .from(merkmalOptionen) + .orderBy(asc(merkmalOptionen.reihenfolge), asc(merkmalOptionen.label)); + for (const r of rows) { + if (!merkmalIds.includes(r.merkmalId)) continue; + const list = map.get(r.merkmalId) ?? []; + list.push({ wert: r.wert, label: r.label }); + map.set(r.merkmalId, list); + } + return map; +} + +/** + * Liefert die Pflicht-/Vorgabemerkmale einer Fahrzeug-Vorlage, geordnet. + * Vorgabewerte werden typgerecht aus drei Spalten gelesen. + */ +export async function getMerkmaleForTemplate( + templateId: string, +): Promise { + const rows = await db + .select({ + merkmalId: merkmale.id, + name: merkmale.name, + typ: merkmale.typ, + einheit: merkmale.einheit, + pflicht: vehicleTemplateMerkmale.pflicht, + reihenfolge: vehicleTemplateMerkmale.reihenfolge, + vorgabeNum: vehicleTemplateMerkmale.vorgabewertNum, + vorgabeText: vehicleTemplateMerkmale.vorgabewertText, + vorgabeBool: vehicleTemplateMerkmale.vorgabewertBool, + }) + .from(vehicleTemplateMerkmale) + .innerJoin(merkmale, eq(merkmale.id, vehicleTemplateMerkmale.merkmalId)) + .where(eq(vehicleTemplateMerkmale.templateId, templateId)) + .orderBy(asc(vehicleTemplateMerkmale.reihenfolge), asc(merkmale.name)); + + const opts = await optionenFor(rows.map((r) => r.merkmalId)); + return rows.map((r) => ({ + merkmalId: r.merkmalId, + name: r.name, + typ: r.typ, + einheit: r.einheit, + pflicht: r.pflicht, + reihenfolge: r.reihenfolge, + optionen: opts.get(r.merkmalId) ?? [], + vorgabeNum: r.vorgabeNum, + vorgabeText: r.vorgabeText, + vorgabeBool: r.vorgabeBool, + })); +} + +/** + * Liefert die Merkmale einer Geräte-Kategorie, geordnet. Kategorien tragen + * keine Vorgabewerte/Pflicht — beide bleiben neutral (`pflicht=false`). + */ +export async function getMerkmaleForCategory( + categoryId: string, +): Promise { + const rows = await db + .select({ + merkmalId: merkmale.id, + name: merkmale.name, + typ: merkmale.typ, + einheit: merkmale.einheit, + reihenfolge: equipmentCategoryMerkmale.reihenfolge, + }) + .from(equipmentCategoryMerkmale) + .innerJoin(merkmale, eq(merkmale.id, equipmentCategoryMerkmale.merkmalId)) + .where(eq(equipmentCategoryMerkmale.categoryId, categoryId)) + .orderBy(asc(equipmentCategoryMerkmale.reihenfolge), asc(merkmale.name)); + + const opts = await optionenFor(rows.map((r) => r.merkmalId)); + return rows.map((r) => ({ + merkmalId: r.merkmalId, + name: r.name, + typ: r.typ, + einheit: r.einheit, + pflicht: false, + reihenfolge: r.reihenfolge, + optionen: opts.get(r.merkmalId) ?? [], + vorgabeNum: null, + vorgabeText: null, + vorgabeBool: null, + })); +} + +/** + * Liest die aktuell gespeicherten Merkmal-Werte einer Entity (für die + * Bearbeiten-Ansicht). Liefert `MerkmalValueInput[]` (genau eine Wertspalte je + * Eintrag gesetzt). + */ +export async function getMerkmalValuesForEntity( + entityTyp: "vehicle" | "equipment", + entityId: string, +): Promise { + const rows = await db + .select({ + merkmalId: merkmalValues.merkmalId, + num: merkmalValues.valueNum, + text: merkmalValues.valueText, + bool: merkmalValues.valueBool, + }) + .from(merkmalValues) + .where( + and( + eq(merkmalValues.entityTyp, entityTyp), + eq(merkmalValues.entityId, entityId), + ), + ); + return rows.map((r) => ({ + merkmalId: r.merkmalId, + num: r.num, + text: r.text, + bool: r.bool, + })); +} diff --git a/src/server/data/vehicles.ts b/src/server/data/vehicles.ts new file mode 100644 index 0000000..5a8b118 --- /dev/null +++ b/src/server/data/vehicles.ts @@ -0,0 +1,88 @@ +import { and, asc, desc, eq } from "drizzle-orm"; +import { db } from "@/db"; +import { + vehicles, + vehicleTemplates, + brigades, +} from "@/db/schema"; + +/** + * Lesehelfer für Fahrzeuge des Wehr-Bereichs (Workstream 7). ALLE Helfer sind + * auf eine `brigadeId` beschränkt (Scope kommt aus der Session, nie aus Input). + */ + +export interface VehicleListItem { + id: string; + name: string; + funkrufname: string | null; + status: (typeof vehicles.$inferSelect)["status"]; + templateName: string | null; +} + +/** Liste der Fahrzeuge EINER Wehr, neueste zuerst, mit Vorlagenname. */ +export async function listVehiclesForBrigade( + brigadeId: string, +): Promise { + return db + .select({ + id: vehicles.id, + name: vehicles.name, + funkrufname: vehicles.funkrufname, + status: vehicles.status, + templateName: vehicleTemplates.name, + }) + .from(vehicles) + .leftJoin(vehicleTemplates, eq(vehicleTemplates.id, vehicles.templateId)) + .where(eq(vehicles.brigadeId, brigadeId)) + .orderBy(desc(vehicles.erstelltAm)); +} + +/** + * Lädt EIN Fahrzeug, aber nur wenn es zur angegebenen Wehr gehört. Liefert + * `null`, wenn es nicht existiert ODER einer fremden Wehr gehört (Scoping: + * der Aufrufer reagiert mit `notFound()`). + */ +export async function getVehicleForBrigade( + vehicleId: string, + brigadeId: string, +): Promise { + const [v] = await db + .select() + .from(vehicles) + .where(and(eq(vehicles.id, vehicleId), eq(vehicles.brigadeId, brigadeId))); + return v ?? null; +} + +/** Prüft (scoped), ob ein Fahrzeug zur Wehr gehört — für Geräte-Zuordnung. */ +export async function vehicleBelongsToBrigade( + vehicleId: string, + brigadeId: string, +): Promise { + const v = await getVehicleForBrigade(vehicleId, brigadeId); + return v !== null; +} + +/** Alle Fahrzeug-Vorlagen, geordnet — für den Vorlagen-Picker. */ +export async function listTemplates(): Promise< + { id: string; code: string; name: string }[] +> { + return db + .select({ + id: vehicleTemplates.id, + code: vehicleTemplates.code, + name: vehicleTemplates.name, + }) + .from(vehicleTemplates) + .orderBy(asc(vehicleTemplates.reihenfolge), asc(vehicleTemplates.name)); +} + +/** Stammdaten der eigenen Wehr (für die Profilseite). */ +export async function getBrigade( + brigadeId: string, +): Promise { + const [b] = await db + .select() + .from(brigades) + .where(eq(brigades.id, brigadeId)); + return b ?? null; +} diff --git a/src/server/merkmale/__tests__/upsertValues.test.ts b/src/server/merkmale/__tests__/upsertValues.test.ts new file mode 100644 index 0000000..1c58e05 --- /dev/null +++ b/src/server/merkmale/__tests__/upsertValues.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from "vitest"; +import { upsertMerkmalValues } from "../upsertValues"; +import type { MerkmalValueInput } from "@/lib/merkmale/types"; + +/** + * Test-Double für die Drizzle-Transaktion. Es zeichnet nur auf, wie oft + * delete/insert aufgerufen wurden — kein echtes Postgres. `upsertMerkmalValues` + * ruft pro Wert genau ein `delete(...).where(...)` und (bei nicht-leerem Wert) + * ein `insert(...).values(...)`. + */ +function makeFakeTx() { + const deletes: unknown[] = []; + const inserts: Record[] = []; + const tx = { + delete: vi.fn(() => ({ + where: vi.fn(async (cond: unknown) => { + deletes.push(cond); + }), + })), + insert: vi.fn(() => ({ + values: vi.fn(async (v: Record) => { + inserts.push(v); + }), + })), + }; + return { tx, deletes, inserts }; +} + +const MID = "11111111-1111-1111-1111-111111111111"; + +describe("upsertMerkmalValues", () => { + it("löscht alten Wert und fügt neuen ein (delete-then-insert)", async () => { + const { tx, deletes, inserts } = makeFakeTx(); + const werte: MerkmalValueInput[] = [{ merkmalId: MID, num: 2000 }]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await upsertMerkmalValues(tx as any, "vehicle", "veh-1", werte); + expect(deletes).toHaveLength(1); + expect(inserts).toHaveLength(1); + expect(inserts[0]).toMatchObject({ + merkmalId: MID, + entityTyp: "vehicle", + entityId: "veh-1", + valueNum: 2000, + valueText: null, + valueBool: null, + }); + }); + + it("schreibt bei leerem Wert nur delete, kein insert", async () => { + const { tx, deletes, inserts } = makeFakeTx(); + const werte: MerkmalValueInput[] = [ + { merkmalId: MID, num: null, text: "", bool: null }, + ]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await upsertMerkmalValues(tx as any, "vehicle", "veh-1", werte); + expect(deletes).toHaveLength(1); + expect(inserts).toHaveLength(0); + }); + + it("schreibt boolean false als Wert (nicht als leer behandelt)", async () => { + const { tx, inserts } = makeFakeTx(); + const werte: MerkmalValueInput[] = [{ merkmalId: MID, bool: false }]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await upsertMerkmalValues(tx as any, "equipment", "eq-1", werte); + expect(inserts).toHaveLength(1); + expect(inserts[0]).toMatchObject({ valueBool: false, valueNum: null, valueText: null }); + }); +}); diff --git a/src/server/merkmale/upsertValues.ts b/src/server/merkmale/upsertValues.ts new file mode 100644 index 0000000..56db798 --- /dev/null +++ b/src/server/merkmale/upsertValues.ts @@ -0,0 +1,50 @@ +import { and, eq } from "drizzle-orm"; +import { merkmalValues } from "@/db/schema"; +import type { Tx } from "@/lib/audit"; +import type { MerkmalValueInput } from "@/lib/merkmale/types"; + +/** + * Schreibt die Merkmal-Werte einer Entity (Fahrzeug/Gerät) in `merkmal_values` + * per delete-then-insert: pro Merkmal wird der bestehende Wert gelöscht und — + * sofern nicht leer — neu eingefügt. So bleibt pro (entity, merkmal) genau eine + * Zeile; ein geleerter Wert hinterlässt KEINE Zeile. + * + * Läuft IMMER innerhalb der aufrufenden Transaktion (`Tx` aus @/lib/audit, + * kein `any` — Querschnittsstandard 12), damit Insert/Audit atomar sind. + * + * `boolean false` und `num 0` gelten als gesetzt; nur `null`/leerer String + * zählen als leer. + */ +export async function upsertMerkmalValues( + tx: Tx, + entityTyp: "vehicle" | "equipment", + entityId: string, + werte: MerkmalValueInput[], +): Promise { + for (const w of werte) { + await tx + .delete(merkmalValues) + .where( + and( + eq(merkmalValues.entityTyp, entityTyp), + eq(merkmalValues.entityId, entityId), + eq(merkmalValues.merkmalId, w.merkmalId), + ), + ); + + const empty = + w.num == null && + (w.text == null || w.text === "") && + w.bool == null; + if (empty) continue; + + await tx.insert(merkmalValues).values({ + merkmalId: w.merkmalId, + entityTyp, + entityId, + valueNum: w.num ?? null, + valueText: w.text ?? null, + valueBool: w.bool ?? null, + }); + } +} diff --git a/tests/e2e/verwaltung-fuhrpark.spec.ts b/tests/e2e/verwaltung-fuhrpark.spec.ts new file mode 100644 index 0000000..fd62307 --- /dev/null +++ b/tests/e2e/verwaltung-fuhrpark.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "@playwright/test"; + +/** + * Happy-Path des Wehr-Bereichs: Fuhrpark, Geräte, Profil, Benutzer + * (Workstream 7). + * + * NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet einen + * angemeldeten `wehr_admin` (Fixture aus dem Test-Workstream) und befüllte + * Taxonomie (Seed/Admin). + */ + +test.describe("Verwaltung: Fuhrpark & Benutzer (Happy-Path)", () => { + test.skip( + !process.env.E2E_WEHR_ADMIN_STATE, + "benötigt wehr_admin-Fixture (Test-Workstream)", + ); + test.use({ + storageState: process.env.E2E_WEHR_ADMIN_STATE ?? { cookies: [], origins: [] }, + }); + + test("Fahrzeug per Vorlage anlegen befüllt typisierte Merkmale", async ({ + page, + }) => { + await page.goto("/verwaltung/fahrzeuge/neu"); + await page.getByLabel("Fahrzeug-Vorlage").selectOption({ label: /HLF 2/ }); + // Vorlagen-Merkmale werden nachgeladen (Löschwassertank, Feuerlöschpumpe …). + await expect(page.getByText("Löschwassertank")).toBeVisible(); + await page.getByLabel("Name").fill("HLF 2 Musterdorf"); + await page.getByRole("button", { name: "Speichern" }).click(); + await expect(page).toHaveURL(/\/verwaltung\/fahrzeuge$/); + await expect(page.getByText("HLF 2 Musterdorf")).toBeVisible(); + }); + + test("Gerät 'im Gerätehaus' anlegen (keine Fahrzeug-Zuordnung)", async ({ + page, + }) => { + await page.goto("/verwaltung/geraete/neu"); + await page.getByLabel("Name").fill("Tragkraftspritze 1"); + await page.getByLabel("Kategorie").selectOption({ index: 1 }); + // Zuordnung bleibt auf 'im Gerätehaus'. + await page.getByRole("button", { name: "Speichern" }).click(); + await expect(page).toHaveURL(/\/verwaltung\/geraete$/); + await expect(page.getByText("im Gerätehaus").first()).toBeVisible(); + }); + + test("Profil speichern zeigt Bestätigung", async ({ page }) => { + await page.goto("/verwaltung/profil"); + await page.getByLabel("Straße").fill("Hauptstraße 1"); + await page.getByLabel("PLZ").fill("3100"); + await page.getByLabel("Ort").fill("St. Pölten"); + await page.getByRole("button", { name: "Speichern" }).click(); + await expect(page.getByText(/gespeichert|geokodiert|geokodiert werden/)).toBeVisible(); + }); + + test("Benutzer anlegen zeigt Einmal-Passwort", async ({ page }) => { + await page.goto("/verwaltung/benutzer"); + await page.getByLabel("Name").fill("Neue Person"); + await page.getByLabel("E-Mail").fill("neu@ff-musterdorf.at"); + await page.getByLabel("Rolle").selectOption("wehr_read"); + await page.getByRole("button", { name: "Benutzer anlegen" }).click(); + await expect(page.getByText(/Einmal-Passwort/)).toBeVisible(); + }); +}); diff --git a/tests/e2e/verwaltung-scoping.spec.ts b/tests/e2e/verwaltung-scoping.spec.ts new file mode 100644 index 0000000..4eef234 --- /dev/null +++ b/tests/e2e/verwaltung-scoping.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "@playwright/test"; + +/** + * Scoping & Gating des Wehr-Bereichs (Workstream 7, Querschnittsstandard 1–3, + * default-deny dreifach). + * + * NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet: + * - anonym: jede /verwaltung/*-Seite -> Redirect auf /login. + * - wehr_read: jede /verwaltung/*-Seite -> 403 (forbidden()). + * - wehr_admin: erreichbar; fremdes Fahrzeug/Gerät -> 404 (notFound). + * + * Negativ-Probe: Entfernen von `requireWehrAdmin()` aus (app)/verwaltung/ + * layout.tsx ODER aus einer Server-Action muss diese Suite rot machen. + */ + +const VERWALTUNG_PAGES = [ + "/verwaltung/profil", + "/verwaltung/fahrzeuge", + "/verwaltung/fahrzeuge/neu", + "/verwaltung/geraete", + "/verwaltung/geraete/neu", + "/verwaltung/benutzer", +]; + +test.describe("Verwaltung: anonym -> Redirect auf /login", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + for (const path of VERWALTUNG_PAGES) { + test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({ + page, + }) => { + await page.goto(path); + await expect(page).toHaveURL(/\/login/); + }); + } +}); + +test.describe("Verwaltung: wehr_read -> 403", () => { + test.skip( + !process.env.E2E_WEHR_READ_STATE, + "benötigt wehr_read-Fixture (Test-Workstream)", + ); + test.use({ + storageState: process.env.E2E_WEHR_READ_STATE ?? { cookies: [], origins: [] }, + }); + for (const path of VERWALTUNG_PAGES) { + test(`wehr_read-Aufruf von ${path} -> 403`, async ({ page }) => { + const res = await page.goto(path); + expect(res?.status()).toBe(403); + }); + } +}); + +test.describe("Verwaltung: fremde Wehr -> 404 (Scoping)", () => { + test.skip( + !process.env.E2E_WEHR_ADMIN_STATE || !process.env.E2E_FOREIGN_VEHICLE_ID, + "benötigt wehr_admin-Fixture + fremde Fahrzeug-ID (Test-Workstream)", + ); + test.use({ + storageState: process.env.E2E_WEHR_ADMIN_STATE ?? { cookies: [], origins: [] }, + }); + test("Aufruf eines fremden Fahrzeugs liefert 404", async ({ page }) => { + const res = await page.goto( + `/verwaltung/fahrzeuge/${process.env.E2E_FOREIGN_VEHICLE_ID}`, + ); + expect(res?.status()).toBe(404); + }); +});