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>
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 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 |
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 gesetztesapp.current_organisationliefert 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:
UPDATEist per Trigger immer gesperrt.DELETEnur 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:
- 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, Scopesopenid profile email. - E-Mail und Passwort — Rückfalloption für den Erststart und für den Fall,
dass authentik nicht erreichbar ist. Über
AUTH_LOCAL_ENABLEDabschaltbar. - Terminal (PIN oder RFID) — für das Kiosk-Gerät im Gerätehaus. Zwei
Schranken: erst weist sich das Gerät mit
KIOSK_DEVICE_SECRETaus, 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:
- Zufall — 32 zufällige Bytes im Link.
- Nur ein Hash in der Datenbank — wer sie liest, bekommt keinen gültigen Link.
- Zeitlich begrenzt — Vorgabe 24 Stunden, jederzeit widerrufbar, jeder Aufruf wird mitgezählt.
- 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
-
Einmal einrichten —
/kiosk/einrichtenverlangt 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=… -
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.
-
Erfassen — Mangel melden, Kommen/Gehen, Fahrt beginnen und beenden, Wäsche abgeben, Inventurgang.
-
Von selbst abmelden — nach
KIOSK_IDLE_TIMEOUTSekunden 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:
- QR-Code am Gerät scannen — oder die Inventarnummer eintippen.
- Foto aufnehmen und einen Satz dazu.
- 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
- Eröffnen — Bezeichnung genügt. Die Zuordnung zu einem Einsatz lässt sich nachtragen; im Ernstfall wird zuerst überwacht.
- 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.
- Einstieg — ab hier läuft die Uhr.
- Druckabfrage — für den ganzen Trupp in einer Zeile. Nach
ATEMSCHUTZ_ABFRAGE_INTERVALLSekunden ohne Meldung wird erinnert; gerechnet ab der letzten Meldung, nicht ab dem Einstieg. - Rückweg und heraus — beim Herauskommen die Ausgangsdrücke.
- 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.
EventSourcebringt 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
EventSourceeinfach.
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.
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
npm run vapid # VAPID-Schlüsselpaar für Web-Push 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, 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
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.