Compare commits

...
Author SHA1 Message Date
florian.wilde 12243f177d Phase 7: Benachrichtigungen und Termine (#8) 2026-09-04 07:19:13 +00:00
Florian WildeandClaude Opus 5 0b8874da26 Phase 7: Benachrichtigungen und Termine
Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und
Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP,
ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als
ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite.

Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere
Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr
verlaesst, bekommt nur die Kurzfassung ohne Namen und Details.

Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp
(Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) --
beides im README begruendet.

56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 08:39:38 +02:00
florian.wilde c779266450 Phase 6: Kartenmodule (#7) 2026-09-04 04:32:47 +00:00
Florian WildeandClaude Opus 5 4cda79434f Bildschirmfotos fuer Phase 6 erneuern
Karte, Objekte und Wasserfoerderung dazu; die uebrigen Bilder auf den
aktuellen Stand gebracht (die Seitenleiste zeigt jetzt die Karte).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 03:11:07 +02:00
Florian WildeandClaude Opus 5 1c1e141666 Kartenmodule ergaenzen
Phase 6: alles, was einen Ort hat — Loeschwasser, Objekte mit Feuerwehrplan,
die Lagekarte eines Einsatzes und die geplante Foerderstrecke.

Keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre Hydranten
liegen, ist niemandes Geschaeft; der Kartenserver laeuft selbst
(tileserver-gl im Compose-Profil "karte"). Ohne ihn zeigt die Karte einen
leeren Grund und sagt das auch — Punkte, Entfernungen und Umkreissuche
funktionieren trotzdem. Dasselbe gilt fuer einen eingetragenen, aber nicht
erreichbaren Server.

Die Umkreissuche laeuft ueber PostGIS: ST_DWithin auf der Geografie mit
GiST-Index rechnet in Metern und nutzt den Index. Gefragt wird waehrend der
Fahrt, und bei tausend Hydranten waere alles andere ein vollstaendiger
Tabellendurchlauf.

Der Import aus GeoJSON und Stadtwerke-CSV ist nachsichtig, aber ehrlich: er
erkennt "DN 100", deutsche Kommazahlen, Datumsangaben und vertauschte Achsen
— raet aber nichts. Fehlt die Position, wird die Zeile uebersprungen; ohne
Statusangabe steht "ungeprueft", nicht "einsatzbereit".

22 taktische Zeichen nach FwDV 102 selbst gezeichnet, die Lagekarte gleicht
ueber denselben Ereignisstrom ab wie die Atemschutzueberwachung.

Die Wasserfoerderung rechnet Reibung, Hoehe und Pumpenzahl; das Ergebnis wird
nie gespeichert, sondern bei jeder Anzeige neu gerechnet. Geht eine Strecke
nicht auf, sagt die Ausgabe das.

Behoben: die Reibungsbeiwerte lagen im ersten Wurf um den Faktor zehn daneben.
Aufgefallen ist es nur, weil die Tests gegen die Tabellenwerte der Ausbildung
pruefen und nicht gegen die eigene Implementierung.

Dazu ein Abruf fuer abgelegte Dateien (/api/dateien/[id]) — bis hierher gab es
nur den Upload.

59 neue Tests (48 Unit, 11 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 01:41:24 +02:00
florian.wilde e1480c0c3e Phase 5: Atemschutzüberwachung nach FwDV 7 (#6) 2026-09-03 21:42:20 +00:00
Florian WildeandClaude Opus 5 4e2daaafd4 Atemschutzueberwachung nach FwDV 7 ergaenzen
Phase 5: das sicherheitsrelevanteste Modul des Projekts. Ueber jeder Ansicht
steht dauerhaft, dass die analoge Atemschutzueberwachung fuehrend bleibt —
abschaltbar nur zusammen mit der Fundstelle einer Freigabe des Kommandanten.

Es gibt keinen Timer. In der Datenbank stehen Zeitstempel; wie lange ein
Trupp unter Atemschutz ist, rechnet jede Ansicht bei jedem Bild neu aus.
Damit ist die Anforderung "uebersteht Neuladen und Verbindungsabbruch" keine
Funktion, die gelingen muss, sondern eine Eigenschaft, die sich nicht
verlieren laesst. Server und Browser rechnen mit denselben Funktionen.

Der Rueckzugsdruck folgt dem doppelten Anmarschverbrauch: was der Weg hinein
gekostet hat, kostet der Weg zurueck noch einmal. Bis der Trupp das
Einsatzziel meldet, greift die Zwei-Drittel-Regel — sie liegt hoeher und ist
damit die vorsichtigere Annahme.

Das Protokoll ist doppelt gesichert: entzogene Rechte und ein Trigger auf
druckmessung und atemschutz_ereignis. Ein Fehler wird durch einen neuen
Eintrag richtiggestellt, nicht durch Aendern.

Verteilt wird ueber Server-Sent Events statt Socket.IO — es geht nur in eine
Richtung, und so bleibt das Deployment bei "next start".

Behoben: ein abgerissener Ereignisstrom meldete sich nicht. Die Anzeige haette
weiter "Verbunden" behauptet, waehrend der Draht tot war. Jetzt gilt Stille
von acht Sekunden als Abriss. Dazu zwei Altlasten: der Navigationseintrag
"Protokoll" fuehrte ins Leere, und die Startseite stand noch auf Phase 1.

68 neue Tests (51 Unit, 17 gegen eine echte Datenbank, 8 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 23:19:05 +02:00
florian.wilde b2b31ed00a Phase 4: Terminal im Gerätehaus mit Offline-Betrieb (#5) 2026-09-03 18:25:35 +00:00
Florian WildeandClaude Opus 5 e500ad6c0e Terminal haelt sich an Aussetzern nicht mehr fest
Drei Nachbesserungen aus dem Gesamtlauf der E2E-Tests:

Die Offline-Seite blieb stehen, wenn nur eine einzelne Anfrage unterwegs
abgebrochen war. Fuer ein Wandtablet ist das falsch herum gedacht: dort
tippt niemand auf "erneut versuchen". Sie versucht es jetzt selbst — sofort,
wenn der Browser wieder Netz meldet, sonst alle zehn Sekunden.

Dazu ein Wiederholungsversuch auch fuer den lokalen Testlauf. Vier
gleichzeitige Arbeiter gegen "next dev" plus bcrypt mit zwoelf Runden lassen
gelegentlich einen Test ins Zeitlimit laufen, der einzeln zuverlaessig gruen
ist — verstreut ueber alle Dateien, nicht an einer Stelle. Ein echter Fehler
faellt zweimal durch und bleibt sichtbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 19:24:14 +02:00
Florian WildeandClaude Opus 5 791523027f Terminal im Geraetehaus mit Offline-Betrieb ergaenzen
Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der
Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz
weg ist.

Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang,
Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs
erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel;
kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt
ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und
verweigert UPDATE und DELETE auf kiosk_vorgang.

Offline: von Hand geschriebener Service Worker, Warteschlange in der
IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste
ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als
Konflikt unter /terminal statt verworfen zu werden.

Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy,
Hallenmonitor mit optionalen DWD-Warnungen, Installation als App.

Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern
neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal
geschickt. Gefunden vom Offline-E2E-Test.

31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:35:48 +02:00
florian.wilde d8d692098d Phase 3: Einsatz- und Übungserfassung (#4) 2026-09-03 12:06:22 +00:00
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
florian.wilde bc62a9c1e8 Phase 2: Wartungs- und Prüfmanagement (#3) 2026-09-03 10:35:44 +00:00
Florian WildeandClaude Opus 5 e503aafc18 Phase 2: Wartungs- und Pruefmanagement
Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf,
Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche
Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung.

Elf neue Tabellen, alle mit organisationId und Row Level Security. Die
Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger
durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung
mit Verweis auf die alte, beide bleiben lesbar.

79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:07:11 +02:00
florian.wilde 7f866ac35c Geräteformular zum Anlegen und Bearbeiten (#2) 2026-09-03 07:53:22 +00:00
Florian WildeandClaude Opus 5 1744403620 Geraeteformular zum Anlegen und Bearbeiten ergaenzen
Schliesst die letzte offene Luecke aus Phase 1. Bis dahin liessen sich Geraete
nur ueber den Import erfassen — fuer einen Bestand richtig, fuer ein einzelnes
nachbeschafftes Geraet umstaendlich.

Neu:
- /geraete/neu und /geraete/<id>/bearbeiten, beide ueber dieselbe Komponente
- Die Lagerung ist ein Umschalter statt zweier Auswahlfelder. Standort und
  Fahrzeug schliessen sich aus; so kann der Fehler gar nicht erst entstehen,
  den die Datenbank sonst abweisen wuerde. Beim Wechsel des Fahrzeugs wird die
  Raumangabe zurueckgesetzt.
- Prueftermin und Lebensdauerende werden beim Tippen vorgerechnet, mit
  denselben Funktionen wie auf dem Server. Beim Speichern rechnet der Server
  erneut und behaelt das letzte Wort.
- Gibt es nur einen Standort, ist er beim Anlegen vorbelegt.
- Knopf "Neues Geraet" in der Liste, "Bearbeiten" auf der Detailseite

Behoben:
- Das Formular stuerzte beim ersten Rechnen ab. Die Vorgabewerte lagen in einem
  "use client"-Modul und wurden von einer Server-Komponente importiert; dort
  kommt nur eine Client-Referenz an, kein Objekt, und das Spreaden ergibt lauter
  undefined. Die Werte liegen jetzt in geraet-formular-werte.ts ohne
  "use client".
- Das Sternchen an Pflichtfeldern wurde von Sprachausgaben mitgelesen
  ("Bezeichnung Stern"). Es traegt jetzt aria-hidden; dass ein Feld ausgefuellt
  werden muss, sagt bereits das required-Attribut.
- Fehlermeldungen in Formularen sind eindeutig ansteuerbar. Next.js haengt
  einen eigenen Routen-Ansager mit der Rolle "alert" in jede Seite.

Tests:
- 7 neue Playwright-Tests zum Formular, darunter die Lagerungsregel und die
  Fristenvorschau
- Zwei davon laufen nicht unter Touch-Emulation: Chromium nimmt dort keine
  zeichenweise Eingabe in Datumsfelder an. Begruendet uebersprungen, die
  Rechnung selbst deckt qr-und-fristen.test.ts ab.
- Insgesamt 104 Unit-Tests und 56 E2E-Tests gruen

Anleitung, Changelog, Devlog, Aufwandsschaetzung und Roadmap fortgeschrieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:20:30 +02:00
florian.wilde 47b8e36a38 Phase 1: Stammdatenverwaltung (#1)
Standorte, Fahrzeuge mit Beladeordnung, Gerätetypen als Vorgabe, Geräte mit
QR-Etiketten, Qualifikationen mit Ablaufwarnung und der Import aus vorhandenen
Tabellen. Enthält zusätzlich die Planung der Kassenverwaltung als Phase 9.
2026-09-03 06:39:50 +00:00
Florian WildeandClaude Opus 5 26b62a3477 Phase 1: Stammdatenverwaltung
Ab hier fuehrt die Wehr ihren Bestand in FLORIAN.

Datenmodell:
- Standort, Geraeteraum, Geraetetyp, Qualifikationstyp und Qualifikation
- Fahrzeug um Fahrgestellnummer, DIN-Typ, Erstzulassung, Sitzplaetze,
  Tankinhalt und UVV-Frist erweitert
- Geraet um Geraetetyp, Lebensdauerkategorie, errechnetes Lebensdauerende,
  Anschaffungspreis und die Lagerung erweitert
- Ein Geraet steht entweder an einem Standort oder ist auf einem Fahrzeug
  verlastet. Eine Pruefbedingung setzt das durch, ein Trigger stellt zusaetzlich
  sicher, dass ein angegebener Geraeteraum zum angegebenen Fahrzeug gehoert.
- RLS auf allen neuen Tabellen, Trigram-Indizes fuer die Volltextsuche

Fachlogik:
- lib/stammdaten/fristen.ts rechnet Prueftermine, Lebensdauer und den
  abgeleiteten Geraetestatus. Der Geraetetyp gibt vor, das Geraet darf
  abweichen.
- lib/stammdaten/qr.ts und qr-parser.ts erzeugen und lesen die Codes
- lib/stammdaten/etiketten.ts baut den A4-Bogen mit 65 Etiketten
- lib/stammdaten/import.ts liest CSV und Excel, erkennt deutsche Datums- und
  Zahlenformate, Windows-1252 und Dubletten innerhalb der Datei
- lib/pdf.ts spricht Gotenberg an

Oberflaeche:
- Geraeteliste mit Volltextsuche, Statusfilter, serverseitiger Sortierung,
  Blaettern, Spaltenauswahl und Massenbearbeitung
- Geraetedetail mit QR-Code, Pruefvorgabe aus dem Geraetetyp und Verlauf
- Fahrzeugliste und -detail mit Beladung nach Beladeordnung
- Mitgliederliste nach Abteilung, Mitgliedsdetail mit Qualifikationen
- Qualifikationen mit Ablaufwarnung je Vorwarnzeit des Typs
- Import-Assistent mit Zuordnung und Vorschau vor dem Uebernehmen
- QR-Scanner ueber die Kamera, mit Eingabefeld als Rueckfallweg

Behoben:
- Fristenrechnung lief ueber startOfDay aus date-fns und damit in der Zeitzone
  des Servers. Ein DATE aus der Datenbank kommt als UTC-Mitternacht und wurde
  dadurch auf den Vortag geschoben — Fristen waeren einen Tag zu frueh faellig
  geworden. Jetzt eigene Kalenderarithmetik auf UTC-Bestandteilen.
- ctx.sitzung blieb in tRPC-Prozeduren nullbar, weil t.middleware() die
  Verengung der vorigen Middleware verwirft. Middleware jetzt direkt an .use().
- CardTitle war ein div und damit keine Ueberschrift. Wer mit einer
  Sprachausgabe arbeitet, springt von Ueberschrift zu Ueberschrift; Karten
  tauchten in dieser Navigation nicht auf.

Daten und Tests:
- Seed um 3 Standorte, 20 Geraeteraeume, 14 Geraetetypen mit echten
  Pruefvorgaben, 49 Geraete und 107 Qualifikationen erweitert
- 104 Vitest-Tests (vorher 32), 44 Playwright-Tests (vorher 10)
- Anleitung um sieben bebilderte Abschnitte erweitert, Changelog, Devlog,
  Aufwandsschaetzung und Roadmap fortgeschrieben

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 08:31:08 +02:00
372 changed files with 67998 additions and 410 deletions
+154
View File
@@ -185,6 +185,57 @@ KIOSK_SPERRDAUER=300
# Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden.
KIOSK_IDLE_TIMEOUT=120
# -----------------------------------------------------------------------------
# Kartenmodule (Phase 6)
# -----------------------------------------------------------------------------
# FLORIAN laedt keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre
# Hydranten liegen, ist niemandes Geschaeft — der Kartenserver laeuft selbst:
#
# docker compose --profile karte up -d
#
# Dafuer wird eine MBTiles-Datei des Ausrueckegebiets gebraucht (siehe README).
# Ohne KARTE_STIL_URL zeigt die Karte einen leeren Hintergrund mit Gitternetz;
# Hydranten, Objekte und Lagekarte funktionieren trotzdem.
KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json
# Kartenmittelpunkt beim Oeffnen (Musterhausen).
KARTE_BREITENGRAD=48.5
KARTE_LAENGENGRAD=12.15
KARTE_ZOOM=13
# -----------------------------------------------------------------------------
# Atemschutz nach FwDV 7 (Phase 5)
# -----------------------------------------------------------------------------
# Der Sicherheitshinweis ueber jeder Ansicht der Atemschutzueberwachung:
# "Ergaenzendes System — die analoge Atemschutzueberwachung bleibt fuehrend."
# Er gehoert dorthin. Auf false setzen darf ihn nur, wer eine schriftliche
# Freigabe des Kommandanten hat — und deren Fundstelle gehoert in
# ATEMSCHUTZ_FREIGABE, sonst bleibt der Hinweis stehen.
ATEMSCHUTZ_HINWEIS=true
# Aktenzeichen oder Fundstelle der Freigabe, z. B. "Kommandantenbeschluss vom 12.03.2026".
ATEMSCHUTZ_FREIGABE=
# Abstand der Druckabfrage in Sekunden. FwDV 7 nennt keinen festen Wert;
# zehn Minuten sind die uebliche Praxis.
ATEMSCHUTZ_ABFRAGE_INTERVALL=600
# Ansprechdruck der Warneinrichtung in bar. Je nach Baujahr 50 bis 60.
ATEMSCHUTZ_WARNDRUCK=55
# Sicherheitszuschlag auf den errechneten Rueckzugsdruck, in bar.
ATEMSCHUTZ_ZUSCHLAG=10
# -----------------------------------------------------------------------------
# Wetterwarnungen auf dem Hallenmonitor (optional, DWD)
# Die einzige Verbindung nach draussen, die FLORIAN von sich aus aufbaut —
# deshalb standardmaessig aus. Ohne WETTER_ENABLED=true wird nichts abgerufen.
# -----------------------------------------------------------------------------
WETTER_ENABLED=false
# Warnzellen-Nummer der eigenen Gemeinde aus der DWD-Warnzellenliste:
# https://www.dwd.de/DE/leistungen/opendata/help/warnungen/cap_warncellids_csv.csv
DWD_WARNCELL=
DWD_WARNUNGEN_URL=https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json
# Zeitlimit fuer den Abruf, in Sekunden. Der Monitor darf davon nicht haengen.
WETTER_TIMEOUT=5
# Wie lange eine Antwort gilt, bevor neu gefragt wird, in Sekunden.
WETTER_ZWISCHENSPEICHER=300
# -----------------------------------------------------------------------------
# E-Mail-Versand (optional, hinter lib/integrations/mail.ts abstrahiert)
# Ohne MAIL_ENABLED=true wird nur ins Log geschrieben — praktisch für Tests.
@@ -204,6 +255,76 @@ TELEGRAM_ENABLED=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# -----------------------------------------------------------------------------
# Push über ntfy (Phase 7, optional)
#
# ntfy ist ein winziger Push-Dienst, den man selbst betreibt: ein Container,
# eine App im Store, fertig. Man abonniert ein "Thema" (einen frei gewählten
# Namen) und bekommt Meldungen darauf — ohne Konto, ohne Telefonnummer.
#
# Läuft der Server im eigenen Netz, verlässt keine Nachricht das Gerätehaus.
# Deshalb gilt ntfy in FLORIAN als INTERNER Kanal und bekommt die
# ausführlichen Texte. Wer stattdessen das öffentliche ntfy.sh einträgt, macht
# ihn zu einem externen Dienst wie jeden anderen.
#
# ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest mit.
# FLORIAN schlägt deshalb Namen mit Zufallsanteil vor.
# -----------------------------------------------------------------------------
NTFY_ENABLED=false
# NTFY_URL=http://ntfy:80
# Nur nötig, wenn der eigene ntfy Zugriffsschutz eingeschaltet hat.
NTFY_TOKEN=
NTFY_TIMEOUT=10
# -----------------------------------------------------------------------------
# Web-Push in den Browser (Phase 7, optional)
#
# Der Weg, der ohne App auskommt. Die Meldung läuft über den Push-Dienst des
# Browserherstellers (Mozilla, Google, Apple) — verschlüsselt, aber sie
# verlässt das Netz der Wehr. Deshalb ist Web-Push ein EXTERNER Kanal und
# bekommt nur Kurzfassungen: "3 Prüfungen fällig", nicht welche.
#
# Schlüsselpaar erzeugen mit: npm run vapid
#
# ACHTUNG: Beide Schlüssel gehören ins Backup. Ein neuer öffentlicher Schlüssel
# macht alle bestehenden Push-Abos wertlos — jeder müsste Push neu erlauben,
# und niemand merkt es, die Meldungen bleiben einfach aus.
# -----------------------------------------------------------------------------
WEBPUSH_ENABLED=false
WEBPUSH_PUBLIC_KEY=
WEBPUSH_PRIVATE_KEY=
# Kontaktweg für den Push-Dienst, "mailto:..." oder eine Adresse.
WEBPUSH_KONTAKT=
# -----------------------------------------------------------------------------
# WhatsApp: bewusst NICHT.
#
# Es gibt keine WHATSAPP_-Variablen, und das ist eine Entscheidung, keine Lücke.
# Die Begründung steht im README unter "Warum kein WhatsApp" — kurz: die
# Business-Plattform kostet je Konversation, verlangt einen Vertrag mit Meta
# als Auftragsverarbeiter, und die App gleicht das Telefonbuch ab. Für eine
# Wehr, deren Daten im Gerätehaus bleiben sollen, ist das der falsche Weg.
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Benachrichtigungen und Termine (Phase 7)
# -----------------------------------------------------------------------------
# Wann der Worker die Erinnerungen anlegt und verschickt (cron, Europe/Berlin).
# Bewusst am frühen Morgen und nicht nachts: eine Push-Meldung um drei Uhr
# weckt Leute, die am nächsten Tag arbeiten müssen.
BENACHRICHTIGUNG_LAUF=0 7 * * *
# Wie viele Tage vor einem Termin erinnert wird (kommagetrennt).
# "7,1": einmal in der Woche davor, damit man den Abend freihalten kann, und
# einmal am Vortag, weil man es bis dahin wieder vergessen hat.
TERMIN_ERINNERUNG_TAGE=7,1
# Wie oft eine fehlgeschlagene Zustellung wiederholt wird, bevor sie als
# fehlgeschlagen stehen bleibt. Stehen bleiben ist Absicht: eine still
# verschluckte Benachrichtigung ist schlimmer als eine sichtbar gescheiterte.
BENACHRICHTIGUNG_VERSUCHE=3
# Wie lange ZUGESTELLTE Benachrichtigungen aufgehoben werden.
# Fehlgeschlagene bleiben — sie zeigen einen kaputten Kanal an.
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE=90
# -----------------------------------------------------------------------------
# LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang)
# -----------------------------------------------------------------------------
@@ -213,6 +334,39 @@ LLM_BASE_URL=http://ollama:11434/v1
LLM_API_KEY=
LLM_MODELL=qwen2.5:14b
# -----------------------------------------------------------------------------
# Einsätze und Übungen (Phase 3)
# -----------------------------------------------------------------------------
# Adressen zu Koordinaten auflösen. Vorgesehen ist ein SELBST betriebener
# Nominatim — Einsatzadressen gehören nicht an einen öffentlichen Dienst, und
# dessen Nutzungsbedingungen erlauben es ohnehin nicht.
GEOCODING_ENABLED=false
# NOMINATIM_URL=http://nominatim:8080
GEOCODING_TIMEOUT=5
# Auf welche Länder die Suche eingegrenzt wird (ISO-Codes, kommagetrennt).
GEOCODING_LAENDER=de,at,ch
# Einsatzportal für Fremdwehren: ein signierter Link ohne Anmeldung, über den
# eine Nachbarwehr den Stand sieht und Lagemeldungen absetzen kann.
PORTAL_ENABLED=true
# Wie lange ein neu erzeugter Link gilt.
PORTAL_GUELTIGKEIT_STUNDEN=24
# Höchstzahl Zugriffe je Link und Stunde.
PORTAL_MAX_ZUGRIFFE=120
# -----------------------------------------------------------------------------
# Wartung und Prüfung (Phase 2)
# -----------------------------------------------------------------------------
# Wann der nächtliche Lauf Prüftermine plant. Cron-Syntax, Zeitzone
# Europe/Berlin. Der Worker trägt den Zeitplan beim Start je Wehr selbst ein.
WARTUNG_NACHTLAUF=15 3 * * *
# Wann die Erinnerungen verschickt werden. Bewusst nach der Planung: sonst
# erinnert der Lauf an Termine, die es noch gar nicht gibt.
WARTUNG_ERINNERUNG_LAUF=45 3 * * *
# Wie weit im Voraus Termine angelegt werden. Ohne Horizont stünden Termine für
# die nächsten zehn Jahre im Dashboard.
WARTUNG_PLANUNGSHORIZONT_TAGE=180
# -----------------------------------------------------------------------------
# Datenschutz und Aufbewahrung (DSGVO)
# -----------------------------------------------------------------------------
+865 -1
View File
@@ -8,7 +8,871 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht]
Noch nichts. Nächster Schritt ist Phase 1 (Stammdatenverwaltung).
Nächster Schritt ist Phase 8 (Lokale KI).
---
## [0.8.0] — 2026-09-04
Phase 7: Benachrichtigungen und Termine. Ab hier weiß die Mannschaft, wann der
nächste Dienst ist — ohne dass jemand eine Rundmail tippt.
**Niemand wird ungefragt benachrichtigt.** Wer nichts einträgt, bekommt nichts.
Es gibt keine Voreinstellung, die stillschweigend alle anmeldet: ein neu
eingerichteter Zustellweg hat eine leere Ereignisliste, und erst wer ankreuzt,
wofür er ihn will, bekommt etwas.
### Hinzugefügt
**Datenmodell**
- `Termin` mit Art, Ort, Beginn und Ende, Leitung, Platzbegrenzung,
Anmeldeschluss und Serienkennung. Abgesagt wird mit Grund und Zeitpunkt —
**gelöscht wird nicht**, wer zugesagt hat, soll den Grund sehen.
- `Terminanmeldung` mit Zu-, Absage oder Warteliste, dem Grund einer Absage und
der Anwesenheit als eigenem Feld. Zusage und Anwesenheit sind bewusst
getrennt: wer zugesagt hat und nicht kam, steht genau so in der Liste.
- `Zustellkanal` — ein Datensatz je Person und Weg, mit der Adresse dieses Wegs
und der Liste der Ereignisse, für die er benutzt wird. Leer bedeutet: für
nichts.
- `Benachrichtigung` — steht **vor** dem Versand in der Tabelle, nicht danach.
Damit ist nachvollziehbar, wer wann worüber informiert wurde und wer nicht,
weil sein Kanal nicht erreichbar war.
- `Benachrichtigungsvorlage` je Ereignis und Kanal, `Kalenderzugang` mit
SHA-256 des Tokens.
**Terminkalender**
- Liste mit Filter nach Art und „nur meine", Rückmeldung direkt aus der Zeile.
- Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder
Abend ist ein eigener Datensatz. Sonst wäre jeder ausgefallene Übungsabend
ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele Termine daraus
werden.
- Kalenderrechnung statt Millisekunden: „monatlich" heißt derselbe Tag im
nächsten Monat, und der 31. Januar landet im Februar auf dem 28. — nicht im
März, wie es passiert, wenn man stur Monate addiert.
- **Warteliste, die von selbst nachrückt.** Wer bei belegten Plätzen zusagt,
landet auf ihr; sagt jemand ab, rückt der Erste nach und erfährt es.
- Absage mit Grund, wahlweise für alle folgenden Termine einer Serie.
Vergangene bleiben unberührt. Benachrichtigt werden die Angemeldeten, nicht
die ganze Wehr.
- Anwesenheitsliste über **alle aktiven Mitglieder**, nicht nur die
Angemeldeten — auf einem Blatt zum Abhaken muss jeder stehen. Zum Abhaken am
Tablet oder zum Ausdrucken.
**Kalenderabonnement**
- ICS-Feed nach RFC 5545 unter `/api/kalender/<token>.ics`. Die Termine der
Wehr stehen damit im Handy neben allem anderen.
- **Bewusst kein CalDAV**: das ist ein Schreib-Lese-Protokoll mit
Rechteverwaltung und Konfliktauflösung. Gebraucht wird nur Lesen.
- Die Adresse steht genau einmal auf dem Bildschirm; gespeichert wird nur ihr
Hash. Jeder Zugang lässt sich einzeln widerrufen, und wer austritt, verliert
ihn.
- Ausgeliefert werden nur Termine — keine Anmeldelisten, keine Personen außer
der Leitung.
**Vier Zustellwege, hinter Schnittstellen**
- **SMTP** löst den Platzhalter aus Phase 0 ab: nodemailer mit
Verbindungspool, damit ein Sammellauf mit vierzig Nachrichten Sekunden statt
Minuten braucht.
- **ntfy** (neu, selbst gehostet, Compose-Profil `push`): Push aufs Handy ohne
Konto, ohne Telefonnummer, ohne Telefonbuchabgleich. Läuft der Server im
Gerätehaus, verlässt keine Nachricht das Haus.
- **Web-Push** (neu, VAPID): Push in den Browser ohne App. Schlüsselpaar über
`npm run vapid`.
- **Telegram** aus Phase 0 wird jetzt tatsächlich benutzt.
- Was das Netz der Wehr verlässt (Telegram, Web-Push), bekommt **nur die
Kurzfassung** — „3 Prüfungen fällig", nicht welche. In der Oberfläche steht
das neben jedem externen Kanal.
**Textvorlagen**
- Je Ereignis und Kanal, mit eingebautem Standardtext für alles. Eine Wehr, die
nichts einstellt, bekommt trotzdem brauchbare Nachrichten.
- Platzhalter zum Anklicken, Vorschau mit erfundenen Beispielwerten. Ein
Platzhalter, den es für das Ereignis nicht gibt, wird beim Speichern
abgewiesen — mit der Liste der erlaubten daneben.
- Ein unbekannter Platzhalter bleibt im Text **sichtbar stehen**, statt still
zu verschwinden. Eine Lücke, die niemand bemerkt, ist schlimmer als ein
seltsam aussehender Text, den jemand korrigiert.
**Digitaler Dienstausweis**
- QR-Code auf dem Handy, Prüfseite ohne Anmeldung unter `/ausweis/<code>`.
Sie zeigt Name, Wehr, Funktion, Foto und die gültigen Qualifikationen.
- Signiert statt gespeichert (HMAC über Person, Wehr und Zeitstempel), **fünf
Minuten gültig**. Die Prüfseite nennt den Zeitpunkt der Ausstellung — ein
Ausweis, der „gültig" sagt, ohne zu sagen seit wann, ist wertlos.
- Der Zweck steht im signierten Teil, damit ein Ausweis nicht als Anmeldemarke
aus Phase 4 durchgeht und umgekehrt.
- Ein gültig signierter Code für jemanden, der ausgetreten ist, wird
abgewiesen. Das Passbild ist über denselben Code erreichbar und damit genauso
lange gültig.
**Was von selbst benachrichtigt**
- Prüfung fällig, Mangel gemeldet, Qualifikation läuft ab (90/30/7 Tage
vorher), neuer Termin, Terminerinnerung (7 und 1 Tag vorher), Einsatzbericht
freigegeben.
- **Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird nur
eine Zeile geschrieben; der Worker schickt sie weg. Sonst hinge das Speichern
eines Termins am Antwortverhalten von Telegram.
- Erinnert wird um sieben Uhr morgens, zugestellt jede Minute: eine
Push-Meldung um drei Uhr weckt Leute, die am nächsten Tag arbeiten müssen —
eine Absage am Dienstagabend muss dagegen ankommen, solange sie noch etwas
nützt.
- Die Sammelmail an den zuständigen Gerätewart bleibt, wie sie war. Wer sie
ohnehin bekommt, wird von der zusätzlichen Benachrichtigung ausgenommen und
hat sie damit nicht doppelt.
### Geändert
- `lib/integrations/mail.ts` verschickt tatsächlich, statt eine Meldung ins Log
zu schreiben. Ohne `MAIL_ENABLED` bleibt es beim Log.
- Neue Rechte: `termin.lesen` und `benachrichtigung.eigene` als Grundrechte
(jeder sieht den Kalender und richtet seine eigenen Wege ein — das ist keine
Sache des Kommandanten), `termin.bearbeiten`, `termin.anwesenheit` und
`benachrichtigung.vorlagen` bei Kommandant, Schriftführer und Ausbilder.
- `DIENSTGRAD_TEXT` liegt jetzt in `lib/stammdaten/texte.ts` statt zweimal fast
gleich in zwei Seiten.
- Der Service Worker nimmt Push-Meldungen an und öffnet beim Antippen ein schon
offenes FLORIAN, statt einen zwölften Tab aufzumachen.
- Der Aufbewahrungslauf räumt zugestellte Benachrichtigungen nach Frist ab.
**Fehlgeschlagene bleiben** — sie zeigen einen kaputten Kanal an, und das
verliert seinen Wert nicht durch Zeitablauf.
### Behoben
- Die Vorlagenmaske überschrieb den gerade getippten Text, sobald die Liste im
Hintergrund nachgeladen wurde (nach dem Speichern, beim Zurückkommen ins
Fenster). Gefunden hat es der E2E-Test, nicht das Ausprobieren.
### Sicherheit
- Row Level Security und Mandantenfilter für alle sechs neuen Tabellen.
- `REVOKE DELETE ON "benachrichtigung"` für die Anwendungsrolle: wer wann
worüber informiert wurde, lässt sich nicht über die Oberfläche wegräumen.
Die Aufbewahrungsfrist läuft über den Systemzugang.
- Prüfbedingungen in der Datenbank: kein Ende vor dem Beginn, kein
Anmeldeschluss nach dem Termin, kein Kontingent von null Plätzen, keine
negative Versuchszahl.
- Kalendertoken nur als SHA-256; ein Push-Abo, das der Dienst als tot meldet,
wird stillgelegt statt gelöscht, damit die Person es in ihren Einstellungen
sieht und neu erlauben kann.
- **Kein WhatsApp** — Begründung im README unter „Warum kein WhatsApp":
Verarbeitung über Meta-Server, Kosten je Konversation, Telefonbuchabgleich.
### Tests
- 56 neue Unit-Tests: Serienrechnung (Monatsenden, Schaltjahr, Kappung),
ICS-Erzeugung (Maskierung, Zeilenumbruch nach 75 Oktetten, CRLF, Absage),
Textbausteine (Kurzfassung für externe Kanäle, unbekannte Platzhalter),
Dienstausweis (Frist, Fälschung, Verwechslung mit der Anmeldemarke) und die
Datenbankregeln der neuen Tabellen.
- 17 neue E2E-Tests für Kalender, Rückmeldung, Warteliste, Absage,
Anwesenheitsliste, Kalenderabonnement, Vorlagen und Prüfseite.
---
## [0.7.0] — 2026-09-04
Phase 6: Kartenmodule. Ab hier weiß die Wehr auf der Anfahrt, wo der nächste
Hydrant liegt und was er hergibt.
**Keine Kacheln aus dem Netz.** Wo eine Wehr ausrückt, wo ihre Hydranten liegen
und welche Objekte sie im Blick hat, ist niemandes Geschäft. Der Kartenserver
läuft selbst (tileserver-gl im Compose-Profil `karte`); ohne ihn zeigt die
Karte einen leeren Grund und sagt das auch — Punkte, Entfernungen und
Umkreissuche funktionieren trotzdem.
### Hinzugefügt
**Datenmodell**
- `Hydrant` mit Typ (Unterflur, Überflur, Saugstelle, Zisterne, offenes
Gewässer), Nennweite, Durchfluss, Zustand, letzter Prüfung und Hinweis für
das, was man vor Ort wissen muss.
- `Objekt` mit Kategorie, **Zufahrt, Gefahren und Schlüsseldepot als eigene
Felder** — im Einsatz sucht niemand in einem Absatz. Dazu `Objektkontakt`
(mit „jederzeit erreichbar") und `Objektplan` (DIN 14095).
- `Lagekarte` und `Lageelement` — eine Karte je Einsatz, jedes Element mit
Geometrie, Zeichen und Urheber.
- `Wasserfoerderung` und `Foerderpunkt` — die Eingaben einer Förderstrecke.
Das Ergebnis wird **nicht** gespeichert, sondern bei jeder Anzeige neu
gerechnet.
- Standorte haben jetzt Dezimalgrade neben dem PostGIS-Punkt und liegen damit
auf der Karte.
**Karte**
- MapLibre GL aus dem eigenen Paketstand, Kacheln aus dem eigenen
tileserver-gl. Kein Skript, kein Stylesheet, kein Bild von einem fremden
Server.
- Hydranten und Objekte als getrennt schaltbare Ebenen, Filter nach Art und
Zustand, Suche über Straße, Nummer, Objektname und Gefahrenstichwort.
- **Umkreissuche über PostGIS**: `ST_DWithin` auf der Geografie mit
GiST-Index — in die Karte tippen, und es steht da, was in 500 Metern
erreichbar ist, nach Entfernung sortiert.
**Import**
- GeoJSON und Tabellen der Stadtwerke. Spaltennamen werden erkannt („DN",
„Lage", „Latitude", „Pruefdatum"), deutsche Kommazahlen und Datumsangaben
ebenso, vertauschte Achsen werden erkannt und zurückgedreht.
- Zwei Schritte wie beim Geräteimport aus Phase 1: erst zeigen, was
herauskäme, dann übernehmen. Übersprungene Zeilen stehen mit Zeilennummer
und Grund da.
- Wiederholbar: Hydranten mit einer Nummer des Versorgers werden aktualisiert
statt doppelt angelegt.
**Objekte und Feuerwehrpläne**
- Zufahrt, Gefahren, Schlüsseldepot und Löschwasser am Objekt stehen ganz
oben — in dieser Reihenfolge werden sie auf der Anfahrt gelesen.
- Ansprechpartner mit Telefonnummern zum Antippen und der Angabe, wer nachts
ans Telefon geht.
- Planblätter nach DIN 14095 mit Art und Stand. Ein Plan von 2009 ist eine
Vermutung, und das steht dran.
**Lagekarte**
- 22 taktische Zeichen nach FwDV 102, **selbst gezeichnet** als SVG in
`lib/karte/taktische-zeichen.ts`: Fahrzeuge, Einheiten, Führung, Stellen,
Gefahren, Maßnahmen.
- Zeichen setzen, Linien ziehen, Flächen aufziehen, beschriften — ein
Werkzeug, ein Klick.
- Abgleich zwischen Geräten über denselben Ereignisstrom wie die
Atemschutzüberwachung aus Phase 5; der Zustand der Verbindung steht sichtbar
da.
- Zurücknehmen statt löschen: was um 14:12 eingezeichnet wurde, gehört zur
Mitschrift.
**Wasserförderung**
- Strecke in die Karte tippen, Durchmesser und Fördermenge einstellen — die
Rechnung läuft mit: Reibungsverlust, Höhenunterschied, Zahl der
Verstärkerpumpen, Schlauchlängen je Abschnitt.
- Gerechnet wird im Browser **und** auf dem Server mit demselben Code.
- Reicht ein Abschnitt selbst mit voller Pumpe nicht, sagt die Ausgabe das,
statt eine Zahl auszuwerfen, die niemand halten kann.
- Unter jeder Rechnung steht, dass sie eine Planungshilfe ist — was zählt, ist
der Druck am Verteiler.
**Dateien abrufen**
- `/api/dateien/[id]` liefert eine abgelegte Datei aus, mandantengebunden und
mit Eintrag im Protokoll. Bis Phase 6 gab es nur den Upload und eigene
Endpunkte je Dokumentart.
**Sonstiges**
- Der Seed legt 24 Löschwasserstellen an — mit Weiher, Zisterne und
Saugstelle im Außenbereich, zwei defekten und zwei ungeprüften — sowie drei
Objekte mit Ansprechpartnern: Schule, Pflegeheim, Aussiedlerhof.
- 59 neue Tests: 48 Unit-Tests zu Druckverlust, Förderplan und Import,
11 Playwright-Durchläufe je Browser.
### Geändert
- Der Koordinaten-Trigger aus Phase 3 ist jetzt allgemein: eine Funktion für
jede Tabelle mit Breiten- und Längengrad. Sie hängt an Standort, Hydrant und
Objekt.
- Der Ereignisstrom-Hook aus Phase 5 nimmt jetzt eine Adresse entgegen und
wird von Lagekarte und Atemschutzüberwachung gemeinsam benutzt. Zwei Ströme
mit unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler
verstecken kann.
---
## [0.6.0] — 2026-09-03
Phase 5: Atemschutzüberwachung nach FwDV 7. Das sicherheitsrelevanteste Modul
des Projekts — und deshalb das mit dem höchsten Testanteil.
Über jeder Ansicht steht: **Ergänzendes System — die analoge
Atemschutzüberwachung bleibt führend.** Der Hinweis lässt sich nur abschalten,
wenn zusätzlich die Fundstelle einer Freigabe des Kommandanten hinterlegt ist;
ohne sie bleibt er stehen.
### Hinzugefügt
**Datenmodell**
- `Atemschutzeinsatz` — eine Überwachung mit Überwacher, Abfrageintervall,
Warndruck und Sicherheitszuschlag. Hängt an einem Einsatz oder einer Übung,
kann aber auch für sich stehen: im Ernstfall wird zuerst überwacht und
später zugeordnet.
- `Atemschutztrupp` mit Status (bereit, im Einsatz, Rückweg, draußen), den
Zeitstempeln dazu, dem errechneten Rückzugsdruck und dem Notfallzeitpunkt.
- `Truppmitglied` — zwei oder drei Personen je Trupp, genau eine davon
Truppführer, optional mit dem verwendeten Pressluftatmer.
- `Druckmessung` mit Art (Eingang, Einsatzziel, Zwischenmeldung, Ausgang).
- `AtemschutzEreignis` — jede Zeile des Protokolls mit Zeitpunkt.
- `G26Untersuchung` und `Belastungsuebung` als eigene Nachweise.
**Die Zeit gehört dem Server**
- Nirgends wird eine Restzeit heruntergezählt. Jede Dauer entsteht aus zwei
Zeitstempeln — einem aus der Datenbank und der aktuellen Uhr. Deshalb
übersteht die Anzeige ein Neuladen, einen zweiten Bildschirm und einen
Verbindungsabbruch: es gibt nichts, das dabei verloren gehen könnte.
- Server und Browser rechnen mit **denselben** Funktionen aus
`lib/atemschutz/berechnung.ts`. Was das Tablet anzeigt, weist das Protokoll
später genauso aus.
**Rückzugsdruck nach dem doppelten Anmarschverbrauch**
- Der Trupp meldet beim Erreichen des Einsatzziels einmal seinen Druck. Daraus:
`2 × Anmarschverbrauch + Ansprechdruck der Warneinrichtung + Zuschlag`.
- Solange keine Zielmeldung vorliegt, greift die Zwei-Drittel-Regel — sie liegt
höher und ist damit die vorsichtigere Annahme.
- Maßgeblich ist immer der ungünstigste Wert im Trupp: der Trupp geht
gemeinsam zurück.
- Ampel in drei Stufen mit Klartext daneben, Gelb schon zehn Prozent über der
Grenze. Bei Notfall zusätzlich ein erzeugter Ton — ausdrücklich eine Zugabe.
**Überwachungsschirm**
- Trupp anlegen mit Eingangsdruck je Person; daneben steht, ob die Person nach
FwDV 7 überhaupt einsetzbar ist. **Gesperrt wird nichts** — im Einsatz
entscheidet der Einheitsführer, und ein Programm, das ihm im entscheidenden
Moment eine Person verweigert, wird umgangen.
- Druckabfrage für den ganzen Trupp in einer Zeile, Erinnerung im
konfigurierbaren Intervall (Vorgabe zehn Minuten).
- Mayday je Trupp, ausdrücklich aufzuheben statt wegzuklicken.
- Rückzug anordnen, Statuswechsel, Notizen ins Protokoll.
- Die Überwachung lässt sich erst beenden, wenn alle Trupps als draußen
gemeldet sind.
**Ereignisstrom über Server-Sent Events**
- Ein Route Handler unter `/api/atemschutz/strom` meldet Änderungen; der
Browser holt daraufhin den Stand. Kein eigener Server nötig, `EventSource`
verbindet nach einem Abbruch von selbst neu.
- Der Zustand der Verbindung steht sichtbar auf dem Schirm. Bleibt das
Lebenszeichen des Servers aus, gilt die Verbindung nach acht Sekunden als
tot — auch wenn der Browser keinen Fehler meldet.
- Fällt der Strom ganz aus, bleibt die Ansicht bedienbar: sie fragt zusätzlich
im festen Takt nach.
**Protokoll**
- Lückenloses PDF mit Rahmen, Trupps und dem Verlauf aus Ereignissen und
Druckmessungen in einer gemeinsamen Zeitachse.
- Entsteht beim Beenden im Hintergrundprozess und bei Bedarf beim Abruf — ein
Nachweis, den man nicht bekommt, weil ein Dienst einen schlechten Tag hatte,
wäre keiner.
- Im Kopf steht, ob die digitale Überwachung freigegeben war. Wer das Blatt
später liest, soll wissen, was er in der Hand hält.
**Tauglichkeit nach FwDV 7**
- Drei Nachweise, die gleichzeitig gelten müssen: gültige G26.3,
Belastungsübung im laufenden Jahr, Einsatzübung im laufenden Jahr. Ein
Einsatz unter Atemschutz ersetzt die Einsatzübung.
- Ampel mit **Klartext**, was fehlt — eine rote Ampel ohne Begründung hilft am
Fahrzeug niemandem.
- Fristvorschlag für die G26 nach Alter: drei Jahre, ab fünfzig eines.
- Nachweise pflegen: G26 je Person, Belastungsübung für die ganze Gruppe auf
einmal.
- Kachel auf der Startseite: wie viele Träger heute einsetzbar sind und wie
viele davon knapp davor stehen, es nicht mehr zu sein.
**Sonstiges**
- Der Seed legt acht G26-Untersuchungen an — darunter eine abgelaufene und
eine bald ablaufende —, zwölf Übungsnachweise und eine vollständig
durchgespielte Überwachung mit zwei Trupps.
- 68 neue Tests: 51 Unit-Tests zu Rückzugsdruck, Ampel, Restzeit, Statusfolge
und Tauglichkeit, 17 gegen eine echte Datenbank und 8 Playwright-Durchläufe
je Browser — darunter die beiden, um die es in dieser Phase geht: die Uhr
nach einem Neuladen und bei abgerissenem Ereignisstrom.
### Geändert
- Die Datenbank verweigert dem Anwendungszugang `UPDATE` und `DELETE` auf
`druckmessung` und `atemschutz_ereignis`; zusätzlich sitzt auf beiden ein
Trigger. Ein Fehler wird durch einen neuen Eintrag richtiggestellt, nicht
durch Ändern — sonst wäre jede Aufzeichnung nachträglich glättbar.
- Neue Bedingungen: Druck zwischen 0 und 400 bar, kein Rückzug vor dem
Einstieg, kein Ende vor dem Beginn, genau ein Truppführer je Trupp.
- Die Startseite stand noch auf dem Stand von Phase 1 („Als Nächstes kommt das
Prüfmanagement"). Sie nennt jetzt den tatsächlichen Ausbaustand.
- Der Navigationseintrag „Protokoll" führte auf eine Seite, die es nicht gibt.
Die Ansicht des Audit-Logs gehört zum Löschkonzept in Phase 8; bis dahin
steht der Eintrag ausgegraut da statt ins Leere zu führen.
---
## [0.5.0] — 2026-09-03
Phase 4: das Terminal im Gerätehaus. Ein Wandtablet, das mit Handschuhen
bedienbar ist und weiterarbeitet, wenn das Netz weg ist.
### Hinzugefügt
**Datenmodell**
- `Fahrt` — Fahrtenbuch je Fahrzeug: Fahrer, Zweck, Ziel, Kilometerstand bei
Abfahrt und Rückkehr, getankte Menge. Eine Fahrt ohne Rückkehr ist die
laufende Fahrt.
- `Anwesenheit` — wer im Gerätehaus ist, mit Anlass (Dienst, Übung, Einsatz,
Arbeitsdienst) und offener Abmeldung, solange jemand da ist.
- `Waescheabgabe` — Schutzkleidung in der Reinigung, mit Grund und Stand von
„abgegeben" bis „zurück".
- `Inventurgang` und `Inventurposten` — der Gerätewart geht mit dem Tablet durch
die Halle und scannt; am Ende steht die Liste dessen, was fehlt.
- `KioskVorgang` — jeder vom Terminal übermittelte Vorgang, **mit der im
Browser erzeugten Kennung als Primärschlüssel**. Das ist der Kern des
Offline-Betriebs: kommt derselbe Vorgang nach einem abgebrochenen
Sendeversuch ein zweites Mal an, wird er wiedererkannt statt neu angelegt.
- `SyncKonflikt` — was sich beim Nachtragen nicht anwenden ließ. Nichts geht
verloren, es wartet auf eine Entscheidung.
**Terminaloberfläche unter `/kiosk`**
- Kachelschirm: sieben Kacheln, jede mindestens 160 Pixel hoch, jede mit Symbol
und Text, sichtbar nach Rechten. Keine Navigationsleiste.
- Anmeldung auf drei Wegen: Ausweis auflegen (der RFID-Leser tippt die Kennung
als Tastatureingabe, erkannt am Anschlagstempo), QR-Code vom Handy oder
Mitgliedsnummer samt PIN.
- Mangel melden in drei Schritten — Gerät scannen, Foto und ein Satz dazu,
Dringlichkeit antippen. Anspruch war „unter dreißig Sekunden".
- Kommen und Gehen mit zwei Tipps, Fahrtenbuch, Wäscheabgabe und Inventurgang.
- Abmeldeuhr: nach `KIOSK_IDLE_TIMEOUT` Sekunden Untätigkeit meldet sich das
Terminal von selbst ab, mit sichtbarer Warnung vorher.
- Hallenmonitor unter `/kiosk/monitor`: Uhrzeit, laufender Einsatz, wer im Haus
ist, Fahrzeuge, fällige Prüfungen und die nächsten Termine — Vollbild ohne
Bedienung.
**Offline-Betrieb**
- Ein von Hand geschriebener Service Worker (`public/sw.js`): App-Shell und
Stammdaten im Zwischenspeicher, `network-first` für Seiten mit
Ausweichseite `/kiosk/offline`, `stale-while-revalidate` für lesende
tRPC-Aufrufe. Schreibende Anfragen und alles unter `/api/auth` fasst er nicht
an — eine zwischengespeicherte Anmeldung wäre eine Sicherheitslücke.
- Warteschlange in einer IndexedDB (Dexie): **jede** schreibende Aktion geht
zuerst dorthin, auch online. Sonst gäbe es zwei Wege durch den Code, und der
seltenere wäre der ungetestete.
- Fotos liegen als Blob in derselben Warteschlange und werden beim Übertragen
zuerst hochgeladen.
- Sichtbarer Zustand als Leiste über allem: verbunden, wie viele Änderungen
warten, kein Netz — in Klartext, nicht nur als Farbe.
- Erledigt heißt: angenommen **oder** als bereits verarbeitet erkannt **oder**
in der Konfliktliste gelandet. In allen drei Fällen ist der Vorgang beim
Server und darf aus der Warteschlange verschwinden.
**Anmeldemarke**
- `/anmeldemarke` zeigt einen QR-Code fürs Handy: signiert mit `AUTH_SECRET`,
gültig 60 Sekunden, nicht gespeichert. Ein abfotografierter Code aus der
Umkleide nützt nach einer Minute nichts mehr.
**Terminalübersicht in der Verwaltung**
- `/terminal` zeigt Konflikte zuerst — das sind die Vorgänge, die ein Mensch
anfassen muss —, danach Anwesende, Wäsche und Fahrtenbuch.
**Installierbar als App**
- Web-App-Manifest, eigene Symbole (`npm run icons`), Startseite `/kiosk`. Kein
externes CDN, keine fremde Schriftart.
**Wetterwarnungen (optional, aus)**
- Warnungen des DWD auf dem Hallenmonitor, hinter einer Schnittstelle in
`lib/integrations/`. Standardmäßig **aus** — es ist die einzige Verbindung,
die FLORIAN von sich aus nach außen aufbaut. Fällt sie aus, bleibt die Kachel
leer.
**Sonstiges**
- Der Seed legt ein Fahrtenbuch mit vier Fahrten (eine läuft noch), vier
Anwesenheiten (eine Person ist noch im Haus) und drei Wäscheabgaben an.
- 31 neue Tests: 20 Unit-Tests zu Anmeldemarke und Warteschlange (letztere
unter jsdom mit `fake-indexeddb`), 11 gegen eine echte Datenbank
(Kilometerstände, Anwesenheitszeiten, Unveränderbarkeit der Vorgänge,
Mandantentrennung) und 9 Playwright-Durchläufe je Browser — darunter der
Offline-Fall mit `context.setOffline(true)` und der doppelt gesendete
Vorgang, dessen Antwort unterwegs verloren geht.
### Geändert
- Die Datenbank setzt vier neue Regeln selbst durch: Kilometerstände laufen
vorwärts, eine Rückkehr liegt nicht vor der Abfahrt, ein Gehen nicht vor dem
Kommen, eine Wäscheabgabe umfasst mindestens ein Stück. Dazu `REVOKE UPDATE,
DELETE` auf `kiosk_vorgang` — ein übermittelter Vorgang lässt sich nicht
nachträglich glätten.
- Die Beschriftung der DWD-Warnstufen liegt in
`src/lib/integrations/wetter-texte.ts`, getrennt vom `server-only`-Modul
daneben — dieselbe Trennung wie bei `qr.ts` und `qr-parser.ts`.
- Neue Rechte `fahrtenbuch.lesen` und `fahrtenbuch.fuehren`, beide im
Grundrechtebündel.
### Behoben
- Die Warteschlange rief sich selbst in eine Renderschleife: `jetztSenden`
hatte das Mutationsobjekt von React Query in seinen Abhängigkeiten, und das
ist bei jedem Rendern ein neues. Damit wurde der 20-Sekunden-Zeitgeber
ständig neu aufgezogen und lief nie ab — ein liegen gebliebener Vorgang wäre
ohne Zutun nie ein zweites Mal losgeschickt worden. Gefunden vom
Offline-E2E-Test, der genau darauf wartet.
- `/kiosk/einrichten` ließ sich nicht vorab erzeugen, weil `useSearchParams()`
ohne Suspense-Grenze stand. Der Produktionsbuild brach dabei ab.
- Der Anwesenheits- und der Fahrten-Wizard zeigten erst die eine, nach dem
Laden des eigenen Stands die andere Fassung — wer zügig tippte, drückte ins
Leere. Bis der Stand bekannt ist, steht dort jetzt ein Platzhalter.
- Die Offline-Seite blieb stehen, wenn nur eine einzelne Anfrage abgebrochen
war. Am Hallenmonitor, den niemand bedient, hieße das: bis jemand vorbeikommt.
Sie versucht es jetzt alle zehn Sekunden von selbst.
- Der Hallenmonitor stapelte alle laufenden Einsätze übereinander und schob
damit Anwesenheit, Fahrzeuge und Prüfungen aus dem Bild. Zwei stehen groß,
der Rest als Zeile.
---
## [0.4.0] — 2026-09-03
Phase 3: Einsätze und Übungen. Ab hier dokumentiert die Wehr, was sie tut —
für die Gemeinde, für die Versicherung und für die eigene Mannschaft.
### Hinzugefügt
**Datenmodell**
- `Einsatz` mit den sechs Zeitstempeln (alarmiert, ausgerückt, eingetroffen,
Einsatzende, eingerückt, einsatzbereit), Ort samt Koordinaten, Kategorie nach
der bayerischen Feuerwehrstatistik und den Zahlen für die Gemeindemeldung.
- `Einsatzfahrzeug` mit eigenen Zeiten je Fahrzeug — Nachalarmierung bleibt
sichtbar —, Kilometerstand und Wasserabgabe.
- `Einsatzkraft` mit Funktion nach FwDV 3, Fahrzeug, Zeitraum und
Atemschutz-Kennzeichen.
- `Materialverbrauch` und `Lagemeldung` mit Foto und Herkunft.
- `PortalZugang` — ein zeitlich begrenzter Link für eine Fremdwehr, gespeichert
nur als Hash.
- `Uebung` und `Uebungsteilnahme` mit Thema, Lernzielen und Anwesenheit.
**Schnellerfassung**
- Stichwort, Ort, ausgerückte Fahrzeuge — mehr fragt die Erfassung im Alarmfall
nicht. Alarmzeit ist der Moment des Anlegens, alles Weitere wird nachgetragen.
- Fortlaufende Einsatznummer `JJJJ-NNNN` je Wehr und Jahr, vergeben unter einer
Transaktionssperre.
**Zeiten und Stärke**
- Ein Knopf je Zeitstempel, solange der Einsatz läuft.
- Daraus gerechnet: Ausrückzeit, Anfahrtszeit, Hilfsfrist und Einsatzdauer.
- Stärke nach FwDV 3 in der Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`,
mit Zuordnung zur taktischen Einheit (Trupp, Staffel, Gruppe, Zug).
- Hinweise statt Sperren: eine verdrehte Zeitreihenfolge oder eine fehlende
Führungskraft wird angezeigt, verhindert das Speichern aber nicht — ein
Einsatz, der drei Wochen später nachgetragen wird, ist immer noch besser als
einer, den niemand erfasst.
**Personalvorschlag**
- Sortiert nach Einsatzerfahrung der letzten zwölf Monate, mit dem
Qualifikationsstand daneben: eine abgelaufene G26.3 fällt auf, bevor jemand
unter Atemschutz geht.
**Einsatzbericht und Freigabe**
- Bericht als PDF über Gotenberg: Kopf, Zeitleiste mit Kennzahlen, Fahrzeuge,
Mannschaft mit Stärke, Material, Verlauf und Bericht.
- Mit der Freigabe wird der Einsatz festgeschrieben — durchgesetzt von einem
Datenbanktrigger, nicht vom Anwendungscode. Ausgenommen bleiben das
nachgetragene PDF und die Lagemeldungen.
- Jahresexport als quer gedrucktes PDF unter `/api/einsaetze/jahresexport`.
**Statistik**
- Einsätze nach Kategorie und Monat, Verteilung der Hilfsfristen mit dem
80-Prozent-Wert, Beteiligung je Person (Einsätze, davon unter Atemschutz,
Übungen) und Auslastung je Fahrzeug.
**Einsatzportal für Fremdwehren**
- Signierter Link ohne Anmeldung, gültig 24 Stunden, jederzeit widerrufbar,
jeder Aufruf wird mitgezählt.
- Zeigt Stichwort, Ort, Zeitstempel und Lagemeldungen — keine Namen von
Einsatzkräften, keinen Bericht, keinen Weg in den Rest der Anwendung.
- Nimmt Lagemeldungen mit Foto an, begrenzt auf 30 Meldungen und 10 Fotos je
Zugang und Stunde.
- Eigene Zugriffsbegrenzung über Redis (`src/lib/ratelimit.ts`), die bei
Störung durchlässt statt zu blockieren.
**Übungen**
- Thema, Art, Lernziele, Ort und Leitung; Anwesenheit als Liste zum Abhaken.
- Fließt in die Beteiligungsstatistik ein.
**Adressen zu Koordinaten**
- Anbindung an einen **selbst betriebenen** Nominatim hinter einer Schnittstelle
in `lib/integrations/`. Ohne Konfiguration bleibt die Suche leer und die
Koordinaten werden von Hand eingetragen.
- Koordinaten liegen doppelt: als Dezimalgrad für die Anwendung und als
PostGIS-Punkt für die Karte ab Phase 6, zusammengehalten von einem Trigger.
**Sonstiges**
- Der Seed legt acht Einsätze über das Jahr verteilt an — Brände, technische
Hilfeleistungen, ein Fehlalarm einer Brandmeldeanlage — dazu sechs Übungen
und die Beteiligung der Mannschaft.
- 57 neue Tests: 32 Unit-Tests zu Stärke, Zeiten und Nummernkreis, 14 gegen eine
echte Datenbank (Koordinaten-Trigger, Freigabe, Mandantentrennung) und 11
Playwright-Durchläufe einschließlich des Portals in einem Browser **ohne**
Sitzung.
### Geändert
- Der Nummernkreis `JJJJ-NNNN` liegt jetzt in `src/lib/nummernkreis.ts` und wird
von Prüfprotokollen, Einsätzen und Übungen gemeinsam verwendet.
- Die Seitenleiste zeigt Einsätze und Übungen als verfügbar.
- Das Zeitlimit der E2E-Erwartungen steigt von 15 auf 25 Sekunden: mit fünfzig
Routen dauert die erste Übersetzung einer Seite im Entwicklungsserver unter
Last länger als vorher, und daran scheiterten gelegentlich Tests, die einzeln
grün sind.
### Behoben
- Die Anwesenheitsliste einer Übung forderte 300 Mitglieder an und lief damit
in die Höchstzahl von 200 je Seite — die Liste blieb leer. Gefunden über die
Server-Ausgabe während der E2E-Tests.
---
## [0.3.0] — 2026-09-03
Phase 2: Wartungs- und Prüfmanagement. Das Modul, wegen dem eine Wehr so ein
Programm einführt — Fristen im Blick behalten und im Streitfall nachweisen
können, dass geprüft wurde.
### Hinzugefügt
**Datenmodell**
- `Wartungsvorlage` — was an einem Gerätetyp in welchem Takt geprüft wird, mit
Intervall, Vorwarnzeit, Prüfvorschrift und Erfahrungswert für die Dauer.
- `Checklistenpunkt` in vier Ausprägungen: Ja/Nein, Messwert (mit zulässigem
Bereich und Einheit), Text und Foto. Pflichtpunkte sind die, an denen die
Einsatzbereitschaft hängt.
- `Wartungstermin` — ein fälliger Termin an einem konkreten Gerät. Eindeutig
über Gerät, Vorlage und Fälligkeitstag, damit ein zweiter nächtlicher Lauf
nichts doppelt anlegt.
- `Pruefung`, `Pruefergebnis` und `Signatur` — die durchgeführte Prüfung mit
Antworten je Punkt und Unterschrift.
- `Mangel` mit dem Weg melden → zugewiesen → in Arbeit → behoben.
- `Rueckruf`, `RueckrufGeraetetyp` und `RueckrufBetroffenheit` — eine
Herstellerwarnung, die betroffene Bauart und der Stand je Gerät.
- `Erinnerung` als Nachweis und gleichzeitig als Sperre gegen Doppelversand.
**Unveränderbarkeit der Protokolle**
- Ein Trigger in der Datenbank weist jede Änderung an einer abgeschlossenen
Prüfung ab. Zulässig bleiben genau drei Dinge: der Wechsel auf „ersetzt", das
Nachtragen des PDF, solange keines hinterlegt ist, und `geaendertAm`.
- Dieselbe Sperre gilt für die Bestandteile: an einem abgeschlossenen Protokoll
lassen sich weder Einzelergebnisse ändern noch Unterschriften nachtragen.
- Korrekturen erzeugen eine **neue** Prüfung mit Verweis auf die alte. Beide
bleiben lesbar.
- Gelöscht wird ein Protokoll nur im gekennzeichneten Aufbewahrungslauf —
derselbe Schalter wie beim Audit-Log.
**Fristen-Dashboard** (`/pruefungen`)
- Vier Töpfe nach Dringlichkeit: überfällig, diese Woche, diesen Monat, nächste
90 Tage. Jede Zahl ist gleichzeitig ein Filter.
- Bündelung nach Fahrzeug, Standort, Gerätetyp oder Zuständigem — wer einen
Prüftag ansetzt, arbeitet ein Fahrzeug ab, keine alphabetische Liste.
- Sammelzuweisung mehrerer Termine an eine Person.
- Daneben die Lebensdauerüberwachung (`/pruefungen/lebensdauer`) mit Vorschlag
zur Aussonderung.
**Prüfwizard** (`/pruefungen/neu`)
- Ein Prüfpunkt je Bildschirm, große Schaltflächen, Foto direkt aus der Kamera.
- Zeiterfassung läuft mit und landet im Protokoll.
- Unterschrift auf einem Zeichenfeld, das Finger, Stift und Maus versteht; das
PNG landet in MinIO.
- Jede Antwort geht sofort zum Server — bricht das WLAN weg, ist höchstens die
letzte verloren, und der Entwurf wird beim nächsten Öffnen fortgesetzt.
- Mängel werden aus den beanstandeten Punkten vorgeschlagen.
- Externe Wartung: Fachfirma, Kosten und das Fremdprotokoll als PDF.
**Prüfprotokoll als PDF**
- Erzeugt der Hintergrundprozess über Gotenberg und legt es in MinIO ab: Kopf
mit Wehr, Gerät und Prüfer-Kennung, Checkliste mit Soll, Ist und Befund,
eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift, Prüfdatum
und Seitenzahl.
- Laufende Protokollnummer `JJJJ-NNNN` je Wehr und Jahr.
**Nächtlicher Lauf**
- `fristen.pruefen` (3:15 Uhr) plant Termine aus den Vorlagen und zieht Frist
und Status an jedem Gerät nach.
- `erinnerung.senden` (3:45 Uhr) verschickt Vorwarnungen bei T-30, T-7, T-0 und
einmal bei Überfälligkeit — als Sammelnachricht je Empfänger.
- Beide Läufe sind wiederholbar und tragen sich beim Start des Workers je Wehr
selbst ein.
**Mängel und Rückrufe**
- Melden darf jedes Mitglied; ein kritischer Mangel sperrt das Gerät sofort.
- Beim Beheben wird die Sperre aufgehoben — aber nur, wenn kein anderer offener
Mangel dasselbe Gerät sperrt.
- Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
und Herstellungszeitraum. Das Ergebnis ist ein Vorschlag, keine automatische
Sperre.
**Sonstiges**
- Datei-Upload unter `/api/dateien` für Fotos, Unterschriften und
Fremdprotokolle — als Multipart, nicht Base64 durch eine JSON-Antwort.
- Der Seed legt 5 Wartungsvorlagen mit echten Prüfvorgaben an (DGUV 112-190,
DIN 14811, DGUV Vorschrift 3, DIN 14920), dazu die fälligen Termine, ein
abgeschlossenes Protokoll samt Mangel und einen Beispielrückruf.
- 79 neue Tests: 47 Unit-Tests zu Fristen, Bewertung und Planung, 10 gegen eine
echte Datenbank zur Unveränderbarkeit und Mandantentrennung, 22 Playwright-
Durchläufe über Dashboard, Vorlagen, Wizard, Mängel und Rückrufe.
**Nachzügler aus Phase 1**
Mit dieser Version wird auch das Geräteformular ausgeliefert, das nach der
0.2.0 entstanden ist:
- **Formular zum Anlegen und Bearbeiten eines Geräts** (`/geraete/neu` und
`/geraete/<id>/bearbeiten`). Damit ist die letzte offene Lücke aus Phase 1
geschlossen: Ein Neuzugang lässt sich einzeln erfassen, ohne den Umweg über
eine Importdatei.
- Die Lagerung wird als Umschalter geführt statt als zwei Auswahlfelder, die
man versehentlich beide füllen kann.
- Prüftermin und Lebensdauerende werden schon beim Tippen ausgerechnet und
angezeigt — mit denselben Funktionen, mit denen der Server anschließend
rechnet.
- Ist nur ein Standort angelegt, wird er vorbelegt.
- 7 weitere Playwright-Tests zum Formular (nun 58 statt 44, zwei davon unter
Touch-Emulation begründet übersprungen).
Dabei behoben:
- **Das Formular stürzte beim ersten Rechnen ab.** Die Vorgabewerte lagen in
einem `"use client"`-Modul und wurden von einer Server-Komponente importiert;
dort kommt nur eine Client-Referenz an, kein Objekt. Das Spreaden davon ergab
lauter `undefined`. Die Werte liegen jetzt in einem eigenen Modul ohne
`"use client"`.
- **Das Sternchen an Pflichtfeldern wurde vorgelesen.** Eine Sprachausgabe
nannte das Feld „Bezeichnung Stern". Das Sternchen trägt jetzt `aria-hidden`;
dass ein Feld ausgefüllt werden muss, sagt das `required`-Attribut.
- Fehlermeldungen in Formularen sind eindeutig ansteuerbar (`data-testid`) —
Next.js hängt einen eigenen Routen-Ansager mit derselben Rolle in jede Seite.
### Geändert
- Die Datumsarithmetik aus Phase 1 (`utcTag`, `monateAddieren`, `heutigerTag`,
`tagesabstand`) ist jetzt exportiert. Zwei Umsetzungen derselben Rechnung
wären früher oder später um einen Tag auseinandergelaufen.
- Die Seitenleiste zeigt Prüfungen, Mängel und Rückrufe als verfügbar.
- Die Gerätedetailseite zeigt Prüfhistorie, offene Mängel und anstehende
Termine — und startet die Prüfung.
### Behoben
- **Zwei gleichzeitig abgeschlossene Prüfungen zogen dieselbe Protokollnummer.**
Die eindeutige Bedingung in der Datenbank fing das ab, aber der zweite Prüfer
bekam einen Datenbankfehler statt eines Protokolls. Die Nummer wird jetzt
unter einer Transaktionssperre je Wehr und Jahr vergeben — gefunden von den
E2E-Tests, weil Desktop- und Tablet-Durchlauf parallel prüften.
- **Der Worker ließ sich außerhalb des Containers nicht starten.** Ihm fehlte
das Laden der `.env`, und `server-only` warf beim Import unter Node. Beide
Startbefehle laufen jetzt mit `--conditions=react-server`, womit der leere
Export dieses Pakets greift.
---
## [0.2.0] — 2026-09-03
Phase 1: Stammdaten. Ab hier führt die Wehr ihren Bestand in FLORIAN — mit
Standorten, Fahrzeugen samt Beladeordnung, Gerätetypen als Vorgabe und
Geräten, die per QR-Code am Regal auffindbar sind.
### Hinzugefügt
**Datenmodell**
- `Standort` (Gerätehaus, Lager, Außenstelle, Werkstatt) mit PostGIS-Punkt für
die Karte ab Phase 6.
- `Geraeteraum` — die Fächer am Fahrzeug (G1 bis G6, Heck, Dach,
Mannschaftsraum), in Beladereihenfolge sortierbar.
- `Geraetetyp` als Vorgabe für Prüfintervall, Prüfvorschrift und Lebensdauer.
Ein einzelnes Gerät darf davon abweichen; sonst gilt der Typ.
- `Qualifikationstyp` und `Qualifikation` mit Gültigkeitsdauer und eigener
Vorwarnzeit je Typ.
- `Fahrzeug` erweitert um Fahrgestellnummer, DIN-Typkurzzeichen, Erstzulassung,
Sitzplätze, Tankinhalt, Gesamtmasse und die UVV-Frist.
- `Geraet` erweitert um Gerätetyp, Lebensdauerkategorie, errechnetes
Lebensdauerende, Anschaffungspreis und die Lagerung.
**Regeln in der Datenbank**
- Ein Gerät steht entweder an einem Standort oder ist auf einem Fahrzeug
verlastet — eine Prüfbedingung setzt das durch, ein Trigger stellt zusätzlich
sicher, dass ein angegebener Geräteraum auch zum angegebenen Fahrzeug gehört.
- Row Level Security auf allen neuen Tabellen.
- Trigram-Indizes für die Volltextsuche über Geräte, Fahrzeuge und Personen.
**Funktionen**
- Geräteliste mit Volltextsuche, Statusfilter, serverseitiger Sortierung,
Blättern und Spaltenauswahl. Die Spaltenauswahl bleibt je Browser gespeichert.
- Massenbearbeitung: Status mehrerer Geräte auf einmal setzen, Geräte umlagern.
Jede Änderung erzeugt einen eigenen Protokolleintrag am jeweiligen Gerät.
- QR-Code je Gerät. Der Code zeigt auf `/g/<inventarnummer>` und führt nach der
Anmeldung direkt zur Gerätedetailseite.
- Etikettenbogen als PDF über Gotenberg: DIN A4 mit 65 Etiketten
(38,1 × 21,2 mm). Der Druck kann an einer beliebigen Position beginnen, damit
angebrochene Bögen weiterverwendet werden können.
- QR-Scan über die Kamera im Browser, mit Eingabefeld als Rückfallweg.
- Import aus CSV und Excel: Spalten werden nach den Überschriften automatisch
zugeordnet, die Vorschau zeigt Zeile für Zeile, was übernommen würde und was
daran nicht stimmt. Geschrieben wird erst nach Bestätigung.
- Gerätehistorie und Fahrzeughistorie, gerendert aus dem Audit-Log.
- Qualifikationen mit Ablaufwarnung, gerechnet mit der Vorwarnzeit des
jeweiligen Typs.
- Fahrzeugdetail zeigt die Beladung nach Geräteräumen statt alphabetisch.
**Daten und Tests**
- Der Seed legt jetzt zusätzlich 3 Standorte, 20 Geräteräume, 14 Gerätetypen
mit echten Prüfvorgaben (DGUV, DIN, vfdb), 49 Geräte und 107 Qualifikationen
an. Fristen sind bewusst gestreut, damit die Ampeln etwas zeigen.
- 104 Vitest-Tests (vorher 32), davon 40 zum Import und 32 zu QR und Fristen.
- 44 Playwright-Tests (vorher 10) über Desktop und Tablet.
### Geändert
- **Fristenrechnung auf UTC-Kalenderarithmetik umgestellt.** Vorher wurde über
`startOfDay` aus date-fns gerechnet, das die Zeitzone des Servers verwendet.
Ein Prüfdatum kommt aus der Datenbank als UTC-Mitternacht und wurde dabei auf
den Vortag zurückgeschoben — Fristen wären einen Tag zu früh fällig geworden.
- `CardTitle` rendert eine echte Überschrift statt eines `div`. Wer mit einer
Sprachausgabe arbeitet, springt von Überschrift zu Überschrift; Karten
tauchten in dieser Navigation vorher gar nicht auf.
- Die Entwicklungsanzeige von Next.js ist abgeschaltet, damit sie nicht in den
Bildern der Anleitung mitläuft.
### Sicherheit
- Der Etikettendruck wird protokolliert (`EXPORTIERT`) — damit ist
nachvollziehbar, wann welche Aufkleber erzeugt wurden.
- Der Import übernimmt keine Werte, die der Browser geschickt hat: die Datei
wird beim Übernehmen erneut serverseitig eingelesen und geprüft. Sonst ließe
sich die Vorschau umgehen.
## [0.1.0] — 2026-09-03
+833 -10
View File
@@ -24,13 +24,13 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Bereich | Stand |
|---|---|
| Phase 0 — Fundament | fertig |
| Phase 1 — Stammdaten | offen |
| Phase 2 — Wartung und Prüfung | offen |
| Phase 3 — Einsätze und Übungen | offen |
| Phase 4 — Kiosk-PWA | offen |
| Phase 5 — Atemschutz (FwDV 7) | offen |
| Phase 6 — Kartenmodule | offen |
| Phase 7 — Benachrichtigungen und Termine | offen |
| Phase 1 — Stammdaten | fertig |
| Phase 2 — Wartung und Prüfung | fertig |
| Phase 3 — Einsätze und Übungen | fertig |
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
| Phase 5 — Atemschutz (FwDV 7) | fertig |
| Phase 6 — Kartenmodule | fertig |
| Phase 7 — Benachrichtigungen und Termine | fertig |
| Phase 8 — Lokale KI | offen |
| Phase 9 — Kassenverwaltung | offen |
@@ -154,6 +154,825 @@ Downloads laufen über presignte Links mit begrenzter Gültigkeit
---
## Phase 2 — Wartung und Prüfung
Das Modul, wegen dem eine Wehr so ein Programm überhaupt einführt: Prüffristen
im Blick behalten und im Streitfall nachweisen können, dass geprüft wurde.
### Der Weg durch das Modul
```
Wartungsvorlage was an einem Gerätetyp in welchem Takt geprüft wird
└─ Checklistenpunkt die einzelne Frage: ja/nein, Messwert, Text, Foto
↓ nächtlicher Lauf
Wartungstermin ein fälliger Termin an einem konkreten Gerät
↓ Prüfwizard
Pruefung durchgeführte Prüfung, unterschrieben, festgeschrieben
├─ Pruefergebnis je Checklistenpunkt eine Antwort
├─ Signatur Unterschrift aus dem Zeichenfeld, PNG in MinIO
└─ Mangel was aufgefallen ist und behoben werden muss
```
Ohne Vorlage gibt es keine Termine, ohne Termine kein Fristen-Dashboard. Die
erste Seite, die eine Wehr in Phase 2 ausfüllt, ist deshalb
`/pruefungen/vorlagen`.
### Das Protokoll ist unveränderbar
Ein abgeschlossenes Prüfprotokoll ist der Nachweis gegenüber der Unfallkasse.
Wäre es nachträglich änderbar, wäre es als Nachweis wertlos. Die Sperre sitzt
deshalb in der Datenbank (Trigger `pruefung_unveraenderlich`, Migration
`20260903000300_wartung_rls`) und nicht nur im Anwendungscode — ein vergessener
Zustandscheck in einem Router würde sonst reichen, um sie auszuhebeln.
Nach dem Abschluss sind genau drei Änderungen zulässig, und alle drei ergänzen
das Protokoll, statt es zu verfälschen:
| Änderung | Warum |
|---|---|
| `ABGESCHLOSSEN` → `ERSETZT` | eine Korrekturprüfung ist nachgefolgt |
| `protokollDateiId`, solange leer | das PDF entsteht im Hintergrund und wird nachgetragen |
| `geaendertAm` | setzt Prisma bei jedem Schreibvorgang mit |
Dasselbe gilt für die Bestandteile: an einem abgeschlossenen Protokoll lassen
sich weder Einzelergebnisse ändern noch Unterschriften nachtragen. **Korrekturen
erzeugen eine neue Prüfung** mit Verweis auf die alte; beide bleiben lesbar.
Gelöscht wird ein Protokoll nie — außer im ausdrücklich gekennzeichneten
Aufbewahrungslauf, demselben Schalter wie beim Audit-Log
(`SET LOCAL app.audit_bereinigung = 'an'`).
### Der nächtliche Lauf
Der Worker trägt sich beim Start je Wehr zwei wiederkehrende Aufträge ein:
| Auftrag | Vorgabe | Was er tut |
|---|---|---|
| `fristen.pruefen` | 03:15 Uhr | Termine aus den Vorlagen planen, Frist und Status an jedem Gerät nachziehen |
| `erinnerung.senden` | 03:45 Uhr | Vorwarnungen zu offenen Terminen verschicken |
Beide sind wiederholbar: doppelte Termine verhindert die Eindeutigkeit über
Gerät, Vorlage und Fälligkeitstag, doppelte Mails ein Erinnerungseintrag je
Termin und Stufe. Erinnert wird bei T-30 (bzw. der Vorwarnzeit der Vorlage),
T-7, T-0 und einmal bei Überfälligkeit — als **eine** Sammelnachricht je
Empfänger, nicht als eine Mail je Gerät.
„Überfällig" ist bewusst kein gespeicherter Zustand, sondern wird aus dem
Fälligkeitsdatum und dem heutigen Tag gerechnet. Gespeicherte Zustände laufen
der Uhr hinterher; berechnete nicht.
### Prüfwizard
Zugeschnitten auf das Tablet im Gerätehaus: ein Prüfpunkt je Bildschirm, große
Schaltflächen, Foto direkt aus der Kamera, Unterschrift auf einem Zeichenfeld,
das Finger, Stift und Maus gleichermaßen versteht.
Jede Antwort geht sofort zum Server. Bricht das WLAN weg, ist höchstens die
letzte Antwort verloren, nicht die ganze Prüfung — beim erneuten Öffnen wird der
Entwurf fortgesetzt.
Die Bewertung rechnet der Server, nicht der Browser
([`src/lib/wartung/bewertung.ts`](src/lib/wartung/bewertung.ts)):
- ein Messwert außerhalb des zulässigen Bereichs gilt als beanstandet,
- ein beanstandeter **Pflichtpunkt** bedeutet „nicht bestanden" und sperrt das
Gerät,
- ein beanstandeter Kann-Punkt bedeutet „bestanden mit Mängeln".
Das Ergebnis ist ein Vorschlag; die Entscheidung trifft der Prüfer, denn er
trägt die Verantwortung.
### Prüfprotokoll als PDF
Erzeugt der Worker über Gotenberg
([`src/lib/wartung/protokoll.ts`](src/lib/wartung/protokoll.ts)) und legt es in
MinIO ab. Kopf mit Wehr, Gerät und Prüfer-Kennung, die Checkliste mit Soll, Ist
und Befund, eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift,
Prüfdatum und Seitenzahl.
Alles ist eingebettet — keine externen Schriften, keine nachgeladenen Bilder.
Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute.
Die laufende Protokollnummer (`JJJJ-NNNN`) wird je Wehr und Jahr unter einer
Transaktionssperre vergeben; zwei gleichzeitig abschließende Prüfer bekommen
verschiedene Nummern.
### Mängel und Rückrufe
Der Mängelweg ist immer derselbe: melden → zugewiesen → in Arbeit → behoben.
**Melden darf jedes Mitglied** — der Mangel fällt dem auf, der das Gerät
benutzt, nicht dem Gerätewart am Schreibtisch. Ein kritischer Mangel sperrt das
Gerät sofort; beim Beheben wird die Sperre wieder aufgehoben, aber nur, wenn
kein anderer offener Mangel dasselbe Gerät sperrt.
Bei einem Rückruf sucht FLORIAN anhand von Bauart, Modell, Seriennummernbereich
und Herstellungszeitraum nach betroffenen Geräten. Das Ergebnis ist ein
**Vorschlag**, keine automatische Sperre: eine Chargenangabe des Herstellers
passt selten eins zu eins auf die Seriennummern im eigenen Bestand.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `WARTUNG_NACHTLAUF` | `15 3 * * *` | wann Termine geplant werden (cron, Europe/Berlin) |
| `WARTUNG_ERINNERUNG_LAUF` | `45 3 * * *` | wann Erinnerungen verschickt werden |
| `WARTUNG_PLANUNGSHORIZONT_TAGE` | `180` | wie weit im Voraus Termine angelegt werden |
---
## Phase 3 — Einsätze und Übungen
Was die Wehr tut, wird hier dokumentiert — für die Gemeinde, für die
Versicherung und für die eigene Mannschaft.
### Der Weg durch das Modul
```
Schnellerfassung Stichwort, Ort, Fahrzeuge — im Alarmfall in 20 Sekunden
↓ während des Einsatzes
Zeitstempel alarmiert, ausgerückt, eingetroffen, Ende, eingerückt,
einsatzbereit — je ein Knopf
Lagemeldungen aus der Wehr und über das Portal einer Fremdwehr
↓ danach, in Ruhe
Kräfte und Material wer war dabei, was wurde verbraucht
Bericht der Text des Einsatzleiters
↓ Freigabe
Einsatzbericht als PDF, festgeschrieben, an die Gemeinde
```
### Die sechs Zeitstempel
Sie sind der Kern jeder Einsatzauswertung. Aus ihnen ergeben sich drei Zahlen:
| Kennzahl | Rechnung | sagt etwas aus über |
|---|---|---|
| **Ausrückzeit** | Alarm bis Ausrücken | die Wehr selbst |
| **Anfahrtszeit** | Ausrücken bis Eintreffen | Lage und Wegstrecke |
| **Hilfsfrist** | Alarm bis Eintreffen | das, wonach die Gemeinde fragt |
In Bayern gilt als Ziel: **in 80 Prozent der Fälle innerhalb von zehn Minuten**.
Genau dieser Wert steht in der Statistik — nicht der Durchschnitt, denn der
verdeckt die Ausreißer, um die es geht.
### Stärke nach FwDV 3
Aus den Funktionen der erfassten Kräfte errechnet FLORIAN die Stärke in der
üblichen Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`:
| Einheit | Stärke | Besetzung |
|---|---|---|
| Trupp | 0/1/1/2 | Truppführer, 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 |
**Regional unterschiedlich.** Manche Wehren zählen den Maschinisten zu den
Unterführern, andere schreiben dreistellig ohne Unterführer (Gruppe = 1/8/9).
Die Zuordnung steht an einer Stelle — [`src/lib/einsatz/staerke.ts`](src/lib/einsatz/staerke.ts) —
und ist bewusst leicht zu ändern.
### Der freigegebene Bericht ist unveränderbar
Dieselbe Logik wie beim Prüfprotokoll aus Phase 2, denn der Bericht geht an
die Gemeinde und im Schadensfall an die Versicherung. Bis zur Freigabe ist
alles änderbar — ein Einsatz wird tagelang ergänzt, und genau dafür ist der
Entwurfsstand da. Mit der Freigabe ist Schluss; der Trigger
`einsatz_freigabe_schuetzen` setzt das durch.
Zwei Ausnahmen bleiben, beide nachtragend: das PDF, solange keines hinterlegt
ist, und `geaendertAm`.
**Lagemeldungen sind ausdrücklich ausgenommen.** Sie sind das Protokoll des
Verlaufs, kein Bestandteil des Berichts — und eine Meldung aus dem Portal kann
eintreffen, wenn der Bericht längst geschrieben ist.
### Einsatzportal für Fremdwehren
Bei einer größeren Sache sind Nachbarwehren, THW oder Rettungsdienst mit vor
Ort. Die haben kein Konto in FLORIAN und sollen auch keines bekommen. Der
Einsatzleiter erzeugt stattdessen einen Link, den er als QR-Code zeigt oder
weiterschickt.
Vier Schranken sichern ihn ab:
1. **Zufall** — 32 zufällige Bytes im Link.
2. **Nur ein Hash in der Datenbank** — wer sie liest, bekommt keinen gültigen Link.
3. **Zeitlich begrenzt** — Vorgabe 24 Stunden, jederzeit widerrufbar, jeder
Aufruf wird mitgezählt.
4. **Zugriffsbegrenzung** — 120 Aufrufe je Link und Stunde, 30 Meldungen, 10
Fotos.
Und eine fünfte, die keine technische ist: das Portal zeigt Stichwort, Ort,
Zeitstempel und Lagemeldungen. **Keine Namen von Einsatzkräften, keine
Berichte, kein Weg in den Rest der Anwendung.**
### Adressen zu Koordinaten
Über einen **selbst betriebenen** Nominatim (`NOMINATIM_URL`). Einsatzadressen
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.
Ohne Konfiguration bleibt das Feld leer und die Koordinaten werden von Hand
eingetragen — kein Fehler, sondern der Normalfall in einer frischen
Installation. Die Karte dazu kommt in Phase 6.
Gespeichert wird doppelt: als Dezimalgrad, die Prisma lesen kann, und als
PostGIS-Punkt für die Karte. Ein Trigger hält beides zusammen.
### Statistik und Jahresexport
`/einsaetze/statistik` beantwortet beide Fragen: die der Gemeinde (Anzahl nach
Kategorie der Landesstatistik, Hilfsfristen) und die der Wehr (Beteiligung je
Person, Auslastung je Fahrzeug). Der Jahresexport als PDF liegt unter
`/api/einsaetze/jahresexport?jahr=…` und wird bewusst **nicht** abgelegt — er
ist eine Auswertung, keine Urkunde, und ändert sich, solange noch Einsätze
nachgetragen werden.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `GEOCODING_ENABLED` | `false` | Adresssuche einschalten |
| `NOMINATIM_URL` | — | Adresse des eigenen Nominatim |
| `GEOCODING_LAENDER` | `de,at,ch` | Eingrenzung der Suche |
| `PORTAL_ENABLED` | `true` | Einsatzportal für Fremdwehren |
| `PORTAL_GUELTIGKEIT_STUNDEN` | `24` | Lebensdauer eines Links |
| `PORTAL_MAX_ZUGRIFFE` | `120` | Aufrufe je Link und Stunde |
---
## Phase 4 — Das Terminal im Gerätehaus
Ein Wandtablet neben der Fahrzeughalle. Es muss zwei Dinge können, die eine
Verwaltungsoberfläche am Schreibtisch nicht können muss: **mit Handschuhen
bedienbar** sein und **weiterarbeiten, wenn das Netz weg ist**. Stahlbeton,
Metalltore und ein Repeater, der irgendwann ausfällt — das ist der Normalfall,
nicht die Ausnahme.
Die Oberfläche liegt unter `/kiosk` und ist eine eigene: große Kacheln statt
Navigationsleiste, Schaltflächen ab 64 Pixel Höhe, Symbol **und** Text.
### Der Weg durch das Modul
1. **Einmal einrichten** — `/kiosk/einrichten` verlangt den Kurznamen der Wehr
und das Gerätegeheimnis aus der `.env` (`KIOSK_DEVICE_SECRET`). Beides nimmt
die Seite auch aus der Adresse an, damit am Tablet nichts abgetippt werden
muss:
```
/kiosk/einrichten?wehr=ff-musterhausen&schluessel=…
```
2. **Anmelden** — Ausweis auflegen, QR-Code vom Handy oder Mitgliedsnummer und
PIN. Ohne eingerichtetes Gerät kommt niemand hinein, auch nicht mit richtiger
PIN: das Gerätegeheimnis ist die erste Schranke.
3. **Erfassen** — Mangel melden, Kommen/Gehen, Fahrt beginnen und beenden,
Wäsche abgeben, Inventurgang.
4. **Von selbst abmelden** — nach `KIOSK_IDLE_TIMEOUT` Sekunden Untätigkeit,
mit sichtbarer Warnung vorher. Ein Terminal, an dem die letzte Anmeldung
stehen bleibt, erfasst irgendwann alles auf den Falschen.
Was dabei zusammenkommt, steht in der Verwaltung unter `/terminal`.
### Mangel melden in unter dreißig Sekunden
Der Vorgang, an dem sich das Terminal misst. Drei Schritte, kein Formular:
1. QR-Code am Gerät scannen — oder die Inventarnummer eintippen.
2. Foto aufnehmen und einen Satz dazu.
3. Dringlichkeit antippen. Damit ist es abgeschickt.
„Kritisch" sperrt das Gerät sofort. Eine unbekannte Inventarnummer hält die
Meldung **nicht** auf: sie geht raus und landet notfalls in der Konfliktliste —
lieber ein Zettel zu viel als ein Riss im Schlauch, den niemand gemeldet hat.
### Offline: wie nichts verloren geht
Drei Bausteine, und keiner davon ist optional:
**Service Worker** (`public/sw.js`, von Hand geschrieben, keine Bibliothek):
App-Shell und Symbole liegen im Zwischenspeicher, Seitenaufrufe gehen
`network-first` mit Ausweichseite `/kiosk/offline`, lesende tRPC-Aufrufe
`stale-while-revalidate`. Schreibende Anfragen und alles unter `/api/auth`
fasst er **nicht** an — eine zwischengespeicherte Anmeldung wäre eine Lücke,
kein Komfort.
**Warteschlange in der IndexedDB** (`src/lib/kiosk/outbox.ts`, Dexie): jede
schreibende Aktion geht zuerst dorthin — auch online. Sonst gäbe es zwei Wege
durch den Code, und der seltenere wäre der ungetestete. Fotos liegen als Blob
daneben und werden beim Übertragen zuerst hochgeladen.
**Idempotenzschlüssel**: die Kennung erzeugt der Browser beim Einreihen
(`crypto.randomUUID()`), nicht der Server beim Empfangen. Bricht die
Übertragung ab und wird wiederholt, erkennt der Server denselben Vorgang an
derselben Kennung wieder und antwortet mit dem Ergebnis von damals. Die Tabelle
`kiosk_vorgang` hat diese Kennung als Primärschlüssel, und die Datenbank
verweigert dem Anwendungszugang `UPDATE` und `DELETE` darauf.
Ein Vorgang verschwindet aus der Warteschlange, wenn der Server ihn angenommen
**oder** als bereits verarbeitet erkannt **oder** in die Konfliktliste gelegt
hat. In allen drei Fällen ist er angekommen. Liegen bleibt nur, was technisch
nicht durchging.
Der Zustand steht als Leiste über allem — verbunden, wie viele Änderungen
warten, kein Netz — in Klartext und nicht nur als Farbe.
**Wem ein Vorgang zugeschrieben wird.** Der Server nimmt dafür die Person, die
beim Empfang angemeldet ist. Im Regelfall ist das dieselbe, die erfasst hat —
wer offline erfasst, bleibt angemeldet, weil auch das Abmelden Netz braucht.
Damit dazwischen kein Fenster bleibt, überträgt das Terminal vor jedem
Abmelden — von Hand wie durch die Abmeldeuhr — noch einmal.
### Konflikte
Ein Vorgang, der sich nicht anwenden lässt (Gerät zwischenzeitlich ausgesondert,
Prüfung inzwischen festgeschrieben), wird nicht verworfen und nicht erzwungen,
sondern in `sync_konflikt` gelegt. Der Gerätewart sieht ihn unter `/terminal`
und entscheidet. Das ist der ehrliche Umgang mit einem Tablet, das drei Tage
offline war.
### Anmeldemarke fürs Handy
Wer den Ausweis vergessen hat, öffnet `/anmeldemarke` und hält den QR-Code vor
die Kamera des Terminals. Die Marke ist mit `AUTH_SECRET` signiert, **nicht
gespeichert**, und gilt 60 Sekunden — ein abfotografierter Code aus der Umkleide
nützt danach nichts mehr. Die Seite erneuert ihn von selbst.
### Hallenmonitor
`/kiosk/monitor` läuft im Vollbild ohne Bedienung: Uhrzeit, laufender Einsatz,
wer im Haus ist, Fahrzeuge samt laufenden Fahrten, fällige Prüfungen und die
nächsten Termine. Optional Wetterwarnungen des DWD — die einzige Verbindung,
die FLORIAN von sich aus nach außen aufbaut, und deshalb standardmäßig aus.
### Als App installieren
Das Manifest liegt unter `/manifest.webmanifest`, die Symbole entstehen mit
`npm run icons` und liegen unter `public/icons/`. Startseite ist `/kiosk`. Im
Kiosk-Browser des Tablets wird daraus eine Vollbildanwendung ohne Adresszeile.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `KIOSK_ENABLED` | `true` | Terminalanmeldung freischalten |
| `KIOSK_DEVICE_SECRET` | — | Geheimnis, mit dem sich ein Tablet ausweist |
| `KIOSK_IDLE_TIMEOUT` | `120` | Abmeldung nach Untätigkeit, in Sekunden |
| `WETTER_ENABLED` | `false` | Wetterwarnungen auf dem Monitor |
| `DWD_WARNCELL` | — | Warnzellen-Nummer der eigenen Gemeinde |
| `DWD_WARNUNGEN_URL` | DWD-Warnapp | Quelle der Warnungen |
| `WETTER_TIMEOUT` | `5` | Zeitlimit des Abrufs, in Sekunden |
| `WETTER_ZWISCHENSPEICHER` | `300` | Gültigkeit einer Antwort, in Sekunden |
---
## Phase 5 — Atemschutz nach FwDV 7
Das sicherheitsrelevanteste Modul des Projekts. Über jeder Ansicht steht
deshalb dauerhaft:
> **Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend.**
Der Hinweis lässt sich abschalten, aber nur zusammen mit einer Fundstelle:
`ATEMSCHUTZ_HINWEIS=false` wirkt erst, wenn unter `ATEMSCHUTZ_FREIGABE` steht,
worauf sich die Freigabe stützt — etwa „Kommandantenbeschluss vom 12.03.2026".
Fehlt sie, bleibt der Hinweis stehen. Eine Freigabe ohne Beschluss ist keine.
### Die Zeit gehört dem Server
Das ist die eine Entscheidung, aus der alles Weitere folgt. **Nirgends wird
eine Restzeit heruntergezählt.** In der Datenbank stehen Zeitstempel; wie lange
ein Trupp drin ist, rechnet jede Ansicht bei jedem Bild neu aus dem
Einstiegszeitpunkt und der aktuellen Uhr.
Daraus ergibt sich von selbst, was Phase-5.md als wichtigste Anforderung nennt:
| Was passiert | Was die Anzeige macht |
|---|---|
| Browser neu laden | Zeigt dieselbe Zeit weiter — es gab nichts zu verlieren |
| Zweiter Bildschirm daneben | Zeigt dieselbe Zeit, ohne Abgleich |
| Verbindung bricht ab | Die Uhr läuft weiter, und der Abriss steht sichtbar da |
| Server neu gestartet | Nach der ersten Antwort ist alles wieder da |
Server und Browser rechnen mit **denselben** Funktionen
(`src/lib/atemschutz/berechnung.ts`). Was das Tablet anzeigt, weist das
Protokoll später genauso aus.
### Rückzugsdruck
Gerechnet wird nach dem **doppelten Anmarschverbrauch**: was der Weg hinein an
Luft gekostet hat, kostet der Weg zurück noch einmal.
```
Rückzug = 2 × (Eingangsdruck − Druck am Einsatzziel) + Warndruck + Zuschlag
```
Dafür meldet der Trupp beim Erreichen des Einsatzziels einmal seinen Druck.
Solange das nicht geschehen ist, greift die **Zwei-Drittel-Regel** — sie liegt
in aller Regel höher und ist damit die vorsichtigere Annahme; die Karte sagt
dann „vorläufig" dazu.
Maßgeblich ist immer der ungünstigste Wert im Trupp: der Trupp geht gemeinsam
zurück, also zählt, wer am wenigsten Luft hat.
Die Ampel steht schon zehn Prozent über der Grenze auf Gelb — der Trupp soll
losgehen, bevor der Wert erreicht ist, nicht wenn er erreicht ist. Farbe **und**
Text, wie überall in FLORIAN.
### Der Weg durch eine Überwachung
1. **Eröffnen** — Bezeichnung genügt. Die Zuordnung zu einem Einsatz lässt sich
nachtragen; im Ernstfall wird zuerst überwacht.
2. **Trupp anlegen** — zwei oder drei Personen, für jede der Eingangsdruck, eine
davon Truppführer. Daneben steht, ob die Person nach FwDV 7 einsetzbar ist.
**Gesperrt wird nichts:** im Einsatz entscheidet der Einheitsführer, und ein
Programm, das ihm im entscheidenden Moment eine Person verweigert, wird
umgangen — dann steht am Ende gar nichts im Protokoll.
3. **Einstieg** — ab hier läuft die Uhr.
4. **Druckabfrage** — für den ganzen Trupp in einer Zeile. Nach
`ATEMSCHUTZ_ABFRAGE_INTERVALL` Sekunden ohne Meldung wird erinnert; gerechnet
ab der letzten Meldung, nicht ab dem Einstieg.
5. **Rückweg und heraus** — beim Herauskommen die Ausgangsdrücke.
6. **Beenden** — geht erst, wenn alle Trupps als draußen gemeldet sind. Eine
beendete Überwachung mit einem Trupp, der noch drin steht, wäre die
gefährlichste Zeile im ganzen Protokoll.
Dazu jederzeit: **Rückzug anordnen**, **Mayday** (aufzuheben, nicht
wegzuklicken) und Notizen ins Protokoll.
### Der Ereignisstrom
Änderungen kommen über **Server-Sent Events** unter `/api/atemschutz/strom` —
kein WebSocket, kein eigener Node-Server, `next start` bleibt wie es ist. Der
Strom meldet nur „es hat sich etwas geändert"; die Ansicht holt daraufhin den
Stand. Geschrieben wird wie überall über tRPC.
Zwei Dinge machen ihn belastbar:
- **Er verbindet sich selbst neu.** `EventSource` bringt das mit.
- **Stille gilt als Abriss.** Der Server schickt alle zwei Sekunden ein
Lebenszeichen; bleibt es acht Sekunden aus, steht die Verbindung sichtbar auf
„unterbrochen" — auch wenn der Browser keinen Fehler meldet. Dieser Fall ist
real: wird das Netz gekappt, während der Strom offen ist, schweigt
`EventSource` einfach.
Fällt der Strom ganz aus, bleibt die Ansicht bedienbar — sie fragt zusätzlich im
festen Takt nach. Der Strom macht sie schneller, nicht erst funktionsfähig.
### Das Protokoll
Ein PDF mit Rahmen, Trupps und dem Verlauf — Ereignisse und Druckmessungen in
**einer** Zeitachse, damit niemand zwei Spalten gleichzeitig lesen muss. Es
entsteht beim Beenden im Hintergrundprozess und, falls das nicht geklappt hat,
beim Abruf unter `/api/atemschutz/protokoll?id=…`.
Im Kopf steht, ob die digitale Überwachung freigegeben war. Wer das Blatt
später in der Hand hält, soll wissen, was er da hat.
Verändern lässt sich nichts: `druckmessung` und `atemschutz_ereignis` sind für
den Anwendungszugang gegen `UPDATE` und `DELETE` gesperrt, und zusätzlich sitzt
auf beiden ein Trigger. Ein Fehler wird durch einen neuen Eintrag
richtiggestellt.
### Wer darf unter Atemschutz?
Drei Nachweise, die gleichzeitig gelten müssen:
| Nachweis | Frist |
|---|---|
| **G26.3** | nach Alter: bis 50 Jahre drei Jahre, danach eines |
| **Belastungsübung** | jährlich (Übungsstrecke) |
| **Einsatzübung unter Atemschutz** | jährlich — ein echter Einsatz zählt |
Dazu die Qualifikation als Atemschutzgeräteträger aus Phase 1. Die Übersicht
zeigt eine Ampel **mit Klartext**, was fehlt: eine rote Ampel ohne Begründung
hilft am Fahrzeug niemandem. Auf der Startseite steht die Zahl, nach der im
Einsatz als Erstes gefragt wird — wie viele Träger heute einsetzbar sind.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `ATEMSCHUTZ_HINWEIS` | `true` | Sicherheitshinweis über jeder Ansicht |
| `ATEMSCHUTZ_FREIGABE` | — | Fundstelle der Freigabe; ohne sie bleibt der Hinweis |
| `ATEMSCHUTZ_ABFRAGE_INTERVALL` | `600` | Abstand der Druckabfrage in Sekunden |
| `ATEMSCHUTZ_WARNDRUCK` | `55` | Ansprechdruck der Warneinrichtung in bar |
| `ATEMSCHUTZ_ZUSCHLAG` | `10` | Sicherheitszuschlag auf den Rückzugsdruck |
---
## Phase 6 — Kartenmodule
Die Frage, wegen der es das Modul gibt, wird auf der Anfahrt gestellt: **Was
liegt in der Nähe, und was gibt es her?**
### Keine Kacheln aus dem Netz
Wo eine Wehr ausrückt, wo ihre Hydranten liegen und welche Objekte sie im Blick
hat, ist niemandes Geschäft. FLORIAN lädt deshalb **keine** Kartenkacheln von
einem öffentlichen Dienst — auch nicht „nur zum Ausprobieren". Der Kartenserver
läuft selbst:
```bash
docker compose --profile karte up -d
```
Dafür wird eine MBTiles-Datei des eigenen Ausrückegebiets gebraucht; woher man
sie bekommt, steht in [docker/karten/LIESMICH.md](docker/karten/LIESMICH.md).
Danach in der `.env`:
```
KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json
```
**Ohne Kartenserver läuft alles trotzdem** — die Karte zeigt einen leeren Grund
mit einem Hinweis darauf. Punkte liegen an der richtigen Stelle, Entfernungen
stimmen, die Umkreissuche rechnet; es fehlen nur die Straßen darunter. Ist ein
Server eingetragen, aber nicht erreichbar, sieht es genauso aus — mit
entsprechendem Hinweis.
### Löschwasser
Hydranten, Saugstellen, Zisternen und offene Gewässer mit Typ, Nennweite,
Durchfluss, Zustand und dem, was man vor Ort wissen muss („im Winter oft
zugeschoben").
Die **Umkreissuche** ist der eigentliche Zweck: in die Karte tippen, und es
steht da, was in 500 Metern erreichbar ist — nach Entfernung sortiert, mit
Nennweite und Durchfluss. Sie läuft über PostGIS (`ST_DWithin` auf der
Geografie, GiST-Index), nicht über die Dezimalgrade: bei tausend Hydranten wäre
alles andere ein vollständiger Tabellendurchlauf, und gefragt wird während der
Fahrt.
### Import
Zwei Quellen, die eine Wehr tatsächlich bekommt:
| Quelle | Was erkannt wird |
|---|---|
| **GeoJSON** | FeatureCollection mit Punkten, Eigenschaften wie in der CSV |
| **CSV der Stadtwerke** | Spaltennamen wie `DN`, `Lage`, `Latitude`, `Pruefdatum`, `Bauart` |
Der Import ist nachsichtig, aber ehrlich: „DN 100" wird zu 100, „48,5031" zu
48.5031, „24.05.2026" zu einem Datum, und vertauschte Achsen werden erkannt und
zurückgedreht. Was nicht lesbar ist, wird mit Zeilennummer und Grund gemeldet —
und der Rest läuft durch. Eine Liste, die zu 90 Prozent stimmt, ist im Einsatz
mehr wert als keine.
**Geraten wird nichts.** Fehlt die Position, wird die Zeile übersprungen: ein
Hydrant ohne Ort ist kein Hydrant. Und ohne Statusangabe steht „ungeprüft",
nicht „einsatzbereit".
### Objekte und Feuerwehrpläne
Was hier gepflegt wird, wird auf der Anfahrt gelesen — oft vom Beifahrersitz,
oft in zwanzig Sekunden. Deshalb sind **Zufahrt**, **Gefahren** und
**Schlüsseldepot** eigene Felder und stehen ganz oben, nicht in einer
Fließtextnotiz.
Dazu Ansprechpartner mit der einzigen Angabe, die im Einsatz zählt — wer nachts
ans Telefon geht — und die Planblätter nach DIN 14095 mit ihrem Stand.
Gesucht wird über alles: Name, Straße, Gefahrenstichwort, Ansprechpartner. Wer
im Einsatz sucht, weiß selten, wie das Objekt im System heißt.
### Lagekarte
Eine je Einsatz, unterhalb der Einsatzdaten. 22 taktische Zeichen nach
FwDV 102, selbst gezeichnet als SVG — fertige Sätze sind selten frei
verwendbar, und nachgeladen wird ohnehin nichts.
Ein Werkzeug, ein Klick: Zeichen wählen, in die Karte tippen. Dazu Linienzüge
(Schlauchleitung, Absperrung), Flächen (Bereitstellungsraum) und
Beschriftungen.
Der Abgleich zwischen Geräten läuft über denselben Ereignisstrom wie die
Atemschutzüberwachung, und wie dort steht der Zustand der Verbindung sichtbar
da. Zurückgenommene Elemente verschwinden von der Karte, nicht aus dem
Datenbestand: eine Lagekarte ist die Mitschrift einer Entscheidung.
### Wasserförderung
Die Rechnung, die sonst mit Tabelle und Bleistift auf der Motorhaube entsteht.
Strecke in die Karte tippen, Durchmesser und Fördermenge einstellen:
```
Reibungsverlust je 100 m = Beiwert × (Fördermenge / 100)²
Höhenverlust = Höhenmeter × 0,1 bar
```
Daraus: Zahl der Verstärkerpumpen, Schlauchlängen je Abschnitt, Restdruck und
der Druck, der an der Einsatzstelle ankommt. Die Beiwerte treffen die
Tabellenwerte der Ausbildung — 1,5 bar je 100 m im B-Schlauch bei 800 l/min.
Reicht ein Abschnitt selbst mit voller Pumpe nicht, sagt die Ausgabe das,
statt eine Zahl auszuwerfen, die niemand halten kann. Und unter jeder Rechnung
steht, was sie ist: **eine Planungshilfe. Was zählt, ist der Druck am
Verteiler.**
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `KARTE_STIL_URL` | — | Stil des eigenen Kartenservers; leer = leerer Grund |
| `KARTE_BREITENGRAD` | `48.5` | Mittelpunkt, falls kein Standort verortet ist |
| `KARTE_LAENGENGRAD` | `12.15` | dito |
| `KARTE_ZOOM` | `13` | Zoomstufe beim Öffnen |
| `KARTE_PORT` | `8085` | Port des tileserver-gl (Profil `karte`) |
---
## Phase 7 — Benachrichtigungen und Termine
Der Satz, der über der ganzen Phase steht und der auch die Technik darunter
bestimmt: **Niemand wird ungefragt benachrichtigt.** Wer nichts einträgt,
bekommt nichts. Es gibt keine Voreinstellung, die stillschweigend alle
anmeldet — ein neu eingerichteter Zustellweg hat eine leere Ereignisliste, und
erst wer ankreuzt, wofür er ihn will, bekommt etwas.
### Der Terminkalender
Übungsabende, Lehrgänge, Arbeitsdienste, Sitzungen. Drei Entscheidungen prägen
ihn, und alle drei kommen aus dem Alltag einer Wehr:
**Anmeldung ist die Ausnahme, nicht die Regel.** Beim normalen Übungsabend
meldet sich niemand an, man kommt oder man kommt nicht. Erst beim Lehrgang mit
zwölf Plätzen und beim Ausflug mit Busbestellung wird eine Rückmeldung
verlangt.
**Abgesagt, nicht gelöscht.** Ein Termin, der ohne ein Wort verschwindet, führt
dazu, dass am Dienstagabend jemand vor dem verschlossenen Gerätehaus steht.
Eine Absage braucht einen Grund, bleibt im Kalender stehen und erreicht die,
die zugesagt hatten — nicht die ganze Wehr.
**Die Warteliste rückt von selbst nach.** Wer bei belegten Plätzen zusagt,
landet auf der Warteliste; sagt jemand ab, rückt der Erste nach und erfährt es.
Sonst führt jemand die Liste von Hand, und das tut niemand zuverlässig.
Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder
Abend ist ein eigener Datensatz. Der Grund ist derselbe Alltag — der Übungsabend
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Mit einer Regel wäre jede
dieser Ausnahmen ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele
Termine daraus werden.
Die Anwesenheitsliste zeigt **alle aktiven Mitglieder**, nicht nur die
Angemeldeten: auf einem Blatt zum Abhaken muss jeder stehen. Zusage und
Anwesenheit sind getrennt — wer zugesagt hat und nicht kam, steht genau so in
der Liste.
### Der Kalender fürs Handy
Eine Adresse, die man im Handy oder im Mailprogramm abonniert; die Termine
stehen dann neben allem anderen, ohne dass jemand FLORIAN öffnen muss. Das ist
der Kanal mit der größten Wirkung pro Zeile Code in dieser Phase.
**Bewusst kein CalDAV.** Das ist ein Schreib-Lese-Protokoll mit
Rechteverwaltung und Konfliktauflösung; gebraucht wird hier nur Lesen, und dafür
genügt eine Adresse, die eine ICS-Datei ausliefert. Jeder Kalender kann das.
Die Adresse ist der Schlüssel — Kalenderprogramme können sich nicht anmelden,
sie rufen stumpf eine URL ab. Daraus folgt:
- Gespeichert wird nur der SHA-256 des Tokens; die Adresse steht **genau
einmal** auf dem Bildschirm.
- Jeder Zugang lässt sich einzeln widerrufen. Ein verlorenes Handy kostet einen
Klick, nicht ein neues Passwort für alle.
- Ausgeliefert werden **nur Termine** — keine Anmeldelisten, keine Personen
außer der Leitung, keine Mängel, keine Einsätze.
- Wer aus der Wehr austritt, verliert das Abonnement.
### Die vier Kanäle
| Kanal | Läuft über | Bekommt |
|---|---|---|
| **E-Mail** (SMTP) | den eigenen bzw. den Mailserver der Gemeinde | den ausführlichen Text |
| **ntfy** (selbst gehostet) | den eigenen Server, Profil `push` | den ausführlichen Text |
| **Telegram** | einen fremden Server | nur die Kurzfassung |
| **Web-Push** | den Push-Dienst des Browserherstellers | nur die Kurzfassung |
Die Spalte ganz rechts ist keine Bequemlichkeit, sondern die Regel: was das Netz
der Wehr verlässt, bekommt einen Hinweis ohne Namen, Gerätebezeichnungen und
Einsatzdetails — „3 Prüfungen fällig", nicht welche. In der Oberfläche steht das
neben jedem externen Kanal, und die Vorlagenmaske sagt es noch einmal, bevor
jemand einen Namen in eine Telegram-Vorlage tippt.
**ntfy** ist der interessanteste Weg für eine Wehr: ein Container, eine App im
Store, kein Konto, keine Telefonnummer, kein Telefonbuchabgleich. Man abonniert
ein Thema — einen frei gewählten Namen — und bekommt Meldungen darauf. Läuft
der Server im Gerätehaus, verlässt keine Nachricht das Haus. Der Preis: ein
Thema ist nur so geheim wie sein Name, deshalb schlägt FLORIAN Namen mit
Zufallsanteil vor.
```bash
docker compose --profile push up -d # ntfy startet auf Port 8086
npm run vapid # Schlüsselpaar für Web-Push
```
### Warum kein WhatsApp
Es gibt keine `WHATSAPP_`-Variablen, und das ist eine Entscheidung, keine
Lücke. Drei Gründe, in dieser Reihenfolge:
**Datenschutz.** Der einzige zulässige Weg für eine Organisation ist die
WhatsApp Business Platform. Nachrichten laufen über Server von Meta, und die
Wehr müsste einen Vertrag zur Auftragsverarbeitung schließen — für eine
Freiwillige Feuerwehr, deren Daten ausdrücklich im Gerätehaus bleiben sollen,
ist das der falsche Weg. Die gewöhnliche App scheidet ohnehin aus: sie gleicht
das Telefonbuch ab und gibt damit die Kontakte aller Mitglieder weiter.
**Kosten.** Abgerechnet wird je Konversation. Eine Wehr mit fünfzig Mitgliedern
und zwei Terminerinnerungen im Monat zahlt dauerhaft für etwas, das über den
eigenen Mailserver nichts kostet.
**Aufwand.** Business-Konto, verifizierte Telefonnummer, genehmigte
Nachrichtenvorlagen, ein Meta-Entwicklerkonto. Das ist mehr Verwaltung als der
gesamte Rest dieser Phase.
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts
und verrät niemandem etwas.
### Der digitale Dienstausweis
Ein QR-Code auf dem Handy, den man an der Absperrung, beim Lehrgang oder bei
einer Kontrolle vorzeigt. Wer ihn scannt, landet auf einer Prüfseite und sieht
Name, Wehr, Funktion, Foto und die gültigen Qualifikationen — **ohne sich
anzumelden**, denn wer prüft, hat keinen Zugang zu FLORIAN.
- **Signiert, nicht gespeichert.** HMAC über Person, Wehr und Zeitstempel.
Keine Tabelle, kein Aufräumjob.
- **Fünf Minuten gültig.** Er wird vorgezeigt, abgelesen und weggesteckt; ein
abfotografierter Code ist danach wertlos. Die Prüfseite nennt deshalb den
Zeitpunkt der Ausstellung — ein Ausweis, der „gültig" sagt, ohne zu sagen
seit wann, ist wertlos.
- **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein. Wer den
Code hat, bekommt keinen Zugang zu einem einzigen Datensatz.
- Was **nicht** darauf steht: Anschrift, Geburtsdatum, Telefonnummer,
Mitgliedsnummer. Ein Ausweis weist aus; er ist keine Personalakte.
- Ein gültig signierter Code für jemanden, der ausgetreten ist, wird abgewiesen.
### Was von selbst benachrichtigt
Sechs Anlässe, an die vorhandenen Abläufe angehängt:
| Anlass | Wann | An wen |
|---|---|---|
| Prüfung fällig | im Erinnerungslauf | wer es abonniert hat |
| Mangel gemeldet | sofort | wer es abonniert hat |
| Qualifikation läuft ab | 90, 30 und 7 Tage vorher | die Person selbst |
| Neuer Termin | beim Anlegen | wer es abonniert hat |
| Terminerinnerung | 7 Tage und 1 Tag vorher | Angemeldete, sonst Abonnenten |
| Einsatzbericht freigegeben | bei der Freigabe | wer es abonniert hat |
Die Sammelmail an den zuständigen Gerätewart bleibt, wie sie war — sie geht an
den, der zuständig ist, unabhängig davon, ob er irgendwo ein Kreuzchen gesetzt
hat. Ein Prüftermin ist eine Pflicht, kein Abo. Wer sie ohnehin bekommt, wird
von der Benachrichtigung ausgenommen und hat sie damit nicht doppelt.
Erinnert wird um sieben Uhr morgens, nicht nachts: eine Push-Meldung um drei Uhr
weckt Leute, die am nächsten Tag arbeiten müssen. Zugestellt wird dagegen jede
Minute — eine Absage am Dienstagabend muss ankommen, solange sie noch etwas
nützt.
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird nur eine
Zeile geschrieben; der Worker schickt sie weg und vermerkt, wie es ausging.
Sonst hinge das Speichern eines Termins am Antwortverhalten von Telegram. Eine
fehlgeschlagene Zustellung bleibt sichtbar stehen und wird nicht nach Frist
abgeräumt — sie ist der Hinweis darauf, dass ein Kanal kaputt ist, und der
verliert seinen Wert nicht durch Zeitablauf.
Der Probeknopf steht direkt an jedem Zustellweg und läuft durch dieselbe
Maschinerie wie alles andere. Ohne ihn merkt jemand erst in drei Wochen, dass
sein ntfy-Thema einen Tippfehler hat — nämlich dann, wenn die Nachricht wichtig
gewesen wäre.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `NTFY_ENABLED` | `false` | Push über den eigenen ntfy |
| `NTFY_URL` | — | Adresse des eigenen ntfy, z. B. `http://ntfy:80` |
| `NTFY_TOKEN` | — | nur bei eingeschaltetem Zugriffsschutz |
| `NTFY_PORT` | `8086` | Port des ntfy-Containers (Profil `push`) |
| `WEBPUSH_ENABLED` | `false` | Push in den Browser |
| `WEBPUSH_PUBLIC_KEY` | — | aus `npm run vapid`; **ins Backup** |
| `WEBPUSH_PRIVATE_KEY` | — | dito |
| `WEBPUSH_KONTAKT` | `APP_URL` | Kontaktweg für den Push-Dienst |
| `BENACHRICHTIGUNG_LAUF` | `0 7 * * *` | wann erinnert wird |
| `TERMIN_ERINNERUNG_TAGE` | `7,1` | Vorlauf der Terminerinnerungen |
| `BENACHRICHTIGUNG_VERSUCHE` | `3` | Zustellversuche, bevor es stehen bleibt |
| `BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE` | `90` | Frist für **zugestellte** Nachrichten |
Ein neuer öffentlicher VAPID-Schlüssel macht alle bestehenden Push-Abos
wertlos — jeder müsste Push von Hand neu erlauben, und niemand merkt es, die
Meldungen bleiben einfach aus. Beide Schlüssel gehören deshalb ins Backup.
---
## Befehle
```bash
@@ -167,6 +986,7 @@ npm run db:migrate # ausstehende Migrationen anwenden
npm run db:seed # Beispielwehr einspielen
npm run db:studio # Prisma Studio
npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen
npm run vapid # VAPID-Schlüsselpaar für Web-Push erzeugen
```
## Dienste und Ports (Entwicklung)
@@ -205,9 +1025,12 @@ tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
Schriften verwendet, die auf dem Gerät vorhanden sind.
- Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`,
`AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`).
- Externe Dienste (Mail, Telegram, LLM) sitzen hinter Schnittstellen in
`src/lib/integrations/` und sind einzeln abschaltbar. Ohne Freischaltung
wird nur ins Log geschrieben.
- Externe Dienste (Mail, Telegram, ntfy, Web-Push, LLM) sitzen hinter
Schnittstellen in `src/lib/integrations/` und sind einzeln abschaltbar. Ohne
Freischaltung wird nur ins Log geschrieben.
- Über Kanäle, die das Netz der Wehr verlassen (Telegram, Web-Push), gehen nur
Kurzfassungen ohne Namen, Gerätebezeichnungen und Einsatzdetails.
- Kein WhatsApp — die Begründung steht oben unter „Warum kein WhatsApp".
## Sicherheitshinweis
+111 -1
View File
@@ -47,6 +47,20 @@ x-app-env: &app-env
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120}
KARTE_STIL_URL: ${KARTE_STIL_URL:-}
KARTE_BREITENGRAD: ${KARTE_BREITENGRAD:-48.5}
KARTE_LAENGENGRAD: ${KARTE_LAENGENGRAD:-12.15}
KARTE_ZOOM: ${KARTE_ZOOM:-13}
ATEMSCHUTZ_HINWEIS: ${ATEMSCHUTZ_HINWEIS:-true}
ATEMSCHUTZ_FREIGABE: ${ATEMSCHUTZ_FREIGABE:-}
ATEMSCHUTZ_ABFRAGE_INTERVALL: ${ATEMSCHUTZ_ABFRAGE_INTERVALL:-600}
ATEMSCHUTZ_WARNDRUCK: ${ATEMSCHUTZ_WARNDRUCK:-55}
ATEMSCHUTZ_ZUSCHLAG: ${ATEMSCHUTZ_ZUSCHLAG:-10}
WETTER_ENABLED: ${WETTER_ENABLED:-false}
DWD_WARNCELL: ${DWD_WARNCELL:-}
DWD_WARNUNGEN_URL: ${DWD_WARNUNGEN_URL:-https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json}
WETTER_TIMEOUT: ${WETTER_TIMEOUT:-5}
WETTER_ZWISCHENSPEICHER: ${WETTER_ZWISCHENSPEICHER:-300}
MAIL_ENABLED: ${MAIL_ENABLED:-false}
MAIL_HOST: ${MAIL_HOST:-}
MAIL_PORT: ${MAIL_PORT:-587}
@@ -57,10 +71,32 @@ x-app-env: &app-env
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
NTFY_ENABLED: ${NTFY_ENABLED:-false}
NTFY_URL: ${NTFY_URL:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
NTFY_TIMEOUT: ${NTFY_TIMEOUT:-10}
WEBPUSH_ENABLED: ${WEBPUSH_ENABLED:-false}
WEBPUSH_PUBLIC_KEY: ${WEBPUSH_PUBLIC_KEY:-}
WEBPUSH_PRIVATE_KEY: ${WEBPUSH_PRIVATE_KEY:-}
WEBPUSH_KONTAKT: ${WEBPUSH_KONTAKT:-}
BENACHRICHTIGUNG_LAUF: ${BENACHRICHTIGUNG_LAUF:-0 7 * * *}
TERMIN_ERINNERUNG_TAGE: ${TERMIN_ERINNERUNG_TAGE:-7,1}
BENACHRICHTIGUNG_VERSUCHE: ${BENACHRICHTIGUNG_VERSUCHE:-3}
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE: ${BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE:-90}
LLM_ENABLED: ${LLM_ENABLED:-false}
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODELL: ${LLM_MODELL:-}
GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false}
NOMINATIM_URL: ${NOMINATIM_URL:-}
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
GEOCODING_LAENDER: ${GEOCODING_LAENDER:-de,at,ch}
PORTAL_ENABLED: ${PORTAL_ENABLED:-true}
PORTAL_GUELTIGKEIT_STUNDEN: ${PORTAL_GUELTIGKEIT_STUNDEN:-24}
PORTAL_MAX_ZUGRIFFE: ${PORTAL_MAX_ZUGRIFFE:-120}
WARTUNG_NACHTLAUF: ${WARTUNG_NACHTLAUF:-15 3 * * *}
WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *}
WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180}
AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10}
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
@@ -252,6 +288,76 @@ services:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# Kartenserver (Phase 6).
#
# Nur mit Profil: "docker compose --profile karte up -d". FLORIAN läuft ohne
# ihn — die Karte zeigt dann einen leeren Hintergrund, Hydranten und
# Lagekarte funktionieren trotzdem.
#
# Gebraucht wird eine MBTiles-Datei des Ausrückegebiets unter
# docker/karten/. Woher man sie bekommt, steht in der README; sie liegt
# bewusst nicht im Repository — das sind je nach Zuschnitt hunderte Megabyte
# bis Gigabyte, und jede Wehr braucht einen anderen Ausschnitt.
#
# Der Dienst hängt im internen Netz und im Edge-Netz: der Browser des
# Anwenders holt die Kacheln direkt, nicht über die Anwendung.
# ---------------------------------------------------------------------------
tileserver:
image: maptiler/tileserver-gl:v4.11.1
container_name: florian-tileserver
restart: unless-stopped
profiles: [karte]
command: ["--port", "8080", "--public_url", "${KARTE_OEFFENTLICH:-http://localhost:8085/}"]
volumes:
- ./docker/karten:/data:ro
ports:
- "${KARTE_PORT:-8085}:8080"
networks:
- edge
- intern
logging: *logging
# ---------------------------------------------------------------------------
# ntfy — Push aufs Handy, ohne dass etwas das Haus verlässt (Phase 7).
#
# Nur mit Profil: docker compose --profile push up -d
#
# Warum das der interessanteste Push-Weg für eine Wehr ist: kein Konto, keine
# Telefonnummer, kein Telefonbuchabgleich. Man abonniert in der App ein
# Thema — einen frei gewählten Namen — und bekommt Meldungen darauf.
#
# Der Container ist winzig und speichert standardmäßig nichts dauerhaft:
# eine zugestellte Meldung ist erledigt. Wer sie nachlesen können will,
# schaltet den Cache ein (siehe ntfy-Dokumentation) — dann liegen die Texte
# allerdings auch dort und nicht nur in FLORIAN.
#
# ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest
# mit. Deshalb schlägt FLORIAN Namen mit Zufallsanteil vor, und deshalb
# steht NTFY_TOKEN bereit, falls der eigene ntfy Zugriffsschutz bekommt.
# ---------------------------------------------------------------------------
ntfy:
image: binwiederhier/ntfy:v2.11.0
container_name: florian-ntfy
restart: unless-stopped
profiles: [push]
command: ["serve"]
environment:
NTFY_BASE_URL: ${NTFY_OEFFENTLICH:-http://localhost:8086}
NTFY_LISTEN_HTTP: ":80"
# Keine Anmeldung nötig, um zu lesen und zu schreiben — geschützt ist
# das Ding durch die Unerratbarkeit der Themennamen und dadurch, dass es
# im eigenen Netz steht. Wer es ins Internet stellt, setzt
# NTFY_AUTH_DEFAULT_ACCESS auf "deny-all" und vergibt Token.
NTFY_AUTH_DEFAULT_ACCESS: ${NTFY_ZUGRIFF:-read-write}
NTFY_BEHIND_PROXY: "true"
ports:
- "${NTFY_PORT:-8086}:80"
networks:
- edge
- intern
logging: *logging
# ---------------------------------------------------------------------------
# Die Anwendung selbst (Next.js, Standalone-Build).
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
@@ -298,7 +404,11 @@ services:
image: florian-app:${IMAGE_TAG:-latest}
container_name: florian-worker
restart: unless-stopped
command: ["node", "--enable-source-maps", "dist/worker/index.js"]
# --conditions=react-server: lib/storage.ts und lib/pdf.ts sichern sich mit
# dem Paket "server-only" ab. Unter node wirft dessen Standardexport;
# mit dieser Auflösungsbedingung greift der leere react-server-Export,
# und der Worker darf dieselben Bausteine benutzen wie die Anwendung.
command: ["node", "--enable-source-maps", "--conditions=react-server", "dist/worker/index.js"]
environment:
<<: *app-env
ROLLE: worker
+46
View File
@@ -0,0 +1,46 @@
# Kartenkacheln
Hier gehört die MBTiles-Datei des Ausrückegebiets hinein. Sie liegt **nicht**
im Repository: je nach Zuschnitt sind das hunderte Megabyte bis Gigabyte, und
jede Wehr braucht einen anderen Ausschnitt.
## Woher
Zwei Wege, beide ohne laufende Kosten und ohne Anmeldung:
1. **Fertiger Auszug.** Bei [maptiler.com/data](https://data.maptiler.com/downloads/planet/)
oder aus einem OpenMapTiles-Auszug den eigenen Landkreis herunterladen.
2. **Selbst schneiden.** Aus einem Geofabrik-Auszug (`.osm.pbf`) mit
[planetiler](https://github.com/onthegomap/planetiler) den eigenen
Ausschnitt erzeugen:
```bash
java -Xmx4g -jar planetiler.jar --download --area=oberbayern \
--output=/pfad/zu/docker/karten/ausrueckegebiet.mbtiles
```
Dazu gehört ein Stil. tileserver-gl bringt `basic` mit; ein eigener Stil kommt
als `styles/<name>/style.json` daneben.
## Starten
```bash
docker compose --profile karte up -d
```
Danach in der `.env`:
```
KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json
```
## Ohne Kacheln
FLORIAN läuft auch ohne. Die Karte zeigt dann einen leeren Hintergrund mit
Gitternetz — Hydranten, Objekte und die Lagekarte liegen trotzdem an der
richtigen Stelle, nur ohne Straßen darunter. Für den Anfang reicht das, um
Daten zu erfassen; für den Einsatz nicht.
**Keine Kacheln aus dem Netz.** Wo eine Wehr ausrückt, wo ihre Hydranten
liegen und welche Objekte sie im Blick hat, ist niemandes Geschäft. Deshalb
gibt es in FLORIAN keinen Rückfall auf einen öffentlichen Kartendienst.
+1460 -15
View File
File diff suppressed because it is too large Load Diff
+179 -18
View File
@@ -1,7 +1,7 @@
# Aufwandsschätzung
Schätzung je Phase, daneben der tatsächliche Aufwand aus dem
[Devlog](DEVLOG.md). Stand: 3. September 2026.
[Devlog](DEVLOG.md). Stand: 4. September 2026.
## Wie hier gerechnet wird
@@ -31,13 +31,13 @@ Faktoren, die in die Schätzung eingehen:
| Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich |
|---|---|---:|---:|---:|
| 0 | Fundament | 12–16 PT | — | **1:00 h** |
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | offen |
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | offen |
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | offen |
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen |
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | offen |
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** |
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** |
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | **3:00 h** |
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | **2:40 h** |
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** |
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** |
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen |
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
| | **Summe** | **123–164 PT** | **28–37 h** | |
@@ -72,7 +72,7 @@ das nachträglich einbaut, zahlt ein Vielfaches.
## Phase 1 — Stammdaten
**Vergleichsaufwand: 10–14 PT**
**Vergleichsaufwand: 10–14 PT** · **Tatsächlich: 2:05 h Sitzungszeit** (1:15 h Hauptteil, 0:50 h Nachtrag)
| Baustein | PT |
|---|---:|
@@ -89,9 +89,23 @@ Der Import ist der Posten, der erfahrungsgemäß aus dem Ruder läuft. Jede Wehr
bringt eine andere Excel-Datei mit, und die Vorschau vor dem Übernehmen ist
kein Komfort, sondern die Bedingung dafür, dass die Wehr dem Ergebnis traut.
**Rückblick.** Die Schätzung hat gut getroffen — mit einer Verschiebung: Der
Import war teurer als angesetzt (deutsche Datumsformate, Semikolon-CSV,
Windows-1252, Excel-Seriennummern, Dubletten in der Datei), die Tabellen
dagegen billiger, weil Sortierung und Blättern serverseitig laufen und keine
Tabellenbibliothek nötig war. Nicht eingeplant war die Zeitzonen-Falle in der
Fristenrechnung; sie hätte in Phase 2 deutlich mehr gekostet, wenn sie erst
dort aufgefallen wäre.
Das zunächst offen gebliebene Formular zum einzelnen Anlegen von Geräten wurde
als Nachtrag ergänzt — geschätzt waren 0,5 PT, tatsächlich 0:50 h Sitzungszeit.
Die Hälfte davon ging für zwei Dinge drauf, die nichts mit dem Formular zu tun
hatten: eine Server/Client-Grenze, die stillschweigend `undefined` liefert, und
Datumsfelder, die sich in Chromium nicht automatisiert füllen lassen.
## Phase 2 — Wartung und Prüfung
**Vergleichsaufwand: 18–24 PT** — die aufwendigste Phase
**Vergleichsaufwand: 18–24 PT** · **Tatsächlich: 2:25 h Sitzungszeit** — die aufwendigste Phase
| Baustein | PT |
|---|---:|
@@ -110,9 +124,32 @@ der Unfallkasse. Das heißt aber auch, dass jeder Fehler teuer ist. Die
Unveränderlichkeit abgeschlossener Protokolle ist der Kern — und der Grund,
warum der Audit-Log schon in Phase 0 append-only ist.
**Rückblick.** Deutlich unter der geschätzten Sitzungszeit, und das aus einem
Grund, der sich benennen lässt: die Fristenrechnung aus Phase 1 war schon da
und musste nur exportiert werden. Wäre sie hier entstanden — samt der
Zeitzonen-Falle, über die Phase 1 gestolpert ist —, sähe die Zahl anders aus.
Teurer als gedacht war das, was zwischen den Bausteinen liegt. Drei Beispiele:
- Die Reihenfolge beim Abschluss einer Prüfung ist nicht beliebig.
Unterschriften und Mängel müssen vor dem Statuswechsel entstehen, denn danach
lässt die Datenbank am Protokoll nichts mehr zu. Derselbe Stolperstein traf
auch den Seed, der die Beispielprüfung zunächst fertig anlegen wollte.
- Die Protokollnummer muss unter einer Sperre vergeben werden. Aufgefallen ist
das erst, weil die E2E-Tests Desktop und Tablet gleichzeitig prüfen ließen —
zwei Prüfer, die im selben Moment abschließen, sind im Gerätehaus kein
konstruierter Fall.
- Das PDF entsteht zwischen zwei Datenbanktransaktionen, nicht in einer. Fotos
aus MinIO holen und Gotenberg rendern lassen dauert länger, als eine offene
Transaktion warten darf.
Billiger als angesetzt war das Unterschriftenfeld: Pointer-Events decken Finger,
Stift und Maus in einem ab, und mehr als Skalierung auf die Pixeldichte und
`touch-action: none` braucht es nicht.
## Phase 3 — Einsätze und Übungen
**Vergleichsaufwand: 12–16 PT**
**Vergleichsaufwand: 12–16 PT** · **Tatsächlich: 2:10 h Sitzungszeit**
| Baustein | PT |
|---|---:|
@@ -129,9 +166,33 @@ Das externe Portal für Fremdwehren ist sicherheitsrelevant: ein Zugang ohne
Anmeldung, der zeitlich begrenzt und mengenbegrenzt sein muss. Der Aufwand
steckt weniger in der Funktion als in der Absicherung.
**Rückblick.** Die Schätzung lag richtig darin, wo der Aufwand steckt — nur
nicht in welcher Reihenfolge.
Billiger als gedacht war das Portal. Der Grund liegt in Phase 0 und 2: die
Mandantentrennung, das Hashen von Geheimnissen und die Ablage von Dateien gab
es schon, und der Zugang war am Ende ein Datensatz mit Hash, ein Zeitfenster
und ein Zähler in Redis. Was blieb, war die Frage, was das Portal **nicht**
zeigen darf — und die ist fachlicher Natur, nicht technischer.
Teurer war die Statistik. Nicht das Rechnen, sondern die Entscheidung, welche
Zahl gilt: der Durchschnitt der Eintreffzeiten sagt nichts, weil ein einzelner
Einsatz auf der Autobahn ihn verschiebt. Es zählt der 80-Prozent-Wert, weil die
Gemeinde danach fragt — und das steht in keinem Pflichtenheft, das muss man
wissen.
Zwei Dinge fielen erst beim Tun auf:
- Koordinaten müssen doppelt liegen. Prisma kann den PostGIS-Typ nicht lesen;
ohne die Dezimalgrade daneben wäre jede Anzeige eine Rohabfrage geworden. Ein
Trigger hält beides zusammen.
- Der Nummernkreis war schon zweimal da (Prüfprotokolle, jetzt Einsätze und
Übungen). Beim dritten Mal wurde daraus ein eigener Baustein — die passende
Gelegenheit war genau diese, keine früher.
## Phase 4 — Kiosk-PWA
**Vergleichsaufwand: 16–22 PT**
**Vergleichsaufwand: 16–22 PT** · **Tatsächlich: 3:00 h Sitzungszeit**
| Baustein | PT |
|---|---:|
@@ -150,9 +211,45 @@ unter Verbindungsabbruch, Doppelklick und parallelem Zugriff von zwei
Terminals nichts verliert und nichts doppelt schreibt, ist die eigentliche
Arbeit. Die Idempotenzschlüssel sind dafür der Schlüssel, nicht die Synchronisation.
**Rückblick.** Der Satz oben hat sich bestätigt, nur anders als gedacht. Die
Outbox selbst war in einer halben Stunde geschrieben und mit zwölf Unit-Tests
belegt. Zwei Stellen kosteten die Zeit, und keine davon steht in der
Aufstellung:
- **Der Zeitgeber, der nie ablief.** Die Warteschlange hatte einen
Wiederholungstakt von 20 Sekunden — und der wurde bei jedem Rendern neu
aufgezogen, weil das Mutationsobjekt von React Query in den Abhängigkeiten
stand. Alles sah richtig aus, alle Unit-Tests waren grün, und der liegen
gebliebene Vorgang wäre im Gerätehaus trotzdem liegen geblieben. Gefunden hat
das nur der Playwright-Test, der `context.setOffline(true)` setzt und dann
wartet. Die 1,5 PT für „Playwright-Tests für den Offline-Fall" waren der
bestangelegte Posten der Phase.
- **Die Frage, wann ein Vorgang aus der Warteschlange darf.** Nicht „wenn
erfolgreich", sondern „wenn er beim Server angekommen ist" — dazu gehört auch
„bereits verarbeitet" und „in der Konfliktliste". Diese Unterscheidung ist die
ganze Idempotenz; ohne sie kreist ein Vorgang für immer.
Billiger als angesetzt war die Anmeldung per RFID: die Aufstellung nennt WebHID,
gebraucht wurde es nicht. Handelsübliche Leser geben die Kartenkennung als
Tastatureingabe aus. Ein Zuhörer auf dem Fenster, der am Anschlagstempo
erkennt, dass da keine Hand tippt, ersetzt eine ganze Geräteschnittstelle.
Billiger war auch der Service Worker — 80 Zeilen von Hand, ohne Workbox. Die
Regeln sind einfach, solange man sie sich traut aufzuschreiben: Seiten aus dem
Netz mit Ausweichseite, lesende Abfragen aus dem Zwischenspeicher mit
Nachladen, schreibende **überhaupt nicht** anfassen. Eine Bibliothek hätte hier
mehr Fragen aufgeworfen als beantwortet.
Teurer war die Terminaloberfläche selbst. Nicht die Kacheln, sondern die
Zustände dahinter: Wer schon eingecheckt ist, sieht „Gehen" statt „Kommen". Wer
mit einem Fahrzeug unterwegs ist, sieht „Fahrt beenden". Wer keine Rechte für
die Inventur hat, sieht die Kachel nicht. Jede dieser Verzweigungen ist
trivial — sechs davon in einem Schirm, der ohne Nachdenken bedienbar bleiben
soll, sind es nicht.
## Phase 5 — Atemschutz nach FwDV 7
**Vergleichsaufwand: 14–18 PT**
**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:40 h Sitzungszeit**
| Baustein | PT |
|---|---:|
@@ -171,9 +268,36 @@ Timer, der einen Reload überlebt, ist Pflicht — deshalb muss die Zeit
serverseitig geführt werden und der Browser nur anzeigen. Diese Phase bekommt
den höchsten Testanteil im Projekt.
**Rückblick.** Der Satz stimmt, aber die Schlussfolgerung war die falsche. Die
Aufstellung veranschlagt 3,5 PT für „serverseitig autoritative Timer" — als
wäre ein Timer zu bauen. Gebraucht wurde das Gegenteil: **kein** Timer. Wer die
Zeit als Zeitstempel in die Datenbank schreibt und jede Ansicht selbst rechnen
lässt, hat nichts, was einen Reload nicht überlebt. Der teuerste Posten der
Schätzung war der billigste der Umsetzung, weil die richtige Entscheidung ihn
auflöst statt ihn zu lösen.
Auch Socket.IO fiel weg. Es geht nur in eine Richtung — der Server meldet eine
Änderung, der Browser holt den Stand über den Weg, den es ohnehin gibt. Ein
Route Handler mit Server-Sent Events ist siebzig Zeilen und braucht kein
verändertes Deployment.
Teurer war das, was in keiner Zeile der Aufstellung steht: **die Frage, was
passiert, wenn nichts passiert.** Ein abgerissener Ereignisstrom meldet sich
nicht — `EventSource` schweigt einfach. Eine Anzeige, die deshalb weiter
„Verbunden" behauptet, ist gefährlicher als eine, die gar nichts anzeigt: sie
lässt einen alten Stand wie einen aktuellen aussehen. Aufgefallen ist das nur,
weil der E2E-Test das Netz gekappt und danach nachgesehen hat — die 2,0 PT für
„Tests: Reload und Verbindungsabbruch überstehen" waren wieder der
bestangelegte Posten der Phase.
Billiger als gedacht war die Tauglichkeit. Nicht das Rechnen — die
Entscheidung, **nicht** zu sperren. Damit entfielen alle Sonderfälle, die eine
Sperre nach sich zieht: Übersteuern, Begründungspflicht, Freigabe durch wen.
Was bleibt, ist ein Satz neben dem Namen, und der ist im Einsatz mehr wert.
## Phase 6 — Kartenmodule
**Vergleichsaufwand: 14–18 PT**
**Vergleichsaufwand: 14–18 PT** · **Tatsächlich: 2:50 h Sitzungszeit**
| Baustein | PT |
|---|---:|
@@ -189,9 +313,33 @@ Das SVG-Set für die taktischen Zeichen ist Fleißarbeit, aber unvermeidbar —
fertige Sets sind selten frei verwendbar. Die Wasserförderungsberechnung ist
Physik mit klaren Formeln und deshalb gut testbar.
**Rückblick.** Beide Sätze stimmen. Das Zeichenset war Fleißarbeit — 22 Zeichen
als SVG-Pfade, keine davon schwierig, alle zusammen eine Stunde. Und die
Wasserförderung war tatsächlich der am besten testbare Teil des ganzen
Projekts: 21 Tests, die alle etwas über die Physik aussagen statt über den Code.
Dabei fiel auf, dass die Beiwerte im ersten Wurf um den Faktor zehn danebenlagen
— aufgefallen ist es nur, weil die Tests gegen die **Tabellenwerte der
Ausbildung** prüfen und nicht gegen das, was die Funktion gerade ausrechnet.
Ein Test, der die eigene Implementierung bestätigt, hätte hier fröhlich grün
gemeldet.
Billiger als angesetzt war die Umkreissuche. 3,0 PT für „Hydranten mit PostGIS,
Cluster, Filter, Umkreissuche" — davon entfiel der Löwenanteil auf eine einzige
SQL-Abfrage mit `ST_DWithin` und einen GiST-Index. PostGIS war seit Phase 0 im
Container; genutzt wurde es erst jetzt.
Teurer war die Frage, was passiert, wenn es **keine Karte** gibt. Eine MBTiles-
Datei des Ausrückegebiets ist hunderte Megabyte groß, gehört niemandem außer
der Wehr und kann nicht im Repository liegen. Das heißt: der Normalfall beim
ersten Start ist eine Karte ohne Hintergrund — und sie muss trotzdem etwas
taugen. Aus dieser Anforderung kam der leere Stil mit Hinweis, die Erkennung
eines nicht erreichbaren Servers und die Zusicherung im E2E-Test, dass genau
das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
## Phase 7 — Benachrichtigungen und Termine
**Vergleichsaufwand: 10–13 PT**
**Vergleichsaufwand: 10–13 PT** · **Tatsächlich: 3:10 h Sitzungszeit**
| Baustein | PT |
|---|---:|
@@ -199,11 +347,24 @@ Physik mit klaren Formeln und deshalb gut testbar.
| Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 |
| Abonnements je Person | 1,0 |
| Kalender mit Serienterminen und Selbstanmeldung | 3,0 |
| CalDAV-/ICS-Feed je Person mit Token | 1,5 |
| ICS-Feed je Person mit Token | 1,5 |
| Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 |
Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0
(`lib/integrations/`), das spart hier etwa einen Personentag.
Das Grundgerüst der Kanalabstraktion stand bereits aus Phase 0
(`lib/integrations/`), das hat etwa einen Personentag gespart.
Die einzige Phase bisher, die über der geschätzten Sitzungszeit liegt — und
zwar an einer Stelle, die in der Aufstellung nicht vorkommt. „Kalender mit
Serienterminen" klingt nach einer Datumsrechnung; teuer war die Entscheidung
**gegen** eine Regel und **für** ausgeschriebene Termine, und dann die
Kalenderarithmetik dahinter: `setMonth(+1)` auf dem 31. Januar ergibt den
3. März, der 29. Februar existiert in vier von fünf Jahren nicht, und ein
Übungsabend muss über die ganze Serie auf demselben Wochentag bleiben. Das sind
drei Tests, die man erst schreibt, wenn man den Fall kennt.
Die 1,5 PT für den Feed waren dagegen zu hoch angesetzt, weil sie CalDAV
unterstellen. ICS ist ein Textformat; der ganze Erzeuger sind hundertsechzig
Zeilen samt Maskierung und Zeilenumbruch nach 75 Oktetten.
## Phase 8 — Lokale KI
+731 -1
View File
@@ -11,7 +11,737 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| Phase | Sitzungszeit | Sitzungen | abgeschlossen |
|---|---:|---:|---|
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
| **Summe** | **1:00 h** | **1** | |
| Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 |
| Phase 1 — Nachtrag Geräteformular | 0:50 h | 1 | 03.09.2026 |
| Phase 2 — Wartung und Prüfung | 2:25 h | 1 | 03.09.2026 |
| Phase 3 — Einsätze und Übungen | 2:10 h | 1 | 03.09.2026 |
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
| Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
| **Summe** | **19:20 h** | **9** | |
---
## 2026-09-04 · Phase 7 — Benachrichtigungen und Termine
**04:10 – 07:20 · 3:10 h · Version 0.8.0**
### Was entstanden ist
Der Jahresplan der Wehr und die Wege, auf denen die Mannschaft davon erfährt.
| Bereich | Dateien |
|---|---|
| Rechnen | `lib/termin/serie.ts`, `ics.ts`, `lib/benachrichtigung/vorlagen.ts`, `versand.ts`, `lib/dienstausweis.ts` |
| Kanäle | `lib/integrations/mail.ts` (echtes SMTP), `ntfy.ts`, `webpush.ts` |
| Server | `server/routers/termin/termin.ts`, `benachrichtigung.ts`, `stammdaten/dienstausweis.ts` |
| Öffentlich | `api/kalender/[token]`, `ausweis/[code]`, `api/ausweis/[code]/foto` |
| Oberfläche | `app/(app)/termine/`, `benachrichtigungen/`, `ausweis/` |
| Hintergrund | `worker/benachrichtigung.ts`, zwei neue Zeitpläne |
| Tests | 56 Unit, 17 E2E je Browser |
### Entscheidungen
**Niemand wird ungefragt benachrichtigt.** Ein neu eingerichteter Zustellweg
hat eine leere Ereignisliste. Es gibt keinen Weg, jemand anderem einen Kanal
einzutragen — auch nicht für den Kommandanten. Der Seed legt bewusst *keine*
Zustellkanäle an, obwohl das die Beispieldaten hübscher machen würde. Wer eine
Wehr ungefragt anmeldet, hat binnen einer Woche eine Wehr, die die
Benachrichtigungen abschaltet, und danach kommen auch die wichtigen nicht mehr
an.
**Serientermine werden ausgeschrieben, nicht gerechnet.** Eine Regel wäre
weniger Zeilen in der Datenbank und mehr Sonderfälle im Code: der Übungsabend
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Ausgeschrieben ist jede
dieser Ausnahmen einfach ein Datensatz, den man ändert. Der Preis sind hundert
Zeilen für zwei Jahre — Kleingeld, und die Zahl steht vor dem Anlegen in der
Maske.
**Gerechnet wird auf dem Kalender, nicht in Millisekunden.** „Monatlich" heißt
derselbe Tag im nächsten Monat. `setMonth(+1)` auf dem 31. Januar ergibt den
3. März, weil der Februar keinen 31. hat — ein Fehler, der in einer
Übungsserie erst im Frühjahr auffällt. Fällt der Tag im Zielmonat aus, rutscht
der Termin auf den letzten des Monats.
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird eine
Zeile geschrieben, sonst nichts. Der Worker nimmt sich, was auf WARTET steht.
Wären es eine Sache, hinge das Speichern eines Termins am Antwortverhalten von
Telegram. So ist die schlimmste Folge eines toten Mailservers eine
Benachrichtigung, die auf FEHLGESCHLAGEN steht und sichtbar ist.
**Intern bekommt den langen Text, extern die Kurzfassung.** Der Mailserver
einer Wehr ist ihr eigener oder der der Gemeinde, und ein selbst betriebener
ntfy steht im Gerätehaus — dorthin dürfen Namen, Gerätebezeichnungen und
Einsatznummern. Telegram und Web-Push laufen über fremde Server und bekommen
„3 Prüfungen fällig", nicht welche. Das steht in `KANAL_EXTERN` an einer
Stelle, wird im Test für *jeden* externen Kanal geprüft und in der Oberfläche
zweimal gesagt: neben dem Kanal und über dem Vorlagenfeld.
**Kein CalDAV, sondern ICS.** CalDAV ist ein Schreib-Lese-Protokoll mit
Rechteverwaltung und Konfliktauflösung. Gebraucht wird Lesen. Eine Adresse, die
eine ICS-Datei ausliefert, kann jeder Kalender — das Format ist eine Liste von
Zeilen, und die interessanten Teile (Zeitzone, Umbruch nach 75 Oktetten) stehen
in zwanzig Zeilen. Eine Abhängigkeit dafür wäre in einer Anwendung ohne
Netzzugang der schlechtere Tausch.
**Der Ausweis gilt fünf Minuten, die Anmeldemarke eine.** Beide sind mit
AUTH_SECRET signiert; was sie trennt, ist der Zweck im signierten Teil. Ohne
den ginge das eine als das andere durch, und der Ausweis wäre plötzlich ein
Anmeldeweg. Die fünf Minuten sind der Unterschied zwischen „wird gescannt" und
„wird vorgezeigt, abgelesen und weggesteckt" — und die Prüfseite nennt den
Zeitpunkt der Ausstellung, weil ein Ausweis, der „gültig" sagt ohne zu sagen
seit wann, wertlos ist.
**Fehlgeschlagene Benachrichtigungen werden nicht abgeräumt.** Der
Aufbewahrungslauf nimmt sich nur die zugestellten. Eine fehlgeschlagene ist der
Hinweis darauf, dass ein Kanal kaputt ist, und der verliert seinen Wert nicht
durch Zeitablauf.
**Erinnert wird um sieben, zugestellt jede Minute.** Eine Push-Meldung um drei
Uhr weckt Leute, die am nächsten Tag arbeiten müssen. Eine Absage am
Dienstagabend muss dagegen ankommen, solange sie noch etwas nützt.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Die Vorlagenmaske überschrieb den gerade getippten Text | Der Effekt, der den passenden Text lädt, hing an `vorlagen.data`. Wird die Liste im Hintergrund nachgeladen — nach dem Speichern, beim Zurückkommen ins Fenster —, war die Eingabe weg | Merker auf `ereignis:kanal`; geladen wird nur beim Wechsel der Auswahl. Gefunden hat es der E2E-Test, nicht das Ausprobieren |
| `personId` wurde zweimal gesetzt und überschrieb sich | Einschränkung und Ausschluss als zwei Objekt-Einträge im selben `where` — der zweite gewinnt still | Beide in eine Bedingung zusammengeführt |
| Der Aufräumlauf durfte nicht löschen | `REVOKE DELETE ON "benachrichtigung"` gilt der Anwendungsrolle, und `fuerOrganisation` benutzt genau die | Über `ohneMandantenfilter` mit ausdrücklichem Mandantenfilter in der Bedingung — begründet, wie es die Regel verlangt |
| Zwei E2E-Selektoren trafen zu viel | „Absagen" steckt auch in „Termin absagen"; `{titel}` steht im Textfeld *und* auf dem Platzhalterknopf | `exact: true` und Suche innerhalb des Dialogs |
| Der Warteliste-Test hing an der Reihenfolge im Seed | Ob der angemeldete Nutzer schon einen Platz hat, entscheidet die Mitgliedsnummer | Test prüft jetzt die Anzeige der Warteliste — die gilt für alle |
### Abweichung von der Vorgabe
**Kein CalDAV**, obwohl Phase-7.md „CalDAV-/ICS-Feed" nennt. Begründung oben;
sie steht auch im README und in der Roadmap.
**Termine einer Serie werden einzeln geändert, nicht „alle folgenden".** Der
Fall ist selten und mehrdeutig — meint „alle folgenden" auch die, die jemand
schon von Hand verschoben hat? Absagen geht dagegen für die ganze Serie, weil
das der Fall ist, der wirklich vorkommt (Sommerpause, Bauarbeiten am
Gerätehaus).
**Kein Push für iOS ohne Umweg.** Web-Push funktioniert auf dem iPhone erst,
wenn FLORIAN zum Startbildschirm hinzugefügt wurde. Das steht in der
Oberfläche; ein eigener Weg dafür wäre eine App im Store, und die gibt es
bewusst nicht.
### Nachweise
- `npx tsc --noEmit` — ohne Ausgabe
- `npm run test` — 422 Tests grün, davon 15 neue gegen eine echte Datenbank
- `npx playwright test tests/e2e/termine.spec.ts` — 17 grün
- `npm run build` — 50 Routen, darunter `/termine`, `/benachrichtigungen`,
`/ausweis` und die beiden öffentlichen Endpunkte
- `npm run db:seed` — 19 Termine, Warteliste und abgehakte Anwesenheit
- `docker compose config` — ntfy im Profil `push` prüft durch
---
## 2026-09-04 · Phase 6 — Kartenmodule
**22:10 – 01:00 · 2:50 h · Version 0.7.0**
### Was entstanden ist
Alles, was einen Ort hat: Löschwasser, Objekte mit Feuerwehrplan, die Lagekarte
eines Einsatzes und die geplante Förderstrecke.
| Bereich | Dateien |
|---|---|
| Rechnen | `lib/karte/wasserfoerderung.ts`, `hydrant-import.ts`, `taktische-zeichen.ts` |
| Server | `server/routers/karte/hydrant.ts`, `objekt.ts`, `lagekarte.ts` |
| Karte | `components/karte/karte.tsx` (MapLibre), `api/lagekarte/strom` |
| Oberfläche | `app/(app)/karte/` mit Karte, Hydranten, Objekten, Wasserförderung; Lagekarte im Einsatz |
| Betrieb | tileserver-gl im Compose-Profil `karte`, `docker/karten/LIESMICH.md` |
| Tests | 48 Unit, 11 E2E je Browser |
### Entscheidungen
**Kein öffentlicher Kartendienst — auch nicht als Rückfall.** Wo eine Wehr
ausrückt und wo ihre Hydranten liegen, ist niemandes Geschäft. Die Kacheln
kommen aus dem eigenen tileserver-gl. Ohne ihn zeigt die Karte einen leeren
Grund **und sagt das**; Punkte, Entfernungen und Umkreissuche funktionieren
weiter. Ein stiller leerer Kasten wäre die schlechtere Lösung gewesen: er sieht
aus wie ein Fehler, ohne einer zu sein.
**Die Umkreissuche gehört in die Datenbank.** `ST_DWithin` auf der Geografie
mit GiST-Index rechnet in Metern und nutzt den Index. In der Anwendung wäre es
ein vollständiger Tabellendurchlauf mit Grad-Arithmetik — und gefragt wird
während der Fahrt.
**Der Import rät nicht.** Er erkennt viel („DN 100", „48,5031", „24.05.2026",
vertauschte Achsen), aber wo die Position fehlt, wird die Zeile übersprungen
statt geschätzt. Und ohne Statusangabe steht „ungeprüft" — nicht
„einsatzbereit". Eine Liste, die tausend Hydranten als geprüft ausweist, weil
die Spalte fehlte, wäre schlimmer als keine Liste.
**Ein Ereignisstrom für beides.** Die Lagekarte benutzt denselben Hook wie die
Atemschutzüberwachung aus Phase 5, nur mit anderer Adresse. Zwei Ströme mit
unterschiedlichem Verhalten wären ein Ort mehr, an dem sich ein Fehler
verstecken kann.
**Das Ergebnis der Wasserförderung wird nicht gespeichert.** Nur die Eingaben.
Eine gespeicherte Zahl neben geänderten Eingaben wäre falsch, ohne falsch
auszusehen.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Der Druckverlust lag um den Faktor zehn daneben | Die Beiwerte waren aus dem Kopf gegriffen statt aus der Formel abgeleitet: gebraucht wird `verlust = beiwert × (menge/100)²`, also 0,0234 für den B-Schlauch, nicht 0,23 | Beiwerte aus den Tabellenwerten zurückgerechnet. Aufgefallen ist es nur, weil die Tests gegen die Ausbildungstabelle prüfen und nicht gegen die eigene Implementierung |
| Ein nicht erreichbarer Kartenserver ließ die Karte kaputt aussehen | Der Hinweis „kein Hintergrund" hing an `stilUrl === null`, nicht daran, ob der Stil geladen wurde | MapLibre meldet den Fehlschlag; die Karte fällt jetzt in denselben ehrlichen Zustand wie ohne Server |
| Die Klicks in die Karte gingen im E2E-Test ins Leere | Die Lagekarte liegt unten auf der Einsatzseite, außerhalb des Fensters. `page.mouse` klickt in Fensterkoordinaten | Karte vor dem Klick ins Bild holen |
| Prisma wollte wieder die Volltextindizes abräumen | Bekannt seit Phase 1 | DropIndex-Block aus der Migration entfernt, im Kopf vermerkt |
### Abweichung von der Vorgabe
**Keine Cluster-Darstellung.** Phase-6.md nennt sie. Bei 24 Löschwasserstellen
im Beispiel und einigen hundert in einer echten Wehr sind Cluster eine Lösung
für ein Problem, das erst bei Zehntausenden entsteht — und sie verstecken genau
das, was man sehen will: die Lücke im Netz. Nachrüstbar, wenn eine Wehr mit
großem Ausrückegebiet es vermisst.
**Kein Höhenprofil aus einem Geländemodell.** Die Höhe je Punkt wird von Hand
eingetragen. Ein Höhenmodell (SRTM, DGM) wäre ein weiterer Datensatz von
hunderten Megabyte, den jede Wehr selbst beschaffen müsste. Die Rechnung ist
ohne Höhe zu günstig — und das steht auch so an der Eingabe.
### Nachweise
- `npx tsc --noEmit` — ohne Ausgabe
- `npm run test` — 354 Tests grün
- `npx playwright test tests/e2e/karte.spec.ts` — 22 grün (Desktop und Tablet)
- `npm run build` — 44 Routen
- `npm run db:seed` — 24 Löschwasserstellen, 3 Objekte, Standorte verortet
---
## 2026-09-03 · Phase 5 — Atemschutz nach FwDV 7
**19:40 – 22:20 · 2:40 h · Version 0.6.0**
### Was entstanden ist
Die Atemschutzüberwachung. Sieben Tabellen, ein Ereignisstrom, ein
lückenloses Protokoll — und der Hinweis über allem, dass die analoge
Überwachung führend bleibt.
| Bereich | Dateien |
|---|---|
| Rechnen | `lib/atemschutz/berechnung.ts`, `tauglichkeit.ts`, `verfuegbarkeit.ts` |
| Server | `server/routers/atemschutz/ueberwachung.ts`, `traeger.ts` |
| Strom | `app/api/atemschutz/strom/route.ts`, `components/atemschutz/stand.ts` |
| Protokoll | `lib/atemschutz/protokoll.ts`, `protokoll-erzeugen.ts`, `api/atemschutz/protokoll` |
| Oberfläche | `app/(app)/atemschutz/`, `components/atemschutz/` |
| Tests | 51 Unit, 17 Datenbank, 8 E2E je Browser |
### Entscheidungen
**Server-Sent Events statt Socket.IO.** Phase-5.md nennt Socket.IO; das
verlangt in Next.js einen eigenen Node-Server und damit ein anderes
Deployment. Gebraucht wird aber nur eine Richtung: der Server meldet „es hat
sich etwas geändert", der Browser holt den Stand über tRPC. Ein Route Handler
genügt, `EventSource` verbindet nach einem Abbruch von selbst neu, und
`next start` bleibt wie es ist. Nach Rücksprache so entschieden.
**Es gibt keinen Timer.** Kein Zähler, kein Intervall, das Zeit führt. Jede
Dauer entsteht aus zwei Zeitstempeln — einem aus der Datenbank, einem von der
Uhr. Damit ist die Anforderung „übersteht Reload und Verbindungsabbruch" keine
Funktion, die man testen muss, sondern eine Eigenschaft, die sich nicht
verlieren lässt. Getestet wird sie trotzdem.
**Der Rückzugsdruck nach dem doppelten Anmarschverbrauch.** Nach Rücksprache:
die Wehr rechnet mit dem tatsächlichen Verbrauch auf dem Hinweg. Solange keine
Zielmeldung vorliegt, greift die Zwei-Drittel-Regel — sie liegt höher, ist also
die vorsichtigere Annahme, und das steht auch so auf der Karte.
**Die Tauglichkeit sperrt niemanden.** Neben jedem Namen steht, ob die Person
nach FwDV 7 einsetzbar wäre und was fehlt. Verweigert wird nichts. Ein
Programm, das im Einsatz eine Person blockiert, wird umgangen — und dann steht
am Ende gar nichts im Protokoll. Der Hinweis nützt, die Sperre schadet.
**Das Protokoll ist doppelt gesichert.** Entzogene Rechte **und** ein Trigger
auf `druckmessung` und `atemschutz_ereignis`. Eine Sperre hätte gereicht; bei
einer Aufzeichnung, an der Menschenleben hängen, ist die zweite ihren Preis
wert.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Ein gekapptes Netz ließ die Anzeige „Verbunden" melden | `context.setOffline()` trennt einen **offenen** SSE-Strom in Chromium nicht — er liefert weiter. Der Test prüfte damit etwas anderes als gedacht. Schlimmer: bei einem echten Abriss schweigt `EventSource`, ohne einen Fehler zu melden | Der Server schickt alle zwei Sekunden ein Lebenszeichen; bleibt es acht Sekunden aus, gilt die Verbindung als tot. Der Test schneidet den Strom jetzt über eine abgewiesene Route ab, statt das Netz zu kappen |
| Prisma meldete den Teilindex nicht beim Namen | Ein `UNIQUE INDEX … WHERE` erscheint als verletzte Eindeutigkeit auf der Spalte | Zusicherung im Test daran angepasst, mit Verweis auf den Index |
| Die Bildschirmfotos zeigten Testleichen | 34 Überwachungen aus den E2E-Läufen standen über der einen echten aus dem Seed | Rückstände in der Entwicklungsdatenbank abgeräumt, Auswahl im Skript auf Nicht-Testdaten eingegrenzt. Die Ursache bleibt: die E2E-Suite schreibt in die Demowehr |
### Abweichung von der Vorgabe
**Kein Socket.IO.** Siehe oben — nach Rücksprache durch Server-Sent Events
ersetzt. Die Roadmap ist entsprechend berichtigt.
**Kein RFID-Scan beim Truppanlegen.** Phase-5.md nennt „Truppanlage per
RFID-Scan oder Auswahl". Umgesetzt ist die Auswahl. Der Kartenleser hängt am
Terminal im Gerätehaus (Phase 4) und meldet dort Personen an; am
Überwachungsschirm steht der Überwacher mit einem Tablet und wählt zwei Namen
aus einer Liste, auf der ohnehin die Tauglichkeit steht. Der Scan wäre dort
ein zweiter Weg zum selben Ziel — nachrüstbar, wenn er im Betrieb vermisst
wird.
### Nachweise
- `npx tsc --noEmit` — ohne Ausgabe
- `npm run test` — 306 Tests grün, davon 36 gegen eine echte Datenbank
- `npx playwright test tests/e2e/atemschutz.spec.ts` — 16 grün (Desktop und Tablet)
- `npm run db:seed` — Nachweise und eine abgeschlossene Überwachung angelegt
---
## 2026-09-03 · Phase 4 — Kiosk-PWA im Gerätehaus
**14:40 – 17:40 · 3:00 h · Version 0.5.0**
### Was entstanden ist
Das Terminal an der Wand. Sieben neue Tabellen, eine eigene Oberfläche unter
`/kiosk`, ein Service Worker und eine Warteschlange, die den Betrieb ohne Netz
trägt.
| Bereich | Dateien |
|---|---|
| Datenmodell | `Fahrt`, `Anwesenheit`, `Waescheabgabe`, `Inventurgang`, `Inventurposten`, `KioskVorgang`, `SyncKonflikt` |
| Offline | `public/sw.js`, `lib/kiosk/outbox.ts`, `components/kiosk/warteschlange.tsx` |
| Anmeldung | `lib/kiosk/anmeldemarke.ts`, Anbieter `terminal-marke` in `lib/auth.ts`, `components/kiosk/anmeldung.tsx` |
| Oberfläche | `app/kiosk/` mit Kacheln, sechs Wizards, Monitor und Einrichtung |
| Verwaltung | `app/(app)/terminal/`, `app/(app)/anmeldemarke/` |
| Server | `server/routers/kiosk/kiosk.ts`, `lib/integrations/wetter.ts` |
| Tests | `tests/unit/kiosk-marke.test.ts`, `kiosk-outbox.test.ts`, `kiosk-datenbank.test.ts`, `tests/e2e/kiosk.spec.ts` |
### Entscheidungen
**Die Warteschlange ist der einzige Weg, auch online.** Es wäre naheliegend,
online direkt zu senden und nur im Fehlerfall zu puffern. Dann gäbe es zwei
Wege durch den Code — und der seltenere wäre der ungetestete, also genau der,
der im Gerätehaus gebraucht wird. Jetzt läuft alles durch dieselbe Bahn, und
der Offline-Fall ist der Normalfall mit längerer Wartezeit.
**Die Kennung erzeugt der Browser, nicht der Server.** `crypto.randomUUID()`
beim Einreihen. Damit ist sie derselbe Wert über beliebig viele Sendeversuche
hinweg, und der Server kann einen wiederholten Vorgang erkennen, statt ihn
doppelt zu buchen. Die Kennung ist der Primärschlüssel von `kiosk_vorgang`, und
die Datenbank verweigert dem Anwendungszugang `UPDATE` und `DELETE` darauf —
sonst ließe sich die Spur nachträglich glätten.
**Konflikte werden nicht erzwungen und nicht verworfen.** Ein Tablet, das drei
Tage offline war, schickt Vorgänge, deren Ziel es nicht mehr gibt oder das
inzwischen festgeschrieben ist. Beides ist kein Fehler des Anwenders. Der
Vorgang landet in `sync_konflikt`, verschwindet aus der Warteschlange (er ist
angekommen) und wartet unter `/terminal` auf eine Entscheidung.
**Die Prüfung bleibt online.** Der Wizard aus Phase 2 mit Checkliste, Messwerten,
Fotos und Unterschrift lässt sich nicht sinnvoll in eine Warteschlange legen —
und ein halb erfasstes Protokoll ist schlechter als keins. Die Kachel zeigt
deshalb „braucht Netz" und ist ohne Verbindung gesperrt, statt ins Leere zu
führen. `PRUEFUNG_ERFASSEN` steht trotzdem im Datenmodell: die Atemschutzprüfung
in Phase 5 ist kurz genug dafür.
**Die Anmeldemarke wird signiert, nicht gespeichert.** HMAC über Person, Wehr
und Zeitstempel mit `AUTH_SECRET`, gültig 60 Sekunden. Keine Tabelle, kein
Aufräumjob, kein zweiter Ort mit einem Geheimnis — und ein abfotografierter
Code aus der Umkleide ist nach einer Minute wertlos.
**Der Kartenleser hängt am Fenster, nicht an einem Eingabefeld.** Ein RFID-Leser
tippt die Kennung in Millisekunden und schließt mit Enter ab. Wer die Karte
auflegt, soll vorher nichts antippen müssen; unterschieden wird am
Anschlagstempo (60 ms), nicht am Fokus.
**Wetterwarnungen sind aus.** Es ist die einzige Verbindung, die FLORIAN von
sich aus nach außen aufbaut. Wer sie einschaltet, soll das bewusst tun; ohne
`DWD_WARNCELL` passiert nichts, und bei Ausfall bleibt die Kachel leer statt
den Monitor aufzuhalten.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Die Warteschlange schickte einen liegen gebliebenen Vorgang nie ein zweites Mal | `jetztSenden` hatte das Mutationsobjekt von React Query in den Abhängigkeiten. Das ist bei jedem Rendern ein neues, also änderte sich die Identität der Funktion dauernd, der Effekt lief neu, zog den 20-Sekunden-Zeitgeber neu auf — und der lief nie ab. Dazu wurde der `online`-Zuhörer ständig neu registriert und verpasste das Ereignis manchmal | Mutation, tRPC-Werkzeuge und das „läuft gerade"-Kennzeichen über Referenzen statt über Abhängigkeiten. Damit ist `jetztSenden` stabil |
| Der Produktionsbuild brach bei `/kiosk/einrichten` ab | `useSearchParams()` ohne Suspense-Grenze lässt sich nicht vorab erzeugen | `<Suspense>` um das Formular |
| Der Build brach bei `wetter.ts` ab | Der Hallenmonitor ist eine Client-Komponente und importierte die Warnstufentexte aus einem `server-only`-Modul | Texte nach `lib/integrations/wetter-texte.ts`, dieselbe Trennung wie bei `qr.ts` / `qr-parser.ts` |
| Der Outbox-Test lief ins Leere | `speicherVerfuegbar()` fragt `window`, und die Testsuite läuft in Node | Diese Datei unter jsdom (`@vitest-environment`) mit `fake-indexeddb` |
| Das Einrichtungsformular ließ sich im Test nicht abschicken | Playwright klickte, bevor der Effekt die Felder aus der Adresse gefüllt hatte — das Pflichtfeld war leer | Im Test auf die Feldwerte warten |
| Der Idempotenztest sah in der Verwaltung kein Anmeldeformular | Im selben Browserkontext lief noch die Terminalsitzung, und `/anmelden` leitet Angemeldete weiter | Prüfung in einen eigenen Test mit frischem Kontext |
| `getByText("B 3 Wohnhausbrand")` in den Einsatztests wurde mehrdeutig | Frühere E2E-Läufe haben Einsätze mit ähnlichem Stichwort in der Demowehr angelegt | `exact: true`; die eigentliche Ursache bleibt offen (siehe unten) |
### Abweichung von der Vorgabe
**Kein Auto-Logout nach 90 Sekunden.** Die Roadmap nennt 90 Sekunden, die
Konfiguration hatte schon `KIOSK_IDLE_TIMEOUT=120`. Der konfigurierte Wert
gewinnt — 90 Sekunden sind knapp, wenn jemand mitten in einer Mangelmeldung
noch das Foto machen muss. Die Roadmap ist entsprechend berichtigt.
**`npm run lint` läuft in diesem Projekt nicht.** Es gibt keine
ESLint-Konfiguration; `next lint` fragt interaktiv nach einer. Das ist älter als
diese Phase und in ihr nicht behoben — es würde quer über alle bisherigen
Dateien Befunde erzeugen und gehört in einen eigenen Schritt.
### Nachweise
- `npx tsc --noEmit` — ohne Ausgabe
- `npm run test` — 258 Tests grün, davon 19 gegen eine echte Datenbank
- `npx playwright test tests/e2e/kiosk.spec.ts` — 18 Tests grün (Desktop und Tablet)
- `npm run build` — 34 Routen, 103 kB gemeinsames JavaScript
- `npm run db:seed` — Fahrtenbuch, Anwesenheiten und Wäsche angelegt
---
## 2026-09-03 · Phase 3 — Einsätze und Übungen
**12:25 – 14:35 · 2:10 h · Version 0.4.0**
### Was entstanden ist
Ab hier dokumentiert die Wehr, was sie tut. Acht neue Tabellen, ein öffentlich
erreichbares Portal und die Zahlen, nach denen die Gemeinde fragt.
**Schnellerfassung.** Im Alarmfall fragt FLORIAN drei Dinge: Stichwort, Ort,
welche Fahrzeuge raus sind. Alarmzeit ist der Moment des Anlegens. Alles
Weitere wird nachgetragen, wenn wieder Ruhe ist.
**Zeitstempel und Kennzahlen.** Sechs Stempel, je ein Knopf. Daraus rechnet
sich Ausrückzeit, Anfahrtszeit, Hilfsfrist und Einsatzdauer.
**Stärke nach FwDV 3.** Aus den Funktionen der Mannschaft, in der üblichen
Schreibweise, mit Zuordnung zur taktischen Einheit.
**Einsatzbericht.** Als PDF über Gotenberg, mit der Freigabe festgeschrieben.
Dazu der Jahresexport quer gedruckt für die Gemeinde.
**Statistik.** Kategorien nach Landesstatistik, Verteilung der Hilfsfristen mit
dem 80-Prozent-Wert, Beteiligung je Person, Auslastung je Fahrzeug.
**Portal für Fremdwehren.** Ein signierter Link ohne Anmeldung: Stand sehen,
Lagemeldung mit Foto absetzen. Zeitlich begrenzt, mengenbegrenzt, widerrufbar.
**Übungen.** Thema, Lernziele, Anwesenheit zum Abhaken — die Grundlage für die
Teilnahmequote in der Jahreshauptversammlung.
### Entscheidungen
**Die Hilfsfrist wird als 80-Prozent-Wert ausgewiesen, nicht als Durchschnitt.**
Der Durchschnitt sagt bei Eintreffzeiten nichts: ein einzelner Einsatz auf der
Autobahn zieht ihn nach oben, während neunzig Prozent der Fälle im Ort liegen.
In Bayern gilt als Ziel, in 80 Prozent der Fälle binnen zehn Minuten da zu
sein — also ist genau das die Zahl.
**Hinweise statt Sperren bei Zeiten und Stärke.** Eine verdrehte Reihenfolge
oder eine fehlende Führungskraft wird angezeigt, aber nicht verhindert.
Einsätze werden Tage später aus dem Gedächtnis nachgetragen; ein Einsatz mit
unsicheren Zeiten ist immer noch besser als einer, den niemand erfasst, weil
das Formular ihn nicht angenommen hat.
**Der freigegebene Bericht ist unveränderbar — Lagemeldungen nicht.** Der
Bericht geht an die Gemeinde, die Meldungen sind das Protokoll des Verlaufs.
Eine Meldung aus dem Portal kann eintreffen, wenn der Bericht längst
geschrieben ist; sie nachträglich abzuweisen wäre falsch.
**Das Portal zeigt bewusst wenig.** Stichwort, Ort, Zeitstempel und Meldungen —
also das, was an der Einsatzstelle ohnehin jeder sieht. Keine Namen von
Einsatzkräften, kein Bericht, keine Navigation. Der Link ist ein Fenster auf
einen Einsatz, keine Tür in die Anwendung.
**Koordinaten liegen doppelt.** Als Dezimalgrad, die Prisma lesen kann, und als
PostGIS-Punkt für die Karte ab Phase 6. Ein Trigger hält beides zusammen —
sonst wäre jede Schreiboperation Roh-SQL geworden, und ein über einen anderen
Weg angelegter Einsatz fehlte später auf der Karte.
**Geocoding nur gegen einen eigenen Nominatim.** Einsatzadressen sagen, wo es
gebrannt hat. Solche Anfragen gehören nicht an einen fremden Dienst — und die
Nutzungsbedingungen des öffentlichen Nominatim erlauben es ohnehin nicht.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Prisma wollte wieder die Suchindizes löschen | wie in Phase 2: gin_trgm und gist kennt das Schema nicht | DROP INDEX-Zeilen aus der erzeugten Migration entfernt, mit Kommentar |
| Der Prisma-Client ließ sich nicht neu erzeugen | die Engine-DLL war vom laufenden Entwicklungsserver gesperrt | Server und Worker beenden, dann erzeugen — unter Windows unvermeidlich |
| Die Anwesenheitsliste blieb leer | sie forderte 300 Mitglieder an, erlaubt sind 200 je Seite | auf 200 gesetzt; aufgefallen in der Server-Ausgabe während der E2E-Tests |
| Zwei E2E-Tests schlugen im Gesamtlauf fehl, einzeln aber nicht | die erste Übersetzung einer Route dauert unter Last länger als das Erwartungslimit | Limit von 15 auf 25 Sekunden — Wartezeit des Entwicklungsservers, kein Fehler der Anwendung |
### Abweichung von der Vorgabe
**Keine.** Alle acht Tabellen tragen `organisationId` und stehen unter Row
Level Security, jede schreibende Operation protokolliert, gelöscht wird weich.
Eine Ergänzung: die Portalprozeduren sind die **ersten öffentlich erreichbaren
Endpunkte** in FLORIAN. Sie prüfen den Token selbst, laufen für die Suche des
Zugangs bewusst ohne Mandantenfilter (begründet im Code) und arbeiten danach im
Mandantenkontext der gefundenen Wehr.
### Offen und bewusst so gelassen
Die Zuordnung der Funktionen zur Stärke ist **regional unterschiedlich**. Hier
zählt der Maschinist zur Mannschaft und nur der Truppführer als Unterführer;
eine Gruppe ergibt damit 1/3/5/9. Andere Wehren rechnen anders. Die Zuordnung
steht an einer Stelle in `lib/einsatz/staerke.ts` und ist leicht zu ändern —
bestätigt ist sie noch nicht.
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run test` — 207 Tests grün, davon 32 gegen eine echte Datenbank
- `npm run test:e2e` — 95 Tests grün, 2 übersprungen
- `npm run build` — 38 Routen
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund
---
## 2026-09-03 · Phase 2 — Wartungs- und Prüfmanagement
**09:55 – 12:20 · 2:25 h · Version 0.3.0**
### Was entstanden ist
Das Modul, wegen dem eine Wehr so ein Programm überhaupt einführt. Ab hier
ersetzt FLORIAN den Ordner mit den Prüfnachweisen.
**Datenmodell.** Elf neue Tabellen: Wartungsvorlage und Checklistenpunkt als
Regel, Wartungstermin als deren Anwendung auf ein einzelnes Gerät, Prüfung mit
Einzelergebnissen und Unterschriften als Nachweis, dazu Mangel, Rückruf mit
betroffener Bauart und je Gerät ein Bearbeitungsstand, und schließlich
Erinnerung als Nachweis und Sperre gegen Doppelversand in einem.
**Fristen-Dashboard.** Vier Töpfe nach Dringlichkeit, jede Zahl gleichzeitig
ein Filter, darunter die Verteilung nach Fahrzeug, Standort, Gerätetyp oder
Zuständigem. Daneben die Lebensdauerüberwachung mit Aussonderungsvorschlag.
**Prüfwizard.** Ein Punkt je Bildschirm, Foto aus der Kamera, Zeiterfassung im
Hintergrund, Unterschrift auf einem Zeichenfeld. Jede Antwort geht sofort zum
Server; ein abgebrochener Durchgang wird beim nächsten Öffnen fortgesetzt.
**Protokoll.** Der Worker rendert über Gotenberg ein PDF mit Kopf, Checkliste,
eingebetteten Fotos und Unterschriften und legt es in MinIO ab. Laufende
Nummer je Wehr und Jahr.
**Nächtlicher Lauf.** Termine planen, Gerätestatus nachziehen, erinnern bei
T-30, T-7, T-0 und einmal bei Überfälligkeit — als Sammelnachricht je
Empfänger.
### Entscheidungen
**Die Unveränderbarkeit sitzt in der Datenbank, nicht im Code.** Ein Trigger
weist jede Änderung an einer abgeschlossenen Prüfung ab, auch für den
Eigentümer-Zugang. Zulässig bleiben drei Dinge, die das Protokoll ergänzen
statt es zu verfälschen: der Wechsel auf „ersetzt", das Nachtragen des PDF,
solange keines hinterlegt ist, und `geaendertAm`. Dasselbe gilt für die
Bestandteile — sonst ließe sich ein festgeschriebenes Protokoll über den Umweg
der Einzelergebnisse verändern.
Der Grund ist derselbe wie beim Audit-Log in Phase 0: ein vergessener
Zustandscheck in einem Router reicht sonst, um den Nachweis wertlos zu machen.
**„Überfällig" wird gerechnet, nicht gespeichert.** Ein gespeicherter Zustand
läuft der Uhr hinterher — ein Termin, der heute Nacht fällig geworden ist,
stünde bis zum nächsten Lauf als „offen" da. Aus demselben Grund kennt
`WartungsterminStatus` nur offen, erledigt und entfallen.
**Pflichtpunkt heißt: davon hängt die Einsatzbereitschaft ab.** Ein
beanstandeter Pflichtpunkt bedeutet „nicht bestanden" und sperrt das Gerät, ein
beanstandeter Kann-Punkt „bestanden mit Mängeln". Damit ist das Ergebnis eine
fachliche Aussage und keine Zählübung. Übersteuern darf der Prüfer trotzdem —
er trägt die Verantwortung, nicht die Software.
**Bestanden hebt eine bestehende Sperre nicht auf.** Wer ein Gerät gesperrt
hat, hatte einen Grund, und der kann außerhalb dieser Prüfung liegen. Von
selbst löst sich nur „Prüfung fällig".
**Melden darf jedes Mitglied.** Der Mangel fällt dem auf, der das Gerät
benutzt. Eine Hürde beim Melden führt dazu, dass gar nicht gemeldet wird — und
ein nicht gemeldeter Riss im Schlauch ist gefährlicher als eine Fehlmeldung,
die jemand wieder verwirft.
**Die Rückrufsuche ist ein Vorschlag.** Seriennummern werden als Text
verglichen; das stimmt bei gleicher Stellenzahl und sonst nicht immer. Eine
automatische Sperre auf dieser Grundlage wäre gefährlicher als hilfreich.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Prisma wollte die Suchindizes aus Phase 1 löschen | gin_trgm- und gist-Indizes kennt das Schema nicht | die `DROP INDEX`-Zeilen aus der erzeugten Migration entfernt, mit Kommentar für den nächsten, der sie neu erzeugt |
| Der Seed brach beim Beispielprotokoll ab | er legte die Prüfung fertig an, und der Trigger verbot die Ergebnisse dazu | erst Entwurf, dann abschließen — genau wie im Wizard |
| Zwei gleichzeitig abgeschlossene Prüfungen zogen dieselbe Protokollnummer | die Nummer kam aus einer Zählung ohne Sperre | Transaktionssperre je Wehr und Jahr, Nummer aus dem Höchstwert statt aus der Anzahl |
| Der Worker startete außerhalb des Containers nicht | `.env` wurde nicht geladen, und `server-only` wirft unter Node | `dotenv/config` und `--conditions=react-server` in beiden Startbefehlen |
| Der Wizard-Test klickte ins Leere | die Mauskoordinaten beziehen sich auf das Fenster, das Zeichenfeld lag darunter | vor dem Zeichnen ins Bild holen |
| Zwei E2E-Projekte teilten sich denselben Prüfentwurf | Desktop und Tablet laufen parallel unter demselben Benutzer | je Projekt ein eigenes Gerät |
### Abweichung von der Vorgabe
**Keine.** Alle elf Tabellen tragen `organisationId` und stehen unter Row Level
Security, jede schreibende Operation protokolliert, gelöscht wird weich.
Eine Ergänzung zur Regel „Prüfdaten werden nie hart gelöscht": ein
**Prüfentwurf** darf verschwinden. Er ist noch kein Nachweis, sondern ein
abgebrochener Vorgang; ihn aufzuheben würde die Liste der offenen Prüfungen
zumüllen. Ab dem Abschluss greift die Sperre.
### Was aufgefallen ist
Der Navigationseintrag „Protokoll" (`/protokoll`) zeigt seit Phase 1 auf eine
Seite, die es nicht gibt. Das Audit-Log ist über tRPC abrufbar, hat aber keine
Ansicht. Gehört in ein eigenes Issue.
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run test` — 161 Tests grün, davon 18 gegen eine echte Datenbank
- `npm run test:e2e` — 71 Tests grün, 2 begründet übersprungen (Desktop und Tablet)
- `npm run build` — 30 Routen
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund; Worker
richtet die Zeitpläne ein und erzeugt die Protokoll-PDFs in gut einer Sekunde
---
## 2026-09-03 · Phase 1 — Nachtrag: Geräteformular
**08:30 – 09:20 · 0:50 h**
### Was entstanden ist
Die letzte offene Lücke aus Phase 1: ein Formular zum Anlegen und Bearbeiten
eines einzelnen Geräts. Bis dahin konnte man Geräte nur über den Import
erfassen — für einen Bestand richtig, für den einen nachbeschafften Gerät
umständlich.
| Datei | Inhalt |
|---|---|
| `components/stammdaten/geraet-formular.tsx` | das Formular, geteilt von Anlegen und Bearbeiten |
| `components/stammdaten/geraet-formular-werte.ts` | Typ und Vorgabewerte, bewusst ohne `"use client"` |
| `components/ui/textarea.tsx` | fehlender Baustein |
| `app/(app)/geraete/neu/page.tsx` | Anlegen |
| `app/(app)/geraete/[id]/bearbeiten/page.tsx` | Bearbeiten |
| `tests/e2e/geraet-formular.spec.ts` | 7 Tests |
### Entscheidungen
**Die Lagerung ist ein Umschalter, keine zwei Auswahlfelder.** Standort und
Fahrzeug schließen sich aus. Zwei nebeneinanderstehende Felder laden dazu ein,
beide zu füllen; die Datenbank weist das ab, aber eine Fehlermeldung nach dem
Absenden ist schlechter als ein Formular, in dem der Fehler gar nicht möglich
ist. Beim Wechsel des Fahrzeugs wird der Geräteraum geleert — er gehört zum
Fahrzeug.
**Die Fristen werden schon beim Tippen vorgerechnet.** Wer ein Prüfintervall
einträgt, sieht sofort, wann die nächste Prüfung fällig wird. Gerechnet wird
mit denselben Funktionen wie auf dem Server; beim Speichern rechnet der Server
erneut und behält das letzte Wort. Die Vorschau ist Anzeige, keine Quelle.
**Bei nur einem Standort wird er vorbelegt.** Das ist bei den meisten Wehren
der Fall, und ein Pflichtfeld weniger ist ein Klick weniger.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Formular stürzte beim ersten Rechnen ab | `LEERES_GERAET` lag im `"use client"`-Modul; eine Server-Komponente bekommt beim Import nur eine Client-Referenz, das Spreaden ergibt `undefined` | Werte in ein eigenes Modul ohne `"use client"` |
| E2E fand die Felder nicht | `getByLabel` vergleicht den Text des `<label>`, nicht den zugänglichen Namen — am Pflichtfeld steht dort ein Sternchen | Tests suchen über die Rolle; das Sternchen bekam zusätzlich `aria-hidden`, weil es sonst vorgelesen wird |
| `fill()` blieb bei Datumsfeldern wirkungslos | Chromium nimmt bei `input[type=date]` keinen gesetzten Wert über `fill()` an | Datum wird getippt, wie es ein Mensch auch täte |
| Zwei Tests scheiterten unter Touch-Emulation | dort öffnet Chromium den Auswähler des Betriebssystems statt Zeichen anzunehmen | begründet übersprungen; die Rechnung selbst deckt der Unit-Test ab |
| Anmeldung im Screenshot-Skript lief ins Zeitlimit | bei kaltem `.next` übersetzt `next dev` die Route erst beim ersten Aufruf | Zeitlimit von 20 auf 90 Sekunden |
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run test` — 104 Tests grün
- `npm run test:e2e` — 56 grün, 2 begründet übersprungen
- `npm run build` — 19 Routen
- Testgeräte aus dem E2E-Lauf wurden aus der Entwicklungsdatenbank entfernt
---
## 2026-09-03 · Phase 1 — Stammdaten
**07:10 – 08:25 · 1:15 h · Version 0.2.0**
### Was entstanden ist
41 neue Quelldateien, der Umfang wächst von rund 6.100 auf 15.600 Zeilen.
| Bereich | Inhalt |
|---|---|
| Datenmodell | 5 neue Tabellen (Standort, Geraeteraum, Geraetetyp, Qualifikationstyp, Qualifikation), Fahrzeug und Geraet deutlich erweitert, 2 Migrationen |
| Fachlogik | `fristen.ts` (Prüftermine, Lebensdauer, Statusableitung), `qr.ts` und `qr-parser.ts`, `etiketten.ts`, `pdf.ts`, `import.ts` und `import-felder.ts` |
| Server | 8 tRPC-Router mit Liste, Detail, Anlegen, Ändern, Massenbearbeitung und Verlauf |
| Oberfläche | Geräteliste mit Suche und Spaltenauswahl, Gerätedetail mit QR, Fahrzeugliste und -detail mit Beladung, Mitgliederliste, Mitgliedsdetail, Qualifikationen, Import-Assistent, QR-Scanner |
| Daten | Seed erweitert: 3 Standorte, 20 Geräteräume, 14 Gerätetypen, 49 Geräte, 14 Qualifikationstypen, 107 Qualifikationen |
| Tests | 104 Vitest-Tests (vorher 32), 44 Playwright-Tests (vorher 10) |
### Entscheidungen
**Der Gerätetyp ist Vorgabe, das Gerät darf abweichen.** Vierzig Pressluftatmer
haben dasselbe Prüfintervall — das gehört einmal an den Typ und nicht
vierzigmal an das Gerät. Wo der Hersteller für eine Charge etwas anderes
vorschreibt, gewinnt der Wert am Gerät. Umgesetzt in `effektiverWert()`, das
bewusst zwischen `null` und `0` unterscheidet: ein Intervall von 0 ist eine
Angabe, kein fehlender Wert.
**Ein Gerät liegt entweder an einem Standort oder auf einem Fahrzeug.** Beides
gleichzeitig wäre eine Falschangabe, keines von beidem auch. Das steht als
Prüfbedingung in der Datenbank, nicht nur im Formular — und ein Trigger prüft
zusätzlich, dass ein angegebener Geräteraum wirklich zu dem angegebenen
Fahrzeug gehört. Das ließe sich als CHECK nicht ausdrücken.
**Der QR-Code enthält die Inventarnummer, nicht die UUID.** Eine UUID wäre
36 Zeichen lang; der Code bekäme ein feines Muster, und auf einem 38 × 21 mm
großen Etikett im staubigen Geräteraum wird das unzuverlässig. Die
Inventarnummer steht ohnehin lesbar daneben, und wer den Code mit einem
beliebigen Scanner liest, sieht sofort, um welches Gerät es geht.
**Der Import liest die Datei zweimal.** Die Vorschau zeigt, was passieren
würde; beim Übernehmen wird die Datei erneut hochgeladen und serverseitig neu
eingelesen. Es wandert kein einziger Wert aus dem Browser in die Datenbank.
Der Preis ist eine zweite Übertragung — bei den Größen, um die es geht, kein
Thema.
**Der Etikettenbogen kann an beliebiger Position beginnen.** Wer drei Etiketten
braucht, soll nicht einen ganzen Bogen opfern.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Fristen um einen Tag verschoben | `startOfDay` aus date-fns rechnet in der Zeitzone des Servers; ein DATE aus der Datenbank kommt als UTC-Mitternacht und landete dadurch auf dem Vortag | eigene Kalenderarithmetik auf UTC-Bestandteilen, „heute“ über Europe/Berlin bestimmt |
| `ctx.sitzung` blieb nullbar | `t.middleware()` erzeugt eine Middleware auf dem Wurzelkontext; die Verengung aus der vorigen Middleware ging verloren | Middleware direkt als Funktion an `.use()` übergeben |
| Import-Tests brachen beim Laden ab | `import "server-only"` wirft beim blossen Import, und Vitest ist weder Server noch Browser | im Testlauf durch ein leeres Modul ersetzt (`tests/stubs/server-only.ts`) |
| 15 E2E-Tests im Zeitlimit | `next dev` übersetzt jede Route erst beim ersten Aufruf; zu viele parallele Erstaufrufe | Worker auf 4 begrenzt, Zeitlimit angehoben |
| 5 weitere E2E-Tests rot | `CardTitle` war ein `div` und damit keine Überschrift | in der Komponente auf ein echtes `h3` umgestellt — behebt zugleich einen Zugänglichkeitsmangel |
| Erste Bilder zeigten Ladebalken | „networkidle“ greift zu früh: die Listen holen ihre Daten erst nach, wenn React im Browser gelaufen ist | Screenshot-Skript wartet, bis keine Ladeanzeige mehr im Baum ist |
| Demodaten inkonsistent | die 40 Geräte aus Phase 0 hatten keinen Gerätetyp, und der Seed ist idempotent | Geräte-Zeilen der Entwicklungsdatenbank gezielt geleert und neu eingespielt |
### Abweichung von der Vorgabe
**`prisma migrate reset` wurde nicht ausgeführt.** Der naheliegende Weg, die
Demodaten sauber neu aufzubauen, verlangt eine ausdrückliche Freigabe — Prisma
verweigert den Befehl gegenüber einem KI-Agenten ohne Zustimmung. Statt zu
fragen wurde der schmalere Weg gewählt: nur die Tabelle `geraet` der
Entwicklungsdatenbank geleert und neu eingespielt.
**Kein eigenes Formular zum Anlegen von Geräten.** Die Router können anlegen,
ändern, umlagern und aussondern; die Oberfläche dazu deckt Liste, Detail,
Massenbearbeitung und Import ab. Ein einzelnes Anlegeformular fehlt noch — in
der Praxis kommen Bestände als Tabelle herein, und einzelne Neuzugänge lassen
sich bis dahin über den Import erfassen. Nachzuholen in Phase 2, wo ohnehin
Formulare für Prüfungen entstehen.
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run test` — 104 Tests grün, davon 8 gegen eine echte Datenbank
- `npm run test:e2e` — 44 Tests grün (Desktop und Tablet)
- `npm run build` — 17 Routen, 103 kB gemeinsames JavaScript
- `npm run screenshots` — 18 Bilder, hell und dunkel
- Keine Konsolenfehler und keine Hydratationswarnungen im Browser
---
+83 -37
View File
@@ -10,14 +10,14 @@ Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md).
```
Phase 0 ████████████████████ fertig 0.1.0
Phase 1 ░░░░░░░░░░░░░░░░░░░░ offen 0.2.0
Phase 2 ░░░░░░░░░░░░░░░░░░░░ offen 0.3.0
Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0
Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
Phase 1 ████████████████████ fertig 0.2.0
Phase 2 ████████████████████ fertig 0.3.0
Phase 3 ████████████████████ fertig 0.4.0
Phase 4 ████████████████████ fertig 0.5.0
Phase 5 ████████████████████ fertig 0.6.0
Phase 6 ████████████████████ fertig 0.7.0
Phase 7 ████████████████████ fertig 0.8.0
Phase 8 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.9.0
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
```
@@ -39,7 +39,7 @@ Alles, worauf der Rest aufsetzt.
- Basis-Layout mit Seitenleiste und dunkler Darstellung
- Seed mit einer vollständigen Beispielwehr
## Phase 1 — Stammdaten · als Nächstes
## Phase 1 — Stammdaten · fertig
Die Wehr trägt ein, was sie hat.
@@ -53,27 +53,37 @@ Die Wehr trägt ein, was sie hat.
- CSV-/Excel-Import mit Spaltenzuordnung und Vorschau
- Gerätehistorie aus dem Audit-Log
- Qualifikationen mit Ablaufwarnung auf der Übersicht
- Formular zum Anlegen und Bearbeiten eines einzelnen Geräts, mit
Fristenvorschau beim Ausfüllen
**Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per
QR-Code am Regal wiederfinden.
## Phase 2 — Wartung und Prüfung
## Phase 2 — Wartung und Prüfung · fertig
Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner.
- Wartungsvorlagen mit Checklisten, Intervall und Vorwarnzeit
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage
- Nächtlicher Job erzeugt Termine, setzt Status, erinnert bei T-30, T-7 und T-0
- Prüfungs-Wizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage,
gebündelt nach Fahrzeug, Standort, Gerätetyp oder Zuständigem
- Nächtlicher Lauf erzeugt Termine, zieht den Gerätestatus nach und erinnert bei
T-30, T-7, T-0 und einmal bei Überfälligkeit
- Prüfwizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift auf
dem Zeichenfeld
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO — die Sperre setzt
ein Datenbanktrigger durch, nicht der Anwendungscode
- Korrekturen erzeugen ein neues Protokoll mit Verweis auf das alte
- Lebensdauerüberwachung mit Aussonderungsvorschlag
- Rückrufe markieren betroffene Geräte automatisch
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben
- Rückrufe suchen betroffene Geräte über Bauart, Modell, Seriennummernbereich
und Herstellungszeitraum
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben, mit
Gerätesperre bei kritischen Mängeln
- Externe Wartung: Fachfirma, Kosten und Fremdprotokoll als PDF
**Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch
die Unfallkasse standhält.
## Phase 3 — Einsätze und Übungen
## Phase 3 — Einsätze und Übungen · fertig
- Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen,
Einsatzende, eingerückt, einsatzbereit
@@ -87,28 +97,35 @@ die Unfallkasse standhält.
**Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen.
## Phase 4 — Kiosk-PWA
## Phase 4 — Kiosk-PWA · fertig
Für die Wandtablets in der Fahrzeughalle.
- Eigenes Layout: große Kacheln, mindestens 64px, mit Handschuhen bedienbar
- Anmeldung per RFID, PIN oder QR vom Handy, Auto-Logout nach 90 Sekunden
- Wizards: Wartung, Fahrtenbuch, Mangel melden, Inventur, Wäsche, Anwesenheit
- Anmeldung per RFID, PIN oder QR vom Handy, Abmeldung nach Untätigkeit
(`KIOSK_IDLE_TIMEOUT`, Vorgabe 120 Sekunden)
- Wizards: Fahrtenbuch, Mangel melden, Inventur, Wäsche, Anwesenheit. Die
Prüfung bleibt beim Wizard aus Phase 2 — Checkliste, Fotos und Unterschrift
lassen sich nicht sinnvoll in eine Warteschlange legen; die Kachel sagt
„braucht Netz"
- Hallen-Monitor: Vollbild mit Terminen, überfälligen Prüfungen,
Fahrzeugstatus, Wetterwarnung
- Offline-Betrieb mit IndexedDB-Outbox und Synchronisation bei Reconnect
- Offline-Betrieb mit IndexedDB-Outbox, Idempotenzschlüssel und
Konfliktliste für alles, was sich beim Nachtragen nicht anwenden lässt
**Danach kann die Wehr:** einen Mangel in unter 30 Sekunden melden, ohne den
Rechner im Büro einzuschalten — auch wenn das Netz gerade weg ist.
## Phase 5 — Atemschutz nach FwDV 7
## Phase 5 — Atemschutz nach FwDV 7 · fertig
Sicherheitsrelevant. Jede Ansicht zeigt dauerhaft den Hinweis, dass die
analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die
eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
- Truppanlage per RFID, Eingangsdruck erfassen
- Serverseitig autoritative Timer, per Socket.IO verteilt
- Serverseitig autoritative Zeitstempel; verteilt über Server-Sent Events
statt Socket.IO — es geht nur in eine Richtung, und so bleibt es bei
`next start` ohne eigenen Node-Server
- Rückzugsdruck-Berechnung mit Ampel und akustischem Alarm
- Erinnerung zur Druckabfrage im konfigurierbaren Intervall
- Mayday-Knopf je Trupp
@@ -118,37 +135,66 @@ eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
**Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur
analogen, bis die Wehr ausdrücklich anders entscheidet.
## Phase 6 — Kartenmodule
## Phase 6 — Kartenmodule · fertig
- MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
GeoJSON und Stadtwerke-CSV
- Objekte und Feuerwehrpläne nach DIN 14095, vom Einsatz aus erreichbar
- Lagekarte mit taktischen Zeichen nach FwDV 102, Echtzeit-Sync
- Lagekarte mit taktischen Zeichen nach FwDV 102, Abgleich über denselben
Ereignisstrom wie der Atemschutz
- Wasserförderung: Druckverlust, Verstärkerpumpen, druckbares Schema
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
und was ihn hergibt.
## Phase 7 — Benachrichtigungen und Termine
## Phase 7 — Benachrichtigungen und Termine · fertig
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
- Kalender: Dienste, Übungen, Lehrgänge, Arbeitsdienste; Serientermine,
Selbstanmeldung mit Teilnehmerlimit, Anwesenheitsliste zum Ausdrucken
- CalDAV-/ICS-Feed je Person
- Digitaler Dienstausweis mit signiertem QR und Prüfseite
Die Wehr weiß, wann der nächste Dienst ist — ohne dass jemand eine Rundmail
tippt.
- Terminkalender mit Dienst, Übung, Lehrgang, Arbeitsdienst, Veranstaltung und
Sitzung. Serientermine werden ausgeschrieben, nicht als Regel gespeichert.
- Selbstanmeldung mit Platzbegrenzung und einer Warteliste, die von selbst
nachrückt. Absage mit Grund, wahlweise für alle folgenden Termine einer Serie.
- Anwesenheitsliste über alle aktiven Mitglieder — zum Abhaken am Tablet oder
zum Ausdrucken. Zusage und Anwesenheit sind getrennt.
- Vier Kanäle hinter Schnittstellen: E-Mail (echtes SMTP statt Platzhalter),
ntfy (selbst gehostet), Telegram, Web-Push. Was das Netz der Wehr verlässt,
bekommt nur die Kurzfassung.
- Vorlagen je Ereignis und Kanal mit Platzhaltern, Vorschau und eingebautem
Standardtext für alles.
- ICS-Abonnement je Person, Token nur als Hash gespeichert, einzeln widerrufbar.
- Digitaler Dienstausweis: signierter QR, fünf Minuten gültig, Prüfseite ohne
Anmeldung.
**Danach kann die Wehr:** ihren Jahresplan führen, Rückmeldungen einsammeln,
Anwesenheit nachweisen und jedes Mitglied auf dem Weg erreichen, den es selbst
gewählt hat.
### Kein CalDAV
CalDAV ist ein Schreib-Lese-Protokoll mit Rechteverwaltung und
Konfliktauflösung. Gebraucht wird hier nur Lesen — die Termine entstehen in
FLORIAN, nicht im Kalender des Anwenders. Dafür genügt eine Adresse, die eine
ICS-Datei ausliefert; jeder Kalender kann das. CalDAV wäre ein Vielfaches an
Aufwand für dieselbe Wirkung.
### Warum kein WhatsApp
Die WhatsApp Business API kostet je Konversation und verlangt einen
zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nummern aller
Mitglieder gingen an Meta, was sich mit einer Anwendung, die bewusst im
Gerätehaus steht, nicht verträgt. Telegram, ntfy und Web-Push decken denselben
Bedarf ab, ntfy sogar vollständig selbst gehostet.
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nachrichten liefen
über Server von Meta, die Wehr müsste einen Vertrag zur Auftragsverarbeitung
schließen, und die gewöhnliche App gleicht zusätzlich das Telefonbuch ab. Das
verträgt sich nicht mit einer Anwendung, die bewusst im Gerätehaus steht.
Telegram, ntfy und Web-Push decken denselben Bedarf ab, ntfy sogar vollständig
selbst gehostet.
## Phase 8 — Lokale KI
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts.
## Phase 8 — Lokale KI · als Nächstes
Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+3
View File
@@ -8,6 +8,9 @@ const nextConfig: NextConfig = {
images: { remotePatterns: [] },
serverExternalPackages: ["@prisma/client", "bcryptjs", "minio", "bullmq"],
eslint: { ignoreDuringBuilds: true },
// Die Entwicklungsanzeige unten links wuerde in den Dokumentationsbildern
// mitlaufen. Sie betrifft ohnehin nur "next dev".
devIndicators: false,
async headers() {
return [
{
+1925 -17
View File
File diff suppressed because it is too large Load Diff
+27 -4
View File
@@ -1,6 +1,6 @@
{
"name": "florian",
"version": "0.1.0",
"version": "0.8.0",
"private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": {
@@ -10,8 +10,8 @@
"dev": "next dev -p 3100",
"build": "next build",
"start": "next start -p 3100",
"worker": "tsx src/worker/index.ts",
"worker:dev": "tsx watch src/worker/index.ts",
"worker": "tsx --conditions=react-server src/worker/index.ts",
"worker:dev": "tsx watch --conditions=react-server src/worker/index.ts",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
@@ -23,36 +23,54 @@
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"screenshots": "tsx scripts/screenshots.ts"
"screenshots": "tsx scripts/screenshots.ts",
"icons": "tsx scripts/icons.ts",
"vapid": "tsx scripts/vapid.ts"
},
"dependencies": {
"@prisma/client": "^6.16.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-table": "^9.2.4",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"@zxing/browser": "^0.2.1",
"@zxing/library": "^0.23.0",
"bcryptjs": "^3.0.2",
"bullmq": "^5.61.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"dexie": "^4.4.5",
"exceljs": "^4.4.0",
"ioredis": "^5.8.1",
"lucide-react": "^0.544.0",
"maplibre-gl": "^6.7.0",
"minio": "^8.0.6",
"next": "^15.5.4",
"next-auth": "^5.0.0-beta.29",
"next-themes": "^0.4.6",
"nodemailer": "^8.0.11",
"papaparse": "^5.7.0",
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"server-only": "^0.0.1",
"superjson": "^2.2.6",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -61,13 +79,18 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.18.6",
"@types/nodemailer": "^8.0.1",
"@types/papaparse": "^5.5.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.3",
"dotenv": "^17.4.2",
"esbuild": "^0.25.10",
"eslint": "^9.36.0",
"eslint-config-next": "^15.5.4",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.0.0",
"prisma": "^6.16.2",
"tailwindcss": "^4.1.13",
+34 -5
View File
@@ -13,11 +13,40 @@ export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
/**
* Ein Wiederholungsversuch auch lokal.
*
* Der Grund ist derselbe wie bei den großzügigen Zeitlimits unten: "next
* dev" übersetzt jede Route beim ersten Aufruf, und mit vier gleichzeitigen
* Arbeitern kommt dazu die Anmeldung mit bcrypt (12 Runden, wie in
* Produktion). Unter dieser Last läuft gelegentlich ein Test ins Zeitlimit,
* der einzeln zuverlässig grün ist — verstreut über alle Dateien, nicht an
* einer Stelle. Ein echter Fehler fällt zweimal durch und bleibt sichtbar.
*/
retries: process.env.CI ? 2 : 1,
/**
* Bewusst wenige Worker: "next dev" übersetzt jede Route erst beim ersten
* Aufruf. Zu viele gleichzeitige Erstaufrufe lassen den Server so lange
* kompilieren, dass die Navigation ins Zeitlimit läuft — und das sieht dann
* nach einem Fehler in der Anwendung aus, ist aber keiner.
*
* Ab Phase 5 von vier auf zwei: mit über 130 Tests, bcrypt bei jeder
* Anmeldung und einer Datenbank daneben fielen bei vier Arbeitern in jedem
* Gesamtlauf zwei bis fünf Tests ins Zeitlimit — jedes Mal andere, jeder
* einzeln grün. Ein Ergebnis, das man nicht glauben kann, ist wertlos; der
* Lauf dauert dafür etwa ein Drittel länger.
*/
workers: process.env.CI ? 1 : 2,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
timeout: 30_000,
expect: { timeout: 10_000 },
/** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */
timeout: 90_000,
/**
* Ab Phase 3 großzügiger: mit fünfzig Routen dauert die erste Übersetzung
* einer bisher unbesuchten Seite unter Last auch mal zwanzig Sekunden. Das
* sind Wartezeiten des Entwicklungsservers, keine der Anwendung — vorher
* schlugen dadurch gelegentlich Tests fehl, die einzeln laufen und grün sind.
*/
expect: { timeout: 25_000 },
use: {
baseURL: BASIS_URL,
@@ -44,6 +73,6 @@ export default defineConfig({
command: "npm run dev",
url: BASIS_URL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
timeout: 180_000,
},
});
@@ -0,0 +1,234 @@
-- CreateEnum
CREATE TYPE "StandortArt" AS ENUM ('GERAETEHAUS', 'LAGER', 'AUSSENSTELLE', 'WERKSTATT', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "GeraeteKategorie" AS ENUM ('ATEMSCHUTZ', 'BELEUCHTUNG', 'BRANDBEKAEMPFUNG', 'ELEKTRO', 'FUNK', 'HYGIENE', 'MEDIZIN', 'MESSTECHNIK', 'PERSOENLICHE_SCHUTZAUSRUESTUNG', 'RETTUNG', 'SCHLAUCHMATERIAL', 'TECHNISCHE_HILFELEISTUNG', 'UMWELTSCHUTZ', 'WASSERFOERDERUNG', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "LebensdauerKategorie" AS ENUM ('KEINE', 'HELM', 'SCHUTZKLEIDUNG', 'HANDSCHUHE', 'STIEFEL', 'SEIL_GURT', 'PRESSLUFTATMER', 'ATEMANSCHLUSS', 'DRUCKBEHAELTER', 'SCHLAUCH', 'LEITER', 'SONSTIGE');
-- CreateEnum
CREATE TYPE "LebensdauerBezug" AS ENUM ('HERSTELLUNG', 'INDIENSTSTELLUNG');
-- CreateEnum
CREATE TYPE "QualifikationsArt" AS ENUM ('LEHRGANG', 'BEFAEHIGUNG', 'UNTERSUCHUNG', 'FUEHRERSCHEIN', 'UNTERWEISUNG', 'SONSTIGES');
-- AlterTable
ALTER TABLE "fahrzeug" ADD COLUMN "aufbauhersteller" VARCHAR(120),
ADD COLUMN "bemerkung" VARCHAR(1000),
ADD COLUMN "dinTyp" VARCHAR(40),
ADD COLUMN "erstzulassung" DATE,
ADD COLUMN "fahrgestellnummer" VARCHAR(30),
ADD COLUMN "gesamtmasse" INTEGER,
ADD COLUMN "indienststellung" DATE,
ADD COLUMN "kilometerstand" INTEGER,
ADD COLUMN "naechsteWartung" DATE,
ADD COLUMN "sitzplaetze" INTEGER,
ADD COLUMN "standortId" UUID,
ADD COLUMN "tankinhalt" INTEGER,
ADD COLUMN "uvvBis" DATE;
-- AlterTable
ALTER TABLE "geraet" ADD COLUMN "anschaffungspreis" DECIMAL(12,2),
ADD COLUMN "ausgesondertAm" DATE,
ADD COLUMN "ausgesondertGrund" VARCHAR(500),
ADD COLUMN "bemerkung" VARCHAR(1000),
ADD COLUMN "geraeteraumId" UUID,
ADD COLUMN "geraetetypId" UUID,
ADD COLUMN "lebensdauerBis" DATE,
ADD COLUMN "lebensdauerKategorie" "LebensdauerKategorie",
ADD COLUMN "letztePruefung" DATE,
ADD COLUMN "modell" VARCHAR(120),
ADD COLUMN "standortId" UUID;
-- CreateTable
CREATE TABLE "standort" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"kuerzel" VARCHAR(20),
"art" "StandortArt" NOT NULL DEFAULT 'GERAETEHAUS',
"strasse" VARCHAR(200),
"plz" VARCHAR(10),
"ort" VARCHAR(120),
"koordinaten" geography(Point, 4326),
"bemerkung" VARCHAR(1000),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "standort_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "geraeteraum" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"fahrzeugId" UUID NOT NULL,
"bezeichnung" VARCHAR(40) NOT NULL,
"beschreibung" VARCHAR(200),
"sortierung" INTEGER NOT NULL DEFAULT 0,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "geraeteraum_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "geraetetyp" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"kategorie" "GeraeteKategorie" NOT NULL DEFAULT 'SONSTIGES',
"hersteller" VARCHAR(120),
"modell" VARCHAR(120),
"pruefintervallMonate" INTEGER,
"pruefvorschrift" VARCHAR(200),
"lebensdauerKategorie" "LebensdauerKategorie" NOT NULL DEFAULT 'KEINE',
"lebensdauerJahre" INTEGER,
"lebensdauerBezug" "LebensdauerBezug" NOT NULL DEFAULT 'INDIENSTSTELLUNG',
"vorwarnzeitMonate" INTEGER NOT NULL DEFAULT 3,
"beschreibung" VARCHAR(1000),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "geraetetyp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "qualifikationstyp" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"kuerzel" VARCHAR(20),
"art" "QualifikationsArt" NOT NULL DEFAULT 'LEHRGANG',
"gueltigkeitMonate" INTEGER,
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 90,
"einsatzrelevant" BOOLEAN NOT NULL DEFAULT false,
"beschreibung" VARCHAR(1000),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "qualifikationstyp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "qualifikation" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"typId" UUID NOT NULL,
"erworbenAm" DATE NOT NULL,
"gueltigBis" DATE,
"ausstellendeStelle" VARCHAR(200),
"nachweisnummer" VARCHAR(80),
"urkundeDateiId" UUID,
"bestaetigtVonId" UUID,
"bestaetigtAm" TIMESTAMPTZ(3),
"bemerkung" VARCHAR(1000),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "qualifikation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "standort_organisationId_aktiv_idx" ON "standort"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "standort_organisationId_name_key" ON "standort"("organisationId", "name");
-- CreateIndex
CREATE INDEX "geraeteraum_organisationId_fahrzeugId_idx" ON "geraeteraum"("organisationId", "fahrzeugId");
-- CreateIndex
CREATE UNIQUE INDEX "geraeteraum_fahrzeugId_bezeichnung_key" ON "geraeteraum"("fahrzeugId", "bezeichnung");
-- CreateIndex
CREATE INDEX "geraetetyp_organisationId_kategorie_idx" ON "geraetetyp"("organisationId", "kategorie");
-- CreateIndex
CREATE INDEX "geraetetyp_organisationId_aktiv_idx" ON "geraetetyp"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "geraetetyp_organisationId_name_key" ON "geraetetyp"("organisationId", "name");
-- CreateIndex
CREATE INDEX "qualifikationstyp_organisationId_aktiv_idx" ON "qualifikationstyp"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "qualifikationstyp_organisationId_name_key" ON "qualifikationstyp"("organisationId", "name");
-- CreateIndex
CREATE INDEX "qualifikation_organisationId_personId_idx" ON "qualifikation"("organisationId", "personId");
-- CreateIndex
CREATE INDEX "qualifikation_organisationId_typId_idx" ON "qualifikation"("organisationId", "typId");
-- CreateIndex
CREATE INDEX "qualifikation_organisationId_gueltigBis_idx" ON "qualifikation"("organisationId", "gueltigBis");
-- CreateIndex
CREATE INDEX "fahrzeug_organisationId_tuevBis_idx" ON "fahrzeug"("organisationId", "tuevBis");
-- CreateIndex
CREATE UNIQUE INDEX "fahrzeug_organisationId_kennzeichen_key" ON "fahrzeug"("organisationId", "kennzeichen");
-- CreateIndex
CREATE INDEX "geraet_organisationId_lebensdauerBis_idx" ON "geraet"("organisationId", "lebensdauerBis");
-- CreateIndex
CREATE INDEX "geraet_organisationId_standortId_idx" ON "geraet"("organisationId", "standortId");
-- CreateIndex
CREATE INDEX "geraet_organisationId_geraetetypId_idx" ON "geraet"("organisationId", "geraetetypId");
-- CreateIndex
CREATE INDEX "geraet_organisationId_seriennummer_idx" ON "geraet"("organisationId", "seriennummer");
-- AddForeignKey
ALTER TABLE "standort" ADD CONSTRAINT "standort_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrzeug" ADD CONSTRAINT "fahrzeug_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraeteraum" ADD CONSTRAINT "geraeteraum_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraeteraum" ADD CONSTRAINT "geraeteraum_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraetetyp" ADD CONSTRAINT "geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_geraeteraumId_fkey" FOREIGN KEY ("geraeteraumId") REFERENCES "geraeteraum"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "qualifikationstyp" ADD CONSTRAINT "qualifikationstyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_typId_fkey" FOREIGN KEY ("typId") REFERENCES "qualifikationstyp"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_bestaetigtVonId_fkey" FOREIGN KEY ("bestaetigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,172 @@
-- =============================================================================
-- Phase 1: Mandantentrennung, Lagerungsregel und Suchindizes.
--
-- Die vorige Migration hat die Tabellen angelegt. Ohne Row Level Security wären
-- sie für die Anwendungsrolle offen — jede neue Tabelle muss deshalb dieselbe
-- Behandlung bekommen wie die aus Phase 0.
--
-- Dazu kommen zwei Dinge, die sich nur in der Datenbank sauber durchsetzen
-- lassen: die Regel, wo ein Gerät liegen darf, und schnelle Volltextsuche.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security für die neuen Tabellen
-- -----------------------------------------------------------------------------
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'standort',
'geraeteraum',
'geraetetyp',
'qualifikationstyp',
'qualifikation'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
EXECUTE format(
'CREATE POLICY %I ON %I FOR ALL '
'USING ("organisationId" = aktuelle_organisation()) '
'WITH CHECK ("organisationId" = aktuelle_organisation())',
tabelle || '_mandant', tabelle
);
END LOOP;
END;
$$;
-- -----------------------------------------------------------------------------
-- Bestandsdaten auf die neue Lagerungsregel bringen.
--
-- Vor Phase 1 konnte ein Gerät nur einen Freitext-Lagerort haben. Damit die
-- Prüfbedingung weiter unten greift, braucht jedes Gerät ohne Fahrzeug jetzt
-- einen echten Standort. Je Wehr wird dafür ein Gerätehaus angelegt, sofern
-- noch keines existiert.
-- -----------------------------------------------------------------------------
INSERT INTO "standort" ("id", "organisationId", "name", "kuerzel", "art", "erstelltAm", "geaendertAm")
SELECT
gen_random_uuid(),
o."id",
'Gerätehaus',
'GH',
'GERAETEHAUS',
now(),
now()
FROM "organisation" o
WHERE NOT EXISTS (
SELECT 1 FROM "standort" s WHERE s."organisationId" = o."id"
);
UPDATE "geraet" g
SET "standortId" = (
SELECT s."id"
FROM "standort" s
WHERE s."organisationId" = g."organisationId"
AND s."art" = 'GERAETEHAUS'
ORDER BY s."erstelltAm"
LIMIT 1
)
WHERE g."fahrzeugId" IS NULL
AND g."standortId" IS NULL;
-- Fahrzeuge dem Gerätehaus zuordnen, sofern noch kein Standort gesetzt ist.
UPDATE "fahrzeug" f
SET "standortId" = (
SELECT s."id"
FROM "standort" s
WHERE s."organisationId" = f."organisationId"
AND s."art" = 'GERAETEHAUS'
ORDER BY s."erstelltAm"
LIMIT 1
)
WHERE f."standortId" IS NULL;
-- -----------------------------------------------------------------------------
-- Wo ein Gerät liegen darf.
--
-- Entweder es steht an einem Standort — dann ist kein Fahrzeug gesetzt. Oder es
-- ist auf einem Fahrzeug verlastet — dann ist kein Standort gesetzt, und der
-- Geräteraum darf angegeben werden, muss aber nicht (manches liegt lose in der
-- Kabine). Beides gleichzeitig oder gar nichts ist eine Falschangabe.
--
-- Dass ein angegebener Geräteraum auch wirklich zum angegebenen Fahrzeug
-- gehört, lässt sich als CHECK nicht ausdrücken — das prüft der Trigger
-- darunter.
-- -----------------------------------------------------------------------------
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_lagerung_eindeutig" CHECK (
("standortId" IS NOT NULL AND "fahrzeugId" IS NULL AND "geraeteraumId" IS NULL)
OR ("standortId" IS NULL AND "fahrzeugId" IS NOT NULL)
);
CREATE OR REPLACE FUNCTION geraeteraum_gehoert_zum_fahrzeug()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
raum_fahrzeug uuid;
BEGIN
IF NEW."geraeteraumId" IS NULL THEN
RETURN NEW;
END IF;
SELECT "fahrzeugId" INTO raum_fahrzeug
FROM "geraeteraum"
WHERE "id" = NEW."geraeteraumId";
IF raum_fahrzeug IS DISTINCT FROM NEW."fahrzeugId" THEN
RAISE EXCEPTION
'Der Geraeteraum gehoert nicht zu dem angegebenen Fahrzeug.'
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER geraet_raum_pruefen
BEFORE INSERT OR UPDATE OF "fahrzeugId", "geraeteraumId" ON "geraet"
FOR EACH ROW EXECUTE FUNCTION geraeteraum_gehoert_zum_fahrzeug();
-- -----------------------------------------------------------------------------
-- Volltextsuche.
--
-- Die Listen sollen auf Tippen reagieren, auch bei einigen tausend Geräten.
-- pg_trgm macht ILIKE '%text%' indexfähig — ohne diese Indizes läuft jede
-- Suche als vollständiger Tabellendurchlauf.
-- -----------------------------------------------------------------------------
CREATE INDEX "geraet_suche_bezeichnung" ON "geraet" USING gin ("bezeichnung" gin_trgm_ops);
CREATE INDEX "geraet_suche_inventarnummer" ON "geraet" USING gin ("inventarnummer" gin_trgm_ops);
CREATE INDEX "geraet_suche_seriennummer" ON "geraet" USING gin ("seriennummer" gin_trgm_ops);
CREATE INDEX "geraet_suche_hersteller" ON "geraet" USING gin ("hersteller" gin_trgm_ops);
CREATE INDEX "fahrzeug_suche_funkrufname" ON "fahrzeug" USING gin ("funkrufname" gin_trgm_ops);
CREATE INDEX "fahrzeug_suche_bezeichnung" ON "fahrzeug" USING gin ("bezeichnung" gin_trgm_ops);
CREATE INDEX "fahrzeug_suche_kennzeichen" ON "fahrzeug" USING gin ("kennzeichen" gin_trgm_ops);
CREATE INDEX "person_suche_nachname" ON "person" USING gin ("nachname" gin_trgm_ops);
CREATE INDEX "person_suche_vorname" ON "person" USING gin ("vorname" gin_trgm_ops);
CREATE INDEX "geraetetyp_suche_name" ON "geraetetyp" USING gin ("name" gin_trgm_ops);
CREATE INDEX "standort_suche_name" ON "standort" USING gin ("name" gin_trgm_ops);
-- Räumliche Abfragen auf Standorte, ab Phase 6 gebraucht.
CREATE INDEX "standort_koordinaten" ON "standort" USING gist ("koordinaten");
-- -----------------------------------------------------------------------------
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT EXECUTE ON FUNCTION aktuelle_organisation() TO florian_app';
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
ELSE
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
END IF;
END;
$$;
@@ -0,0 +1,469 @@
-- =============================================================================
-- Phase 2: Tabellen für Wartung und Prüfung.
--
-- Erzeugt mit `prisma migrate dev --create-only` und danach an einer Stelle von
-- Hand bereinigt: Prisma wollte die Volltext- und Geoindizes aus Phase 1
-- (gin_trgm_ops, gist auf Koordinaten) löschen, weil es sie im Schema nicht
-- kennt — Prisma kann diese Indexarten nicht abbilden. Die DROP INDEX-Zeilen
-- sind deshalb entfernt. Wer diese Migration neu erzeugt, muss das wieder tun.
--
-- Mandantentrennung, Unveränderbarkeit der Protokolle und die Suchindizes für
-- die neuen Tabellen stehen in der nachfolgenden Migration.
-- =============================================================================
-- CreateEnum
CREATE TYPE "WartungsterminStatus" AS ENUM ('OFFEN', 'ERLEDIGT', 'ENTFALLEN');
-- CreateEnum
CREATE TYPE "PruefungStatus" AS ENUM ('ENTWURF', 'ABGESCHLOSSEN', 'ERSETZT');
-- CreateEnum
CREATE TYPE "PruefErgebnis" AS ENUM ('BESTANDEN', 'BESTANDEN_MIT_MAENGELN', 'NICHT_BESTANDEN');
-- CreateEnum
CREATE TYPE "PruefungArt" AS ENUM ('INTERN', 'EXTERN');
-- CreateEnum
CREATE TYPE "ChecklistenpunktTyp" AS ENUM ('JA_NEIN', 'MESSWERT', 'TEXT', 'FOTO');
-- CreateEnum
CREATE TYPE "SignaturRolle" AS ENUM ('PRUEFER', 'GERAETEWART', 'FREMDFIRMA');
-- CreateEnum
CREATE TYPE "MangelPrioritaet" AS ENUM ('NIEDRIG', 'MITTEL', 'HOCH', 'KRITISCH');
-- CreateEnum
CREATE TYPE "MangelStatus" AS ENUM ('GEMELDET', 'ZUGEWIESEN', 'IN_ARBEIT', 'BEHOBEN', 'VERWORFEN');
-- CreateEnum
CREATE TYPE "RueckrufStatus" AS ENUM ('OFFEN', 'IN_BEARBEITUNG', 'ERLEDIGT');
-- CreateEnum
CREATE TYPE "BetroffenheitStatus" AS ENUM ('OFFEN', 'NICHT_BETROFFEN', 'MASSNAHME_LAEUFT', 'ERLEDIGT');
-- CreateEnum
CREATE TYPE "ErinnerungStufe" AS ENUM ('T_30', 'T_7', 'T_0', 'UEBERFAELLIG');
-- CreateTable
CREATE TABLE "wartungsvorlage" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"geraetetypId" UUID,
"intervallMonate" INTEGER NOT NULL,
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 30,
"pruefvorschrift" VARCHAR(200),
"standardDauerMinuten" INTEGER,
"standardExtern" BOOLEAN NOT NULL DEFAULT false,
"automatisch" BOOLEAN NOT NULL DEFAULT true,
"beschreibung" VARCHAR(1000),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "wartungsvorlage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "checklistenpunkt" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"vorlageId" UUID NOT NULL,
"text" VARCHAR(300) NOT NULL,
"typ" "ChecklistenpunktTyp" NOT NULL DEFAULT 'JA_NEIN',
"pflicht" BOOLEAN NOT NULL DEFAULT true,
"sollwert" VARCHAR(120),
"einheit" VARCHAR(20),
"minWert" DECIMAL(12,3),
"maxWert" DECIMAL(12,3),
"hinweis" VARCHAR(500),
"sortierung" INTEGER NOT NULL DEFAULT 0,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "checklistenpunkt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wartungstermin" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"vorlageId" UUID,
"faelligAm" DATE NOT NULL,
"status" "WartungsterminStatus" NOT NULL DEFAULT 'OFFEN',
"automatischErzeugt" BOOLEAN NOT NULL DEFAULT false,
"zugewiesenAnId" UUID,
"bemerkung" VARCHAR(1000),
"erledigtAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "wartungstermin_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pruefung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"terminId" UUID,
"vorlageId" UUID,
"art" "PruefungArt" NOT NULL DEFAULT 'INTERN',
"status" "PruefungStatus" NOT NULL DEFAULT 'ENTWURF',
"prueferId" UUID,
"prueferName" VARCHAR(200),
"durchgefuehrtAm" DATE NOT NULL,
"begonnenUm" TIMESTAMPTZ(3),
"dauerMinuten" INTEGER,
"ergebnis" "PruefErgebnis",
"externeFirma" VARCHAR(200),
"externeProtokollDateiId" UUID,
"kosten" DECIMAL(10,2),
"naechstePruefungAm" DATE,
"protokollDateiId" UUID,
"protokollNummer" VARCHAR(40),
"abgeschlossenAm" TIMESTAMPTZ(3),
"ersetztPruefungId" UUID,
"korrekturGrund" VARCHAR(500),
"bemerkung" VARCHAR(2000),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "pruefung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pruefergebnis" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"pruefungId" UUID NOT NULL,
"punktId" UUID NOT NULL,
"jaNein" BOOLEAN,
"zahlwert" DECIMAL(12,3),
"text" VARCHAR(1000),
"fotoDateiId" UUID,
"inOrdnung" BOOLEAN NOT NULL DEFAULT true,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "pruefergebnis_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "signatur" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"pruefungId" UUID NOT NULL,
"rolle" "SignaturRolle" NOT NULL DEFAULT 'PRUEFER',
"personId" UUID,
"name" VARCHAR(200) NOT NULL,
"bildDateiId" UUID,
"unterzeichnetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "signatur_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mangel" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"pruefungId" UUID,
"melderId" UUID,
"melderName" VARCHAR(200),
"beschreibung" VARCHAR(2000) NOT NULL,
"fotoDateiId" UUID,
"prioritaet" "MangelPrioritaet" NOT NULL DEFAULT 'MITTEL',
"status" "MangelStatus" NOT NULL DEFAULT 'GEMELDET',
"zugewiesenAnId" UUID,
"gemeldetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"behobenAm" TIMESTAMPTZ(3),
"behobenVonId" UUID,
"behebung" VARCHAR(2000),
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "mangel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rueckruf" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"hersteller" VARCHAR(160) NOT NULL,
"titel" VARCHAR(200) NOT NULL,
"referenz" VARCHAR(80),
"beschreibung" VARCHAR(4000),
"massnahme" VARCHAR(2000),
"veroeffentlichtAm" DATE,
"quelle" VARCHAR(500),
"sperrtGeraete" BOOLEAN NOT NULL DEFAULT false,
"status" "RueckrufStatus" NOT NULL DEFAULT 'OFFEN',
"erledigtAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "rueckruf_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rueckruf_geraetetyp" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"rueckrufId" UUID NOT NULL,
"geraetetypId" UUID,
"modell" VARCHAR(160),
"seriennummerVon" VARCHAR(120),
"seriennummerBis" VARCHAR(120),
"herstellungVon" DATE,
"herstellungBis" DATE,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rueckruf_geraetetyp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rueckruf_betroffenheit" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"rueckrufId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"status" "BetroffenheitStatus" NOT NULL DEFAULT 'OFFEN',
"festgestelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"erledigtAm" TIMESTAMPTZ(3),
"bemerkung" VARCHAR(1000),
"geraetGesperrt" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "rueckruf_betroffenheit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "erinnerung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"terminId" UUID NOT NULL,
"stufe" "ErinnerungStufe" NOT NULL,
"versendetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"kanal" VARCHAR(40) NOT NULL DEFAULT 'mail',
"empfaenger" TEXT[] DEFAULT ARRAY[]::TEXT[],
"erfolgreich" BOOLEAN NOT NULL DEFAULT true,
"meldung" VARCHAR(500),
CONSTRAINT "erinnerung_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "wartungsvorlage_organisationId_geraetetypId_idx" ON "wartungsvorlage"("organisationId", "geraetetypId");
-- CreateIndex
CREATE INDEX "wartungsvorlage_organisationId_aktiv_idx" ON "wartungsvorlage"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "wartungsvorlage_organisationId_name_key" ON "wartungsvorlage"("organisationId", "name");
-- CreateIndex
CREATE INDEX "checklistenpunkt_organisationId_vorlageId_idx" ON "checklistenpunkt"("organisationId", "vorlageId");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_faelligAm_idx" ON "wartungstermin"("organisationId", "faelligAm");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_status_faelligAm_idx" ON "wartungstermin"("organisationId", "status", "faelligAm");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_geraetId_idx" ON "wartungstermin"("organisationId", "geraetId");
-- CreateIndex
CREATE INDEX "wartungstermin_organisationId_zugewiesenAnId_idx" ON "wartungstermin"("organisationId", "zugewiesenAnId");
-- CreateIndex
CREATE UNIQUE INDEX "wartungstermin_geraetId_vorlageId_faelligAm_key" ON "wartungstermin"("geraetId", "vorlageId", "faelligAm");
-- CreateIndex
CREATE INDEX "pruefung_organisationId_geraetId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "geraetId", "durchgefuehrtAm" DESC);
-- CreateIndex
CREATE INDEX "pruefung_organisationId_status_idx" ON "pruefung"("organisationId", "status");
-- CreateIndex
CREATE INDEX "pruefung_organisationId_durchgefuehrtAm_idx" ON "pruefung"("organisationId", "durchgefuehrtAm" DESC);
-- CreateIndex
CREATE INDEX "pruefung_organisationId_prueferId_idx" ON "pruefung"("organisationId", "prueferId");
-- CreateIndex
CREATE UNIQUE INDEX "pruefung_organisationId_protokollNummer_key" ON "pruefung"("organisationId", "protokollNummer");
-- CreateIndex
CREATE INDEX "pruefergebnis_organisationId_pruefungId_idx" ON "pruefergebnis"("organisationId", "pruefungId");
-- CreateIndex
CREATE UNIQUE INDEX "pruefergebnis_pruefungId_punktId_key" ON "pruefergebnis"("pruefungId", "punktId");
-- CreateIndex
CREATE INDEX "signatur_organisationId_pruefungId_idx" ON "signatur"("organisationId", "pruefungId");
-- CreateIndex
CREATE INDEX "mangel_organisationId_status_prioritaet_idx" ON "mangel"("organisationId", "status", "prioritaet");
-- CreateIndex
CREATE INDEX "mangel_organisationId_geraetId_idx" ON "mangel"("organisationId", "geraetId");
-- CreateIndex
CREATE INDEX "mangel_organisationId_zugewiesenAnId_idx" ON "mangel"("organisationId", "zugewiesenAnId");
-- CreateIndex
CREATE INDEX "rueckruf_organisationId_status_idx" ON "rueckruf"("organisationId", "status");
-- CreateIndex
CREATE INDEX "rueckruf_organisationId_hersteller_idx" ON "rueckruf"("organisationId", "hersteller");
-- CreateIndex
CREATE INDEX "rueckruf_geraetetyp_organisationId_rueckrufId_idx" ON "rueckruf_geraetetyp"("organisationId", "rueckrufId");
-- CreateIndex
CREATE INDEX "rueckruf_betroffenheit_organisationId_rueckrufId_idx" ON "rueckruf_betroffenheit"("organisationId", "rueckrufId");
-- CreateIndex
CREATE INDEX "rueckruf_betroffenheit_organisationId_geraetId_idx" ON "rueckruf_betroffenheit"("organisationId", "geraetId");
-- CreateIndex
CREATE UNIQUE INDEX "rueckruf_betroffenheit_rueckrufId_geraetId_key" ON "rueckruf_betroffenheit"("rueckrufId", "geraetId");
-- CreateIndex
CREATE INDEX "erinnerung_organisationId_versendetAm_idx" ON "erinnerung"("organisationId", "versendetAm" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "erinnerung_terminId_stufe_key" ON "erinnerung"("terminId", "stufe");
-- AddForeignKey
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wartungstermin" ADD CONSTRAINT "wartungstermin_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_vorlageId_fkey" FOREIGN KEY ("vorlageId") REFERENCES "wartungsvorlage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_prueferId_fkey" FOREIGN KEY ("prueferId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_protokollDateiId_fkey" FOREIGN KEY ("protokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_externeProtokollDateiId_fkey" FOREIGN KEY ("externeProtokollDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefung" ADD CONSTRAINT "pruefung_ersetztPruefungId_fkey" FOREIGN KEY ("ersetztPruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_punktId_fkey" FOREIGN KEY ("punktId") REFERENCES "checklistenpunkt"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pruefergebnis" ADD CONSTRAINT "pruefergebnis_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "signatur" ADD CONSTRAINT "signatur_bildDateiId_fkey" FOREIGN KEY ("bildDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_pruefungId_fkey" FOREIGN KEY ("pruefungId") REFERENCES "pruefung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_melderId_fkey" FOREIGN KEY ("melderId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_zugewiesenAnId_fkey" FOREIGN KEY ("zugewiesenAnId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_behobenVonId_fkey" FOREIGN KEY ("behobenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "mangel" ADD CONSTRAINT "mangel_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf" ADD CONSTRAINT "rueckruf_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_geraetetyp" ADD CONSTRAINT "rueckruf_geraetetyp_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_rueckrufId_fkey" FOREIGN KEY ("rueckrufId") REFERENCES "rueckruf"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rueckruf_betroffenheit" ADD CONSTRAINT "rueckruf_betroffenheit_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "erinnerung" ADD CONSTRAINT "erinnerung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "wartungstermin"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,243 @@
-- =============================================================================
-- Phase 2: Mandantentrennung, Unveränderbarkeit der Prüfprotokolle, Indizes.
--
-- Die vorige Migration hat die Tabellen angelegt. Drei Dinge lassen sich nur
-- in der Datenbank verlässlich durchsetzen und stehen deshalb hier:
--
-- 1. Row Level Security für die elf neuen Tabellen — dieselbe Behandlung wie
-- für die Tabellen aus Phase 0 und 1.
--
-- 2. Die Unveränderbarkeit abgeschlossener Prüfprotokolle. Ein Protokoll ist
-- ein Nachweis gegenüber der Unfallkasse. Wäre es nachträglich änderbar,
-- wäre es als Nachweis wertlos — und ein vergessener Zustandscheck im
-- Code reicht, um genau das zu ermöglichen. Deshalb sitzt die Sperre in
-- der Datenbank, nicht nur in lib/ und server/.
--
-- 3. Indizes für das Fristen-Dashboard und die Suche über Mängel und
-- Rückrufe.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security
-- -----------------------------------------------------------------------------
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'wartungsvorlage',
'checklistenpunkt',
'wartungstermin',
'pruefung',
'pruefergebnis',
'signatur',
'mangel',
'rueckruf',
'rueckruf_geraetetyp',
'rueckruf_betroffenheit',
'erinnerung'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
EXECUTE format(
'CREATE POLICY %I ON %I FOR ALL '
'USING ("organisationId" = aktuelle_organisation()) '
'WITH CHECK ("organisationId" = aktuelle_organisation())',
tabelle || '_mandant', tabelle
);
END LOOP;
END;
$$;
-- -----------------------------------------------------------------------------
-- Unveränderbarkeit abgeschlossener Prüfungen
--
-- Solange eine Prüfung im Status ENTWURF ist, darf der Wizard alles ändern.
-- Mit dem Abschluss ist Schluss. Genau drei Änderungen bleiben danach zulässig,
-- und alle drei ergänzen das Protokoll, statt es zu verfälschen:
--
-- * status ABGESCHLOSSEN -> ERSETZT, wenn eine Korrekturprüfung nachfolgt
-- * protokollDateiId und protokollNummer, solange sie noch leer sind: das
-- PDF entsteht im Hintergrundprozess und wird nachgetragen
-- * geaendertAm, das Prisma bei jedem Schreibvorgang mitsetzt
--
-- Alles andere — Ergebnis, Datum, Prüfer, Messwerte — ist festgeschrieben.
-- Korrekturen laufen über eine neue Prüfung mit Verweis auf die alte.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION pruefung_unveraenderlich()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
vergleich "pruefung"%ROWTYPE;
BEGIN
-- Entwürfe sind frei änderbar.
IF OLD."status" = 'ENTWURF' THEN
RETURN NEW;
END IF;
-- Die zulässigen Änderungen auf den alten Stand zurückdrehen. Bleibt danach
-- ein Unterschied übrig, wurde etwas Unzulässiges angefasst.
vergleich := NEW;
vergleich."geaendertAm" := OLD."geaendertAm";
IF OLD."status" = 'ABGESCHLOSSEN' AND NEW."status" = 'ERSETZT' THEN
vergleich."status" := OLD."status";
END IF;
IF OLD."protokollDateiId" IS NULL THEN
vergleich."protokollDateiId" := OLD."protokollDateiId";
END IF;
IF OLD."protokollNummer" IS NULL THEN
vergleich."protokollNummer" := OLD."protokollNummer";
END IF;
IF vergleich IS DISTINCT FROM OLD THEN
RAISE EXCEPTION
'Die Pruefung % ist abgeschlossen und damit unveraenderlich. '
'Eine Korrektur wird als neue Pruefung mit Verweis auf diese erfasst.',
OLD."id"
USING ERRCODE = 'insufficient_privilege';
END IF;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION pruefung_unveraenderlich() IS
'Sperrt Aenderungen an abgeschlossenen Pruefungen, bis auf Ersetzung und Nachtrag des Protokolls.';
-- Gelöscht wird ein Protokoll gar nicht — auch nicht weich. Die einzige
-- Ausnahme ist der Aufbewahrungslauf, derselbe Schalter wie beim Audit-Log.
CREATE OR REPLACE FUNCTION pruefung_kein_loeschen()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD."status" = 'ENTWURF' THEN
RETURN OLD;
END IF;
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION
'Die Pruefung % ist abgeschlossen und darf nicht geloescht werden '
'(nur im Aufbewahrungslauf).',
OLD."id"
USING ERRCODE = 'insufficient_privilege';
END;
$$;
CREATE TRIGGER pruefung_abschluss_schuetzen
BEFORE UPDATE ON "pruefung"
FOR EACH ROW EXECUTE FUNCTION pruefung_unveraenderlich();
CREATE TRIGGER pruefung_loeschschutz
BEFORE DELETE ON "pruefung"
FOR EACH ROW EXECUTE FUNCTION pruefung_kein_loeschen();
-- -----------------------------------------------------------------------------
-- Dasselbe für die Bestandteile eines Protokolls.
--
-- Ohne diese Sperre ließe sich ein festgeschriebenes Protokoll über den Umweg
-- der Einzelergebnisse verändern: die Prüfung bliebe unangetastet, ihr Inhalt
-- nicht. Beim Löschen der Prüfung selbst greift die Kaskade — dann gibt es die
-- Prüfung schon nicht mehr, und die Abfrage unten liefert NULL.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION pruefungsinhalt_gesperrt()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
betroffene_pruefung uuid;
zustand "PruefungStatus";
BEGIN
IF TG_OP = 'DELETE' THEN
betroffene_pruefung := OLD."pruefungId";
ELSE
betroffene_pruefung := NEW."pruefungId";
END IF;
SELECT "status" INTO zustand FROM "pruefung" WHERE "id" = betroffene_pruefung;
-- Prüfung existiert nicht mehr (Kaskade) oder ist noch Entwurf: erlaubt.
IF zustand IS NULL OR zustand = 'ENTWURF' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END IF;
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END IF;
RAISE EXCEPTION
'Die Pruefung % ist abgeschlossen. % auf % ist nicht mehr zulaessig.',
betroffene_pruefung, TG_OP, TG_TABLE_NAME
USING ERRCODE = 'insufficient_privilege';
END;
$$;
CREATE TRIGGER pruefergebnis_gesperrt
BEFORE INSERT OR UPDATE OR DELETE ON "pruefergebnis"
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
CREATE TRIGGER signatur_gesperrt
BEFORE INSERT OR UPDATE OR DELETE ON "signatur"
FOR EACH ROW EXECUTE FUNCTION pruefungsinhalt_gesperrt();
-- -----------------------------------------------------------------------------
-- Ein Messwert außerhalb seines Bereichs ist kein Messwert, sondern ein Fehler
-- in der Vorlage: die Untergrenze darf nicht über der Obergrenze liegen.
-- -----------------------------------------------------------------------------
ALTER TABLE "checklistenpunkt" ADD CONSTRAINT "checklistenpunkt_bereich_sinnvoll" CHECK (
"minWert" IS NULL OR "maxWert" IS NULL OR "minWert" <= "maxWert"
);
-- Ein Intervall von null Monaten würde im nächtlichen Lauf endlos Termine
-- erzeugen.
ALTER TABLE "wartungsvorlage" ADD CONSTRAINT "wartungsvorlage_intervall_positiv" CHECK (
"intervallMonate" > 0 AND "vorwarnzeitTage" >= 0
);
-- -----------------------------------------------------------------------------
-- Indizes
--
-- Das Fristen-Dashboard fragt immer dasselbe: offene Termine, sortiert nach
-- Fälligkeit. Ein Teilindex darauf ist klein und bleibt es auch, wenn die
-- Tabelle nach Jahren zehntausende erledigte Termine enthält.
-- -----------------------------------------------------------------------------
CREATE INDEX "wartungstermin_offen" ON "wartungstermin" ("organisationId", "faelligAm")
WHERE "status" = 'OFFEN' AND "geloeschtAm" IS NULL;
CREATE INDEX "mangel_offen" ON "mangel" ("organisationId", "prioritaet", "gemeldetAm")
WHERE "status" <> 'BEHOBEN' AND "status" <> 'VERWORFEN' AND "geloeschtAm" IS NULL;
-- Volltextsuche wie in Phase 1: pg_trgm macht ILIKE '%text%' indexfähig.
CREATE INDEX "mangel_suche_beschreibung" ON "mangel" USING gin ("beschreibung" gin_trgm_ops);
CREATE INDEX "rueckruf_suche_titel" ON "rueckruf" USING gin ("titel" gin_trgm_ops);
CREATE INDEX "rueckruf_suche_hersteller" ON "rueckruf" USING gin ("hersteller" gin_trgm_ops);
CREATE INDEX "wartungsvorlage_suche_name" ON "wartungsvorlage" USING gin ("name" gin_trgm_ops);
-- -----------------------------------------------------------------------------
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
-- DELETE auf "pruefung" bleibt erlaubt: ein abgebrochener Entwurf soll
-- verschwinden koennen. Abgeschlossene Protokolle schuetzt der Trigger
-- pruefung_loeschschutz, und der gilt fuer jede Rolle.
ELSE
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
END IF;
END;
$$;
@@ -0,0 +1,347 @@
-- =============================================================================
-- Phase 3: Tabellen für Einsätze und Übungen.
--
-- Erzeugt mit `prisma migrate dev --create-only` und wie in Phase 2 an einer
-- Stelle bereinigt: die DROP INDEX-Zeilen für die Volltext- und Geoindizes
-- sind entfernt. Prisma kennt gin_trgm_ops und gist nicht und würde sie bei
-- jeder Migration erneut löschen wollen.
--
-- Mandantentrennung, Koordinaten-Trigger, Unveränderbarkeit freigegebener
-- Berichte und die Suchindizes stehen in der nachfolgenden Migration.
-- =============================================================================
-- CreateEnum
CREATE TYPE "EinsatzKategorie" AS ENUM ('BRAND_KLEIN', 'BRAND_MITTEL', 'BRAND_GROSS', 'TECHNISCHE_HILFE_KLEIN', 'TECHNISCHE_HILFE_MITTEL', 'TECHNISCHE_HILFE_GROSS', 'GEFAHRGUT', 'RETTUNGSDIENST', 'BRANDMELDEANLAGE', 'BOESWILLIGER_ALARM', 'SICHERHEITSWACHE', 'UNTERSTUETZUNG', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "EinsatzStatus" AS ENUM ('ENTWURF', 'LAUFEND', 'ABGESCHLOSSEN', 'FREIGEGEBEN');
-- CreateEnum
CREATE TYPE "Alarmierungsart" AS ENUM ('SIRENE', 'MELDEEMPFAENGER', 'TELEFON', 'APP', 'PERSOENLICH', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "EinsatzFunktion" AS ENUM ('EINSATZLEITER', 'ZUGFUEHRER', 'GRUPPENFUEHRER', 'STAFFELFUEHRER', 'TRUPPFUEHRER', 'MASCHINIST', 'MELDER', 'TRUPPMANN', 'ATEMSCHUTZTRAEGER', 'SICHERHEITSTRUPP', 'SANITAETER', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "UebungsArt" AS ENUM ('PRAXIS', 'THEORIE', 'ATEMSCHUTZ', 'MASCHINIST', 'FUNK', 'ERSTE_HILFE', 'GEMEINSCHAFTSUEBUNG', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "LagemeldungQuelle" AS ENUM ('INTERN', 'PORTAL');
-- CreateTable
CREATE TABLE "einsatz" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatznummer" VARCHAR(40) NOT NULL,
"stichwort" VARCHAR(160) NOT NULL,
"kategorie" "EinsatzKategorie" NOT NULL DEFAULT 'SONSTIGES',
"status" "EinsatzStatus" NOT NULL DEFAULT 'ENTWURF',
"ilsNummer" VARCHAR(60),
"alarmierungsart" "Alarmierungsart",
"strasse" VARCHAR(200),
"hausnummer" VARCHAR(20),
"plz" VARCHAR(10),
"ort" VARCHAR(120),
"ortsteil" VARCHAR(120),
"objekt" VARCHAR(200),
"breitengrad" DECIMAL(9,6),
"laengengrad" DECIMAL(9,6),
"koordinaten" geography(Point, 4326),
"koordinatenQuelle" VARCHAR(40),
"alarmiertUm" TIMESTAMPTZ(3),
"ausgeruecktUm" TIMESTAMPTZ(3),
"eingetroffenUm" TIMESTAMPTZ(3),
"einsatzendeUm" TIMESTAMPTZ(3),
"eingeruecktUm" TIMESTAMPTZ(3),
"einsatzbereitUm" TIMESTAMPTZ(3),
"einsatzleiterId" UUID,
"beschreibung" VARCHAR(4000),
"bericht" TEXT,
"wetter" VARCHAR(200),
"temperatur" INTEGER,
"weitereKraefte" VARCHAR(500),
"geretteteePersonen" INTEGER NOT NULL DEFAULT 0,
"verletztePersonen" INTEGER NOT NULL DEFAULT 0,
"totePersonen" INTEGER NOT NULL DEFAULT 0,
"sachschaden" DECIMAL(12,2),
"kostenpflichtig" BOOLEAN NOT NULL DEFAULT false,
"berichtDateiId" UUID,
"freigegebenAm" TIMESTAMPTZ(3),
"freigegebenVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "einsatz_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "einsatzfahrzeug" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatzId" UUID NOT NULL,
"fahrzeugId" UUID NOT NULL,
"ausgeruecktUm" TIMESTAMPTZ(3),
"eingetroffenUm" TIMESTAMPTZ(3),
"eingeruecktUm" TIMESTAMPTZ(3),
"kilometerAbfahrt" INTEGER,
"kilometerRueckkehr" INTEGER,
"wasserabgabe" INTEGER,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "einsatzfahrzeug_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "einsatzkraft" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatzId" UUID NOT NULL,
"personId" UUID NOT NULL,
"funktion" "EinsatzFunktion" NOT NULL DEFAULT 'TRUPPMANN',
"fahrzeugId" UUID,
"vonUm" TIMESTAMPTZ(3),
"bisUm" TIMESTAMPTZ(3),
"atemschutz" BOOLEAN NOT NULL DEFAULT false,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "einsatzkraft_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "materialverbrauch" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatzId" UUID NOT NULL,
"geraetId" UUID,
"bezeichnung" VARCHAR(200) NOT NULL,
"menge" DECIMAL(10,2) NOT NULL,
"einheit" VARCHAR(20) NOT NULL,
"kosten" DECIMAL(10,2),
"beschaedigt" BOOLEAN NOT NULL DEFAULT false,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "materialverbrauch_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lagemeldung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatzId" UUID NOT NULL,
"zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"text" VARCHAR(2000) NOT NULL,
"quelle" "LagemeldungQuelle" NOT NULL DEFAULT 'INTERN',
"verfasserId" UUID,
"verfasserName" VARCHAR(200),
"fotoDateiId" UUID,
"wichtig" BOOLEAN NOT NULL DEFAULT false,
"portalZugangId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "lagemeldung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "portal_zugang" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"einsatzId" UUID NOT NULL,
"bezeichnung" VARCHAR(160) NOT NULL,
"tokenHash" VARCHAR(120) NOT NULL,
"gueltigBis" TIMESTAMPTZ(3) NOT NULL,
"widerrufenAm" TIMESTAMPTZ(3),
"aufrufe" INTEGER NOT NULL DEFAULT 0,
"letzterAufruf" TIMESTAMPTZ(3),
"letzteIp" INET,
"erstelltVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "portal_zugang_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "uebung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"nummer" VARCHAR(40) NOT NULL,
"thema" VARCHAR(200) NOT NULL,
"art" "UebungsArt" NOT NULL DEFAULT 'PRAXIS',
"datum" DATE NOT NULL,
"beginnUm" TIMESTAMPTZ(3),
"endeUm" TIMESTAMPTZ(3),
"ort" VARCHAR(200),
"leiterId" UUID,
"lernziele" VARCHAR(2000),
"beschreibung" VARCHAR(4000),
"bericht" TEXT,
"berichtDateiId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "uebung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "uebungsteilnahme" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"uebungId" UUID NOT NULL,
"personId" UUID NOT NULL,
"funktion" "EinsatzFunktion",
"vonUm" TIMESTAMPTZ(3),
"bisUm" TIMESTAMPTZ(3),
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "uebungsteilnahme_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "einsatz_organisationId_alarmiertUm_idx" ON "einsatz"("organisationId", "alarmiertUm" DESC);
-- CreateIndex
CREATE INDEX "einsatz_organisationId_status_idx" ON "einsatz"("organisationId", "status");
-- CreateIndex
CREATE INDEX "einsatz_organisationId_kategorie_idx" ON "einsatz"("organisationId", "kategorie");
-- CreateIndex
CREATE UNIQUE INDEX "einsatz_organisationId_einsatznummer_key" ON "einsatz"("organisationId", "einsatznummer");
-- CreateIndex
CREATE INDEX "einsatzfahrzeug_organisationId_einsatzId_idx" ON "einsatzfahrzeug"("organisationId", "einsatzId");
-- CreateIndex
CREATE INDEX "einsatzfahrzeug_organisationId_fahrzeugId_idx" ON "einsatzfahrzeug"("organisationId", "fahrzeugId");
-- CreateIndex
CREATE UNIQUE INDEX "einsatzfahrzeug_einsatzId_fahrzeugId_key" ON "einsatzfahrzeug"("einsatzId", "fahrzeugId");
-- CreateIndex
CREATE INDEX "einsatzkraft_organisationId_einsatzId_idx" ON "einsatzkraft"("organisationId", "einsatzId");
-- CreateIndex
CREATE INDEX "einsatzkraft_organisationId_personId_idx" ON "einsatzkraft"("organisationId", "personId");
-- CreateIndex
CREATE UNIQUE INDEX "einsatzkraft_einsatzId_personId_key" ON "einsatzkraft"("einsatzId", "personId");
-- CreateIndex
CREATE INDEX "materialverbrauch_organisationId_einsatzId_idx" ON "materialverbrauch"("organisationId", "einsatzId");
-- CreateIndex
CREATE INDEX "lagemeldung_organisationId_einsatzId_zeitpunkt_idx" ON "lagemeldung"("organisationId", "einsatzId", "zeitpunkt");
-- CreateIndex
CREATE INDEX "portal_zugang_organisationId_einsatzId_idx" ON "portal_zugang"("organisationId", "einsatzId");
-- CreateIndex
CREATE INDEX "portal_zugang_gueltigBis_idx" ON "portal_zugang"("gueltigBis");
-- CreateIndex
CREATE INDEX "uebung_organisationId_datum_idx" ON "uebung"("organisationId", "datum" DESC);
-- CreateIndex
CREATE INDEX "uebung_organisationId_art_idx" ON "uebung"("organisationId", "art");
-- CreateIndex
CREATE UNIQUE INDEX "uebung_organisationId_nummer_key" ON "uebung"("organisationId", "nummer");
-- CreateIndex
CREATE INDEX "uebungsteilnahme_organisationId_personId_idx" ON "uebungsteilnahme"("organisationId", "personId");
-- CreateIndex
CREATE UNIQUE INDEX "uebungsteilnahme_uebungId_personId_key" ON "uebungsteilnahme"("uebungId", "personId");
-- AddForeignKey
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_einsatzleiterId_fkey" FOREIGN KEY ("einsatzleiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_freigegebenVonId_fkey" FOREIGN KEY ("freigegebenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzfahrzeug" ADD CONSTRAINT "einsatzfahrzeug_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "einsatzkraft" ADD CONSTRAINT "einsatzkraft_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "materialverbrauch" ADD CONSTRAINT "materialverbrauch_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_verfasserId_fkey" FOREIGN KEY ("verfasserId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lagemeldung" ADD CONSTRAINT "lagemeldung_portalZugangId_fkey" FOREIGN KEY ("portalZugangId") REFERENCES "portal_zugang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_leiterId_fkey" FOREIGN KEY ("leiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebung" ADD CONSTRAINT "uebung_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "uebungsteilnahme" ADD CONSTRAINT "uebungsteilnahme_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,243 @@
-- =============================================================================
-- Phase 3: Mandantentrennung, Koordinaten, Berichtsfreigabe, Indizes.
--
-- Vier Dinge, die in der Datenbank besser aufgehoben sind als im Code:
--
-- 1. Row Level Security für die acht neuen Tabellen.
--
-- 2. Der Gleichlauf von Dezimalgrad und PostGIS-Punkt. Die Anwendung
-- schreibt Breite und Länge als Zahl; den geography-Punkt setzt ein
-- Trigger. Sonst müsste jede Schreiboperation Roh-SQL sein, und die
-- Karte ab Phase 6 fände Einsätze, die über einen anderen Weg entstanden
-- sind, nicht wieder.
--
-- 3. Die Unveränderbarkeit freigegebener Einsatzberichte. Der Bericht geht
-- an die Gemeinde und im Schadensfall an die Versicherung. Dieselbe Logik
-- wie beim Prüfprotokoll aus Phase 2.
--
-- 4. Suchindizes und ein Teilindex auf die laufenden Einsätze.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security
-- -----------------------------------------------------------------------------
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'einsatz',
'einsatzfahrzeug',
'einsatzkraft',
'materialverbrauch',
'lagemeldung',
'portal_zugang',
'uebung',
'uebungsteilnahme'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
EXECUTE format(
'CREATE POLICY %I ON %I FOR ALL '
'USING ("organisationId" = aktuelle_organisation()) '
'WITH CHECK ("organisationId" = aktuelle_organisation())',
tabelle || '_mandant', tabelle
);
END LOOP;
END;
$$;
-- -----------------------------------------------------------------------------
-- Koordinaten: Dezimalgrad und PostGIS-Punkt zusammenhalten
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION einsatz_koordinaten_setzen()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW."breitengrad" IS NULL OR NEW."laengengrad" IS NULL THEN
NEW."koordinaten" := NULL;
RETURN NEW;
END IF;
-- Reihenfolge beachten: ST_MakePoint nimmt erst die Länge, dann die Breite.
NEW."koordinaten" := ST_SetSRID(
ST_MakePoint(NEW."laengengrad"::double precision, NEW."breitengrad"::double precision),
4326
)::geography;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION einsatz_koordinaten_setzen() IS
'Haelt den PostGIS-Punkt eines Einsatzes mit den Dezimalgraden im Gleichlauf.';
CREATE TRIGGER einsatz_koordinaten
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "einsatz"
FOR EACH ROW EXECUTE FUNCTION einsatz_koordinaten_setzen();
-- Koordinaten, die es auf der Erde nicht gibt, sind ein Tippfehler.
ALTER TABLE "einsatz" ADD CONSTRAINT "einsatz_koordinaten_gueltig" CHECK (
("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90))
AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180))
);
-- -----------------------------------------------------------------------------
-- Freigegebene Einsatzberichte sind unveränderbar
--
-- Bis zur Freigabe darf alles nachgetragen werden — ein Einsatz wird tagelang
-- ergänzt, und genau dafür ist der Entwurfsstand da. Mit der Freigabe ist
-- Schluss: der Bericht ist dann ein Dokument, kein Arbeitsstand.
--
-- Zwei Ausnahmen bleiben, beide nachtragend statt verändernd: das PDF, solange
-- keines hinterlegt ist, und geaendertAm.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION einsatz_freigabe_schuetzen()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
vergleich "einsatz"%ROWTYPE;
BEGIN
IF OLD."status" <> 'FREIGEGEBEN' THEN
RETURN NEW;
END IF;
vergleich := NEW;
vergleich."geaendertAm" := OLD."geaendertAm";
IF OLD."berichtDateiId" IS NULL THEN
vergleich."berichtDateiId" := OLD."berichtDateiId";
END IF;
IF vergleich IS DISTINCT FROM OLD THEN
RAISE EXCEPTION
'Der Einsatzbericht % ist freigegeben und damit unveraenderlich.',
OLD."einsatznummer"
USING ERRCODE = 'insufficient_privilege';
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION einsatz_kein_loeschen()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD."status" <> 'FREIGEGEBEN' THEN
RETURN OLD;
END IF;
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION
'Der Einsatzbericht % ist freigegeben und darf nicht geloescht werden '
'(nur im Aufbewahrungslauf).',
OLD."einsatznummer"
USING ERRCODE = 'insufficient_privilege';
END;
$$;
CREATE TRIGGER einsatz_freigabe
BEFORE UPDATE ON "einsatz"
FOR EACH ROW EXECUTE FUNCTION einsatz_freigabe_schuetzen();
CREATE TRIGGER einsatz_loeschschutz
BEFORE DELETE ON "einsatz"
FOR EACH ROW EXECUTE FUNCTION einsatz_kein_loeschen();
-- Dasselbe für die Bestandteile: sonst ließe sich ein freigegebener Bericht
-- über die Einsatzkräfte oder den Materialverbrauch nachträglich verändern.
CREATE OR REPLACE FUNCTION einsatzinhalt_gesperrt()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
betroffener_einsatz uuid;
zustand "EinsatzStatus";
BEGIN
IF TG_OP = 'DELETE' THEN
betroffener_einsatz := OLD."einsatzId";
ELSE
betroffener_einsatz := NEW."einsatzId";
END IF;
SELECT "status" INTO zustand FROM "einsatz" WHERE "id" = betroffener_einsatz;
IF zustand IS NULL OR zustand <> 'FREIGEGEBEN' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END IF;
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END IF;
RAISE EXCEPTION
'Der Einsatz % ist freigegeben. % auf % ist nicht mehr zulaessig.',
betroffener_einsatz, TG_OP, TG_TABLE_NAME
USING ERRCODE = 'insufficient_privilege';
END;
$$;
CREATE TRIGGER einsatzfahrzeug_gesperrt
BEFORE INSERT OR UPDATE OR DELETE ON "einsatzfahrzeug"
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
CREATE TRIGGER einsatzkraft_gesperrt
BEFORE INSERT OR UPDATE OR DELETE ON "einsatzkraft"
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
CREATE TRIGGER materialverbrauch_gesperrt
BEFORE INSERT OR UPDATE OR DELETE ON "materialverbrauch"
FOR EACH ROW EXECUTE FUNCTION einsatzinhalt_gesperrt();
-- Lagemeldungen bleiben ausdrücklich ausgenommen: sie sind ein Protokoll des
-- Verlaufs, kein Bestandteil des Berichts, und eine Meldung aus dem Portal
-- kann eintreffen, während der Bericht schon freigegeben ist.
-- -----------------------------------------------------------------------------
-- Portalzugänge
-- -----------------------------------------------------------------------------
-- Ein Zugang, der nie gültig war, ist ein Fehler in der Erzeugung.
ALTER TABLE "portal_zugang" ADD CONSTRAINT "portal_zugang_gueltigkeit" CHECK (
"gueltigBis" > "erstelltAm"
);
-- -----------------------------------------------------------------------------
-- Indizes
-- -----------------------------------------------------------------------------
-- Der Alarmmonitor fragt immer dasselbe: was läuft gerade.
CREATE INDEX "einsatz_laufend" ON "einsatz" ("organisationId", "alarmiertUm" DESC)
WHERE "status" = 'LAUFEND' AND "geloeschtAm" IS NULL;
-- Räumliche Abfragen ab Phase 6.
CREATE INDEX "einsatz_koordinaten_idx" ON "einsatz" USING gist ("koordinaten");
-- Volltextsuche wie in den Phasen davor.
CREATE INDEX "einsatz_suche_stichwort" ON "einsatz" USING gin ("stichwort" gin_trgm_ops);
CREATE INDEX "einsatz_suche_ort" ON "einsatz" USING gin ("ort" gin_trgm_ops);
CREATE INDEX "einsatz_suche_nummer" ON "einsatz" USING gin ("einsatznummer" gin_trgm_ops);
CREATE INDEX "uebung_suche_thema" ON "uebung" USING gin ("thema" gin_trgm_ops);
-- -----------------------------------------------------------------------------
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
-- Die Einschraenkungen aus den Phasen davor gelten unveraendert weiter.
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
ELSE
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
END IF;
END;
$$;
@@ -0,0 +1,269 @@
-- =============================================================================
-- Phase 4: Tabellen für das Terminal im Gerätehaus und den Offline-Betrieb.
--
-- Erzeugt mit `prisma migrate dev --create-only` und wie in den Phasen davor
-- um die DROP INDEX-Zeilen bereinigt: Prisma kennt die Trigram- und
-- Geoindizes nicht und würde sie sonst bei jeder Migration löschen wollen.
--
-- Mandantentrennung, die Sperre gegen doppelte Vorgänge und die Indizes für
-- den Terminalbetrieb stehen in der nachfolgenden Migration.
-- =============================================================================
-- CreateEnum
CREATE TYPE "FahrtZweck" AS ENUM ('EINSATZ', 'UEBUNG', 'AUSBILDUNG', 'WERKSTATT', 'BESCHAFFUNG', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "WaescheStatus" AS ENUM ('ABGEGEBEN', 'IN_REINIGUNG', 'ZURUECK', 'VERSCHOLLEN');
-- CreateEnum
CREATE TYPE "AnwesenheitAnlass" AS ENUM ('DIENST', 'UEBUNG', 'EINSATZ', 'ARBEITSDIENST', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "InventurStatus" AS ENUM ('LAEUFT', 'ABGESCHLOSSEN', 'ABGEBROCHEN');
-- CreateEnum
CREATE TYPE "KioskVorgangArt" AS ENUM ('MANGEL_MELDEN', 'FAHRT_BEGINNEN', 'FAHRT_BEENDEN', 'ANWESENHEIT_KOMMEN', 'ANWESENHEIT_GEHEN', 'WAESCHE_ABGEBEN', 'INVENTUR_SCANNEN', 'PRUEFUNG_ERFASSEN');
-- CreateEnum
CREATE TYPE "KonfliktGrund" AS ENUM ('ZIEL_FEHLT', 'ZIEL_GESPERRT', 'DOPPELT', 'VERALTET', 'SONSTIGES');
-- CreateTable
CREATE TABLE "fahrt" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"fahrzeugId" UUID NOT NULL,
"fahrerId" UUID NOT NULL,
"zweck" "FahrtZweck" NOT NULL DEFAULT 'SONSTIGES',
"einsatzId" UUID,
"uebungId" UUID,
"abfahrtUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"rueckkehrUm" TIMESTAMPTZ(3),
"kilometerAbfahrt" INTEGER NOT NULL,
"kilometerRueckkehr" INTEGER,
"ziel" VARCHAR(200),
"tankmenge" DECIMAL(8,2),
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "fahrt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "anwesenheit" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"anlass" "AnwesenheitAnlass" NOT NULL DEFAULT 'DIENST',
"angemeldetUm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"abgemeldetUm" TIMESTAMPTZ(3),
"einsatzId" UUID,
"uebungId" UUID,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "anwesenheit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "waescheabgabe" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"geraetId" UUID,
"bezeichnung" VARCHAR(200) NOT NULL,
"menge" INTEGER NOT NULL DEFAULT 1,
"status" "WaescheStatus" NOT NULL DEFAULT 'ABGEGEBEN',
"grund" VARCHAR(200),
"abgegebenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zurueckAm" TIMESTAMPTZ(3),
"angenommenVonId" UUID,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "waescheabgabe_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "inventurgang" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"bezeichnung" VARCHAR(160) NOT NULL,
"status" "InventurStatus" NOT NULL DEFAULT 'LAEUFT',
"standortId" UUID,
"fahrzeugId" UUID,
"begonnenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"abgeschlossenAm" TIMESTAMPTZ(3),
"begonnenVonId" UUID,
"bemerkung" VARCHAR(1000),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "inventurgang_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "inventurposten" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"inventurId" UUID NOT NULL,
"geraetId" UUID NOT NULL,
"gescanntAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"gefunden" BOOLEAN NOT NULL DEFAULT true,
"vorgefundenAn" VARCHAR(200),
"bemerkung" VARCHAR(500),
"erfasstVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "inventurposten_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "kiosk_vorgang" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"art" "KioskVorgangArt" NOT NULL,
"personId" UUID,
"erfasstAm" TIMESTAMPTZ(3) NOT NULL,
"empfangenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ergebnisId" UUID,
"nutzlast" JSONB,
"erfolgreich" BOOLEAN NOT NULL DEFAULT true,
"meldung" VARCHAR(500),
CONSTRAINT "kiosk_vorgang_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sync_konflikt" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"vorgangId" UUID NOT NULL,
"art" "KioskVorgangArt" NOT NULL,
"grund" "KonfliktGrund" NOT NULL DEFAULT 'SONSTIGES',
"beschreibung" VARCHAR(500) NOT NULL,
"nutzlast" JSONB,
"personId" UUID,
"entstandenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"erledigtAm" TIMESTAMPTZ(3),
"erledigtVonId" UUID,
"erledigung" VARCHAR(500),
CONSTRAINT "sync_konflikt_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "fahrt_organisationId_fahrzeugId_abfahrtUm_idx" ON "fahrt"("organisationId", "fahrzeugId", "abfahrtUm" DESC);
-- CreateIndex
CREATE INDEX "fahrt_organisationId_fahrerId_idx" ON "fahrt"("organisationId", "fahrerId");
-- CreateIndex
CREATE INDEX "anwesenheit_organisationId_personId_angemeldetUm_idx" ON "anwesenheit"("organisationId", "personId", "angemeldetUm" DESC);
-- CreateIndex
CREATE INDEX "anwesenheit_organisationId_abgemeldetUm_idx" ON "anwesenheit"("organisationId", "abgemeldetUm");
-- CreateIndex
CREATE INDEX "waescheabgabe_organisationId_status_idx" ON "waescheabgabe"("organisationId", "status");
-- CreateIndex
CREATE INDEX "waescheabgabe_organisationId_personId_idx" ON "waescheabgabe"("organisationId", "personId");
-- CreateIndex
CREATE INDEX "inventurgang_organisationId_status_idx" ON "inventurgang"("organisationId", "status");
-- CreateIndex
CREATE INDEX "inventurposten_organisationId_inventurId_idx" ON "inventurposten"("organisationId", "inventurId");
-- CreateIndex
CREATE UNIQUE INDEX "inventurposten_inventurId_geraetId_key" ON "inventurposten"("inventurId", "geraetId");
-- CreateIndex
CREATE INDEX "kiosk_vorgang_organisationId_empfangenAm_idx" ON "kiosk_vorgang"("organisationId", "empfangenAm" DESC);
-- CreateIndex
CREATE INDEX "kiosk_vorgang_organisationId_art_idx" ON "kiosk_vorgang"("organisationId", "art");
-- CreateIndex
CREATE INDEX "sync_konflikt_organisationId_erledigtAm_idx" ON "sync_konflikt"("organisationId", "erledigtAm");
-- AddForeignKey
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_fahrerId_fkey" FOREIGN KEY ("fahrerId") REFERENCES "person"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrt" ADD CONSTRAINT "fahrt_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "anwesenheit" ADD CONSTRAINT "anwesenheit_uebungId_fkey" FOREIGN KEY ("uebungId") REFERENCES "uebung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "waescheabgabe" ADD CONSTRAINT "waescheabgabe_angenommenVonId_fkey" FOREIGN KEY ("angenommenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurgang" ADD CONSTRAINT "inventurgang_begonnenVonId_fkey" FOREIGN KEY ("begonnenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_inventurId_fkey" FOREIGN KEY ("inventurId") REFERENCES "inventurgang"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_geraetId_fkey" FOREIGN KEY ("geraetId") REFERENCES "geraet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventurposten" ADD CONSTRAINT "inventurposten_erfasstVonId_fkey" FOREIGN KEY ("erfasstVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kiosk_vorgang" ADD CONSTRAINT "kiosk_vorgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kiosk_vorgang" ADD CONSTRAINT "kiosk_vorgang_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sync_konflikt" ADD CONSTRAINT "sync_konflikt_erledigtVonId_fkey" FOREIGN KEY ("erledigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;

Some files were not shown because too many files have changed in this diff Show More