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:
197
src/server/actions/equipment.ts
Normal file
197
src/server/actions/equipment.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user