Files
FireAdministration/README.md
T
Florian WildeandClaude Opus 5 791523027f Terminal im Geraetehaus mit Offline-Betrieb ergaenzen
Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der
Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz
weg ist.

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

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

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

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

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

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

593 lines
24 KiB
Markdown

# 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) | offen |
| Phase 6 — Kartenmodule | offen |
| Phase 7 — Benachrichtigungen und Termine | offen |
| Phase 8 — Lokale KI | offen |
| Phase 9 — Kassenverwaltung | offen |
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](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 |
---
## 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
```
## 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
```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, LLM) sitzen hinter Schnittstellen in
`src/lib/integrations/` und sind einzeln abschaltbar. Ohne Freischaltung
wird nur ins Log geschrieben.
## Sicherheitshinweis
Atemschutzüberwachung und Alarmmonitor sind sicherheitsrelevant. Solange die
Wehr das System nicht ausdrücklich freigegeben hat, bleibt die **analoge
Überwachung führend**. Jede entsprechende Ansicht zeigt diesen Hinweis
dauerhaft an.