Files
FireAdministration/src/server/routers/einsatz/uebung.ts
T
Florian WildeandClaude Opus 5 91954c402b Phase 3: Einsatz- und Uebungserfassung
Schnellerfassung im Alarmfall, sechs Zeitstempel mit Ausrueckzeit, Anfahrt
und Hilfsfrist, Staerke nach FwDV 3, Personalvorschlag mit
Qualifikationsstand, Einsatzbericht als PDF, Jahresexport fuer die Gemeinde,
Statistik und Uebungen mit Anwesenheitsliste.

Dazu das Einsatzportal fuer Fremdwehren: ein signierter Link ohne Anmeldung,
zeitlich und mengenbegrenzt, der Stichwort, Ort, Zeiten und Lagemeldungen
zeigt und Meldungen mit Foto annimmt. Es sind die ersten oeffentlich
erreichbaren Endpunkte in FLORIAN.

Acht neue Tabellen, alle mit organisationId und Row Level Security. Ein
freigegebener Einsatzbericht ist unveraenderlich; ein Datenbanktrigger setzt
das durch. Koordinaten liegen doppelt — als Dezimalgrad und als
PostGIS-Punkt, zusammengehalten von einem Trigger.

57 neue Tests (32 Unit, 14 gegen eine echte Datenbank, 11 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 13:50:58 +02:00

315 lines
10 KiB
TypeScript

import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { EinsatzFunktion, Prisma, UebungsArt } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
} from "@/server/routers/stammdaten/gemeinsam";
import { naechsteNummer } from "@/lib/nummernkreis";
/**
* Übungen.
*
* Aufgebaut wie ein Einsatz, aber mit Ausbildungsthema und Lernzielen statt
* Schadenlage. Der Zweck ist ein anderer: eine Übung ist kein Nachweis
* gegenüber der Gemeinde, sondern gegenüber der eigenen Mannschaft — wer war
* dabei, wie oft, und woran wurde gearbeitet.
*
* Daraus ergibt sich die Teilnahmequote, nach der in der Jahreshauptversammlung
* gefragt wird, und die Grundlage für Beförderungen.
*
* Ein Übungsbericht wird nicht festgeschrieben. Er geht an niemanden außerhalb
* der Wehr, und ein Tippfehler soll ohne Korrekturvorgang zu beheben sein.
*/
const LISTEN_AUSWAHL = {
id: true,
nummer: true,
thema: true,
art: true,
datum: true,
beginnUm: true,
endeUm: true,
ort: true,
geloeschtAm: true,
leiter: { select: { id: true, vorname: true, nachname: true } },
_count: { select: { teilnahmen: true } },
} satisfies Prisma.UebungSelect;
const uebungFelder = z.object({
thema: z.string().trim().min(1, "Ohne Thema ist es keine Übung, sondern ein Treffen.").max(200),
art: z.nativeEnum(UebungsArt).default(UebungsArt.PRAXIS),
datum: z.coerce.date(),
beginnUm: z.coerce.date().nullish(),
endeUm: z.coerce.date().nullish(),
ort: z.string().trim().max(200).nullish(),
leiterId: z.string().uuid().nullish(),
lernziele: z.string().trim().max(2000).nullish(),
beschreibung: z.string().trim().max(4000).nullish(),
bericht: z.string().trim().max(50_000).nullish(),
});
export const uebungRouter = router({
liste: mitRecht("ausbildung.lesen")
.input(
listenEingabe(["datum", "thema", "nummer"] as const).extend({
art: z.array(z.nativeEnum(UebungsArt)).optional(),
jahr: z.number().int().min(2000).max(2100).optional(),
personId: z.string().uuid().optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.UebungWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.art?.length ? { art: { in: input.art } } : {}),
...(input.jahr
? {
datum: {
gte: new Date(Date.UTC(input.jahr, 0, 1)),
lt: new Date(Date.UTC(input.jahr + 1, 0, 1)),
},
}
: {}),
...(input.personId ? { teilnahmen: { some: { personId: input.personId } } } : {}),
...(input.suche
? {
OR: [
{ thema: { contains: input.suche, mode: "insensitive" as const } },
{ nummer: { contains: input.suche, mode: "insensitive" as const } },
{ ort: { contains: input.suche, mode: "insensitive" as const } },
],
}
: {}),
};
const [eintraege, gesamt] = await Promise.all([
db.uebung.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy: input.sortierung ? { [input.sortierung]: input.richtung } : { datum: "desc" },
...blaettern(input),
}),
db.uebung.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
einzeln: mitRecht("ausbildung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const uebung = await db.uebung.findUnique({
where: { id: input.id },
include: {
leiter: { select: { id: true, vorname: true, nachname: true } },
teilnahmen: {
include: {
person: {
select: { id: true, vorname: true, nachname: true, dienstgrad: true },
},
},
orderBy: { erstelltAm: "asc" },
},
},
});
if (!uebung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
}
const dauer =
uebung.beginnUm && uebung.endeUm
? Math.round((uebung.endeUm.getTime() - uebung.beginnUm.getTime()) / 60_000)
: null;
return { ...uebung, dauerMinuten: dauer };
}),
),
anlegen: mitRecht("ausbildung.bearbeiten")
.input(uebungFelder.extend({ teilnehmerIds: z.array(z.string().uuid()).max(200).default([]) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { teilnehmerIds, ...felder } = input;
const jahr = felder.datum.getUTCFullYear();
await db.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`uebung:${ctx.sitzung.organisationId}:${jahr}`}))`;
const letzte = await db.uebung.findFirst({
where: { nummer: { startsWith: `${jahr}-` } },
orderBy: { nummer: "desc" },
select: { nummer: true },
});
const nummer = naechsteNummer(jahr, letzte?.nummer);
const uebung = await db.uebung.create({
data: {
organisationId: ctx.sitzung.organisationId,
nummer,
...felder,
teilnahmen: {
create: teilnehmerIds.map((personId) => ({
organisationId: ctx.sitzung.organisationId,
personId,
})),
},
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Uebung",
entitaetId: uebung.id,
beschreibung: `Übung ${nummer} — ${uebung.thema} mit ${teilnehmerIds.length} Teilnehmern`,
sitzung: ctx.sitzung,
nachher: uebung,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return uebung;
}),
),
aendern: mitRecht("ausbildung.bearbeiten")
.input(uebungFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.uebung.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
}
const nachher = await db.uebung.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Uebung",
entitaetId: id,
beschreibung: `Übung ${nachher.nummer} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/** Teilnehmerliste setzen — Anwesenheit wird am Stück abgehakt. */
teilnahmeSetzen: mitRecht("ausbildung.bearbeiten")
.input(
z.object({
uebungId: z.string().uuid(),
teilnehmer: z
.array(
z.object({
personId: z.string().uuid(),
funktion: z.nativeEnum(EinsatzFunktion).nullish(),
vonUm: z.coerce.date().nullish(),
bisUm: z.coerce.date().nullish(),
bemerkung: z.string().trim().max(500).nullish(),
}),
)
.max(200),
/** Nicht genannte Teilnehmer entfernen — die Liste ist dann vollständig. */
ersetzen: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const uebung = await db.uebung.findUnique({
where: { id: input.uebungId },
select: { id: true, nummer: true },
});
if (!uebung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
}
if (input.ersetzen) {
await db.uebungsteilnahme.deleteMany({
where: {
uebungId: input.uebungId,
personId: { notIn: input.teilnehmer.map((eintrag) => eintrag.personId) },
},
});
}
for (const teilnehmer of input.teilnehmer) {
await db.uebungsteilnahme.upsert({
where: {
uebungId_personId: { uebungId: input.uebungId, personId: teilnehmer.personId },
},
create: {
organisationId: ctx.sitzung.organisationId,
uebungId: input.uebungId,
...teilnehmer,
},
update: {
funktion: teilnehmer.funktion ?? null,
vonUm: teilnehmer.vonUm ?? null,
bisUm: teilnehmer.bisUm ?? null,
bemerkung: teilnehmer.bemerkung ?? null,
},
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Uebung",
entitaetId: input.uebungId,
beschreibung: `Übung ${uebung.nummer}: ${input.teilnehmer.length} Teilnehmer erfasst`,
sitzung: ctx.sitzung,
nachher: { anzahl: input.teilnehmer.length },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfasst: input.teilnehmer.length };
}),
),
loeschen: mitRecht("ausbildung.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const uebung = await db.uebung.findUnique({ where: { id: input.id } });
if (!uebung || uebung.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Übung gibt es nicht." });
}
await db.uebung.update({ where: { id: input.id }, data: { geloeschtAm: new Date() } });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Uebung",
entitaetId: input.id,
beschreibung: `Übung ${uebung.nummer} gelöscht`,
sitzung: ctx.sitzung,
vorher: uebung,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
});