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:
131
src/server/actions/brigade-users.ts
Normal file
131
src/server/actions/brigade-users.ts
Normal 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 };
|
||||
}
|
||||
66
src/server/actions/brigade.ts
Normal file
66
src/server/actions/brigade.ts
Normal 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 };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
191
src/server/actions/vehicles.ts
Normal file
191
src/server/actions/vehicles.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user