Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf, Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung. Elf neue Tabellen, alle mit organisationId und Row Level Security. Die Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung mit Verweis auf die alte, beide bleiben lesbar. 79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FLORIAN — Feuerwehr-Verwaltungsplattform
Selbstgehostete Verwaltungssoftware für Freiwillige Feuerwehren. Läuft im Gerätehaus, nicht in der Cloud. Keine Telemetrie, keine externen CDNs.
Schnellstart
git clone https://git.wilde-server.de/florian.wilde/FireAdministration.git && cd FireAdministration
cp .env.example .env # Passwörter und AUTH_SECRET ersetzen
npm ci # Abhängigkeiten
docker compose up -d # Postgres, Redis, MinIO, Gotenberg
npm run db:migrate && npm run db:seed # Schema und Beispielwehr
npm run dev # http://localhost:3100
Anmelden mit SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORT aus der .env
(Vorgabe: admin@beispielwehr.de / Feuerwehr2026). Passwort danach ändern.
Was drin ist
| Bereich | Stand |
|---|---|
| Phase 0 — Fundament | fertig |
| Phase 1 — Stammdaten | fertig |
| Phase 2 — Wartung und Prüfung | fertig |
| Phase 3 — Einsätze und Übungen | offen |
| Phase 4 — Kiosk-PWA | offen |
| 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 und docs/AUFWANDSSCHAETZUNG.md.
Weiterführende Dokumentation
| Datei | Inhalt |
|---|---|
| docs/ANLEITUNG.md | Bedienung mit Bildschirmfotos |
| docs/ROADMAP.md | Phasenplan und was jeweils entsteht |
| docs/AUFWANDSSCHAETZUNG.md | Aufwand je Phase, Schätzung gegen Ist |
| docs/DEVLOG.md | Entwicklungstagebuch mit Zeiterfassung |
| docs/VERSIONIERUNG.md | Versions- und Branch-Regeln |
| CHANGELOG.md | Was sich wann geändert hat |
Phase 0 — Fundament
Aufbau
FireAdministration/
├─ docker/ Dockerfile, Entrypoint, Postgres-Initialisierung
├─ prisma/ Schema, Migrationen, Seed
├─ scripts/ Hilfsskripte (Bildschirmfotos)
├─ src/
│ ├─ app/ Next.js App Router
│ │ ├─ (app)/ alles, was Anmeldung verlangt
│ │ ├─ anmelden/ Anmeldeseite
│ │ └─ api/ Auth.js, tRPC, Health
│ ├─ components/ shadcn/ui-Bausteine und Layout
│ ├─ lib/ authz, db, storage, audit, env, auth, warteschlange
│ ├─ server/ tRPC-Router und Kontext
│ └─ worker/ BullMQ-Hintergrundprozess
├─ tests/ Vitest (unit) und Playwright (e2e)
└─ docs/ Anleitung, Roadmap, Devlog, Aufwände
Mandantentrennung
Jede Tabelle trägt organisationId, und Postgres setzt die Trennung mit Row
Level Security durch. Es gibt zwei Datenbankverbindungen:
florian_app(DATABASE_URL) — RLS aktiv,NOBYPASSRLS. Über diese Verbindung arbeitet die Anwendung. Ohne gesetztesapp.current_organisationliefert jede Abfrage null Zeilen.- Eigentümer (
DIRECT_DATABASE_URL) — umgeht RLS. Nur für Migrationen, Seed, Anmeldung (die E-Mail-Suche muss über alle Wehren gehen) und Wartungsjobs.
Fachdaten laufen ausschließlich über fuerOrganisation() aus
src/lib/db.ts:
const personen = await fuerOrganisation(orgId, (db) =>
db.person.findMany({ where: { aktiv: true } }),
);
Ein vergessener Mandantenfilter kostet damit im schlimmsten Fall ein leeres
Ergebnis — nicht die Daten einer fremden Wehr.
tests/unit/mandantentrennung.test.ts prüft das gegen eine echte Datenbank.
Audit-Log
Jede schreibende Operation erzeugt einen Eintrag mit Akteur, Zeitpunkt, Entität und Vorher/Nachher als JSONB. Die Tabelle ist append-only:
UPDATEist per Trigger immer gesperrt.DELETEnur im gekennzeichneten Aufbewahrungslauf (SET LOCAL app.audit_bereinigung = 'an'), den ausschließlich der Worker ausführt — nachdem er exportiert hat. Ohne diese Ausnahme ließe sich das Löschkonzept nach DSGVO nicht umsetzen.
Geheimnisse (Passwort-Hashes, PINs, Token) werden vor dem Schreiben entfernt.
Berechtigungen
Alles in src/lib/authz.ts — keine Rollenprüfung in
Komponenten. 9 Rollen, 38 Einzelrechte. Der Seed spiegelt den Katalog in die
Datenbank, damit eine Wehr Rechte nachträglich zuschneiden kann, ohne dass
jemand Code ändert.
export const geraetSpeichern = mitRecht("geraet.bearbeiten").mutation(/* … */);
Anmeldung
Drei Wege, eine Sitzung:
- OIDC gegen authentik — der Regelweg. authentik wird separat
betrieben und ist nicht Teil dieses Stacks. Im Provider einzutragen:
Redirect-URI
${APP_URL}/api/auth/callback/authentik, Scopesopenid profile email. - E-Mail und Passwort — Rückfalloption für den Erststart und für den Fall,
dass authentik nicht erreichbar ist. Über
AUTH_LOCAL_ENABLEDabschaltbar. - Terminal (PIN oder RFID) — für das Kiosk-Gerät im Gerätehaus. Zwei
Schranken: erst weist sich das Gerät mit
KIOSK_DEVICE_SECRETaus, dann die Person mit PIN oder Karte. Die vollständige Oberfläche dazu kommt in Phase 4.
Rollen aus authentik-Gruppen wirken als Vorbelegung beim ersten Login
(AUTH_OIDC_GROUP_MAPPING). Sobald jemand die Rolle in FLORIAN ändert, ist
FLORIAN führend und OIDC überschreibt nicht mehr.
Dateien
Alles liegt in MinIO, nie im Dateisystem und nie in der Datenbank. Zugriff
ausschließlich über src/lib/storage.ts. Objektschlüssel:
<wehr-slug>/<entitaet>/<entitaetId>/<uuid>-<dateiname>
Downloads laufen über presignte Links mit begrenzter Gültigkeit
(S3_URL_TTL, Vorgabe 15 Minuten).
Phase 2 — Wartung und Prüfung
Das Modul, wegen dem eine Wehr so ein Programm überhaupt einführt: Prüffristen im Blick behalten und im Streitfall nachweisen können, dass geprüft wurde.
Der Weg durch das Modul
Wartungsvorlage was an einem Gerätetyp in welchem Takt geprüft wird
└─ Checklistenpunkt die einzelne Frage: ja/nein, Messwert, Text, Foto
↓ nächtlicher Lauf
Wartungstermin ein fälliger Termin an einem konkreten Gerät
↓ Prüfwizard
Pruefung durchgeführte Prüfung, unterschrieben, festgeschrieben
├─ Pruefergebnis je Checklistenpunkt eine Antwort
├─ Signatur Unterschrift aus dem Zeichenfeld, PNG in MinIO
└─ Mangel was aufgefallen ist und behoben werden muss
Ohne Vorlage gibt es keine Termine, ohne Termine kein Fristen-Dashboard. Die
erste Seite, die eine Wehr in Phase 2 ausfüllt, ist deshalb
/pruefungen/vorlagen.
Das Protokoll ist unveränderbar
Ein abgeschlossenes Prüfprotokoll ist der Nachweis gegenüber der Unfallkasse.
Wäre es nachträglich änderbar, wäre es als Nachweis wertlos. Die Sperre sitzt
deshalb in der Datenbank (Trigger pruefung_unveraenderlich, Migration
20260903000300_wartung_rls) und nicht nur im Anwendungscode — ein vergessener
Zustandscheck in einem Router würde sonst reichen, um sie auszuhebeln.
Nach dem Abschluss sind genau drei Änderungen zulässig, und alle drei ergänzen das Protokoll, statt es zu verfälschen:
| Änderung | Warum |
|---|---|
ABGESCHLOSSEN → ERSETZT |
eine Korrekturprüfung ist nachgefolgt |
protokollDateiId, solange leer |
das PDF entsteht im Hintergrund und wird nachgetragen |
geaendertAm |
setzt Prisma bei jedem Schreibvorgang mit |
Dasselbe gilt für die Bestandteile: an einem abgeschlossenen Protokoll lassen sich weder Einzelergebnisse ändern noch Unterschriften nachtragen. Korrekturen erzeugen eine neue Prüfung mit Verweis auf die alte; beide bleiben lesbar.
Gelöscht wird ein Protokoll nie — außer im ausdrücklich gekennzeichneten
Aufbewahrungslauf, demselben Schalter wie beim Audit-Log
(SET LOCAL app.audit_bereinigung = 'an').
Der nächtliche Lauf
Der Worker trägt sich beim Start je Wehr zwei wiederkehrende Aufträge ein:
| Auftrag | Vorgabe | Was er tut |
|---|---|---|
fristen.pruefen |
03:15 Uhr | Termine aus den Vorlagen planen, Frist und Status an jedem Gerät nachziehen |
erinnerung.senden |
03:45 Uhr | Vorwarnungen zu offenen Terminen verschicken |
Beide sind wiederholbar: doppelte Termine verhindert die Eindeutigkeit über Gerät, Vorlage und Fälligkeitstag, doppelte Mails ein Erinnerungseintrag je Termin und Stufe. Erinnert wird bei T-30 (bzw. der Vorwarnzeit der Vorlage), T-7, T-0 und einmal bei Überfälligkeit — als eine Sammelnachricht je Empfänger, nicht als eine Mail je Gerät.
„Überfällig" ist bewusst kein gespeicherter Zustand, sondern wird aus dem Fälligkeitsdatum und dem heutigen Tag gerechnet. Gespeicherte Zustände laufen der Uhr hinterher; berechnete nicht.
Prüfwizard
Zugeschnitten auf das Tablet im Gerätehaus: ein Prüfpunkt je Bildschirm, große Schaltflächen, Foto direkt aus der Kamera, Unterschrift auf einem Zeichenfeld, das Finger, Stift und Maus gleichermaßen versteht.
Jede Antwort geht sofort zum Server. Bricht das WLAN weg, ist höchstens die letzte Antwort verloren, nicht die ganze Prüfung — beim erneuten Öffnen wird der Entwurf fortgesetzt.
Die Bewertung rechnet der Server, nicht der Browser
(src/lib/wartung/bewertung.ts):
- ein Messwert außerhalb des zulässigen Bereichs gilt als beanstandet,
- ein beanstandeter Pflichtpunkt bedeutet „nicht bestanden" und sperrt das Gerät,
- ein beanstandeter Kann-Punkt bedeutet „bestanden mit Mängeln".
Das Ergebnis ist ein Vorschlag; die Entscheidung trifft der Prüfer, denn er trägt die Verantwortung.
Prüfprotokoll als PDF
Erzeugt der Worker über Gotenberg
(src/lib/wartung/protokoll.ts) und legt es in
MinIO ab. Kopf mit Wehr, Gerät und Prüfer-Kennung, die Checkliste mit Soll, Ist
und Befund, eingebettete Fotos und Unterschriften, Fußzeile mit Prüfvorschrift,
Prüfdatum und Seitenzahl.
Alles ist eingebettet — keine externen Schriften, keine nachgeladenen Bilder. Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute.
Die laufende Protokollnummer (JJJJ-NNNN) wird je Wehr und Jahr unter einer
Transaktionssperre vergeben; zwei gleichzeitig abschließende Prüfer bekommen
verschiedene Nummern.
Mängel und Rückrufe
Der Mängelweg ist immer derselbe: melden → zugewiesen → in Arbeit → behoben. Melden darf jedes Mitglied — der Mangel fällt dem auf, der das Gerät benutzt, nicht dem Gerätewart am Schreibtisch. Ein kritischer Mangel sperrt das Gerät sofort; beim Beheben wird die Sperre wieder aufgehoben, aber nur, wenn kein anderer offener Mangel dasselbe Gerät sperrt.
Bei einem Rückruf sucht FLORIAN anhand von Bauart, Modell, Seriennummernbereich und Herstellungszeitraum nach betroffenen Geräten. Das Ergebnis ist ein Vorschlag, keine automatische Sperre: eine Chargenangabe des Herstellers passt selten eins zu eins auf die Seriennummern im eigenen Bestand.
Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
WARTUNG_NACHTLAUF |
15 3 * * * |
wann Termine geplant werden (cron, Europe/Berlin) |
WARTUNG_ERINNERUNG_LAUF |
45 3 * * * |
wann Erinnerungen verschickt werden |
WARTUNG_PLANUNGSHORIZONT_TAGE |
180 |
wie weit im Voraus Termine angelegt werden |
Befehle
npm run dev # Entwicklungsserver auf Port 3100
npm run build # Produktionsbuild
npm run worker:dev # Hintergrundprozess mit Neuladen
npm run test # Vitest
npm run test:e2e # Playwright
npm run typecheck # TypeScript ohne Ausgabe
npm run db:migrate # ausstehende Migrationen anwenden
npm run db:seed # Beispielwehr einspielen
npm run db:studio # Prisma Studio
npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen
Dienste und Ports (Entwicklung)
Bewusst nicht die Standardports, damit FLORIAN neben anderen lokalen Stacks
laufen kann. Alle Werte stehen in der .env.
| Dienst | Adresse |
|---|---|
| Anwendung | http://localhost:3100 |
| PostgreSQL 17 + PostGIS | localhost:5442 |
| Redis | localhost:6389 |
| MinIO API | http://localhost:9010 |
| MinIO Weboberfläche | http://localhost:9011 |
| Gotenberg | http://localhost:3101 |
| Mailpit (nur Entwicklung) | http://localhost:8125 |
| Adminer (nur Entwicklung) | http://localhost:8081 |
Produktionsbetrieb
docker compose -f docker-compose.yml up -d
Die Angabe von -f ist wichtig: ohne sie legt Docker Compose automatisch
docker-compose.override.yml darüber, und das ist die Entwicklungsvariante
ohne Traefik. In Produktion terminiert Traefik TLS und verteilt auf
APP_DOMAIN und MINIO_DOMAIN.
Der App-Container wendet beim Start ausstehende Migrationen an; der Worker tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
Datenschutz
- Keine Telemetrie, keine externen CDNs, keine Google Fonts. Es werden nur Schriften verwendet, die auf dem Gerät vorhanden sind.
- Aufbewahrungsfristen konfigurierbar (
AUFBEWAHRUNG_JAHRE,AUDIT_AUFBEWAHRUNG_TAGE,PERSON_ANONYMISIERUNG_TAGE). - Externe Dienste (Mail, Telegram, 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.