Workstream 7: Wehr-Bereich — Fuhrpark & Benutzer (Phase 4)

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) <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-06-09 11:06:17 +02:00
parent 628d35bfcd
commit 5cda09c411
39 changed files with 3201 additions and 0 deletions

View File

@@ -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<string, string> = {
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 (
<div className="space-y-6">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.navBenutzer}
</h1>
<BrigadeUserForm />
{users.length === 0 ? (
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
{de.verwaltung.keineBenutzer}
</p>
) : (
<ul className="divide-y divide-rand rounded border border-rand bg-white">
{users.map((u) => (
<li
key={u.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<p className="font-medium text-anthrazit">
{u.name}
{u.id === s.user.id ? (
<span className="ml-2 text-xs text-anthrazit/50">(Sie)</span>
) : null}
</p>
<p className="truncate text-sm text-anthrazit/60">{u.email}</p>
</div>
<div className="flex items-center gap-2">
<Badge>{ROLLE_LABEL[u.rolle] ?? u.rolle}</Badge>
<Badge>
{u.authTyp === "local"
? de.verwaltung.authLokal
: de.verwaltung.authAuthentik}
</Badge>
<Badge
className={
u.aktiv
? "border-bereit/30 bg-bereit/10 text-bereit"
: "border-anthrazit/30 bg-anthrazit/10 text-anthrazit"
}
>
{u.aktiv ? de.verwaltung.aktiv : de.verwaltung.inaktiv}
</Badge>
{u.aktiv ? (
<DeactivateUserButton
userId={u.id}
disabled={u.id === s.user.id}
/>
) : null}
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -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<Status, string> = {
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<string | null>(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 (
<div className="flex flex-wrap items-center gap-3 rounded border border-rand bg-white p-4">
<label className="text-sm font-medium text-anthrazit" htmlFor="vstatus">
{de.verwaltung.status}
</label>
<select
id="vstatus"
className="h-9 rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={status}
disabled={pending}
onChange={(e) => onStatus(e.target.value as Status)}
>
{assetStatusEnum.enumValues.map((v) => (
<option key={v} value={v}>
{STATUS_LABEL[v]}
</option>
))}
</select>
<span className="flex-1" />
<Button
type="button"
variant="outline"
size="sm"
disabled={pending}
onClick={onDelete}
>
{de.verwaltung.loeschen}
</Button>
{error ? <span className="w-full text-sm text-signal">{error}</span> : null}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.fahrzeugBearbeiten}
</h1>
<VehicleControls vehicleId={vehicle.id} status={vehicle.status} />
<VehicleForm
mode="edit"
vehicleId={vehicle.id}
initial={{
name: vehicle.name,
funkrufname: vehicle.funkrufname ?? "",
notiz: vehicle.notiz ?? "",
}}
definitionen={defs}
vorhandeneWerte={werte}
/>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.fahrzeugAnlegen}
</h1>
<VehicleForm
mode="create"
templates={templates}
loadTemplateMerkmale={getTemplateMerkmaleAction}
/>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.navFahrzeuge}
</h1>
<Button asChild>
<Link href="/verwaltung/fahrzeuge/neu">
{de.verwaltung.fahrzeugAnlegen}
</Link>
</Button>
</header>
{items.length === 0 ? (
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
{de.verwaltung.keineFahrzeuge}
</p>
) : (
<ul className="divide-y divide-rand rounded border border-rand bg-white">
{items.map((v) => (
<li
key={v.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<Link
href={`/verwaltung/fahrzeuge/${v.id}`}
className="font-medium text-navy hover:underline"
>
{v.name}
</Link>
<p className="truncate text-sm text-anthrazit/60">
{v.funkrufname ?? de.search.keinFunkrufname}
{v.templateName ? ` · ${v.templateName}` : ""}
</p>
</div>
<StatusBadge status={v.status} />
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -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<Status, string> = {
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<string | null>(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 (
<div className="flex flex-wrap items-center gap-3 rounded border border-rand bg-white p-4">
<label className="text-sm font-medium text-anthrazit" htmlFor="estatus">
{de.verwaltung.status}
</label>
<select
id="estatus"
className="h-9 rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={status}
disabled={pending}
onChange={(e) => onStatus(e.target.value as Status)}
>
{assetStatusEnum.enumValues.map((v) => (
<option key={v} value={v}>
{STATUS_LABEL[v]}
</option>
))}
</select>
<span className="flex-1" />
<Button
type="button"
variant="outline"
size="sm"
disabled={pending}
onClick={onDelete}
>
{de.verwaltung.loeschen}
</Button>
{error ? <span className="w-full text-sm text-signal">{error}</span> : null}
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.geraetBearbeiten}
</h1>
<EquipmentControls equipmentId={item.id} status={item.status} />
<EquipmentForm
mode="edit"
equipmentId={item.id}
categories={categories}
vehicles={vehicles.map((v) => ({ id: v.id, name: v.name }))}
initial={{
name: item.name,
categoryId: item.categoryId,
vehicleId: item.vehicleId ?? "",
}}
definitionen={defs}
vorhandeneWerte={werte}
loadCategoryMerkmale={getCategoryMerkmaleAction}
/>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.geraetAnlegen}
</h1>
<EquipmentForm
mode="create"
categories={categories}
vehicles={vehicles.map((v) => ({ id: v.id, name: v.name }))}
loadCategoryMerkmale={getCategoryMerkmaleAction}
/>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.navGeraete}
</h1>
<Button asChild>
<Link href="/verwaltung/geraete/neu">
{de.verwaltung.geraetAnlegen}
</Link>
</Button>
</header>
{items.length === 0 ? (
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
{de.verwaltung.keineGeraete}
</p>
) : (
<ul className="divide-y divide-rand rounded border border-rand bg-white">
{items.map((e) => (
<li
key={e.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<Link
href={`/verwaltung/geraete/${e.id}`}
className="font-medium text-navy hover:underline"
>
{e.name}
</Link>
<p className="truncate text-sm text-anthrazit/60">
{e.categoryName} ·{" "}
{e.vehicleName ?? de.verwaltung.imGeraetehaus}
</p>
</div>
<div className="flex items-center gap-2">
{e.vehicleId ? null : (
<Badge>{de.verwaltung.imGeraetehaus}</Badge>
)}
<StatusBadge status={e.status} />
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -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 (
<div>
<VerwaltungNav />
<main className="mx-auto max-w-5xl px-6 py-8">{children}</main>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<header>
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.profilTitel}
</h1>
{b ? (
<p className="mt-1 text-sm text-anthrazit/70">{b.name}</p>
) : null}
</header>
<BrigadeProfileForm
initial={{
strasse: b?.strasse ?? "",
plz: b?.plz ?? "",
ort: b?.ort ?? "",
telefon: b?.telefon ?? "",
email: b?.email ?? "",
wehrfuehrer: b?.wehrfuehrer ?? "",
funkrufnameSchema: b?.funkrufnameSchema ?? "",
}}
/>
</div>
);
}

View File

@@ -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<string | null>(null);
const [info, setInfo] = React.useState<{ warnung: boolean } | null>(null);
const [pending, startTransition] = React.useTransition();
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
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 (
<form onSubmit={onSubmit} className="max-w-2xl space-y-5">
<div className="grid gap-1.5 sm:grid-cols-2 sm:gap-4">
<div className="grid gap-1.5 sm:col-span-2">
<Label htmlFor="strasse">{de.verwaltung.strasse}</Label>
<Input id="strasse" name="strasse" required defaultValue={initial.strasse} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="plz">{de.verwaltung.plz}</Label>
<Input id="plz" name="plz" required defaultValue={initial.plz} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="ort">{de.verwaltung.ort}</Label>
<Input id="ort" name="ort" required defaultValue={initial.ort} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="telefon">{de.verwaltung.telefon}</Label>
<Input id="telefon" name="telefon" defaultValue={initial.telefon} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="email">{de.verwaltung.email}</Label>
<Input id="email" name="email" type="email" defaultValue={initial.email} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="wehrfuehrer">{de.verwaltung.wehrfuehrer}</Label>
<Input id="wehrfuehrer" name="wehrfuehrer" defaultValue={initial.wehrfuehrer} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="funkrufnameSchema">
{de.verwaltung.funkrufnameSchema}
</Label>
<Input
id="funkrufnameSchema"
name="funkrufnameSchema"
defaultValue={initial.funkrufnameSchema}
/>
</div>
</div>
{error ? (
<p role="alert" className="text-sm text-signal">
{error}
</p>
) : null}
{info ? (
<p
className={
info.warnung
? "rounded border border-wartung/40 bg-wartung/5 px-3 py-2 text-sm text-wartung"
: "rounded border border-bereit/40 bg-bereit/5 px-3 py-2 text-sm text-anthrazit"
}
>
{info.warnung
? de.verwaltung.geocodeWarnung
: `${de.verwaltung.profilGespeichert} ${de.verwaltung.geocodeOk}`}
</p>
) : null}
<Button type="submit" disabled={pending}>
{de.verwaltung.speichern}
</Button>
</form>
);
}

View File

@@ -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<string | null>(null);
const [tempPassword, setTempPassword] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
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 (
<form
onSubmit={onSubmit}
className="space-y-4 rounded border border-rand bg-white p-5"
>
<div className="grid gap-1.5 sm:grid-cols-2 sm:gap-4">
<div className="grid gap-1.5">
<Label htmlFor="user-name">{de.verwaltung.name}</Label>
<Input id="user-name" name="name" required />
</div>
<div className="grid gap-1.5">
<Label htmlFor="user-email">{de.verwaltung.email}</Label>
<Input id="user-email" name="email" type="email" required />
</div>
<div className="grid gap-1.5">
<Label htmlFor="user-rolle">{de.verwaltung.rolle}</Label>
<select
id="user-rolle"
name="rolle"
defaultValue="wehr_read"
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
>
<option value="wehr_read">{de.verwaltung.rolleRead}</option>
<option value="wehr_admin">{de.verwaltung.rolleAdmin}</option>
</select>
</div>
</div>
{error ? (
<p role="alert" className="text-sm text-signal">
{error}
</p>
) : null}
{tempPassword ? (
<div className="rounded border border-bereit/40 bg-bereit/5 p-4">
<p className="text-sm font-medium text-anthrazit/70">
{de.verwaltung.tempPasswort}
</p>
<code className="mt-1 block rounded border border-rand bg-white px-3 py-2 font-mono text-lg tracking-wide text-navy">
{tempPassword}
</code>
</div>
) : null}
<Button type="submit" disabled={pending}>
{de.verwaltung.benutzerAnlegen}
</Button>
</form>
);
}
/**
* 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<string | null>(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 (
<span className="inline-flex flex-col items-end gap-1">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || pending}
onClick={onClick}
>
{de.verwaltung.deaktivieren}
</Button>
{error ? <span className="text-xs text-signal">{error}</span> : null}
</span>
);
}

View File

@@ -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<MerkmalDefinition[]>;
}
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<MerkmalDefinition[]>;
}
type Props = CreateProps | EditProps;
export function EquipmentForm(props: Props) {
const router = useRouter();
const [error, setError] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
const [categoryId, setCategoryId] = React.useState<string>(
props.mode === "edit" ? props.initial.categoryId : "",
);
const [vehicleId, setVehicleId] = React.useState<string>(
props.mode === "edit" ? props.initial.vehicleId : "",
);
const [defs, setDefs] = React.useState<MerkmalDefinition[]>(
props.mode === "edit" ? props.definitionen : [],
);
const [werte, setWerte] = React.useState<MerkmalWerteState>(
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<HTMLFormElement>) {
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 (
<form onSubmit={onSubmit} className="max-w-2xl space-y-6">
<div className="grid gap-1.5">
<Label htmlFor="name">{de.verwaltung.name}</Label>
<Input id="name" name="name" required defaultValue={initialName} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="categoryId">{de.verwaltung.kategorie}</Label>
<select
id="categoryId"
name="categoryId"
required
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={categoryId}
onChange={(e) => void onCategoryChange(e.target.value)}
>
<option value=""></option>
{props.categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="vehicleId">{de.verwaltung.zuordnung}</Label>
<select
id="vehicleId"
name="vehicleId"
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={vehicleId}
onChange={(e) => setVehicleId(e.target.value)}
>
<option value="">{de.verwaltung.imGeraetehaus}</option>
{props.vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
<fieldset className="rounded border border-rand bg-white p-5">
<legend className="px-1 text-sm font-semibold text-navy">
{de.verwaltung.merkmale}
</legend>
<MerkmalValueEditor
definitionen={defs}
werte={werte}
onChange={setWerte}
/>
</fieldset>
{error ? (
<p role="alert" className="text-sm text-signal">
{error}
</p>
) : null}
<div className="flex gap-2">
<Button type="submit" disabled={pending}>
{de.verwaltung.speichern}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push("/verwaltung/geraete")}
>
{de.verwaltung.abbrechen}
</Button>
</div>
</form>
);
}

View File

@@ -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<string, MerkmalValueInput>;
/**
* 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 (
<p className="text-sm text-anthrazit/60">{de.verwaltung.keineMerkmale}</p>
);
}
function update(merkmalId: string, patch: Partial<MerkmalValueInput>) {
onChange({
...werte,
[merkmalId]: { ...werte[merkmalId], merkmalId, ...patch },
});
}
return (
<div className="space-y-4">
{definitionen.map((d) => {
const v = werte[d.merkmalId] ?? { merkmalId: d.merkmalId };
const fieldId = `merkmal-${d.merkmalId}`;
return (
<div key={d.merkmalId} className="grid gap-1.5">
<Label htmlFor={fieldId}>
{d.name}
{d.einheit ? (
<span className="text-anthrazit/50"> ({d.einheit})</span>
) : null}
{d.pflicht ? (
<span className="ml-1 text-signal" aria-hidden>
*
</span>
) : null}
</Label>
{d.typ === "number" ? (
<Input
id={fieldId}
name={fieldId}
type="number"
inputMode="decimal"
step="any"
required={d.pflicht}
value={v.num ?? ""}
onChange={(e) =>
update(d.merkmalId, {
num: e.target.value === "" ? null : Number(e.target.value),
})
}
/>
) : d.typ === "boolean" ? (
<select
id={fieldId}
name={fieldId}
required={d.pflicht}
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={v.bool == null ? "" : v.bool ? "true" : "false"}
onChange={(e) =>
update(d.merkmalId, {
bool:
e.target.value === ""
? null
: e.target.value === "true",
})
}
>
<option value="">{de.search.egal}</option>
<option value="true">{de.search.ja}</option>
<option value="false">{de.search.nein}</option>
</select>
) : d.typ === "enum" ? (
<select
id={fieldId}
name={fieldId}
required={d.pflicht}
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={v.text ?? ""}
onChange={(e) =>
update(d.merkmalId, {
text: e.target.value === "" ? null : e.target.value,
})
}
>
<option value=""></option>
{d.optionen.map((o) => (
<option key={o.wert} value={o.wert}>
{o.label}
</option>
))}
</select>
) : (
<Input
id={fieldId}
name={fieldId}
type="text"
required={d.pflicht}
value={v.text ?? ""}
onChange={(e) =>
update(d.merkmalId, {
text: e.target.value === "" ? null : e.target.value,
})
}
/>
)}
</div>
);
})}
</div>
);
}
/** Wandelt den Editor-Zustand in die Liste der Server-Eingaben um. */
export function werteToList(state: MerkmalWerteState): MerkmalValueInput[] {
return Object.values(state);
}

View File

@@ -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 (
<div className="grid gap-1.5">
<Label htmlFor="templateId">{de.verwaltung.vorlage}</Label>
<select
id="templateId"
name="templateId"
disabled={disabled}
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy disabled:opacity-50"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{de.verwaltung.keineVorlage}</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.code} {t.name}
</option>
))}
</select>
</div>
);
}

View File

@@ -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<MerkmalDefinition[]>;
}
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<string | null>(null);
const [pending, startTransition] = React.useTransition();
const [templateId, setTemplateId] = React.useState<string>("");
const [defs, setDefs] = React.useState<MerkmalDefinition[]>(
props.mode === "edit" ? props.definitionen : [],
);
const [werte, setWerte] = React.useState<MerkmalWerteState>(
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<HTMLFormElement>) {
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 (
<form onSubmit={onSubmit} className="max-w-2xl space-y-6">
{props.mode === "create" ? (
<TemplatePicker
templates={props.templates}
value={templateId}
onChange={(id) => void onTemplateChange(id)}
/>
) : null}
<div className="grid gap-1.5">
<Label htmlFor="name">{de.verwaltung.name}</Label>
<Input
id="name"
name="name"
required
defaultValue={initial?.name ?? ""}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="funkrufname">{de.verwaltung.funkrufname}</Label>
<Input
id="funkrufname"
name="funkrufname"
defaultValue={initial?.funkrufname ?? ""}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="notiz">{de.verwaltung.notiz}</Label>
<Input id="notiz" name="notiz" defaultValue={initial?.notiz ?? ""} />
</div>
<fieldset className="rounded border border-rand bg-white p-5">
<legend className="px-1 text-sm font-semibold text-navy">
{de.verwaltung.merkmale}
</legend>
<MerkmalValueEditor
definitionen={defs}
werte={werte}
onChange={setWerte}
/>
</fieldset>
{error ? (
<p role="alert" className="text-sm text-signal">
{error}
</p>
) : null}
<div className="flex gap-2">
<Button type="submit" disabled={pending}>
{de.verwaltung.speichern}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push("/verwaltung/fahrzeuge")}
>
{de.verwaltung.abbrechen}
</Button>
</div>
</form>
);
}

View File

@@ -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 (
<nav
aria-label="Wehr-Verwaltungsnavigation"
className="border-b border-rand bg-white"
>
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-1 px-6 py-2">
<span className="mr-4 font-display text-sm font-semibold text-navy">
{de.verwaltung.titel}
</span>
{ITEMS.map((item) => {
const active = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={cn(
"rounded px-3 py-1.5 text-sm font-medium transition-colors",
active
? "bg-navy text-white"
: "text-anthrazit/80 hover:bg-nebel hover:text-anthrazit",
)}
>
{item.label}
</Link>
);
})}
</div>
</nav>
);
}

View File

@@ -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;

49
src/lib/merkmale/types.ts Normal file
View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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>): 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);
});
});

View File

@@ -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<typeof brigadeUserCreateSchema>;
export const brigadeUserDeactivateSchema = z.object({
userId: uuidSchema,
});
export type BrigadeUserDeactivateInput = z.infer<
typeof brigadeUserDeactivateSchema
>;

View File

@@ -38,3 +38,37 @@ export const userResetSchema = z.object({
});
export type UserResetInput = z.infer<typeof userResetSchema>;
/**
* 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<typeof brigadeProfileSchema>;

View File

@@ -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<typeof equipmentBaseSchema>;
export const equipmentCreateSchema = equipmentBaseSchema;
export const equipmentUpdateSchema = z.object({
id: uuidSchema,
...equipmentBaseSchema.shape,
});
export type EquipmentUpdateInput = z.infer<typeof equipmentUpdateSchema>;
export const equipmentStatusSchema = z.object({
id: uuidSchema,
status: assetStatusSchema,
});
export type EquipmentStatusInput = z.infer<typeof equipmentStatusSchema>;
export const equipmentIdSchema = z.object({ id: uuidSchema });

View File

@@ -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<typeof vehicleBaseSchema>;
export const vehicleCreateSchema = vehicleBaseSchema;
export const vehicleUpdateSchema = z.object({
id: uuidSchema,
...vehicleBaseSchema.shape,
});
export type VehicleUpdateInput = z.infer<typeof vehicleUpdateSchema>;
export const vehicleStatusSchema = z.object({
id: uuidSchema,
status: assetStatusSchema,
});
export type VehicleStatusInput = z.infer<typeof vehicleStatusSchema>;
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);
}

View File

@@ -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<CreateUserResult> {
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<DeactivateResult> {
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 };
}

View File

@@ -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<ProfileActionResult> {
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 };
}

View File

@@ -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<MerkmalDefinition[]> {
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<boolean> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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 };
}

View File

@@ -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<MerkmalDefinition[]> {
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<MerkmalDefinition[]> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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 };
}

View File

@@ -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<BrigadeUserListItem[]> {
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));
}

View File

@@ -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<EquipmentListItem[]> {
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<typeof equipment.$inferSelect | null> {
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));
}

149
src/server/data/merkmale.ts Normal file
View File

@@ -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<Map<string, MerkmalOption[]>> {
const map = new Map<string, MerkmalOption[]>();
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<MerkmalDefinition[]> {
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<MerkmalDefinition[]> {
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<MerkmalValueInput[]> {
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,
}));
}

View File

@@ -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<VehicleListItem[]> {
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<typeof vehicles.$inferSelect | null> {
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<boolean> {
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<typeof brigades.$inferSelect | null> {
const [b] = await db
.select()
.from(brigades)
.where(eq(brigades.id, brigadeId));
return b ?? null;
}

View File

@@ -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<string, unknown>[] = [];
const tx = {
delete: vi.fn(() => ({
where: vi.fn(async (cond: unknown) => {
deletes.push(cond);
}),
})),
insert: vi.fn(() => ({
values: vi.fn(async (v: Record<string, unknown>) => {
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 });
});
});

View File

@@ -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<void> {
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,
});
}
}