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

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

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

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

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

19 KiB

FLORIAN — Feuerwehr-Verwaltungsplattform

Selbstgehostete Verwaltungssoftware für Freiwillige Feuerwehren. Läuft im Gerätehaus, nicht in der Cloud. Keine Telemetrie, keine externen CDNs.

Schnellstart

git clone https://git.wilde-server.de/florian.wilde/FireAdministration.git && cd FireAdministration
cp .env.example .env                      # Passwörter und AUTH_SECRET ersetzen
npm ci                                    # Abhängigkeiten
docker compose up -d                      # Postgres, Redis, MinIO, Gotenberg
npm run db:migrate && npm run db:seed     # Schema und Beispielwehr
npm run dev                               # http://localhost:3100

Anmelden mit SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORT aus der .env (Vorgabe: admin@beispielwehr.de / Feuerwehr2026). Passwort danach ändern.


Was drin ist

Bereich Stand
Phase 0 — Fundament fertig
Phase 1 — Stammdaten fertig
Phase 2 — Wartung und Prüfung fertig
Phase 3 — Einsätze und Übungen fertig
Phase 4 — Kiosk-PWA offen
Phase 5 — Atemschutz (FwDV 7) offen
Phase 6 — Kartenmodule offen
Phase 7 — Benachrichtigungen und Termine offen
Phase 8 — Lokale KI offen
Phase 9 — Kassenverwaltung offen

Vollständige Planung mit Aufwänden: docs/ROADMAP.md und docs/AUFWANDSSCHAETZUNG.md.

Weiterführende Dokumentation

Datei Inhalt
docs/ANLEITUNG.md Bedienung mit Bildschirmfotos
docs/ROADMAP.md Phasenplan und was jeweils entsteht
docs/AUFWANDSSCHAETZUNG.md Aufwand je Phase, Schätzung gegen Ist
docs/DEVLOG.md Entwicklungstagebuch mit Zeiterfassung
docs/VERSIONIERUNG.md Versions- und Branch-Regeln
CHANGELOG.md Was sich wann geändert hat

Phase 0 — Fundament

Aufbau

FireAdministration/
├─ docker/              Dockerfile, Entrypoint, Postgres-Initialisierung
├─ prisma/              Schema, Migrationen, Seed
├─ scripts/             Hilfsskripte (Bildschirmfotos)
├─ src/
│  ├─ app/              Next.js App Router
│  │  ├─ (app)/         alles, was Anmeldung verlangt
│  │  ├─ anmelden/      Anmeldeseite
│  │  └─ api/           Auth.js, tRPC, Health
│  ├─ components/       shadcn/ui-Bausteine und Layout
│  ├─ lib/              authz, db, storage, audit, env, auth, warteschlange
│  ├─ server/           tRPC-Router und Kontext
│  └─ worker/           BullMQ-Hintergrundprozess
├─ tests/               Vitest (unit) und Playwright (e2e)
└─ docs/                Anleitung, Roadmap, Devlog, Aufwände

Mandantentrennung

Jede Tabelle trägt organisationId, und Postgres setzt die Trennung mit Row Level Security durch. Es gibt zwei Datenbankverbindungen:

  • florian_app (DATABASE_URL) — RLS aktiv, NOBYPASSRLS. Über diese Verbindung arbeitet die Anwendung. Ohne gesetztes app.current_organisation liefert jede Abfrage null Zeilen.
  • Eigentümer (DIRECT_DATABASE_URL) — umgeht RLS. Nur für Migrationen, Seed, Anmeldung (die E-Mail-Suche muss über alle Wehren gehen) und Wartungsjobs.

Fachdaten laufen ausschließlich über fuerOrganisation() aus src/lib/db.ts:

const personen = await fuerOrganisation(orgId, (db) =>
  db.person.findMany({ where: { aktiv: true } }),
);

Ein vergessener Mandantenfilter kostet damit im schlimmsten Fall ein leeres Ergebnis — nicht die Daten einer fremden Wehr. tests/unit/mandantentrennung.test.ts prüft das gegen eine echte Datenbank.

Audit-Log

Jede schreibende Operation erzeugt einen Eintrag mit Akteur, Zeitpunkt, Entität und Vorher/Nachher als JSONB. Die Tabelle ist append-only:

  • UPDATE ist per Trigger immer gesperrt.
  • DELETE nur im gekennzeichneten Aufbewahrungslauf (SET LOCAL app.audit_bereinigung = 'an'), den ausschließlich der Worker ausführt — nachdem er exportiert hat. Ohne diese Ausnahme ließe sich das Löschkonzept nach DSGVO nicht umsetzen.

Geheimnisse (Passwort-Hashes, PINs, Token) werden vor dem Schreiben entfernt.

Berechtigungen

Alles in src/lib/authz.ts — keine Rollenprüfung in Komponenten. 9 Rollen, 38 Einzelrechte. Der Seed spiegelt den Katalog in die Datenbank, damit eine Wehr Rechte nachträglich zuschneiden kann, ohne dass jemand Code ändert.

export const geraetSpeichern = mitRecht("geraet.bearbeiten").mutation(/* … */);

Anmeldung

Drei Wege, eine Sitzung:

  1. OIDC gegen authentik — der Regelweg. authentik wird separat betrieben und ist nicht Teil dieses Stacks. Im Provider einzutragen: Redirect-URI ${APP_URL}/api/auth/callback/authentik, Scopes openid profile email.
  2. E-Mail und Passwort — Rückfalloption für den Erststart und für den Fall, dass authentik nicht erreichbar ist. Über AUTH_LOCAL_ENABLED abschaltbar.
  3. Terminal (PIN oder RFID) — für das Kiosk-Gerät im Gerätehaus. Zwei Schranken: erst weist sich das Gerät mit KIOSK_DEVICE_SECRET aus, dann die Person mit PIN oder Karte. Die vollständige Oberfläche dazu kommt in Phase 4.

Rollen aus authentik-Gruppen wirken als Vorbelegung beim ersten Login (AUTH_OIDC_GROUP_MAPPING). Sobald jemand die Rolle in FLORIAN ändert, ist FLORIAN führend und OIDC überschreibt nicht mehr.

Dateien

Alles liegt in MinIO, nie im Dateisystem und nie in der Datenbank. Zugriff ausschließlich über src/lib/storage.ts. Objektschlüssel:

<wehr-slug>/<entitaet>/<entitaetId>/<uuid>-<dateiname>

Downloads laufen über presignte Links mit begrenzter Gültigkeit (S3_URL_TTL, Vorgabe 15 Minuten).


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):

  • 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) 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 — 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

Befehle

npm run dev              # Entwicklungsserver auf Port 3100
npm run build            # Produktionsbuild
npm run worker:dev       # Hintergrundprozess mit Neuladen
npm run test             # Vitest
npm run test:e2e         # Playwright
npm run typecheck        # TypeScript ohne Ausgabe
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

Dienste und Ports (Entwicklung)

Bewusst nicht die Standardports, damit FLORIAN neben anderen lokalen Stacks laufen kann. Alle Werte stehen in der .env.

Dienst Adresse
Anwendung http://localhost:3100
PostgreSQL 17 + PostGIS localhost:5442
Redis localhost:6389
MinIO API http://localhost:9010
MinIO Weboberfläche http://localhost:9011
Gotenberg http://localhost:3101
Mailpit (nur Entwicklung) http://localhost:8125
Adminer (nur Entwicklung) http://localhost:8081

Produktionsbetrieb

docker compose -f docker-compose.yml up -d

Die Angabe von -f ist wichtig: ohne sie legt Docker Compose automatisch docker-compose.override.yml darüber, und das ist die Entwicklungsvariante ohne Traefik. In Produktion terminiert Traefik TLS und verteilt auf APP_DOMAIN und MINIO_DOMAIN.

Der App-Container wendet beim Start ausstehende Migrationen an; der Worker tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.

Datenschutz

  • Keine Telemetrie, keine externen CDNs, keine Google Fonts. Es werden nur 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.

Sicherheitshinweis

Atemschutzüberwachung und Alarmmonitor sind sicherheitsrelevant. Solange die Wehr das System nicht ausdrücklich freigegeben hat, bleibt die analoge Überwachung führend. Jede entsprechende Ansicht zeigt diesen Hinweis dauerhaft an.