Phase 5: das sicherheitsrelevanteste Modul des Projekts. Ueber jeder Ansicht steht dauerhaft, dass die analoge Atemschutzueberwachung fuehrend bleibt — abschaltbar nur zusammen mit der Fundstelle einer Freigabe des Kommandanten. Es gibt keinen Timer. In der Datenbank stehen Zeitstempel; wie lange ein Trupp unter Atemschutz ist, rechnet jede Ansicht bei jedem Bild neu aus. Damit ist die Anforderung "uebersteht Neuladen und Verbindungsabbruch" keine Funktion, die gelingen muss, sondern eine Eigenschaft, die sich nicht verlieren laesst. Server und Browser rechnen mit denselben Funktionen. Der Rueckzugsdruck folgt dem doppelten Anmarschverbrauch: was der Weg hinein gekostet hat, kostet der Weg zurueck noch einmal. Bis der Trupp das Einsatzziel meldet, greift die Zwei-Drittel-Regel — sie liegt hoeher und ist damit die vorsichtigere Annahme. Das Protokoll ist doppelt gesichert: entzogene Rechte und ein Trigger auf druckmessung und atemschutz_ereignis. Ein Fehler wird durch einen neuen Eintrag richtiggestellt, nicht durch Aendern. Verteilt wird ueber Server-Sent Events statt Socket.IO — es geht nur in eine Richtung, und so bleibt das Deployment bei "next start". Behoben: ein abgerissener Ereignisstrom meldete sich nicht. Die Anzeige haette weiter "Verbunden" behauptet, waehrend der Draht tot war. Jetzt gilt Stille von acht Sekunden als Abriss. Dazu zwei Altlasten: der Navigationseintrag "Protokoll" fuehrte ins Leere, und die Startseite stand noch auf Phase 1. 68 neue Tests (51 Unit, 17 gegen eine echte Datenbank, 8 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
728 lines
30 KiB
Markdown
728 lines
30 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) | fertig |
|
||
| 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 |
|
||
|
||
---
|
||
|
||
## 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 |
|
||
|
||
---
|
||
|
||
## 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.
|