Files
FireAdministration/README.md
T
Florian WildeandClaude Opus 5 30faa6cb02
Pruefung / pruefung (pull_request) Canceled after 0s
Pruefung / oberflaeche (pull_request) Canceled after 0s
Version 1.0.0 — Freigabe fuer den Echtbetrieb
Kein neues Modul. Zwoelf Phasen sind fertig, und die Wehr kann ab jetzt
echte Daten hineingeben, ohne befuerchten zu muessen, sie beim naechsten
Update zu verlieren.

Neu dazu, weil beides zum Betrieb gehoert:

update.sh aktualisiert auf einem Linux-Server in einem Befehl. Der Ablauf
darf an jeder Stelle abbrechen, ohne eine halb aktualisierte Anlage zu
hinterlassen: erst nachsehen ob es etwas Neues gibt, dann sichern, dann
holen und bauen (schlaegt das fehl, laeuft die alte Fassung unveraendert
weiter), dann umschalten, dann pruefen ob die Anwendung antwortet. Tut sie
es nicht, stehen die Protokollzeilen da und die Befehle fuer den Rueckweg.

docs/KURZANLEITUNG.md ist der Einstieg fuer die Wehr - nach Rollen
sortiert, aufgabenorientiert statt nach Modulen, und bei jedem heiklen
Punkt steht das Warum dabei. Die vollstaendige Anleitung hat 63 Kapitel;
die liest niemand, bevor er das erste Geraet pruefen will.

Die Roadmap haelt den Stand JEDER Freigabevoraussetzung fest, auch der
offenen: Datenuebernahme entfaellt (die Wehr pflegt von Hand ein),
Sicherungskonzept erledigt, Loeschkonzept technisch vollstaendig aber
nicht abgenommen, Schulung steht aus, Atemschutzhinweis bewusst stehen
gelassen. Eine Freigabe, die abhakt was nicht geprueft wurde, ist keine.

Nebenbei die Ursache der Testflackerei gefunden, die sich durch mehrere
Phasen zog: zwei Datenbanktests schalteten beim Aufraeumen ihre Trigger
mit ALTER TABLE ... DISABLE TRIGGER ab - eine Schemaaenderung, die fuer
die ganze Datenbank gilt. Vitest faehrt die Dateien parallel, also pruefte
eine Datei gerade dass ein Trigger greift, waehrend eine andere ihn
abgeraeumt hatte. Jetzt sitzungslokal ueber session_replication_role.

Erstmals in diesem Projekt: fuenf Unit-Testlaeufe hintereinander 601 gruen
und ein E2E-Lauf mit 268 gruen ohne Fehlschlag und ohne Flackern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:09:26 +02:00

74 KiB
Raw Blame History

FLORIAN — Feuerwehr-Verwaltungsplattform

Version 1.0.0 — freigegeben für den Echtbetrieb.

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

Wer FLORIAN benutzen und nicht bauen will: Kurzanleitung — alles Wichtige nach Rollen sortiert, auf ein paar Seiten.

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 im Gerätehaus fertig
Phase 5 — Atemschutz (FwDV 7) fertig
Phase 6 — Kartenmodule fertig
Phase 7 — Benachrichtigungen und Termine fertig
Phase 8 — Lokale KI fertig
Phase 9 — Kassenverwaltung fertig
Phase 10 — Mandantenfähigkeit fertig
Phase 11 — Freigabereife fertig

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

Weiterführende Dokumentation

Datei Inhalt
docs/KURZANLEITUNG.md Der Einstieg. Nach Rollen sortiert, ein paar Seiten
docs/ANLEITUNG.md Die vollständige Bedienung mit Bildschirmfotos, zum Nachschlagen
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

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:

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. 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.

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.


Phase 8 — Lokale KI

Ein Satz bestimmt alles, was hier gebaut wurde:

Nichts, was ein Modell erzeugt, gelangt ungeprüft in einen Nachweis.

Daraus folgt der Aufbau: jede KI-Ausgabe ist zuerst ein eigener Datensatz und nicht ein Feld im Zieldokument. Ein Einsatzbericht bekommt seinen Text erst, wenn ein Mensch „übernehmen" gedrückt hat — bis dahin liegt der Entwurf daneben und ist als Entwurf erkennbar, mit gestricheltem Rahmen und dem Modellnamen darüber. Das ist der Unterschied zwischen einer Hilfe und einer Fälschung.

Der zweite Grundsatz: es läuft alles im Haus. Ollama und faster-whisper als Container daneben, kein Anbieter, kein Schlüssel, keine Zeile Text, die das Gerätehaus verlässt. Es gibt keinen Rückfall auf einen Cloud-Dienst: fehlen die Container, fehlt die Funktion. Das ist die einzige Bauart, bei der der Satz „die Daten bleiben im Haus" auch dann noch stimmt, wenn der eigene Server aus ist.

Ohne Modelle geht alles andere weiter

Die wichtigste Eigenschaft der Phase, und die am meisten Arbeit gemacht hat: FLORIAN bleibt ohne Sprachmodell vollständig benutzbar. Wiki lesen und schreiben, Wortsuche, Belege ablegen und von Hand ausfüllen, beide Protokolle — alles läuft. Wo etwas fehlt, steht ein Satz, der sagt was fehlt und wie es ohne geht, statt eines kaputten Knopfes.

Das ist kein Zugeständnis, sondern der Normalfall: eine Wehr setzt FLORIAN auf, lange bevor sie über eine Grafikkarte nachdenkt.

Das Wiki

Dienstvorschriften, Ausbildungsunterlagen, Merkblätter — das, was sonst als PDF im Ordner steht und im Einsatz niemand findet. Inhalt ist Markdown, weil es sich ohne Werkzeug lesen lässt, auch in zwanzig Jahren, auch ohne FLORIAN.

Zwei Suchen nebeneinander, und beide werden gebraucht:

Suche Findet Braucht ein Modell
Wortsuche was dasteht — „FwDV 7" findet die Seite mit dieser Zeichenfolge nein
Frage was gemeint ist — „wie lange hält ein Schlauch" findet den Abschnitt über Ablegereife ja

Die Frage wird ausschließlich aus den eigenen Seiten beantwortet, mit Fundstellen darunter. Steht die Antwort nicht drin, sagt FLORIAN genau das. Ein Modell, das auf die Frage nach der Prüffrist von Steckleitern eine Zahl nennt, die nirgends steht, wäre gefährlicher als gar keine Antwort — deshalb wird ohne passende Auszüge gar nicht erst gefragt.

Zerlegt wird an den Überschriften, nicht nach Zeichenzahl: eine Fundstelle soll ein Kapitel sein, kein aus der Mitte gerissener Absatz. Und ein kurzer Abschnitt wird nie unter eine fremde Überschrift gehängt — sonst zitierte die Antwort eine Fundstelle, in der der Satz gar nicht steht.

Jede Änderung schreibt die vorherige Fassung weg. Eine Dienstvorschrift, die sich unbemerkt ändert, ist schlimmer als keine.

Diktat

Der Weg, den es in jeder Wehr schon gibt: der Einsatzleiter spricht auf der Rückfahrt in sein Telefon, und daraus wird abends ein Bericht. Neu ist nur, dass abends schon ein Text dasteht.

Ein Knopf, keine Einstellungen. Die Aufnahme bleibt abgelegt, solange über den Text nicht entschieden ist — wer dem Transkript nicht traut, hört nach. Und das Modell darf nichts verbessern: „Hohlstrahlrohr" bleibt „Hohlstrahlrohr", auch wenn es das Wort nicht kennt.

Berichtsassistent

Stichpunkte hinein, Entwurf heraus. Was das Modell nicht bekommt, ist ebenso wichtig: Zeitstempel und Einsatznummer stehen schon als Felder im Bericht und stünden sonst zweimal da, beim zweiten Mal womöglich falsch.

Der Entwurf landet nicht im Berichtsfeld. Er steht daneben, gekennzeichnet, mit zwei gleich großen Knöpfen — „Übernehmen" ist nicht der Standardweg und sieht auch nicht so aus.

Belegerfassung

Quittung fotografieren, Angaben prüfen, gegenzeichnen. Alle Felder sind änderbar, und was das Modell nicht erkannt hat, bleibt leer und gelb hinterlegt statt geraten zu werden. Ein leeres Feld ist eine Aufforderung; ein geratener Betrag ist eine falsche Buchung, die niemand bemerkt.

Die Buchung daraus kommt mit der Kasse in Phase 9 — hier entsteht erst einmal ein sauber erfasster Beleg mit Foto.

Zwei Protokolle nebeneinander

Ansicht Beantwortet
KI-Protokoll Was hat die Maschine getan? Jede Befragung, jedes Modell, jede Entscheidung mit Namen und Zeitpunkt.
Protokoll (Audit-Log) Was haben Menschen getan? Wird seit Phase 0 geschrieben; die Ansicht dazu kam jetzt.

Eingabe, Ausgabe, Modell und Art eines KI-Vorgangs sind nach dem Anlegen unveränderlich — ein Trigger hält dagegen. Löschen ist der Anwendungsrolle entzogen, und über einen Vorschlag wird genau einmal entschieden. Ohne das ließe sich in einem Jahr nicht mehr sagen, welcher Satz in einem Einsatzbericht von einem Menschen stammt.

Einrichtung

Postgres braucht seit dieser Phase pgvector neben PostGIS. Es gibt kein offizielles Image mit beidem, deshalb baut FLORIAN eines (docker/postgres/Dockerfile):

docker compose build postgres        # bei einer bestehenden Installation
docker compose up -d postgres        # Datenverzeichnis bleibt unberührt
npm run db:migrate

Dann die Modelle:

docker compose --profile ki up -d

docker compose exec ollama ollama pull qwen2.5:14b       # Text
docker compose exec ollama ollama pull nomic-embed-text  # Einbettungen
docker compose exec ollama ollama pull llama3.2-vision   # Belege (optional)

Auf einem Rechner ohne Grafikkarte läuft das Einbettungsmodell noch flott, das Textmodell zäh und das Vision-Modell praktisch nicht. Deshalb sind alle drei einzeln konfigurierbar: eine Wehr kann das Wiki durchsuchbar machen, ohne Berichte schreiben zu lassen.

Für eine Nvidia-Karte in die docker-compose.override.yml:

services:
  ollama:
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

Ein anderes Einbettungsmodell

Die Spalte wikiabschnitt.einbettung ist auf 768 Dimensionen festgelegt — das liefert nomic-embed-text. Ein Modell mit anderer Länge braucht eine Migration:

ALTER TABLE "wikiabschnitt" DROP COLUMN "einbettung";
ALTER TABLE "wikiabschnitt" ADD COLUMN "einbettung" vector(1024);
CREATE INDEX "wikiabschnitt_einbettung_idx"
    ON "wikiabschnitt" USING hnsw ("einbettung" vector_cosine_ops);

Danach LLM_EINBETTUNG_DIMENSIONEN anpassen und alle Seiten neu einbetten. Der nächtliche Lauf holt das von selbst nach; wer nicht warten will, drückt auf jeder Seite „jetzt nachziehen".

Ohne Anpassung weist FLORIAN das Modell mit einer klaren Meldung ab, statt eine Fehlermeldung der Datenbank durchzureichen, die niemand versteht.

Zusätzliche Umgebungsvariablen

Variable Vorgabe Bedeutung
LLM_ENABLED false Schaltet alle Textfunktionen ein
LLM_BASE_URL — OpenAI-kompatibler Endpunkt, z. B. http://ollama:11434/v1
LLM_MODELL — Modell für Berichte und Antworten
LLM_TIMEOUT 120 Zeitlimit in Sekunden
LLM_EINBETTUNG_MODELL nomic-embed-text Modell für die semantische Suche
LLM_EINBETTUNG_DIMENSIONEN 768 Muss zur Spalte passen
LLM_VISION_MODELL — Modell für die Belegerfassung
WHISPER_ENABLED false Schaltet das Diktat ein
WHISPER_URL — z. B. http://whisper:8000
WHISPER_MODELL base tiny, base, small
DIKTAT_MAX_SEKUNDEN 600 Höchstlänge einer Aufnahme
OLLAMA_PORT 11435 Port des Ollama-Containers (Profil ki)
WHISPER_PORT 8087 Port des Whisper-Containers (Profil ki)

Phase 9 — Kassenverwaltung

Kassenbuch, Mitgliedsbeiträge, Rechnungen an Dritte, Kassenprüfung und die Zahlen für die Jahreshauptversammlung.

Eine Feuerwehrkasse wird von einem Ehrenamtlichen geführt und von zwei anderen geprüft. Was die Prüfung wert ist, hängt einzig daran, dass sich das Geprüfte hinterher nicht mehr ändern lässt. Deshalb ist der zentrale Punkt dieser Phase kein Formular, sondern eine Sperre.

Drei Festlegungen

Eine festgeschriebene Buchung ist unveränderbar. Nicht, weil ein Zweig im Router es so vorsieht, sondern weil die Datenbank es ablehnt — ein vergessener Zustandscheck, ein Skript, ein Import würden sonst reichen. Korrigiert wird über eine Gegenbuchung mit Verweis: beide Buchungen bleiben stehen, und im Kassenbuch ist zu sehen, dass etwas berichtigt wurde. Genau das will ein Kassenprüfer sehen; eine verschwundene Buchung kann er nicht prüfen.

Der Kassenabschluss schließt seinen Zeitraum. Danach wird keine Buchung mit einem Datum darin mehr angenommen — auch keine nachträglich hineindatierte. Ohne diese zweite Hälfte stimmte der geprüfte Bestand nach einem Datumswechsel nicht mehr, und niemand hätte es bemerkt. Wer nach der Prüfung noch eine Quittung findet, bucht sie mit dem heutigen Datum.

Beträge sind DECIMAL(12,2), gerechnet wird mit Prisma.Decimal. 0.1 + 0.2 ergibt in JavaScript 0.30000000000000004. In einer Kasse ist das kein Rundungsfehler, sondern eine Differenz in der Prüfung, die niemand erklären kann.

Das Kassenbuch

Drei Kontenarten: Barkasse, Bankkonto, Rücklage. Der Bestand wird gerechnet, nie gespeichert — Anfangsbestand plus die Wirkung aller Buchungen. Ein gespeicherter Bestand neben einer nachgetragenen Buchung wäre falsch, ohne falsch auszusehen, und genau danach sucht ein Kassenprüfer.

Der Betrag einer Buchung ist immer positiv; die Richtung steht daneben. Eine negative Ausgabe wäre eine Einnahme in Verkleidung und in keiner Auswertung als solche zu erkennen — die Datenbank nimmt sie deshalb nicht an.

Die Buchungsmaske schlägt eine Kategorie vor: zuerst aus den eigenen früheren Buchungen der Wehr, erst danach aus einer kurzen Stichwortliste. Der Nutzen liegt nicht in den gesparten Sekunden, sondern darin, dass dieselbe Sache immer unter derselben Kategorie landet — erst das macht die Jahresauswertung brauchbar. Unter der Hälfte gemeinsamer Wörter schweigt der Vorschlag: ein falscher wird übernommen, ohne gelesen zu werden.

Ein gegengezeichneter Beleg aus Phase 8 wird mit einem Klick zur Buchung: Konto und Kategorie wählen, fertig. Betrag, Datum und Händler stehen schon fest, und das Foto hängt danach an der Buchung — zu jeder Ausgabe ein Beleg, an der Buchung und nicht in einem Ordner daneben. Ein Entwurf lässt sich nicht buchen: er ist ein von einem Modell ausgefülltes Formular.

Kontoauszug einlesen

Das Online-Banking bietet beides zum Herunterladen an, meist unter „Umsatzexport“:

Format Datei Was gelesen wird
CAMT.053 .xml, .camt Datum, Richtung, Betrag, Verwendungszweck, Gegenseite, Bankkennung
MT940 .txt, .sta dasselbe, aus den Feldern :61: und :86:

Erkannt wird am Inhalt, nicht an der Dateiendung. Es entstehen Entwürfe, keine fertigen Buchungen — durchsehen muss sie der Kassier.

Jeder Umsatz trägt eine Kennung: die der Bank, sonst eine aus Datum, Betrag und Zweck gebildete. Derselbe Auszug zweimal eingelesen wird dadurch als solcher erkannt und in der Liste als „steht schon in der Kasse“ ausgewiesen. Ohne das wäre es ein doppelt gebuchter Monat, und der fiele erst beim Kassenabschluss auf.

Was nicht gelesen werden konnte, steht mit Grund daneben. Ein Parser, der stillschweigend drei Umsätze verschluckt, ist schlimmer als einer, der gar nicht erst liest.

Mitgliedsbeiträge

Sätze gelten ab einem Jahr und je Abteilung; ein Satz ohne Abteilung gilt für alle übrigen. Rückwirkend geändert wird nichts — sonst stimmten die Beiträge vergangener Jahre nicht mehr, und die sind bereits geprüft.

Der Beitragslauf zeigt vorher, was entstehen wird: wie viele Beiträge, welche Summe, und wer keinen passenden Satz hat. Der letzte Teil ist der interessante — das sind die Leute, für die niemand entschieden hat, was sie zahlen. Der Lauf ist wiederholbar; im Februar noch einmal gestartet legt er nur die Neuen an.

Ein Zahlungseingang erzeugt zugleich die Buchung. Ein Häkchen ohne Geld in der Kasse wäre eine Behauptung. Ein Erlass braucht eine Begründung — die Datenbank lehnt einen ohne ab, weil er in der Kassenprüfung sonst eine offene Frage ist.

Die Rückstandsliste geht über alle Jahre. Sie ist die einzige Ansicht, in der auffällt, wer seit drei Jahren offen steht.

Rechnungen an Dritte

Kostenersatz nach Art. 28 BayFwG, Vermietungen, Veranstaltungen. Die Nummer vergibt FLORIAN fortlaufend je Jahr und lückenlos: eine fehlende Nummer muss in der Kassenprüfung erklärt werden.

Der Betrag ist die Summe der Positionen, gerechnet und nicht eingegeben. Ein eingetippter Gesamtbetrag neben Positionen, die etwas anderes ergeben, ist der Fehler, den niemand bemerkt, bis der Empfänger anruft. Die Menge hat drei Nachkommastellen — Einsatzstunden werden viertelstündlich abgerechnet, und 0,25 × 47,50 muss auf den Cent stimmen.

Bis zum Versenden ist eine Rechnung ein Entwurf, danach unveränderbar: was beim Empfänger liegt, muss dem entsprechen, was hier steht. Korrigiert wird über Storno und eine neue Rechnung; die stornierte bleibt mit Grund stehen.

Kassenprüfung

In der Prüfmaske steht links der Bestand laut Buch und rechts der gezählte. In dieser Reihenfolge, und die gezählten Felder fangen leer an — wer erst das Buch sieht und dann zählt, zählt, bis es passt.

Ohne Prüfer und Prüfdatum lässt sich nichts festschreiben: ohne beides ist es kein Abschluss, sondern eine Notiz. Eine Differenz braucht eine Erläuterung, sonst steht in fünf Jahren „stimmt nicht“ im Bericht und niemand weiß mehr, warum.

Der Kassenprüfbericht entsteht als PDF mit vier Unterschriftsfeldern und wird im Hintergrund erzeugt.

Auswertungen

Einnahmen und Ausgaben nach Kategorie und Monat, mit dem Vorjahr daneben — eine Zahl ohne Vergleich sagt nichts. Der Export ist eine CSV-Datei mit Semikolon und BOM: das ist, was Excel in Deutschland ohne Rückfrage richtig öffnet. Eine Datei, die beim Doppelklick als eine Spalte voller Semikolons erscheint, wird zurückgeschickt.

Wer was sieht

Recht Wer Was
kasse.lesen Kassier, Kommandant, Vorstand Buchungen und Auswertungen ansehen
kasse.bearbeiten Kassier Buchungen, Beiträge, Rechnungen erfassen
kasse.abschluss Kassier, Kassenprüfer Kassenabschluss anlegen und festschreiben

Der Gerätewart sieht keine Buchungen, der Kassier keine Atemschutzdaten. Die Trennung liegt in lib/authz.ts, nicht in den Komponenten.

Keine zusätzlichen Umgebungsvariablen. Die Kasse braucht nur, was ohnehin läuft: Postgres, MinIO für die Belege und Gotenberg für die PDFs.


Phase 10 — Mandantenfähigkeit

Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehörigkeiten. Eine Kreisebene, die zusieht.

Datenmodell und Row Level Security tragen seit Phase 0 mehrere Wehren — jede Tabelle hat organisationId, jede Abfrage läuft im Mandantenkontext. Was fehlte, war alles darüber: eine Anmeldung, die zu mehr als einer Wehr gehören kann, ein Weg zum Umschalten, und eine Verwaltung für den, der den Server betreibt.

Konto und Akte sind nicht dasselbe

Bis Phase 9 war die Personenakte zugleich das Anmeldekonto: Passwort-Hash und OIDC-Subject hingen an person, und person gehört genau einer Wehr. Daraus folgten zwei Dinge, die niemand wollte:

  • Wer in zwei Wehren geführt wurde, brauchte zwei Passwörter, die auseinanderlaufen können.
  • Die Anmeldung per E-Mail war mehrdeutig, sobald dieselbe Adresse in zwei Wehren stand — die Eindeutigkeit galt nur je Organisation, und gefunden wurde, was die Datenbank zuerst hergab.

Seit Phase 10:

Tabelle Was sie ist Mandant
Benutzer das Anmeldekonto — E-Mail, Passwort, OIDC keiner
Mitgliedschaft Zugehörigkeit eines Kontos zu einer Wehr organisationId
Person die Akte in einer Wehr — Dienstgrad, Qualifikationen, Einsätze organisationId

Benutzer trägt bewusst keine Mandantenkennung und kann keine tragen: sie ist ja der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. RLS greift dort deshalb nicht. Geschützt wird sie stattdessen über Rechte — dieselbe Lösung wie bei Berechtigung aus Phase 0 und aus demselben Grund: eine Tabelle, die über allen Mandanten steht, gehört nicht in die Hand der Anwendungsrolle. Was ein Konto in einer Wehr darf, entscheidet nicht es selbst, sondern seine Mitgliedschaft.

Ein angenehmer Nebeneffekt: der Passwort-Hash kann aus einer Personenabfrage gar nicht mehr herausfallen. Die drei Stellen, die ihn bisher vorsichtshalber abgeschnitten haben, sind entfallen — eine Sorgfaltsmaßnahme ist durch eine Struktur ersetzt.

Die Wehr wechseln

Der Wähler steht in der Seitenleiste unter dem Namen der Wehr — aber nur, wenn es etwas zu wählen gibt. Für die allermeisten gibt es das nie, und ein Auswahlfeld mit genau einem Eintrag ist keine Hilfe, sondern eine Frage ohne Antwortmöglichkeit.

Nach der Anmeldung öffnet die eigene Wehr, nicht die betreute: wer eine Wehr führt und acht andere beaufsichtigt, will in seiner landen.

Technisch: die Wehr steht im Sitzungstoken, also wird beim Wechsel ein neues ausgestellt. Was der Browser dabei schickt, ist ein Wunsch — geprüft wird serverseitig gegen die Mitgliedschaften des Kontos. Eine fremde Kennung zu nennen bringt nichts, weil es dazu keine Mitgliedschaft gibt.

Die Kreisebene

Kreisbrandrat und Kreisbrandinspektion gehören den betreuten Wehren nicht an. Sie bekommen deshalb dort keine Personenakte, tauchen in keiner Mitgliederliste auf und können nichts ändern.

Ihre Rechte werden nicht aufgezählt, sondern gefiltert: alles, was .lesen heißt, und sonst nichts. Eine von Hand gepflegte Liste wäre bei der nächsten Phase veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte mit, die niemand nachträgt. So bekommt die Aufsicht neue Leserechte automatisch und neue Schreibrechte nie.

Vier Rechte sind ausdrücklich ausgenommen, obwohl sie auf .lesen enden:

Recht Warum nicht
kasse.lesen Die Kasse gehört dem Verein oder der Kommune, nicht der Kreisaufsicht.
person.export Das Auskunftsrecht nach DSGVO erfüllt die Wehr, die die Daten führt.
system.auditlog Wer wann was getan hat, bleibt innerhalb der Wehr.
ki.protokoll Dasselbe.

Der Atemschutz steht dagegen bewusst drin: ob ein Träger tauglich ist, ist genau die Frage, für die es eine Kreisebene gibt.

Wer als Aufsicht unterwegs ist, sieht dauerhaft einen Hinweis darauf — nicht als kurz aufblitzende Meldung, sondern als Zeile über der Ansicht. Man soll nicht erst merken, in welcher Rolle man ist, wenn ein Knopf fehlt.

Doppelmitgliedschaft

Ein Konto, mehrere Zugehörigkeiten. In Bayern der Regelfall bei allem, was über die eigene Wehr hinausgeht: aktiv in A, Ehrenmitglied in B, oder Kommandant der einen und Kreisbrandinspektor für sechs weitere.

Zwei Zusicherungen setzt die Datenbank durch:

  • Ein Konto gehört einer Wehr höchstens einmal an.
  • Eine Personenakte hängt an höchstens einem Konto. Sonst wäre nicht entscheidbar, wer unter ihrem Namen gehandelt hat.

Und eine dritte, die weniger offensichtlich ist: die Akte muss zu derselben Wehr gehören wie die Zuordnung. Ein Fremdschlüssel prüft nur, dass es die Akte gibt, nicht wo sie liegt — ohne diese Prüfung ließe sich ein Konto über eine Akte aus Wehr A in Wehr B hängen, und die Sitzung stünde dort mit einer Personenkennung da, die niemandem gehört. Ein Trigger lehnt das ab.

Wehren und Konten verwalten

Unter /wehren, und nur für den, der die Installation betreibt.

Der Zugang hängt an einem Merkmal des Kontos (istVerwalter), nicht an einem Recht. Rechte gelten je Wehr; wer Wehren anlegt, steht über allen. Ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre genau die Hintertür, die diese Phase vermeiden soll.

Was dort geht:

  • Eine Wehr anlegen — sie bekommt sofort ihre Systemrollen samt Rechten. Ein Konto bekommt sie nicht: eine Wehr anzulegen und jemanden hineinzusetzen sind zwei Entscheidungen, und die zweite trifft meist jemand anders.
  • Ein Konto anlegen — ohne Wehr. Die kommt über die Zuordnung.
  • Zuordnen: als Mitglied mit Personenakte oder als Kreisaufsicht ohne.

Jede Zuordnung wird in der betroffenen Wehr protokolliert, nicht beim Verwalter. Sonst hätte der Betreiber einen Weg, sich still in fremde Daten zu setzen.

Keine zusätzlichen Umgebungsvariablen.

Was bewusst fehlt

Wehrübergreifende Auswertungen. Eine Zahl über mehrere Wehren hinweg braucht eine abgestimmte Definition — was zählt als Einsatz, was als Übung, wie wird eine Doppelmitgliedschaft gezählt. Ohne die ist eine solche Zahl schlimmer als keine, weil sie benutzt wird. Die Aufsicht sieht jede Wehr einzeln, und das reicht für die Fragen, die tatsächlich gestellt werden.

Ein Einladungsverfahren. Passwörter werden vom Verwalter vergeben und weitergegeben. FLORIAN verschickt keine.


Phase 11 — Freigabereife

Die drei Dinge, die zwischen dem letzten Modul und dem Echtbetrieb standen: das Auskunftsrecht, ein geprüftes Sicherungskonzept und eine Pipeline.

Auskunft nach Art. 15 DSGVO

Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet werden. In einer Feuerwehr ist das mehr, als die meisten erwarten — und mit den G26-Untersuchungen sind Gesundheitsdaten dabei, also eine besondere Kategorie nach Art. 9.

Auf der Seite eines Mitglieds steht dafür der Knopf Datenauskunft (Recht person.export). Ein Durchgang liefert drei Dinge:

Wofür
Anzeige Was ausgehändigt wird, vor dem Aushändigen
PDF Das Schreiben an die Person, für die Akte
JSON Datenübertragbarkeit nach Art. 20

Bewusst nicht drei Knöpfe: wer eine Auskunft erteilt, soll sie vollständig erteilen und nicht in Schritten, von denen zwei vergessen werden können.

Über jedem Abschnitt steht, wozu die Daten vorliegen. Art. 15 Abs. 1 verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger, Speicherdauer und den Hinweis auf die weiteren Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und ist keine.

Zwei Festlegungen bestimmen den Zuschnitt:

Was über die Person gespeichert ist, steht vollständig drin — auch das Unangenehme: eine nicht bestandene G26, ein gemahnter Beitrag. Eine Auskunft, die auswählt, was gut aussieht, ist keine.

Was die Person getan hat, steht als Zahl drin. Wer zwölf Prüfprotokolle unterschrieben hat, bekommt die Zahl — nicht zwölf Protokolle. Die gehören zum Nachweisbestand der Wehr; sie mitzuliefern hieße, dass jeder über eine Selbstauskunft an die Geräteakten käme. Die Trennlinie ist nicht „viel oder wenig", sondern „über mich" gegen „von mir".

Leere Abschnitte bleiben stehen. „Dazu liegt nichts vor" ist eine Auskunft; ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas ist.

Der Vorgang wird protokolliert — dass eine Auskunft erteilt wurde, gehört festgehalten.

Sicherung

npm run sicherung                      # nach ./sicherungen/<Zeitstempel>/
npm run sicherung -- --ziel D:\backups

Gesichert wird beides, aus demselben Moment: die Datenbank über pg_dump im Container und alle Dateien aus MinIO. Eine Datenbank von heute mit Dateien von gestern ergibt Datensätze, die auf nichts zeigen.

Daneben entsteht ein manifest.json mit Zeitpunkt, Version und Umfang. Es wird beim Zurückspielen angezeigt, damit niemand versehentlich eine Sicherung von vor drei Monaten einspielt, weil das Verzeichnis ähnlich hieß.

Wiederherstellung

npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-16-48
npm run wiederherstellen -- --quelle ... --wirklich

Ohne --wirklich passiert nichts — es wird nur angezeigt, was geschähe. Das ist keine Bequemlichkeit: das Skript überschreibt den gesamten Bestand.

Der eigentliche Zweck ist nicht der Notfall, sondern die Probe. Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung: der Ordner kann leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es an dem Tag, an dem man es nicht erfahren will.

Wie berechtigt das ist, hat sich hier gezeigt. Der erste Rückspielversuch lief sauber durch — und hatte dabei sämtliche Unveränderlichkeitsgarantien der Datenbank entfernt. Der Grund liegt tiefer, als man vermutet: docker/postgres/10-init-app-role.sh richtet eine Vorgabeprivilegierung ein, damit neue Tabellen aus Migrationen automatisch nutzbar sind. pg_restore legt die Tabellen aber ebenfalls neu an — die Vorgabe greift also auch dort, und die GRANT-Zeilen aus dem Dump können nur hinzufügen, nie wegnehmen. Die Anwendungsrolle durfte danach das Audit-Log ändern, Wiki-Fassungen löschen und Kiosk-Vorgänge umschreiben. Nichts an der Anwendung hätte darauf hingewiesen.

Deshalb setzt das Skript die Sperren nach dem Zurückspielen neu und prüft danach nach, ob es gewirkt hat. Die Liste dafür steht in scripts/sicherung-sperren.ts, und ein Test gleicht sie gegen die migrierte Datenbank ab: läuft sie auseinander, käme eine Sperre nach einer Wiederherstellung nicht zurück, von der niemand mehr weiß, dass es sie gab.

Nach dem Zurückspielen bleiben drei Handgriffe, die keine Software abnehmen kann:

  1. Anmelden — kommt jemand hinein?
  2. Ein Prüfprotokoll öffnen — ist das PDF da?
  3. Die Mitgliederzahl mit der Erwartung vergleichen.

Erst danach gilt die Sicherung als geprüft.

Die Pipeline

.gitea/workflows/pruefung.yml, zwei Aufträge:

Auftrag Was Wann
pruefung typecheck, lint, Migrationen, Seed, 599 Unit-Tests jeder Push und Pull Request
oberflaeche die E2E-Tests über Chromium nur main und Pull Requests

Die Trennung ist eine Zeitfrage: der erste Auftrag dauert Minuten, der zweite eine Dreiviertelstunde.

Die Datenbank wird aus docker/postgres gebaut statt aus einem fertigen Image genommen. FLORIAN braucht PostGIS und pgvector, und kein öffentliches Image bringt beides mit; ein Dienst-Container mit postgres:17 ließe die Migrationen durchlaufen und scheiterte erst bei der ersten Umkreissuche. Der Seed läuft mit, weil er selbst ein Test ist: fällt ein Standardwert um, merkt man es hier und nicht, wenn eine Wehr ihn zum ersten Mal ausführt.

Voraussetzung: ein registrierter Runner. Die Datei allein genügt nicht — Gitea braucht einen act_runner, der die Aufträge annimmt. Ist keiner eingerichtet, erscheinen die Läufe als „wartend" und laufen nie. Zum Zeitpunkt dieser Phase war noch keiner registriert; der Ablauf ist also geschrieben, aber nicht im Betrieb erprobt.


Befehle

npm run sicherung          # Datenbank und Dateien sichern
npm run wiederherstellen   # zurueckspielen (Probelauf ohne --wirklich)
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 (vorher: npm run db:test-reset)
npm run lint             # ESLint
npm run lint:fix         # ESLint mit automatischen Korrekturen
npm run typecheck        # TypeScript ohne Ausgabe
npm run db:migrate       # ausstehende Migrationen anwenden
npm run db:seed          # Beispielwehr einspielen
npm run db:test-reset    # Testwehr für die E2E-Tests neu aufbauen
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

Tests

npm run test             # Vitest — Unit und Datenbankregeln
npm run db:test-reset    # einmalig vor dem E2E-Lauf
npm run test:e2e         # Playwright

Zwei Wehren in der Datenbank

Die E2E-Tests laufen gegen eine eigene Wehr (ff-teststelle), nicht gegen die Beispielwehr aus der Anleitung. Der Grund ist ein handfester: bis Phase 8 schrieben sie in ff-musterhausen, und nach fünf Phasen standen dort 120 Atemschutz-Überwachungen statt einer und 115 Geräte statt 45. Zweimal fielen Tests aus, weil ein gesuchter Eintrag von Seite 1 gerutscht war — und bei jedem Lauf unterschieden sich alle 72 Bildschirmfotos.

npm run db:test-reset löscht die Testwehr und legt sie neu an, mit denselben Beispieldaten. Die Beispielwehr wird dabei nicht angefasst; ein Sicherungsriegel im Skript bricht ab, wenn beide Kennungen übereinstimmen.

Anmeldung der Testwehr: e2e@teststelle.de / Feuerwehr2026, änderbar über E2E_ADMIN_EMAIL und E2E_ADMIN_PASSWORT.

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
ntfy (Profil push) http://localhost:8086
Ollama (Profil ki) http://localhost:11435
faster-whisper (Profil ki) http://localhost:8087
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.

Aktualisieren

./update.sh --pruefen   # nachsehen, ob es etwas Neues gibt
./update.sh             # einspielen

Der Ablauf ist so gebaut, dass er an jeder Stelle abbrechen darf, ohne eine halb aktualisierte Anlage zu hinterlassen:

  1. Nachsehen, ob überhaupt etwas Neues da ist — sonst endet es hier.
  2. Sichern. Vor allem anderen; ein Update ist der häufigste Anlass, eine Sicherung zu brauchen.
  3. Neue Fassung holen und die Abbilder bauen. Schlägt das fehl, läuft die alte Fassung unverändert weiter — es wurde noch nichts umgeschaltet.
  4. Umschalten. Der App-Container spielt dabei die Migrationen ein.
  5. Nachsehen, ob die Anwendung wieder antwortet. Tut sie es nach zweieinhalb Minuten nicht, stehen die letzten Protokollzeilen da und die Befehle, mit denen der vorherige Stand zurückkommt.

Vorher prüft das Skript, dass Docker, git und die .env da sind und dass im Verzeichnis keine unverbuchten Änderungen liegen. --ohne-sicherung gibt es für Testinstallationen; im Betrieb gehört es nicht benutzt.

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, ntfy, Web-Push) sitzen hinter Schnittstellen in src/lib/integrations/ und sind einzeln abschaltbar. Ohne Freischaltung wird nur ins Log geschrieben.
  • Die KI-Funktionen laufen ausschließlich gegen selbst betriebene Modelle. Es gibt keinen Rückfall auf einen Anbieter: fehlt der eigene Endpunkt, fehlt die Funktion. Kein Text verlässt das Gerätehaus.
  • Jede KI-Ausgabe ist gekennzeichnet, wird als eigener Datensatz festgehalten und muss von einem Menschen übernommen werden, bevor sie in einen Nachweis gelangt. Nachvollziehbar im KI-Protokoll.
  • Ü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

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.