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>
This commit is contained in:
Florian Wilde
2026-09-03 13:50:58 +02:00
co-authored by Claude Opus 5
parent bc62a9c1e8
commit 91954c402b
100 changed files with 9667 additions and 38 deletions
+304
View File
@@ -0,0 +1,304 @@
import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { tokenZerlegen, tokenZusammensetzen } from "@/lib/einsatz/portal";
/**
* Was die Datenbank bei Einsätzen durchsetzt.
*
* Drei Dinge, die nicht im Anwendungscode stehen dürfen, weil ein vergessener
* Zweig dort ausreichen würde:
*
* 1. Mandantentrennung der acht neuen Tabellen
* 2. Der Gleichlauf von Dezimalgrad und PostGIS-Punkt
* 3. Die Unveränderbarkeit freigegebener Einsatzberichte
*
* Dazu die Zerlegung des Portal-Tokens, die ohne Datenbank auskommt.
*
* Ohne erreichbare Datenbank überspringen sich die ersten drei Gruppen selbst.
*/
const APP_URL = process.env.DATABASE_URL ?? "";
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
const SLUG = "test-einsatz-wehr";
let erreichbar = false;
let organisationId = "";
let personId = "";
beforeAll(async () => {
try {
await system.$queryRaw`SELECT 1`;
erreichbar = true;
} catch {
return;
}
const organisation = await system.organisation.upsert({
where: { slug: SLUG },
update: {},
create: { slug: SLUG, name: "Einsatz-Testwehr", kurzname: "ETW" },
});
organisationId = organisation.id;
const person = await system.person.upsert({
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "E001" } },
update: {},
create: { organisationId, mitgliedsnummer: "E001", vorname: "Erika", nachname: "Einsatz" },
});
personId = person.id;
});
afterAll(async () => {
if (erreichbar) {
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.organisation.deleteMany({ where: { slug: SLUG } });
});
}
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
});
let laufend = 0;
/** Legt einen Einsatz an — mit fortlaufender Nummer, damit Tests sich nicht stören. */
async function einsatzAnlegen(felder: Record<string, unknown> = {}) {
laufend += 1;
return system.einsatz.create({
data: {
organisationId,
einsatznummer: `2026-${String(9000 + laufend)}`,
stichwort: "Testeinsatz",
alarmiertUm: new Date("2026-06-15T01:42:00.000Z"),
...felder,
},
});
}
describe.runIf(process.env.DATABASE_URL)("Koordinaten", () => {
it("setzt den PostGIS-Punkt aus den Dezimalgraden", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ breitengrad: 48.137154, laengengrad: 11.576124 });
const [zeile] = await system.$queryRaw<Array<{ laenge: number; breite: number }>>`
SELECT ST_X("koordinaten"::geometry) AS laenge, ST_Y("koordinaten"::geometry) AS breite
FROM "einsatz" WHERE "id" = ${einsatz.id}::uuid
`;
expect(zeile?.breite).toBeCloseTo(48.137154, 5);
expect(zeile?.laenge).toBeCloseTo(11.576124, 5);
});
it("räumt den Punkt wieder ab, wenn die Koordinaten entfallen", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ breitengrad: 48.1, laengengrad: 11.5 });
await system.einsatz.update({
where: { id: einsatz.id },
data: { breitengrad: null, laengengrad: null },
});
const [zeile] = await system.$queryRaw<Array<{ vorhanden: boolean }>>`
SELECT ("koordinaten" IS NOT NULL) AS vorhanden
FROM "einsatz" WHERE "id" = ${einsatz.id}::uuid
`;
expect(zeile?.vorhanden).toBe(false);
});
it("weist Koordinaten ab, die es auf der Erde nicht gibt", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(einsatzAnlegen({ breitengrad: 95, laengengrad: 11.5 })).rejects.toThrow();
});
});
describe.runIf(process.env.DATABASE_URL)("Freigegebene Einsatzberichte", () => {
it("lässt sich bis zur Freigabe frei ändern", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "ABGESCHLOSSEN" });
const geaendert = await system.einsatz.update({
where: { id: einsatz.id },
data: { bericht: "wird noch geschrieben" },
});
expect(geaendert.bericht).toBe("wird noch geschrieben");
});
it("weist jede Änderung nach der Freigabe ab", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({
status: "FREIGEGEBEN",
freigegebenAm: new Date(),
bericht: "So war es.",
});
await expect(
system.einsatz.update({ where: { id: einsatz.id }, data: { bericht: "Doch anders." } }),
).rejects.toThrow(/unveraenderlich/i);
await expect(
system.einsatz.update({ where: { id: einsatz.id }, data: { stichwort: "Anderes" } }),
).rejects.toThrow(/unveraenderlich/i);
});
it("lässt das Berichts-PDF nachtragen, solange keines hinterlegt ist", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
const datei = await system.datei.create({
data: {
organisationId,
bucket: "test",
objektSchluessel: `test/${einsatz.id}.pdf`,
dateiname: "bericht.pdf",
mimeTyp: "application/pdf",
groesseBytes: BigInt(2048),
},
});
const mitPdf = await system.einsatz.update({
where: { id: einsatz.id },
data: { berichtDateiId: datei.id },
});
expect(mitPdf.berichtDateiId).toBe(datei.id);
});
it("sperrt den Umweg über die Einsatzkräfte", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "ABGESCHLOSSEN" });
const kraft = await system.einsatzkraft.create({
data: { organisationId, einsatzId: einsatz.id, personId, funktion: "TRUPPMANN" },
});
await system.einsatz.update({
where: { id: einsatz.id },
data: { status: "FREIGEGEBEN", freigegebenAm: new Date() },
});
await expect(
system.einsatzkraft.update({
where: { id: kraft.id },
data: { funktion: "GRUPPENFUEHRER" },
}),
).rejects.toThrow(/freigegeben/i);
await expect(
system.einsatzkraft.create({
data: { organisationId, einsatzId: einsatz.id, personId, funktion: "MELDER" },
}),
).rejects.toThrow(/freigegeben/i);
});
it("lässt Lagemeldungen ausdrücklich weiter zu", async ({ skip }) => {
if (!erreichbar) return skip();
// Eine Meldung aus dem Portal kann eintreffen, wenn der Bericht längst
// geschrieben ist. Der Verlauf ist kein Bestandteil des Berichts.
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
const meldung = await system.lagemeldung.create({
data: {
organisationId,
einsatzId: einsatz.id,
text: "Nachtrag von der Nachbarwehr",
quelle: "PORTAL",
},
});
expect(meldung.id).toBeTruthy();
});
it("verhindert das Löschen freigegebener Berichte", async ({ skip }) => {
if (!erreichbar) return skip();
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
await expect(system.einsatz.delete({ where: { id: einsatz.id } })).rejects.toThrow(
/nicht geloescht/i,
);
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.einsatz.delete({ where: { id: einsatz.id } });
});
expect(await system.einsatz.findUnique({ where: { id: einsatz.id } })).toBeNull();
});
});
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Einsatztabellen", () => {
it("liefert ohne gesetzten Mandantenkontext keine Zeilen", async ({ skip }) => {
if (!erreichbar) return skip();
await einsatzAnlegen();
const gefunden = await app.$transaction(async (tx) => ({
einsaetze: await tx.einsatz.findMany(),
kraefte: await tx.einsatzkraft.findMany(),
meldungen: await tx.lagemeldung.findMany(),
zugaenge: await tx.portalZugang.findMany(),
uebungen: await tx.uebung.findMany(),
}));
expect(gefunden.einsaetze).toHaveLength(0);
expect(gefunden.kraefte).toHaveLength(0);
expect(gefunden.meldungen).toHaveLength(0);
expect(gefunden.zugaenge).toHaveLength(0);
expect(gefunden.uebungen).toHaveLength(0);
});
it("zeigt im Kontext der Wehr nur ihre eigenen Einsätze", async ({ skip }) => {
if (!erreichbar) return skip();
const einsaetze = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.einsatz.findMany();
});
expect(einsaetze.length).toBeGreaterThan(0);
expect(einsaetze.every((einsatz) => einsatz.organisationId === organisationId)).toBe(true);
});
});
describe("Portal-Token", () => {
it("setzt Kennung und Geheimnis zusammen und zerlegt sie wieder", () => {
const kennung = "3f2504e0-4f89-11d3-9a0c-0305e82c3301";
const geheimnis = "abcdefghijklmnopqrstuvwxyz012345";
const token = tokenZusammensetzen(kennung, geheimnis);
expect(tokenZerlegen(token)).toEqual({ zugangId: kennung, geheimnis });
});
it("weist alles ab, was nicht wie ein Token aussieht", () => {
expect(tokenZerlegen("")).toBeNull();
expect(tokenZerlegen("ohnetrenner")).toBeNull();
expect(tokenZerlegen(".nurgeheimnis")).toBeNull();
expect(tokenZerlegen("3f2504e0-4f89-11d3-9a0c-0305e82c3301.")).toBeNull();
// Keine gültige Kennung
expect(tokenZerlegen("nichtsGutes.abcdefghijklmnopqrstuvwxyz012345")).toBeNull();
// Geheimnis zu kurz
expect(tokenZerlegen("3f2504e0-4f89-11d3-9a0c-0305e82c3301.kurz")).toBeNull();
});
it("verträgt Trennzeichen im Geheimnis nicht als Verwechslung", () => {
// base64url enthält kein "." — der erste Punkt trennt also eindeutig.
const token = tokenZusammensetzen(
"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"abc_def-ghi012345678901234567890",
);
expect(tokenZerlegen(token)?.geheimnis).toBe("abc_def-ghi012345678901234567890");
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import {
EINHEITEN,
staerkeAlsText,
staerkeBerechnen,
staerkeHinweise,
taktischeEinheit,
type Funktion,
} from "@/lib/einsatz/staerke";
/**
* Stärke nach FwDV 3.
*
* Die Zahlen stehen später im Einsatzbericht und in der Meldung an die
* Gemeinde. Wer die Zuordnung ändert, ändert damit die Statistik der ganzen
* Wehr — deshalb steht sie hier ausführlich unter Test.
*/
/** Die Besetzung einer Gruppe nach FwDV 3. */
const GRUPPE: Funktion[] = [
"GRUPPENFUEHRER",
"MASCHINIST",
"MELDER",
"TRUPPFUEHRER",
"TRUPPMANN",
"TRUPPFUEHRER",
"TRUPPMANN",
"TRUPPFUEHRER",
"TRUPPMANN",
];
/** Die Besetzung einer Staffel. */
const STAFFEL: Funktion[] = [
"GRUPPENFUEHRER",
"MASCHINIST",
"TRUPPFUEHRER",
"TRUPPMANN",
"TRUPPFUEHRER",
"TRUPPMANN",
];
describe("staerkeBerechnen", () => {
it("zählt eine Gruppe als 1/3/5/9", () => {
const staerke = staerkeBerechnen(GRUPPE);
expect(staerke).toEqual({ fuehrer: 1, unterfuehrer: 3, mannschaft: 5, gesamt: 9 });
expect(staerkeAlsText(staerke)).toBe("1/3/5/9");
});
it("zählt eine Staffel als 1/2/3/6", () => {
expect(staerkeAlsText(staerkeBerechnen(STAFFEL))).toBe("1/2/3/6");
});
it("zählt einen Trupp als 0/1/1/2", () => {
expect(staerkeAlsText(staerkeBerechnen(["TRUPPFUEHRER", "TRUPPMANN"]))).toBe("0/1/1/2");
});
it("zählt jede Führungsfunktion als Führer", () => {
const staerke = staerkeBerechnen([
"EINSATZLEITER",
"ZUGFUEHRER",
"GRUPPENFUEHRER",
"STAFFELFUEHRER",
]);
expect(staerke.fuehrer).toBe(4);
expect(staerke.mannschaft).toBe(0);
});
it("zählt den Maschinisten zur Mannschaft", () => {
// Regional unterschiedlich — dieser Test hält die getroffene Festlegung
// fest, damit eine Änderung auffällt statt durchzurutschen.
const staerke = staerkeBerechnen(["MASCHINIST"]);
expect(staerke.mannschaft).toBe(1);
expect(staerke.unterfuehrer).toBe(0);
});
it("liefert für niemanden lauter Nullen", () => {
expect(staerkeAlsText(staerkeBerechnen([]))).toBe("0/0/0/0");
});
});
describe("taktischeEinheit", () => {
it("erkennt die Einheiten der FwDV 3", () => {
expect(taktischeEinheit(staerkeBerechnen(GRUPPE)).einheit).toBe("Gruppe");
expect(taktischeEinheit(staerkeBerechnen(STAFFEL)).einheit).toBe("Staffel");
expect(taktischeEinheit(staerkeBerechnen(["TRUPPFUEHRER", "TRUPPMANN"])).einheit).toBe("Trupp");
});
it("nennt die überzähligen Kräfte", () => {
const zehn = [...GRUPPE, "TRUPPMANN" as Funktion];
const ergebnis = taktischeEinheit(staerkeBerechnen(zehn));
expect(ergebnis.einheit).toBe("Gruppe");
expect(ergebnis.ueberzaehlig).toBe(1);
});
it("gibt bei einer einzelnen Kraft keine Einheit zurück", () => {
const ergebnis = taktischeEinheit(staerkeBerechnen(["GRUPPENFUEHRER"]));
expect(ergebnis.einheit).toBeNull();
expect(ergebnis.ueberzaehlig).toBe(1);
});
it("kennt die Sollstärken", () => {
expect(EINHEITEN.map((einheit) => einheit.staerke)).toEqual([2, 6, 9, 18]);
});
});
describe("staerkeHinweise", () => {
it("meldet, wenn niemand erfasst ist", () => {
expect(staerkeHinweise(staerkeBerechnen([]))[0]).toContain("noch niemand erfasst");
});
it("meldet eine fehlende Führungskraft", () => {
const hinweise = staerkeHinweise(staerkeBerechnen(["TRUPPFUEHRER", "TRUPPMANN"]));
expect(hinweise.some((hinweis) => hinweis.includes("Führungskraft"))).toBe(true);
});
it("meldet den Alleingang", () => {
const hinweise = staerkeHinweise(staerkeBerechnen(["GRUPPENFUEHRER"]));
expect(hinweise.some((hinweis) => hinweis.includes("Alleine"))).toBe(true);
});
it("schweigt bei einer vollständigen Gruppe", () => {
expect(staerkeHinweise(staerkeBerechnen(GRUPPE))).toHaveLength(0);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
alsDauer,
anfahrtszeit,
ausrueckzeit,
einsatzdauer,
hilfsfrist,
kennzahlen,
naechsterStempel,
zeitHinweise,
} from "@/lib/einsatz/zeiten";
import { nummerBilden, nummerLesen, naechsteNummer } from "@/lib/nummernkreis";
/**
* Einsatzzeiten und Nummernkreis.
*
* Die Hilfsfrist ist die Zahl, über die eine Wehr mit ihrer Gemeinde
* diskutiert. Wenn sie falsch gerechnet wird, merkt es niemand — bis es
* darauf ankommt.
*/
function uhr(stunde: number, minute: number): Date {
return new Date(Date.UTC(2026, 5, 15, stunde, minute, 0));
}
const EINSATZ = {
alarmiertUm: uhr(3, 42),
ausgeruecktUm: uhr(3, 47),
eingetroffenUm: uhr(3, 51),
einsatzendeUm: uhr(6, 5),
eingeruecktUm: uhr(6, 30),
einsatzbereitUm: uhr(7, 0),
};
describe("Kennzahlen", () => {
it("rechnet Ausrückzeit, Anfahrt und Hilfsfrist", () => {
expect(ausrueckzeit(EINSATZ)).toBe(5);
expect(anfahrtszeit(EINSATZ)).toBe(4);
expect(hilfsfrist(EINSATZ)).toBe(9);
});
it("rechnet die Einsatzdauer bis zum Einrücken", () => {
expect(einsatzdauer(EINSATZ)).toBe(168);
});
it("nimmt für die Dauer ersatzweise das Einsatzende", () => {
expect(einsatzdauer({ ...EINSATZ, eingeruecktUm: null })).toBe(143);
});
it("liefert null, wo ein Stempel fehlt", () => {
expect(hilfsfrist({ alarmiertUm: uhr(3, 42) })).toBeNull();
expect(ausrueckzeit({})).toBeNull();
});
it("liefert alle vier Zahlen auf einmal", () => {
expect(kennzahlen(EINSATZ)).toEqual({
ausrueckzeit: 5,
anfahrtszeit: 4,
hilfsfrist: 9,
dauer: 168,
});
});
it("gibt keine negative Zeit zurück, wenn Stempel verdreht sind", () => {
// Die Reihenfolge stimmt nicht — dann lieber keine Zahl als eine falsche.
expect(ausrueckzeit({ alarmiertUm: uhr(4, 0), ausgeruecktUm: uhr(3, 50) })).toBeNull();
});
});
describe("zeitHinweise", () => {
it("schweigt bei einem stimmigen Ablauf", () => {
expect(zeitHinweise(EINSATZ)).toHaveLength(0);
});
it("meldet eine verdrehte Reihenfolge", () => {
const hinweise = zeitHinweise({ ...EINSATZ, eingetroffenUm: uhr(3, 40) });
expect(hinweise.length).toBeGreaterThan(0);
expect(hinweise[0]).toContain("eingetroffen");
});
it("meldet eine unplausibel lange Anfahrt", () => {
const hinweise = zeitHinweise({
alarmiertUm: uhr(3, 0),
eingetroffenUm: uhr(5, 0),
});
expect(hinweise.some((hinweis) => hinweis.includes("120 Minuten"))).toBe(true);
});
});
describe("naechsterStempel", () => {
it("nennt den ersten offenen Stempel", () => {
expect(naechsterStempel({})).toBe("alarmiertUm");
expect(naechsterStempel({ alarmiertUm: uhr(3, 42) })).toBe("ausgeruecktUm");
});
it("gibt null zurück, wenn alles gesetzt ist", () => {
expect(naechsterStempel(EINSATZ)).toBeNull();
});
});
describe("alsDauer", () => {
it("schreibt Minuten unter einer Stunde als Minuten", () => {
expect(alsDauer(9)).toBe("9 min");
expect(alsDauer(59)).toBe("59 min");
});
it("schreibt längere Zeiten als Stunden", () => {
expect(alsDauer(60)).toBe("1:00 h");
expect(alsDauer(168)).toBe("2:48 h");
});
it("kommt mit fehlenden Werten zurecht", () => {
expect(alsDauer(null)).toBe("—");
expect(alsDauer(undefined)).toBe("—");
});
});
describe("Nummernkreis", () => {
it("bildet und liest Nummern", () => {
expect(nummerBilden(2026, 7)).toBe("2026-0007");
expect(nummerLesen("2026-0007")).toEqual({ jahr: 2026, laufend: 7 });
expect(nummerLesen("Unsinn")).toBeNull();
});
it("zählt von der bisher höchsten Nummer weiter", () => {
expect(naechsteNummer(2026, "2026-0041")).toBe("2026-0042");
});
it("beginnt im leeren Jahr bei eins", () => {
expect(naechsteNummer(2027, null)).toBe("2027-0001");
});
it("übernimmt keine Nummer aus einem anderen Jahr", () => {
// Der Aufrufer sucht die höchste Nummer des Jahres; bekommt er keine,
// beginnt das neue Jahr wieder bei eins.
expect(naechsteNummer(2027, undefined)).toBe("2027-0001");
});
});