Files
FireAdministration/README.md
T
Florian WildeandClaude Opus 5 f7ccd9a908 Phase 8: Lokale KI
Wiki mit semantischer Suche ueber pgvector, Diktat ueber faster-whisper,
Berichtsassistent und Belegerfassung — alles gegen selbst betriebene
Modelle. Dazu zwei Protokollansichten: was die Maschine getan hat und was
Menschen getan haben.

Nichts, was ein Modell erzeugt, gelangt ungeprueft in einen Nachweis. Jede
Ausgabe ist zuerst ein eigener Datensatz, gekennzeichnet, mit zwei gleich
grossen Knoepfen davor. Die Datenbank haelt gegen: Eingabe, Ausgabe und
Modell sind unveraenderlich, geloescht wird nicht, entschieden wird einmal.

Kein Rueckfall auf einen Anbieter: fehlen die Container, fehlt die Funktion.
Dafuer bleibt alles andere ohne Modell vollstaendig benutzbar — geprueft von
vierzehn E2E-Tests, die ohne laufendes Modell durchlaufen.

Postgres bringt jetzt PostGIS und pgvector mit (eigenes Image), Ollama und
faster-whisper stehen im Compose-Profil "ki".

73 neue Unit-Tests, 14 neue E2E-Tests, Version 0.9.0.

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

1232 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FLORIAN — Feuerwehr-Verwaltungsplattform
Selbstgehostete Verwaltungssoftware für Freiwillige Feuerwehren. Läuft im
Gerätehaus, nicht in der Cloud. Keine Telemetrie, keine externen CDNs.
## Schnellstart
```bash
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 | offen |
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md).
## Weiterführende Dokumentation
| Datei | Inhalt |
|---|---|
| [docs/ANLEITUNG.md](docs/ANLEITUNG.md) | Bedienung mit Bildschirmfotos |
| [docs/ROADMAP.md](docs/ROADMAP.md) | Phasenplan und was jeweils entsteht |
| [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md) | Aufwand je Phase, Schätzung gegen Ist |
| [docs/DEVLOG.md](docs/DEVLOG.md) | Entwicklungstagebuch mit Zeiterfassung |
| [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md) | Versions- und Branch-Regeln |
| [CHANGELOG.md](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`](src/lib/db.ts):
```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`](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.
```ts
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`](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`](src/lib/wartung/bewertung.ts)):
- ein Messwert außerhalb des zulässigen Bereichs gilt als beanstandet,
- ein beanstandeter **Pflichtpunkt** bedeutet „nicht bestanden" und sperrt das
Gerät,
- ein beanstandeter Kann-Punkt bedeutet „bestanden mit Mängeln".
Das Ergebnis ist ein Vorschlag; die Entscheidung trifft der Prüfer, denn er
trägt die Verantwortung.
### Prüfprotokoll als PDF
Erzeugt der Worker über Gotenberg
([`src/lib/wartung/protokoll.ts`](src/lib/wartung/protokoll.ts)) und legt es in
MinIO ab. Kopf mit Wehr, Gerät und Prüfer-Kennung, die Checkliste mit Soll, Ist
und Befund, eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift,
Prüfdatum und Seitenzahl.
Alles ist eingebettet — keine externen Schriften, keine nachgeladenen Bilder.
Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute.
Die laufende Protokollnummer (`JJJJ-NNNN`) wird je Wehr und Jahr unter einer
Transaktionssperre vergeben; zwei gleichzeitig abschließende Prüfer bekommen
verschiedene Nummern.
### Mängel und Rückrufe
Der Mängelweg ist immer derselbe: melden → zugewiesen → in Arbeit → behoben.
**Melden darf jedes Mitglied** — der Mangel fällt dem auf, der das Gerät
benutzt, nicht dem Gerätewart am Schreibtisch. Ein kritischer Mangel sperrt das
Gerät sofort; beim Beheben wird die Sperre wieder aufgehoben, aber nur, wenn
kein anderer offener Mangel dasselbe Gerät sperrt.
Bei einem Rückruf sucht FLORIAN anhand von Bauart, Modell, Seriennummernbereich
und Herstellungszeitraum nach betroffenen Geräten. Das Ergebnis ist ein
**Vorschlag**, keine automatische Sperre: eine Chargenangabe des Herstellers
passt selten eins zu eins auf die Seriennummern im eigenen Bestand.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `WARTUNG_NACHTLAUF` | `15 3 * * *` | wann Termine geplant werden (cron, Europe/Berlin) |
| `WARTUNG_ERINNERUNG_LAUF` | `45 3 * * *` | wann Erinnerungen verschickt werden |
| `WARTUNG_PLANUNGSHORIZONT_TAGE` | `180` | wie weit im Voraus Termine angelegt werden |
---
## Phase 3 — Einsätze und Übungen
Was die Wehr tut, wird hier dokumentiert — für die Gemeinde, für die
Versicherung und für die eigene Mannschaft.
### Der Weg durch das Modul
```
Schnellerfassung Stichwort, Ort, Fahrzeuge — im Alarmfall in 20 Sekunden
↓ während des Einsatzes
Zeitstempel alarmiert, ausgerückt, eingetroffen, Ende, eingerückt,
einsatzbereit — je ein Knopf
Lagemeldungen aus der Wehr und über das Portal einer Fremdwehr
↓ danach, in Ruhe
Kräfte und Material wer war dabei, was wurde verbraucht
Bericht der Text des Einsatzleiters
↓ Freigabe
Einsatzbericht als PDF, festgeschrieben, an die Gemeinde
```
### Die sechs Zeitstempel
Sie sind der Kern jeder Einsatzauswertung. Aus ihnen ergeben sich drei Zahlen:
| Kennzahl | Rechnung | sagt etwas aus über |
|---|---|---|
| **Ausrückzeit** | Alarm bis Ausrücken | die Wehr selbst |
| **Anfahrtszeit** | Ausrücken bis Eintreffen | Lage und Wegstrecke |
| **Hilfsfrist** | Alarm bis Eintreffen | das, wonach die Gemeinde fragt |
In Bayern gilt als Ziel: **in 80 Prozent der Fälle innerhalb von zehn Minuten**.
Genau dieser Wert steht in der Statistik — nicht der Durchschnitt, denn der
verdeckt die Ausreißer, um die es geht.
### Stärke nach FwDV 3
Aus den Funktionen der erfassten Kräfte errechnet FLORIAN die Stärke in der
üblichen Schreibweise `Führer/Unterführer/Mannschaft/Gesamt`:
| Einheit | Stärke | Besetzung |
|---|---|---|
| Trupp | 0/1/1/2 | Truppführer, Truppmann |
| Staffel | 1/1/4/6 | Gruppenführer, Maschinist, zwei Trupps |
| Gruppe | 1/3/5/9 | Gruppenführer, Maschinist, Melder, drei Trupps |
| Zug | 1/5/12/18 | Zugführer, Melder, Gruppe und Staffel |
**Regional unterschiedlich.** Manche Wehren zählen den Maschinisten zu den
Unterführern, andere schreiben dreistellig ohne Unterführer (Gruppe = 1/8/9).
Die Zuordnung steht an einer Stelle — [`src/lib/einsatz/staerke.ts`](src/lib/einsatz/staerke.ts) —
und ist bewusst leicht zu ändern.
### Der freigegebene Bericht ist unveränderbar
Dieselbe Logik wie beim Prüfprotokoll aus Phase 2, denn der Bericht geht an
die Gemeinde und im Schadensfall an die Versicherung. Bis zur Freigabe ist
alles änderbar — ein Einsatz wird tagelang ergänzt, und genau dafür ist der
Entwurfsstand da. Mit der Freigabe ist Schluss; der Trigger
`einsatz_freigabe_schuetzen` setzt das durch.
Zwei Ausnahmen bleiben, beide nachtragend: das PDF, solange keines hinterlegt
ist, und `geaendertAm`.
**Lagemeldungen sind ausdrücklich ausgenommen.** Sie sind das Protokoll des
Verlaufs, kein Bestandteil des Berichts — und eine Meldung aus dem Portal kann
eintreffen, wenn der Bericht längst geschrieben ist.
### Einsatzportal für Fremdwehren
Bei einer größeren Sache sind Nachbarwehren, THW oder Rettungsdienst mit vor
Ort. Die haben kein Konto in FLORIAN und sollen auch keines bekommen. Der
Einsatzleiter erzeugt stattdessen einen Link, den er als QR-Code zeigt oder
weiterschickt.
Vier Schranken sichern ihn ab:
1. **Zufall** — 32 zufällige Bytes im Link.
2. **Nur ein Hash in der Datenbank** — wer sie liest, bekommt keinen gültigen Link.
3. **Zeitlich begrenzt** — Vorgabe 24 Stunden, jederzeit widerrufbar, jeder
Aufruf wird mitgezählt.
4. **Zugriffsbegrenzung** — 120 Aufrufe je Link und Stunde, 30 Meldungen, 10
Fotos.
Und eine fünfte, die keine technische ist: das Portal zeigt Stichwort, Ort,
Zeitstempel und Lagemeldungen. **Keine Namen von Einsatzkräften, keine
Berichte, kein Weg in den Rest der Anwendung.**
### Adressen zu Koordinaten
Über einen **selbst betriebenen** Nominatim (`NOMINATIM_URL`). Einsatzadressen
sagen, wo es gebrannt hat und wer betroffen war; solche Anfragen gehören nicht
an einen fremden Dienst, und die Nutzungsbedingungen des öffentlichen Nominatim
erlauben es ohnehin nicht.
Ohne Konfiguration bleibt das Feld leer und die Koordinaten werden von Hand
eingetragen — kein Fehler, sondern der Normalfall in einer frischen
Installation. Die Karte dazu kommt in Phase 6.
Gespeichert wird doppelt: als Dezimalgrad, die Prisma lesen kann, und als
PostGIS-Punkt für die Karte. Ein Trigger hält beides zusammen.
### Statistik und Jahresexport
`/einsaetze/statistik` beantwortet beide Fragen: die der Gemeinde (Anzahl nach
Kategorie der Landesstatistik, Hilfsfristen) und die der Wehr (Beteiligung je
Person, Auslastung je Fahrzeug). Der Jahresexport als PDF liegt unter
`/api/einsaetze/jahresexport?jahr=…` und wird bewusst **nicht** abgelegt — er
ist eine Auswertung, keine Urkunde, und ändert sich, solange noch Einsätze
nachgetragen werden.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `GEOCODING_ENABLED` | `false` | Adresssuche einschalten |
| `NOMINATIM_URL` | — | Adresse des eigenen Nominatim |
| `GEOCODING_LAENDER` | `de,at,ch` | Eingrenzung der Suche |
| `PORTAL_ENABLED` | `true` | Einsatzportal für Fremdwehren |
| `PORTAL_GUELTIGKEIT_STUNDEN` | `24` | Lebensdauer eines Links |
| `PORTAL_MAX_ZUGRIFFE` | `120` | Aufrufe je Link und Stunde |
---
## Phase 4 — Das Terminal im Gerätehaus
Ein Wandtablet neben der Fahrzeughalle. Es muss zwei Dinge können, die eine
Verwaltungsoberfläche am Schreibtisch nicht können muss: **mit Handschuhen
bedienbar** sein und **weiterarbeiten, wenn das Netz weg ist**. Stahlbeton,
Metalltore und ein Repeater, der irgendwann ausfällt — das ist der Normalfall,
nicht die Ausnahme.
Die Oberfläche liegt unter `/kiosk` und ist eine eigene: große Kacheln statt
Navigationsleiste, Schaltflächen ab 64 Pixel Höhe, Symbol **und** Text.
### Der Weg durch das Modul
1. **Einmal einrichten** — `/kiosk/einrichten` verlangt den Kurznamen der Wehr
und das Gerätegeheimnis aus der `.env` (`KIOSK_DEVICE_SECRET`). Beides nimmt
die Seite auch aus der Adresse an, damit am Tablet nichts abgetippt werden
muss:
```
/kiosk/einrichten?wehr=ff-musterhausen&schluessel=…
```
2. **Anmelden** — Ausweis auflegen, QR-Code vom Handy oder Mitgliedsnummer und
PIN. Ohne eingerichtetes Gerät kommt niemand hinein, auch nicht mit richtiger
PIN: das Gerätegeheimnis ist die erste Schranke.
3. **Erfassen** — Mangel melden, Kommen/Gehen, Fahrt beginnen und beenden,
Wäsche abgeben, Inventurgang.
4. **Von selbst abmelden** — nach `KIOSK_IDLE_TIMEOUT` Sekunden Untätigkeit,
mit sichtbarer Warnung vorher. Ein Terminal, an dem die letzte Anmeldung
stehen bleibt, erfasst irgendwann alles auf den Falschen.
Was dabei zusammenkommt, steht in der Verwaltung unter `/terminal`.
### Mangel melden in unter dreißig Sekunden
Der Vorgang, an dem sich das Terminal misst. Drei Schritte, kein Formular:
1. QR-Code am Gerät scannen — oder die Inventarnummer eintippen.
2. Foto aufnehmen und einen Satz dazu.
3. Dringlichkeit antippen. Damit ist es abgeschickt.
„Kritisch" sperrt das Gerät sofort. Eine unbekannte Inventarnummer hält die
Meldung **nicht** auf: sie geht raus und landet notfalls in der Konfliktliste —
lieber ein Zettel zu viel als ein Riss im Schlauch, den niemand gemeldet hat.
### Offline: wie nichts verloren geht
Drei Bausteine, und keiner davon ist optional:
**Service Worker** (`public/sw.js`, von Hand geschrieben, keine Bibliothek):
App-Shell und Symbole liegen im Zwischenspeicher, Seitenaufrufe gehen
`network-first` mit Ausweichseite `/kiosk/offline`, lesende tRPC-Aufrufe
`stale-while-revalidate`. Schreibende Anfragen und alles unter `/api/auth`
fasst er **nicht** an — eine zwischengespeicherte Anmeldung wäre eine Lücke,
kein Komfort.
**Warteschlange in der IndexedDB** (`src/lib/kiosk/outbox.ts`, Dexie): jede
schreibende Aktion geht zuerst dorthin — auch online. Sonst gäbe es zwei Wege
durch den Code, und der seltenere wäre der ungetestete. Fotos liegen als Blob
daneben und werden beim Übertragen zuerst hochgeladen.
**Idempotenzschlüssel**: die Kennung erzeugt der Browser beim Einreihen
(`crypto.randomUUID()`), nicht der Server beim Empfangen. Bricht die
Übertragung ab und wird wiederholt, erkennt der Server denselben Vorgang an
derselben Kennung wieder und antwortet mit dem Ergebnis von damals. Die Tabelle
`kiosk_vorgang` hat diese Kennung als Primärschlüssel, und die Datenbank
verweigert dem Anwendungszugang `UPDATE` und `DELETE` darauf.
Ein Vorgang verschwindet aus der Warteschlange, wenn der Server ihn angenommen
**oder** als bereits verarbeitet erkannt **oder** in die Konfliktliste gelegt
hat. In allen drei Fällen ist er angekommen. Liegen bleibt nur, was technisch
nicht durchging.
Der Zustand steht als Leiste über allem — verbunden, wie viele Änderungen
warten, kein Netz — in Klartext und nicht nur als Farbe.
**Wem ein Vorgang zugeschrieben wird.** Der Server nimmt dafür die Person, die
beim Empfang angemeldet ist. Im Regelfall ist das dieselbe, die erfasst hat —
wer offline erfasst, bleibt angemeldet, weil auch das Abmelden Netz braucht.
Damit dazwischen kein Fenster bleibt, überträgt das Terminal vor jedem
Abmelden — von Hand wie durch die Abmeldeuhr — noch einmal.
### Konflikte
Ein Vorgang, der sich nicht anwenden lässt (Gerät zwischenzeitlich ausgesondert,
Prüfung inzwischen festgeschrieben), wird nicht verworfen und nicht erzwungen,
sondern in `sync_konflikt` gelegt. Der Gerätewart sieht ihn unter `/terminal`
und entscheidet. Das ist der ehrliche Umgang mit einem Tablet, das drei Tage
offline war.
### Anmeldemarke fürs Handy
Wer den Ausweis vergessen hat, öffnet `/anmeldemarke` und hält den QR-Code vor
die Kamera des Terminals. Die Marke ist mit `AUTH_SECRET` signiert, **nicht
gespeichert**, und gilt 60 Sekunden — ein abfotografierter Code aus der Umkleide
nützt danach nichts mehr. Die Seite erneuert ihn von selbst.
### Hallenmonitor
`/kiosk/monitor` läuft im Vollbild ohne Bedienung: Uhrzeit, laufender Einsatz,
wer im Haus ist, Fahrzeuge samt laufenden Fahrten, fällige Prüfungen und die
nächsten Termine. Optional Wetterwarnungen des DWD — die einzige Verbindung,
die FLORIAN von sich aus nach außen aufbaut, und deshalb standardmäßig aus.
### Als App installieren
Das Manifest liegt unter `/manifest.webmanifest`, die Symbole entstehen mit
`npm run icons` und liegen unter `public/icons/`. Startseite ist `/kiosk`. Im
Kiosk-Browser des Tablets wird daraus eine Vollbildanwendung ohne Adresszeile.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `KIOSK_ENABLED` | `true` | Terminalanmeldung freischalten |
| `KIOSK_DEVICE_SECRET` | — | Geheimnis, mit dem sich ein Tablet ausweist |
| `KIOSK_IDLE_TIMEOUT` | `120` | Abmeldung nach Untätigkeit, in Sekunden |
| `WETTER_ENABLED` | `false` | Wetterwarnungen auf dem Monitor |
| `DWD_WARNCELL` | — | Warnzellen-Nummer der eigenen Gemeinde |
| `DWD_WARNUNGEN_URL` | DWD-Warnapp | Quelle der Warnungen |
| `WETTER_TIMEOUT` | `5` | Zeitlimit des Abrufs, in Sekunden |
| `WETTER_ZWISCHENSPEICHER` | `300` | Gültigkeit einer Antwort, in Sekunden |
---
## Phase 5 — Atemschutz nach FwDV 7
Das sicherheitsrelevanteste Modul des Projekts. Über jeder Ansicht steht
deshalb dauerhaft:
> **Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend.**
Der Hinweis lässt sich abschalten, aber nur zusammen mit einer Fundstelle:
`ATEMSCHUTZ_HINWEIS=false` wirkt erst, wenn unter `ATEMSCHUTZ_FREIGABE` steht,
worauf sich die Freigabe stützt — etwa „Kommandantenbeschluss vom 12.03.2026".
Fehlt sie, bleibt der Hinweis stehen. Eine Freigabe ohne Beschluss ist keine.
### Die Zeit gehört dem Server
Das ist die eine Entscheidung, aus der alles Weitere folgt. **Nirgends wird
eine Restzeit heruntergezählt.** In der Datenbank stehen Zeitstempel; wie lange
ein Trupp drin ist, rechnet jede Ansicht bei jedem Bild neu aus dem
Einstiegszeitpunkt und der aktuellen Uhr.
Daraus ergibt sich von selbst, was Phase-5.md als wichtigste Anforderung nennt:
| Was passiert | Was die Anzeige macht |
|---|---|
| Browser neu laden | Zeigt dieselbe Zeit weiter — es gab nichts zu verlieren |
| Zweiter Bildschirm daneben | Zeigt dieselbe Zeit, ohne Abgleich |
| Verbindung bricht ab | Die Uhr läuft weiter, und der Abriss steht sichtbar da |
| Server neu gestartet | Nach der ersten Antwort ist alles wieder da |
Server und Browser rechnen mit **denselben** Funktionen
(`src/lib/atemschutz/berechnung.ts`). Was das Tablet anzeigt, weist das
Protokoll später genauso aus.
### Rückzugsdruck
Gerechnet wird nach dem **doppelten Anmarschverbrauch**: was der Weg hinein an
Luft gekostet hat, kostet der Weg zurück noch einmal.
```
Rückzug = 2 × (Eingangsdruck − Druck am Einsatzziel) + Warndruck + Zuschlag
```
Dafür meldet der Trupp beim Erreichen des Einsatzziels einmal seinen Druck.
Solange das nicht geschehen ist, greift die **Zwei-Drittel-Regel** — sie liegt
in aller Regel höher und ist damit die vorsichtigere Annahme; die Karte sagt
dann „vorläufig" dazu.
Maßgeblich ist immer der ungünstigste Wert im Trupp: der Trupp geht gemeinsam
zurück, also zählt, wer am wenigsten Luft hat.
Die Ampel steht schon zehn Prozent über der Grenze auf Gelb — der Trupp soll
losgehen, bevor der Wert erreicht ist, nicht wenn er erreicht ist. Farbe **und**
Text, wie überall in FLORIAN.
### Der Weg durch eine Überwachung
1. **Eröffnen** — Bezeichnung genügt. Die Zuordnung zu einem Einsatz lässt sich
nachtragen; im Ernstfall wird zuerst überwacht.
2. **Trupp anlegen** — zwei oder drei Personen, für jede der Eingangsdruck, eine
davon Truppführer. Daneben steht, ob die Person nach FwDV 7 einsetzbar ist.
**Gesperrt wird nichts:** im Einsatz entscheidet der Einheitsführer, und ein
Programm, das ihm im entscheidenden Moment eine Person verweigert, wird
umgangen — dann steht am Ende gar nichts im Protokoll.
3. **Einstieg** — ab hier läuft die Uhr.
4. **Druckabfrage** — für den ganzen Trupp in einer Zeile. Nach
`ATEMSCHUTZ_ABFRAGE_INTERVALL` Sekunden ohne Meldung wird erinnert; gerechnet
ab der letzten Meldung, nicht ab dem Einstieg.
5. **Rückweg und heraus** — beim Herauskommen die Ausgangsdrücke.
6. **Beenden** — geht erst, wenn alle Trupps als draußen gemeldet sind. Eine
beendete Überwachung mit einem Trupp, der noch drin steht, wäre die
gefährlichste Zeile im ganzen Protokoll.
Dazu jederzeit: **Rückzug anordnen**, **Mayday** (aufzuheben, nicht
wegzuklicken) und Notizen ins Protokoll.
### Der Ereignisstrom
Änderungen kommen über **Server-Sent Events** unter `/api/atemschutz/strom` —
kein WebSocket, kein eigener Node-Server, `next start` bleibt wie es ist. Der
Strom meldet nur „es hat sich etwas geändert"; die Ansicht holt daraufhin den
Stand. Geschrieben wird wie überall über tRPC.
Zwei Dinge machen ihn belastbar:
- **Er verbindet sich selbst neu.** `EventSource` bringt das mit.
- **Stille gilt als Abriss.** Der Server schickt alle zwei Sekunden ein
Lebenszeichen; bleibt es acht Sekunden aus, steht die Verbindung sichtbar auf
„unterbrochen" — auch wenn der Browser keinen Fehler meldet. Dieser Fall ist
real: wird das Netz gekappt, während der Strom offen ist, schweigt
`EventSource` einfach.
Fällt der Strom ganz aus, bleibt die Ansicht bedienbar — sie fragt zusätzlich im
festen Takt nach. Der Strom macht sie schneller, nicht erst funktionsfähig.
### Das Protokoll
Ein PDF mit Rahmen, Trupps und dem Verlauf — Ereignisse und Druckmessungen in
**einer** Zeitachse, damit niemand zwei Spalten gleichzeitig lesen muss. Es
entsteht beim Beenden im Hintergrundprozess und, falls das nicht geklappt hat,
beim Abruf unter `/api/atemschutz/protokoll?id=…`.
Im Kopf steht, ob die digitale Überwachung freigegeben war. Wer das Blatt
später in der Hand hält, soll wissen, was er da hat.
Verändern lässt sich nichts: `druckmessung` und `atemschutz_ereignis` sind für
den Anwendungszugang gegen `UPDATE` und `DELETE` gesperrt, und zusätzlich sitzt
auf beiden ein Trigger. Ein Fehler wird durch einen neuen Eintrag
richtiggestellt.
### Wer darf unter Atemschutz?
Drei Nachweise, die gleichzeitig gelten müssen:
| Nachweis | Frist |
|---|---|
| **G26.3** | nach Alter: bis 50 Jahre drei Jahre, danach eines |
| **Belastungsübung** | jährlich (Übungsstrecke) |
| **Einsatzübung unter Atemschutz** | jährlich — ein echter Einsatz zählt |
Dazu die Qualifikation als Atemschutzgeräteträger aus Phase 1. Die Übersicht
zeigt eine Ampel **mit Klartext**, was fehlt: eine rote Ampel ohne Begründung
hilft am Fahrzeug niemandem. Auf der Startseite steht die Zahl, nach der im
Einsatz als Erstes gefragt wird — wie viele Träger heute einsetzbar sind.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `ATEMSCHUTZ_HINWEIS` | `true` | Sicherheitshinweis über jeder Ansicht |
| `ATEMSCHUTZ_FREIGABE` | — | Fundstelle der Freigabe; ohne sie bleibt der Hinweis |
| `ATEMSCHUTZ_ABFRAGE_INTERVALL` | `600` | Abstand der Druckabfrage in Sekunden |
| `ATEMSCHUTZ_WARNDRUCK` | `55` | Ansprechdruck der Warneinrichtung in bar |
| `ATEMSCHUTZ_ZUSCHLAG` | `10` | Sicherheitszuschlag auf den Rückzugsdruck |
---
## Phase 6 — Kartenmodule
Die Frage, wegen der es das Modul gibt, wird auf der Anfahrt gestellt: **Was
liegt in der Nähe, und was gibt es her?**
### Keine Kacheln aus dem Netz
Wo eine Wehr ausrückt, wo ihre Hydranten liegen und welche Objekte sie im Blick
hat, ist niemandes Geschäft. FLORIAN lädt deshalb **keine** Kartenkacheln von
einem öffentlichen Dienst — auch nicht „nur zum Ausprobieren". Der Kartenserver
läuft selbst:
```bash
docker compose --profile karte up -d
```
Dafür wird eine MBTiles-Datei des eigenen Ausrückegebiets gebraucht; woher man
sie bekommt, steht in [docker/karten/LIESMICH.md](docker/karten/LIESMICH.md).
Danach in der `.env`:
```
KARTE_STIL_URL=http://localhost:8085/styles/basic/style.json
```
**Ohne Kartenserver läuft alles trotzdem** — die Karte zeigt einen leeren Grund
mit einem Hinweis darauf. Punkte liegen an der richtigen Stelle, Entfernungen
stimmen, die Umkreissuche rechnet; es fehlen nur die Straßen darunter. Ist ein
Server eingetragen, aber nicht erreichbar, sieht es genauso aus — mit
entsprechendem Hinweis.
### Löschwasser
Hydranten, Saugstellen, Zisternen und offene Gewässer mit Typ, Nennweite,
Durchfluss, Zustand und dem, was man vor Ort wissen muss („im Winter oft
zugeschoben").
Die **Umkreissuche** ist der eigentliche Zweck: in die Karte tippen, und es
steht da, was in 500 Metern erreichbar ist — nach Entfernung sortiert, mit
Nennweite und Durchfluss. Sie läuft über PostGIS (`ST_DWithin` auf der
Geografie, GiST-Index), nicht über die Dezimalgrade: bei tausend Hydranten wäre
alles andere ein vollständiger Tabellendurchlauf, und gefragt wird während der
Fahrt.
### Import
Zwei Quellen, die eine Wehr tatsächlich bekommt:
| Quelle | Was erkannt wird |
|---|---|
| **GeoJSON** | FeatureCollection mit Punkten, Eigenschaften wie in der CSV |
| **CSV der Stadtwerke** | Spaltennamen wie `DN`, `Lage`, `Latitude`, `Pruefdatum`, `Bauart` |
Der Import ist nachsichtig, aber ehrlich: „DN 100" wird zu 100, „48,5031" zu
48.5031, „24.05.2026" zu einem Datum, und vertauschte Achsen werden erkannt und
zurückgedreht. Was nicht lesbar ist, wird mit Zeilennummer und Grund gemeldet —
und der Rest läuft durch. Eine Liste, die zu 90 Prozent stimmt, ist im Einsatz
mehr wert als keine.
**Geraten wird nichts.** Fehlt die Position, wird die Zeile übersprungen: ein
Hydrant ohne Ort ist kein Hydrant. Und ohne Statusangabe steht „ungeprüft",
nicht „einsatzbereit".
### Objekte und Feuerwehrpläne
Was hier gepflegt wird, wird auf der Anfahrt gelesen — oft vom Beifahrersitz,
oft in zwanzig Sekunden. Deshalb sind **Zufahrt**, **Gefahren** und
**Schlüsseldepot** eigene Felder und stehen ganz oben, nicht in einer
Fließtextnotiz.
Dazu Ansprechpartner mit der einzigen Angabe, die im Einsatz zählt — wer nachts
ans Telefon geht — und die Planblätter nach DIN 14095 mit ihrem Stand.
Gesucht wird über alles: Name, Straße, Gefahrenstichwort, Ansprechpartner. Wer
im Einsatz sucht, weiß selten, wie das Objekt im System heißt.
### Lagekarte
Eine je Einsatz, unterhalb der Einsatzdaten. 22 taktische Zeichen nach
FwDV 102, selbst gezeichnet als SVG — fertige Sätze sind selten frei
verwendbar, und nachgeladen wird ohnehin nichts.
Ein Werkzeug, ein Klick: Zeichen wählen, in die Karte tippen. Dazu Linienzüge
(Schlauchleitung, Absperrung), Flächen (Bereitstellungsraum) und
Beschriftungen.
Der Abgleich zwischen Geräten läuft über denselben Ereignisstrom wie die
Atemschutzüberwachung, und wie dort steht der Zustand der Verbindung sichtbar
da. Zurückgenommene Elemente verschwinden von der Karte, nicht aus dem
Datenbestand: eine Lagekarte ist die Mitschrift einer Entscheidung.
### Wasserförderung
Die Rechnung, die sonst mit Tabelle und Bleistift auf der Motorhaube entsteht.
Strecke in die Karte tippen, Durchmesser und Fördermenge einstellen:
```
Reibungsverlust je 100 m = Beiwert × (Fördermenge / 100)²
Höhenverlust = Höhenmeter × 0,1 bar
```
Daraus: Zahl der Verstärkerpumpen, Schlauchlängen je Abschnitt, Restdruck und
der Druck, der an der Einsatzstelle ankommt. Die Beiwerte treffen die
Tabellenwerte der Ausbildung — 1,5 bar je 100 m im B-Schlauch bei 800 l/min.
Reicht ein Abschnitt selbst mit voller Pumpe nicht, sagt die Ausgabe das,
statt eine Zahl auszuwerfen, die niemand halten kann. Und unter jeder Rechnung
steht, was sie ist: **eine Planungshilfe. Was zählt, ist der Druck am
Verteiler.**
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `KARTE_STIL_URL` | — | Stil des eigenen Kartenservers; leer = leerer Grund |
| `KARTE_BREITENGRAD` | `48.5` | Mittelpunkt, falls kein Standort verortet ist |
| `KARTE_LAENGENGRAD` | `12.15` | dito |
| `KARTE_ZOOM` | `13` | Zoomstufe beim Öffnen |
| `KARTE_PORT` | `8085` | Port des tileserver-gl (Profil `karte`) |
---
## Phase 7 — Benachrichtigungen und Termine
Der Satz, der über der ganzen Phase steht und der auch die Technik darunter
bestimmt: **Niemand wird ungefragt benachrichtigt.** Wer nichts einträgt,
bekommt nichts. Es gibt keine Voreinstellung, die stillschweigend alle
anmeldet — ein neu eingerichteter Zustellweg hat eine leere Ereignisliste, und
erst wer ankreuzt, wofür er ihn will, bekommt etwas.
### Der Terminkalender
Übungsabende, Lehrgänge, Arbeitsdienste, Sitzungen. Drei Entscheidungen prägen
ihn, und alle drei kommen aus dem Alltag einer Wehr:
**Anmeldung ist die Ausnahme, nicht die Regel.** Beim normalen Übungsabend
meldet sich niemand an, man kommt oder man kommt nicht. Erst beim Lehrgang mit
zwölf Plätzen und beim Ausflug mit Busbestellung wird eine Rückmeldung
verlangt.
**Abgesagt, nicht gelöscht.** Ein Termin, der ohne ein Wort verschwindet, führt
dazu, dass am Dienstagabend jemand vor dem verschlossenen Gerätehaus steht.
Eine Absage braucht einen Grund, bleibt im Kalender stehen und erreicht die,
die zugesagt hatten — nicht die ganze Wehr.
**Die Warteliste rückt von selbst nach.** Wer bei belegten Plätzen zusagt,
landet auf der Warteliste; sagt jemand ab, rückt der Erste nach und erfährt es.
Sonst führt jemand die Liste von Hand, und das tut niemand zuverlässig.
Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder
Abend ist ein eigener Datensatz. Der Grund ist derselbe Alltag — der Übungsabend
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Mit einer Regel wäre jede
dieser Ausnahmen ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele
Termine daraus werden.
Die Anwesenheitsliste zeigt **alle aktiven Mitglieder**, nicht nur die
Angemeldeten: auf einem Blatt zum Abhaken muss jeder stehen. Zusage und
Anwesenheit sind getrennt — wer zugesagt hat und nicht kam, steht genau so in
der Liste.
### Der Kalender fürs Handy
Eine Adresse, die man im Handy oder im Mailprogramm abonniert; die Termine
stehen dann neben allem anderen, ohne dass jemand FLORIAN öffnen muss. Das ist
der Kanal mit der größten Wirkung pro Zeile Code in dieser Phase.
**Bewusst kein CalDAV.** Das ist ein Schreib-Lese-Protokoll mit
Rechteverwaltung und Konfliktauflösung; gebraucht wird hier nur Lesen, und dafür
genügt eine Adresse, die eine ICS-Datei ausliefert. Jeder Kalender kann das.
Die Adresse ist der Schlüssel — Kalenderprogramme können sich nicht anmelden,
sie rufen stumpf eine URL ab. Daraus folgt:
- Gespeichert wird nur der SHA-256 des Tokens; die Adresse steht **genau
einmal** auf dem Bildschirm.
- Jeder Zugang lässt sich einzeln widerrufen. Ein verlorenes Handy kostet einen
Klick, nicht ein neues Passwort für alle.
- Ausgeliefert werden **nur Termine** — keine Anmeldelisten, keine Personen
außer der Leitung, keine Mängel, keine Einsätze.
- Wer aus der Wehr austritt, verliert das Abonnement.
### Die vier Kanäle
| Kanal | Läuft über | Bekommt |
|---|---|---|
| **E-Mail** (SMTP) | den eigenen bzw. den Mailserver der Gemeinde | den ausführlichen Text |
| **ntfy** (selbst gehostet) | den eigenen Server, Profil `push` | den ausführlichen Text |
| **Telegram** | einen fremden Server | nur die Kurzfassung |
| **Web-Push** | den Push-Dienst des Browserherstellers | nur die Kurzfassung |
Die Spalte ganz rechts ist keine Bequemlichkeit, sondern die Regel: was das Netz
der Wehr verlässt, bekommt einen Hinweis ohne Namen, Gerätebezeichnungen und
Einsatzdetails — „3 Prüfungen fällig", nicht welche. In der Oberfläche steht das
neben jedem externen Kanal, und die Vorlagenmaske sagt es noch einmal, bevor
jemand einen Namen in eine Telegram-Vorlage tippt.
**ntfy** ist der interessanteste Weg für eine Wehr: ein Container, eine App im
Store, kein Konto, keine Telefonnummer, kein Telefonbuchabgleich. Man abonniert
ein Thema — einen frei gewählten Namen — und bekommt Meldungen darauf. Läuft
der Server im Gerätehaus, verlässt keine Nachricht das Haus. Der Preis: ein
Thema ist nur so geheim wie sein Name, deshalb schlägt FLORIAN Namen mit
Zufallsanteil vor.
```bash
docker compose --profile push up -d # ntfy startet auf Port 8086
npm run vapid # Schlüsselpaar für Web-Push
```
### Warum kein WhatsApp
Es gibt keine `WHATSAPP_`-Variablen, und das ist eine Entscheidung, keine
Lücke. Drei Gründe, in dieser Reihenfolge:
**Datenschutz.** Der einzige zulässige Weg für eine Organisation ist die
WhatsApp Business Platform. Nachrichten laufen über Server von Meta, und die
Wehr müsste einen Vertrag zur Auftragsverarbeitung schließen — für eine
Freiwillige Feuerwehr, deren Daten ausdrücklich im Gerätehaus bleiben sollen,
ist das der falsche Weg. Die gewöhnliche App scheidet ohnehin aus: sie gleicht
das Telefonbuch ab und gibt damit die Kontakte aller Mitglieder weiter.
**Kosten.** Abgerechnet wird je Konversation. Eine Wehr mit fünfzig Mitgliedern
und zwei Terminerinnerungen im Monat zahlt dauerhaft für etwas, das über den
eigenen Mailserver nichts kostet.
**Aufwand.** Business-Konto, verifizierte Telefonnummer, genehmigte
Nachrichtenvorlagen, ein Meta-Entwicklerkonto. Das ist mehr Verwaltung als der
gesamte Rest dieser Phase.
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts
und verrät niemandem etwas.
### Der digitale Dienstausweis
Ein QR-Code auf dem Handy, den man an der Absperrung, beim Lehrgang oder bei
einer Kontrolle vorzeigt. Wer ihn scannt, landet auf einer Prüfseite und sieht
Name, Wehr, Funktion, Foto und die gültigen Qualifikationen — **ohne sich
anzumelden**, denn wer prüft, hat keinen Zugang zu FLORIAN.
- **Signiert, nicht gespeichert.** HMAC über Person, Wehr und Zeitstempel.
Keine Tabelle, kein Aufräumjob.
- **Fünf Minuten gültig.** Er wird vorgezeigt, abgelesen und weggesteckt; ein
abfotografierter Code ist danach wertlos. Die Prüfseite nennt deshalb den
Zeitpunkt der Ausstellung — ein Ausweis, der „gültig" sagt, ohne zu sagen
seit wann, ist wertlos.
- **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein. Wer den
Code hat, bekommt keinen Zugang zu einem einzigen Datensatz.
- Was **nicht** darauf steht: Anschrift, Geburtsdatum, Telefonnummer,
Mitgliedsnummer. Ein Ausweis weist aus; er ist keine Personalakte.
- Ein gültig signierter Code für jemanden, der ausgetreten ist, wird abgewiesen.
### Was von selbst benachrichtigt
Sechs Anlässe, an die vorhandenen Abläufe angehängt:
| Anlass | Wann | An wen |
|---|---|---|
| Prüfung fällig | im Erinnerungslauf | wer es abonniert hat |
| Mangel gemeldet | sofort | wer es abonniert hat |
| Qualifikation läuft ab | 90, 30 und 7 Tage vorher | die Person selbst |
| Neuer Termin | beim Anlegen | wer es abonniert hat |
| Terminerinnerung | 7 Tage und 1 Tag vorher | Angemeldete, sonst Abonnenten |
| Einsatzbericht freigegeben | bei der Freigabe | wer es abonniert hat |
Die Sammelmail an den zuständigen Gerätewart bleibt, wie sie war — sie geht an
den, der zuständig ist, unabhängig davon, ob er irgendwo ein Kreuzchen gesetzt
hat. Ein Prüftermin ist eine Pflicht, kein Abo. Wer sie ohnehin bekommt, wird
von der Benachrichtigung ausgenommen und hat sie damit nicht doppelt.
Erinnert wird um sieben Uhr morgens, nicht nachts: eine Push-Meldung um drei Uhr
weckt Leute, die am nächsten Tag arbeiten müssen. Zugestellt wird dagegen jede
Minute — eine Absage am Dienstagabend muss ankommen, solange sie noch etwas
nützt.
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird nur eine
Zeile geschrieben; der Worker schickt sie weg und vermerkt, wie es ausging.
Sonst hinge das Speichern eines Termins am Antwortverhalten von Telegram. Eine
fehlgeschlagene Zustellung bleibt sichtbar stehen und wird nicht nach Frist
abgeräumt — sie ist der Hinweis darauf, dass ein Kanal kaputt ist, und der
verliert seinen Wert nicht durch Zeitablauf.
Der Probeknopf steht direkt an jedem Zustellweg und läuft durch dieselbe
Maschinerie wie alles andere. Ohne ihn merkt jemand erst in drei Wochen, dass
sein ntfy-Thema einen Tippfehler hat — nämlich dann, wenn die Nachricht wichtig
gewesen wäre.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `NTFY_ENABLED` | `false` | Push über den eigenen ntfy |
| `NTFY_URL` | — | Adresse des eigenen ntfy, z. B. `http://ntfy:80` |
| `NTFY_TOKEN` | — | nur bei eingeschaltetem Zugriffsschutz |
| `NTFY_PORT` | `8086` | Port des ntfy-Containers (Profil `push`) |
| `WEBPUSH_ENABLED` | `false` | Push in den Browser |
| `WEBPUSH_PUBLIC_KEY` | — | aus `npm run vapid`; **ins Backup** |
| `WEBPUSH_PRIVATE_KEY` | — | dito |
| `WEBPUSH_KONTAKT` | `APP_URL` | Kontaktweg für den Push-Dienst |
| `BENACHRICHTIGUNG_LAUF` | `0 7 * * *` | wann erinnert wird |
| `TERMIN_ERINNERUNG_TAGE` | `7,1` | Vorlauf der Terminerinnerungen |
| `BENACHRICHTIGUNG_VERSUCHE` | `3` | Zustellversuche, bevor es stehen bleibt |
| `BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE` | `90` | Frist für **zugestellte** Nachrichten |
Ein neuer öffentlicher VAPID-Schlüssel macht alle bestehenden Push-Abos
wertlos — jeder müsste Push von Hand neu erlauben, und niemand merkt es, die
Meldungen bleiben einfach aus. Beide Schlüssel gehören deshalb ins Backup.
---
## 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`):
```bash
docker compose build postgres # bei einer bestehenden Installation
docker compose up -d postgres # Datenverzeichnis bleibt unberührt
npm run db:migrate
```
Dann die Modelle:
```bash
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`:
```yaml
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:
```sql
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`) |
---
## Befehle
```bash
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 |
| 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
```bash
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) 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.