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