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:
co-authored by
Claude Opus 5
parent
bc62a9c1e8
commit
91954c402b
@@ -0,0 +1,248 @@
|
||||
import "server-only";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { hochladen, objektSchluessel } from "@/lib/storage";
|
||||
import { berichtPdf, type BerichtDaten } from "@/lib/einsatz/bericht";
|
||||
import { kennzahlen } from "@/lib/einsatz/zeiten";
|
||||
import { staerkeAlsText, staerkeBerechnen } from "@/lib/einsatz/staerke";
|
||||
|
||||
/**
|
||||
* Vom Einsatz zum abgelegten Bericht.
|
||||
*
|
||||
* Wie beim Prüfprotokoll dreigeteilt — lesen, rendern, schreiben. Zwischen
|
||||
* Lesen und Schreiben liegt der Aufruf an Gotenberg, und der darf dauern.
|
||||
*
|
||||
* Wiederholbar: gibt es den Bericht schon, passiert nichts.
|
||||
*/
|
||||
|
||||
const KATEGORIE_TEXT: Record<string, string> = {
|
||||
BRAND_KLEIN: "Brand klein",
|
||||
BRAND_MITTEL: "Brand mittel",
|
||||
BRAND_GROSS: "Brand groß",
|
||||
TECHNISCHE_HILFE_KLEIN: "Technische Hilfeleistung klein",
|
||||
TECHNISCHE_HILFE_MITTEL: "Technische Hilfeleistung mittel",
|
||||
TECHNISCHE_HILFE_GROSS: "Technische Hilfeleistung groß",
|
||||
GEFAHRGUT: "Gefahrguteinsatz",
|
||||
RETTUNGSDIENST: "Unterstützung Rettungsdienst",
|
||||
BRANDMELDEANLAGE: "Brandmeldeanlage",
|
||||
BOESWILLIGER_ALARM: "böswilliger Alarm",
|
||||
SICHERHEITSWACHE: "Sicherheitswache",
|
||||
UNTERSTUETZUNG: "Unterstützung / Amtshilfe",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
|
||||
const ALARMIERUNG_TEXT: Record<string, string> = {
|
||||
SIRENE: "Sirene",
|
||||
MELDEEMPFAENGER: "Meldeempfänger",
|
||||
TELEFON: "Telefon",
|
||||
APP: "App",
|
||||
PERSOENLICH: "persönlich",
|
||||
SONSTIGES: "sonstige",
|
||||
};
|
||||
|
||||
/** Adresse aus den Einzelfeldern, wie sie im Bericht stehen soll. */
|
||||
function adresse(einsatz: {
|
||||
strasse: string | null;
|
||||
hausnummer: string | null;
|
||||
plz: string | null;
|
||||
ort: string | null;
|
||||
ortsteil: string | null;
|
||||
}): string {
|
||||
const strasse = [einsatz.strasse, einsatz.hausnummer].filter(Boolean).join(" ");
|
||||
const ort = [einsatz.plz, einsatz.ort ?? einsatz.ortsteil].filter(Boolean).join(" ");
|
||||
return [strasse, ort].filter(Boolean).join(", ") || "ohne Ortsangabe";
|
||||
}
|
||||
|
||||
export async function einsatzberichtErzeugen(
|
||||
organisationId: string,
|
||||
einsatzId: string,
|
||||
): Promise<{ dateiId: string; neu: boolean } | null> {
|
||||
// ---------------------------------------------------------------- lesen
|
||||
const geladen = await fuerOrganisation(organisationId, async (db) => {
|
||||
const einsatz = await db.einsatz.findUnique({
|
||||
where: { id: einsatzId },
|
||||
include: {
|
||||
organisation: { select: { name: true, slug: true, strasse: true, plz: true, ort: true } },
|
||||
einsatzleiter: { select: { vorname: true, nachname: true } },
|
||||
freigegebenVon: { select: { vorname: true, nachname: true } },
|
||||
fahrzeuge: {
|
||||
include: { fahrzeug: { select: { funkrufname: true, bezeichnung: true } } },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
},
|
||||
kraefte: {
|
||||
include: {
|
||||
person: { select: { vorname: true, nachname: true, dienstgrad: true } },
|
||||
fahrzeug: { select: { funkrufname: true } },
|
||||
},
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
},
|
||||
material: { orderBy: { erstelltAm: "asc" } },
|
||||
lagemeldungen: {
|
||||
where: { geloeschtAm: null },
|
||||
orderBy: { zeitpunkt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!einsatz) return null;
|
||||
if (einsatz.status !== "FREIGEGEBEN") return null;
|
||||
|
||||
return einsatz;
|
||||
});
|
||||
|
||||
if (!geladen) return null;
|
||||
if (geladen.berichtDateiId) return { dateiId: geladen.berichtDateiId, neu: false };
|
||||
|
||||
// --------------------------------------------------------------- rendern
|
||||
const staerke = staerkeBerechnen(geladen.kraefte.map((kraft) => kraft.funktion));
|
||||
|
||||
const daten: BerichtDaten = {
|
||||
organisation: {
|
||||
name: geladen.organisation.name,
|
||||
strasse: geladen.organisation.strasse,
|
||||
plz: geladen.organisation.plz,
|
||||
ort: geladen.organisation.ort,
|
||||
},
|
||||
einsatz: {
|
||||
einsatznummer: geladen.einsatznummer,
|
||||
stichwort: geladen.stichwort,
|
||||
kategorie: KATEGORIE_TEXT[geladen.kategorie] ?? geladen.kategorie,
|
||||
ilsNummer: geladen.ilsNummer,
|
||||
alarmierungsart: geladen.alarmierungsart
|
||||
? (ALARMIERUNG_TEXT[geladen.alarmierungsart] ?? geladen.alarmierungsart)
|
||||
: null,
|
||||
adresse: adresse(geladen),
|
||||
objekt: geladen.objekt,
|
||||
koordinaten:
|
||||
geladen.breitengrad && geladen.laengengrad
|
||||
? `${geladen.breitengrad.toString()}, ${geladen.laengengrad.toString()}`
|
||||
: null,
|
||||
beschreibung: geladen.beschreibung,
|
||||
bericht: geladen.bericht,
|
||||
wetter: geladen.wetter,
|
||||
temperatur: geladen.temperatur,
|
||||
weitereKraefte: geladen.weitereKraefte,
|
||||
gerettet: geladen.geretteteePersonen,
|
||||
verletzt: geladen.verletztePersonen,
|
||||
getoetet: geladen.totePersonen,
|
||||
sachschaden: geladen.sachschaden ? geladen.sachschaden.toNumber() : null,
|
||||
kostenpflichtig: geladen.kostenpflichtig,
|
||||
einsatzleiter: geladen.einsatzleiter
|
||||
? `${geladen.einsatzleiter.vorname} ${geladen.einsatzleiter.nachname}`
|
||||
: null,
|
||||
freigegebenVon: geladen.freigegebenVon
|
||||
? `${geladen.freigegebenVon.vorname} ${geladen.freigegebenVon.nachname}`
|
||||
: null,
|
||||
freigegebenAm: geladen.freigegebenAm,
|
||||
},
|
||||
zeiten: {
|
||||
alarmiertUm: geladen.alarmiertUm,
|
||||
ausgeruecktUm: geladen.ausgeruecktUm,
|
||||
eingetroffenUm: geladen.eingetroffenUm,
|
||||
einsatzendeUm: geladen.einsatzendeUm,
|
||||
eingeruecktUm: geladen.eingeruecktUm,
|
||||
einsatzbereitUm: geladen.einsatzbereitUm,
|
||||
},
|
||||
kennzahlen: kennzahlen(geladen),
|
||||
staerkeText: staerkeAlsText(staerke),
|
||||
fahrzeuge: geladen.fahrzeuge.map((eintrag) => ({
|
||||
funkrufname: eintrag.fahrzeug.funkrufname,
|
||||
bezeichnung: eintrag.fahrzeug.bezeichnung,
|
||||
ausgeruecktUm: eintrag.ausgeruecktUm,
|
||||
eingetroffenUm: eintrag.eingetroffenUm,
|
||||
eingeruecktUm: eintrag.eingeruecktUm,
|
||||
kilometer:
|
||||
eintrag.kilometerAbfahrt !== null && eintrag.kilometerRueckkehr !== null
|
||||
? eintrag.kilometerRueckkehr - eintrag.kilometerAbfahrt
|
||||
: null,
|
||||
wasserabgabe: eintrag.wasserabgabe,
|
||||
})),
|
||||
kraefte: geladen.kraefte.map((kraft) => ({
|
||||
name: `${kraft.person.nachname}, ${kraft.person.vorname}`,
|
||||
dienstgrad: kraft.person.dienstgrad,
|
||||
funktion: kraft.funktion,
|
||||
atemschutz: kraft.atemschutz,
|
||||
fahrzeug: kraft.fahrzeug?.funkrufname ?? null,
|
||||
von: kraft.vonUm,
|
||||
bis: kraft.bisUm,
|
||||
})),
|
||||
material: geladen.material.map((posten) => ({
|
||||
bezeichnung: posten.bezeichnung,
|
||||
menge: `${posten.menge.toString()} ${posten.einheit}`,
|
||||
beschaedigt: posten.beschaedigt,
|
||||
})),
|
||||
meldungen: geladen.lagemeldungen.map((meldung) => ({
|
||||
zeitpunkt: meldung.zeitpunkt,
|
||||
text: meldung.text,
|
||||
wichtig: meldung.wichtig,
|
||||
verfasser: meldung.verfasserName,
|
||||
quelle: meldung.quelle,
|
||||
})),
|
||||
erzeugtAm: new Date(),
|
||||
};
|
||||
|
||||
const pdf = await berichtPdf(daten);
|
||||
|
||||
const dateiname = `Einsatzbericht-${geladen.einsatznummer}.pdf`;
|
||||
const schluessel = objektSchluessel({
|
||||
organisationSlug: geladen.organisation.slug,
|
||||
entitaet: "Einsatz",
|
||||
entitaetId: geladen.id,
|
||||
dateiname,
|
||||
});
|
||||
|
||||
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
|
||||
einsatz: geladen.id,
|
||||
nummer: geladen.einsatznummer,
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- schreiben
|
||||
return fuerOrganisation(organisationId, async (db) => {
|
||||
const stand = await db.einsatz.findUnique({
|
||||
where: { id: geladen.id },
|
||||
select: { berichtDateiId: true },
|
||||
});
|
||||
|
||||
if (stand?.berichtDateiId) return { dateiId: stand.berichtDateiId, neu: false };
|
||||
|
||||
const datei = await db.datei.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bucket: abgelegt.bucket,
|
||||
objektSchluessel: abgelegt.objektSchluessel,
|
||||
dateiname,
|
||||
mimeTyp: "application/pdf",
|
||||
groesseBytes: BigInt(abgelegt.groesseBytes),
|
||||
pruefsumme: abgelegt.pruefsumme,
|
||||
entitaet: "Einsatz",
|
||||
entitaetId: geladen.id,
|
||||
kategorie: "einsatzbericht",
|
||||
hochgeladenVonId: geladen.freigegebenVonId,
|
||||
},
|
||||
});
|
||||
|
||||
// Erlaubt trotz Freigabe: das Berichtsfeld war leer (siehe Trigger
|
||||
// einsatz_freigabe_schuetzen in Migration 20260903000500).
|
||||
await db.einsatz.update({
|
||||
where: { id: geladen.id },
|
||||
data: { berichtDateiId: datei.id },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId,
|
||||
aktion: AuditAktion.DATEI_HOCHGELADEN,
|
||||
entitaet: "Einsatz",
|
||||
entitaetId: geladen.id,
|
||||
beschreibung: `Einsatzbericht ${geladen.einsatznummer} erzeugt (${Math.round(
|
||||
abgelegt.groesseBytes / 1024,
|
||||
)} kB)`,
|
||||
akteurTyp: AkteurTyp.WORKER,
|
||||
akteurName: "Hintergrundprozess",
|
||||
nachher: { dateiId: datei.id, pruefsumme: abgelegt.pruefsumme },
|
||||
});
|
||||
|
||||
return { dateiId: datei.id, neu: true };
|
||||
});
|
||||
}
|
||||
|
||||
export { KATEGORIE_TEXT, ALARMIERUNG_TEXT, adresse as einsatzAdresse };
|
||||
@@ -0,0 +1,642 @@
|
||||
import "server-only";
|
||||
import { htmlZuPdf, html } from "@/lib/pdf";
|
||||
import { alsDatum, alsDatumZeit, alsEuro, alsUhrzeit } from "@/lib/utils";
|
||||
import { alsDauer, ZEITSTEMPEL, ZEITSTEMPEL_TEXT, type Zeitstempel } from "@/lib/einsatz/zeiten";
|
||||
import { FUNKTION_TEXT, type Funktion } from "@/lib/einsatz/staerke";
|
||||
|
||||
/**
|
||||
* Der Einsatzbericht als PDF.
|
||||
*
|
||||
* Er geht an die Gemeinde, bei Kostenersatz an den Verursacher und im
|
||||
* Schadensfall an die Versicherung. Danach richtet sich der Aufbau:
|
||||
*
|
||||
* Kopf Wehr, Einsatznummer, Stichwort, Ergebnis auf einen Blick
|
||||
* Einsatzdaten Ort, Kategorie, Leitstellennummer
|
||||
* Zeiten die sechs Stempel und die drei Kennzahlen daraus
|
||||
* Kräfte Fahrzeuge und Mannschaft mit Stärke nach FwDV 3
|
||||
* Verlauf Lagemeldungen in zeitlicher Reihenfolge
|
||||
* Bericht der Text des Einsatzleiters
|
||||
* Fuß wer freigegeben hat und wann
|
||||
*
|
||||
* Wie beim Prüfprotokoll: alles eingebettet, keine externen Schriften, keine
|
||||
* nachgeladenen Bilder.
|
||||
*/
|
||||
|
||||
export interface BerichtKraft {
|
||||
name: string;
|
||||
dienstgrad?: string | null;
|
||||
funktion: Funktion;
|
||||
atemschutz: boolean;
|
||||
fahrzeug?: string | null;
|
||||
von?: Date | null;
|
||||
bis?: Date | null;
|
||||
}
|
||||
|
||||
export interface BerichtFahrzeug {
|
||||
funkrufname: string;
|
||||
bezeichnung?: string | null;
|
||||
ausgeruecktUm?: Date | null;
|
||||
eingetroffenUm?: Date | null;
|
||||
eingeruecktUm?: Date | null;
|
||||
kilometer?: number | null;
|
||||
wasserabgabe?: number | null;
|
||||
}
|
||||
|
||||
export interface BerichtMeldung {
|
||||
zeitpunkt: Date;
|
||||
text: string;
|
||||
wichtig: boolean;
|
||||
verfasser?: string | null;
|
||||
quelle: string;
|
||||
}
|
||||
|
||||
export interface BerichtDaten {
|
||||
organisation: {
|
||||
name: string;
|
||||
strasse?: string | null;
|
||||
plz?: string | null;
|
||||
ort?: string | null;
|
||||
};
|
||||
einsatz: {
|
||||
einsatznummer: string;
|
||||
stichwort: string;
|
||||
kategorie: string;
|
||||
ilsNummer?: string | null;
|
||||
alarmierungsart?: string | null;
|
||||
adresse: string;
|
||||
objekt?: string | null;
|
||||
koordinaten?: string | null;
|
||||
beschreibung?: string | null;
|
||||
bericht?: string | null;
|
||||
wetter?: string | null;
|
||||
temperatur?: number | null;
|
||||
weitereKraefte?: string | null;
|
||||
gerettet: number;
|
||||
verletzt: number;
|
||||
getoetet: number;
|
||||
sachschaden?: number | null;
|
||||
kostenpflichtig: boolean;
|
||||
einsatzleiter?: string | null;
|
||||
freigegebenVon?: string | null;
|
||||
freigegebenAm?: Date | null;
|
||||
};
|
||||
zeiten: Partial<Record<Zeitstempel, Date | null>>;
|
||||
kennzahlen: {
|
||||
ausrueckzeit: number | null;
|
||||
anfahrtszeit: number | null;
|
||||
hilfsfrist: number | null;
|
||||
dauer: number | null;
|
||||
};
|
||||
staerkeText: string;
|
||||
fahrzeuge: BerichtFahrzeug[];
|
||||
kraefte: BerichtKraft[];
|
||||
material: Array<{ bezeichnung: string; menge: string; beschaedigt: boolean }>;
|
||||
meldungen: BerichtMeldung[];
|
||||
erzeugtAm: Date;
|
||||
}
|
||||
|
||||
function feld(bezeichnung: string, wert: string | null | undefined): string {
|
||||
return `
|
||||
<div class="feld">
|
||||
<div class="beschriftung">${html(bezeichnung)}</div>
|
||||
<div class="wert">${html(wert && wert !== "" ? wert : "—")}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const STIL = `
|
||||
/* Ränder kommen von Gotenberg — hier keine setzen. */
|
||||
@page { size: A4; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
color: #111;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
h1 { font-size: 15pt; margin: 0 0 1mm; }
|
||||
h2 {
|
||||
font-size: 10.5pt;
|
||||
margin: 6mm 0 2mm;
|
||||
padding-bottom: 1mm;
|
||||
border-bottom: 0.4mm solid #111;
|
||||
}
|
||||
|
||||
.kopf {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8mm;
|
||||
padding-bottom: 3mm;
|
||||
border-bottom: 0.6mm solid #111;
|
||||
}
|
||||
|
||||
.kopf .wehr { font-size: 8.5pt; color: #333; }
|
||||
|
||||
.nummer {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nummer .marke {
|
||||
display: inline-block;
|
||||
padding: 1.5mm 3mm;
|
||||
border: 0.5mm solid #111;
|
||||
border-radius: 1.5mm;
|
||||
font-weight: 700;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.felder {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2mm 6mm;
|
||||
margin-top: 3mm;
|
||||
}
|
||||
|
||||
.feld .beschriftung {
|
||||
font-size: 7.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.feld .wert { font-size: 9.5pt; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 7.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #555;
|
||||
border-bottom: 0.3mm solid #999;
|
||||
padding: 1.5mm 2mm;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1.6mm 2mm;
|
||||
border-bottom: 0.2mm solid #ddd;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr { break-inside: avoid; }
|
||||
|
||||
td.zahl, th.zahl { text-align: right; white-space: nowrap; }
|
||||
|
||||
.zeitleiste {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 2mm;
|
||||
margin-top: 2mm;
|
||||
}
|
||||
|
||||
.zeitleiste .stempel {
|
||||
border: 0.2mm solid #bbb;
|
||||
border-radius: 1.5mm;
|
||||
padding: 2mm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zeitleiste .bezeichnung {
|
||||
font-size: 7pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.zeitleiste .uhrzeit { font-size: 11pt; font-weight: 700; }
|
||||
|
||||
.kennzahlen {
|
||||
display: flex;
|
||||
gap: 6mm;
|
||||
margin-top: 3mm;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.kennzahlen strong { font-size: 11pt; }
|
||||
|
||||
.verlauf li { margin-bottom: 1.5mm; break-inside: avoid; }
|
||||
.verlauf .zeit { font-weight: 700; }
|
||||
.verlauf .wichtig { color: #b91c1c; font-weight: 700; }
|
||||
|
||||
.bericht {
|
||||
white-space: pre-line;
|
||||
margin-top: 2mm;
|
||||
}
|
||||
|
||||
.hinweis {
|
||||
margin-top: 6mm;
|
||||
padding: 2.5mm 3mm;
|
||||
background: #f4f4f5;
|
||||
border-left: 1mm solid #999;
|
||||
font-size: 8pt;
|
||||
color: #333;
|
||||
}
|
||||
`;
|
||||
|
||||
/** Baut das eigenständige HTML des Einsatzberichts. */
|
||||
export function berichtHtml(daten: BerichtDaten): string {
|
||||
const { organisation, einsatz, zeiten, kennzahlen, fahrzeuge, kraefte, material, meldungen } =
|
||||
daten;
|
||||
|
||||
const anschrift = [organisation.strasse, [organisation.plz, organisation.ort].filter(Boolean).join(" ")]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Einsatzbericht ${html(einsatz.einsatznummer)}</title>
|
||||
<style>${STIL}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="kopf">
|
||||
<div>
|
||||
<h1>Einsatzbericht</h1>
|
||||
<div class="wehr">${html(organisation.name)}${anschrift ? ` · ${html(anschrift)}` : ""}</div>
|
||||
</div>
|
||||
<div class="nummer">
|
||||
<div class="marke">${html(einsatz.einsatznummer)}</div>
|
||||
<div class="wehr" style="margin-top:1.5mm">${html(einsatz.kategorie)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Einsatz</h2>
|
||||
<div class="felder">
|
||||
${feld("Stichwort", einsatz.stichwort)}
|
||||
${feld("Kategorie", einsatz.kategorie)}
|
||||
${feld("Leitstellennummer", einsatz.ilsNummer)}
|
||||
${feld("Einsatzort", einsatz.adresse)}
|
||||
${feld("Objekt", einsatz.objekt)}
|
||||
${feld("Koordinaten", einsatz.koordinaten)}
|
||||
${feld("Alarmierung", einsatz.alarmierungsart)}
|
||||
${feld("Einsatzleiter", einsatz.einsatzleiter)}
|
||||
${feld("Stärke (FwDV 3)", daten.staerkeText)}
|
||||
${feld("Wetter", [einsatz.wetter, einsatz.temperatur !== null && einsatz.temperatur !== undefined ? `${einsatz.temperatur} °C` : null].filter(Boolean).join(", "))}
|
||||
${feld("Weitere Kräfte", einsatz.weitereKraefte)}
|
||||
${feld("Kostenersatzpflichtig", einsatz.kostenpflichtig ? "ja" : "nein")}
|
||||
</div>
|
||||
|
||||
${einsatz.beschreibung ? `<p style="margin-top:3mm">${html(einsatz.beschreibung)}</p>` : ""}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Zeiten</h2>
|
||||
<div class="zeitleiste">
|
||||
${ZEITSTEMPEL.map(
|
||||
(stempel) => `
|
||||
<div class="stempel">
|
||||
<div class="bezeichnung">${html(ZEITSTEMPEL_TEXT[stempel])}</div>
|
||||
<div class="uhrzeit">${zeiten[stempel] ? html(alsUhrzeit(zeiten[stempel])) : "—"}</div>
|
||||
</div>`,
|
||||
).join("")}
|
||||
</div>
|
||||
|
||||
<div class="kennzahlen">
|
||||
<div>Ausrückzeit <strong>${html(alsDauer(kennzahlen.ausrueckzeit))}</strong></div>
|
||||
<div>Anfahrt <strong>${html(alsDauer(kennzahlen.anfahrtszeit))}</strong></div>
|
||||
<div>Hilfsfrist <strong>${html(alsDauer(kennzahlen.hilfsfrist))}</strong></div>
|
||||
<div>Einsatzdauer <strong>${html(alsDauer(kennzahlen.dauer))}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Fahrzeuge</h2>
|
||||
${
|
||||
fahrzeuge.length === 0
|
||||
? "<p>Kein Fahrzeug erfasst.</p>"
|
||||
: `<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fahrzeug</th>
|
||||
<th>ausgerückt</th>
|
||||
<th>eingetroffen</th>
|
||||
<th>eingerückt</th>
|
||||
<th class="zahl">km</th>
|
||||
<th class="zahl">Wasser</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${fahrzeuge
|
||||
.map(
|
||||
(fahrzeug) => `
|
||||
<tr>
|
||||
<td>
|
||||
${html(fahrzeug.funkrufname)}
|
||||
${fahrzeug.bezeichnung ? `<div style="font-size:8pt;color:#555">${html(fahrzeug.bezeichnung)}</div>` : ""}
|
||||
</td>
|
||||
<td>${fahrzeug.ausgeruecktUm ? html(alsUhrzeit(fahrzeug.ausgeruecktUm)) : "—"}</td>
|
||||
<td>${fahrzeug.eingetroffenUm ? html(alsUhrzeit(fahrzeug.eingetroffenUm)) : "—"}</td>
|
||||
<td>${fahrzeug.eingeruecktUm ? html(alsUhrzeit(fahrzeug.eingeruecktUm)) : "—"}</td>
|
||||
<td class="zahl">${fahrzeug.kilometer ?? "—"}</td>
|
||||
<td class="zahl">${fahrzeug.wasserabgabe ? `${fahrzeug.wasserabgabe} l` : "—"}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>`
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Eingesetzte Kräfte — Stärke ${html(daten.staerkeText)}</h2>
|
||||
${
|
||||
kraefte.length === 0
|
||||
? "<p>Keine Einsatzkraft erfasst.</p>"
|
||||
: `<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Funktion</th>
|
||||
<th>Fahrzeug</th>
|
||||
<th>Atemschutz</th>
|
||||
<th>von</th>
|
||||
<th>bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${kraefte
|
||||
.map(
|
||||
(kraft) => `
|
||||
<tr>
|
||||
<td>${html(kraft.name)}</td>
|
||||
<td>${html(FUNKTION_TEXT[kraft.funktion] ?? kraft.funktion)}</td>
|
||||
<td>${html(kraft.fahrzeug ?? "—")}</td>
|
||||
<td>${kraft.atemschutz ? "ja" : "—"}</td>
|
||||
<td>${kraft.von ? html(alsUhrzeit(kraft.von)) : "—"}</td>
|
||||
<td>${kraft.bis ? html(alsUhrzeit(kraft.bis)) : "—"}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>`
|
||||
}
|
||||
</section>
|
||||
|
||||
${
|
||||
material.length > 0
|
||||
? `<section>
|
||||
<h2>Material</h2>
|
||||
<table>
|
||||
<thead><tr><th>Bezeichnung</th><th>Menge</th><th>Anmerkung</th></tr></thead>
|
||||
<tbody>
|
||||
${material
|
||||
.map(
|
||||
(posten) => `
|
||||
<tr>
|
||||
<td>${html(posten.bezeichnung)}</td>
|
||||
<td>${html(posten.menge)}</td>
|
||||
<td>${posten.beschaedigt ? "beschädigt" : "verbraucht"}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>`
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
meldungen.length > 0
|
||||
? `<section>
|
||||
<h2>Verlauf</h2>
|
||||
<ul class="verlauf">
|
||||
${meldungen
|
||||
.map(
|
||||
(meldung) => `
|
||||
<li>
|
||||
<span class="zeit">${html(alsUhrzeit(meldung.zeitpunkt))}</span>
|
||||
${meldung.wichtig ? '<span class="wichtig">[wichtig]</span>' : ""}
|
||||
${html(meldung.text)}
|
||||
${meldung.verfasser ? `<span style="color:#555"> — ${html(meldung.verfasser)}</span>` : ""}
|
||||
</li>`,
|
||||
)
|
||||
.join("")}
|
||||
</ul>
|
||||
</section>`
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
einsatz.bericht
|
||||
? `<section>
|
||||
<h2>Bericht</h2>
|
||||
<div class="bericht">${html(einsatz.bericht)}</div>
|
||||
</section>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<section>
|
||||
<h2>Ergebnis</h2>
|
||||
<div class="felder">
|
||||
${feld("Gerettete Personen", String(einsatz.gerettet))}
|
||||
${feld("Verletzte", String(einsatz.verletzt))}
|
||||
${feld("Getötete", String(einsatz.getoetet))}
|
||||
${feld(
|
||||
"Geschätzter Sachschaden",
|
||||
einsatz.sachschaden !== null && einsatz.sachschaden !== undefined
|
||||
? alsEuro(einsatz.sachschaden)
|
||||
: null,
|
||||
)}
|
||||
${feld("Freigegeben von", einsatz.freigegebenVon)}
|
||||
${feld("Freigegeben am", einsatz.freigegebenAm ? alsDatumZeit(einsatz.freigegebenAm) : null)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="hinweis">
|
||||
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Dieser Bericht
|
||||
ist nach der Freigabe unveränderbar gespeichert.
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function kopfzeile(daten: BerichtDaten): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
|
||||
width: 100%; margin: 0; padding: 0 12mm; }
|
||||
.zeile { display: flex; justify-content: space-between; }
|
||||
</style></head><body>
|
||||
<div class="zeile">
|
||||
<span>${html(daten.organisation.name)}</span>
|
||||
<span>Einsatzbericht ${html(daten.einsatz.einsatznummer)} · ${html(daten.einsatz.stichwort)}</span>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function fusszeile(daten: BerichtDaten): string {
|
||||
const datum = daten.zeiten.alarmiertUm ? alsDatum(daten.zeiten.alarmiertUm) : "";
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
|
||||
width: 100%; margin: 0; padding: 0 12mm; }
|
||||
.zeile { display: flex; justify-content: space-between; }
|
||||
</style></head><body>
|
||||
<div class="zeile">
|
||||
<span>Einsatz vom ${html(datum)}</span>
|
||||
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
/** Erzeugt den Einsatzbericht als PDF. */
|
||||
export async function berichtPdf(daten: BerichtDaten): Promise<Buffer> {
|
||||
return htmlZuPdf(berichtHtml(daten), {
|
||||
randOben: 0.55,
|
||||
randUnten: 0.55,
|
||||
randLinks: 0.5,
|
||||
randRechts: 0.5,
|
||||
hintergrundDrucken: true,
|
||||
kopfzeileHtml: kopfzeile(daten),
|
||||
fusszeileHtml: fusszeile(daten),
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Jahresexport für die Gemeinde
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface JahresexportDaten {
|
||||
organisation: { name: string };
|
||||
jahr: number;
|
||||
einsaetze: Array<{
|
||||
einsatznummer: string;
|
||||
datum: Date | null;
|
||||
stichwort: string;
|
||||
kategorie: string;
|
||||
ort: string;
|
||||
staerke: string;
|
||||
hilfsfrist: number | null;
|
||||
dauer: number | null;
|
||||
}>;
|
||||
nachKategorie: Array<{ kategorie: string; anzahl: number }>;
|
||||
zeiten: { hilfsfristP80: number | null; hilfsfristMedian: number | null; gemessen: number };
|
||||
personen: { gerettet: number; verletzt: number; getoetet: number };
|
||||
uebungen: { gesamt: number; teilnahmen: number };
|
||||
erzeugtAm: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Sammelexport eines Jahres.
|
||||
*
|
||||
* Das ist das Blatt, das die Wehr der Gemeinde vorlegt: wie viele Einsätze
|
||||
* welcher Art, wie schnell, und was dabei herauskam. Querformat, weil die
|
||||
* Tabelle sonst nicht lesbar ist.
|
||||
*/
|
||||
export function jahresexportHtml(daten: JahresexportDaten): string {
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Einsatzstatistik ${daten.jahr} — ${html(daten.organisation.name)}</title>
|
||||
<style>${STIL}
|
||||
.felder { grid-template-columns: repeat(4, 1fr); }
|
||||
td, th { font-size: 8.5pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="kopf">
|
||||
<div>
|
||||
<h1>Einsatzstatistik ${daten.jahr}</h1>
|
||||
<div class="wehr">${html(daten.organisation.name)}</div>
|
||||
</div>
|
||||
<div class="nummer">
|
||||
<div class="marke">${daten.einsaetze.length} Einsätze</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Überblick</h2>
|
||||
<div class="felder">
|
||||
${feld("Einsätze gesamt", String(daten.einsaetze.length))}
|
||||
${feld(
|
||||
"Hilfsfrist (80 %)",
|
||||
daten.zeiten.hilfsfristP80 !== null ? alsDauer(daten.zeiten.hilfsfristP80) : null,
|
||||
)}
|
||||
${feld(
|
||||
"Hilfsfrist (Median)",
|
||||
daten.zeiten.hilfsfristMedian !== null ? alsDauer(daten.zeiten.hilfsfristMedian) : null,
|
||||
)}
|
||||
${feld("gemessen an", `${daten.zeiten.gemessen} Einsätzen`)}
|
||||
${feld("Gerettete Personen", String(daten.personen.gerettet))}
|
||||
${feld("Verletzte", String(daten.personen.verletzt))}
|
||||
${feld("Getötete", String(daten.personen.getoetet))}
|
||||
${feld("Übungen", `${daten.uebungen.gesamt} mit ${daten.uebungen.teilnahmen} Teilnahmen`)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Nach Kategorie</h2>
|
||||
<table>
|
||||
<thead><tr><th>Kategorie</th><th class="zahl">Anzahl</th></tr></thead>
|
||||
<tbody>
|
||||
${daten.nachKategorie
|
||||
.map(
|
||||
(eintrag) =>
|
||||
`<tr><td>${html(eintrag.kategorie)}</td><td class="zahl">${eintrag.anzahl}</td></tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Einsätze im Einzelnen</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nummer</th>
|
||||
<th>Datum</th>
|
||||
<th>Stichwort</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Ort</th>
|
||||
<th>Stärke</th>
|
||||
<th class="zahl">Hilfsfrist</th>
|
||||
<th class="zahl">Dauer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${daten.einsaetze
|
||||
.map(
|
||||
(einsatz) => `
|
||||
<tr>
|
||||
<td>${html(einsatz.einsatznummer)}</td>
|
||||
<td>${einsatz.datum ? html(alsDatum(einsatz.datum)) : "—"}</td>
|
||||
<td>${html(einsatz.stichwort)}</td>
|
||||
<td>${html(einsatz.kategorie)}</td>
|
||||
<td>${html(einsatz.ort)}</td>
|
||||
<td>${html(einsatz.staerke)}</td>
|
||||
<td class="zahl">${html(alsDauer(einsatz.hilfsfrist))}</td>
|
||||
<td class="zahl">${html(alsDauer(einsatz.dauer))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div class="hinweis">
|
||||
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Enthalten sind
|
||||
alle nicht gelöschten Einsätze des Jahres ${daten.jahr}.
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Erzeugt den Jahresexport als PDF — quer, damit die Tabelle passt. */
|
||||
export async function jahresexportPdf(daten: JahresexportDaten): Promise<Buffer> {
|
||||
return htmlZuPdf(jahresexportHtml(daten), {
|
||||
quer: true,
|
||||
randOben: 0.5,
|
||||
randUnten: 0.5,
|
||||
randLinks: 0.4,
|
||||
randRechts: 0.4,
|
||||
hintergrundDrucken: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import "server-only";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { env } from "@/lib/env";
|
||||
import { hashen, pruefen } from "@/lib/passwort";
|
||||
import { ohneMandantenfilter } from "@/lib/db";
|
||||
import { zugriffZaehlen } from "@/lib/ratelimit";
|
||||
|
||||
/**
|
||||
* Einsatzportal für Fremdwehren.
|
||||
*
|
||||
* Die Lage: bei einer größeren Sache sind Nachbarwehren, THW oder
|
||||
* Rettungsdienst mit vor Ort. Die haben kein Konto in FLORIAN und sollen auch
|
||||
* keines bekommen — sie sollen den Stand sehen und eine Meldung mit Foto
|
||||
* absetzen können, mehr nicht.
|
||||
*
|
||||
* Der Weg dahin ist ein Link, den der Einsatzleiter erzeugt und als QR-Code
|
||||
* zeigt oder weiterschickt. Vier Schranken sichern ihn ab:
|
||||
*
|
||||
* 1. **Zufall.** Das Geheimnis im Link sind 32 zufällige Bytes. Raten ist
|
||||
* aussichtslos.
|
||||
* 2. **Nur ein Hash liegt in der Datenbank.** Wer sie liest, bekommt keinen
|
||||
* gültigen Link.
|
||||
* 3. **Zeitlich begrenzt.** Nach `PORTAL_GUELTIGKEIT_STUNDEN` ist Schluss;
|
||||
* widerrufen lässt er sich jederzeit sofort.
|
||||
* 4. **Zugriffsbegrenzung.** Höchstens `PORTAL_MAX_ZUGRIFFE` Aufrufe je Link
|
||||
* und Stunde.
|
||||
*
|
||||
* Und eine fünfte, die keine technische ist: das Portal zeigt Stichwort, Ort
|
||||
* und Lagemeldungen — keine Namen von Einsatzkräften, keine Patientendaten,
|
||||
* keine Berichte.
|
||||
*/
|
||||
|
||||
/** Aufbau des Links: `<zugangId>.<geheimnis>`. */
|
||||
const TRENNER = ".";
|
||||
|
||||
export interface NeuerZugang {
|
||||
/** Der vollständige Token für den Link. Nur jetzt einmal im Klartext. */
|
||||
token: string;
|
||||
tokenHash: string;
|
||||
gueltigBis: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt Geheimnis und Hash für einen neuen Zugang.
|
||||
*
|
||||
* Die Kennung des Datensatzes wird erst danach bekannt, deshalb setzt der
|
||||
* Aufrufer sie mit `tokenZusammensetzen` davor.
|
||||
*/
|
||||
export async function zugangErzeugen(gueltigkeitStunden?: number): Promise<NeuerZugang> {
|
||||
const geheimnis = randomBytes(32).toString("base64url");
|
||||
const stunden = gueltigkeitStunden ?? env.PORTAL_GUELTIGKEIT_STUNDEN;
|
||||
|
||||
return {
|
||||
token: geheimnis,
|
||||
tokenHash: await hashen(geheimnis),
|
||||
gueltigBis: new Date(Date.now() + stunden * 3600_000),
|
||||
};
|
||||
}
|
||||
|
||||
/** Setzt Kennung und Geheimnis zum Token des Links zusammen. */
|
||||
export function tokenZusammensetzen(zugangId: string, geheimnis: string): string {
|
||||
return `${zugangId}${TRENNER}${geheimnis}`;
|
||||
}
|
||||
|
||||
/** Zerlegt den Token wieder. Gibt null zurück, wenn er nicht passt. */
|
||||
export function tokenZerlegen(token: string): { zugangId: string; geheimnis: string } | null {
|
||||
const stelle = token.indexOf(TRENNER);
|
||||
if (stelle <= 0 || stelle === token.length - 1) return null;
|
||||
|
||||
const zugangId = token.slice(0, stelle);
|
||||
const geheimnis = token.slice(stelle + 1);
|
||||
|
||||
// Grobe Vorprüfung, bevor irgendetwas in eine Abfrage wandert.
|
||||
if (!/^[0-9a-f-]{36}$/i.test(zugangId)) return null;
|
||||
if (!/^[A-Za-z0-9_-]{20,200}$/.test(geheimnis)) return null;
|
||||
|
||||
return { zugangId, geheimnis };
|
||||
}
|
||||
|
||||
/** Die vollständige Adresse des Portals für einen Token. */
|
||||
export function portalAdresse(token: string): string {
|
||||
return `${env.APP_URL}/portal/${token}`;
|
||||
}
|
||||
|
||||
export type PortalFehler =
|
||||
| "abgeschaltet"
|
||||
| "ungueltig"
|
||||
| "abgelaufen"
|
||||
| "widerrufen"
|
||||
| "zu_viele_zugriffe";
|
||||
|
||||
export interface PortalSitzung {
|
||||
zugangId: string;
|
||||
einsatzId: string;
|
||||
organisationId: string;
|
||||
bezeichnung: string;
|
||||
gueltigBis: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft einen Token und liefert den Zugang.
|
||||
*
|
||||
* Läuft bewusst ohne Mandantenfilter: der Aufrufer hat keine Sitzung, und
|
||||
* welche Wehr gemeint ist, steht erst im gefundenen Datensatz. Alles Weitere
|
||||
* passiert danach im Mandantenkontext dieser Wehr.
|
||||
*/
|
||||
export async function portalPruefen(
|
||||
token: string,
|
||||
ipAdresse?: string | null,
|
||||
): Promise<{ sitzung: PortalSitzung } | { fehler: PortalFehler; wartenSekunden?: number }> {
|
||||
if (!env.PORTAL_ENABLED) return { fehler: "abgeschaltet" };
|
||||
|
||||
const zerlegt = tokenZerlegen(token);
|
||||
if (!zerlegt) return { fehler: "ungueltig" };
|
||||
|
||||
// Erst die Zugriffsbegrenzung, dann der teure bcrypt-Vergleich. Sonst wäre
|
||||
// die Prüfung selbst der Hebel für eine Überlastung.
|
||||
const begrenzung = await zugriffZaehlen(
|
||||
`portal:${zerlegt.zugangId}`,
|
||||
env.PORTAL_MAX_ZUGRIFFE,
|
||||
);
|
||||
|
||||
if (!begrenzung.erlaubt) {
|
||||
return { fehler: "zu_viele_zugriffe", wartenSekunden: begrenzung.zuruecksetztIn };
|
||||
}
|
||||
|
||||
const zugang = await ohneMandantenfilter(
|
||||
"Portal-Zugang prüfen — der Aufrufer hat keine Sitzung",
|
||||
(db) =>
|
||||
db.portalZugang.findUnique({
|
||||
where: { id: zerlegt.zugangId },
|
||||
select: {
|
||||
id: true,
|
||||
organisationId: true,
|
||||
einsatzId: true,
|
||||
bezeichnung: true,
|
||||
tokenHash: true,
|
||||
gueltigBis: true,
|
||||
widerrufenAm: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!zugang) return { fehler: "ungueltig" };
|
||||
if (!(await pruefen(zerlegt.geheimnis, zugang.tokenHash))) return { fehler: "ungueltig" };
|
||||
if (zugang.widerrufenAm) return { fehler: "widerrufen" };
|
||||
if (zugang.gueltigBis.getTime() < Date.now()) return { fehler: "abgelaufen" };
|
||||
|
||||
// Jeder Aufruf wird mitgezählt. Ein Link, der nach dem Einsatz noch benutzt
|
||||
// wird, fällt so auf.
|
||||
await ohneMandantenfilter("Portal-Aufruf mitzählen", (db) =>
|
||||
db.portalZugang.update({
|
||||
where: { id: zugang.id },
|
||||
data: {
|
||||
aufrufe: { increment: 1 },
|
||||
letzterAufruf: new Date(),
|
||||
letzteIp: ipAdresse ?? null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
sitzung: {
|
||||
zugangId: zugang.id,
|
||||
einsatzId: zugang.einsatzId,
|
||||
organisationId: zugang.organisationId,
|
||||
bezeichnung: zugang.bezeichnung,
|
||||
gueltigBis: zugang.gueltigBis,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const PORTAL_FEHLER_TEXT: Record<PortalFehler, string> = {
|
||||
abgeschaltet: "Das Einsatzportal ist in dieser Installation abgeschaltet.",
|
||||
ungueltig: "Dieser Link ist nicht gültig.",
|
||||
abgelaufen: "Dieser Link ist abgelaufen. Die Wehr kann einen neuen erzeugen.",
|
||||
widerrufen: "Dieser Link wurde zurückgezogen.",
|
||||
zu_viele_zugriffe: "Zu viele Zugriffe in kurzer Zeit. Bitte später noch einmal versuchen.",
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Einsatzstärke nach FwDV 3.
|
||||
*
|
||||
* Die Stärke ist die Kurzform dafür, wer ausgerückt ist. Geschrieben wird sie
|
||||
* als `Führer / Unterführer / Mannschaft / Gesamt`, gesprochen "eins, drei,
|
||||
* fünf, neun".
|
||||
*
|
||||
* Die taktischen Einheiten der FwDV 3:
|
||||
*
|
||||
* | Einheit | Stärke | wer |
|
||||
* |---|---|---|
|
||||
* | Trupp | 0/1/1/2 | Truppführer und Truppmann |
|
||||
* | Staffel | 1/1/4/6 | Gruppenführer, Maschinist, zwei Trupps |
|
||||
* | Gruppe | 1/3/5/9 | Gruppenführer, Maschinist, Melder, drei Trupps |
|
||||
* | Zug | 1/5/12/18 | Zugführer, Melder, Gruppe und Staffel |
|
||||
*
|
||||
* **Achtung, regional unterschiedlich.** Manche Wehren zählen den Maschinisten
|
||||
* zu den Unterführern, andere zur Mannschaft; verbreitet ist auch die
|
||||
* dreistellige Schreibweise ohne Unterführer (Gruppe = 1/8/9). Die Zuordnung
|
||||
* steht deshalb an einer Stelle und ist bewusst leicht zu ändern.
|
||||
*/
|
||||
|
||||
export type Funktion =
|
||||
| "EINSATZLEITER"
|
||||
| "ZUGFUEHRER"
|
||||
| "GRUPPENFUEHRER"
|
||||
| "STAFFELFUEHRER"
|
||||
| "TRUPPFUEHRER"
|
||||
| "MASCHINIST"
|
||||
| "MELDER"
|
||||
| "TRUPPMANN"
|
||||
| "ATEMSCHUTZTRAEGER"
|
||||
| "SICHERHEITSTRUPP"
|
||||
| "SANITAETER"
|
||||
| "SONSTIGES";
|
||||
|
||||
export const FUNKTION_TEXT: Record<Funktion, string> = {
|
||||
EINSATZLEITER: "Einsatzleiter",
|
||||
ZUGFUEHRER: "Zugführer",
|
||||
GRUPPENFUEHRER: "Gruppenführer",
|
||||
STAFFELFUEHRER: "Staffelführer",
|
||||
TRUPPFUEHRER: "Truppführer",
|
||||
MASCHINIST: "Maschinist",
|
||||
MELDER: "Melder",
|
||||
TRUPPMANN: "Truppmann",
|
||||
ATEMSCHUTZTRAEGER: "Atemschutzgeräteträger",
|
||||
SICHERHEITSTRUPP: "Sicherheitstrupp",
|
||||
SANITAETER: "Sanitäter",
|
||||
SONSTIGES: "sonstige Funktion",
|
||||
};
|
||||
|
||||
/** Wer als Führer zählt: wer eine taktische Einheit führt. */
|
||||
const FUEHRER: Funktion[] = [
|
||||
"EINSATZLEITER",
|
||||
"ZUGFUEHRER",
|
||||
"GRUPPENFUEHRER",
|
||||
"STAFFELFUEHRER",
|
||||
];
|
||||
|
||||
/** Wer als Unterführer zählt: wer innerhalb der Einheit einen Trupp führt. */
|
||||
const UNTERFUEHRER: Funktion[] = ["TRUPPFUEHRER"];
|
||||
|
||||
export interface Staerke {
|
||||
fuehrer: number;
|
||||
unterfuehrer: number;
|
||||
mannschaft: number;
|
||||
gesamt: number;
|
||||
}
|
||||
|
||||
export const LEERE_STAERKE: Staerke = {
|
||||
fuehrer: 0,
|
||||
unterfuehrer: 0,
|
||||
mannschaft: 0,
|
||||
gesamt: 0,
|
||||
};
|
||||
|
||||
/** Zählt die Funktionen zur Stärke zusammen. */
|
||||
export function staerkeBerechnen(funktionen: Funktion[]): Staerke {
|
||||
const staerke = { ...LEERE_STAERKE };
|
||||
|
||||
for (const funktion of funktionen) {
|
||||
if (FUEHRER.includes(funktion)) staerke.fuehrer += 1;
|
||||
else if (UNTERFUEHRER.includes(funktion)) staerke.unterfuehrer += 1;
|
||||
else staerke.mannschaft += 1;
|
||||
|
||||
staerke.gesamt += 1;
|
||||
}
|
||||
|
||||
return staerke;
|
||||
}
|
||||
|
||||
/** Die übliche Schreibweise: "1/3/5/9". */
|
||||
export function staerkeAlsText(staerke: Staerke): string {
|
||||
return `${staerke.fuehrer}/${staerke.unterfuehrer}/${staerke.mannschaft}/${staerke.gesamt}`;
|
||||
}
|
||||
|
||||
/** Die taktischen Einheiten der FwDV 3, von klein nach groß. */
|
||||
export const EINHEITEN = [
|
||||
{ name: "Trupp", staerke: 2 },
|
||||
{ name: "Staffel", staerke: 6 },
|
||||
{ name: "Gruppe", staerke: 9 },
|
||||
{ name: "Zug", staerke: 18 },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Welcher taktischen Einheit die Mannschaft entspricht.
|
||||
*
|
||||
* Gibt die größte Einheit zurück, die vollständig besetzt ist — und dazu, wie
|
||||
* viele darüber hinaus mitgefahren sind. Eine Wehr rückt selten in exakter
|
||||
* Sollstärke aus; die Angabe soll trotzdem etwas sagen.
|
||||
*/
|
||||
export function taktischeEinheit(staerke: Staerke): {
|
||||
einheit: string | null;
|
||||
ueberzaehlig: number;
|
||||
} {
|
||||
if (staerke.gesamt === 0) return { einheit: null, ueberzaehlig: 0 };
|
||||
|
||||
let gefunden: (typeof EINHEITEN)[number] | null = null;
|
||||
for (const einheit of EINHEITEN) {
|
||||
if (staerke.gesamt >= einheit.staerke) gefunden = einheit;
|
||||
}
|
||||
|
||||
if (!gefunden) {
|
||||
return { einheit: null, ueberzaehlig: staerke.gesamt };
|
||||
}
|
||||
|
||||
return { einheit: gefunden.name, ueberzaehlig: staerke.gesamt - gefunden.staerke };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlt eine Führungskraft?
|
||||
*
|
||||
* Ohne Führer keine taktische Einheit — das ist keine Formsache, sondern der
|
||||
* Unterschied zwischen einer Einheit und einer Ansammlung von Leuten. Die
|
||||
* Oberfläche weist darauf hin; verhindert wird das Speichern nicht, denn beim
|
||||
* Nachtragen eines Einsatzes von vor drei Wochen weiß man es manchmal nicht
|
||||
* mehr genau.
|
||||
*/
|
||||
export function staerkeHinweise(staerke: Staerke): string[] {
|
||||
const hinweise: string[] = [];
|
||||
|
||||
if (staerke.gesamt === 0) {
|
||||
hinweise.push("Zu diesem Einsatz ist noch niemand erfasst.");
|
||||
return hinweise;
|
||||
}
|
||||
|
||||
if (staerke.fuehrer === 0) {
|
||||
hinweise.push("Es ist keine Führungskraft erfasst — wer hat den Einsatz geführt?");
|
||||
}
|
||||
|
||||
if (staerke.gesamt < 2) {
|
||||
hinweise.push("Ein Trupp besteht aus zwei Kräften. Alleine geht niemand vor.");
|
||||
}
|
||||
|
||||
return hinweise;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
Alarmierungsart,
|
||||
EinsatzFunktion,
|
||||
EinsatzKategorie,
|
||||
EinsatzStatus,
|
||||
LagemeldungQuelle,
|
||||
UebungsArt,
|
||||
} from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Klartext zu den Aufzählungen aus Phase 3.
|
||||
*
|
||||
* Wie bei der Wartung: eine Datei ohne "use client", damit Server- und
|
||||
* Client-Komponenten dieselben Beschriftungen verwenden können.
|
||||
*/
|
||||
|
||||
export const KATEGORIE_TEXT: Record<EinsatzKategorie, string> = {
|
||||
BRAND_KLEIN: "Brand klein",
|
||||
BRAND_MITTEL: "Brand mittel",
|
||||
BRAND_GROSS: "Brand groß",
|
||||
TECHNISCHE_HILFE_KLEIN: "Technische Hilfeleistung klein",
|
||||
TECHNISCHE_HILFE_MITTEL: "Technische Hilfeleistung mittel",
|
||||
TECHNISCHE_HILFE_GROSS: "Technische Hilfeleistung groß",
|
||||
GEFAHRGUT: "Gefahrguteinsatz",
|
||||
RETTUNGSDIENST: "Unterstützung Rettungsdienst",
|
||||
BRANDMELDEANLAGE: "Brandmeldeanlage",
|
||||
BOESWILLIGER_ALARM: "böswilliger Alarm",
|
||||
SICHERHEITSWACHE: "Sicherheitswache",
|
||||
UNTERSTUETZUNG: "Unterstützung / Amtshilfe",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
|
||||
/** Grobe Einordnung für Farben und Auswertungen. */
|
||||
export const KATEGORIE_GRUPPE: Record<EinsatzKategorie, "brand" | "hilfe" | "sonstige"> = {
|
||||
BRAND_KLEIN: "brand",
|
||||
BRAND_MITTEL: "brand",
|
||||
BRAND_GROSS: "brand",
|
||||
TECHNISCHE_HILFE_KLEIN: "hilfe",
|
||||
TECHNISCHE_HILFE_MITTEL: "hilfe",
|
||||
TECHNISCHE_HILFE_GROSS: "hilfe",
|
||||
GEFAHRGUT: "hilfe",
|
||||
RETTUNGSDIENST: "hilfe",
|
||||
BRANDMELDEANLAGE: "sonstige",
|
||||
BOESWILLIGER_ALARM: "sonstige",
|
||||
SICHERHEITSWACHE: "sonstige",
|
||||
UNTERSTUETZUNG: "sonstige",
|
||||
SONSTIGES: "sonstige",
|
||||
};
|
||||
|
||||
export const EINSATZ_STATUS_TEXT: Record<EinsatzStatus, string> = {
|
||||
ENTWURF: "Entwurf",
|
||||
LAUFEND: "läuft",
|
||||
ABGESCHLOSSEN: "abgeschlossen",
|
||||
FREIGEGEBEN: "freigegeben",
|
||||
};
|
||||
|
||||
export const ALARMIERUNG_TEXT: Record<Alarmierungsart, string> = {
|
||||
SIRENE: "Sirene",
|
||||
MELDEEMPFAENGER: "Meldeempfänger",
|
||||
TELEFON: "Telefon",
|
||||
APP: "App",
|
||||
PERSOENLICH: "persönlich",
|
||||
SONSTIGES: "sonstige",
|
||||
};
|
||||
|
||||
export const FUNKTION_TEXT: Record<EinsatzFunktion, string> = {
|
||||
EINSATZLEITER: "Einsatzleiter",
|
||||
ZUGFUEHRER: "Zugführer",
|
||||
GRUPPENFUEHRER: "Gruppenführer",
|
||||
STAFFELFUEHRER: "Staffelführer",
|
||||
TRUPPFUEHRER: "Truppführer",
|
||||
MASCHINIST: "Maschinist",
|
||||
MELDER: "Melder",
|
||||
TRUPPMANN: "Truppmann",
|
||||
ATEMSCHUTZTRAEGER: "Atemschutzgeräteträger",
|
||||
SICHERHEITSTRUPP: "Sicherheitstrupp",
|
||||
SANITAETER: "Sanitäter",
|
||||
SONSTIGES: "sonstige Funktion",
|
||||
};
|
||||
|
||||
export const UEBUNGSART_TEXT: Record<UebungsArt, string> = {
|
||||
PRAXIS: "Praxisübung",
|
||||
THEORIE: "Theorie",
|
||||
ATEMSCHUTZ: "Atemschutz",
|
||||
MASCHINIST: "Maschinist",
|
||||
FUNK: "Funk",
|
||||
ERSTE_HILFE: "Erste Hilfe",
|
||||
GEMEINSCHAFTSUEBUNG: "Gemeinschaftsübung",
|
||||
SONSTIGES: "Sonstiges",
|
||||
};
|
||||
|
||||
export const QUELLE_TEXT: Record<LagemeldungQuelle, string> = {
|
||||
INTERN: "aus der Wehr",
|
||||
PORTAL: "über das Portal",
|
||||
};
|
||||
|
||||
export const MONAT_KURZ = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mär",
|
||||
"Apr",
|
||||
"Mai",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Okt",
|
||||
"Nov",
|
||||
"Dez",
|
||||
] as const;
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Einsatzzeiten.
|
||||
*
|
||||
* Sechs Zeitstempel beschreiben einen Einsatz vollständig:
|
||||
*
|
||||
* | Stempel | bedeutet |
|
||||
* |---|---|
|
||||
* | alarmiert | die Leitstelle hat alarmiert |
|
||||
* | ausgerückt | das erste Fahrzeug verlässt das Gerätehaus |
|
||||
* | eingetroffen | das erste Fahrzeug ist an der Einsatzstelle |
|
||||
* | Einsatzende | die Arbeit an der Einsatzstelle ist beendet |
|
||||
* | eingerückt | zurück im Gerätehaus |
|
||||
* | einsatzbereit | Fahrzeuge und Geräte sind wieder hergerichtet |
|
||||
*
|
||||
* Daraus ergeben sich die drei Zahlen, nach denen eine Wehr gefragt wird:
|
||||
*
|
||||
* - **Ausrückzeit** — Alarm bis Ausrücken. Sagt etwas über die Wehr aus.
|
||||
* - **Anfahrtszeit** — Ausrücken bis Eintreffen. Sagt etwas über die Lage aus.
|
||||
* - **Hilfsfrist** — Alarm bis Eintreffen. Die Zahl, die in der Diskussion mit
|
||||
* der Gemeinde zählt (in Bayern: zehn Minuten in 80 Prozent der Fälle).
|
||||
*
|
||||
* Reine Funktionen ohne Datenbankzugriff — sie gehen später in die Statistik
|
||||
* ein und müssen ohne laufenden Stack durchtestbar sein.
|
||||
*/
|
||||
|
||||
export interface Einsatzzeiten {
|
||||
alarmiertUm?: Date | null;
|
||||
ausgeruecktUm?: Date | null;
|
||||
eingetroffenUm?: Date | null;
|
||||
einsatzendeUm?: Date | null;
|
||||
eingeruecktUm?: Date | null;
|
||||
einsatzbereitUm?: Date | null;
|
||||
}
|
||||
|
||||
/** Die Stempel in ihrer natürlichen Reihenfolge. */
|
||||
export const ZEITSTEMPEL = [
|
||||
"alarmiertUm",
|
||||
"ausgeruecktUm",
|
||||
"eingetroffenUm",
|
||||
"einsatzendeUm",
|
||||
"eingeruecktUm",
|
||||
"einsatzbereitUm",
|
||||
] as const;
|
||||
|
||||
export type Zeitstempel = (typeof ZEITSTEMPEL)[number];
|
||||
|
||||
export const ZEITSTEMPEL_TEXT: Record<Zeitstempel, string> = {
|
||||
alarmiertUm: "alarmiert",
|
||||
ausgeruecktUm: "ausgerückt",
|
||||
eingetroffenUm: "eingetroffen",
|
||||
einsatzendeUm: "Einsatzende",
|
||||
eingeruecktUm: "eingerückt",
|
||||
einsatzbereitUm: "einsatzbereit",
|
||||
};
|
||||
|
||||
/** Minuten zwischen zwei Zeitpunkten, auf ganze Minuten gerundet. */
|
||||
function minutenZwischen(von: Date | null | undefined, bis: Date | null | undefined): number | null {
|
||||
if (!von || !bis) return null;
|
||||
const minuten = Math.round((bis.getTime() - von.getTime()) / 60_000);
|
||||
return minuten >= 0 ? minuten : null;
|
||||
}
|
||||
|
||||
/** Alarm bis Ausrücken. */
|
||||
export function ausrueckzeit(zeiten: Einsatzzeiten): number | null {
|
||||
return minutenZwischen(zeiten.alarmiertUm, zeiten.ausgeruecktUm);
|
||||
}
|
||||
|
||||
/** Ausrücken bis Eintreffen. */
|
||||
export function anfahrtszeit(zeiten: Einsatzzeiten): number | null {
|
||||
return minutenZwischen(zeiten.ausgeruecktUm, zeiten.eingetroffenUm);
|
||||
}
|
||||
|
||||
/** Alarm bis Eintreffen — die Hilfsfrist. */
|
||||
export function hilfsfrist(zeiten: Einsatzzeiten): number | null {
|
||||
return minutenZwischen(zeiten.alarmiertUm, zeiten.eingetroffenUm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie lange der Einsatz gedauert hat.
|
||||
*
|
||||
* Gerechnet wird von der Alarmierung bis zum Einrücken, ersatzweise bis zum
|
||||
* Einsatzende — das ist die Zeit, die den Leuten tatsächlich gefehlt hat.
|
||||
* Die Wiederherstellung der Einsatzbereitschaft zählt bewusst nicht mit: sie
|
||||
* macht der Gerätewart oft am Tag darauf.
|
||||
*/
|
||||
export function einsatzdauer(zeiten: Einsatzzeiten): number | null {
|
||||
return (
|
||||
minutenZwischen(zeiten.alarmiertUm, zeiten.eingeruecktUm) ??
|
||||
minutenZwischen(zeiten.alarmiertUm, zeiten.einsatzendeUm)
|
||||
);
|
||||
}
|
||||
|
||||
/** Alle drei Kennzahlen auf einmal. */
|
||||
export function kennzahlen(zeiten: Einsatzzeiten) {
|
||||
return {
|
||||
ausrueckzeit: ausrueckzeit(zeiten),
|
||||
anfahrtszeit: anfahrtszeit(zeiten),
|
||||
hilfsfrist: hilfsfrist(zeiten),
|
||||
dauer: einsatzdauer(zeiten),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Widersprüche in der Reihenfolge.
|
||||
*
|
||||
* Absichtlich Hinweise und keine Fehler: Zeiten werden oft Tage später aus dem
|
||||
* Gedächtnis nachgetragen, und ein Einsatz, dessen Stempel nicht ganz stimmen,
|
||||
* ist immer noch besser als einer, den niemand erfasst hat. Die Oberfläche
|
||||
* zeigt die Hinweise an, das Speichern verhindern sie nicht.
|
||||
*/
|
||||
export function zeitHinweise(zeiten: Einsatzzeiten): string[] {
|
||||
const hinweise: string[] = [];
|
||||
|
||||
const paare: Array<[Zeitstempel, Zeitstempel]> = [
|
||||
["alarmiertUm", "ausgeruecktUm"],
|
||||
["ausgeruecktUm", "eingetroffenUm"],
|
||||
["eingetroffenUm", "einsatzendeUm"],
|
||||
["einsatzendeUm", "eingeruecktUm"],
|
||||
["eingeruecktUm", "einsatzbereitUm"],
|
||||
];
|
||||
|
||||
for (const [frueher, spaeter] of paare) {
|
||||
const a = zeiten[frueher];
|
||||
const b = zeiten[spaeter];
|
||||
if (a && b && b.getTime() < a.getTime()) {
|
||||
hinweise.push(
|
||||
`„${ZEITSTEMPEL_TEXT[spaeter]}" liegt vor „${ZEITSTEMPEL_TEXT[frueher]}" — bitte prüfen.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const frist = hilfsfrist(zeiten);
|
||||
if (frist !== null && frist > 60) {
|
||||
hinweise.push(
|
||||
`Zwischen Alarm und Eintreffen liegen ${frist} Minuten. Stimmen die Zeiten?`,
|
||||
);
|
||||
}
|
||||
|
||||
return hinweise;
|
||||
}
|
||||
|
||||
/** Der nächste Stempel, der gesetzt werden müsste. */
|
||||
export function naechsterStempel(zeiten: Einsatzzeiten): Zeitstempel | null {
|
||||
for (const stempel of ZEITSTEMPEL) {
|
||||
if (!zeiten[stempel]) return stempel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Minuten als "1:05 h" oder "23 min". */
|
||||
export function alsDauer(minuten: number | null | undefined): string {
|
||||
if (minuten === null || minuten === undefined) return "—";
|
||||
if (minuten < 60) return `${minuten} min`;
|
||||
const stunden = Math.floor(minuten / 60);
|
||||
return `${stunden}:${String(minuten % 60).padStart(2, "0")} h`;
|
||||
}
|
||||
@@ -106,6 +106,23 @@ const schema = z.object({
|
||||
LLM_API_KEY: z.string().optional(),
|
||||
LLM_MODELL: z.string().optional(),
|
||||
|
||||
// Einsätze und Übungen (Phase 3)
|
||||
/// Adressen zu Koordinaten auflösen. Ohne eigenen Nominatim aus.
|
||||
GEOCODING_ENABLED: boolAusText(false),
|
||||
/// Adresse des SELBST betriebenen Nominatim. Einsatzadressen gehören nicht
|
||||
/// an einen öffentlichen Dienst.
|
||||
NOMINATIM_URL: z.string().url().optional(),
|
||||
GEOCODING_TIMEOUT: zahl(5),
|
||||
/// Auf welche Länder die Suche eingegrenzt wird (ISO-Codes, kommagetrennt).
|
||||
GEOCODING_LAENDER: z.string().default("de,at,ch"),
|
||||
|
||||
/// Einsatzportal für Fremdwehren: Link ohne Anmeldung, zeitlich begrenzt.
|
||||
PORTAL_ENABLED: boolAusText(true),
|
||||
/// Wie lange ein neu erzeugter Portal-Link gilt.
|
||||
PORTAL_GUELTIGKEIT_STUNDEN: zahl(24),
|
||||
/// Höchstzahl Zugriffe je Portal-Link und Stunde.
|
||||
PORTAL_MAX_ZUGRIFFE: zahl(120),
|
||||
|
||||
// Wartung und Prüfung (Phase 2)
|
||||
/** Wann der nächtliche Lauf Termine plant. Cron-Syntax, Europe/Berlin. */
|
||||
WARTUNG_NACHTLAUF: z.string().default("15 3 * * *"),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import "server-only";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Adressen zu Koordinaten — hinter einer Schnittstelle, wie alle externen
|
||||
* Dienste (Projektregel).
|
||||
*
|
||||
* Vorgesehen ist ein **selbst betriebener** Nominatim, kein öffentlicher.
|
||||
* Einsatzadressen sind besonders schutzwürdig: sie sagen, wo es gebrannt hat
|
||||
* und wer betroffen war. Solche Anfragen gehören nicht an einen fremden
|
||||
* Dienst, und die Nutzungsbedingungen des öffentlichen Nominatim erlauben es
|
||||
* ohnehin nicht.
|
||||
*
|
||||
* Ist nichts konfiguriert, liefert die Suche schlicht kein Ergebnis. Die
|
||||
* Anwendung fällt dann auf die Eingabe von Hand zurück — das ist kein Fehler,
|
||||
* sondern der Normalfall in einer frischen Installation.
|
||||
*/
|
||||
|
||||
export interface Ort {
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
/** Die vom Dienst zurückgegebene Schreibweise der Adresse. */
|
||||
anzeige: string;
|
||||
/** Wie sicher der Treffer ist, 0 bis 1. */
|
||||
guete?: number;
|
||||
}
|
||||
|
||||
export interface Adresssuche {
|
||||
suchen(adresse: string): Promise<Ort[]>;
|
||||
/** Umgekehrt: von Koordinaten zur Adresse — für den Kartenklick ab Phase 6. */
|
||||
rueckwaerts(breitengrad: number, laengengrad: number): Promise<Ort | null>;
|
||||
verfuegbar(): boolean;
|
||||
}
|
||||
|
||||
/** Ohne Konfiguration: keine Treffer, kein Fehler. */
|
||||
class KeineSuche implements Adresssuche {
|
||||
verfuegbar(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async suchen(adresse: string): Promise<Ort[]> {
|
||||
console.info(
|
||||
`[geocoding] (abgeschaltet) Adresse "${adresse}" wird nicht aufgelöst — Koordinaten von Hand eintragen.`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
async rueckwaerts(): Promise<Ort | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein selbst betriebener Nominatim. */
|
||||
class Nominatim implements Adresssuche {
|
||||
verfuegbar(): boolean {
|
||||
return Boolean(env.NOMINATIM_URL);
|
||||
}
|
||||
|
||||
private async abfragen(pfad: string, parameter: Record<string, string>): Promise<unknown> {
|
||||
const adresse = new URL(pfad, env.NOMINATIM_URL);
|
||||
for (const [schluessel, wert] of Object.entries(parameter)) {
|
||||
adresse.searchParams.set(schluessel, wert);
|
||||
}
|
||||
adresse.searchParams.set("format", "jsonv2");
|
||||
// Deutsche Schreibweise der Orte, unabhängig davon, wie der Server steht.
|
||||
adresse.searchParams.set("accept-language", "de");
|
||||
|
||||
const antwort = await fetch(adresse, {
|
||||
signal: AbortSignal.timeout(env.GEOCODING_TIMEOUT * 1000),
|
||||
headers: {
|
||||
// Nominatim verlangt eine erkennbare Kennung, auch beim eigenen Server.
|
||||
"User-Agent": "FLORIAN Feuerwehrverwaltung",
|
||||
},
|
||||
});
|
||||
|
||||
if (!antwort.ok) {
|
||||
throw new Error(`Nominatim antwortete mit ${antwort.status}.`);
|
||||
}
|
||||
|
||||
return antwort.json();
|
||||
}
|
||||
|
||||
async suchen(adresse: string): Promise<Ort[]> {
|
||||
if (!this.verfuegbar() || !adresse.trim()) return [];
|
||||
|
||||
try {
|
||||
const ergebnis = (await this.abfragen("/search", {
|
||||
q: adresse,
|
||||
limit: "5",
|
||||
countrycodes: env.GEOCODING_LAENDER,
|
||||
})) as Array<{
|
||||
lat: string;
|
||||
lon: string;
|
||||
display_name: string;
|
||||
importance?: number;
|
||||
}>;
|
||||
|
||||
return ergebnis.map((treffer) => ({
|
||||
breitengrad: Number(treffer.lat),
|
||||
laengengrad: Number(treffer.lon),
|
||||
anzeige: treffer.display_name,
|
||||
guete: treffer.importance,
|
||||
}));
|
||||
} catch (fehler) {
|
||||
// Ein ausgefallener Geocoder darf die Einsatzerfassung nicht aufhalten.
|
||||
// Im Alarmfall zählt, dass der Einsatz erfasst wird, nicht dass die
|
||||
// Karte stimmt.
|
||||
console.error("[geocoding] Adresssuche fehlgeschlagen:", fehler);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async rueckwaerts(breitengrad: number, laengengrad: number): Promise<Ort | null> {
|
||||
if (!this.verfuegbar()) return null;
|
||||
|
||||
try {
|
||||
const treffer = (await this.abfragen("/reverse", {
|
||||
lat: String(breitengrad),
|
||||
lon: String(laengengrad),
|
||||
})) as { lat: string; lon: string; display_name: string } | null;
|
||||
|
||||
if (!treffer) return null;
|
||||
|
||||
return {
|
||||
breitengrad: Number(treffer.lat),
|
||||
laengengrad: Number(treffer.lon),
|
||||
anzeige: treffer.display_name,
|
||||
};
|
||||
} catch (fehler) {
|
||||
console.error("[geocoding] Rückwärtssuche fehlgeschlagen:", fehler);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sucheCache: Adresssuche | null = null;
|
||||
|
||||
export function adresssuche(): Adresssuche {
|
||||
if (!sucheCache) {
|
||||
sucheCache = env.GEOCODING_ENABLED && env.NOMINATIM_URL ? new Nominatim() : new KeineSuche();
|
||||
}
|
||||
return sucheCache;
|
||||
}
|
||||
|
||||
/** Nur für Tests: eigene Umsetzung unterschieben. */
|
||||
export function _adresssucheErsetzen(ersatz: Adresssuche | null): void {
|
||||
sucheCache = ersatz;
|
||||
}
|
||||
|
||||
/** Baut aus den Einzelfeldern eines Einsatzes eine Suchzeile. */
|
||||
export function adresszeile(teile: {
|
||||
strasse?: string | null;
|
||||
hausnummer?: string | null;
|
||||
plz?: string | null;
|
||||
ort?: string | null;
|
||||
ortsteil?: string | null;
|
||||
}): string {
|
||||
const strasse = [teile.strasse, teile.hausnummer].filter(Boolean).join(" ");
|
||||
const ort = [teile.plz, teile.ort ?? teile.ortsteil].filter(Boolean).join(" ");
|
||||
return [strasse, ort].filter(Boolean).join(", ");
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Laufende Nummern je Wehr und Jahr.
|
||||
*
|
||||
* Prüfprotokolle, Einsätze und Übungen tragen dieselbe Art von Nummer:
|
||||
* `JJJJ-NNNN`, fortlaufend innerhalb eines Jahres, je Wehr getrennt. Sie steht
|
||||
* auf Papier und wird am Telefon genannt — deshalb kurz, sprechbar und ohne
|
||||
* Bindestrichwüste.
|
||||
*
|
||||
* Die Nummern werden nie wiederverwendet. Vergeben wird die höchste vorhandene
|
||||
* plus eins, nicht die Anzahl der Datensätze; sonst bekäme ein im
|
||||
* Aufbewahrungslauf entfernter Vorgang seine Nummer an einen neuen vererbt.
|
||||
*
|
||||
* Reine Zeichenkettenarbeit, kein Datenbankzugriff: wer die Nummer vergibt,
|
||||
* muss dabei selbst für eine Sperre sorgen (siehe `abschliessen` im
|
||||
* Prüfungs-Router und `anlegen` im Einsatz-Router).
|
||||
*/
|
||||
|
||||
/** Vier Stellen reichen für 9999 Vorgänge im Jahr — mehr hat keine Wehr. */
|
||||
const STELLEN = 4;
|
||||
|
||||
/** Bildet "2026-0042". */
|
||||
export function nummerBilden(jahr: number, laufend: number): string {
|
||||
return `${jahr}-${String(laufend).padStart(STELLEN, "0")}`;
|
||||
}
|
||||
|
||||
/** Zerlegt eine Nummer wieder. Gibt null zurück, wenn sie nicht passt. */
|
||||
export function nummerLesen(
|
||||
nummer: string | null | undefined,
|
||||
): { jahr: number; laufend: number } | null {
|
||||
if (!nummer) return null;
|
||||
const treffer = /^(\d{4})-(\d{1,6})$/.exec(nummer.trim());
|
||||
if (!treffer) return null;
|
||||
return { jahr: Number(treffer[1]), laufend: Number(treffer[2]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Die nächste Nummer nach der bisher höchsten.
|
||||
*
|
||||
* @param hoechste die bisher höchste Nummer des Jahres, oder null
|
||||
*/
|
||||
export function naechsteNummer(jahr: number, hoechste: string | null | undefined): string {
|
||||
return nummerBilden(jahr, (nummerLesen(hoechste)?.laufend ?? 0) + 1);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import "server-only";
|
||||
import { redis } from "@/lib/warteschlange";
|
||||
|
||||
/**
|
||||
* Zugriffsbegrenzung über Redis.
|
||||
*
|
||||
* Gebraucht für alles, was ohne Anmeldung erreichbar ist — bisher das
|
||||
* Einsatzportal für Fremdwehren. Ein Link, der aus Versehen in einer
|
||||
* WhatsApp-Gruppe landet, soll die Anwendung nicht in die Knie zwingen.
|
||||
*
|
||||
* Verfahren: ein Zähler je Schlüssel und Zeitfenster. INCR legt den Schlüssel
|
||||
* an, wenn es ihn nicht gibt; beim ersten Zugriff bekommt er eine Lebensdauer.
|
||||
* Das ist ein festes Fenster und keine gleitende Rate — ungenau an der
|
||||
* Fenstergrenze, dafür in zwei Redis-Befehlen erledigt und ohne Zustand in der
|
||||
* Anwendung.
|
||||
*
|
||||
* Fällt Redis aus, wird durchgelassen. Eine Zugriffsbegrenzung, die bei
|
||||
* Störung den Betrieb anhält, richtet mehr Schaden an als sie verhindert —
|
||||
* die eigentliche Zugangsprüfung sitzt ohnehin woanders.
|
||||
*/
|
||||
|
||||
export interface Begrenzung {
|
||||
/** Wurde der Zugriff durchgelassen? */
|
||||
erlaubt: boolean;
|
||||
/** Wie viele Zugriffe im laufenden Fenster noch übrig sind. */
|
||||
uebrig: number;
|
||||
/** Sekunden bis zum Zurücksetzen des Fensters. */
|
||||
zuruecksetztIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt einen Zugriff und sagt, ob er noch erlaubt ist.
|
||||
*
|
||||
* @param schluessel fachliche Kennung, z. B. `portal:<zugangId>`
|
||||
* @param hoechstzahl erlaubte Zugriffe je Fenster
|
||||
* @param fensterSekunden Länge des Fensters
|
||||
*/
|
||||
export async function zugriffZaehlen(
|
||||
schluessel: string,
|
||||
hoechstzahl: number,
|
||||
fensterSekunden = 3600,
|
||||
): Promise<Begrenzung> {
|
||||
const redisSchluessel = `ratelimit:${schluessel}`;
|
||||
|
||||
try {
|
||||
const verbindung = redis();
|
||||
const anzahl = await verbindung.incr(redisSchluessel);
|
||||
|
||||
if (anzahl === 1) {
|
||||
await verbindung.expire(redisSchluessel, fensterSekunden);
|
||||
}
|
||||
|
||||
const rest = await verbindung.ttl(redisSchluessel);
|
||||
|
||||
return {
|
||||
erlaubt: anzahl <= hoechstzahl,
|
||||
uebrig: Math.max(0, hoechstzahl - anzahl),
|
||||
zuruecksetztIn: rest > 0 ? rest : fensterSekunden,
|
||||
};
|
||||
} catch (fehler) {
|
||||
console.error(`[ratelimit] Redis nicht erreichbar (${schluessel}):`, fehler);
|
||||
return { erlaubt: true, uebrig: hoechstzahl, zuruecksetztIn: fensterSekunden };
|
||||
}
|
||||
}
|
||||
|
||||
/** Setzt den Zähler zurück — für Tests und für den Widerruf eines Zugangs. */
|
||||
export async function zaehlerZuruecksetzen(schluessel: string): Promise<void> {
|
||||
try {
|
||||
await redis().del(`ratelimit:${schluessel}`);
|
||||
} catch {
|
||||
// Ein nicht gelöschter Zähler läuft von selbst ab.
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { env } from "@/lib/env";
|
||||
|
||||
export const WARTESCHLANGE = "florian";
|
||||
|
||||
/** Bekannte Auftragsarten. Ab Phase 2 kommen die fachlichen Jobs dazu. */
|
||||
/** Bekannte Auftragsarten. */
|
||||
export const AUFTRAG = {
|
||||
/** Prüft alle Geräte auf fällige Fristen und setzt den Status. */
|
||||
FRISTEN_PRUEFEN: "fristen.pruefen",
|
||||
@@ -24,6 +24,10 @@ export const AUFTRAG = {
|
||||
ERINNERUNG_SENDEN: "erinnerung.senden",
|
||||
/** Erzeugt ein PDF über Gotenberg. */
|
||||
PDF_ERZEUGEN: "pdf.erzeugen",
|
||||
/** Erzeugt einen Einsatzbericht als PDF (Phase 3). */
|
||||
EINSATZBERICHT_ERZEUGEN: "einsatzbericht.erzeugen",
|
||||
/** Erzeugt den Sammelexport eines Jahres für die Gemeinde (Phase 3). */
|
||||
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
|
||||
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
|
||||
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
|
||||
} as const;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
tagesabstand,
|
||||
utcTag,
|
||||
} from "@/lib/stammdaten/fristen";
|
||||
import { nummerBilden, nummerLesen } from "@/lib/nummernkreis";
|
||||
|
||||
/**
|
||||
* Fristenlogik der Wartung.
|
||||
@@ -148,19 +149,11 @@ export function innerhalbPlanungshorizont(
|
||||
* Laufende Protokollnummer im Format "JJJJ-NNNN", z. B. "2026-0042".
|
||||
*
|
||||
* Die Nummer ist je Wehr und Jahr fortlaufend. Sie steht auf dem PDF und ist
|
||||
* das, was in einer Rückfrage der Unfallkasse genannt wird — deshalb kurz,
|
||||
* sprechbar und ohne Bindestrichwüste.
|
||||
* das, was in einer Rückfrage der Unfallkasse genannt wird.
|
||||
*
|
||||
* Ab Phase 3 teilen sich Prüfprotokolle, Einsätze und Übungen denselben
|
||||
* Nummernkreis-Baustein — die Regel ist dieselbe, und zwei Umsetzungen davon
|
||||
* würden über kurz oder lang verschieden aussehen.
|
||||
*/
|
||||
export function protokollNummer(jahr: number, laufend: number): string {
|
||||
return `${jahr}-${String(laufend).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
/** Zerlegt eine Protokollnummer wieder. Gibt null zurück, wenn sie nicht passt. */
|
||||
export function protokollNummerLesen(
|
||||
nummer: string | null | undefined,
|
||||
): { jahr: number; laufend: number } | null {
|
||||
if (!nummer) return null;
|
||||
const treffer = /^(\d{4})-(\d{1,6})$/.exec(nummer.trim());
|
||||
if (!treffer) return null;
|
||||
return { jahr: Number(treffer[1]), laufend: Number(treffer[2]) };
|
||||
}
|
||||
export const protokollNummer = nummerBilden;
|
||||
export const protokollNummerLesen = nummerLesen;
|
||||
|
||||
Reference in New Issue
Block a user