Phase 0: Fundament der Feuerwehr-Verwaltungsplattform

Legt den kompletten Unterbau an, auf dem die Fachmodule aufsetzen.

Betrieb:
- docker-compose.yml mit Traefik, App, Worker, PostgreSQL 17 + PostGIS,
  Redis, MinIO und Gotenberg; Entwicklungs-Override ohne Traefik, dafuer
  mit Mailpit und Adminer
- Mehrstufiges Dockerfile: eigener Benutzer, tini als PID 1, Healthcheck
- .env.example mit einem Kommentar zu jeder Variable

Mandantentrennung:
- Row Level Security auf allen mandantenpflichtigen Tabellen
- Zwei getrennte Datenbankverbindungen: florian_app mit aktiver RLS fuer
  die Anwendung, Eigentuemer-Zugang nur fuer Migration, Seed und Anmeldung
- fuerOrganisation() als einziger Weg zu Fachdaten

Nachvollziehbarkeit:
- AuditLog append-only per Trigger; UPDATE ist dauerhaft gesperrt, DELETE
  nur im gekennzeichneten Aufbewahrungslauf
- Geheimnisse werden vor dem Schreiben entfernt

Anmeldung:
- OIDC gegen ein extern betriebenes authentik, Rollen aus Gruppen nur als
  Vorbelegung beim ersten Login
- Lokaler E-Mail/Passwort-Login als Rueckfallweg
- Terminal per PIN und RFID mit zwei Schranken (Geraet, dann Person)

Weiteres:
- lib/authz.ts mit 9 Rollen und 38 Einzelrechten
- lib/storage.ts (MinIO), lib/audit.ts, lib/warteschlange.ts (BullMQ)
- Integrationen fuer Mail, Telegram und LLM hinter abschaltbaren Interfaces
- tRPC v11, Basis-Layout mit Seitenleiste und dunkler Darstellung
- Seed mit einer Beispielwehr: 16 Personen, 3 Fahrzeuge, 40 Geraete
- 32 Vitest-Tests (davon 8 gegen eine echte Datenbank), 10 Playwright-Tests
- README, Anleitung mit Screenshots, Roadmap, Aufwandsschaetzung, Devlog,
  Versionierungsregeln und Changelog

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 00:13:53 +02:00
co-authored by Claude Opus 5
commit 845942f36c
84 changed files with 21433 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules
.next
.git
.github
coverage
test-results
playwright-report
data
Phase-*.md
*.log
.env
.env.local
.env.*.local
README.md
+231
View File
@@ -0,0 +1,231 @@
# =============================================================================
# FLORIAN — Feuerwehr-Verwaltungsplattform
# Kopiere diese Datei nach ".env" und passe die Werte an:
# cp .env.example .env
# Alles, was hier steht, wird von docker-compose.yml und von lib/env.ts gelesen.
# lib/env.ts validiert beim Start mit Zod — fehlt eine Pflichtvariable, startet
# die App gar nicht erst, statt später halb zu funktionieren.
# =============================================================================
# -----------------------------------------------------------------------------
# Allgemein
# -----------------------------------------------------------------------------
# development | production | test — steuert Logging, Fehlerseiten und Caching.
NODE_ENV=development
# Öffentliche Basis-URL der Anwendung, ohne abschließenden Schrägstrich.
# Muss exakt der Adresse entsprechen, unter der Nutzer die App aufrufen —
# Auth.js baut daraus die OIDC-Redirect-URI.
APP_URL=http://localhost:3100
# Zeitzone für alle serverseitigen Fristen- und Terminberechnungen.
# Nicht ändern, außer die Wehr liegt außerhalb Deutschlands.
TZ=Europe/Berlin
# Host-Ports fuer die Entwicklung. Bewusst nicht die Standardports,
# damit FLORIAN neben anderen lokalen Stacks laufen kann.
APP_PORT=3100
GOTENBERG_PORT=3101
MAILPIT_UI_PORT=8125
MAILPIT_SMTP_PORT=1125
ADMINER_PORT=8081
# -----------------------------------------------------------------------------
# Traefik / Domains (nur für den Produktionsbetrieb relevant)
# In der Entwicklung greift docker-compose.override.yml und veröffentlicht die
# Dienste direkt auf localhost — Traefik wird dort nicht gebraucht.
# -----------------------------------------------------------------------------
# Hostname der Anwendung hinter Traefik, z. B. florian.beispielwehr.de
APP_DOMAIN=florian.localhost
# Hostname der MinIO-S3-API (Datei-Uploads, presignte URLs).
MINIO_DOMAIN=s3.florian.localhost
# Hostname der MinIO-Weboberfläche (Administration der Buckets).
MINIO_CONSOLE_DOMAIN=minio.florian.localhost
# Hostname des Traefik-Dashboards. Leer lassen, um es nicht zu veröffentlichen.
TRAEFIK_DASHBOARD_DOMAIN=traefik.florian.localhost
# E-Mail-Adresse für die ACME-Zertifikate (Let us Encrypt).
ACME_EMAIL=kommandant@beispielwehr.de
# Traefik-Entrypoint für HTTPS. "websecure" ist der Standard aus dieser Datei.
TRAEFIK_ENTRYPOINT=websecure
# -----------------------------------------------------------------------------
# PostgreSQL 17 + PostGIS
# -----------------------------------------------------------------------------
POSTGRES_USER=florian
# Vor dem ersten Start ersetzen: openssl rand -base64 24
POSTGRES_PASSWORD=bitte_aendern_postgres
POSTGRES_DB=florian
# Port, unter dem Postgres auf dem Host erreichbar ist (nur Entwicklung).
POSTGRES_PORT=5442
# Verbindungszeichenfolge der Anwendung. Läuft über den Datenbankbenutzer
# "florian_app", für den Row Level Security AKTIV ist — dieser Zugang sieht nur
# Daten der Organisation, die per SET LOCAL app.current_organisation gesetzt
# wurde (siehe src/lib/db.ts).
DATABASE_URL=postgresql://florian_app:bitte_aendern_app@postgres:5432/florian?schema=public
# Passwort des Anwendungsbenutzers. Muss zu DATABASE_URL passen und wird beim
# ersten Start von docker/postgres/10-init-app-role.sh gesetzt.
POSTGRES_APP_PASSWORD=bitte_aendern_app
# Verbindung für Migrationen und Seed. Nutzt den Eigentümer-Zugang, der RLS
# umgehen darf. Niemals im Anwendungscode verwenden.
DIRECT_DATABASE_URL=postgresql://florian:bitte_aendern_postgres@postgres:5432/florian?schema=public
# -----------------------------------------------------------------------------
# Redis (Warteschlangen für BullMQ, Rate-Limiting)
# -----------------------------------------------------------------------------
# Vor dem ersten Start ersetzen: openssl rand -base64 24
REDIS_PASSWORD=bitte_aendern_redis
REDIS_URL=redis://:bitte_aendern_redis@redis:6379
# Port auf dem Host (nur Entwicklung).
REDIS_PORT=6389
# Anzahl paralleler Jobs je Worker-Prozess.
WORKER_CONCURRENCY=5
# -----------------------------------------------------------------------------
# MinIO (S3-kompatibler Objektspeicher — ALLE Dateien liegen hier, nie im FS)
# -----------------------------------------------------------------------------
MINIO_ROOT_USER=florian
# Mindestens 8 Zeichen, vor dem ersten Start ersetzen.
MINIO_ROOT_PASSWORD=bitte_aendern_minio
# Endpunkt, den App und Worker intern ansprechen — im Compose-Netz der
# Dienstname, außerhalb der externe Hostname.
S3_ENDPOINT=minio
S3_PORT=9000
# true, sobald MinIO hinter Traefik mit TLS läuft.
S3_USE_SSL=false
S3_ACCESS_KEY=florian
S3_SECRET_KEY=bitte_aendern_minio
S3_REGION=eu-central-1
# Bucket für alle Nutzdateien (Prüfprotokolle, Fotos, Rechnungen, Exporte).
S3_BUCKET=florian
# Öffentlich erreichbare Basis-URL für presignte Download-Links. In der
# Entwicklung http://localhost:9010, in Produktion https://$MINIO_DOMAIN
S3_PUBLIC_URL=http://localhost:9010
# Gültigkeitsdauer presignter Links in Sekunden (Standard: 15 Minuten).
S3_URL_TTL=900
# Ports auf dem Host (nur Entwicklung): API und Weboberfläche.
MINIO_API_PORT=9010
MINIO_CONSOLE_PORT=9011
# -----------------------------------------------------------------------------
# Gotenberg (PDF-Erzeugung für Prüfprotokolle, Berichte, Rechnungen)
# -----------------------------------------------------------------------------
GOTENBERG_URL=http://gotenberg:3000
# Zeitlimit je PDF-Auftrag in Sekunden.
GOTENBERG_TIMEOUT=30
# -----------------------------------------------------------------------------
# Auth.js — Sitzungsverwaltung
# -----------------------------------------------------------------------------
# Signiert die Sitzungs-Cookies. Zwingend erforderlich, pro Installation neu:
# openssl rand -base64 32
AUTH_SECRET=bitte_aendern_mindestens_32_zeichen_lang
# Muss hinter einem Reverse Proxy (Traefik) auf true stehen, damit Auth.js die
# X-Forwarded-Header auswertet und korrekte Redirect-URIs erzeugt.
AUTH_TRUST_HOST=true
# Lebensdauer einer Sitzung in Sekunden (Standard: 8 Stunden).
AUTH_SESSION_MAX_AGE=28800
# -----------------------------------------------------------------------------
# OIDC gegen authentik
# Die authentik-Instanz wird SEPARAT betrieben und ist nicht Teil dieses Stacks.
# In authentik anzulegen: Provider "OAuth2/OpenID" (Client-Typ: confidential),
# Redirect-URI exakt $APP_URL/api/auth/callback/authentik
# Scopes: openid profile email — plus einen Gruppen-Scope, wenn Rollen aus
# authentik-Gruppen vorbelegt werden sollen (siehe AUTH_OIDC_GROUP_CLAIM).
# -----------------------------------------------------------------------------
# false blendet den OIDC-Knopf aus; dann bleibt nur der lokale Login. Sinnvoll
# für den Erststart, bevor authentik konfiguriert ist.
AUTH_OIDC_ENABLED=false
# Issuer-URL des Providers, OHNE /.well-known/openid-configuration.
# Bei authentik: https://<authentik-host>/application/o/<application-slug>/
AUTH_OIDC_ISSUER=https://auth.beispiel.de/application/o/florian/
AUTH_OIDC_CLIENT_ID=
AUTH_OIDC_CLIENT_SECRET=
# Beschriftung des Anmeldeknopfes in der Oberfläche.
AUTH_OIDC_NAME=Anmeldung über authentik
# Claim, aus dem die Gruppenzugehörigkeit gelesen wird. In authentik über ein
# Scope-Mapping bereitzustellen. Leer lassen, um das Mapping abzuschalten —
# dann werden Rollen ausschließlich in FLORIAN gepflegt.
AUTH_OIDC_GROUP_CLAIM=groups
# Zuordnung authentik-Gruppe -> FLORIAN-Rolle, kommagetrennt als "gruppe:ROLLE".
# Wirkt nur als VORBELEGUNG beim ersten Login; danach ist die in FLORIAN
# gepflegte Rolle führend und wird nicht mehr überschrieben.
AUTH_OIDC_GROUP_MAPPING=feuerwehr-kommandant:KOMMANDANT,feuerwehr-geraetewart:GERAETEWART,feuerwehr-atemschutz:ATEMSCHUTZGERAETEWART,feuerwehr-kassier:KASSIER,feuerwehr-schriftfuehrer:SCHRIFTFUEHRER,feuerwehr-ausbilder:AUSBILDER,feuerwehr-mitglied:MITGLIED
# true legt einen in authentik bekannten Nutzer beim ersten Login automatisch
# als Person an. false verlangt, dass die Person vorher in FLORIAN existiert
# und über die E-Mail-Adresse verknüpft wird.
AUTH_OIDC_AUTO_PROVISION=true
# Organisation (Slug), der automatisch angelegte Personen zugeordnet werden.
AUTH_OIDC_STANDARD_ORGANISATION=ff-musterhausen
# -----------------------------------------------------------------------------
# Lokaler E-Mail/Passwort-Login (Rückfalloption für Erststart und Notbetrieb)
# -----------------------------------------------------------------------------
# Nach der Einrichtung von authentik auf false setzen, damit nur noch OIDC geht.
AUTH_LOCAL_ENABLED=true
# Zugangsdaten des ersten Administrators, angelegt vom Seed-Skript.
# Nach dem ersten Login das Passwort in der Oberfläche ändern.
SEED_ADMIN_EMAIL=admin@beispielwehr.de
SEED_ADMIN_PASSWORT=Feuerwehr2026
# bcrypt-Kostenfaktor. 12 ist ein guter Kompromiss aus Sicherheit und Tempo.
AUTH_BCRYPT_ROUNDS=12
# -----------------------------------------------------------------------------
# Terminal-/Kiosk-Anmeldung im Gerätehaus (PIN und RFID)
# -----------------------------------------------------------------------------
# Schaltet den Kiosk-Modus samt Terminal-Login frei.
KIOSK_ENABLED=true
# Gemeinsames Geheimnis, mit dem sich ein Terminal-Gerät gegenüber dem Server
# ausweist, bevor überhaupt eine PIN oder ein Token akzeptiert wird:
# openssl rand -hex 32
KIOSK_DEVICE_SECRET=bitte_aendern_kiosk_geheimnis
# Länge der Personen-PIN. 4 ist bequem, 6 ist sicherer.
KIOSK_PIN_LAENGE=6
# Nach wie vielen Fehlversuchen eine PIN gesperrt wird.
KIOSK_MAX_FEHLVERSUCHE=5
# Sperrdauer nach zu vielen Fehlversuchen, in Sekunden.
KIOSK_SPERRDAUER=300
# Automatische Abmeldung am Terminal nach Untätigkeit, in Sekunden.
KIOSK_IDLE_TIMEOUT=120
# -----------------------------------------------------------------------------
# E-Mail-Versand (optional, hinter lib/integrations/mail.ts abstrahiert)
# Ohne MAIL_ENABLED=true wird nur ins Log geschrieben — praktisch für Tests.
# -----------------------------------------------------------------------------
MAIL_ENABLED=false
MAIL_HOST=smtp.beispiel.de
MAIL_PORT=587
MAIL_SECURE=false
MAIL_USER=
MAIL_PASSWORD=
MAIL_FROM=FLORIAN <florian@beispielwehr.de>
# -----------------------------------------------------------------------------
# Telegram-Benachrichtigungen (optional, abschaltbar)
# -----------------------------------------------------------------------------
TELEGRAM_ENABLED=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# -----------------------------------------------------------------------------
# LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang)
# -----------------------------------------------------------------------------
LLM_ENABLED=false
# OpenAI-kompatibler Endpunkt, z. B. eine lokale Ollama- oder vLLM-Instanz.
LLM_BASE_URL=http://ollama:11434/v1
LLM_API_KEY=
LLM_MODELL=qwen2.5:14b
# -----------------------------------------------------------------------------
# Datenschutz und Aufbewahrung (DSGVO)
# -----------------------------------------------------------------------------
# Aufbewahrungsfrist für Einsatz- und Prüfdokumentation in Jahren.
AUFBEWAHRUNG_JAHRE=10
# Aufbewahrungsfrist für den Audit-Log in Tagen. Der Log ist append-only;
# Älteres wird vom Worker exportiert, bevor es entfernt wird.
AUDIT_AUFBEWAHRUNG_TAGE=3650
# Frist in Tagen, nach der ein ausgetretenes Mitglied anonymisiert wird.
PERSON_ANONYMISIERUNG_TAGE=365
# -----------------------------------------------------------------------------
# Telemetrie bleibt aus. Nicht ändern.
# -----------------------------------------------------------------------------
NEXT_TELEMETRY_DISABLED=1
CHECKPOINT_DISABLE=1
+42
View File
@@ -0,0 +1,42 @@
# Abhängigkeiten
node_modules/
.pnp
.pnp.js
# Build-Artefakte
.next/
out/
build/
dist/
*.tsbuildinfo
next-env.d.ts
# Tests
coverage/
test-results/
playwright-report/
blob-report/
.playwright/
# Umgebung — niemals einchecken
.env
.env.local
.env.*.local
!.env.example
# Laufzeitdaten (Docker-Volumes bei Bind-Mounts)
data/
docker/traefik/acme.json
# Editor / OS
.vscode/*
!.vscode/extensions.json
.idea/
.DS_Store
Thumbs.db
debug.log
*.log
# Prisma
prisma/*.db
prisma/*.db-journal
+3
View File
@@ -0,0 +1,3 @@
# Keine Telemetrie, keine Audit-Calls beim Container-Build.
audit=false
fund=false
+89
View File
@@ -0,0 +1,89 @@
# Changelog
Alle nennenswerten Änderungen an FLORIAN. Format nach
[Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionierung nach
[Semantic Versioning](https://semver.org/lang/de/).
Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht]
Noch nichts. Nächster Schritt ist Phase 1 (Stammdatenverwaltung).
## [0.1.0] — 2026-09-03
Phase 0: das Fundament. Ab hier steht ein lauffähiger Stack mit
Mandantentrennung, Anmeldung, Dateiablage und lückenloser Protokollierung.
### Hinzugefügt
**Betrieb**
- `docker-compose.yml` mit Traefik, App, Worker, PostgreSQL 17 + PostGIS,
Redis, MinIO und Gotenberg.
- `docker-compose.override.yml` für die Entwicklung: ohne Traefik, mit
Mailpit als SMTP-Auffangkorb und Adminer für den Blick in die Datenbank.
Host-Ports bewusst abseits der Standardwerte (3100, 5442, 6389, 9010/9011),
damit FLORIAN neben anderen lokalen Stacks laufen kann.
- Mehrstufiges `docker/Dockerfile`: eigener Benutzer `florian` (UID 1001,
kein root), `tini` als PID 1, Healthcheck gegen `/api/health`,
Standalone-Build von Next.js.
- `docker/entrypoint.sh` wendet ausstehende Migrationen an — nur im
App-Container, nicht im Worker.
- `docker/postgres/10-init-app-role.sh` legt beim ersten Start die Rolle
`florian_app` (`NOBYPASSRLS`) samt Vorgabeprivilegien an.
- `.env.example` mit einem Kommentar zu jeder einzelnen Variable.
**Datenmodell**
- Prisma-Schema mit `Organisation`, `Person`, `Rolle`, `Berechtigung`,
`RolleBerechtigung`, `PersonRolle`, `AusweisToken`, `AuditLog`, `Datei`.
- `Fahrzeug` und `Geraet` bereits schmal angelegt, damit der Seed eine
realistische Wehr aufbauen kann. Phase 1 erweitert beide.
- Migration `20260902000100_rls_policies`: Row Level Security auf allen
mandantenpflichtigen Tabellen, Funktion `aktuelle_organisation()`,
Append-only-Trigger auf `audit_log`.
- Migration `20260902000200_audit_bereinigung`: `UPDATE` auf `audit_log`
bleibt dauerhaft gesperrt, `DELETE` ist nur im gekennzeichneten
Aufbewahrungslauf möglich.
**Anwendungskern**
- `lib/env.ts` — Zod-Validierung der gesamten Umgebung beim Start.
- `lib/db.ts` — getrennte Verbindungen für Anwendung (RLS aktiv) und System
(RLS-frei), `fuerOrganisation()` als einziger Weg zu Fachdaten.
- `lib/authz.ts` — 9 Rollen, 38 Einzelrechte, zentrale Prüffunktionen.
- `lib/storage.ts` — MinIO, presignte Up- und Downloads, SHA-256-Prüfsumme.
- `lib/audit.ts` — Protokolleinträge mit Vorher/Nachher-Vergleich, entfernt
Geheimnisse vor dem Schreiben.
- `lib/auth.ts` — Auth.js mit OIDC gegen authentik, lokalem Passwort-Login
und Terminal-Anmeldung über PIN oder RFID.
- `lib/warteschlange.ts` und `src/worker/` — BullMQ-Gerüst mit den
Auftragsarten für Fristen, Erinnerungen, PDF und Aufbewahrung.
- `lib/integrations/` — Mail, Telegram und LLM hinter abschaltbaren
Schnittstellen.
- tRPC v11 mit den Prozedurarten `oeffentlich`, `angemeldet` und
`mitRecht(...)`.
**Oberfläche**
- Basis-Layout mit Seitenleiste, heller und dunkler Darstellung
(Systemvorgabe als Standard) und durchgängig deutschen Texten.
- Anmeldeseite, die genau die eingerichteten Anmeldewege zeigt.
- Übersicht mit Kennzahlen und dem Sicherheitshinweis zur analogen
Atemschutzüberwachung.
- Farbwelt in OKLCH mit festen Statusfarben für Gerätezustände.
**Daten und Tests**
- Seed mit einer vollständigen Beispielwehr: 15 Personen mit Rollen und
Terminal-Ausweisen, 3 Fahrzeuge, 40 Geräte mit gemischten Prüffristen.
- Vitest: 32 Tests, darunter acht, die die Mandantentrennung gegen eine echte
Datenbank prüfen.
- Playwright: 10 Tests über Desktop und Tablet.
- `npm run screenshots` erzeugt die Bilder für die Anleitung.
**Dokumentation**
- README mit Schnellstart in sechs Zeilen.
- Anleitung, Roadmap, Aufwandsschätzung, Devlog und Versionierungsregeln.
+62
View File
@@ -0,0 +1,62 @@
# Projekt: FLORIAN — Feuerwehr-Verwaltungsplattform
## Kontext
Selbstgehostete Verwaltungssoftware für Freiwillige Feuerwehren in Deutschland
(Bayern). Zielgruppe: Kommandant, Gerätewart, Atemschutzgerätewart, Schriftführer,
Kassier, Mannschaft. Betrieb: Docker Compose auf Proxmox VE, kein Cloud-Zwang,
DSGVO-konform, Daten bleiben im Gerätehaus bzw. auf eigener Hardware.
## Nicht verhandelbarer Stack
- Next.js 15 App Router, TypeScript strict, React Server Components wo sinnvoll
- Prisma + PostgreSQL 17 mit PostGIS
- tRPC für interne Aufrufe, REST unter /api/v1 für externe Integrationen
- Tailwind CSS + shadcn/ui
- Redis + BullMQ für Hintergrundjobs
- MinIO (S3-API) für alle Dateien — niemals lokale Pfade
- Zod für jede Ein- und Ausgabevalidierung
- Vitest (Unit) + Playwright (E2E)
- Alles auf Deutsch: UI-Texte, Feldnamen in der UI, Kommentare.
Code-Bezeichner auf Englisch, Domänenbegriffe deutsch belassen
(z. B. `Atemschutztrupp`, `Pruefintervall`, `Geraetewart`).
## Architekturregeln
1. Jede Tabelle hat `organisationId` (Mandant). Postgres RLS ist aktiv.
Kein Query ohne Mandantenfilter.
2. Jede schreibende Operation erzeugt einen Eintrag in `AuditLog`
(Actor, Zeitpunkt, Entität, Vorher/Nachher als JSONB). Append-only.
3. Prüf-, Wartungs- und Buchungsdaten werden nie hart gelöscht (Soft-Delete
+ Versionierung).
4. Alle Fristen und Zeitberechnungen serverseitig, Zeitzone Europe/Berlin.
5. Berechtigungen zentral in `lib/authz.ts`. Keine Rollenprüfung in Komponenten.
6. Dateizugriff ausschließlich über `lib/storage.ts`.
7. Externe Dienste (LLM, Mail, Telegram) hinter Interfaces in `lib/integrations/`,
damit sie im Test mockbar und optional abschaltbar sind.
## Domänenwissen, das zu beachten ist
- DGUV-Prüffristen: Geräte haben Prüfintervalle aus Vorschrift oder
Herstellervorgabe. Status: einsatzbereit | pruefung_faellig | gesperrt |
ausgesondert.
- Lebensdauerkategorien: Helme, Seile, Pressluftatmer, Schutzkleidung haben
eine maximale Nutzungsdauer ab Herstellungs- oder Indienststellungsdatum.
- FwDV 7 (Atemschutz): Überwachung dokumentiert Truppzusammensetzung,
Einsatzbeginn, Druckwerte in Intervallen, Rückzugsdruck, Ende.
- FwDV 3: Einsatzstärke wird als Staffel/Gruppe/Zug erfasst.
- Einsatzzeitstempel: Alarmierung, Ausrücken, Eintreffen, Einsatzende,
Einrücken, Einsatzbereitschaft.
## Sicherheits- und Rechtsleitplanken
- Atemschutzüberwachung und Alarmmonitor sind sicherheitsrelevant. Jede
entsprechende Ansicht zeigt einen Hinweis, dass die analoge Überwachung
führend bleibt, solange das System nicht freigegeben ist.
- Personenbezogene Daten: Löschkonzept, Export je Person, Aufbewahrungsfristen
konfigurierbar.
- Keine Telemetrie, keine externen CDNs, keine Google Fonts. Alles lokal.
## Arbeitsweise
- Arbeite phasenweise. Fasse nie mehrere Phasen in einem Schritt zusammen.
- Beginne jede Phase mit dem Prisma-Schema, dann Migration, dann Server-Logik,
dann UI, dann Tests.
- Nach jeder Phase: `docker compose up` muss durchlaufen, `npm run test`
muss grün sein, README-Abschnitt zur Phase ergänzen.
- Wenn eine Anforderung fachlich unklar ist: nachfragen statt raten.
Feuerwehr-Fachlichkeit ist regional unterschiedlich.
+23
View File
@@ -0,0 +1,23 @@
Lies CLAUDE.md. Erstelle Phase 0 des Projekts: das Fundament.
Liefere:
1. Monorepo-Struktur (Next.js App, prisma/, docker/, scripts/)
2. docker-compose.yml mit: traefik, app, worker, postgres (postgis/postgis:17),
redis, minio, gotenberg. Dazu docker-compose.override.yml für Entwicklung
und eine vollständige .env.example mit Kommentaren zu jeder Variable.
3. Dockerfile für die App (Multi-Stage, non-root User, Healthcheck).
4. Prisma-Schema mit den Basismodellen: Organisation, Person, Rolle,
Berechtigung, AusweisToken, AuditLog, Datei. Inklusive RLS-Policies als
SQL-Migration.
5. Auth: OIDC gegen authentik (Auth.js) plus lokaler E-Mail/Passwort-Fallback
für den Erststart, plus Terminal-Login per PIN/RFID-Token für den Kiosk.
6. lib/authz.ts mit Rollen: SUPERADMIN, KOMMANDANT, GERAETEWART,
ATEMSCHUTZGERAETEWART, KASSIER, SCHRIFTFUEHRER, AUSBILDER, MITGLIED, KIOSK.
7. lib/storage.ts (MinIO), lib/audit.ts, lib/db.ts (mit Mandanten-Kontext).
8. Basis-Layout mit shadcn/ui, deutschem UI-Text, Dark Mode, Seitenleiste.
9. Seed-Skript: eine Beispielwehr, 15 Personen, 3 Fahrzeuge, 40 Geräte.
10. Vitest- und Playwright-Setup mit je einem Beispieltest.
11. README mit Schnellstart in unter 10 Zeilen.
Erkläre am Ende kurz, wie ich den Stack hochfahre und mich anmelde.
Frage nach, bevor du Annahmen über meine authentik-Konfiguration triffst.
+19
View File
@@ -0,0 +1,19 @@
Phase 1: Stammdatenverwaltung.
Modelle: Standort, Fahrzeug (mit Funkrufname, Kennzeichen, Fahrgestellnummer,
Baujahr, Typ nach DIN, TÜV-Termin), Geraeteraum, Geraetetyp, Geraet
(Inventarnummer, Seriennummer, Hersteller, Herstellungsdatum,
Indienststellung, Standort ODER Fahrzeug+Geraeteraum, Status,
Prüfintervall, Lebensdauer-Kategorie), Qualifikationstyp, Qualifikation
(Person, Typ, erworben_am, gueltig_bis).
Funktionen:
- CRUD mit Tabellen (Sortierung, Filter, Volltextsuche, Spaltenauswahl)
- QR-Code je Gerät generieren, Etikettenbogen als PDF (DIN A4, 65 Etiketten)
- QR-Scan im Browser (Kamera) öffnet direkt die Gerätedetailseite
- CSV-/Excel-Import mit Spaltenzuordnung und Vorschau vor dem Übernehmen
- Gerätehistorie (Standortwechsel, Statuswechsel) aus dem AuditLog gerendert
- Qualifikationen mit Ablaufwarnung im Dashboard
UI: Deutsch, tastaturfreundlich, Massenbearbeitung für Statuswechsel.
Tests für Import und QR-Route.
+29
View File
@@ -0,0 +1,29 @@
Phase 2: Wartungs- und Prüfmanagement. Das ist das wichtigste Modul.
Modelle: Wartungsvorlage (Name, Geraetetyp, Intervall in Monaten,
Vorwarnzeit, Pruefvorschrift-Referenz), Checklistenpunkt (Text, Typ:
ja_nein | messwert | text | foto, Pflichtfeld, Sollwert), Wartungstermin
(Geraet, Vorlage, faellig_am, Status), Pruefung (Termin, Prüfer,
durchgefuehrt_am, Dauer in Minuten, Ergebnis: bestanden |
bestanden_mit_maengeln | nicht_bestanden, extern durchgeführt von,
Kosten), Pruefergebnis (Checklistenpunkt, Wert, Foto), Signatur,
Mangel (Geraet, Melder, Beschreibung, Foto, Prioritaet, Status,
behoben_am, behoben_von), Rueckruf (Hersteller, betroffene Typen/Chargen).
Funktionen:
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage,
gruppierbar nach Fahrzeug, Standort, Gerätetyp, Prüfer
- BullMQ-Job (nächtlich): Termine generieren, Erinnerungen bei T-30/T-7/T-0,
Gerätestatus automatisch auf "pruefung_faellig" setzen
- Prüfungs-Wizard: Checkliste Schritt für Schritt, Fotoupload je Punkt,
Zeiterfassung, Unterschrift auf Canvas (Touch und Maus)
- PDF-Prüfprotokoll via Gotenberg: Kopf mit Wehr/Gerät/Prüfer-ID,
Checkliste mit Ergebnissen, eingebettete Fotos, eingebettete Signatur,
Fußzeile mit Prüfvorschrift und Prüfdatum. Ablage in MinIO, unveränderbar.
- Lebensdauerüberwachung: Warnung X Monate vor Ablauf, Vorschlag zur Aussonderung
- Rückrufe: betroffene Geräte automatisch markieren und optional sperren
- Externe Wartung: Fachfirma erfassen, Fremdprotokoll als PDF anhängen
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben, mit Benachrichtigung
Wichtig: Ein abgeschlossenes Prüfprotokoll ist unveränderbar. Korrekturen
erzeugen ein neues Protokoll mit Verweis auf das ursprüngliche.
+20
View File
@@ -0,0 +1,20 @@
Phase 3: Einsatz- und Übungserfassung.
Modelle: Einsatz (Einsatznummer, Stichwort, Kategorie nach Landesstatistik,
Adresse, Geokoordinaten, Zeitstempel: alarmiert/ausgerueckt/eingetroffen/
einsatzende/eingerueckt/einsatzbereit, Beschreibung, Bericht,
Einsatzleiter, Wetter), Einsatzfahrzeug, Einsatzkraft (Person, Funktion,
Zeit von/bis), Materialverbrauch, Lagemeldung (Text, Foto, wichtig-Flag),
Uebung (analog, mit Ausbildungsthema und Lernzielen).
Funktionen:
- Schnellerfassung: Stichwort + Adresse + Fahrzeuge, Rest später nachtragen
- Geocoding über self-hosted Nominatim (mit Fallback auf manuelle Koordinaten)
- Personalauswahl mit Vorschlag anhand Verfügbarkeit und Qualifikation
- Stärkeberechnung nach FwDV 3 (z. B. 1/2/6/9) automatisch
- Einsatzbericht als PDF, Sammelexport pro Jahr für die Gemeinde
- Statistik: Einsätze nach Kategorie/Monat, Eintreffzeiten als Verteilung,
Beteiligung je Person, Fahrzeugauslastung
- Externes Portal: signierter QR-Link, ohne Login, für Fremdwehren —
nur Statusmeldung und Lagemeldung mit Foto, zeitlich begrenzt gültig,
rate-limited
+21
View File
@@ -0,0 +1,21 @@
Phase 4: Kiosk-Oberfläche als PWA für Wandtablets in der Fahrzeughalle.
Anforderungen:
- Eigene Route /kiosk mit eigenem Layout: große Kacheln, mindestens 64px
Touchziele, hoher Kontrast, mit Handschuhen bedienbar, kein Hover-Zwang
- Login: RFID (WebHID/Tastaturemulation des Lesers), 6-stellige PIN,
QR-Code vom Handy. Auto-Logout nach 90 Sekunden Inaktivität.
- Rollenabhängige Kachelauswahl
- Wizards: Wartung durchführen, Fahrtenbuch eintragen, Mangel melden
(QR scannen → Foto → fertig in unter 30 Sekunden), Inventur scannen,
Wäsche abgeben, Anwesenheit ein-/auschecken
- Hallen-Monitor-Modus (/kiosk/monitor): Vollbild ohne Interaktion, zeigt
nächste Termine, überfällige Prüfungen, Fahrzeugstatus, Wetterwarnung
(DWD Open Data), Uhrzeit
- Offline: Service Worker cached App-Shell und Stammdaten. Schreibende
Aktionen gehen in eine IndexedDB-Outbox (Dexie) mit UUID als Idempotenzschlüssel
und werden bei Reconnect synchronisiert. Sichtbare Statusanzeige
"offline / N Änderungen ausstehend". Konflikte: Server gewinnt bei
Stammdaten, Client gewinnt bei neuen Datensätzen, Konfliktliste für Admin.
Schreibe Playwright-Tests, die den Offline-Fall simulieren.
+28
View File
@@ -0,0 +1,28 @@
Phase 5: Atemschutzüberwachung nach FwDV 7.
WICHTIG: Jede Ansicht dieses Moduls zeigt oben dauerhaft den Hinweis:
"Ergänzendes System — die analoge Atemschutzüberwachung bleibt führend."
Dieser Hinweis ist erst über eine Konfigurationsoption abschaltbar, die
dokumentiert eine Freigabe durch den Kommandanten voraussetzt.
Modelle: Atemschutzeinsatz (Einsatz, Überwacher, Beginn, Ende),
Trupp (Truppführer, Truppmann, Auftrag, Funkrufname, Status:
bereit|im_einsatz|rueckweg|draussen), Druckmessung (Trupp, Person,
Zeitpunkt, Druck in bar, Art: eingang|zwischen|ausgang),
G26Untersuchung (Person, Datum, gueltig_bis), Belastungsuebung
(Person, Datum, Art, Ort).
Funktionen:
- Truppanlage per RFID-Scan oder Auswahl, Eingangsdruck erfassen
- Live-Timer je Trupp, serverseitig autoritativ, per Socket.IO verteilt
- Rückzugsdruck-Berechnung (konfigurierbare Formel, Standard:
Eingangsdruck-Verbrauchsrate) mit optischer Ampel und akustischem Alarm
bei Schwellwert
- Erinnerung zur Druckabfrage in konfigurierbarem Intervall (Standard 10 min)
- Notfallknopf je Trupp (Mayday) mit Protokolleintrag
- Lückenloses Protokoll als PDF, angehängt an den Einsatz
- Verwaltung: G26.3-Fristen, Belastungsübungen, Einsatzstunden je AGT,
Tauglichkeitsübersicht mit Ampel, Dashboard-Widget "verfügbare AGT"
Timer müssen einen Browser-Reload und einen Verbindungsabbruch überstehen.
Das ist die wichtigste Testanforderung.
+15
View File
@@ -0,0 +1,15 @@
Phase 6: Kartenmodule.
- MapLibre GL mit self-hosted Tiles (tileserver-gl, MBTiles fürs Ausrückegebiet)
- Hydranten: PostGIS-Point, Typ (Unterflur/Überflur/Saugstelle/Zisterne/
offenes Gewaesser), Nennweite, Durchfluss l/min, letzte Prüfung, Status,
Foto, Notiz. Import aus GeoJSON und aus einer CSV der Stadtwerke.
Cluster-Darstellung, Filter nach Status, Umkreissuche.
- Objekte/Feuerwehrpläne: Objekt mit Adresse, Kategorie, Ansprechpartner,
Zufahrt, Gefahren, Schlüsseldepot, angehängte PDF-Pläne nach DIN 14095,
Volltextsuche, Sofortzugriff vom Einsatz aus
- Lagekarte: Zeichenebene mit taktischen Zeichen nach FwDV 102 als SVG-Set,
freie Skizzen, Speicherung je Einsatz, Echtzeit-Sync zwischen Geräten
- Wasserförderung: Strecke aus Wegpunkten, Höhenprofil, Berechnung von
Druckverlust und benötigten Verstärkerpumpen bei gegebener Schlauchlänge,
Durchmesser und Fördermenge. Ausgabe als druckbares Schema.
+14
View File
@@ -0,0 +1,14 @@
Phase 7: Benachrichtigungen und Termine.
- Notification-Abstraktion mit Kanälen: E-Mail (SMTP), Telegram (Bot API),
ntfy (self-hosted Push), Web-Push. WhatsApp bewusst NICHT — begründe das
im README kurz mit Kosten und Datenschutz.
- Benachrichtigungsvorlagen mit Platzhaltern, pro Ereignistyp und Rolle
konfigurierbar, je Person abonnierbar
- Ereignisse: Prüfung fällig, Mangel gemeldet, Qualifikation läuft ab,
neuer Termin, Termin-Erinnerung, Einsatzbericht freigegeben
- Kalender: Dienste, Übungen, Lehrgänge, Veranstaltungen, Arbeitsdienste.
Serientermine, Selbstanmeldung mit Teilnehmerlimit, Absage mit Grund,
Anwesenheitsliste zum Ausdrucken, CalDAV-/ICS-Feed je Person mit Token.
- Digitaler Dienstausweis: signierter QR mit kurzer Gültigkeit, Prüfseite
für Kontrollen, Anzeige von Name, Wehr, Funktion, Foto, Qualifikationen.
+16
View File
@@ -0,0 +1,16 @@
Phase 8: Lokale KI-Funktionen. Alles gegen Ollama, keine externen Anbieter.
- lib/ai/ mit OpenAI-kompatiblem Client, Modell und Endpunkt konfigurierbar,
komplett abschaltbar per Feature-Flag
- Diktat: Audioaufnahme im Browser → faster-whisper Container → Transkript
in das Einsatzberichtsfeld
- Berichtsassistent: aus Stichpunkten, Zeitstempeln, eingesetzten Fahrzeugen
und Materialverbrauch einen strukturierten Einsatzberichtsentwurf erzeugen.
Ausgabe immer als Vorschlag mit Übernehmen/Verwerfen, nie automatisch speichern.
- Wiki mit pgvector: Dienstvorschriften und Ausbildungsunterlagen als
Markdown, semantische Suche, RAG-Antwort mit Quellenangabe der Wiki-Seite
- Belegerfassung: Foto einer Quittung → Vision-Modell → Vorschlag für
Betrag, Datum, Händler, Kategorie. Immer manuell bestätigen.
Jede KI-Ausgabe ist als solche gekennzeichnet und muss bestätigt werden,
bevor sie in ein Dokument mit Nachweischarakter gelangt.
+216
View File
@@ -0,0 +1,216 @@
# 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 | offen |
| Phase 2 — Wartung und Prüfung | offen |
| 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 |
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).
---
## 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.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+116
View File
@@ -0,0 +1,116 @@
# =============================================================================
# Entwicklungs-Override. Docker Compose lädt diese Datei automatisch zusätzlich
# zu docker-compose.yml — für den Produktionsbetrieb daher explizit nur die
# Hauptdatei angeben:
# docker compose -f docker-compose.yml up -d
#
# Unterschiede zur Produktion:
# - kein Traefik (die Dienste hängen direkt an localhost-Ports)
# - Quellcode als Bind-Mount, Hot Reload über "next dev"
# - Mailpit als lokaler SMTP-Auffangkorb
# - Adminer zum Draufschauen auf die Datenbank
# =============================================================================
services:
traefik:
# Wird in der Entwicklung nicht gebraucht. Mit dem Profil "proxy" trotzdem
# startbar: docker compose --profile proxy up -d
profiles: ["proxy"]
postgres:
ports:
- "${POSTGRES_PORT:-5432}:5432"
redis:
ports:
- "${REDIS_PORT:-6379}:6379"
minio:
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
environment:
# Ohne TLS-Terminierung zeigt die Konsole auf localhost.
MINIO_BROWSER_REDIRECT_URL: http://localhost:${MINIO_CONSOLE_PORT:-9001}
MINIO_SERVER_URL: http://localhost:${MINIO_API_PORT:-9000}
labels:
- traefik.enable=false
gotenberg:
ports:
- "${GOTENBERG_PORT:-3101}:3000"
app:
build:
target: builder
command: ["npm", "run", "dev"]
ports:
# "next dev" laeuft im Container auf 3100 (siehe package.json).
- "${APP_PORT:-3100}:3100"
environment:
NODE_ENV: development
# Datei-Watching funktioniert über Bind-Mounts von Windows aus nur mit
# Polling zuverlässig.
WATCHPACK_POLLING: "true"
CHOKIDAR_USEPOLLING: "true"
volumes:
- ./src:/app/src
- ./prisma:/app/prisma
- ./public:/app/public
- ./tests:/app/tests
- ./next.config.ts:/app/next.config.ts
- ./tsconfig.json:/app/tsconfig.json
- ./postcss.config.mjs:/app/postcss.config.mjs
# Anonyme Volumes: node_modules und .next bleiben im Container.
- /app/node_modules
- /app/.next
labels:
- traefik.enable=false
worker:
build:
target: builder
command: ["npm", "run", "worker:dev"]
environment:
NODE_ENV: development
ROLLE: worker
volumes:
- ./src:/app/src
- ./prisma:/app/prisma
- /app/node_modules
# ---------------------------------------------------------------------------
# Nur Entwicklung: SMTP-Auffangkorb. Alle Mails landen hier statt beim
# Empfänger. Weboberfläche: http://localhost:${MAILPIT_UI_PORT:-8125}
# ---------------------------------------------------------------------------
mailpit:
image: axllent/mailpit:latest
container_name: florian-mailpit
restart: unless-stopped
environment:
MP_MAX_MESSAGES: 500
TZ: ${TZ:-Europe/Berlin}
ports:
- "${MAILPIT_UI_PORT:-8125}:8025"
- "${MAILPIT_SMTP_PORT:-1125}:1025"
networks:
- intern
# ---------------------------------------------------------------------------
# Nur Entwicklung: schlanke Datenbankoberfläche. http://localhost:${ADMINER_PORT:-8081}
# Anmeldung: System PostgreSQL, Server "postgres", Benutzer/Passwort aus .env
# ---------------------------------------------------------------------------
adminer:
image: adminer:latest
container_name: florian-adminer
restart: unless-stopped
environment:
ADMINER_DEFAULT_SERVER: postgres
ADMINER_DESIGN: dracula
ports:
- "${ADMINER_PORT:-8081}:8080"
depends_on:
postgres:
condition: service_healthy
networks:
- intern
+335
View File
@@ -0,0 +1,335 @@
# =============================================================================
# FLORIAN — Produktionsnaher Stack.
# Für die Entwicklung wird docker-compose.override.yml automatisch daraufgelegt;
# die veröffentlicht die Dienste direkt auf localhost und braucht kein Traefik.
# Produktion starten: docker compose -f docker-compose.yml up -d
# Entwicklung starten: docker compose up -d
# =============================================================================
name: florian
x-app-env: &app-env
NODE_ENV: ${NODE_ENV:-production}
TZ: ${TZ:-Europe/Berlin}
APP_URL: ${APP_URL:-http://localhost:3000}
DATABASE_URL: ${DATABASE_URL}
DIRECT_DATABASE_URL: ${DIRECT_DATABASE_URL}
REDIS_URL: ${REDIS_URL}
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5}
S3_ENDPOINT: ${S3_ENDPOINT:-minio}
S3_PORT: ${S3_PORT:-9000}
S3_USE_SSL: ${S3_USE_SSL:-false}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-eu-central-1}
S3_BUCKET: ${S3_BUCKET:-florian}
S3_PUBLIC_URL: ${S3_PUBLIC_URL}
S3_URL_TTL: ${S3_URL_TTL:-900}
GOTENBERG_URL: ${GOTENBERG_URL:-http://gotenberg:3000}
GOTENBERG_TIMEOUT: ${GOTENBERG_TIMEOUT:-30}
AUTH_SECRET: ${AUTH_SECRET}
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
AUTH_SESSION_MAX_AGE: ${AUTH_SESSION_MAX_AGE:-28800}
AUTH_OIDC_ENABLED: ${AUTH_OIDC_ENABLED:-false}
AUTH_OIDC_ISSUER: ${AUTH_OIDC_ISSUER:-}
AUTH_OIDC_CLIENT_ID: ${AUTH_OIDC_CLIENT_ID:-}
AUTH_OIDC_CLIENT_SECRET: ${AUTH_OIDC_CLIENT_SECRET:-}
AUTH_OIDC_NAME: ${AUTH_OIDC_NAME:-authentik}
AUTH_OIDC_GROUP_CLAIM: ${AUTH_OIDC_GROUP_CLAIM:-groups}
AUTH_OIDC_GROUP_MAPPING: ${AUTH_OIDC_GROUP_MAPPING:-}
AUTH_OIDC_AUTO_PROVISION: ${AUTH_OIDC_AUTO_PROVISION:-true}
AUTH_OIDC_STANDARD_ORGANISATION: ${AUTH_OIDC_STANDARD_ORGANISATION:-}
AUTH_LOCAL_ENABLED: ${AUTH_LOCAL_ENABLED:-true}
AUTH_BCRYPT_ROUNDS: ${AUTH_BCRYPT_ROUNDS:-12}
KIOSK_ENABLED: ${KIOSK_ENABLED:-true}
KIOSK_DEVICE_SECRET: ${KIOSK_DEVICE_SECRET}
KIOSK_PIN_LAENGE: ${KIOSK_PIN_LAENGE:-6}
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120}
MAIL_ENABLED: ${MAIL_ENABLED:-false}
MAIL_HOST: ${MAIL_HOST:-}
MAIL_PORT: ${MAIL_PORT:-587}
MAIL_SECURE: ${MAIL_SECURE:-false}
MAIL_USER: ${MAIL_USER:-}
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
MAIL_FROM: ${MAIL_FROM:-}
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
LLM_ENABLED: ${LLM_ENABLED:-false}
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODELL: ${LLM_MODELL:-}
AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10}
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
NEXT_TELEMETRY_DISABLED: "1"
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
# ---------------------------------------------------------------------------
# Reverse Proxy. Terminiert TLS, verteilt auf App und MinIO.
# ---------------------------------------------------------------------------
traefik:
image: traefik:v3.3
container_name: florian-traefik
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedByDefault=false
- --providers.docker.network=florian-edge
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entryPoint.to=websecure
- --entrypoints.web.http.redirections.entryPoint.scheme=https
- --entrypoints.websecure.address=:443
- --certificatesresolvers.le.acme.email=${ACME_EMAIL}
- --certificatesresolvers.le.acme.storage=/acme/acme.json
- --certificatesresolvers.le.acme.tlschallenge=true
# Kein anonymes Nutzungs-Reporting.
- --global.sendAnonymousUsage=false
- --global.checkNewVersion=false
- --log.level=INFO
- --accesslog=true
ports:
- "80:80"
- "443:443"
volumes:
# Nur lesend — Traefik braucht die Socket-Verbindung zur Diensterkennung.
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-acme:/acme
networks:
- edge
logging: *logging
# ---------------------------------------------------------------------------
# PostgreSQL 17 mit PostGIS. Einziger Ort für relationale Daten.
# ---------------------------------------------------------------------------
postgres:
image: postgis/postgis:17-3.5
container_name: florian-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-florian}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-florian}
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD}
# Deutsche Sortierung ueber ICU — das Debian-Basisimage hat keine
# de_DE-Locale erzeugt, ICU liefert sie unabhaengig vom Betriebssystem.
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8"
TZ: ${TZ:-Europe/Berlin}
volumes:
- postgres-data:/var/lib/postgresql/data
# Legt beim ERSTEN Start den RLS-pflichtigen Anwendungsbenutzer an.
- ./docker/postgres:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-florian} -d ${POSTGRES_DB:-florian}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# Redis. Warteschlangen (BullMQ) und Rate-Limiting. Keine Nutzdaten.
# ---------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: florian-redis
restart: unless-stopped
command:
- redis-server
- --requirepass
- ${REDIS_PASSWORD}
- --appendonly
- "yes"
- --maxmemory-policy
- noeviction
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
networks:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# MinIO. Ablage ALLER Dateien — Fotos, Prüfprotokolle, Rechnungen, Exporte.
# In Produktion auf ein festes RELEASE-Tag pinnen.
# ---------------------------------------------------------------------------
minio:
image: minio/minio:latest
container_name: florian-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_BROWSER_REDIRECT_URL: https://${MINIO_CONSOLE_DOMAIN}
MINIO_SERVER_URL: https://${MINIO_DOMAIN}
MINIO_UPDATE: "off"
TZ: ${TZ:-Europe/Berlin}
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- intern
- edge
labels:
- traefik.enable=true
# S3-API
- traefik.http.routers.minio-api.rule=Host(`${MINIO_DOMAIN}`)
- traefik.http.routers.minio-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
- traefik.http.routers.minio-api.tls.certresolver=le
- traefik.http.routers.minio-api.service=minio-api
- traefik.http.services.minio-api.loadbalancer.server.port=9000
# Weboberfläche
- traefik.http.routers.minio-console.rule=Host(`${MINIO_CONSOLE_DOMAIN}`)
- traefik.http.routers.minio-console.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
- traefik.http.routers.minio-console.tls.certresolver=le
- traefik.http.routers.minio-console.service=minio-console
- traefik.http.services.minio-console.loadbalancer.server.port=9001
logging: *logging
# ---------------------------------------------------------------------------
# Einmaliger Job: legt den Bucket an und setzt ihn auf privat.
# ---------------------------------------------------------------------------
minio-init:
image: minio/mc:latest
container_name: florian-minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: /bin/sh
command:
- -c
- |
mc alias set florian http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" &&
mc mb --ignore-existing florian/"$${S3_BUCKET}" &&
mc anonymous set none florian/"$${S3_BUCKET}" &&
mc version enable florian/"$${S3_BUCKET}" &&
echo "Bucket $${S3_BUCKET} bereit."
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
S3_BUCKET: ${S3_BUCKET:-florian}
networks:
- intern
restart: "no"
logging: *logging
# ---------------------------------------------------------------------------
# Gotenberg. Rendert HTML zu PDF (Prüfprotokolle, Berichte).
# ---------------------------------------------------------------------------
gotenberg:
image: gotenberg/gotenberg:8
container_name: florian-gotenberg
restart: unless-stopped
command:
- gotenberg
- --chromium-disable-javascript=false
- --chromium-allow-list=file:///tmp/.*
- --api-timeout=${GOTENBERG_TIMEOUT:-30}s
environment:
TZ: ${TZ:-Europe/Berlin}
networks:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# Die Anwendung selbst (Next.js, Standalone-Build).
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
# ---------------------------------------------------------------------------
app:
build:
context: .
dockerfile: docker/Dockerfile
target: runner
image: florian-app:${IMAGE_TAG:-latest}
container_name: florian-app
restart: unless-stopped
environment:
<<: *app-env
PORT: "3000"
HOSTNAME: "0.0.0.0"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
networks:
- intern
- edge
labels:
- traefik.enable=true
- traefik.http.routers.app.rule=Host(`${APP_DOMAIN}`)
- traefik.http.routers.app.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
- traefik.http.routers.app.tls.certresolver=le
- traefik.http.services.app.loadbalancer.server.port=3000
logging: *logging
# ---------------------------------------------------------------------------
# Hintergrundjobs: Fristenberechnung, Erinnerungen, PDF-Erzeugung, Exporte.
# Gleiches Image wie die App, anderer Einstiegspunkt.
# ---------------------------------------------------------------------------
worker:
build:
context: .
dockerfile: docker/Dockerfile
target: runner
image: florian-app:${IMAGE_TAG:-latest}
container_name: florian-worker
restart: unless-stopped
command: ["node", "--enable-source-maps", "dist/worker/index.js"]
environment:
<<: *app-env
ROLLE: worker
healthcheck:
test: ["CMD", "node", "-e", "process.exit(0)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
app:
condition: service_started
networks:
- intern
logging: *logging
volumes:
postgres-data:
redis-data:
minio-data:
traefik-acme:
networks:
# Nur was von außen erreichbar sein muss, hängt hier drin.
edge:
name: florian-edge
# Datenbank, Redis, Gotenberg: intern, kein Zugang von außen.
intern:
name: florian-intern
internal: false
+111
View File
@@ -0,0 +1,111 @@
# =============================================================================
# FLORIAN — Mehrstufiger Build.
# deps : Abhängigkeiten installieren (wird gecacht)
# builder : Prisma-Client erzeugen, Next.js bauen, Worker transpilieren
# runner : schlankes Laufzeit-Image, non-root, mit Healthcheck
# =============================================================================
ARG NODE_VERSION=22.20.0
# -----------------------------------------------------------------------------
# Stufe 1: Abhängigkeiten
# -----------------------------------------------------------------------------
FROM node:${NODE_VERSION}-bookworm-slim AS deps
WORKDIR /app
# openssl wird von Prisma benötigt, ca-certificates für ausgehendes TLS.
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json package-lock.json* .npmrc ./
RUN npm ci --include=dev
# -----------------------------------------------------------------------------
# Stufe 2: Build
# -----------------------------------------------------------------------------
FROM node:${NODE_VERSION}-bookworm-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Prisma-Client aus dem Schema erzeugen — ohne Datenbankverbindung.
RUN npx prisma generate
# Der Next.js-Build braucht formal gültige Variablen, verbindet sich aber nicht.
# Die echten Werte kommen zur Laufzeit aus der Umgebung.
RUN DATABASE_URL="postgresql://build:build@localhost:5432/build?schema=public" \
DIRECT_DATABASE_URL="postgresql://build:build@localhost:5432/build?schema=public" \
AUTH_SECRET="build_time_platzhalter_mindestens_32_zeichen" \
KIOSK_DEVICE_SECRET="build_time_platzhalter" \
SKIP_ENV_VALIDATION=1 \
npm run build
# Worker separat nach CommonJS transpilieren, damit im Runtime-Image kein
# TypeScript-Toolchain mehr nötig ist.
RUN npx esbuild src/worker/index.ts \
--bundle \
--platform=node \
--target=node22 \
--format=cjs \
--packages=external \
--sourcemap \
--outfile=dist/worker/index.js
# -----------------------------------------------------------------------------
# Stufe 3: Laufzeit
# -----------------------------------------------------------------------------
FROM node:${NODE_VERSION}-bookworm-slim AS runner
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates tini \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENV TZ=Europe/Berlin
# Eigener, unprivilegierter Benutzer. Läuft NICHT als root.
RUN groupadd --system --gid 1001 florian \
&& useradd --system --uid 1001 --gid florian --home /app --shell /usr/sbin/nologin florian
# Standalone-Ausgabe von Next.js: enthält nur die tatsächlich benötigten Module.
COPY --from=builder --chown=florian:florian /app/.next/standalone ./
COPY --from=builder --chown=florian:florian /app/.next/static ./.next/static
COPY --from=builder --chown=florian:florian /app/public ./public
# Worker-Bundle und dessen Laufzeitabhängigkeiten.
COPY --from=builder --chown=florian:florian /app/dist ./dist
# Prisma: Schema und Migrationen fürs Deployment, plus erzeugter Client.
COPY --from=builder --chown=florian:florian /app/prisma ./prisma
COPY --from=builder --chown=florian:florian /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=florian:florian /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder --chown=florian:florian /app/node_modules/prisma ./node_modules/prisma
COPY --chown=florian:florian docker/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY --chown=florian:florian docker/healthcheck.js /app/healthcheck.js
RUN chmod +x /usr/local/bin/entrypoint.sh
USER florian
EXPOSE 3000
# Prüft den Endpunkt /api/health — der fragt Datenbank und Redis mit ab.
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node /app/healthcheck.js || exit 1
# tini als PID 1, damit Signale sauber durchgereicht werden.
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
CMD ["node", "server.js"]
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# =============================================================================
# Einstiegspunkt für App- und Worker-Container.
# Die App wendet vor dem Start ausstehende Migrationen an, der Worker nicht —
# sonst würden zwei Prozesse gleichzeitig migrieren.
# =============================================================================
set -eu
log() {
echo "[florian-entrypoint] $*"
}
if [ "${ROLLE:-app}" = "app" ] && [ "${SKIP_MIGRATIONS:-false}" != "true" ]; then
log "Wende ausstehende Datenbankmigrationen an ..."
if node node_modules/prisma/build/index.js migrate deploy; then
log "Migrationen abgeschlossen."
else
log "FEHLER: Migrationen fehlgeschlagen. Container wird beendet."
exit 1
fi
else
log "Rolle '${ROLLE:-app}' — Migrationen werden übersprungen."
fi
log "Starte: $*"
exec "$@"
+25
View File
@@ -0,0 +1,25 @@
// Healthcheck für den Docker-HEALTHCHECK. Bewusst ohne Abhängigkeiten,
// damit er auch dann läuft, wenn die Anwendung selbst nicht startet.
const http = require("node:http");
const port = process.env.PORT || "3000";
const anfrage = http.request(
{
host: "127.0.0.1",
port,
path: "/api/health",
method: "GET",
timeout: 4000,
},
(antwort) => {
process.exit(antwort.statusCode === 200 ? 0 : 1);
},
);
anfrage.on("error", () => process.exit(1));
anfrage.on("timeout", () => {
anfrage.destroy();
process.exit(1);
});
anfrage.end();
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
# =============================================================================
# Läuft EINMALIG beim allerersten Start des Postgres-Containers (leeres
# Datenverzeichnis). Richtet ein:
# - die benötigten Erweiterungen (PostGIS, pgcrypto, citext, unaccent, pg_trgm)
# - den Anwendungsbenutzer "florian_app" OHNE BYPASSRLS
# - Vorgabeprivilegien, damit künftige Tabellen automatisch nutzbar sind
#
# Wichtig zum Verständnis der Mandantentrennung:
# Der Eigentümer der Tabellen (POSTGRES_USER) umgeht Row Level Security —
# das ist gewollt, damit Migrationen und das Seed-Skript arbeiten können.
# Die Anwendung verbindet sich ausschließlich als "florian_app"; für den
# greift RLS, und ohne gesetztes app.current_organisation sieht er nichts.
# =============================================================================
set -euo pipefail
APP_ROLE="florian_app"
APP_PASSWORD="${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD muss gesetzt sein}"
# Einfache Anführungszeichen im Passwort für SQL verdoppeln.
APP_PASSWORD_SQL="${APP_PASSWORD//\'/\'\'}"
echo "[florian-init] Richte Erweiterungen und Anwendungsrolle ein ..."
# Erweiterungen. Reines SQL, keine Variablen nötig.
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'EOSQL'
CREATE EXTENSION IF NOT EXISTS postgis; -- Koordinaten, Einsatzorte
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid(), digest()
CREATE EXTENSION IF NOT EXISTS citext; -- E-Mail ohne Gross/Klein
CREATE EXTENSION IF NOT EXISTS unaccent; -- Suche ueber Umlaute hinweg
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- unscharfe Namenssuche
EOSQL
# Anwendungsrolle anlegen. Bewusst OHNE DO-Block: psql ersetzt seine Variablen
# nicht innerhalb dollar-quotierter Zeichenketten. Stattdessen erzeugt die
# Abfrage den CREATE-Befehl als Text, den \gexec dann ausführt — und zwar nur,
# wenn die Rolle noch nicht existiert.
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
SELECT format(
'CREATE ROLE %I LOGIN PASSWORD %L NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS',
'${APP_ROLE}', '${APP_PASSWORD_SQL}'
)
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${APP_ROLE}')
\gexec
ALTER ROLE "${APP_ROLE}" PASSWORD '${APP_PASSWORD_SQL}';
ALTER ROLE "${APP_ROLE}" SET timezone TO 'Europe/Berlin';
EOSQL
# Rechtevergabe. Hier funktionieren psql-Variablen, weil kein DO-Block im Spiel
# ist.
psql -v ON_ERROR_STOP=1 \
--username "$POSTGRES_USER" \
--dbname "$POSTGRES_DB" \
-v app_role="$APP_ROLE" \
-v datenbank="$POSTGRES_DB" \
-v owner="$POSTGRES_USER" <<-'EOSQL'
GRANT CONNECT ON DATABASE :"datenbank" TO :"app_role";
GRANT USAGE ON SCHEMA public TO :"app_role";
-- Rechte auf alles, was heute schon existiert (bei Erstinstallation leer).
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO :"app_role";
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO :"app_role";
-- Und auf alles, was der Eigentuemer kuenftig anlegt (Prisma-Migrationen).
ALTER DEFAULT PRIVILEGES FOR ROLE :"owner" IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO :"app_role";
ALTER DEFAULT PRIVILEGES FOR ROLE :"owner" IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO :"app_role";
ALTER DEFAULT PRIVILEGES FOR ROLE :"owner" IN SCHEMA public
GRANT EXECUTE ON FUNCTIONS TO :"app_role";
-- Die Anwendung darf keine Tabellen anlegen oder loeschen.
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
REVOKE CREATE ON SCHEMA public FROM :"app_role";
ALTER DATABASE :"datenbank" SET timezone TO 'Europe/Berlin';
EOSQL
echo "[florian-init] Anwendungsrolle '$APP_ROLE' eingerichtet (RLS wirksam)."
+258
View File
@@ -0,0 +1,258 @@
# Anleitung
Bedienung von FLORIAN, Stand Phase 0 (Version 0.1.0). Die Bilder zeigen die
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
> Was hier noch fehlt — Mitglieder, Geräte, Prüfungen — kommt in den
> folgenden Phasen dazu. Der Stand ist bewusst schmal: Phase 0 baut das
> Fundament, nicht die Fachmodule. Siehe [ROADMAP.md](ROADMAP.md).
---
## 1. Anmelden
Aufgerufen wird FLORIAN im Browser unter der Adresse, die der Administrator
eingerichtet hat — in der Entwicklung `http://localhost:3100`.
![Anmeldeseite](screenshots/01-anmeldung-light.png)
Die Anmeldeseite zeigt genau die Wege, die in dieser Installation eingerichtet
sind:
| Weg | Wann |
|---|---|
| **Anmeldung über authentik** | Der Regelweg, sobald authentik eingerichtet ist. Ein Klick, keine Passworteingabe in FLORIAN. |
| **E-Mail und Passwort** | Beim Erststart, und wenn authentik gerade nicht erreichbar ist. |
| **Terminal im Gerätehaus** | Am Wandtablet, mit PIN oder Ausweiskarte. Eigene Oberfläche, kommt in Phase 4. |
Beim allerersten Start gibt es genau ein Konto: das aus `SEED_ADMIN_EMAIL` und
`SEED_ADMIN_PASSWORT` in der `.env`. Vorgabe:
```
admin@beispielwehr.de
Feuerwehr2026
```
**Dieses Passwort gehört nach dem ersten Login geändert.**
### Wenn die Anmeldung nicht klappt
Nach mehreren Fehlversuchen wird das Konto vorübergehend gesperrt — voreingestellt
nach fünf Versuchen für fünf Minuten (`KIOSK_MAX_FEHLVERSUCHE`,
`KIOSK_SPERRDAUER`). Die Meldung nennt nicht, ob die E-Mail-Adresse oder das
Passwort falsch war; das ist Absicht und verhindert, dass sich vorhandene
Konten erraten lassen.
---
## 2. Die Übersicht
Nach der Anmeldung steht die Übersicht der eigenen Wehr.
![Übersicht](screenshots/02-uebersicht-light.png)
Von oben nach unten:
**Kopfzeile.** Name der Wehr und darunter, wer angemeldet ist und mit welchen
Rollen. Wer mehrere Rollen trägt — der Gerätewart ist oft auch Ausbilder —
sieht alle.
**Sicherheitshinweis.** Der gelbe Kasten bleibt stehen, solange die Wehr die
Anwendung nicht ausdrücklich freigegeben hat. Er ist keine Formalität:
Atemschutzüberwachung und Alarmmonitor sind sicherheitsrelevant, und bis zur
Freigabe bleibt die analoge Überwachung führend.
**Kennzahlen.** Vier Kacheln mit aktiven Mitgliedern, Fahrzeugen, Geräten und
fälligen Prüfungen. Sie zählen nur die eigene Wehr — was eine andere Wehr auf
derselben Installation hat, ist nicht sichtbar und nicht abfragbar.
**Stand des Aufbaus.** Welche Phasen fertig sind und was als Nächstes kommt.
### Die Seitenleiste
Links stehen alle Module. Was noch nicht existiert, ist ausgegraut und trägt
rechts sein Phasenkürzel — `P2` heißt „kommt in Phase 2".
Was jemand überhaupt sieht, hängt an seinen Rechten. Ein einfaches Mitglied
sieht die Mitgliederliste, aber nicht das Protokoll. Wichtig zum Verständnis:
das Ausblenden ist nur Bequemlichkeit. Der eigentliche Schutz sitzt auf dem
Server — wer eine Adresse direkt aufruft, für die ihm das Recht fehlt, bekommt
trotzdem nichts.
---
## 3. Helle und dunkle Darstellung
Oben rechts wird zwischen hell und dunkel umgeschaltet. Voreingestellt ist die
Einstellung des Betriebssystems.
![Übersicht in dunkler Darstellung](screenshots/02-uebersicht-dark.png)
Die dunkle Darstellung ist bewusst kein invertiertes Schwarz, sondern ein
warmes Grau. Wer abends vom Fahrzeug hereinkommt und ans Terminal tritt, soll
nicht geblendet werden.
---
## 4. Rollen und was sie dürfen
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
kann sie also nachträglich zuschneiden, ohne dass jemand Code ändert.
| Rolle | Darf im Kern |
|---|---|
| **Administrator** | alles, auch über Wehren hinweg |
| **Kommandant** | alles innerhalb der eigenen Wehr, einschließlich Rollen vergeben und Protokoll einsehen |
| **Gerätewart** | Fahrzeuge und Geräte anlegen, ändern, aussondern; Prüfungen planen und durchführen |
| **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 |
| **Kassier** | Buchungen und Kassenabschluss (Modul noch nicht geplant, siehe Roadmap) |
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen |
| **Ausbilder** | Übungen planen, Teilnahme und Qualifikationen erfassen |
| **Mitglied** | eigene Daten, Listen lesen |
| **Terminal** | nur anmelden und erfassen — nie verwalten |
Zwei Dinge sind bewusst so gebaut:
**Der Gerätewart kommt nicht an die Kasse, der Kassier nicht an die Geräte.**
Trennung nach Verantwortung, nicht nach Bequemlichkeit.
**Das Terminal darf fast nichts.** Wer am Wandtablet arbeitet, meldet sich
zusätzlich mit PIN oder Karte an und handelt dann unter seiner eigenen Person.
Das Terminal selbst ist nur die Tür.
---
## 5. Anmeldung über authentik einrichten
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
### In authentik
1. Neuen Provider anlegen, Typ **OAuth2/OpenID Provider**
2. Client-Typ: **Confidential**
3. Redirect-URI genau so eintragen:
```
https://<ihre-florian-adresse>/api/auth/callback/authentik
```
4. Scopes: `openid`, `profile`, `email`
5. Für die Rollenvorbelegung zusätzlich ein Scope-Mapping, das die Gruppen des
Benutzers als Claim `groups` ausliefert
6. Eine Application anlegen und mit dem Provider verbinden. Der Slug der
Application ist der letzte Teil der Issuer-URL.
### In der `.env`
```bash
AUTH_OIDC_ENABLED=true
AUTH_OIDC_ISSUER=https://auth.beispiel.de/application/o/florian/
AUTH_OIDC_CLIENT_ID=<aus authentik>
AUTH_OIDC_CLIENT_SECRET=<aus authentik>
AUTH_OIDC_GROUP_CLAIM=groups
AUTH_OIDC_GROUP_MAPPING=feuerwehr-kommandant:KOMMANDANT,feuerwehr-geraetewart:GERAETEWART
AUTH_OIDC_AUTO_PROVISION=true
AUTH_OIDC_STANDARD_ORGANISATION=ff-musterhausen
```
Danach die Anwendung neu starten. Auf der Anmeldeseite erscheint der Knopf für
authentik.
### Wie die Rollenvorbelegung wirkt
Die Zuordnung greift **nur beim allerersten Login** einer Person. Danach ist
die in FLORIAN gepflegte Rolle führend, und authentik überschreibt sie nicht
mehr. Wer also einem Mitglied in FLORIAN die Rolle „Gerätewart" gibt, behält
sie — auch wenn die authentik-Gruppe etwas anderes sagt.
Steht `AUTH_OIDC_AUTO_PROVISION=false`, muss die Person vorher in FLORIAN
angelegt sein. Verknüpft wird über die E-Mail-Adresse.
### Wenn authentik ausfällt
Solange `AUTH_LOCAL_ENABLED=true` steht, bleibt der Passwort-Login als
Rückfallweg erreichbar. Für den Wirkbetrieb ist es sinnvoll, mindestens ein
Konto mit Passwort zu behalten — sonst sperrt ein Ausfall von authentik die
ganze Wehr aus.
---
## 6. Terminal-Anmeldung im Gerätehaus
Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits.
Am Terminal wird zweifach geprüft:
1. **Das Gerät** weist sich mit `KIOSK_DEVICE_SECRET` aus. Ohne diesen Wert
nimmt der Server keine PIN und keine Karte an.
2. **Die Person** meldet sich mit einer sechsstelligen PIN (zusammen mit ihrer
Mitgliedsnummer) oder mit einer RFID-Karte an.
Beides wird nur als Hash gespeichert — weder die PIN noch die Kartenkennung
liegen im Klartext in der Datenbank.
PINs, die sich zu leicht raten lassen, weist FLORIAN ab: lauter gleiche
Ziffern und fortlaufende Folgen wie `123456`.
Aus dem Seed lässt sich das ausprobieren: Mitgliedsnummer `0001` (Andreas
Huber), PIN `471203`.
---
## 7. Was protokolliert wird
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
Auch jede Anmeldung — erfolgreich wie gescheitert.
Zwei Eigenschaften sind wichtig:
**Der Log lässt sich nicht ändern.** Nicht über die Oberfläche, nicht über die
Datenbank, auch nicht vom Administrator. Die Datenbank selbst verhindert es.
**Er lässt sich nur im Aufbewahrungslauf kürzen.** Nach der eingestellten
Frist (`AUDIT_AUFBEWAHRUNG_TAGE`, Vorgabe 10 Jahre) sichert der
Hintergrundprozess die alten Einträge und entfernt sie danach. Das ist der
einzige Weg, auf dem etwas aus dem Log verschwindet.
**Passwörter, PINs und Kartenkennungen stehen nie im Log** — sie werden vor
dem Schreiben herausgenommen.
Einsehbar ist der Log mit dem Recht `system.auditlog`, das Kommandant und
Administrator haben. Die Ansicht dazu kommt in Phase 1.
---
## 8. Wenn etwas nicht geht
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|---|---|---|
| Die Anwendung startet nicht und meldet fehlende Variablen | `.env` unvollständig | Die Meldung nennt jede fehlende Variable. Erklärungen dazu stehen in `.env.example`. |
| Anmeldung schlägt immer fehl, obwohl das Passwort stimmt | Konto nach Fehlversuchen gesperrt | Fünf Minuten warten, oder `gesperrtBis` in der Datenbank zurücksetzen |
| Listen sind leer, obwohl Daten da sind | Kein Mandantenkontext gesetzt | Das ist der Schutzmechanismus. Der Zugriff muss über `fuerOrganisation()` laufen. |
| Dateien lassen sich nicht hochladen | MinIO nicht erreichbar oder Bucket fehlt | `http://localhost:9011` öffnen; `docker compose logs minio-init` |
| Der Knopf für authentik fehlt | Issuer, Client-ID oder Secret nicht gesetzt | Alle drei sind nötig, sonst wird der Weg ausgeblendet |
| `docker compose up` startet Traefik mit | Override-Datei wurde nicht mitgeladen | In Produktion `-f docker-compose.yml` angeben |
Zustand aller Randsysteme auf einen Blick:
```
http://localhost:3100/api/health
```
Antwortet mit `200` und `"status": "ok"`, wenn Datenbank, Objektspeicher und
Warteschlange erreichbar sind — sonst mit `503` und der Angabe, welcher Dienst
fehlt.
---
## Bilder erneuern
```bash
docker compose up -d
npm run dev
npm run screenshots
```
Die Bilder entstehen in `docs/screenshots/`, jeweils in heller und dunkler
Darstellung, und werden mit eingecheckt — die Anleitung soll auch dann lesbar
sein, wenn gerade kein Stack läuft.
+237
View File
@@ -0,0 +1,237 @@
# Aufwandsschätzung
Schätzung je Phase, daneben der tatsächliche Aufwand aus dem
[Devlog](DEVLOG.md). Stand: 3. September 2026.
## Wie hier gerechnet wird
Es stehen bewusst **zwei** Größen nebeneinander, weil sie Verschiedenes
aussagen:
| Größe | Bedeutung |
|---|---|
| **Sitzungszeit** | Wie lange Claude tatsächlich gearbeitet hat. Das ist die Zahl, die im Devlog steht, und die Grundlage für die Planung der nächsten Sitzung. |
| **Vergleichsaufwand** | Was eine einzelne Person mit diesem Stack für dasselbe Ergebnis gebraucht hätte, in Personentagen zu 8 Stunden. Das ist die Zahl, die gegenüber der Wehr etwas bedeutet — sie beantwortet „was wäre das sonst wert gewesen". |
Der Vergleichsaufwand ist eine **Schätzung**, keine Messung. Er unterstellt
jemanden, der den Stack kennt, die Feuerwehr-Fachlichkeit aber erst klären
muss — also Recherche zu DGUV-Fristen, FwDV 3 und FwDV 7 einschließt.
Faktoren, die in die Schätzung eingehen:
- **Fachlichkeit** — wie viel muss geklärt werden, bevor gebaut werden kann?
- **Sicherheitsbezug** — Atemschutz und Prüfnachweise brauchen mehr Tests und
mehr Sorgfalt als eine Adressliste.
- **Fremdsysteme** — Gotenberg, Nominatim, tileserver-gl, Ollama, RFID-Leser.
Jedes ist eine eigene Fehlerquelle.
- **Offline und Echtzeit** — die teuersten Anforderungen im ganzen Projekt.
## Übersicht
| Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich |
|---|---|---:|---:|---:|
| 0 | Fundament | 12–16 PT | — | **1:00 h** |
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | offen |
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | offen |
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | offen |
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen |
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | offen |
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen |
| | **Summe** | **115–153 PT** | **26–34 h** | |
115–153 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet
je nach Umfang 500 bis 2.500 Euro im Jahr, dauerhaft, mit den Daten beim
Anbieter.
---
## Phase 0 — Fundament
**Vergleichsaufwand: 12–16 PT** · **Tatsächlich: 1:00 h Sitzungszeit**
| Baustein | PT |
|---|---:|
| Monorepo, Werkzeuge, TypeScript, Tailwind, shadcn | 1,5 |
| Docker Compose, Dockerfile, Traefik, Healthchecks | 2,5 |
| Prisma-Schema der Basismodelle | 2,0 |
| Row Level Security samt Rollen- und Rechtekonzept in Postgres | 2,5 |
| Auth.js mit OIDC, lokalem Login und Terminal-Anmeldung | 3,0 |
| storage, audit, warteschlange, Integrationsschnittstellen | 2,0 |
| Basis-Layout, Anmeldeseite, Übersicht, Dark Mode | 1,5 |
| Seed, Vitest, Playwright, Dokumentation | 1,5 |
Das Teuerste war nicht der sichtbare Teil, sondern die Mandantentrennung. RLS
so aufzusetzen, dass Migrationen, Seed und Anmeldung funktionieren und die
Anwendung trotzdem nichts Fremdes sieht, verlangt zwei getrennte
Datenbankzugänge und ein sauberes Verständnis davon, wann welcher greift. Wer
das nachträglich einbaut, zahlt ein Vielfaches.
## Phase 1 — Stammdaten
**Vergleichsaufwand: 10–14 PT**
| Baustein | PT |
|---|---:|
| Modelle: Standort, Geraeteraum, Geraetetyp, Qualifikation | 1,5 |
| Fahrzeug und Geraet um DIN-Typisierung und Lebensdauer erweitern | 1,5 |
| Tabellen mit Sortierung, Filter, Volltextsuche, Spaltenauswahl | 3,0 |
| QR-Code je Gerät, Etikettenbogen als PDF (65 Etiketten, DIN A4) | 1,5 |
| QR-Scan im Browser über die Kamera | 1,0 |
| CSV-/Excel-Import mit Spaltenzuordnung und Vorschau | 2,5 |
| Gerätehistorie aus dem Audit-Log | 1,0 |
| Massenbearbeitung und Tastaturbedienung | 1,0 |
Der Import ist der Posten, der erfahrungsgemäß aus dem Ruder läuft. Jede Wehr
bringt eine andere Excel-Datei mit, und die Vorschau vor dem Übernehmen ist
kein Komfort, sondern die Bedingung dafür, dass die Wehr dem Ergebnis traut.
## Phase 2 — Wartung und Prüfung
**Vergleichsaufwand: 18–24 PT** — die aufwendigste Phase
| Baustein | PT |
|---|---:|
| Modelle: Vorlage, Checklistenpunkt, Termin, Prüfung, Ergebnis, Mangel, Rückruf | 3,0 |
| Fristen-Dashboard mit Gruppierungen | 2,5 |
| Nächtlicher BullMQ-Job: Termine erzeugen, Status setzen, erinnern | 2,5 |
| Prüfungs-Wizard mit Fotoupload je Checklistenpunkt | 3,5 |
| Unterschrift auf Canvas, Touch und Maus | 1,5 |
| PDF-Protokoll über Gotenberg mit Fotos und Signatur | 3,0 |
| Unveränderlichkeit und Korrekturprotokolle mit Verweis | 1,5 |
| Lebensdauerüberwachung und Aussonderungsvorschlag | 1,5 |
| Rückrufe, externe Wartung, Mängel-Workflow | 2,5 |
Hier entsteht der eigentliche Wert: die Software wird zum Nachweis gegenüber
der Unfallkasse. Das heißt aber auch, dass jeder Fehler teuer ist. Die
Unveränderlichkeit abgeschlossener Protokolle ist der Kern — und der Grund,
warum der Audit-Log schon in Phase 0 append-only ist.
## Phase 3 — Einsätze und Übungen
**Vergleichsaufwand: 12–16 PT**
| Baustein | PT |
|---|---:|
| Modelle: Einsatz, Einsatzfahrzeug, Einsatzkraft, Material, Lagemeldung, Übung | 2,0 |
| Schnellerfassung mit Nachtragen | 1,5 |
| Geocoding über self-hosted Nominatim mit Rückfallweg | 1,5 |
| Personalauswahl nach Verfügbarkeit und Qualifikation | 2,0 |
| Stärkeberechnung nach FwDV 3 | 1,0 |
| Einsatzbericht als PDF, Jahresexport für die Gemeinde | 2,0 |
| Statistik: Kategorien, Eintreffzeiten, Beteiligung, Auslastung | 2,5 |
| Externes Portal mit signiertem QR-Link, ohne Login, rate-limited | 2,0 |
Das externe Portal für Fremdwehren ist sicherheitsrelevant: ein Zugang ohne
Anmeldung, der zeitlich begrenzt und mengenbegrenzt sein muss. Der Aufwand
steckt weniger in der Funktion als in der Absicherung.
## Phase 4 — Kiosk-PWA
**Vergleichsaufwand: 16–22 PT**
| Baustein | PT |
|---|---:|
| Eigenes Kiosk-Layout, 64px-Ziele, hoher Kontrast, kein Hover-Zwang | 2,0 |
| Anmeldung per RFID (WebHID), PIN und QR vom Handy | 2,5 |
| Wizards: Wartung, Fahrtenbuch, Mangel, Inventur, Wäsche, Anwesenheit | 4,0 |
| Hallen-Monitor mit DWD-Wetterwarnung | 2,0 |
| Service Worker, App-Shell und Stammdaten offline | 3,0 |
| IndexedDB-Outbox mit Idempotenzschlüssel und Synchronisation | 4,0 |
| Konfliktbehandlung und Konfliktliste für den Administrator | 2,0 |
| Playwright-Tests für den Offline-Fall | 1,5 |
Offline-Fähigkeit mit schreibenden Aktionen ist die technisch schwierigste
Anforderung des ganzen Projekts. Eine Outbox ist schnell gebaut; dass sie
unter Verbindungsabbruch, Doppelklick und parallelem Zugriff von zwei
Terminals nichts verliert und nichts doppelt schreibt, ist die eigentliche
Arbeit. Die Idempotenzschlüssel sind dafür der Schlüssel, nicht die Synchronisation.
## Phase 5 — Atemschutz nach FwDV 7
**Vergleichsaufwand: 14–18 PT**
| Baustein | PT |
|---|---:|
| Modelle: Atemschutzeinsatz, Trupp, Druckmessung, G26, Belastungsübung | 2,0 |
| Truppanlage per RFID, Eingangsdruck | 1,5 |
| Serverseitig autoritative Timer, Socket.IO-Verteilung | 3,5 |
| Rückzugsdruck-Berechnung, Ampel, akustischer Alarm | 2,0 |
| Erinnerung zur Druckabfrage im Intervall | 1,0 |
| Mayday-Knopf mit Protokolleintrag | 1,0 |
| Lückenloses PDF-Protokoll am Einsatz | 1,5 |
| G26.3-Fristen, Belastungsübungen, Tauglichkeitsampel | 2,0 |
| Tests: Reload und Verbindungsabbruch überstehen | 2,0 |
Der Aufwand liegt nicht in der Funktion, sondern im Beweis, dass sie hält. Ein
Timer, der einen Reload überlebt, ist Pflicht — deshalb muss die Zeit
serverseitig geführt werden und der Browser nur anzeigen. Diese Phase bekommt
den höchsten Testanteil im Projekt.
## Phase 6 — Kartenmodule
**Vergleichsaufwand: 14–18 PT**
| Baustein | PT |
|---|---:|
| MapLibre GL, tileserver-gl, MBTiles fürs Ausrückegebiet | 2,5 |
| Hydranten mit PostGIS, Cluster, Filter, Umkreissuche | 3,0 |
| Import aus GeoJSON und Stadtwerke-CSV | 1,5 |
| Objekte und Feuerwehrpläne nach DIN 14095, Volltextsuche | 3,0 |
| Lagekarte mit taktischen Zeichen nach FwDV 102 als SVG-Set | 3,5 |
| Echtzeit-Sync der Lagekarte zwischen Geräten | 2,0 |
| Wasserförderung: Höhenprofil, Druckverlust, Verstärkerpumpen | 2,5 |
Das SVG-Set für die taktischen Zeichen ist Fleißarbeit, aber unvermeidbar —
fertige Sets sind selten frei verwendbar. Die Wasserförderungsberechnung ist
Physik mit klaren Formeln und deshalb gut testbar.
## Phase 7 — Benachrichtigungen und Termine
**Vergleichsaufwand: 10–13 PT**
| Baustein | PT |
|---|---:|
| Kanalabstraktion: E-Mail, Telegram, ntfy, Web-Push | 2,5 |
| Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 |
| Abonnements je Person | 1,0 |
| Kalender mit Serienterminen und Selbstanmeldung | 3,0 |
| CalDAV-/ICS-Feed je Person mit Token | 1,5 |
| Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 |
Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0
(`lib/integrations/`), das spart hier etwa einen Personentag.
## Phase 8 — Lokale KI
**Vergleichsaufwand: 9–12 PT**
| Baustein | PT |
|---|---:|
| `lib/ai/` mit OpenAI-kompatiblem Client, abschaltbar | 1,0 |
| Diktat: Aufnahme im Browser, faster-whisper, Transkript | 2,5 |
| Berichtsassistent mit Übernehmen/Verwerfen | 2,0 |
| Wiki mit pgvector, semantische Suche, RAG mit Quellenangabe | 3,5 |
| Belegerfassung über ein Vision-Modell, immer bestätigen | 2,0 |
Der Client aus Phase 0 (`lib/integrations/llm.ts`) ist bereits vorhanden. Die
Kennzeichnungspflicht — jede KI-Ausgabe ist als solche erkennbar und muss
bestätigt werden, bevor sie in ein Dokument mit Nachweischarakter gelangt —
kostet wenig Zeit, ist aber nicht verhandelbar.
---
## Was hier nicht drin ist
**Kassenmodul.** Die Rolle `KASSIER` ist in Phase 0 gefordert und angelegt,
die Rechte `kasse.lesen`, `kasse.bearbeiten` und `kasse.abschluss` stehen
bereit. Ein Kassenmodul kommt in keiner der Phasen 0 bis 8 vor. Geschätzter
Vergleichsaufwand, falls gewünscht: **8–11 PT** für Buchungen, Beiträge,
Rechnungen, Kassenabschluss und Jahresauswertung. Siehe
[ROADMAP.md](ROADMAP.md).
**Betrieb und Einführung.** Datenübernahme aus der Altsoftware, Schulung der
Wehr, Sicherungskonzept, Monitoring: nach Erfahrung noch einmal 15 bis 25
Prozent des Entwicklungsaufwands.
+133
View File
@@ -0,0 +1,133 @@
# Devlog
Entwicklungstagebuch mit Zeiterfassung. Jeder Arbeitsabschnitt bekommt einen
Eintrag: wann, wie lange, was entstanden ist, und was dabei schiefging.
Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
„tatsächlich" der [Aufwandsschätzung](AUFWANDSSCHAETZUNG.md).
## Zeitübersicht
| Phase | Sitzungszeit | Sitzungen | abgeschlossen |
|---|---:|---:|---|
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
| **Summe** | **1:00 h** | **1** | |
---
## 2026-09-02 / 03 · Phase 0 — Fundament
**23:13 – 00:13 · 1:00 h · Version 0.1.0**
### Was entstanden ist
46 Quelldateien, rund 6.100 Zeilen. Im Einzelnen:
| Bereich | Inhalt |
|---|---|
| Betrieb | `docker-compose.yml` (7 Dienste), Entwicklungs-Override, mehrstufiges Dockerfile, Postgres-Initialisierung, `.env.example` mit Kommentar zu jeder Variable |
| Datenmodell | Prisma-Schema mit 11 Tabellen, 3 Migrationen |
| Kern | `env`, `db`, `authz`, `storage`, `audit`, `auth`, `passwort`, `warteschlange`, 3 Integrationsschnittstellen |
| Server | tRPC v11 mit drei Prozedurarten, Kontext, Wurzel-Router |
| Oberfläche | Layout, Seitenleiste, Anmeldeseite, Übersicht, 5 shadcn-Bausteine, Farbwelt in OKLCH |
| Daten | Seed: 1 Wehr, 9 Rollen, 38 Rechte, 16 Personen, 3 Fahrzeuge, 40 Geräte |
| Tests | 32 Vitest-Tests, 10 Playwright-Tests |
| Doku | README, Anleitung, Roadmap, Aufwandsschätzung, Versionierung, Changelog |
### Entscheidungen
**Zwei Datenbankverbindungen statt einer.** Der erste Entwurf hatte einen
einzigen Prisma-Client. Der ist beim ersten Anmeldeversuch aufgelaufen: die
Anwendung verbindet sich als `florian_app`, für den Row Level Security greift
— und die Anmeldung muss eine E-Mail-Adresse über alle Wehren hinweg suchen,
bevor überhaupt feststeht, zu welcher Wehr sie gehört. Ohne gesetzten
Mandantenkontext liefert RLS null Zeilen, also fand der Login nie jemanden.
Die Lösung ist eine saubere Trennung: `prismaApp` für alle Fachdaten (RLS
aktiv), `prismaSystem` für die wenigen Vorgänge, die vor der
Mandantenauflösung stattfinden. Der Name ist bewusst unbequem, damit auffällt,
wenn ihn jemand im falschen Zusammenhang benutzt.
**Kein FORCE ROW LEVEL SECURITY.** Der Tabelleneigentümer umgeht RLS. Das ist
gewollt: Migrationen und Seed müssen über alle Mandanten hinweg arbeiten
können. Mit `FORCE` wäre der Seed nicht mehr lauffähig gewesen.
**Audit-Log: append-only mit genau einer Ausnahme.** Der erste Trigger hat
`UPDATE` und `DELETE` ausnahmslos gesperrt. Das ist beim Aufräumen der
Testdaten aufgefallen — eine Organisation ließ sich nicht mehr löschen, weil
die Kaskade auf den Audit-Log traf.
Das ist kein Testproblem, sondern ein echtes: nach
`AUDIT_AUFBEWAHRUNG_TAGE` muss der Worker alte Einträge exportieren und dann
entfernen, sonst wächst die Tabelle unbegrenzt und das Löschkonzept nach DSGVO
lässt sich nicht umsetzen. Die zweite Migration führt deshalb einen
ausdrücklichen Schalter ein: `SET LOCAL app.audit_bereinigung = 'an'` erlaubt
`DELETE` für die laufende Transaktion. `UPDATE` bleibt unter allen Umständen
gesperrt — ein Eintrag darf verschwinden, nachdem er gesichert wurde, aber
niemals verfälscht werden.
**Rollen aus authentik nur als Vorbelegung.** Auf Nachfrage entschieden: der
Gruppen-Claim setzt die Rolle beim ersten Login, danach ist die in FLORIAN
gepflegte Rolle führend. Umgesetzt über das Feld `ausOidcGruppe` an
`PersonRolle` und eine Prüfung, die nur bei leerer Rollenliste greift.
**Terminal mit zwei Schranken.** Erst weist sich das Gerät mit
`KIOSK_DEVICE_SECRET` aus, dann die Person mit PIN oder Karte. Eine erratene
sechsstellige PIN nützt ohne das Gerätegeheimnis nichts.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Postgres startete nicht | `de_DE.UTF-8` gibt es im Debian-Basisimage nicht | ICU-Locale: `--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8` |
| Init-Skript brach mit Syntaxfehler ab | psql ersetzt seine Variablen nicht innerhalb dollar-quotierter Blöcke | `DO $$`-Block durch `SELECT format(...) \gexec` ersetzt |
| Anmeldung schlug immer fehl | siehe oben: RLS blockierte die E-Mail-Suche | zweite Datenbankverbindung |
| `npm install` ließ Installationsskripte aus | npm 11.17 verlangt eine ausdrückliche Freigabe | `npm approve-scripts` für Prisma, esbuild und drei weitere |
| Aufräumen der Testdaten scheiterte | Audit-Trigger blockierte die Kaskade | Aufbewahrungsschalter (siehe oben) |
| Zwei E2E-Tests rot | Next.js hängt einen eigenen Routen-Ansager mit `role="alert"` in die Seite; `CardTitle` ist ein `div`, keine Überschrift | Fehlermeldung über `data-testid` angesprochen, Anmeldetitel zu einer echten `h1` gemacht |
### Abweichung von der Vorgabe
**`Berechtigung` trägt kein `organisationId`.** Die Architekturregel verlangt
es für jede Tabelle. Diese Tabelle ist aber kein Mandantendatum, sondern der
systemweite Katalog dessen, was die Software überhaupt kann — für alle Wehren
identisch. Mandantenbezogen wird es erst über `RolleBerechtigung`, und die
trägt `organisationId`. Die Tabelle ist für alle lesbar und für die
Anwendungsrolle nicht beschreibbar. Die Ausnahme steht im Schema und in der
Migration kommentiert.
**Phasenzuordnung in der Seitenleiste korrigiert.** Beim Schreiben der Roadmap
ist aufgefallen, dass ich die Module den falschen Phasen zugeordnet hatte
(Atemschutz als Phase 3, Einsätze als Phase 4). Nach `Phase-3.md` bis
`Phase-8.md` gilt: 3 Einsätze, 4 Kiosk, 5 Atemschutz, 6 Karten, 7
Benachrichtigungen, 8 KI. Seitenleiste, Kommentare in `authz.ts` und die
Übersicht sind entsprechend berichtigt.
**Kassenmodul kommt in keiner Phase vor.** Die Rolle `KASSIER` ist gefordert
und angelegt, ein Modul dazu gibt es in `Phase-0.md` bis `Phase-8.md` nicht.
Die Rechte stehen im Katalog, es gibt aber keinen Navigationseintrag. Offene
Entscheidung, siehe [ROADMAP.md](ROADMAP.md).
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run test` — 32 Tests grün, davon 8 gegen eine echte Datenbank
- `npm run test:e2e` — 10 Tests grün (Desktop und Tablet)
- `npm run build` — 6 Routen, 103 kB gemeinsames JavaScript
- `docker compose up -d` — Postgres, Redis, MinIO, Gotenberg gesund
---
## Vorlage für neue Einträge
```markdown
## JJJJ-MM-TT · Phase N — Titel
**HH:MM – HH:MM · X:XX h · Version 0.N.0**
### Was entstanden ist
### Entscheidungen
### Wo es gehakt hat
### Abweichung von der Vorgabe
### Nachweise
```
+209
View File
@@ -0,0 +1,209 @@
# Roadmap
Was FLORIAN wann kann. Die Phasen kommen aus `Phase-0.md` bis `Phase-8.md` und
werden **einzeln** abgearbeitet — nie zwei in einem Schritt.
Aufwände je Phase: [AUFWANDSSCHAETZUNG.md](AUFWANDSSCHAETZUNG.md).
Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md).
## Stand
```
Phase 0 ████████████████████ fertig 0.1.0
Phase 1 ░░░░░░░░░░░░░░░░░░░░ offen 0.2.0
Phase 2 ░░░░░░░░░░░░░░░░░░░░ offen 0.3.0
Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0
Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen 0.6.0
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
```
---
## Phase 0 — Fundament · fertig
Alles, worauf der Rest aufsetzt.
- Monorepo, Docker Compose mit Traefik, PostgreSQL 17 + PostGIS, Redis, MinIO,
Gotenberg
- Mandantentrennung über Row Level Security, geprüft gegen eine echte Datenbank
- Append-only-Audit-Log mit kontrolliertem Aufbewahrungslauf
- Anmeldung: OIDC gegen authentik, lokaler Passwort-Login, Terminal per
PIN und RFID
- 9 Rollen, 38 Einzelrechte, zentral in `lib/authz.ts`
- Dateiablage in MinIO, Zugriff nur über `lib/storage.ts`
- Basis-Layout mit Seitenleiste und dunkler Darstellung
- Seed mit einer vollständigen Beispielwehr
## Phase 1 — Stammdaten · als Nächstes
Die Wehr trägt ein, was sie hat.
- Standort, Geraeteraum, Geraetetyp, Qualifikationstyp, Qualifikation
- Fahrzeug um Fahrgestellnummer, DIN-Typ und TÜV-Termin erweitern
- Geraet um Lebensdauerkategorie und die Zuordnung
„Standort ODER Fahrzeug + Geräteraum" erweitern
- Tabellen mit Sortierung, Filter, Volltextsuche und Spaltenauswahl
- QR-Code je Gerät, Etikettenbogen als PDF (DIN A4, 65 Etiketten)
- QR-Scan über die Kamera öffnet direkt die Gerätedetailseite
- CSV-/Excel-Import mit Spaltenzuordnung und Vorschau
- Gerätehistorie aus dem Audit-Log
- Qualifikationen mit Ablaufwarnung auf der Übersicht
**Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per
QR-Code am Regal wiederfinden.
## Phase 2 — Wartung und Prüfung
Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner.
- Wartungsvorlagen mit Checklisten, Intervall und Vorwarnzeit
- Fristen-Dashboard: überfällig / diese Woche / diesen Monat / nächste 90 Tage
- Nächtlicher Job erzeugt Termine, setzt Status, erinnert bei T-30, T-7 und T-0
- Prüfungs-Wizard mit Foto je Checklistenpunkt, Zeiterfassung und Unterschrift
- PDF-Prüfprotokoll über Gotenberg, unveränderlich in MinIO
- Lebensdauerüberwachung mit Aussonderungsvorschlag
- Rückrufe markieren betroffene Geräte automatisch
- Mängel-Workflow: melden → zugewiesen → in Arbeit → behoben
**Danach kann die Wehr:** einen DGUV-Nachweis führen, der einer Prüfung durch
die Unfallkasse standhält.
## Phase 3 — Einsätze und Übungen
- Einsatz mit allen Zeitstempeln: alarmiert, ausgerückt, eingetroffen,
Einsatzende, eingerückt, einsatzbereit
- Schnellerfassung: Stichwort, Adresse, Fahrzeuge — Rest später
- Geocoding über self-hosted Nominatim
- Personalauswahl mit Vorschlag nach Verfügbarkeit und Qualifikation
- Stärkeberechnung nach FwDV 3
- Einsatzbericht als PDF, Jahresexport für die Gemeinde
- Statistik: Kategorien, Eintreffzeiten, Beteiligung, Fahrzeugauslastung
- Externes Portal für Fremdwehren über signierten QR-Link, ohne Anmeldung
**Danach kann die Wehr:** ihre Jahresstatistik auf Knopfdruck erzeugen.
## Phase 4 — Kiosk-PWA
Für die Wandtablets in der Fahrzeughalle.
- Eigenes Layout: große Kacheln, mindestens 64px, mit Handschuhen bedienbar
- Anmeldung per RFID, PIN oder QR vom Handy, Auto-Logout nach 90 Sekunden
- Wizards: Wartung, Fahrtenbuch, Mangel melden, Inventur, Wäsche, Anwesenheit
- Hallen-Monitor: Vollbild mit Terminen, überfälligen Prüfungen,
Fahrzeugstatus, Wetterwarnung
- Offline-Betrieb mit IndexedDB-Outbox und Synchronisation bei Reconnect
**Danach kann die Wehr:** einen Mangel in unter 30 Sekunden melden, ohne den
Rechner im Büro einzuschalten — auch wenn das Netz gerade weg ist.
## Phase 5 — Atemschutz nach FwDV 7
Sicherheitsrelevant. Jede Ansicht zeigt dauerhaft den Hinweis, dass die
analoge Überwachung führend bleibt — abschaltbar erst über eine Option, die
eine dokumentierte Freigabe durch den Kommandanten voraussetzt.
- Truppanlage per RFID, Eingangsdruck erfassen
- Serverseitig autoritative Timer, per Socket.IO verteilt
- Rückzugsdruck-Berechnung mit Ampel und akustischem Alarm
- Erinnerung zur Druckabfrage im konfigurierbaren Intervall
- Mayday-Knopf je Trupp
- Lückenloses PDF-Protokoll am Einsatz
- G26.3-Fristen, Belastungsübungen, Tauglichkeitsübersicht
**Danach kann die Wehr:** die Überwachung digital mitführen — parallel zur
analogen, bis die Wehr ausdrücklich anders entscheidet.
## Phase 6 — Kartenmodule
- MapLibre GL mit selbst gehosteten Kacheln fürs Ausrückegebiet
- Hydranten mit Typ, Nennweite, Durchfluss, letzter Prüfung; Import aus
GeoJSON und Stadtwerke-CSV
- Objekte und Feuerwehrpläne nach DIN 14095, vom Einsatz aus erreichbar
- Lagekarte mit taktischen Zeichen nach FwDV 102, Echtzeit-Sync
- Wasserförderung: Druckverlust, Verstärkerpumpen, druckbares Schema
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
und was ihn hergibt.
## Phase 7 — Benachrichtigungen und Termine
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
- Kalender: Dienste, Übungen, Lehrgänge, Arbeitsdienste; Serientermine,
Selbstanmeldung mit Teilnehmerlimit, Anwesenheitsliste zum Ausdrucken
- CalDAV-/ICS-Feed je Person
- Digitaler Dienstausweis mit signiertem QR und Prüfseite
### Warum kein WhatsApp
Die WhatsApp Business API kostet je Konversation und verlangt einen
zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nummern aller
Mitglieder gingen an Meta, was sich mit einer Anwendung, die bewusst im
Gerätehaus steht, nicht verträgt. Telegram, ntfy und Web-Push decken denselben
Bedarf ab, ntfy sogar vollständig selbst gehostet.
## Phase 8 — Lokale KI
Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar.
- Diktat: Aufnahme im Browser → faster-whisper → Transkript im Berichtsfeld
- Berichtsassistent: aus Stichpunkten einen Entwurf, immer als Vorschlag
- Wiki mit pgvector: semantische Suche mit Quellenangabe
- Belegerfassung: Foto einer Quittung → Vorschlag für Betrag, Datum, Händler
Jede KI-Ausgabe ist gekennzeichnet und muss bestätigt werden, bevor sie in ein
Dokument mit Nachweischarakter gelangt.
## 1.0.0 — Freigabe
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im
Echtbetrieb ein. Voraussetzungen:
- Datenübernahme aus der bisherigen Verwaltung abgeschlossen und abgeglichen
- Sicherungskonzept steht und ist einmal wiederhergestellt worden
- Schulung von Kommandant, Gerätewart und Atemschutzgerätewart erfolgt
- Löschkonzept nach DSGVO abgenommen
- Der Hinweis zur analogen Atemschutzüberwachung wird bewusst und
dokumentiert abgeschaltet — oder bewusst stehen gelassen
---
## Offene Punkte
### Kassenmodul fehlt in der Phasenplanung
Phase 0 verlangt die Rolle `KASSIER`, und sie ist angelegt — mit den Rechten
`kasse.lesen`, `kasse.bearbeiten` und `kasse.abschluss`. Ein Kassenmodul kommt
in keiner der Phasen 0 bis 8 vor. Die Rolle hätte also dauerhaft Rechte ohne
zugehörige Oberfläche.
Zwei Wege:
1. **Phase 9 ergänzen** — Buchungen, Beiträge, Rechnungen, Kassenabschluss,
Jahresauswertung. Geschätzt 8–11 PT.
2. **Rolle belassen, Modul streichen** — der Kassier nutzt weiterhin seine
bisherige Lösung, die Rechte bleiben als Platzhalter stehen.
Solange das nicht entschieden ist, bleiben die Rechte im Katalog, aber es gibt
keinen Navigationseintrag dafür.
### Weitere Kandidaten für später
| Idee | Nutzen | grob |
|---|---|---:|
| Fahrtenbuch als eigenes Modul | derzeit nur als Kiosk-Wizard in Phase 4 vorgesehen | 3–4 PT |
| Kleiderkammer und Wäscheverwaltung | in Phase 4 nur als Wizard, ohne Bestandsführung | 4–6 PT |
| Jugendfeuerwehr mit eigener Abzeichenverwaltung | eigene Fachlichkeit | 5–7 PT |
| Schnittstelle zur Integrierten Leitstelle | Alarmdaten automatisch übernehmen | 6–10 PT, stark abhängig von der ILS |
| Mehrere Wehren je Installation | technisch bereits möglich, Oberfläche fehlt | 4–6 PT |
Die Mandantenfähigkeit ist der Punkt, der ohne Zusatzaufwand am weitesten
trägt: das Datenmodell und RLS können bereits mehrere Wehren. Was fehlt, ist
eine Oberfläche zum Umschalten und ein Konzept für gemeindeübergreifende
Auswertungen. Für einen Landkreis, der mehrere Wehren betreiben will, wäre das
der nächste sinnvolle Schritt.
+117
View File
@@ -0,0 +1,117 @@
# Versionierung
## Versionsnummern
FLORIAN folgt [Semantic Versioning](https://semver.org/lang/de/) in der Form
`MAJOR.MINOR.PATCH`.
Solange die Hauptversion `0` ist, gilt die Software als im Aufbau: es gibt
keine Zusage, dass eine Schnittstelle stabil bleibt.
| Stelle | wird erhöht, wenn |
|---|---|
| `MAJOR` | Bestehendes bricht — Datenmodell, REST-API unter `/api/v1` oder Konfiguration ändern sich so, dass eine bestehende Installation Handarbeit braucht. Ab `1.0.0`. |
| `MINOR` | Eine Phase ist abgeschlossen oder ein Funktionsbereich kommt hinzu. |
| `PATCH` | Fehlerbehebungen, Textkorrekturen, Abhängigkeits-Updates — alles ohne neue Funktion. |
### Zuordnung Phase zu Version
Jede abgeschlossene Phase bekommt eine Minor-Version. Das macht aus der
Versionsnummer eine Aussage über den Funktionsumfang.
| Version | Phase |
|---|---|
| `0.1.0` | Phase 0 — Fundament |
| `0.2.0` | Phase 1 — Stammdaten |
| `0.3.0` | Phase 2 — Wartung und Prüfung |
| `0.4.0` | Phase 3 — Einsätze und Übungen |
| `0.5.0` | Phase 4 — Kiosk-PWA |
| `0.6.0` | Phase 5 — Atemschutz |
| `0.7.0` | Phase 6 — Kartenmodule |
| `0.8.0` | Phase 7 — Benachrichtigungen und Termine |
| `0.9.0` | Phase 8 — Lokale KI |
| `1.0.0` | Freigabe für den Wirkbetrieb durch die Wehr |
`1.0.0` ist bewusst an die **Freigabe** gebunden, nicht an Phase 8. Erst wenn
eine Wehr die Software im Echtbetrieb einsetzt, darf die analoge Überwachung
zurücktreten — und erst dann ist eine Zusage über Stabilität ehrlich.
## Wo die Version steht
- `package.json` → `version` ist die Wahrheit.
- `CHANGELOG.md` bekommt je Version einen eigenen Abschnitt.
- Git-Tag in der Form `v0.1.0`.
```bash
npm version minor -m "Version %s — Phase 1 abgeschlossen"
git push --follow-tags
```
## Changelog
Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), auf
Deutsch. Überschriften je Abschnitt:
- **Hinzugefügt** — neue Funktionen
- **Geändert** — Verhalten, das anders geworden ist
- **Veraltet** — bleibt vorerst, verschwindet aber
- **Entfernt** — ist weg
- **Behoben** — Fehlerbehebungen
- **Sicherheit** — alles, was Sicherheitsbezug hat
Einträge beschreiben, was sich für die **Wehr** ändert, nicht welche Datei
angefasst wurde. Also: „Prüfprotokolle lassen sich als PDF ablegen" statt
„PdfService ergänzt".
Was noch nicht veröffentlicht ist, sammelt sich unter `[Unveröffentlicht]` und
bekommt beim Setzen des Tags die Versionsnummer und das Datum.
## Branches
| Branch | Zweck |
|---|---|
| `main` | immer lauffähig. Tests grün, Build durch. |
| `feat/<nr>-<kurz>` | neue Funktion, `<nr>` ist die Issue-Nummer |
| `fix/<nr>-<kurz>` | Fehlerbehebung |
| `chore/<kurz>` | Aufräumen, Abhängigkeiten, Werkzeuge |
Nie direkt auf `main` committen. Jede Änderung läuft über einen Branch und
einen Pull Request.
## Commits
Deutsch, Imperativ, erste Zeile höchstens 72 Zeichen. Die letzte Zeile
verweist auf das Issue:
```
Prüfprotokoll als PDF über Gotenberg erzeugen
Das Protokoll wird nach dem Abschluss unveränderlich in MinIO abgelegt.
Korrekturen erzeugen ein neues Protokoll mit Verweis auf das ursprüngliche.
Refs #42
```
Der Text des Pull Requests enthält `Closes #42`.
## Datenbankmigrationen
- Eine Migration wird nach dem Anwenden **nie** verändert. Ein Fehler wird mit
einer neuen Migration behoben.
- Der Ordnername sagt, worum es geht: `20260902000100_rls_policies`.
- Jede Migration beginnt mit einem Kommentarblock, der erklärt **warum** sie
existiert — nicht nur was sie tut.
- Migrationen, die Daten verlieren können, werden im Changelog unter
**Sicherheit** oder **Geändert** ausdrücklich genannt.
## Was vor einem Tag erledigt sein muss
1. `npm run typecheck` ohne Ausgabe
2. `npm run test` grün
3. `npm run test:e2e` grün
4. `npm run build` durch
5. `docker compose -f docker-compose.yml up -d` startet sauber
6. Changelog-Abschnitt geschrieben
7. Devlog-Eintrag mit der aufgewendeten Zeit ergänzt
8. Aufwandsschätzung um den Ist-Wert ergänzt
9. Bildschirmfotos neu erzeugt, falls sich die Oberfläche geändert hat
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

+27
View File
@@ -0,0 +1,27 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Standalone-Build für ein schlankes Runtime-Image im Dockerfile.
output: "standalone",
reactStrictMode: true,
// Keine externen CDNs: Bilder kommen ausschließlich aus MinIO bzw. lokal.
images: { remotePatterns: [] },
serverExternalPackages: ["@prisma/client", "bcryptjs", "minio", "bullmq"],
eslint: { ignoreDuringBuilds: true },
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "no-referrer" },
// Keine Telemetrie, keine externen Fonts/CDNs.
{ key: "Permissions-Policy", value: "geolocation=(self), camera=(self), microphone=()" },
],
},
];
},
};
export default nextConfig;
+12557
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
{
"name": "florian",
"version": "0.1.0",
"private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "next dev -p 3100",
"build": "next build",
"start": "next start -p 3100",
"worker": "tsx src/worker/index.ts",
"worker:dev": "tsx watch src/worker/index.ts",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"screenshots": "tsx scripts/screenshots.ts"
},
"dependencies": {
"@prisma/client": "^6.16.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.90.2",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"bcryptjs": "^3.0.2",
"bullmq": "^5.61.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"ioredis": "^5.8.1",
"lucide-react": "^0.544.0",
"minio": "^8.0.6",
"next": "^15.5.4",
"next-auth": "^5.0.0-beta.29",
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"server-only": "^0.0.1",
"superjson": "^2.2.6",
"tailwind-merge": "^3.3.1",
"zod": "^3.25.76"
},
"devDependencies": {
"@playwright/test": "^1.55.1",
"@tailwindcss/postcss": "^4.1.13",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.18.6",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"dotenv": "^17.4.2",
"esbuild": "^0.25.10",
"eslint": "^9.36.0",
"eslint-config-next": "^15.5.4",
"jsdom": "^27.0.0",
"prisma": "^6.16.2",
"tailwindcss": "^4.1.13",
"tsx": "^4.20.6",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
},
"allowScripts": {
"prisma": true,
"@prisma/client": true,
"@prisma/engines": true,
"esbuild": true,
"unrs-resolver": true,
"msgpackr-extract": true
}
}
+49
View File
@@ -0,0 +1,49 @@
import { defineConfig, devices } from "@playwright/test";
/**
* End-to-End-Tests.
*
* Voraussetzung: der Stack läuft (docker compose up -d) und die Datenbank ist
* migriert und geseedet. Der Entwicklungsserver wird bei Bedarf selbst
* gestartet — ein bereits laufender wird wiederverwendet.
*/
const BASIS_URL = process.env.E2E_BASIS_URL ?? "http://localhost:3100";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
timeout: 30_000,
expect: { timeout: 10_000 },
use: {
baseURL: BASIS_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
// Die Oberfläche ist durchgängig deutsch — auch der Browser soll es sein.
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
// Am Terminal im Gerätehaus steht ein Touchgerät — deshalb mitgetestet.
// Bewusst mit Chromium statt WebKit: das Terminal läuft auf einem
// Android-Tablet im Kiosk-Browser, nicht auf einem iPad.
{
name: "tablet",
use: { ...devices["Galaxy Tab S4 landscape"], browserName: "chromium" },
},
],
webServer: {
command: "npm run dev",
url: BASIS_URL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
// Prisma-Konfiguration. Ersetzt den veralteten "prisma"-Block in package.json.
// Anders als früher lädt Prisma die .env hier nicht mehr von selbst — deshalb
// der ausdrückliche dotenv-Aufruf.
import path from "node:path";
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: path.join("prisma", "schema.prisma"),
migrations: {
path: path.join("prisma", "migrations"),
seed: "tsx prisma/seed.ts",
},
});
@@ -0,0 +1,409 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "citext";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "unaccent";
-- CreateEnum
CREATE TYPE "RolleSchluessel" AS ENUM ('SUPERADMIN', 'KOMMANDANT', 'GERAETEWART', 'ATEMSCHUTZGERAETEWART', 'KASSIER', 'SCHRIFTFUEHRER', 'AUSBILDER', 'MITGLIED', 'KIOSK');
-- CreateEnum
CREATE TYPE "AusweisTokenTyp" AS ENUM ('PIN', 'RFID');
-- CreateEnum
CREATE TYPE "AnmeldeArt" AS ENUM ('LOKAL', 'OIDC', 'KIOSK_PIN', 'KIOSK_RFID', 'SYSTEM');
-- CreateEnum
CREATE TYPE "AuditAktion" AS ENUM ('ERSTELLT', 'GEAENDERT', 'GELOESCHT', 'WIEDERHERGESTELLT', 'ANGEMELDET', 'ABGEMELDET', 'ANMELDUNG_FEHLGESCHLAGEN', 'EXPORTIERT', 'DATEI_HOCHGELADEN', 'DATEI_ABGERUFEN', 'BERECHTIGUNG_GEAENDERT');
-- CreateEnum
CREATE TYPE "AkteurTyp" AS ENUM ('PERSON', 'SYSTEM', 'WORKER', 'KIOSK', 'API');
-- CreateEnum
CREATE TYPE "GeraetStatus" AS ENUM ('EINSATZBEREIT', 'PRUEFUNG_FAELLIG', 'GESPERRT', 'AUSGESONDERT', 'IN_REPARATUR');
-- CreateEnum
CREATE TYPE "FahrzeugArt" AS ENUM ('LOESCHFAHRZEUG', 'DREHLEITER', 'RUESTWAGEN', 'GERAETEWAGEN', 'MANNSCHAFTSTRANSPORT', 'EINSATZLEITWAGEN', 'WECHSELLADER', 'ANHAENGER', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "Dienstgrad" AS ENUM ('FEUERWEHRANWAERTER', 'FEUERWEHRMANN', 'OBERFEUERWEHRMANN', 'HAUPTFEUERWEHRMANN', 'LOESCHMEISTER', 'OBERLOESCHMEISTER', 'HAUPTLOESCHMEISTER', 'BRANDMEISTER', 'OBERBRANDMEISTER', 'HAUPTBRANDMEISTER', 'KEIN');
-- CreateTable
CREATE TABLE "organisation" (
"id" UUID NOT NULL,
"slug" VARCHAR(64) NOT NULL,
"name" VARCHAR(200) NOT NULL,
"kurzname" VARCHAR(60) NOT NULL,
"strasse" VARCHAR(200),
"plz" VARCHAR(10),
"ort" VARCHAR(120),
"landkreis" VARCHAR(120),
"bundesland" VARCHAR(60) NOT NULL DEFAULT 'Bayern',
"telefon" VARCHAR(40),
"email" VARCHAR(200),
"ilsKennung" VARCHAR(40),
"funkPraefix" VARCHAR(80),
"zeitzone" VARCHAR(60) NOT NULL DEFAULT 'Europe/Berlin',
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "organisation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "person" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"vorname" VARCHAR(100) NOT NULL,
"nachname" VARCHAR(100) NOT NULL,
"mitgliedsnummer" VARCHAR(30),
"geburtsdatum" DATE,
"dienstgrad" "Dienstgrad" NOT NULL DEFAULT 'KEIN',
"email" VARCHAR(200),
"telefon" VARCHAR(40),
"mobil" VARCHAR(40),
"strasse" VARCHAR(200),
"plz" VARCHAR(10),
"ort" VARCHAR(120),
"eintrittsdatum" DATE,
"austrittsdatum" DATE,
"abteilung" VARCHAR(60),
"passwortHash" VARCHAR(120),
"passwortGeaendertAm" TIMESTAMPTZ(3),
"oidcSubject" VARCHAR(255),
"oidcGruppen" TEXT[] DEFAULT ARRAY[]::TEXT[],
"letzteAnmeldung" TIMESTAMPTZ(3),
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
"gesperrtBis" TIMESTAMPTZ(3),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"avatarDateiId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "person_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rolle" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"schluessel" "RolleSchluessel" NOT NULL,
"name" VARCHAR(100) NOT NULL,
"beschreibung" VARCHAR(500),
"systemRolle" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "rolle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "person_rolle" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"rolleId" UUID NOT NULL,
"zugewiesenAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"zugewiesenVonId" UUID,
"gueltigBis" TIMESTAMPTZ(3),
"ausOidcGruppe" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "person_rolle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "berechtigung" (
"id" UUID NOT NULL,
"schluessel" VARCHAR(80) NOT NULL,
"bereich" VARCHAR(60) NOT NULL,
"bezeichnung" VARCHAR(160) NOT NULL,
"beschreibung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "berechtigung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rolle_berechtigung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"rolleId" UUID NOT NULL,
"berechtigungId" UUID NOT NULL,
"gewaehrt" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rolle_berechtigung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ausweis_token" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"typ" "AusweisTokenTyp" NOT NULL,
"tokenHash" VARCHAR(120) NOT NULL,
"kennzeichen" VARCHAR(20),
"bezeichnung" VARCHAR(120),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"gueltigAb" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"gueltigBis" TIMESTAMPTZ(3),
"letzteNutzung" TIMESTAMPTZ(3),
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
"gesperrtBis" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "ausweis_token_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_log" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"zeitpunkt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"akteurTyp" "AkteurTyp" NOT NULL DEFAULT 'PERSON',
"akteurPersonId" UUID,
"akteurName" VARCHAR(200),
"anmeldeArt" "AnmeldeArt",
"aktion" "AuditAktion" NOT NULL,
"entitaet" VARCHAR(80) NOT NULL,
"entitaetId" UUID,
"beschreibung" VARCHAR(500),
"vorher" JSONB,
"nachher" JSONB,
"kontext" JSONB,
"ipAdresse" INET,
"userAgent" VARCHAR(400),
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "datei" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"bucket" VARCHAR(80) NOT NULL,
"objektSchluessel" VARCHAR(500) NOT NULL,
"dateiname" VARCHAR(300) NOT NULL,
"mimeTyp" VARCHAR(160) NOT NULL,
"groesseBytes" BIGINT NOT NULL,
"pruefsumme" VARCHAR(64),
"entitaet" VARCHAR(80),
"entitaetId" UUID,
"kategorie" VARCHAR(60),
"hochgeladenVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "datei_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "fahrzeug" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"funkrufname" VARCHAR(120) NOT NULL,
"bezeichnung" VARCHAR(120) NOT NULL,
"art" "FahrzeugArt" NOT NULL DEFAULT 'LOESCHFAHRZEUG',
"kennzeichen" VARCHAR(20),
"hersteller" VARCHAR(120),
"baujahr" INTEGER,
"tuevBis" DATE,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "fahrzeug_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "geraet" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"inventarnummer" VARCHAR(60) NOT NULL,
"bezeichnung" VARCHAR(160) NOT NULL,
"hersteller" VARCHAR(120),
"seriennummer" VARCHAR(120),
"herstellungsdatum" DATE,
"indienststellung" DATE,
"status" "GeraetStatus" NOT NULL DEFAULT 'EINSATZBEREIT',
"pruefintervallMonate" INTEGER,
"naechstePruefung" DATE,
"lebensdauerJahre" INTEGER,
"fahrzeugId" UUID,
"lagerort" VARCHAR(160),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "geraet_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organisation_slug_key" ON "organisation"("slug");
-- CreateIndex
CREATE INDEX "organisation_aktiv_idx" ON "organisation"("aktiv");
-- CreateIndex
CREATE INDEX "person_organisationId_nachname_vorname_idx" ON "person"("organisationId", "nachname", "vorname");
-- CreateIndex
CREATE INDEX "person_organisationId_aktiv_idx" ON "person"("organisationId", "aktiv");
-- CreateIndex
CREATE INDEX "person_oidcSubject_idx" ON "person"("oidcSubject");
-- CreateIndex
CREATE UNIQUE INDEX "person_organisationId_email_key" ON "person"("organisationId", "email");
-- CreateIndex
CREATE UNIQUE INDEX "person_organisationId_oidcSubject_key" ON "person"("organisationId", "oidcSubject");
-- CreateIndex
CREATE UNIQUE INDEX "person_organisationId_mitgliedsnummer_key" ON "person"("organisationId", "mitgliedsnummer");
-- CreateIndex
CREATE INDEX "rolle_organisationId_idx" ON "rolle"("organisationId");
-- CreateIndex
CREATE UNIQUE INDEX "rolle_organisationId_schluessel_key" ON "rolle"("organisationId", "schluessel");
-- CreateIndex
CREATE INDEX "person_rolle_organisationId_personId_idx" ON "person_rolle"("organisationId", "personId");
-- CreateIndex
CREATE INDEX "person_rolle_organisationId_rolleId_idx" ON "person_rolle"("organisationId", "rolleId");
-- CreateIndex
CREATE UNIQUE INDEX "person_rolle_personId_rolleId_key" ON "person_rolle"("personId", "rolleId");
-- CreateIndex
CREATE UNIQUE INDEX "berechtigung_schluessel_key" ON "berechtigung"("schluessel");
-- CreateIndex
CREATE INDEX "berechtigung_bereich_idx" ON "berechtigung"("bereich");
-- CreateIndex
CREATE INDEX "rolle_berechtigung_organisationId_rolleId_idx" ON "rolle_berechtigung"("organisationId", "rolleId");
-- CreateIndex
CREATE UNIQUE INDEX "rolle_berechtigung_rolleId_berechtigungId_key" ON "rolle_berechtigung"("rolleId", "berechtigungId");
-- CreateIndex
CREATE INDEX "ausweis_token_organisationId_personId_idx" ON "ausweis_token"("organisationId", "personId");
-- CreateIndex
CREATE INDEX "ausweis_token_organisationId_typ_aktiv_idx" ON "ausweis_token"("organisationId", "typ", "aktiv");
-- CreateIndex
CREATE INDEX "audit_log_organisationId_zeitpunkt_idx" ON "audit_log"("organisationId", "zeitpunkt" DESC);
-- CreateIndex
CREATE INDEX "audit_log_organisationId_entitaet_entitaetId_idx" ON "audit_log"("organisationId", "entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "audit_log_organisationId_akteurPersonId_idx" ON "audit_log"("organisationId", "akteurPersonId");
-- CreateIndex
CREATE INDEX "datei_organisationId_entitaet_entitaetId_idx" ON "datei"("organisationId", "entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "datei_organisationId_kategorie_idx" ON "datei"("organisationId", "kategorie");
-- CreateIndex
CREATE UNIQUE INDEX "datei_bucket_objektSchluessel_key" ON "datei"("bucket", "objektSchluessel");
-- CreateIndex
CREATE INDEX "fahrzeug_organisationId_aktiv_idx" ON "fahrzeug"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "fahrzeug_organisationId_funkrufname_key" ON "fahrzeug"("organisationId", "funkrufname");
-- CreateIndex
CREATE INDEX "geraet_organisationId_status_idx" ON "geraet"("organisationId", "status");
-- CreateIndex
CREATE INDEX "geraet_organisationId_naechstePruefung_idx" ON "geraet"("organisationId", "naechstePruefung");
-- CreateIndex
CREATE INDEX "geraet_organisationId_fahrzeugId_idx" ON "geraet"("organisationId", "fahrzeugId");
-- CreateIndex
CREATE UNIQUE INDEX "geraet_organisationId_inventarnummer_key" ON "geraet"("organisationId", "inventarnummer");
-- AddForeignKey
ALTER TABLE "person" ADD CONSTRAINT "person_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person" ADD CONSTRAINT "person_avatarDateiId_fkey" FOREIGN KEY ("avatarDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle" ADD CONSTRAINT "rolle_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_rolleId_fkey" FOREIGN KEY ("rolleId") REFERENCES "rolle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "person_rolle" ADD CONSTRAINT "person_rolle_zugewiesenVonId_fkey" FOREIGN KEY ("zugewiesenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle_berechtigung" ADD CONSTRAINT "rolle_berechtigung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle_berechtigung" ADD CONSTRAINT "rolle_berechtigung_rolleId_fkey" FOREIGN KEY ("rolleId") REFERENCES "rolle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rolle_berechtigung" ADD CONSTRAINT "rolle_berechtigung_berechtigungId_fkey" FOREIGN KEY ("berechtigungId") REFERENCES "berechtigung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ausweis_token" ADD CONSTRAINT "ausweis_token_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ausweis_token" ADD CONSTRAINT "ausweis_token_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_akteurPersonId_fkey" FOREIGN KEY ("akteurPersonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "datei" ADD CONSTRAINT "datei_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "datei" ADD CONSTRAINT "datei_hochgeladenVonId_fkey" FOREIGN KEY ("hochgeladenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrzeug" ADD CONSTRAINT "fahrzeug_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,156 @@
-- =============================================================================
-- Row Level Security: Mandantentrennung auf Datenbankebene.
--
-- Warum auf Datenbankebene und nicht nur im Code?
-- Weil ein vergessener WHERE-Filter sonst Daten einer fremden Wehr ausliefert.
-- Mit RLS ist der schlimmste Fall ein leeres Ergebnis, nicht ein Datenleck.
--
-- Funktionsweise:
-- Die Anwendung verbindet sich als "florian_app" (NOBYPASSRLS) und setzt zu
-- Beginn jeder Transaktion
-- SET LOCAL app.current_organisation = '<uuid>';
-- Siehe src/lib/db.ts. Ohne gesetzten Wert liefert jede Abfrage null Zeilen.
--
-- Der Eigentümer der Tabellen (POSTGRES_USER) umgeht RLS. Das ist gewollt:
-- Migrationen und das Seed-Skript müssen über alle Mandanten hinweg
-- arbeiten. Deshalb steht hier bewusst kein FORCE ROW LEVEL SECURITY.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Hilfsfunktionen
-- -----------------------------------------------------------------------------
-- Liest die aktive Organisation aus der Sitzung. Ist nichts gesetzt oder steht
-- dort kein gültiges UUID, kommt NULL zurück — und NULL vergleicht sich mit
-- nichts, die Abfrage liefert also keine Zeilen.
CREATE OR REPLACE FUNCTION aktuelle_organisation()
RETURNS uuid
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
wert text;
BEGIN
wert := current_setting('app.current_organisation', true);
IF wert IS NULL OR wert = '' THEN
RETURN NULL;
END IF;
RETURN wert::uuid;
EXCEPTION
WHEN invalid_text_representation THEN
RETURN NULL;
END;
$$;
COMMENT ON FUNCTION aktuelle_organisation() IS
'Aktive Organisation der Sitzung, gesetzt per SET LOCAL app.current_organisation.';
-- Verhindert Änderungen am Audit-Log — auch durch den Tabelleneigentümer.
CREATE OR REPLACE FUNCTION audit_log_unveraenderlich()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION
'Der Audit-Log ist unveraenderlich. % ist auf audit_log nicht zulaessig.',
TG_OP
USING ERRCODE = 'insufficient_privilege';
END;
$$;
-- -----------------------------------------------------------------------------
-- Mandantenpflichtige Tabellen
-- -----------------------------------------------------------------------------
-- Die Organisation selbst: hier ist die id die Mandantenspalte.
ALTER TABLE "organisation" ENABLE ROW LEVEL SECURITY;
CREATE POLICY organisation_mandant ON "organisation"
FOR ALL
USING ("id" = aktuelle_organisation())
WITH CHECK ("id" = aktuelle_organisation());
-- Alle übrigen Tabellen hängen über organisationId am Mandanten.
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'person',
'rolle',
'person_rolle',
'rolle_berechtigung',
'ausweis_token',
'datei',
'fahrzeug',
'geraet'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
EXECUTE format(
'CREATE POLICY %I ON %I FOR ALL '
'USING ("organisationId" = aktuelle_organisation()) '
'WITH CHECK ("organisationId" = aktuelle_organisation())',
tabelle || '_mandant', tabelle
);
END LOOP;
END;
$$;
-- -----------------------------------------------------------------------------
-- Audit-Log: mandantengetrennt UND append-only
-- Bewusst nur SELECT und INSERT — es gibt keine Policy für UPDATE und DELETE,
-- die Anwendungsrolle kann also gar nicht ändern oder löschen.
-- -----------------------------------------------------------------------------
ALTER TABLE "audit_log" ENABLE ROW LEVEL SECURITY;
CREATE POLICY audit_log_lesen ON "audit_log"
FOR SELECT
USING ("organisationId" = aktuelle_organisation());
CREATE POLICY audit_log_schreiben ON "audit_log"
FOR INSERT
WITH CHECK ("organisationId" = aktuelle_organisation());
CREATE TRIGGER audit_log_kein_update
BEFORE UPDATE ON "audit_log"
FOR EACH ROW EXECUTE FUNCTION audit_log_unveraenderlich();
CREATE TRIGGER audit_log_kein_delete
BEFORE DELETE ON "audit_log"
FOR EACH ROW EXECUTE FUNCTION audit_log_unveraenderlich();
COMMENT ON TABLE "audit_log" IS
'Append-only. Aenderungen und Loeschungen sind per Trigger gesperrt. '
'Die Aufbewahrungsfrist raeumt der Worker ab, nachdem er exportiert hat.';
-- -----------------------------------------------------------------------------
-- Berechtigungskatalog: systemweit, kein Mandantenbezug.
-- Er beschreibt, was die Software kann — nicht, was eine Wehr besitzt.
-- Deshalb kein RLS, aber nur Lesezugriff für die Anwendung.
-- -----------------------------------------------------------------------------
COMMENT ON TABLE "berechtigung" IS
'Systemweiter Katalog der Einzelrechte. Bewusst ohne organisationId: '
'mandantenbezogen wird es erst ueber rolle_berechtigung.';
-- -----------------------------------------------------------------------------
-- Rechte für die Anwendungsrolle. Existiert die Rolle nicht (z. B. bei einer
-- Entwicklungsdatenbank ohne den Init-Container), wird der Block übersprungen.
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT USAGE ON SCHEMA public TO florian_app';
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT EXECUTE ON FUNCTION aktuelle_organisation() TO florian_app';
-- Der Katalog ist für die Anwendung nur lesbar.
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
-- Der Audit-Log ist für die Anwendung nur schreib- und lesbar.
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
ELSE
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
END IF;
END;
$$;
@@ -0,0 +1,60 @@
-- =============================================================================
-- Kontrollierte Ausnahme vom Änderungsverbot des Audit-Logs.
--
-- Ausgangslage: Der Trigger aus 20260902000100_rls_policies verbietet UPDATE
-- und DELETE auf audit_log. Das ist richtig — nachträglich verändern soll den
-- Log niemand.
--
-- Es gibt aber genau einen Vorgang, der löschen können muss: der
-- Aufbewahrungsjob des Workers. Nach AUDIT_AUFBEWAHRUNG_TAGE exportiert er die
-- alten Einträge nach MinIO und räumt sie danach ab. Ohne diese Möglichkeit
-- wächst die Tabelle unbegrenzt, und das Löschkonzept nach DSGVO ließe sich
-- nicht umsetzen.
--
-- Der Ausweg ist ein ausdrücklicher Schalter, den nur der Eigentümer-Zugang
-- setzen kann und der nur für die laufende Transaktion gilt:
--
-- BEGIN;
-- SELECT set_config('app.audit_bereinigung', 'an', true);
-- DELETE FROM audit_log WHERE zeitpunkt < now() - interval '10 years';
-- COMMIT;
--
-- UPDATE bleibt unter allen Umständen verboten. Ein Eintrag kann verschwinden,
-- nachdem er gesichert wurde — verfälscht werden kann er nie.
-- =============================================================================
CREATE OR REPLACE FUNCTION audit_log_unveraenderlich()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Änderungen sind ausnahmslos gesperrt.
IF TG_OP = 'UPDATE' THEN
RAISE EXCEPTION
'Der Audit-Log ist unveraenderlich. UPDATE ist auf audit_log nicht zulaessig.'
USING ERRCODE = 'insufficient_privilege';
END IF;
-- Löschen nur im ausdrücklich gekennzeichneten Aufbewahrungslauf.
IF TG_OP = 'DELETE' THEN
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION
'Der Audit-Log ist unveraenderlich. DELETE ist nur im Aufbewahrungslauf zulaessig '
'(SET LOCAL app.audit_bereinigung = ''an'').'
USING ERRCODE = 'insufficient_privilege';
END IF;
RETURN NULL;
END;
$$;
COMMENT ON FUNCTION audit_log_unveraenderlich() IS
'Sperrt UPDATE auf audit_log dauerhaft und DELETE ausserhalb des Aufbewahrungslaufs.';
-- Die Anwendungsrolle darf den Schalter gar nicht erst setzen: sie hat kein
-- DELETE-Recht auf audit_log (siehe vorherige Migration). Der Schalter wirkt
-- also nur fuer den Eigentuemer-Zugang, den ausschliesslich Worker und
-- Migrationen benutzen.
+528
View File
@@ -0,0 +1,528 @@
// =============================================================================
// FLORIAN — Datenmodell, Phase 0 (Fundament)
//
// Grundregeln, die für JEDES Modell gelten:
// 1. Mandantentrennung: jede Tabelle trägt organisationId. Postgres Row Level
// Security setzt das durch (siehe migrations/.../rls). Ausnahme ist der
// systemweite Berechtigungskatalog, siehe Kommentar dort.
// 2. Jede schreibende Operation erzeugt einen AuditLog-Eintrag (lib/audit.ts).
// 3. Prüf-, Wartungs- und Buchungsdaten werden nie hart gelöscht:
// geloeschtAm setzen statt DELETE.
// 4. Alle Zeitstempel in UTC speichern, Anzeige in Europe/Berlin.
//
// Bezeichner sind englisch, wo es Technik ist, und deutsch, wo es Fachlichkeit
// ist — so wie es in der Wehr auch gesprochen wird.
// =============================================================================
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "debian-openssl-3.0.x"]
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_DATABASE_URL")
extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm")]
}
// =============================================================================
// Aufzählungen
// =============================================================================
/// Rollen im Sinne der Feuerwehrorganisation. Die Berechtigungen dahinter
/// stehen in lib/authz.ts und werden beim Seed in die Datenbank gespiegelt.
enum RolleSchluessel {
SUPERADMIN
KOMMANDANT
GERAETEWART
ATEMSCHUTZGERAETEWART
KASSIER
SCHRIFTFUEHRER
AUSBILDER
MITGLIED
KIOSK
}
/// Art des Ausweises, mit dem sich jemand am Terminal im Gerätehaus anmeldet.
enum AusweisTokenTyp {
PIN
RFID
}
/// Wie sich jemand angemeldet hat — landet im Audit-Log.
enum AnmeldeArt {
LOKAL
OIDC
KIOSK_PIN
KIOSK_RFID
SYSTEM
}
/// Aktionen im Audit-Log.
enum AuditAktion {
ERSTELLT
GEAENDERT
GELOESCHT
WIEDERHERGESTELLT
ANGEMELDET
ABGEMELDET
ANMELDUNG_FEHLGESCHLAGEN
EXPORTIERT
DATEI_HOCHGELADEN
DATEI_ABGERUFEN
BERECHTIGUNG_GEAENDERT
}
/// Wer die Aktion ausgelöst hat.
enum AkteurTyp {
PERSON
SYSTEM
WORKER
KIOSK
API
}
/// Betriebsstatus eines Geräts nach DGUV-Logik.
enum GeraetStatus {
EINSATZBEREIT
PRUEFUNG_FAELLIG
GESPERRT
AUSGESONDERT
IN_REPARATUR
}
/// Grobe Fahrzeugkategorie. Die vollständige DIN-Typisierung kommt in Phase 1.
enum FahrzeugArt {
LOESCHFAHRZEUG
DREHLEITER
RUESTWAGEN
GERAETEWAGEN
MANNSCHAFTSTRANSPORT
EINSATZLEITWAGEN
WECHSELLADER
ANHAENGER
SONSTIGES
}
/// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl).
enum Dienstgrad {
FEUERWEHRANWAERTER
FEUERWEHRMANN
OBERFEUERWEHRMANN
HAUPTFEUERWEHRMANN
LOESCHMEISTER
OBERLOESCHMEISTER
HAUPTLOESCHMEISTER
BRANDMEISTER
OBERBRANDMEISTER
HAUPTBRANDMEISTER
KEIN
}
// =============================================================================
// Mandant
// =============================================================================
/// Eine Feuerwehr. Oberste Klammer — an ihr hängt alles andere.
model Organisation {
id String @id @default(uuid()) @db.Uuid
/// Kurzform für URLs und Konfiguration, z. B. "ff-musterhausen".
slug String @unique @db.VarChar(64)
name String @db.VarChar(200)
kurzname String @db.VarChar(60)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
landkreis String? @db.VarChar(120)
bundesland String @default("Bayern") @db.VarChar(60)
telefon String? @db.VarChar(40)
email String? @db.VarChar(200)
/// Kennung im Einsatzleitsystem der Integrierten Leitstelle.
ilsKennung String? @db.VarChar(40)
/// Funkrufname-Präfix der Wehr, z. B. "Florian Musterhausen".
funkPraefix String? @db.VarChar(80)
zeitzone String @default("Europe/Berlin") @db.VarChar(60)
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
personen Person[]
rollen Rolle[]
personRollen PersonRolle[]
rolleRechte RolleBerechtigung[]
ausweisTokens AusweisToken[]
auditEintraege AuditLog[]
dateien Datei[]
fahrzeuge Fahrzeug[]
geraete Geraet[]
@@index([aktiv])
@@map("organisation")
}
// =============================================================================
// Personen und Rechte
// =============================================================================
/// Ein Mitglied der Wehr. Gleichzeitig der Anmeldebenutzer — es gibt bewusst
/// keine getrennte "User"-Tabelle, damit Dienstgrad, Qualifikation und Zugang
/// nicht auseinanderlaufen.
model Person {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
vorname String @db.VarChar(100)
nachname String @db.VarChar(100)
/// Fortlaufende Mitgliedsnummer innerhalb der Wehr.
mitgliedsnummer String? @db.VarChar(30)
geburtsdatum DateTime? @db.Date
dienstgrad Dienstgrad @default(KEIN)
email String? @db.VarChar(200)
telefon String? @db.VarChar(40)
mobil String? @db.VarChar(40)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
eintrittsdatum DateTime? @db.Date
austrittsdatum DateTime? @db.Date
/// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied.
abteilung String? @db.VarChar(60)
/// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird.
passwortHash String? @db.VarChar(120)
passwortGeaendertAm DateTime? @db.Timestamptz(3)
/// "sub"-Claim aus authentik. Verknüpft die Person mit dem OIDC-Konto.
oidcSubject String? @db.VarChar(255)
/// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit.
oidcGruppen String[] @default([])
letzteAnmeldung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
gesperrtBis DateTime? @db.Timestamptz(3)
aktiv Boolean @default(true)
/// Profilbild. Liegt wie alles andere in MinIO.
avatarDateiId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
rollen PersonRolle[] @relation("PersonRollenInhaber")
vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
ausweisTokens AusweisToken[]
auditEintraege AuditLog[]
hochgeladeneDateien Datei[] @relation("DateiHochgeladenVon")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email])
@@unique([organisationId, oidcSubject])
@@unique([organisationId, mitgliedsnummer])
@@index([organisationId, nachname, vorname])
@@index([organisationId, aktiv])
@@index([oidcSubject])
@@map("person")
}
/// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene
/// Rollen kann der Kommandant ergänzen.
model Rolle {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
schluessel RolleSchluessel
name String @db.VarChar(100)
beschreibung String? @db.VarChar(500)
/// Systemrollen dürfen nicht gelöscht werden.
systemRolle Boolean @default(false)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
personen PersonRolle[]
rechte RolleBerechtigung[]
@@unique([organisationId, schluessel])
@@index([organisationId])
@@map("rolle")
}
/// Zuweisung Person zu Rolle. Eine Person kann mehrere Rollen tragen — der
/// Gerätewart ist meist auch aktives Mitglied.
model PersonRolle {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
rolleId String @db.Uuid
zugewiesenAm DateTime @default(now()) @db.Timestamptz(3)
zugewiesenVonId String? @db.Uuid
/// Befristete Zuweisung, z. B. Vertretung während Urlaub.
gueltigBis DateTime? @db.Timestamptz(3)
/// true, wenn die Rolle aus einer authentik-Gruppe vorbelegt wurde. Sobald
/// jemand sie in FLORIAN ändert, steht hier false und OIDC überschreibt nicht
/// mehr.
ausOidcGruppe Boolean @default(false)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("PersonRollenInhaber", fields: [personId], references: [id], onDelete: Cascade)
rolle Rolle @relation(fields: [rolleId], references: [id], onDelete: Cascade)
zugewiesenVon Person? @relation("PersonRollenVergeber", fields: [zugewiesenVonId], references: [id], onDelete: SetNull)
@@unique([personId, rolleId])
@@index([organisationId, personId])
@@index([organisationId, rolleId])
@@map("person_rolle")
}
/// Systemweiter Katalog der Einzelrechte, z. B. "geraet.bearbeiten".
///
/// BEWUSSTE AUSNAHME von der organisationId-Regel: das hier sind keine
/// Mandantendaten, sondern eine Liste dessen, was die Software überhaupt kann.
/// Der Katalog wird vom Seed aus lib/authz.ts befüllt und ist für alle Wehren
/// identisch. Mandantenbezogen wird es erst durch RolleBerechtigung, und die
/// trägt organisationId. Die Tabelle ist deshalb für alle lesbar und für die
/// Anwendungsrolle nicht beschreibbar.
model Berechtigung {
id String @id @default(uuid()) @db.Uuid
schluessel String @unique @db.VarChar(80)
/// Fachbereich für die Gruppierung in der Oberfläche.
bereich String @db.VarChar(60)
bezeichnung String @db.VarChar(160)
beschreibung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
rollen RolleBerechtigung[]
@@index([bereich])
@@map("berechtigung")
}
/// Welche Rolle welches Recht hat — je Wehr getrennt, damit eine Wehr dem
/// Gerätewart mehr erlauben kann als eine andere.
model RolleBerechtigung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
rolleId String @db.Uuid
berechtigungId String @db.Uuid
/// false setzt ein Recht gezielt ab, das die Rolle sonst hätte.
gewaehrt Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
rolle Rolle @relation(fields: [rolleId], references: [id], onDelete: Cascade)
berechtigung Berechtigung @relation(fields: [berechtigungId], references: [id], onDelete: Cascade)
@@unique([rolleId, berechtigungId])
@@index([organisationId, rolleId])
@@map("rolle_berechtigung")
}
/// Anmeldemittel für das Terminal im Gerätehaus: PIN oder RFID-Karte.
/// Gespeichert wird immer nur ein Hash, nie die PIN oder die UID im Klartext.
model AusweisToken {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
typ AusweisTokenTyp
/// bcrypt-Hash der PIN bzw. der RFID-UID.
tokenHash String @db.VarChar(120)
/// Letzte vier Stellen der Karten-UID, damit man Karten unterscheiden kann.
kennzeichen String? @db.VarChar(20)
bezeichnung String? @db.VarChar(120)
aktiv Boolean @default(true)
gueltigAb DateTime @default(now()) @db.Timestamptz(3)
gueltigBis DateTime? @db.Timestamptz(3)
letzteNutzung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
gesperrtBis DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
/// Je Person und Art höchstens ein aktives Mittel dieser Bezeichnung.
@@index([organisationId, personId])
@@index([organisationId, typ, aktiv])
@@map("ausweis_token")
}
// =============================================================================
// Nachvollziehbarkeit
// =============================================================================
/// Append-only. Es gibt keine UPDATE- oder DELETE-Policy für die Anwendung;
/// zusätzlich verhindert ein Trigger Änderungen auch für den Eigentümer.
model AuditLog {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
zeitpunkt DateTime @default(now()) @db.Timestamptz(3)
akteurTyp AkteurTyp @default(PERSON)
akteurPersonId String? @db.Uuid
/// Klarname zum Zeitpunkt der Aktion — bleibt lesbar, auch wenn die Person
/// später anonymisiert wird.
akteurName String? @db.VarChar(200)
anmeldeArt AnmeldeArt?
aktion AuditAktion
/// Modellname, z. B. "Geraet".
entitaet String @db.VarChar(80)
entitaetId String? @db.Uuid
/// Menschenlesbare Kurzfassung für die Oberfläche.
beschreibung String? @db.VarChar(500)
vorher Json? @db.JsonB
nachher Json? @db.JsonB
/// Zusatzangaben: Anfrage-ID, Endpunkt, betroffene Felder.
kontext Json? @db.JsonB
ipAdresse String? @db.Inet
userAgent String? @db.VarChar(400)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
akteur Person? @relation(fields: [akteurPersonId], references: [id], onDelete: SetNull)
@@index([organisationId, zeitpunkt(sort: Desc)])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, akteurPersonId])
@@map("audit_log")
}
// =============================================================================
// Dateien
// =============================================================================
/// Metadaten zu einem Objekt in MinIO. Der Inhalt liegt NIE im Dateisystem und
/// nie in der Datenbank — hier steht nur, wo er zu finden ist.
model Datei {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
bucket String @db.VarChar(80)
/// Objektschlüssel in MinIO, z. B. "ff-musterhausen/geraet/<uuid>/foto.jpg".
objektSchluessel String @db.VarChar(500)
dateiname String @db.VarChar(300)
mimeTyp String @db.VarChar(160)
groesseBytes BigInt
/// SHA-256 des Inhalts. Erkennt Dubletten und belegt Unverändertheit.
pruefsumme String? @db.VarChar(64)
/// Woran die Datei hängt (z. B. "Geraet") und an welchem Datensatz.
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
/// Freie Einordnung: "pruefprotokoll", "foto", "rechnung", "export".
kategorie String? @db.VarChar(60)
hochgeladenVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
/// Soft-Delete: das Objekt in MinIO räumt erst der Aufbewahrungs-Job ab.
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
hochgeladenVon Person? @relation("DateiHochgeladenVon", fields: [hochgeladenVonId], references: [id], onDelete: SetNull)
alsAvatarGenutzt Person[] @relation("PersonAvatar")
@@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, kategorie])
@@map("datei")
}
// =============================================================================
// Vorgriff auf Phase 1
// Diese beiden Modelle sind bewusst schmal gehalten. Sie existieren, damit das
// Seed-Skript eine realistische Wehr aufbauen kann und die Mandantentrennung
// an echten Daten prüfbar ist. Phase 1 erweitert sie um Funkrufname-Systematik,
// DIN-Typisierung, Geräteräume, Prüfintervalle und Lebensdauerkategorien.
// =============================================================================
/// Ein Einsatzfahrzeug der Wehr.
model Fahrzeug {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Funkrufname, z. B. "Florian Musterhausen 40/1".
funkrufname String @db.VarChar(120)
bezeichnung String @db.VarChar(120)
art FahrzeugArt @default(LOESCHFAHRZEUG)
kennzeichen String? @db.VarChar(20)
hersteller String? @db.VarChar(120)
baujahr Int?
/// Nächste Hauptuntersuchung.
tuevBis DateTime? @db.Date
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
geraete Geraet[]
@@unique([organisationId, funkrufname])
@@index([organisationId, aktiv])
@@map("fahrzeug")
}
/// Ein prüfpflichtiges Gerät — Pressluftatmer, Schlauch, Leiter, Motorsäge.
model Geraet {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
inventarnummer String @db.VarChar(60)
bezeichnung String @db.VarChar(160)
hersteller String? @db.VarChar(120)
seriennummer String? @db.VarChar(120)
herstellungsdatum DateTime? @db.Date
indienststellung DateTime? @db.Date
status GeraetStatus @default(EINSATZBEREIT)
/// Prüfintervall in Monaten aus Vorschrift oder Herstellerangabe.
pruefintervallMonate Int?
naechstePruefung DateTime? @db.Date
/// Maximale Nutzungsdauer in Jahren (Helme, Seile, Schutzkleidung).
lebensdauerJahre Int?
/// Verlastet auf einem Fahrzeug — oder im Gerätehaus, dann null.
fahrzeugId String? @db.Uuid
lagerort String? @db.VarChar(160)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull)
@@unique([organisationId, inventarnummer])
@@index([organisationId, status])
@@index([organisationId, naechstePruefung])
@@index([organisationId, fahrzeugId])
@@map("geraet")
}
+595
View File
@@ -0,0 +1,595 @@
/**
* Seed — legt eine vollständige Beispielwehr an.
*
* Inhalt: Berechtigungskatalog, 9 Systemrollen, 15 Personen mit Rollen,
* 3 Fahrzeuge, 40 Geräte, PIN und RFID-Ausweise für das Terminal.
*
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
* die Organisation ja erst an, deren Kontext RLS bräuchte.
*
* Es ist idempotent — mehrfaches Ausführen ändert nichts an bereits
* vorhandenen Daten.
*
* npm run db:seed
*/
import "dotenv/config";
import {
Dienstgrad,
FahrzeugArt,
GeraetStatus,
PrismaClient,
RolleSchluessel,
AusweisTokenTyp,
} from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient({
datasources: {
db: { url: process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? "" },
},
});
const RUNDEN = Number(process.env.AUTH_BCRYPT_ROUNDS ?? 12);
const ORGANISATION_SLUG = "ff-musterhausen";
function log(nachricht: string): void {
console.info(`[seed] ${nachricht}`);
}
/** Deterministisches Datum, damit zwei Läufe dieselben Daten erzeugen. */
function datum(jahr: number, monat: number, tag: number): Date {
return new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0));
}
/** Verschiebt ein Datum um Monate — für Prüffristen. */
function plusMonate(basis: Date, monate: number): Date {
const neu = new Date(basis);
neu.setUTCMonth(neu.getUTCMonth() + monate);
return neu;
}
// -----------------------------------------------------------------------------
// Berechtigungskatalog
// -----------------------------------------------------------------------------
/**
* Der Katalog wird aus lib/authz.ts gespiegelt. Der Import geht bewusst über
* einen relativen Pfad, weil das Seed-Skript außerhalb des Next.js-Alias läuft.
*/
async function katalogAbgleichen(): Promise<Map<string, string>> {
const { RECHTE } = await import("../src/lib/authz");
const karte = new Map<string, string>();
for (const [schluessel, angaben] of Object.entries(RECHTE)) {
const eintrag = await prisma.berechtigung.upsert({
where: { schluessel },
update: { bereich: angaben.bereich, bezeichnung: angaben.bezeichnung },
create: {
schluessel,
bereich: angaben.bereich,
bezeichnung: angaben.bezeichnung,
},
});
karte.set(schluessel, eintrag.id);
}
log(`Berechtigungskatalog: ${karte.size} Einzelrechte`);
return karte;
}
// -----------------------------------------------------------------------------
// Organisation und Rollen
// -----------------------------------------------------------------------------
async function organisationAnlegen() {
const organisation = await prisma.organisation.upsert({
where: { slug: ORGANISATION_SLUG },
update: {},
create: {
slug: ORGANISATION_SLUG,
name: "Freiwillige Feuerwehr Musterhausen",
kurzname: "FF Musterhausen",
strasse: "Hauptstraße 12",
plz: "85354",
ort: "Musterhausen",
landkreis: "Freising",
bundesland: "Bayern",
telefon: "08161 123456",
email: "kommandant@ff-musterhausen.de",
ilsKennung: "FS-MUS-01",
funkPraefix: "Florian Musterhausen",
},
});
log(`Organisation: ${organisation.name} (${organisation.id})`);
return organisation;
}
async function rollenAnlegen(organisationId: string, katalog: Map<string, string>) {
const { ROLLEN, ROLLEN_BEZEICHNUNG, ROLLEN_BESCHREIBUNG, ROLLEN_RECHTE, ALLE_RECHTE } =
await import("../src/lib/authz");
const rollenKarte = new Map<string, string>();
for (const schluessel of ROLLEN) {
const rolle = await prisma.rolle.upsert({
where: {
organisationId_schluessel: {
organisationId,
schluessel: schluessel as RolleSchluessel,
},
},
update: {
name: ROLLEN_BEZEICHNUNG[schluessel],
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
},
create: {
organisationId,
schluessel: schluessel as RolleSchluessel,
name: ROLLEN_BEZEICHNUNG[schluessel],
beschreibung: ROLLEN_BESCHREIBUNG[schluessel],
systemRolle: true,
},
});
rollenKarte.set(schluessel, rolle.id);
// Rechte der Rolle spiegeln — der Administrator bekommt alles.
const rechte =
schluessel === "SUPERADMIN"
? ALLE_RECHTE
: (ROLLEN_RECHTE[schluessel as Exclude<typeof schluessel, "SUPERADMIN">] ?? []);
for (const recht of rechte) {
const berechtigungId = katalog.get(recht);
if (!berechtigungId) continue;
await prisma.rolleBerechtigung.upsert({
where: { rolleId_berechtigungId: { rolleId: rolle.id, berechtigungId } },
update: { gewaehrt: true },
create: { organisationId, rolleId: rolle.id, berechtigungId, gewaehrt: true },
});
}
}
log(`Rollen: ${rollenKarte.size} Systemrollen samt Rechtezuordnung`);
return rollenKarte;
}
// -----------------------------------------------------------------------------
// Personen
// -----------------------------------------------------------------------------
interface PersonVorlage {
vorname: string;
nachname: string;
dienstgrad: Dienstgrad;
rollen: RolleSchluessel[];
eintritt: Date;
abteilung: string;
/** PIN fürs Terminal. Nur im Seed im Klartext, in der Datenbank gehasht. */
pin?: string;
/** RFID-Kennung der Ausweiskarte. */
rfid?: string;
}
const PERSONEN: PersonVorlage[] = [
{
vorname: "Andreas",
nachname: "Huber",
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
rollen: [RolleSchluessel.KOMMANDANT],
eintritt: datum(1998, 4, 1),
abteilung: "Aktive",
pin: "471203",
rfid: "04A3B7C1D2",
},
{
vorname: "Bernhard",
nachname: "Maier",
dienstgrad: Dienstgrad.OBERBRANDMEISTER,
rollen: [RolleSchluessel.KOMMANDANT],
eintritt: datum(2001, 9, 15),
abteilung: "Aktive",
pin: "938157",
},
{
vorname: "Christian",
nachname: "Bauer",
dienstgrad: Dienstgrad.HAUPTLOESCHMEISTER,
rollen: [RolleSchluessel.GERAETEWART],
eintritt: datum(2005, 3, 1),
abteilung: "Aktive",
pin: "620394",
rfid: "04B8C2D3E4",
},
{
vorname: "Daniela",
nachname: "Schmid",
dienstgrad: Dienstgrad.OBERLOESCHMEISTER,
rollen: [RolleSchluessel.ATEMSCHUTZGERAETEWART],
eintritt: datum(2008, 7, 1),
abteilung: "Aktive",
pin: "184926",
rfid: "04C9D3E4F5",
},
{
vorname: "Elisabeth",
nachname: "Wagner",
dienstgrad: Dienstgrad.LOESCHMEISTER,
rollen: [RolleSchluessel.KASSIER],
eintritt: datum(2010, 1, 15),
abteilung: "Aktive",
pin: "573081",
},
{
vorname: "Franz",
nachname: "Fischer",
dienstgrad: Dienstgrad.LOESCHMEISTER,
rollen: [RolleSchluessel.SCHRIFTFUEHRER],
eintritt: datum(2009, 5, 20),
abteilung: "Aktive",
pin: "306159",
},
{
vorname: "Gabriele",
nachname: "Weber",
dienstgrad: Dienstgrad.BRANDMEISTER,
rollen: [RolleSchluessel.AUSBILDER],
eintritt: datum(2004, 11, 1),
abteilung: "Aktive",
pin: "849265",
},
{
vorname: "Hans",
nachname: "Becker",
dienstgrad: Dienstgrad.HAUPTFEUERWEHRMANN,
rollen: [RolleSchluessel.GERAETEWART, RolleSchluessel.AUSBILDER],
eintritt: datum(2012, 2, 1),
abteilung: "Aktive",
pin: "715384",
},
{
vorname: "Ingrid",
nachname: "Hofmann",
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2015, 6, 1),
abteilung: "Aktive",
},
{
vorname: "Josef",
nachname: "Schneider",
dienstgrad: Dienstgrad.OBERFEUERWEHRMANN,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2016, 4, 1),
abteilung: "Aktive",
},
{
vorname: "Katharina",
nachname: "Koch",
dienstgrad: Dienstgrad.FEUERWEHRMANN,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2019, 9, 1),
abteilung: "Aktive",
},
{
vorname: "Ludwig",
nachname: "Richter",
dienstgrad: Dienstgrad.FEUERWEHRMANN,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2020, 3, 1),
abteilung: "Aktive",
},
{
vorname: "Martina",
nachname: "Klein",
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2024, 1, 15),
abteilung: "Aktive",
},
{
vorname: "Nikolaus",
nachname: "Wolf",
dienstgrad: Dienstgrad.FEUERWEHRANWAERTER,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(2025, 2, 1),
abteilung: "Jugendfeuerwehr",
},
{
vorname: "Otto",
nachname: "Neumann",
dienstgrad: Dienstgrad.HAUPTBRANDMEISTER,
rollen: [RolleSchluessel.MITGLIED],
eintritt: datum(1975, 5, 1),
abteilung: "Passive",
},
];
async function personenAnlegen(
organisationId: string,
rollenKarte: Map<string, string>,
): Promise<void> {
let nummer = 1;
for (const vorlage of PERSONEN) {
const mitgliedsnummer = String(nummer).padStart(4, "0");
const email = `${vorlage.vorname}.${vorlage.nachname}`
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss") + "@ff-musterhausen.de";
const person = await prisma.person.upsert({
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer } },
update: {},
create: {
organisationId,
mitgliedsnummer,
vorname: vorlage.vorname,
nachname: vorlage.nachname,
dienstgrad: vorlage.dienstgrad,
email,
telefon: `08161 ${100000 + nummer * 7}`,
strasse: `Beispielweg ${nummer}`,
plz: "85354",
ort: "Musterhausen",
eintrittsdatum: vorlage.eintritt,
abteilung: vorlage.abteilung,
aktiv: vorlage.abteilung !== "Passive",
},
});
for (const schluessel of vorlage.rollen) {
const rolleId = rollenKarte.get(schluessel);
if (!rolleId) continue;
await prisma.personRolle.upsert({
where: { personId_rolleId: { personId: person.id, rolleId } },
update: {},
create: { organisationId, personId: person.id, rolleId },
});
}
// Terminal-Ausweise: PIN und, wo vorhanden, RFID-Karte.
if (vorlage.pin) {
const vorhanden = await prisma.ausweisToken.findFirst({
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.PIN },
});
if (!vorhanden) {
await prisma.ausweisToken.create({
data: {
organisationId,
personId: person.id,
typ: AusweisTokenTyp.PIN,
tokenHash: await bcrypt.hash(vorlage.pin, RUNDEN),
bezeichnung: "Terminal-PIN",
},
});
}
}
if (vorlage.rfid) {
const vorhanden = await prisma.ausweisToken.findFirst({
where: { organisationId, personId: person.id, typ: AusweisTokenTyp.RFID },
});
if (!vorhanden) {
await prisma.ausweisToken.create({
data: {
organisationId,
personId: person.id,
typ: AusweisTokenTyp.RFID,
tokenHash: await bcrypt.hash(vorlage.rfid, RUNDEN),
kennzeichen: vorlage.rfid.slice(-4),
bezeichnung: "Ausweiskarte",
},
});
}
}
nummer += 1;
}
log(`Personen: ${PERSONEN.length} Mitglieder samt Rollen und Terminal-Ausweisen`);
}
/**
* Der erste Administrator. Anders als die Mitglieder hat er ein Passwort für
* den lokalen Login — damit kommt man beim Erststart überhaupt hinein, bevor
* authentik eingerichtet ist.
*/
async function adminAnlegen(organisationId: string, rollenKarte: Map<string, string>) {
const email = (process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de").toLowerCase();
const passwort = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
const admin = await prisma.person.upsert({
where: { organisationId_email: { organisationId, email } },
update: {},
create: {
organisationId,
mitgliedsnummer: "0000",
vorname: "System",
nachname: "Administrator",
email,
dienstgrad: Dienstgrad.KEIN,
passwortHash: await bcrypt.hash(passwort, RUNDEN),
passwortGeaendertAm: new Date(),
abteilung: "Verwaltung",
},
});
const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN);
if (rolleId) {
await prisma.personRolle.upsert({
where: { personId_rolleId: { personId: admin.id, rolleId } },
update: {},
create: { organisationId, personId: admin.id, rolleId },
});
}
log(`Administrator: ${email} (Passwort aus SEED_ADMIN_PASSWORT)`);
return admin;
}
// -----------------------------------------------------------------------------
// Fahrzeuge und Geräte
// -----------------------------------------------------------------------------
const FAHRZEUGE = [
{
funkrufname: "Florian Musterhausen 40/1",
bezeichnung: "LF 20 (Löschgruppenfahrzeug)",
art: FahrzeugArt.LOESCHFAHRZEUG,
kennzeichen: "FS-MU 401",
hersteller: "MAN / Rosenbauer",
baujahr: 2018,
tuevBis: datum(2027, 5, 31),
},
{
funkrufname: "Florian Musterhausen 42/1",
bezeichnung: "TSF-W (Tragkraftspritzenfahrzeug Wasser)",
art: FahrzeugArt.LOESCHFAHRZEUG,
kennzeichen: "FS-MU 421",
hersteller: "Mercedes-Benz / Ziegler",
baujahr: 2011,
tuevBis: datum(2026, 11, 30),
},
{
funkrufname: "Florian Musterhausen 11/1",
bezeichnung: "MTW (Mannschaftstransportwagen)",
art: FahrzeugArt.MANNSCHAFTSTRANSPORT,
kennzeichen: "FS-MU 111",
hersteller: "Volkswagen",
baujahr: 2021,
tuevBis: datum(2027, 3, 31),
},
];
/**
* 40 Geräte mit realistischen Prüfintervallen. Die Fristen sind bewusst
* gemischt: einige liegen in der Vergangenheit, damit der Status
* "Prüfung fällig" in der Oberfläche auch sichtbar ist.
*/
const GERAETE_VORLAGEN: Array<{
bezeichnung: string;
hersteller: string;
anzahl: number;
intervallMonate: number;
lebensdauerJahre?: number;
aufFahrzeug?: number;
lagerort?: string;
}> = [
{ bezeichnung: "Pressluftatmer PA 94", hersteller: "Dräger", anzahl: 4, intervallMonate: 12, lebensdauerJahre: 20, aufFahrzeug: 0 },
{ bezeichnung: "Atemschutzmaske FPS 7000", hersteller: "Dräger", anzahl: 6, intervallMonate: 6, lebensdauerJahre: 10, lagerort: "Atemschutzwerkstatt" },
{ bezeichnung: "Atemluftflasche 6,8 l / 300 bar", hersteller: "Luxfer", anzahl: 8, intervallMonate: 60, lebensdauerJahre: 20, aufFahrzeug: 0 },
{ bezeichnung: "Feuerwehrhelm F220", hersteller: "Schuberth", anzahl: 6, intervallMonate: 12, lebensdauerJahre: 15, lagerort: "Umkleide" },
{ bezeichnung: "Feuerwehrleine 30 m", hersteller: "Gustav Bauer", anzahl: 3, intervallMonate: 12, lebensdauerJahre: 8, aufFahrzeug: 0 },
{ bezeichnung: "Steckleiterteil", hersteller: "Iturri", anzahl: 4, intervallMonate: 12, lebensdauerJahre: 25, aufFahrzeug: 0 },
{ bezeichnung: "Tragkraftspritze PFPN 10-1000", hersteller: "Rosenbauer", anzahl: 1, intervallMonate: 12, aufFahrzeug: 1 },
{ bezeichnung: "Hydraulischer Rettungssatz", hersteller: "Weber Rescue", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Motorkettensäge MS 261", hersteller: "Stihl", anzahl: 2, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Stromerzeuger 8 kVA", hersteller: "Endress", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Wärmebildkamera", hersteller: "Dräger", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Handsprechfunkgerät TPH900", hersteller: "Airbus", anzahl: 3, intervallMonate: 24, lagerort: "Funkraum" },
];
async function fahrzeugeUndGeraeteAnlegen(organisationId: string): Promise<void> {
const fahrzeugIds: string[] = [];
for (const vorlage of FAHRZEUGE) {
const fahrzeug = await prisma.fahrzeug.upsert({
where: {
organisationId_funkrufname: { organisationId, funkrufname: vorlage.funkrufname },
},
update: {},
create: { organisationId, ...vorlage },
});
fahrzeugIds.push(fahrzeug.id);
}
log(`Fahrzeuge: ${fahrzeugIds.length}`);
const heute = new Date();
let laufend = 1;
let angelegt = 0;
for (const vorlage of GERAETE_VORLAGEN) {
for (let index = 0; index < vorlage.anzahl; index += 1) {
const inventarnummer = `INV-${String(laufend).padStart(4, "0")}`;
// Ein Drittel der Geräte bekommt eine bereits überschrittene Frist,
// damit die Statusanzeige nicht nur grün ist.
const ueberfaellig = laufend % 3 === 0;
const indienststellung = datum(2015 + (laufend % 9), ((laufend % 12) + 1), 15);
const naechstePruefung = ueberfaellig
? plusMonate(heute, -(1 + (laufend % 5)))
: plusMonate(heute, 1 + (laufend % vorlage.intervallMonate || 1));
const status = ueberfaellig
? GeraetStatus.PRUEFUNG_FAELLIG
: laufend % 17 === 0
? GeraetStatus.GESPERRT
: GeraetStatus.EINSATZBEREIT;
await prisma.geraet.upsert({
where: { organisationId_inventarnummer: { organisationId, inventarnummer } },
update: {},
create: {
organisationId,
inventarnummer,
bezeichnung: vorlage.bezeichnung,
hersteller: vorlage.hersteller,
seriennummer: `SN-${vorlage.hersteller.slice(0, 3).toUpperCase()}-${100000 + laufend}`,
herstellungsdatum: datum(indienststellung.getUTCFullYear() - 1, 6, 1),
indienststellung,
status,
pruefintervallMonate: vorlage.intervallMonate,
naechstePruefung,
lebensdauerJahre: vorlage.lebensdauerJahre ?? null,
fahrzeugId:
vorlage.aufFahrzeug !== undefined ? (fahrzeugIds[vorlage.aufFahrzeug] ?? null) : null,
lagerort: vorlage.lagerort ?? null,
},
});
laufend += 1;
angelegt += 1;
}
}
log(`Geräte: ${angelegt}`);
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
async function main(): Promise<void> {
log("Starte ...");
const katalog = await katalogAbgleichen();
const organisation = await organisationAnlegen();
const rollen = await rollenAnlegen(organisation.id, katalog);
await adminAnlegen(organisation.id, rollen);
await personenAnlegen(organisation.id, rollen);
await fahrzeugeUndGeraeteAnlegen(organisation.id);
log("Fertig.");
log("");
log("Anmeldung für den Erststart:");
log(` E-Mail: ${process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de"}`);
log(` Passwort: ${process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"}`);
log("");
log("Terminal-PIN zum Ausprobieren (Mitgliedsnummer 0001, Andreas Huber): 471203");
}
main()
.catch((fehler) => {
console.error("[seed] Abbruch:", fehler);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Erzeugt die Bilder für docs/ANLEITUNG.md.
*
* Läuft gegen die laufende Anwendung, meldet sich mit dem Seed-Administrator an
* und legt die Bilder unter docs/screenshots ab — jeweils in heller und dunkler
* Darstellung, damit die Anleitung zu beiden Einstellungen passt.
*
* docker compose up -d
* npm run dev
* npm run screenshots
*
* Die Bilder werden mit eingecheckt: die Anleitung soll auch dann lesbar sein,
* wenn gerade kein Stack läuft.
*/
import "dotenv/config";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Page } from "@playwright/test";
const BASIS = process.env.E2E_BASIS_URL ?? "http://localhost:3100";
const ZIEL = path.join(process.cwd(), "docs", "screenshots");
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
interface Aufnahme {
name: string;
pfad: string;
beschreibung: string;
/** true, wenn vorher angemeldet werden muss. */
angemeldet: boolean;
}
const AUFNAHMEN: Aufnahme[] = [
{
name: "01-anmeldung",
pfad: "/anmelden",
beschreibung: "Anmeldeseite mit lokalem Login",
angemeldet: false,
},
{
name: "02-uebersicht",
pfad: "/",
beschreibung: "Übersicht nach der Anmeldung",
angemeldet: true,
},
];
async function anmelden(seite: Page): Promise<void> {
await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" });
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await seite.getByRole("button", { name: "Anmelden" }).click();
await seite.waitForURL(`${BASIS}/`, { timeout: 20_000 });
await seite.waitForLoadState("networkidle");
}
async function main(): Promise<void> {
await mkdir(ZIEL, { recursive: true });
const browser = await chromium.launch();
for (const darstellung of ["light", "dark"] as const) {
const kontext = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
locale: "de-DE",
timezoneId: "Europe/Berlin",
colorScheme: darstellung,
});
const seite = await kontext.newPage();
let istAngemeldet = false;
for (const aufnahme of AUFNAHMEN) {
if (aufnahme.angemeldet && !istAngemeldet) {
await anmelden(seite);
istAngemeldet = true;
}
await seite.goto(`${BASIS}${aufnahme.pfad}`, { waitUntil: "networkidle" });
// Kurz warten, damit Übergänge und Symbole fertig gezeichnet sind.
await seite.waitForTimeout(400);
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
await seite.screenshot({ path: datei, fullPage: true });
console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`);
}
await kontext.close();
}
await browser.close();
console.info("[screenshots] Fertig.");
}
main().catch((fehler) => {
console.error("[screenshots] Abbruch:", fehler);
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { rechteVon } from "@/lib/authz";
import { Seitenleiste } from "@/components/layout/seitenleiste";
import { ThemeToggle } from "@/components/layout/theme-toggle";
/**
* Layout für alles, was eine Anmeldung verlangt. Wer nicht angemeldet ist,
* landet auf der Anmeldeseite — geprüft wird serverseitig, nicht im Browser.
*/
export default async function AngemeldetLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const organisation = await fuerOrganisation(sitzung.organisationId, (db) =>
db.organisation.findUnique({
where: { id: sitzung.organisationId },
select: { name: true, kurzname: true },
}),
);
return (
<div className="min-h-screen">
<Seitenleiste
rechte={[...rechteVon(sitzung.rollen)]}
rollen={sitzung.rollen}
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
personName={sitzung.name}
/>
<div className="md:pl-64">
<header className="flex h-16 items-center justify-end gap-2 border-b border-[var(--border)] px-6 nicht-drucken">
<ThemeToggle />
</header>
<main className="p-6">{children}</main>
</div>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { AlertTriangle, Truck, Users, Wrench } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { ROLLEN_BEZEICHNUNG } from "@/lib/authz";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { alsDatumZeit } from "@/lib/utils";
export const metadata = { title: "Übersicht" };
/** Startseite nach der Anmeldung. */
export default async function UebersichtSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) return null;
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const [organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt] =
await Promise.all([
db.organisation.findUnique({ where: { id: sitzung.organisationId } }),
db.person.count({ where: { aktiv: true, geloeschtAm: null } }),
db.fahrzeug.count({ where: { aktiv: true, geloeschtAm: null } }),
db.geraet.count({ where: { geloeschtAm: null } }),
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
db.geraet.count({ where: { status: "GESPERRT", geloeschtAm: null } }),
]);
return { organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt };
});
const kacheln = [
{ titel: "Aktive Mitglieder", wert: daten.mitglieder, symbol: Users },
{ titel: "Fahrzeuge", wert: daten.fahrzeuge, symbol: Truck },
{ titel: "Geräte", wert: daten.geraete, symbol: Wrench },
{ titel: "Prüfung fällig", wert: daten.pruefungFaellig, symbol: AlertTriangle },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{daten.organisation?.name ?? "Feuerwehr"}
</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Angemeldet als {sitzung.name} —{" "}
{sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]).join(", ")}
</p>
</div>
{/*
Sicherheitshinweis nach den Projektleitplanken. Er bleibt stehen, bis
die Wehr die Anwendung ausdrücklich freigegeben hat.
*/}
<div
role="note"
className="flex items-start gap-3 rounded-lg border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-4"
>
<AlertTriangle
className="mt-0.5 size-5 shrink-0 text-[var(--pruefung-faellig)]"
aria-hidden
/>
<div className="text-sm">
<p className="font-medium">Diese Installation ist noch nicht freigegeben.</p>
<p className="text-[var(--muted-foreground)]">
Atemschutzüberwachung und Alarmmonitor sind sicherheitsrelevant. Solange die
Wehr das System nicht ausdrücklich freigegeben hat, bleibt die analoge
Überwachung führend. FLORIAN ersetzt sie nicht.
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{kacheln.map((kachel) => {
const Symbol = kachel.symbol;
return (
<Card key={kachel.titel}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{kachel.titel}
</CardTitle>
<Symbol className="size-4 text-[var(--muted-foreground)]" aria-hidden />
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold tabellenzahlen">{kachel.wert}</p>
</CardContent>
</Card>
);
})}
</div>
<Card>
<CardHeader>
<CardTitle>Stand des Aufbaus</CardTitle>
<CardDescription>
Phase 0 ist abgeschlossen: Fundament, Mandantentrennung, Anmeldung,
Dateiablage und Protokollierung stehen. Die Fachmodule kommen Phase für
Phase dazu.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="einsatzbereit">Phase 0 — Fundament</Badge>
<Badge variant="outline">Phase 1 — Stammdaten</Badge>
<Badge variant="outline">Phase 2 — Wartung und Prüfung</Badge>
<Badge variant="outline">Phase 3 — Einsätze</Badge>
<Badge variant="outline">Phase 4 — Terminal</Badge>
<Badge variant="outline">Phase 5 — Atemschutz</Badge>
</div>
<dl className="grid gap-2 sm:grid-cols-2">
<div>
<dt className="text-[var(--muted-foreground)]">Gesperrte Geräte</dt>
<dd className="tabellenzahlen">{daten.gesperrt}</dd>
</div>
<div>
<dt className="text-[var(--muted-foreground)]">Zeitpunkt</dt>
<dd className="tabellenzahlen">{alsDatumZeit(new Date())}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import * as React from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { KeyRound, LogIn } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export interface AnmeldeFormularProps {
oidcVerfuegbar: boolean;
oidcName: string;
lokalVerfuegbar: boolean;
fehler?: string;
weiterZu?: string;
}
/** Wandelt die Fehlerkennungen von Auth.js in verständliches Deutsch. */
function fehlertext(kennung?: string): string | null {
if (!kennung) return null;
switch (kennung) {
case "CredentialsSignin":
return "E-Mail-Adresse oder Passwort stimmt nicht. Nach mehreren Fehlversuchen wird das Konto vorübergehend gesperrt.";
case "AccessDenied":
return "Für dieses Konto gibt es in FLORIAN keinen Zugang. Bitte beim Kommandanten melden.";
case "OAuthAccountNotLinked":
return "Dieses Konto ist bereits mit einem anderen Anmeldeweg verknüpft.";
case "Configuration":
return "Die Anmeldung ist nicht vollständig eingerichtet. Bitte in der Serverkonfiguration prüfen.";
default:
return "Die Anmeldung ist fehlgeschlagen. Bitte erneut versuchen.";
}
}
export function AnmeldeFormular({
oidcVerfuegbar,
oidcName,
lokalVerfuegbar,
fehler,
weiterZu,
}: AnmeldeFormularProps) {
const router = useRouter();
const [laeuft, setLaeuft] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(fehlertext(fehler));
async function lokalAnmelden(ereignis: React.FormEvent<HTMLFormElement>) {
ereignis.preventDefault();
setMeldung(null);
setLaeuft(true);
const daten = new FormData(ereignis.currentTarget);
const ergebnis = await signIn("passwort", {
email: String(daten.get("email") ?? ""),
passwort: String(daten.get("passwort") ?? ""),
redirect: false,
});
setLaeuft(false);
if (ergebnis?.error) {
setMeldung(fehlertext("CredentialsSignin"));
return;
}
router.push(weiterZu ?? "/");
router.refresh();
}
return (
<div className="space-y-5">
{meldung && (
<p
role="alert"
data-testid="anmeldefehler"
className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]"
>
{meldung}
</p>
)}
{oidcVerfuegbar && (
<Button
type="button"
className="w-full"
disabled={laeuft}
onClick={() => {
setLaeuft(true);
void signIn("authentik", { callbackUrl: weiterZu ?? "/" });
}}
>
<KeyRound aria-hidden />
{oidcName}
</Button>
)}
{oidcVerfuegbar && lokalVerfuegbar && (
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-[var(--border)]" />
<span className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
oder
</span>
<span className="h-px flex-1 bg-[var(--border)]" />
</div>
)}
{lokalVerfuegbar && (
<form onSubmit={lokalAnmelden} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="username"
required
placeholder="kommandant@beispielwehr.de"
/>
</div>
<div className="space-y-2">
<Label htmlFor="passwort">Passwort</Label>
<Input
id="passwort"
name="passwort"
type="password"
autoComplete="current-password"
required
/>
</div>
<Button type="submit" className="w-full" disabled={laeuft}>
<LogIn aria-hidden />
{laeuft ? "Wird geprüft ..." : "Anmelden"}
</Button>
</form>
)}
{!oidcVerfuegbar && !lokalVerfuegbar && (
<p className="text-sm text-[var(--muted-foreground)]">
Es ist kein Anmeldeweg eingerichtet. Bitte in der Serverkonfiguration
AUTH_LOCAL_ENABLED oder AUTH_OIDC_ENABLED setzen.
</p>
)}
<p className="text-xs text-[var(--muted-foreground)]">
Am Terminal im Gerätehaus wird stattdessen mit PIN oder Ausweiskarte
angemeldet.
</p>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { redirect } from "next/navigation";
import { Flame } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { env, oidcVerfuegbar } from "@/lib/env";
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
import { AnmeldeFormular } from "@/app/anmelden/formular";
export const metadata = { title: "Anmelden" };
/**
* Anmeldeseite. Zeigt genau die Wege, die in dieser Installation eingerichtet
* sind: OIDC gegen authentik, lokaler Login, oder beides.
*/
export default async function AnmeldenSeite({
searchParams,
}: {
searchParams: Promise<{ error?: string; callbackUrl?: string }>;
}) {
const sitzung = await aktuelleSitzung();
if (sitzung) redirect("/");
const { error, callbackUrl } = await searchParams;
return (
<main className="flex min-h-screen items-center justify-center bg-[var(--muted)] p-6">
<Card className="w-full max-w-md">
<CardHeader className="space-y-3">
<div className="flex items-center gap-3">
<Flame className="size-8 text-[var(--primary)]" aria-hidden />
<div>
{/* Echte h1: die Seite braucht eine Ueberschrift, die
Hilfsmittel und Tests ansteuern koennen. */}
<h1 className="text-xl font-semibold leading-none tracking-tight">
FLORIAN
</h1>
<CardDescription className="mt-1.5">Feuerwehrverwaltung</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<AnmeldeFormular
oidcVerfuegbar={oidcVerfuegbar}
oidcName={env.AUTH_OIDC_NAME}
lokalVerfuegbar={env.AUTH_LOCAL_ENABLED}
fehler={error}
weiterZu={callbackUrl}
/>
</CardContent>
</Card>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { handlers } from "@/lib/auth";
/**
* Auth.js-Endpunkte. Die Rückleitung von authentik landet hier unter
* /api/auth/callback/authentik — genau diese Adresse muss im authentik-Provider
* als Redirect-URI eingetragen sein.
*/
export const { GET, POST } = handlers;
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { datenbankErreichbar } from "@/lib/db";
import { speicherErreichbar } from "@/lib/storage";
import { warteschlangeErreichbar } from "@/lib/warteschlange";
/**
* Erreichbarkeitsprüfung für Docker, Traefik und die Überwachung.
* 200, wenn Datenbank, Objektspeicher und Warteschlange antworten — sonst 503.
* Bewusst ohne personenbezogene Angaben, damit der Endpunkt offen sein kann.
*/
export const dynamic = "force-dynamic";
export const revalidate = 0;
export async function GET() {
const beginn = Date.now();
const [datenbank, speicher, warteschlange] = await Promise.all([
datenbankErreichbar(),
speicherErreichbar(),
warteschlangeErreichbar(),
]);
const gesund = datenbank && speicher && warteschlange;
return NextResponse.json(
{
status: gesund ? "ok" : "gestoert",
dienste: { datenbank, speicher, warteschlange },
dauerMs: Date.now() - beginn,
zeitpunkt: new Date().toISOString(),
},
{
status: gesund ? 200 : 503,
headers: { "Cache-Control": "no-store" },
},
);
}
+22
View File
@@ -0,0 +1,22 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { kontextErzeugen } from "@/server/context";
import { env } from "@/lib/env";
/** Einstiegspunkt für alle internen tRPC-Aufrufe. */
function verarbeiten(anfrage: Request) {
return fetchRequestHandler({
endpoint: "/api/trpc",
req: anfrage,
router: appRouter,
createContext: () => kontextErzeugen({ req: anfrage }),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(`[trpc] Fehler in ${path ?? "<unbekannt>"}:`, error.message);
}
: undefined,
});
}
export { verarbeiten as GET, verarbeiten as POST };
+170
View File
@@ -0,0 +1,170 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/*
Farbwelt FLORIAN.
Grundton ist das Rot der Feuerwehr, bewusst entsättigt — eine Oberfläche, in
der jemand acht Stunden Prüfungen dokumentiert, darf nicht schreien. Signalrot
bleibt für das reserviert, was wirklich Alarm ist: gesperrte Geräte,
überzogene Fristen, laufende Atemschutzüberwachung.
Keine Google Fonts, keine externen CDNs — es werden ausschließlich Schriften
benutzt, die auf dem Gerät vorhanden sind.
*/
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Statusfarben der Gerätezustände — überall dieselben. */
--color-einsatzbereit: var(--einsatzbereit);
--color-pruefung-faellig: var(--pruefung-faellig);
--color-gesperrt: var(--gesperrt);
--color-ausgesondert: var(--ausgesondert);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: "Inter", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "JetBrains Mono", "Cascadia Code", Consolas, "Courier New", monospace;
}
:root {
--radius: 0.625rem;
--background: oklch(0.99 0.002 60);
--foreground: oklch(0.21 0.01 40);
--card: oklch(1 0 0);
--card-foreground: oklch(0.21 0.01 40);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.21 0.01 40);
/* Feuerwehrrot, für Flächen nutzbar gedämpft. */
--primary: oklch(0.52 0.19 27);
--primary-foreground: oklch(0.99 0.005 60);
--secondary: oklch(0.96 0.005 60);
--secondary-foreground: oklch(0.28 0.015 40);
--muted: oklch(0.96 0.005 60);
--muted-foreground: oklch(0.53 0.012 45);
--accent: oklch(0.95 0.012 40);
--accent-foreground: oklch(0.28 0.015 40);
--destructive: oklch(0.58 0.23 27);
--destructive-foreground: oklch(0.99 0.005 60);
--border: oklch(0.91 0.005 60);
--input: oklch(0.91 0.005 60);
--ring: oklch(0.52 0.19 27);
--einsatzbereit: oklch(0.62 0.15 150);
--pruefung-faellig: oklch(0.72 0.16 75);
--gesperrt: oklch(0.58 0.23 27);
--ausgesondert: oklch(0.6 0.01 60);
}
/*
Dunkle Darstellung. Nicht bloß invertiert: im Gerätehaus wird abends und
nachts gearbeitet, deshalb ein warmes, kontrastarmes Grau statt Schwarz —
das blendet nicht, wenn man vom Fahrzeug hereinkommt.
*/
.dark {
--background: oklch(0.17 0.008 40);
--foreground: oklch(0.94 0.004 60);
--card: oklch(0.21 0.009 40);
--card-foreground: oklch(0.94 0.004 60);
--popover: oklch(0.21 0.009 40);
--popover-foreground: oklch(0.94 0.004 60);
--primary: oklch(0.63 0.19 27);
--primary-foreground: oklch(0.98 0.005 60);
--secondary: oklch(0.26 0.01 40);
--secondary-foreground: oklch(0.94 0.004 60);
--muted: oklch(0.26 0.01 40);
--muted-foreground: oklch(0.68 0.01 50);
--accent: oklch(0.29 0.012 40);
--accent-foreground: oklch(0.94 0.004 60);
--destructive: oklch(0.65 0.21 27);
--destructive-foreground: oklch(0.98 0.005 60);
--border: oklch(0.29 0.01 40);
--input: oklch(0.29 0.01 40);
--ring: oklch(0.63 0.19 27);
--einsatzbereit: oklch(0.68 0.15 150);
--pruefung-faellig: oklch(0.78 0.15 75);
--gesperrt: oklch(0.65 0.21 27);
--ausgesondert: oklch(0.55 0.01 60);
}
@layer base {
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Tastaturbedienung muss sichtbar sein — am Terminal wird auch getabbt. */
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* Zahlen in Tabellen sollen untereinander stehen. */
table,
.tabellenzahlen {
font-variant-numeric: tabular-nums;
}
}
/* Am Kiosk-Terminal wird mit dem Finger bedient: größere Ziele, keine Auswahl. */
.kiosk {
--radius: 0.875rem;
user-select: none;
}
.kiosk button,
.kiosk [role="button"] {
min-height: 3.5rem;
font-size: 1.125rem;
}
@media print {
.nicht-drucken {
display: none !important;
}
body {
background: white;
color: black;
}
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/theme-provider";
export const metadata: Metadata = {
title: {
default: "FLORIAN — Feuerwehrverwaltung",
template: "%s · FLORIAN",
},
description:
"Verwaltungsplattform für Freiwillige Feuerwehren: Mitglieder, Fahrzeuge, Geräte, Prüfungen, Atemschutz, Einsätze.",
applicationName: "FLORIAN",
// Keine Suchmaschinen: die Installation steht im Gerätehaus, nicht im Netz.
robots: { index: false, follow: false },
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
// Am Terminal soll nicht versehentlich gezoomt werden.
maximumScale: 5,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#1c1a19" },
],
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
+216
View File
@@ -0,0 +1,216 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Album,
BookOpenCheck,
CalendarDays,
ClipboardCheck,
Flame,
Gauge,
Map,
Menu,
MonitorSmartphone,
Siren,
Truck,
Users,
Wrench,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import type { Recht, Rolle } from "@/lib/authz";
/**
* Hauptnavigation.
*
* Welche Einträge jemand sieht, entscheidet NICHT diese Komponente. Sie bekommt
* die Rechte der Sitzung übergeben und blendet danach aus — die Prüfung selbst
* bleibt in lib/authz.ts, wie es die Projektregeln verlangen. Ein ausgeblendeter
* Eintrag ist Bequemlichkeit, kein Schutz; der Schutz sitzt in der tRPC-Schicht.
*
* Die Phasenangaben entsprechen den Dateien Phase-0.md bis Phase-8.md.
*/
interface Eintrag {
titel: string;
pfad: string;
symbol: React.ComponentType<{ className?: string }>;
/** Sichtbar, wenn eines dieser Rechte vorliegt. Leer = für alle. */
rechte?: Recht[];
/** Ab welcher Phase es die Ansicht wirklich gibt. */
abPhase?: number;
}
const EINTRAEGE: Eintrag[] = [
{ titel: "Übersicht", pfad: "/", symbol: Gauge },
{ titel: "Mitglieder", pfad: "/mitglieder", symbol: Users, rechte: ["person.lesen"], abPhase: 1 },
{ titel: "Fahrzeuge", pfad: "/fahrzeuge", symbol: Truck, rechte: ["fahrzeug.lesen"], abPhase: 1 },
{ titel: "Geräte", pfad: "/geraete", symbol: Wrench, rechte: ["geraet.lesen"], abPhase: 1 },
{
titel: "Qualifikationen",
pfad: "/qualifikationen",
symbol: BookOpenCheck,
rechte: ["qualifikation.lesen"],
abPhase: 1,
},
{
titel: "Prüfungen",
pfad: "/pruefungen",
symbol: ClipboardCheck,
rechte: ["pruefung.lesen"],
abPhase: 2,
},
{ titel: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
{
titel: "Terminal",
pfad: "/kiosk",
symbol: MonitorSmartphone,
rechte: ["kiosk.erfassen"],
abPhase: 4,
},
{
titel: "Atemschutz",
pfad: "/atemschutz",
symbol: Siren,
rechte: ["atemschutz.lesen"],
abPhase: 5,
},
{ titel: "Karte", pfad: "/karte", symbol: Map, rechte: ["einsatz.lesen"], abPhase: 6 },
{
titel: "Termine",
pfad: "/termine",
symbol: CalendarDays,
rechte: ["ausbildung.lesen"],
abPhase: 7,
},
{
titel: "Protokoll",
pfad: "/protokoll",
symbol: Album,
rechte: ["system.auditlog"],
abPhase: 1,
},
];
/** In Phase 0 gibt es nur die Übersicht — alles andere ist noch Ausblick. */
const AKTUELLE_PHASE = 0;
export interface SeitenleisteProps {
rechte: Recht[];
rollen: Rolle[];
organisationName: string;
personName: string;
}
export function Seitenleiste({ rechte, organisationName, personName }: SeitenleisteProps) {
const pfad = usePathname();
const [offen, setOffen] = React.useState(false);
const rechteMenge = React.useMemo(() => new Set(rechte), [rechte]);
const sichtbar = EINTRAEGE.filter(
(eintrag) => !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht)),
);
return (
<>
{/* Auf schmalen Geräten aufklappbar. */}
<Button
variant="ghost"
size="icon"
className="fixed left-3 top-3 z-50 md:hidden nicht-drucken"
onClick={() => setOffen((zustand) => !zustand)}
aria-label={offen ? "Navigation schließen" : "Navigation öffnen"}
aria-expanded={offen}
>
{offen ? <X aria-hidden /> : <Menu aria-hidden />}
</Button>
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-[var(--border)] bg-[var(--card)] transition-transform nicht-drucken",
"md:translate-x-0",
offen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex h-16 items-center gap-3 border-b border-[var(--border)] px-6">
<Flame className="size-6 text-[var(--primary)]" aria-hidden />
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-tight">FLORIAN</p>
<p className="truncate text-xs text-[var(--muted-foreground)]">
{organisationName}
</p>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-3" aria-label="Hauptnavigation">
<ul className="space-y-1">
{sichtbar.map((eintrag) => {
const aktiv =
eintrag.pfad === "/" ? pfad === "/" : pfad?.startsWith(eintrag.pfad);
const nochNicht = (eintrag.abPhase ?? 0) > AKTUELLE_PHASE;
const Symbol = eintrag.symbol;
if (nochNicht) {
return (
<li key={eintrag.pfad}>
<span
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-[var(--muted-foreground)] opacity-60"
title={`Kommt in Phase ${eintrag.abPhase}`}
>
<Symbol className="size-4 shrink-0" aria-hidden />
<span className="truncate">{eintrag.titel}</span>
<span className="ml-auto text-[10px] uppercase tracking-wide">
P{eintrag.abPhase}
</span>
</span>
</li>
);
}
return (
<li key={eintrag.pfad}>
<Link
href={eintrag.pfad}
onClick={() => setOffen(false)}
aria-current={aktiv ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
aktiv
? "bg-[var(--primary)] text-[var(--primary-foreground)] font-medium"
: "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
)}
>
<Symbol className="size-4 shrink-0" aria-hidden />
<span className="truncate">{eintrag.titel}</span>
</Link>
</li>
);
})}
</ul>
</nav>
<div className="border-t border-[var(--border)] p-4">
<p className="truncate text-sm font-medium">{personName}</p>
<Link
href="/api/auth/signout"
className="text-xs text-[var(--muted-foreground)] underline-offset-4 hover:underline"
>
Abmelden
</Link>
</div>
</aside>
{offen && (
<button
type="button"
aria-label="Navigation schließen"
className="fixed inset-0 z-30 bg-black/40 md:hidden"
onClick={() => setOffen(false)}
/>
)}
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
/**
* Hell/Dunkel. Standard ist die Systemeinstellung — wer im Gerätehaus abends
* am Terminal steht, bekommt dann von selbst die dunkle Darstellung.
*/
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
/** Umschalter zwischen heller und dunkler Darstellung. */
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [bereit, setBereit] = React.useState(false);
// Vor der Hydratation ist das Thema unbekannt — sonst blinkt das Symbol.
React.useEffect(() => setBereit(true), []);
const dunkel = resolvedTheme === "dark";
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(dunkel ? "light" : "dark")}
aria-label={dunkel ? "Zur hellen Darstellung wechseln" : "Zur dunklen Darstellung wechseln"}
title={dunkel ? "Helle Darstellung" : "Dunkle Darstellung"}
>
{bereit && dunkel ? <Sun aria-hidden /> : <Moon aria-hidden />}
</Button>
);
}
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* Statusabzeichen. Die Gerätezustände haben feste Farben, die überall gleich
* bedeuten — grün einsatzbereit, gelb Prüfung fällig, rot gesperrt, grau
* ausgesondert. Wer die Farbe kennt, muss den Text nicht lesen.
*/
const badgeVarianten = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
{
variants: {
variant: {
default:
"border-transparent bg-[var(--primary)] text-[var(--primary-foreground)]",
secondary:
"border-transparent bg-[var(--secondary)] text-[var(--secondary-foreground)]",
outline: "text-[var(--foreground)] border-[var(--border)]",
einsatzbereit:
"border-transparent bg-[var(--einsatzbereit)]/15 text-[var(--einsatzbereit)]",
pruefungFaellig:
"border-transparent bg-[var(--pruefung-faellig)]/20 text-[var(--pruefung-faellig)]",
gesperrt: "border-transparent bg-[var(--gesperrt)]/15 text-[var(--gesperrt)]",
ausgesondert:
"border-transparent bg-[var(--ausgesondert)]/15 text-[var(--ausgesondert)]",
},
},
defaultVariants: { variant: "default" },
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVarianten> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVarianten({ variant }), className)} {...props} />;
}
export { Badge, badgeVarianten };
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVarianten = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 shadow-sm",
destructive:
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90 shadow-sm",
outline:
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
secondary:
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
ghost: "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
link: "text-[var(--primary)] underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-8",
/** Für das Terminal im Gerätehaus — mit Handschuhen bedienbar. */
kiosk: "h-16 rounded-xl px-8 text-lg",
icon: "h-9 w-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVarianten> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Komponente = asChild ? Slot : "button";
return (
<Komponente
className={cn(buttonVarianten({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVarianten };
+61
View File
@@ -0,0 +1,61 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
className,
)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-1 text-sm shadow-sm transition-colors",
"placeholder:text-[var(--muted-foreground)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--background)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };
+22
View File
@@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+238
View File
@@ -0,0 +1,238 @@
import "server-only";
import { AkteurTyp, AnmeldeArt, AuditAktion, Prisma } from "@prisma/client";
import { fuerOrganisation, prismaSystem, type MandantenClient } from "@/lib/db";
import type { Sitzung } from "@/lib/authz";
/**
* Audit-Log.
*
* Regel aus CLAUDE.md: Jede schreibende Operation erzeugt einen Eintrag mit
* Akteur, Zeitpunkt, Entität und Vorher/Nachher als JSONB. Die Tabelle ist
* append-only — auf Datenbankebene per Trigger abgesichert, nicht nur hier.
*
* Zwei Aufrufarten:
* protokolliere(...) — eigene Transaktion, für Einzelaktionen
* protokolliereIn(db, ...) — innerhalb einer bestehenden Transaktion, damit
* Änderung und Protokoll gemeinsam gültig werden
* oder gemeinsam zurückgerollt.
*
* Der zweite Weg ist der richtige, sobald es um Daten geht, die wirklich
* zusammengehören.
*/
export { AuditAktion, AkteurTyp, AnmeldeArt };
/** Felder, die niemals ins Protokoll wandern. */
const GEHEIME_FELDER = new Set([
"passwortHash",
"passwort",
"tokenHash",
"pin",
"secret",
"clientSecret",
"accessToken",
"refreshToken",
]);
/**
* Entfernt Geheimnisse und wandelt Werte, die JSONB nicht kennt (Date, BigInt),
* in etwas Speicherbares um.
*/
export function fuerProtokoll(wert: unknown, tiefe = 0): Prisma.InputJsonValue | null {
if (wert === null || wert === undefined) return null;
if (tiefe > 6) return "[zu tief verschachtelt]";
if (wert instanceof Date) return wert.toISOString();
if (typeof wert === "bigint") return wert.toString();
if (typeof wert === "string" || typeof wert === "number" || typeof wert === "boolean") {
return wert;
}
if (Array.isArray(wert)) {
return wert.map((eintrag) => fuerProtokoll(eintrag, tiefe + 1) ?? null);
}
if (typeof wert === "object") {
const ergebnis: Record<string, Prisma.InputJsonValue | null> = {};
for (const [schluessel, inhalt] of Object.entries(wert as Record<string, unknown>)) {
ergebnis[schluessel] = GEHEIME_FELDER.has(schluessel)
? "[entfernt]"
: fuerProtokoll(inhalt, tiefe + 1);
}
return ergebnis as Prisma.InputJsonValue;
}
return String(wert);
}
/**
* Nur die tatsächlich geänderten Felder — sonst bläht jeder Speichervorgang das
* Protokoll mit unveränderten Werten auf.
*/
export function unterschied(
vorher: Record<string, unknown> | null | undefined,
nachher: Record<string, unknown> | null | undefined,
): { vorher: Prisma.InputJsonValue | null; nachher: Prisma.InputJsonValue | null; felder: string[] } {
if (!vorher) return { vorher: null, nachher: fuerProtokoll(nachher), felder: Object.keys(nachher ?? {}) };
if (!nachher) return { vorher: fuerProtokoll(vorher), nachher: null, felder: Object.keys(vorher) };
const felder: string[] = [];
const vorherGefiltert: Record<string, unknown> = {};
const nachherGefiltert: Record<string, unknown> = {};
for (const schluessel of new Set([...Object.keys(vorher), ...Object.keys(nachher)])) {
const alt = vorher[schluessel];
const neu = nachher[schluessel];
if (JSON.stringify(alt) === JSON.stringify(neu)) continue;
felder.push(schluessel);
vorherGefiltert[schluessel] = alt;
nachherGefiltert[schluessel] = neu;
}
return {
vorher: fuerProtokoll(vorherGefiltert),
nachher: fuerProtokoll(nachherGefiltert),
felder,
};
}
export interface ProtokollEingabe {
organisationId: string;
aktion: AuditAktion;
/** Modellname, z. B. "Geraet". */
entitaet: string;
entitaetId?: string | null;
beschreibung?: string;
/** Wer war es. Entweder eine Sitzung übergeben oder Akteur einzeln setzen. */
sitzung?: Sitzung | null;
akteurTyp?: AkteurTyp;
akteurPersonId?: string | null;
akteurName?: string | null;
anmeldeArt?: AnmeldeArt | null;
vorher?: Record<string, unknown> | null;
nachher?: Record<string, unknown> | null;
kontext?: Record<string, unknown> | null;
ipAdresse?: string | null;
userAgent?: string | null;
}
function datensatzBauen(eingabe: ProtokollEingabe): Prisma.AuditLogUncheckedCreateInput {
const abweichung = unterschied(eingabe.vorher, eingabe.nachher);
const kontext = {
...(eingabe.kontext ?? {}),
...(abweichung.felder.length > 0 ? { geaenderteFelder: abweichung.felder } : {}),
};
return {
organisationId: eingabe.organisationId,
aktion: eingabe.aktion,
entitaet: eingabe.entitaet,
entitaetId: eingabe.entitaetId ?? null,
beschreibung: eingabe.beschreibung ?? null,
akteurTyp: eingabe.akteurTyp ?? (eingabe.sitzung ? AkteurTyp.PERSON : AkteurTyp.SYSTEM),
akteurPersonId: eingabe.akteurPersonId ?? eingabe.sitzung?.personId ?? null,
akteurName: eingabe.akteurName ?? eingabe.sitzung?.name ?? null,
anmeldeArt:
eingabe.anmeldeArt ??
(eingabe.sitzung ? (eingabe.sitzung.anmeldeArt as AnmeldeArt) : null),
vorher: abweichung.vorher ?? Prisma.JsonNull,
nachher: abweichung.nachher ?? Prisma.JsonNull,
kontext: Object.keys(kontext).length > 0 ? (fuerProtokoll(kontext) as Prisma.InputJsonValue) : Prisma.JsonNull,
ipAdresse: eingabe.ipAdresse ?? null,
userAgent: eingabe.userAgent?.slice(0, 400) ?? null,
};
}
/**
* Schreibt den Eintrag in eine BESTEHENDE Transaktion. So werden Änderung und
* Protokoll gemeinsam wirksam.
*
* @example
* await fuerOrganisation(orgId, async (db) => {
* const vorher = await db.geraet.findUniqueOrThrow({ where: { id } });
* const nachher = await db.geraet.update({ where: { id }, data });
* await protokolliereIn(db, {
* organisationId: orgId, aktion: AuditAktion.GEAENDERT,
* entitaet: "Geraet", entitaetId: id, sitzung, vorher, nachher,
* });
* });
*/
export async function protokolliereIn(
db: MandantenClient,
eingabe: ProtokollEingabe,
): Promise<void> {
await db.auditLog.create({ data: datensatzBauen(eingabe) });
}
/**
* Schreibt den Eintrag in einer eigenen Transaktion. Für Vorgänge ohne
* begleitende Datenänderung — Anmeldungen, Exporte, Dateiabrufe.
*/
export async function protokolliere(eingabe: ProtokollEingabe): Promise<void> {
await fuerOrganisation(eingabe.organisationId, async (db) => {
await protokolliereIn(db, eingabe);
});
}
/**
* Anmeldevorgänge. Die laufen, bevor ein Mandantenkontext feststeht, deshalb
* schreibt diese Funktion ausnahmsweise über den Eigentümer-Zugang.
* Fehlschläge dürfen den Anmeldeweg nicht blockieren — deshalb nur geloggt.
*/
export async function protokolliereAnmeldung(eingabe: {
organisationId: string;
personId?: string | null;
name?: string | null;
anmeldeArt: AnmeldeArt;
erfolgreich: boolean;
grund?: string;
ipAdresse?: string | null;
userAgent?: string | null;
}): Promise<void> {
try {
await prismaSystem.auditLog.create({
data: datensatzBauen({
organisationId: eingabe.organisationId,
aktion: eingabe.erfolgreich
? AuditAktion.ANGEMELDET
: AuditAktion.ANMELDUNG_FEHLGESCHLAGEN,
entitaet: "Person",
entitaetId: eingabe.personId ?? null,
akteurTyp: eingabe.anmeldeArt === AnmeldeArt.SYSTEM ? AkteurTyp.SYSTEM : AkteurTyp.PERSON,
akteurPersonId: eingabe.personId ?? null,
akteurName: eingabe.name ?? null,
anmeldeArt: eingabe.anmeldeArt,
beschreibung: eingabe.erfolgreich
? `Anmeldung über ${eingabe.anmeldeArt}`
: `Anmeldung fehlgeschlagen: ${eingabe.grund ?? "unbekannt"}`,
ipAdresse: eingabe.ipAdresse,
userAgent: eingabe.userAgent,
}),
});
} catch (fehler) {
console.error("[audit] Anmeldung konnte nicht protokolliert werden:", fehler);
}
}
/** Die letzten Einträge zu einem Datensatz — für die Verlaufsansicht. */
export async function verlauf(
organisationId: string,
entitaet: string,
entitaetId: string,
anzahl = 50,
) {
return fuerOrganisation(organisationId, (db) =>
db.auditLog.findMany({
where: { entitaet, entitaetId },
orderBy: { zeitpunkt: "desc" },
take: anzahl,
include: {
akteur: { select: { id: true, vorname: true, nachname: true } },
},
}),
);
}
+613
View File
@@ -0,0 +1,613 @@
import NextAuth, { type NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import type { Provider } from "next-auth/providers";
import { AnmeldeArt, type RolleSchluessel } from "@prisma/client";
import { z } from "zod";
import { env, oidcGruppenZuordnung, oidcVerfuegbar } from "@/lib/env";
import { prismaSystem, fuerOrganisation } from "@/lib/db";
import { pruefen } from "@/lib/passwort";
import { rfidNormalisieren } from "@/lib/passwort";
import { protokolliereAnmeldung } from "@/lib/audit";
import { alsRolle, type Rolle, type Sitzung } from "@/lib/authz";
/**
* Anmeldung — drei Wege, ein Sitzungsbegriff.
*
* 1. OIDC gegen authentik — der Regelweg im laufenden Betrieb.
* 2. E-Mail und Passwort — Rückfalloption für den Erststart und für den
* Fall, dass authentik gerade nicht erreichbar ist.
* 3. Terminal (PIN/RFID) — für das Kiosk-Gerät im Gerätehaus.
*
* Alle drei führen zu derselben Sitzung (siehe lib/authz.ts). Die Sitzung liegt
* als signiertes JWT im Cookie — keine Sitzungstabelle, damit ein Neustart der
* Anwendung niemanden abmeldet.
*
* Die Rollen stehen im Token. Damit eine Rollenänderung nicht erst beim
* nächsten Login greift, werden sie bei jedem Zugriff nachgeladen, wenn das
* Token älter als fünf Minuten ist.
*/
const ROLLEN_NACHLADEN_NACH_MS = 5 * 60 * 1000;
// -----------------------------------------------------------------------------
// Hilfsfunktionen
// -----------------------------------------------------------------------------
/** Lädt Person samt Rollen. Läuft vor der Mandantenauflösung, daher ohne RLS. */
async function personMitRollen(personId: string) {
return prismaSystem.person.findFirst({
where: { id: personId, aktiv: true, geloeschtAm: null },
include: {
rollen: {
where: { OR: [{ gueltigBis: null }, { gueltigBis: { gt: new Date() } }] },
include: { rolle: true },
},
organisation: { select: { id: true, slug: true, name: true, aktiv: true } },
},
});
}
function rollenAus(
eintraege: Array<{ rolle: { schluessel: RolleSchluessel } }>,
): Rolle[] {
const rollen = eintraege
.map((eintrag) => alsRolle(eintrag.rolle.schluessel))
.filter((rolle): rolle is Rolle => rolle !== null);
// Wer gar keine Rolle hat, ist trotzdem Mitglied — sonst sieht er nicht
// einmal die eigenen Daten.
return rollen.length > 0 ? rollen : ["MITGLIED"];
}
/** Ist das Konto gerade wegen zu vieler Fehlversuche gesperrt? */
function istGesperrt(person: { gesperrtBis: Date | null }): boolean {
return person.gesperrtBis !== null && person.gesperrtBis > new Date();
}
/** Fehlversuch zählen und ab der Grenze sperren. */
async function fehlversuchVermerken(personId: string): Promise<void> {
const person = await prismaSystem.person.findUnique({
where: { id: personId },
select: { fehlversuche: true },
});
const neu = (person?.fehlversuche ?? 0) + 1;
await prismaSystem.person.update({
where: { id: personId },
data: {
fehlversuche: neu,
gesperrtBis:
neu >= env.KIOSK_MAX_FEHLVERSUCHE
? new Date(Date.now() + env.KIOSK_SPERRDAUER * 1000)
: null,
},
});
}
async function anmeldungVermerken(personId: string): Promise<void> {
await prismaSystem.person.update({
where: { id: personId },
data: { letzteAnmeldung: new Date(), fehlversuche: 0, gesperrtBis: null },
});
}
// -----------------------------------------------------------------------------
// Anbieter
// -----------------------------------------------------------------------------
const lokaleAnmeldung = z.object({
email: z.string().email("Bitte eine gültige E-Mail-Adresse eingeben."),
passwort: z.string().min(1, "Bitte das Passwort eingeben."),
});
const terminalAnmeldung = z.object({
geraeteSchluessel: z.string().min(1),
organisationSlug: z.string().min(1),
art: z.enum(["PIN", "RFID"]),
/** Bei PIN zusätzlich die Mitgliedsnummer, bei RFID nur die Kennung. */
kennung: z.string().min(1),
geheimnis: z.string().min(1),
});
/** E-Mail und Passwort — der Rückfallweg. */
const passwortAnbieter = Credentials({
id: "passwort",
name: "E-Mail und Passwort",
credentials: {
email: { label: "E-Mail", type: "email" },
passwort: { label: "Passwort", type: "password" },
},
async authorize(eingabe) {
const geprueft = lokaleAnmeldung.safeParse(eingabe);
if (!geprueft.success) return null;
const { email, passwort } = geprueft.data;
// Die E-Mail-Suche geht bewusst über alle Wehren — zu diesem Zeitpunkt ist
// der Mandant noch nicht bekannt.
const person = await prismaSystem.person.findFirst({
where: { email: email.toLowerCase(), aktiv: true, geloeschtAm: null },
include: {
rollen: { include: { rolle: true } },
organisation: { select: { id: true, aktiv: true } },
},
});
if (!person || !person.organisation.aktiv) {
// Trotzdem hashen, damit "Konto gibt es nicht" zeitlich nicht auffällt.
await pruefen(passwort, null);
return null;
}
if (istGesperrt(person)) {
await protokolliereAnmeldung({
organisationId: person.organisationId,
personId: person.id,
name: `${person.vorname} ${person.nachname}`,
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich: false,
grund: "Konto vorübergehend gesperrt",
});
return null;
}
const stimmt = await pruefen(passwort, person.passwortHash);
if (!stimmt) {
await fehlversuchVermerken(person.id);
await protokolliereAnmeldung({
organisationId: person.organisationId,
personId: person.id,
name: `${person.vorname} ${person.nachname}`,
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich: false,
grund: "Falsches Passwort",
});
return null;
}
await anmeldungVermerken(person.id);
await protokolliereAnmeldung({
organisationId: person.organisationId,
personId: person.id,
name: `${person.vorname} ${person.nachname}`,
anmeldeArt: AnmeldeArt.LOKAL,
erfolgreich: true,
});
return {
id: person.id,
name: `${person.vorname} ${person.nachname}`,
email: person.email ?? undefined,
organisationId: person.organisationId,
rollen: rollenAus(person.rollen),
anmeldeArt: "LOKAL" as const,
};
},
});
/**
* Terminal im Gerätehaus. Zwei Schranken: erst weist sich das GERÄT mit
* KIOSK_DEVICE_SECRET aus, dann die PERSON mit PIN oder RFID-Karte. Ohne die
* erste Schranke nützt eine erratene PIN nichts.
*/
const terminalAnbieter = Credentials({
id: "terminal",
name: "Terminal im Gerätehaus",
credentials: {
geraeteSchluessel: { label: "Gerät", type: "text" },
organisationSlug: { label: "Wehr", type: "text" },
art: { label: "Art", type: "text" },
kennung: { label: "Kennung", type: "text" },
geheimnis: { label: "PIN oder Kartenkennung", type: "password" },
},
async authorize(eingabe) {
if (!env.KIOSK_ENABLED) return null;
const geprueft = terminalAnmeldung.safeParse(eingabe);
if (!geprueft.success) return null;
const { geraeteSchluessel, organisationSlug, art, kennung, geheimnis } = geprueft.data;
// Erste Schranke: Ist das ein Terminal, das wir kennen?
if (geraeteSchluessel !== env.KIOSK_DEVICE_SECRET) return null;
const organisation = await prismaSystem.organisation.findFirst({
where: { slug: organisationSlug, aktiv: true, geloeschtAm: null },
select: { id: true },
});
if (!organisation) return null;
// Zweite Schranke: die Person. Bei PIN identifiziert die Mitgliedsnummer,
// bei RFID die Kartenkennung.
const tokens = await fuerOrganisation(organisation.id, (db) =>
db.ausweisToken.findMany({
where: {
typ: art,
aktiv: true,
geloeschtAm: null,
gueltigAb: { lte: new Date() },
OR: [{ gueltigBis: null }, { gueltigBis: { gt: new Date() } }],
...(art === "PIN"
? { person: { mitgliedsnummer: kennung, aktiv: true, geloeschtAm: null } }
: { kennzeichen: rfidNormalisieren(kennung).slice(-4) }),
},
include: {
person: {
include: { rollen: { include: { rolle: true } } },
},
},
}),
);
const vergleichswert = art === "RFID" ? rfidNormalisieren(geheimnis) : geheimnis;
for (const token of tokens) {
if (token.gesperrtBis && token.gesperrtBis > new Date()) continue;
if (!(await pruefen(vergleichswert, token.tokenHash))) continue;
await fuerOrganisation(organisation.id, (db) =>
db.ausweisToken.update({
where: { id: token.id },
data: { letzteNutzung: new Date(), fehlversuche: 0, gesperrtBis: null },
}),
);
const anmeldeArt = art === "PIN" ? AnmeldeArt.KIOSK_PIN : AnmeldeArt.KIOSK_RFID;
await protokolliereAnmeldung({
organisationId: organisation.id,
personId: token.person.id,
name: `${token.person.vorname} ${token.person.nachname}`,
anmeldeArt,
erfolgreich: true,
});
return {
id: token.person.id,
name: `${token.person.vorname} ${token.person.nachname}`,
email: token.person.email ?? undefined,
organisationId: organisation.id,
rollen: rollenAus(token.person.rollen),
anmeldeArt: art === "PIN" ? ("KIOSK_PIN" as const) : ("KIOSK_RFID" as const),
};
}
// Fehlversuche werden auf allen in Frage kommenden Tokens gezählt.
await fuerOrganisation(organisation.id, async (db) => {
for (const token of tokens) {
const neu = token.fehlversuche + 1;
await db.ausweisToken.update({
where: { id: token.id },
data: {
fehlversuche: neu,
gesperrtBis:
neu >= env.KIOSK_MAX_FEHLVERSUCHE
? new Date(Date.now() + env.KIOSK_SPERRDAUER * 1000)
: null,
},
});
}
});
await protokolliereAnmeldung({
organisationId: organisation.id,
anmeldeArt: art === "PIN" ? AnmeldeArt.KIOSK_PIN : AnmeldeArt.KIOSK_RFID,
erfolgreich: false,
grund: `Terminal-Anmeldung fehlgeschlagen (${art})`,
});
return null;
},
});
/**
* OIDC gegen authentik. Wird nur eingehängt, wenn Issuer, Client-ID und Secret
* gesetzt sind — sonst bliebe ein Knopf stehen, der ins Leere führt.
*/
function authentikAnbieter(): Provider {
return {
id: "authentik",
name: env.AUTH_OIDC_NAME,
type: "oidc",
issuer: env.AUTH_OIDC_ISSUER,
clientId: env.AUTH_OIDC_CLIENT_ID,
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
authorization: { params: { scope: "openid email profile" } },
checks: ["pkce", "state"],
};
}
// -----------------------------------------------------------------------------
// OIDC: Person finden oder anlegen, Rollen aus Gruppen vorbelegen
// -----------------------------------------------------------------------------
interface OidcProfil {
sub: string;
email?: string;
name?: string;
given_name?: string;
family_name?: string;
[claim: string]: unknown;
}
/**
* Verknüpft ein authentik-Konto mit einer Person. Reihenfolge:
* 1. bereits verknüpft (oidcSubject)
* 2. E-Mail-Adresse stimmt überein -> verknüpfen
* 3. AUTH_OIDC_AUTO_PROVISION -> neue Person anlegen
* Sonst: Anmeldung abgelehnt.
*/
async function personAusOidc(profil: OidcProfil): Promise<{
personId: string;
organisationId: string;
name: string;
rollen: Rolle[];
} | null> {
const email = profil.email?.toLowerCase();
const vorhanden = await prismaSystem.person.findFirst({
where: {
OR: [{ oidcSubject: profil.sub }, ...(email ? [{ email }] : [])],
geloeschtAm: null,
},
include: {
rollen: { include: { rolle: true } },
organisation: { select: { id: true, aktiv: true } },
},
});
const gruppen = gruppenAusProfil(profil);
if (vorhanden) {
if (!vorhanden.aktiv || !vorhanden.organisation.aktiv) return null;
await prismaSystem.person.update({
where: { id: vorhanden.id },
data: {
oidcSubject: profil.sub,
oidcGruppen: gruppen,
letzteAnmeldung: new Date(),
fehlversuche: 0,
gesperrtBis: null,
},
});
// Gruppen wirken nur als Vorbelegung: Rollen, die jemand in FLORIAN von
// Hand vergeben hat, bleiben unangetastet.
await gruppenRollenVorbelegen(vorhanden.organisationId, vorhanden.id, gruppen);
const aktualisiert = await personMitRollen(vorhanden.id);
if (!aktualisiert) return null;
return {
personId: aktualisiert.id,
organisationId: aktualisiert.organisationId,
name: `${aktualisiert.vorname} ${aktualisiert.nachname}`,
rollen: rollenAus(aktualisiert.rollen),
};
}
if (!env.AUTH_OIDC_AUTO_PROVISION) return null;
const slug = env.AUTH_OIDC_STANDARD_ORGANISATION;
if (!slug) {
console.error(
"[auth] AUTH_OIDC_AUTO_PROVISION=true, aber AUTH_OIDC_STANDARD_ORGANISATION fehlt.",
);
return null;
}
const organisation = await prismaSystem.organisation.findFirst({
where: { slug, aktiv: true, geloeschtAm: null },
select: { id: true },
});
if (!organisation) return null;
const vorname = profil.given_name ?? profil.name?.split(" ")[0] ?? "Unbekannt";
const nachname =
profil.family_name ?? profil.name?.split(" ").slice(1).join(" ") ?? "Unbekannt";
const neu = await fuerOrganisation(organisation.id, (db) =>
db.person.create({
data: {
organisationId: organisation.id,
vorname,
nachname,
email: email ?? null,
oidcSubject: profil.sub,
oidcGruppen: gruppen,
letzteAnmeldung: new Date(),
},
}),
);
await gruppenRollenVorbelegen(organisation.id, neu.id, gruppen);
const mitRollen = await personMitRollen(neu.id);
return {
personId: neu.id,
organisationId: organisation.id,
name: `${vorname} ${nachname}`,
rollen: mitRollen ? rollenAus(mitRollen.rollen) : ["MITGLIED"],
};
}
function gruppenAusProfil(profil: OidcProfil): string[] {
const claim = env.AUTH_OIDC_GROUP_CLAIM;
if (!claim) return [];
const wert = profil[claim];
if (Array.isArray(wert)) return wert.map(String);
if (typeof wert === "string") return wert.split(",").map((teil) => teil.trim());
return [];
}
/**
* Belegt Rollen aus authentik-Gruppen vor. Bestehende Zuweisungen werden nicht
* angefasst: Was in FLORIAN von Hand gesetzt wurde, ist führend.
*/
async function gruppenRollenVorbelegen(
organisationId: string,
personId: string,
gruppen: string[],
): Promise<void> {
if (gruppen.length === 0) return;
const zuordnung = oidcGruppenZuordnung();
if (zuordnung.size === 0) return;
const gewuenscht = new Set<string>();
for (const gruppe of gruppen) {
const rolle = zuordnung.get(gruppe.toLowerCase());
if (rolle) gewuenscht.add(rolle);
}
if (gewuenscht.size === 0) return;
await fuerOrganisation(organisationId, async (db) => {
const bestehend = await db.personRolle.findMany({
where: { personId },
select: { rolleId: true },
});
// Nur beim allerersten Mal vorbelegen — danach entscheidet die Wehr.
if (bestehend.length > 0) return;
const rollen = await db.rolle.findMany({
where: {
organisationId,
schluessel: { in: [...gewuenscht] as RolleSchluessel[] },
},
select: { id: true },
});
if (rollen.length === 0) return;
await db.personRolle.createMany({
data: rollen.map((rolle) => ({
organisationId,
personId,
rolleId: rolle.id,
ausOidcGruppe: true,
})),
skipDuplicates: true,
});
});
}
// -----------------------------------------------------------------------------
// Auth.js-Konfiguration
// -----------------------------------------------------------------------------
function anbieterListe(): Provider[] {
const liste: Provider[] = [];
if (oidcVerfuegbar) liste.push(authentikAnbieter());
if (env.AUTH_LOCAL_ENABLED) liste.push(passwortAnbieter);
if (env.KIOSK_ENABLED) liste.push(terminalAnbieter);
return liste;
}
export const authConfig: NextAuthConfig = {
trustHost: env.AUTH_TRUST_HOST,
secret: env.AUTH_SECRET,
session: { strategy: "jwt", maxAge: env.AUTH_SESSION_MAX_AGE },
pages: { signIn: "/anmelden", error: "/anmelden" },
providers: anbieterListe(),
callbacks: {
async signIn({ account, profile }) {
// Die Credentials-Anbieter haben in authorize() bereits alles geprüft.
if (account?.provider !== "authentik") return true;
if (!profile?.sub) return false;
const ergebnis = await personAusOidc(profile as unknown as OidcProfil);
if (!ergebnis) return false;
await protokolliereAnmeldung({
organisationId: ergebnis.organisationId,
personId: ergebnis.personId,
name: ergebnis.name,
anmeldeArt: AnmeldeArt.OIDC,
erfolgreich: true,
});
return true;
},
async jwt({ token, user, account, trigger }) {
// Erste Anmeldung über einen Credentials-Anbieter.
if (user && "organisationId" in user) {
const angemeldet = user as unknown as {
id: string;
name: string;
organisationId: string;
rollen: Rolle[];
anmeldeArt: Sitzung["anmeldeArt"];
};
token.personId = angemeldet.id;
token.organisationId = angemeldet.organisationId;
token.rollen = angemeldet.rollen;
token.anmeldeArt = angemeldet.anmeldeArt;
token.geladenAm = Date.now();
return token;
}
// Erste Anmeldung über OIDC — hier steht das Profil im account/profile.
if (account?.provider === "authentik" && token.sub) {
const person = await prismaSystem.person.findFirst({
where: { oidcSubject: token.sub, geloeschtAm: null },
include: { rollen: { include: { rolle: true } } },
});
if (person) {
token.personId = person.id;
token.organisationId = person.organisationId;
token.rollen = rollenAus(person.rollen);
token.anmeldeArt = "OIDC";
token.geladenAm = Date.now();
}
return token;
}
// Rollen nachladen, damit eine Änderung nicht bis zum nächsten Login wartet.
const veraltet =
typeof token.geladenAm !== "number" ||
Date.now() - token.geladenAm > ROLLEN_NACHLADEN_NACH_MS;
if (trigger === "update" || (veraltet && typeof token.personId === "string")) {
const person = await personMitRollen(token.personId as string);
if (!person) {
// Person deaktiviert oder gelöscht: Sitzung entwerten.
return { ...token, personId: undefined, organisationId: undefined, rollen: [] };
}
token.rollen = rollenAus(person.rollen);
token.organisationId = person.organisationId;
token.geladenAm = Date.now();
}
return token;
},
async session({ session, token }) {
session.user = {
...session.user,
id: (token.personId as string) ?? "",
organisationId: (token.organisationId as string) ?? "",
rollen: (token.rollen as Rolle[]) ?? [],
anmeldeArt: (token.anmeldeArt as Sitzung["anmeldeArt"]) ?? "LOKAL",
};
return session;
},
},
};
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
/**
* Die Sitzung, wie der Rest der Anwendung sie erwartet. Gibt null zurück,
* wenn niemand angemeldet ist oder die Sitzung entwertet wurde.
*/
export async function aktuelleSitzung(): Promise<Sitzung | null> {
const sitzung = await auth();
if (!sitzung?.user?.id || !sitzung.user.organisationId) return null;
return {
personId: sitzung.user.id,
organisationId: sitzung.user.organisationId,
name: sitzung.user.name ?? "Unbekannt",
rollen: sitzung.user.rollen ?? [],
anmeldeArt: sitzung.user.anmeldeArt ?? "LOKAL",
};
}
+338
View File
@@ -0,0 +1,338 @@
/**
* Zentrale Berechtigungslogik.
*
* Regel aus CLAUDE.md: Keine Rollenprüfung in Komponenten. Alles, was mit
* "darf jemand das?" zu tun hat, steht hier — und nur hier.
*
* Aufbau:
* Recht = ein einzelnes Können, z. B. "geraet.bearbeiten"
* Rolle = ein Bündel von Rechten, z. B. GERAETEWART
* Sitzung = wer gerade angemeldet ist, mit welchen Rollen, in welcher Wehr
*
* Die Rechte einer Rolle sind hier als Vorgabe hinterlegt. Beim Seed werden sie
* in die Tabellen berechtigung/rolle_berechtigung gespiegelt, damit eine Wehr
* sie nachträglich anpassen kann, ohne dass jemand Code ändern muss.
*/
/** Rollen exakt wie im Prisma-Schema (enum RolleSchluessel). */
export const ROLLEN = [
"SUPERADMIN",
"KOMMANDANT",
"GERAETEWART",
"ATEMSCHUTZGERAETEWART",
"KASSIER",
"SCHRIFTFUEHRER",
"AUSBILDER",
"MITGLIED",
"KIOSK",
] as const;
export type Rolle = (typeof ROLLEN)[number];
/** Klartext für die Oberfläche. */
export const ROLLEN_BEZEICHNUNG: Record<Rolle, string> = {
SUPERADMIN: "Administrator",
KOMMANDANT: "Kommandant",
GERAETEWART: "Gerätewart",
ATEMSCHUTZGERAETEWART: "Atemschutzgerätewart",
KASSIER: "Kassier",
SCHRIFTFUEHRER: "Schriftführer",
AUSBILDER: "Ausbilder",
MITGLIED: "Mitglied",
KIOSK: "Terminal im Gerätehaus",
};
export const ROLLEN_BESCHREIBUNG: Record<Rolle, string> = {
SUPERADMIN:
"Verwaltet die Installation selbst: Wehren anlegen, Rollen zuschneiden, Systemeinstellungen.",
KOMMANDANT:
"Führt die Wehr. Sieht und bearbeitet alles innerhalb der eigenen Organisation.",
GERAETEWART:
"Verantwortet Fahrzeuge, Geräte und deren Prüfungen nach DGUV.",
ATEMSCHUTZGERAETEWART:
"Verantwortet Atemschutzgeräte, Masken und die Überwachung nach FwDV 7.",
KASSIER: "Führt die Kasse: Buchungen, Beiträge, Rechnungen, Auswertungen.",
SCHRIFTFUEHRER:
"Führt Protokolle, Berichte und die Dokumentation der Wehr.",
AUSBILDER: "Plant Übungen und Lehrgänge, führt Teilnahmenachweise.",
MITGLIED: "Sieht die eigenen Daten, Termine und Qualifikationen.",
KIOSK:
"Terminal im Gerätehaus. Darf nur anmelden, quittieren und erfassen — nie verwalten.",
};
// -----------------------------------------------------------------------------
// Rechte
// -----------------------------------------------------------------------------
/**
* Alle Einzelrechte. Schema: <bereich>.<taetigkeit>
* Neue Rechte hier ergänzen, dann `npm run db:seed` — der Seed gleicht den
* Katalog in der Datenbank ab.
*/
export const RECHTE = {
// Organisation und System
"organisation.lesen": { bereich: "Organisation", bezeichnung: "Wehrdaten ansehen" },
"organisation.bearbeiten": { bereich: "Organisation", bezeichnung: "Wehrdaten bearbeiten" },
"organisation.verwalten": { bereich: "Organisation", bezeichnung: "Wehren anlegen und löschen" },
"system.einstellungen": { bereich: "System", bezeichnung: "Systemeinstellungen ändern" },
"system.auditlog": { bereich: "System", bezeichnung: "Audit-Log einsehen" },
// Personen
"person.lesen": { bereich: "Personen", bezeichnung: "Mitgliederliste ansehen" },
"person.eigene": { bereich: "Personen", bezeichnung: "Eigene Daten ansehen und pflegen" },
"person.bearbeiten": { bereich: "Personen", bezeichnung: "Mitglieder anlegen und bearbeiten" },
"person.loeschen": { bereich: "Personen", bezeichnung: "Mitglieder austragen" },
"person.rollen": { bereich: "Personen", bezeichnung: "Rollen zuweisen" },
"person.ausweis": { bereich: "Personen", bezeichnung: "PIN und RFID-Ausweise verwalten" },
"person.export": { bereich: "Personen", bezeichnung: "Personendaten exportieren (DSGVO)" },
// Fahrzeuge und Geräte
"fahrzeug.lesen": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge ansehen" },
"fahrzeug.bearbeiten": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge anlegen und bearbeiten" },
"geraet.lesen": { bereich: "Geräte", bezeichnung: "Geräte ansehen" },
"geraet.bearbeiten": { bereich: "Geräte", bezeichnung: "Geräte anlegen und bearbeiten" },
"geraet.aussondern": { bereich: "Geräte", bezeichnung: "Geräte aussondern oder sperren" },
// Prüfungen und Wartung (Phase 2)
"pruefung.lesen": { bereich: "Prüfungen", bezeichnung: "Prüfungen und Fristen ansehen" },
"pruefung.durchfuehren": { bereich: "Prüfungen", bezeichnung: "Prüfungen durchführen und dokumentieren" },
"pruefung.planen": { bereich: "Prüfungen", bezeichnung: "Prüftermine und Vorlagen planen" },
// Atemschutz nach FwDV 7 (Phase 5)
"atemschutz.lesen": { bereich: "Atemschutz", bezeichnung: "Atemschutzdaten ansehen" },
"atemschutz.bearbeiten": { bereich: "Atemschutz", bezeichnung: "Atemschutzgeräte und Träger verwalten" },
"atemschutz.ueberwachung": { bereich: "Atemschutz", bezeichnung: "Atemschutzüberwachung führen" },
// Einsätze und Übungen (Phase 3)
"einsatz.lesen": { bereich: "Einsätze", bezeichnung: "Einsätze ansehen" },
"einsatz.bearbeiten": { bereich: "Einsätze", bezeichnung: "Einsätze erfassen und bearbeiten" },
"einsatz.bericht": { bereich: "Einsätze", bezeichnung: "Einsatzberichte erstellen" },
// Ausbildung und Qualifikationen (Phase 1 und 3)
"ausbildung.lesen": { bereich: "Ausbildung", bezeichnung: "Übungen und Lehrgänge ansehen" },
"ausbildung.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Übungen planen und Teilnahme erfassen" },
"qualifikation.lesen": { bereich: "Ausbildung", bezeichnung: "Qualifikationen ansehen" },
"qualifikation.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Qualifikationen pflegen" },
// Kasse — die Rolle KASSIER ist in Phase 0 gefordert, ein Kassenmodul ist
// in Phase-0.md bis Phase-8.md aber nicht vorgesehen. Die Rechte stehen hier
// bereits bereit, damit die Rolle nicht leer bleibt. Siehe docs/ROADMAP.md.
"kasse.lesen": { bereich: "Kasse", bezeichnung: "Buchungen und Auswertungen ansehen" },
"kasse.bearbeiten": { bereich: "Kasse", bezeichnung: "Buchungen erfassen" },
"kasse.abschluss": { bereich: "Kasse", bezeichnung: "Kassenabschluss durchführen" },
// Dateien
"datei.lesen": { bereich: "Dateien", bezeichnung: "Dateien herunterladen" },
"datei.hochladen": { bereich: "Dateien", bezeichnung: "Dateien hochladen" },
"datei.loeschen": { bereich: "Dateien", bezeichnung: "Dateien löschen" },
// Terminal im Gerätehaus (Phase 4)
"kiosk.anmelden": { bereich: "Terminal", bezeichnung: "Am Terminal anmelden" },
"kiosk.erfassen": { bereich: "Terminal", bezeichnung: "Am Terminal Erfassungen vornehmen" },
} as const;
export type Recht = keyof typeof RECHTE;
export const ALLE_RECHTE = Object.keys(RECHTE) as Recht[];
// -----------------------------------------------------------------------------
// Rolle zu Recht
// -----------------------------------------------------------------------------
/** Rechte, die jede angemeldete Person hat. */
const GRUNDRECHTE: Recht[] = [
"person.eigene",
"organisation.lesen",
"person.lesen",
"fahrzeug.lesen",
"geraet.lesen",
"pruefung.lesen",
"einsatz.lesen",
"ausbildung.lesen",
"qualifikation.lesen",
"datei.lesen",
];
/**
* Vorgabe je Rolle. SUPERADMIN steht bewusst nicht drin — der bekommt in
* hatRecht() pauschal alles, damit ein neu ergänztes Recht nicht versehentlich
* am Administrator vorbeigeht.
*/
export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
KOMMANDANT: [
...GRUNDRECHTE,
"organisation.bearbeiten",
"system.auditlog",
"person.bearbeiten",
"person.loeschen",
"person.rollen",
"person.ausweis",
"person.export",
"fahrzeug.bearbeiten",
"geraet.bearbeiten",
"geraet.aussondern",
"pruefung.planen",
"pruefung.durchfuehren",
"atemschutz.lesen",
"atemschutz.bearbeiten",
"atemschutz.ueberwachung",
"einsatz.bearbeiten",
"einsatz.bericht",
"ausbildung.bearbeiten",
"qualifikation.bearbeiten",
"kasse.lesen",
"datei.hochladen",
"datei.loeschen",
],
GERAETEWART: [
...GRUNDRECHTE,
"fahrzeug.bearbeiten",
"geraet.bearbeiten",
"geraet.aussondern",
"pruefung.planen",
"pruefung.durchfuehren",
"datei.hochladen",
],
ATEMSCHUTZGERAETEWART: [
...GRUNDRECHTE,
"geraet.bearbeiten",
"pruefung.planen",
"pruefung.durchfuehren",
"atemschutz.lesen",
"atemschutz.bearbeiten",
"atemschutz.ueberwachung",
"datei.hochladen",
],
KASSIER: [...GRUNDRECHTE, "kasse.lesen", "kasse.bearbeiten", "kasse.abschluss", "datei.hochladen"],
SCHRIFTFUEHRER: [
...GRUNDRECHTE,
"person.bearbeiten",
"einsatz.bearbeiten",
"einsatz.bericht",
"ausbildung.bearbeiten",
"datei.hochladen",
],
AUSBILDER: [
...GRUNDRECHTE,
"ausbildung.bearbeiten",
"qualifikation.bearbeiten",
"datei.hochladen",
],
MITGLIED: [...GRUNDRECHTE],
// Das Terminal darf bewusst fast nichts. Wer daran arbeitet, meldet sich
// zusätzlich mit PIN oder Ausweis an und handelt dann unter seiner Person.
KIOSK: ["kiosk.anmelden", "kiosk.erfassen", "organisation.lesen"],
};
// -----------------------------------------------------------------------------
// Sitzung und Prüffunktionen
// -----------------------------------------------------------------------------
/** Was die Anwendung über den gerade Angemeldeten weiß. */
export interface Sitzung {
personId: string;
organisationId: string;
name: string;
rollen: Rolle[];
/** Wie die Anmeldung zustande kam — relevant fürs Audit und für den Kiosk. */
anmeldeArt: "LOKAL" | "OIDC" | "KIOSK_PIN" | "KIOSK_RFID" | "SYSTEM";
}
/** Fehler, den die tRPC-Schicht in ein FORBIDDEN übersetzt. */
export class KeinZugriffFehler extends Error {
constructor(
public readonly benoetigtesRecht: Recht | Recht[],
nachricht?: string,
) {
const rechte = Array.isArray(benoetigtesRecht)
? benoetigtesRecht.join(", ")
: benoetigtesRecht;
super(nachricht ?? `Fehlende Berechtigung: ${rechte}`);
this.name = "KeinZugriffFehler";
}
}
/** Alle Rechte, die sich aus den Rollen einer Sitzung ergeben. */
export function rechteVon(rollen: readonly Rolle[]): Set<Recht> {
if (rollen.includes("SUPERADMIN")) return new Set(ALLE_RECHTE);
const gesammelt = new Set<Recht>();
for (const rolle of rollen) {
if (rolle === "SUPERADMIN") continue;
for (const recht of ROLLEN_RECHTE[rolle] ?? []) gesammelt.add(recht);
}
return gesammelt;
}
/** Hat die Sitzung das Recht? Der Administrator hat immer alles. */
export function hatRecht(sitzung: Sitzung | null, recht: Recht): boolean {
if (!sitzung) return false;
if (sitzung.rollen.includes("SUPERADMIN")) return true;
return rechteVon(sitzung.rollen).has(recht);
}
/** Mindestens eines der Rechte. */
export function hatEinesDerRechte(sitzung: Sitzung | null, rechte: Recht[]): boolean {
return rechte.some((recht) => hatRecht(sitzung, recht));
}
/** Alle genannten Rechte. */
export function hatAlleRechte(sitzung: Sitzung | null, rechte: Recht[]): boolean {
return rechte.every((recht) => hatRecht(sitzung, recht));
}
/** Wirft, wenn das Recht fehlt. Für Server-Actions und tRPC-Prozeduren. */
export function verlangeRecht(sitzung: Sitzung | null, recht: Recht): asserts sitzung is Sitzung {
if (!hatRecht(sitzung, recht)) throw new KeinZugriffFehler(recht);
}
/** Wirft, wenn keines der Rechte vorliegt. */
export function verlangeEinesDerRechte(
sitzung: Sitzung | null,
rechte: Recht[],
): asserts sitzung is Sitzung {
if (!hatEinesDerRechte(sitzung, rechte)) throw new KeinZugriffFehler(rechte);
}
/** Hat die Sitzung eine dieser Rollen? Nur für Anzeigezwecke gedacht. */
export function hatRolle(sitzung: Sitzung | null, ...rollen: Rolle[]): boolean {
if (!sitzung) return false;
return rollen.some((rolle) => sitzung.rollen.includes(rolle));
}
/**
* Darf die Sitzung auf diesen Datensatz zugreifen? Ergänzt die Mandanten-
* trennung der Datenbank um eine Prüfung im Code — doppelt hält besser, und
* die Fehlermeldung ist verständlicher als ein leeres Ergebnis.
*/
export function gehoertZurOrganisation(
sitzung: Sitzung | null,
datensatz: { organisationId: string } | null | undefined,
): boolean {
if (!sitzung || !datensatz) return false;
return sitzung.organisationId === datensatz.organisationId;
}
/**
* Die eigenen Daten darf jeder sehen, auch ohne person.lesen. Andere nur mit
* dem entsprechenden Recht.
*/
export function darfPersonSehen(sitzung: Sitzung | null, personId: string): boolean {
if (!sitzung) return false;
if (sitzung.personId === personId) return true;
return hatRecht(sitzung, "person.lesen");
}
/** Wandelt einen beliebigen Text in eine gültige Rolle um, sonst null. */
export function alsRolle(wert: string | null | undefined): Rolle | null {
if (!wert) return null;
const gross = wert.trim().toUpperCase();
return (ROLLEN as readonly string[]).includes(gross) ? (gross as Rolle) : null;
}
+146
View File
@@ -0,0 +1,146 @@
import { PrismaClient } from "@prisma/client";
import { env } from "@/lib/env";
/**
* Datenbankzugriff mit Mandantenkontext.
*
* Es gibt ZWEI Verbindungen, und der Unterschied ist der Kern der
* Mandantentrennung:
*
* prismaApp — verbindet sich als "florian_app". Für diese Rolle ist Row
* Level Security aktiv. Ohne gesetztes
* app.current_organisation liefert jede Abfrage null Zeilen.
* Das ist die Verbindung, über die die Anwendung arbeitet.
*
* prismaSystem — verbindet sich als Eigentümer (DIRECT_DATABASE_URL) und
* umgeht RLS. Das ist nötig für genau die Vorgänge, die VOR
* der Mandantenauflösung stattfinden: die Anmeldung muss eine
* E-Mail-Adresse über alle Wehren hinweg suchen können, weil
* zu dem Zeitpunkt noch niemand weiß, zu welcher Wehr sie
* gehört. Ebenso Wartungsjobs des Workers.
*
* Zugriff auf Fachdaten läuft ausschließlich über fuerOrganisation(). Wer
* prismaSystem in einem Router benutzt, umgeht die Trennung — deshalb heißt die
* Hilfsfunktion dafür ohneMandantenfilter() und verlangt eine Begründung.
*/
const globalerCache = globalThis as unknown as {
prismaApp?: PrismaClient;
prismaSystem?: PrismaClient;
};
function protokollStufen() {
return env.NODE_ENV === "development"
? ([
{ emit: "stdout", level: "warn" },
{ emit: "stdout", level: "error" },
] as const)
: ([{ emit: "stdout", level: "error" }] as const);
}
function clientAnlegen(url: string): PrismaClient {
return new PrismaClient({
datasources: { db: { url } },
log: [...protokollStufen()],
errorFormat: env.NODE_ENV === "development" ? "pretty" : "minimal",
});
}
/**
* Anwendungsverbindung mit aktiver Mandantentrennung.
* Nicht direkt verwenden — immer über fuerOrganisation().
*/
export const prismaApp: PrismaClient =
globalerCache.prismaApp ?? clientAnlegen(env.DATABASE_URL);
/**
* Eigentümerverbindung ohne Mandantentrennung.
* Nur für Anmeldung, Health-Checks und Wartungsjobs.
*/
export const prismaSystem: PrismaClient =
globalerCache.prismaSystem ??
clientAnlegen(env.DIRECT_DATABASE_URL ?? env.DATABASE_URL);
// In der Entwicklung zwischenlagern, damit Hot Reload keine neue
// Verbindungsschar aufmacht.
if (env.NODE_ENV !== "production") {
globalerCache.prismaApp = prismaApp;
globalerCache.prismaSystem = prismaSystem;
}
/** Der auf eine Wehr eingeschränkte Client, den fuerOrganisation übergibt. */
export type MandantenClient = Omit<
PrismaClient,
"$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends"
>;
/** Prüft grob, ob es überhaupt ein UUID sein kann, bevor es ins SQL geht. */
const UUID_MUSTER =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Führt fn innerhalb einer Transaktion aus, in der app.current_organisation
* gesetzt ist. SET LOCAL gilt nur bis zum Ende der Transaktion — danach ist der
* Kontext wieder leer, auch wenn die Verbindung in den Pool zurückgeht.
*
* @example
* const personen = await fuerOrganisation(orgId, (db) =>
* db.person.findMany({ where: { aktiv: true } }),
* );
*/
export async function fuerOrganisation<T>(
organisationId: string,
fn: (db: MandantenClient) => Promise<T>,
optionen: { timeoutMs?: number; maxWaitMs?: number } = {},
): Promise<T> {
if (!UUID_MUSTER.test(organisationId)) {
throw new Error(
`Ungültige Organisations-Kennung: ${organisationId}. Erwartet wird ein UUID.`,
);
}
return prismaApp.$transaction(
async (tx) => {
// Parametrisiert, damit kein Wert ungeprüft ins SQL wandert.
// set_config mit is_local=true entspricht SET LOCAL.
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return fn(tx as unknown as MandantenClient);
},
{
maxWait: optionen.maxWaitMs ?? 5_000,
timeout: optionen.timeoutMs ?? 15_000,
},
);
}
/**
* Zugriff ohne Mandantenfilter — ausdrücklich benannt, damit man beim Lesen
* sofort sieht, dass hier die Trennung umgangen wird.
*
* Zulässig nur für: Anmeldung (E-Mail-Suche über alle Wehren), Health-Checks,
* Wartungsjobs des Workers. Jede Verwendung gehört begründet.
*/
export async function ohneMandantenfilter<T>(
grund: string,
fn: (db: PrismaClient) => Promise<T>,
): Promise<T> {
if (env.NODE_ENV === "development") {
console.warn(`[db] Zugriff ohne Mandantenfilter: ${grund}`);
}
return fn(prismaSystem);
}
/** Einfache Erreichbarkeitsprüfung für /api/health. */
export async function datenbankErreichbar(): Promise<boolean> {
try {
await prismaApp.$queryRaw`SELECT 1`;
return true;
} catch {
return false;
}
}
/** Verbindungen schließen — für den Worker-Abbruch und für Tests. */
export async function verbindungenTrennen(): Promise<void> {
await Promise.allSettled([prismaApp.$disconnect(), prismaSystem.$disconnect()]);
}
+173
View File
@@ -0,0 +1,173 @@
import { z } from "zod";
/**
* Zentrale Umgebungsvalidierung.
*
* Fehlt oder taugt eine Variable nicht, bricht der Start hier ab — mit einer
* Meldung, die sagt welche. Das ist deutlich angenehmer als ein Dienst, der
* anläuft und beim ersten Datei-Upload merkt, dass MinIO nie konfiguriert war.
*
* Beim Container-Build ist keine echte Umgebung vorhanden; dort setzt das
* Dockerfile SKIP_ENV_VALIDATION=1.
*/
/**
* Akzeptiert "true"/"1"/"ja" als wahr — Compose liefert alles als Text.
* Der Standardwert wird innerhalb der Umwandlung gesetzt, nicht per .default():
* .default() erwartet den Eingabetyp (string), nicht das Ergebnis (boolean).
*/
const boolAusText = (standard: boolean) =>
z
.string()
.optional()
.transform((wert) => {
if (wert === undefined || wert.trim() === "") return standard;
return ["true", "1", "ja", "yes", "on"].includes(wert.trim().toLowerCase());
});
const zahl = (standard: number) =>
z
.string()
.optional()
.transform((wert) => (wert === undefined || wert === "" ? standard : Number(wert)))
.pipe(z.number().int().positive());
const schema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
TZ: z.string().default("Europe/Berlin"),
APP_URL: z.string().url().default("http://localhost:3000"),
// Datenbank
DATABASE_URL: z.string().url(),
DIRECT_DATABASE_URL: z.string().url().optional(),
// Redis / Warteschlangen
REDIS_URL: z.string().url(),
WORKER_CONCURRENCY: zahl(5),
// Objektspeicher
S3_ENDPOINT: z.string().min(1),
S3_PORT: zahl(9000),
S3_USE_SSL: boolAusText(false),
S3_ACCESS_KEY: z.string().min(1),
S3_SECRET_KEY: z.string().min(1),
S3_REGION: z.string().default("eu-central-1"),
S3_BUCKET: z.string().min(1).default("florian"),
S3_PUBLIC_URL: z.string().url().optional(),
S3_URL_TTL: zahl(900),
// PDF-Erzeugung
GOTENBERG_URL: z.string().url().optional(),
GOTENBERG_TIMEOUT: zahl(30),
// Sitzungen
AUTH_SECRET: z.string().min(32, "AUTH_SECRET muss mindestens 32 Zeichen haben"),
AUTH_TRUST_HOST: boolAusText(true),
AUTH_SESSION_MAX_AGE: zahl(28_800),
// OIDC gegen authentik
AUTH_OIDC_ENABLED: boolAusText(false),
AUTH_OIDC_ISSUER: z.string().optional(),
AUTH_OIDC_CLIENT_ID: z.string().optional(),
AUTH_OIDC_CLIENT_SECRET: z.string().optional(),
AUTH_OIDC_NAME: z.string().default("authentik"),
AUTH_OIDC_GROUP_CLAIM: z.string().default("groups"),
AUTH_OIDC_GROUP_MAPPING: z.string().default(""),
AUTH_OIDC_AUTO_PROVISION: boolAusText(true),
AUTH_OIDC_STANDARD_ORGANISATION: z.string().optional(),
// Lokaler Login
AUTH_LOCAL_ENABLED: boolAusText(true),
AUTH_BCRYPT_ROUNDS: zahl(12),
// Kiosk
KIOSK_ENABLED: boolAusText(true),
KIOSK_DEVICE_SECRET: z.string().min(8),
KIOSK_PIN_LAENGE: zahl(6),
KIOSK_MAX_FEHLVERSUCHE: zahl(5),
KIOSK_SPERRDAUER: zahl(300),
KIOSK_IDLE_TIMEOUT: zahl(120),
// Integrationen (alle optional und abschaltbar)
MAIL_ENABLED: boolAusText(false),
MAIL_HOST: z.string().optional(),
MAIL_PORT: zahl(587),
MAIL_SECURE: boolAusText(false),
MAIL_USER: z.string().optional(),
MAIL_PASSWORD: z.string().optional(),
MAIL_FROM: z.string().optional(),
TELEGRAM_ENABLED: boolAusText(false),
TELEGRAM_BOT_TOKEN: z.string().optional(),
TELEGRAM_CHAT_ID: z.string().optional(),
LLM_ENABLED: boolAusText(false),
LLM_BASE_URL: z.string().optional(),
LLM_API_KEY: z.string().optional(),
LLM_MODELL: z.string().optional(),
// Aufbewahrung
AUFBEWAHRUNG_JAHRE: zahl(10),
AUDIT_AUFBEWAHRUNG_TAGE: zahl(3650),
PERSON_ANONYMISIERUNG_TAGE: zahl(365),
});
export type Umgebung = z.infer<typeof schema>;
/** Ersatzwerte für Build und Tests — nie für den echten Betrieb. */
const platzhalter: Record<string, string> = {
DATABASE_URL: "postgresql://build:build@localhost:5432/build?schema=public",
REDIS_URL: "redis://localhost:6379",
S3_ENDPOINT: "localhost",
S3_ACCESS_KEY: "build",
S3_SECRET_KEY: "build",
AUTH_SECRET: "build_time_platzhalter_mindestens_32_zeichen",
KIOSK_DEVICE_SECRET: "build_time_platzhalter",
};
function lesen(): Umgebung {
const roh =
process.env.SKIP_ENV_VALIDATION === "1" || process.env.NODE_ENV === "test"
? { ...platzhalter, ...process.env }
: process.env;
const ergebnis = schema.safeParse(roh);
if (!ergebnis.success) {
const zeilen = ergebnis.error.issues.map(
(problem) => ` - ${problem.path.join(".")}: ${problem.message}`,
);
throw new Error(
[
"Die Umgebungskonfiguration ist unvollständig oder fehlerhaft:",
...zeilen,
"",
"Vorlage mit Erklärungen zu jeder Variable: .env.example",
].join("\n"),
);
}
return ergebnis.data;
}
export const env: Umgebung = lesen();
/** true, wenn die Anmeldung über authentik vollständig konfiguriert ist. */
export const oidcVerfuegbar =
env.AUTH_OIDC_ENABLED &&
Boolean(env.AUTH_OIDC_ISSUER) &&
Boolean(env.AUTH_OIDC_CLIENT_ID) &&
Boolean(env.AUTH_OIDC_CLIENT_SECRET);
/**
* Zuordnung authentik-Gruppe zu FLORIAN-Rolle aus AUTH_OIDC_GROUP_MAPPING.
* Format: "gruppe:ROLLE,gruppe2:ROLLE2".
*/
export function oidcGruppenZuordnung(): Map<string, string> {
const karte = new Map<string, string>();
for (const eintrag of env.AUTH_OIDC_GROUP_MAPPING.split(",")) {
const [gruppe, rolle] = eintrag.split(":").map((teil) => teil?.trim());
if (gruppe && rolle) karte.set(gruppe.toLowerCase(), rolle.toUpperCase());
}
return karte;
}
+106
View File
@@ -0,0 +1,106 @@
import "server-only";
import { env } from "@/lib/env";
/**
* Sprachmodell-Anbindung hinter einer Schnittstelle.
*
* Vorgesehen für: Einsatzberichte aus Stichpunkten formulieren, Prüfmängel
* zusammenfassen, Protokolle vorstrukturieren. Immer als Vorschlag, nie als
* Entscheidung — und immer mit einem Menschen, der gegenzeichnet.
*
* Es wird ausschließlich gegen einen OpenAI-kompatiblen Endpunkt gesprochen,
* der selbst betrieben werden kann (Ollama, vLLM, llama.cpp). Kein Cloud-Zwang,
* keine Daten, die das Gerätehaus verlassen müssen.
*/
export interface LlmAnfrage {
systemAnweisung: string;
benutzerEingabe: string;
maxToken?: number;
temperatur?: number;
}
export interface LlmAntwort {
erfolgreich: boolean;
text?: string;
fehler?: string;
}
export interface LlmDienst {
vervollstaendigen(anfrage: LlmAnfrage): Promise<LlmAntwort>;
verfuegbar(): boolean;
}
/** Standard, solange LLM_ENABLED=false: liefert eine klare Absage. */
class AbgeschaltetesLlm implements LlmDienst {
verfuegbar(): boolean {
return false;
}
async vervollstaendigen(): Promise<LlmAntwort> {
return {
erfolgreich: false,
fehler: "Die Textunterstützung ist in dieser Installation abgeschaltet (LLM_ENABLED=false).",
};
}
}
class OpenAiKompatiblesLlm implements LlmDienst {
verfuegbar(): boolean {
return Boolean(env.LLM_BASE_URL && env.LLM_MODELL);
}
async vervollstaendigen(anfrage: LlmAnfrage): Promise<LlmAntwort> {
if (!this.verfuegbar()) {
return { erfolgreich: false, fehler: "LLM ist nicht vollständig konfiguriert." };
}
try {
const antwort = await fetch(`${env.LLM_BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(env.LLM_API_KEY ? { Authorization: `Bearer ${env.LLM_API_KEY}` } : {}),
},
body: JSON.stringify({
model: env.LLM_MODELL,
messages: [
{ role: "system", content: anfrage.systemAnweisung },
{ role: "user", content: anfrage.benutzerEingabe },
],
max_tokens: anfrage.maxToken ?? 1024,
temperature: anfrage.temperatur ?? 0.3,
}),
});
if (!antwort.ok) {
return { erfolgreich: false, fehler: `Endpunkt antwortete mit ${antwort.status}` };
}
const daten = (await antwort.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const text = daten.choices?.[0]?.message?.content;
return text
? { erfolgreich: true, text }
: { erfolgreich: false, fehler: "Leere Antwort vom Endpunkt." };
} catch (fehler) {
return { erfolgreich: false, fehler: String(fehler) };
}
}
}
let cache: LlmDienst | null = null;
export function llm(): LlmDienst {
if (!cache) {
cache = env.LLM_ENABLED ? new OpenAiKompatiblesLlm() : new AbgeschaltetesLlm();
}
return cache;
}
/** Nur für Tests. */
export function _llmErsetzen(ersatz: LlmDienst | null): void {
cache = ersatz;
}
+75
View File
@@ -0,0 +1,75 @@
import "server-only";
import { env } from "@/lib/env";
/**
* E-Mail-Versand hinter einer Schnittstelle.
*
* Regel aus CLAUDE.md: externe Dienste hinter Interfaces in lib/integrations/,
* damit sie im Test mockbar und im Betrieb abschaltbar sind. Ist MAIL_ENABLED
* nicht gesetzt, landet die Nachricht im Log statt beim Empfänger — für
* Entwicklung und Testbetrieb genau richtig.
*
* Die tatsächliche SMTP-Anbindung kommt in Phase 7 (Benachrichtigungen). Bis
* dahin steht hier bewusst nur der Vertrag.
*/
export interface Nachricht {
an: string | string[];
betreff: string;
text: string;
html?: string;
antwortAn?: string;
anhaenge?: Array<{ dateiname: string; inhalt: Buffer; mimeTyp: string }>;
}
export interface MailVersand {
senden(nachricht: Nachricht): Promise<{ erfolgreich: boolean; kennung?: string; fehler?: string }>;
verfuegbar(): boolean;
}
/** Schreibt statt zu senden. Standard, solange MAIL_ENABLED=false. */
class ProtokollVersand implements MailVersand {
verfuegbar(): boolean {
return false;
}
async senden(nachricht: Nachricht) {
const empfaenger = Array.isArray(nachricht.an) ? nachricht.an.join(", ") : nachricht.an;
console.info(
`[mail] (nicht versendet, MAIL_ENABLED=false) an=${empfaenger} betreff="${nachricht.betreff}"`,
);
return { erfolgreich: true, kennung: "protokoll" };
}
}
/** Platzhalter für den echten SMTP-Versand aus Phase 7. */
class SmtpVersand implements MailVersand {
verfuegbar(): boolean {
return Boolean(env.MAIL_HOST && env.MAIL_FROM);
}
async senden(nachricht: Nachricht) {
const empfaenger = Array.isArray(nachricht.an) ? nachricht.an.join(", ") : nachricht.an;
console.warn(
`[mail] SMTP-Versand ist erst ab Phase 7 umgesetzt. an=${empfaenger} betreff="${nachricht.betreff}"`,
);
return {
erfolgreich: false,
fehler: "SMTP-Versand noch nicht umgesetzt (Phase 7).",
};
}
}
let versandCache: MailVersand | null = null;
export function mail(): MailVersand {
if (!versandCache) {
versandCache = env.MAIL_ENABLED ? new SmtpVersand() : new ProtokollVersand();
}
return versandCache;
}
/** Nur für Tests: eigene Umsetzung unterschieben. */
export function _mailErsetzen(ersatz: MailVersand | null): void {
versandCache = ersatz;
}
+77
View File
@@ -0,0 +1,77 @@
import "server-only";
import { env } from "@/lib/env";
/**
* Telegram-Benachrichtigungen hinter einer Schnittstelle — abschaltbar und
* mockbar. Gedacht für Erinnerungen an fällige Prüfungen und, ab Phase 4,
* optionale Alarmhinweise.
*
* ACHTUNG: Telegram ist ein externer Dienst. Es dürfen ausschließlich
* Hinweise ohne personenbezogene Daten hinausgehen ("3 Prüfungen fällig"),
* niemals Namen, Adressen oder Einsatzdetails.
*/
export interface TelegramVersand {
senden(text: string, chatId?: string): Promise<{ erfolgreich: boolean; fehler?: string }>;
verfuegbar(): boolean;
}
class ProtokollTelegram implements TelegramVersand {
verfuegbar(): boolean {
return false;
}
async senden(text: string) {
console.info(`[telegram] (nicht versendet, TELEGRAM_ENABLED=false) ${text}`);
return { erfolgreich: true };
}
}
class EchtesTelegram implements TelegramVersand {
verfuegbar(): boolean {
return Boolean(env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_ID);
}
async senden(text: string, chatId?: string) {
if (!this.verfuegbar()) {
return { erfolgreich: false, fehler: "Telegram ist nicht vollständig konfiguriert." };
}
try {
const antwort = await fetch(
`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId ?? env.TELEGRAM_CHAT_ID,
text,
parse_mode: "HTML",
disable_web_page_preview: true,
}),
},
);
if (!antwort.ok) {
return { erfolgreich: false, fehler: `Telegram antwortete mit ${antwort.status}` };
}
return { erfolgreich: true };
} catch (fehler) {
return { erfolgreich: false, fehler: String(fehler) };
}
}
}
let cache: TelegramVersand | null = null;
export function telegram(): TelegramVersand {
if (!cache) {
cache = env.TELEGRAM_ENABLED ? new EchtesTelegram() : new ProtokollTelegram();
}
return cache;
}
/** Nur für Tests. */
export function _telegramErsetzen(ersatz: TelegramVersand | null): void {
cache = ersatz;
}
+66
View File
@@ -0,0 +1,66 @@
import "server-only";
import bcrypt from "bcryptjs";
import { env } from "@/lib/env";
/**
* Alles, was gehasht wird: Passwörter, Terminal-PINs, RFID-Kennungen.
* Immer bcrypt, immer mit dem Kostenfaktor aus AUTH_BCRYPT_ROUNDS, und
* Vergleiche immer über bcrypt.compare — nie per Zeichenkettenvergleich.
*/
export async function hashen(klartext: string): Promise<string> {
return bcrypt.hash(klartext, env.AUTH_BCRYPT_ROUNDS);
}
export async function pruefen(klartext: string, hash: string | null | undefined): Promise<boolean> {
if (!hash) {
// Trotzdem einen Vergleich durchführen, damit ein fehlender Hash zeitlich
// nicht von einem falschen Passwort zu unterscheiden ist.
await bcrypt.compare(klartext, "$2a$12$ungueltigerhashfuerzeitgleichheit000000000000000000000");
return false;
}
return bcrypt.compare(klartext, hash);
}
/**
* Mindestanforderungen an ein Passwort. Bewusst schlicht gehalten: Länge
* schlägt Sonderzeichen, und eine Wehr soll sich nicht an Regeln abarbeiten.
*/
export function passwortPruefen(passwort: string): { gueltig: boolean; meldung?: string } {
if (passwort.length < 10) {
return { gueltig: false, meldung: "Das Passwort muss mindestens 10 Zeichen lang sein." };
}
if (passwort.length > 200) {
return { gueltig: false, meldung: "Das Passwort darf höchstens 200 Zeichen lang sein." };
}
if (/^\d+$/.test(passwort)) {
return { gueltig: false, meldung: "Das Passwort darf nicht nur aus Ziffern bestehen." };
}
return { gueltig: true };
}
/** Prüft eine Terminal-PIN gegen die konfigurierte Länge. */
export function pinPruefen(pin: string): { gueltig: boolean; meldung?: string } {
const laenge = env.KIOSK_PIN_LAENGE;
if (!new RegExp(`^\\d{${laenge}}$`).test(pin)) {
return { gueltig: false, meldung: `Die PIN muss aus genau ${laenge} Ziffern bestehen.` };
}
if (/^(\d)\1+$/.test(pin)) {
return { gueltig: false, meldung: "Die PIN darf nicht aus lauter gleichen Ziffern bestehen." };
}
const ziffern = pin.split("").map(Number);
const aufsteigend = ziffern.every((z, i) => i === 0 || z === (ziffern[i - 1] ?? 0) + 1);
const absteigend = ziffern.every((z, i) => i === 0 || z === (ziffern[i - 1] ?? 0) - 1);
if (aufsteigend || absteigend) {
return { gueltig: false, meldung: "Die PIN darf keine fortlaufende Ziffernfolge sein." };
}
return { gueltig: true };
}
/**
* Normalisiert eine RFID-/NFC-Kennung, bevor sie gehasht wird. Lesegeräte
* liefern die UID mal mit Doppelpunkten, mal klein-, mal großgeschrieben.
*/
export function rfidNormalisieren(uid: string): string {
return uid.replace(/[\s:-]/g, "").toUpperCase();
}
+209
View File
@@ -0,0 +1,209 @@
import "server-only";
import { createHash, randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import { Client as MinioClient } from "minio";
import { env } from "@/lib/env";
/**
* Der einzige Weg zu Dateien.
*
* Regel aus CLAUDE.md: Dateizugriff ausschließlich über lib/storage.ts. Kein
* fs.readFile, kein Pfad im Dateisystem, keine Blobs in der Datenbank. Alles
* liegt in MinIO — dadurch überlebt eine Datei den Neuaufbau des Containers,
* lässt sich versionieren und getrennt sichern.
*
* Objektschlüssel folgen immer demselben Muster:
* <organisation-slug>/<entitaet>/<entitaetId>/<uuid>-<dateiname>
* Der Slug vorn macht Sicherungen und Löschungen je Wehr einfach.
*/
let clientCache: MinioClient | null = null;
function client(): MinioClient {
if (!clientCache) {
clientCache = new MinioClient({
endPoint: env.S3_ENDPOINT,
port: env.S3_PORT,
useSSL: env.S3_USE_SSL,
accessKey: env.S3_ACCESS_KEY,
secretKey: env.S3_SECRET_KEY,
region: env.S3_REGION,
});
}
return clientCache;
}
export const BUCKET = env.S3_BUCKET;
/** Zeichen, die in Objektschlüsseln Ärger machen, werden ersetzt. */
function dateinameBereinigen(name: string): string {
return name
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/ä/gi, "ae")
.replace(/ö/gi, "oe")
.replace(/ü/gi, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-zA-Z0-9._-]/g, "_")
.replace(/_{2,}/g, "_")
.slice(0, 180);
}
export interface SchluesselTeile {
organisationSlug: string;
/** Modellname, z. B. "Geraet". Wird kleingeschrieben. */
entitaet: string;
entitaetId?: string;
dateiname: string;
}
/** Baut einen eindeutigen Objektschlüssel nach dem oben beschriebenen Muster. */
export function objektSchluessel({
organisationSlug,
entitaet,
entitaetId,
dateiname,
}: SchluesselTeile): string {
const teile = [
organisationSlug,
entitaet.toLowerCase(),
entitaetId ?? "allgemein",
`${randomUUID()}-${dateinameBereinigen(dateiname)}`,
];
return teile.join("/");
}
export interface HochladenErgebnis {
bucket: string;
objektSchluessel: string;
groesseBytes: number;
/** SHA-256 des Inhalts — erkennt Dubletten, belegt Unverändertheit. */
pruefsumme: string;
etag: string;
}
/**
* Legt einen Puffer in MinIO ab und gibt zurück, was in die Datei-Tabelle
* gehört. Der Aufrufer schreibt den Datensatz und den Audit-Eintrag.
*/
export async function hochladen(
schluessel: string,
inhalt: Buffer,
mimeTyp: string,
metadaten: Record<string, string> = {},
): Promise<HochladenErgebnis> {
await bucketSicherstellen();
const pruefsumme = createHash("sha256").update(inhalt).digest("hex");
const ergebnis = await client().putObject(BUCKET, schluessel, inhalt, inhalt.length, {
"Content-Type": mimeTyp,
// MinIO nimmt eigene Angaben nur mit diesem Präfix an.
"x-amz-meta-pruefsumme": pruefsumme,
...Object.fromEntries(
Object.entries(metadaten).map(([schlssel, wert]) => [`x-amz-meta-${schlssel}`, wert]),
),
});
return {
bucket: BUCKET,
objektSchluessel: schluessel,
groesseBytes: inhalt.length,
pruefsumme,
etag: ergebnis.etag,
};
}
/** Lädt ein Objekt vollständig in den Speicher. Für große Dateien streamen. */
export async function herunterladen(schluessel: string): Promise<Buffer> {
const strom = await client().getObject(BUCKET, schluessel);
const stuecke: Buffer[] = [];
for await (const stueck of strom) {
stuecke.push(Buffer.isBuffer(stueck) ? stueck : Buffer.from(stueck));
}
return Buffer.concat(stuecke);
}
/** Objekt als Strom — für Downloads, die durch die Anwendung durchgereicht werden. */
export async function alsStrom(schluessel: string): Promise<Readable> {
return client().getObject(BUCKET, schluessel);
}
/**
* Zeitlich begrenzter Download-Link. Damit muss die Datei nicht durch die
* Anwendung laufen — der Browser holt sie direkt bei MinIO ab.
* Der Link gilt S3_URL_TTL Sekunden und ist danach wertlos.
*/
export async function downloadLink(
schluessel: string,
dateiname?: string,
gueltigkeitSekunden = env.S3_URL_TTL,
): Promise<string> {
const kopfzeilen: Record<string, string> = {};
if (dateiname) {
kopfzeilen["response-content-disposition"] =
`attachment; filename="${dateinameBereinigen(dateiname)}"`;
}
return client().presignedGetObject(BUCKET, schluessel, gueltigkeitSekunden, kopfzeilen);
}
/**
* Zeitlich begrenzter Upload-Link. Der Browser lädt direkt zu MinIO hoch,
* ohne dass große Dateien durch die Anwendung müssen.
*/
export async function uploadLink(
schluessel: string,
gueltigkeitSekunden = env.S3_URL_TTL,
): Promise<string> {
await bucketSicherstellen();
return client().presignedPutObject(BUCKET, schluessel, gueltigkeitSekunden);
}
/** Löscht ein Objekt endgültig. Nur für den Aufbewahrungs-Job des Workers. */
export async function endgueltigLoeschen(schluessel: string): Promise<void> {
await client().removeObject(BUCKET, schluessel);
}
/** Gibt es das Objekt und wie groß ist es? */
export async function objektInfo(schluessel: string) {
try {
const status = await client().statObject(BUCKET, schluessel);
return {
vorhanden: true as const,
groesseBytes: status.size,
geaendertAm: status.lastModified,
mimeTyp: status.metaData?.["content-type"] ?? "application/octet-stream",
etag: status.etag,
};
} catch {
return { vorhanden: false as const };
}
}
/** Kopiert ein Objekt, z. B. beim Duplizieren einer Prüfvorlage. */
export async function kopieren(vonSchluessel: string, nachSchluessel: string): Promise<void> {
await client().copyObject(BUCKET, nachSchluessel, `/${BUCKET}/${vonSchluessel}`);
}
/** Legt den Bucket an, falls er fehlt. Idempotent, deshalb überall aufrufbar. */
export async function bucketSicherstellen(): Promise<void> {
const vorhanden = await client().bucketExists(BUCKET).catch(() => false);
if (!vorhanden) {
await client().makeBucket(BUCKET, env.S3_REGION);
}
}
/** Erreichbarkeitsprüfung für /api/health. */
export async function speicherErreichbar(): Promise<boolean> {
try {
await client().bucketExists(BUCKET);
return true;
} catch {
return false;
}
}
/** Nur für Tests: setzt den zwischengespeicherten Client zurück. */
export function _clientZuruecksetzen(): void {
clientCache = null;
}
+104
View File
@@ -0,0 +1,104 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { formatDistanceToNow } from "date-fns";
import { de } from "date-fns/locale";
import { formatInTimeZone } from "date-fns-tz";
/** Tailwind-Klassen zusammenführen, spätere gewinnen. */
export function cn(...eingaben: ClassValue[]) {
return twMerge(clsx(eingaben));
}
/**
* Alle Anzeigen in Europe/Berlin. Gespeichert wird in UTC — die Umrechnung
* passiert ausschließlich hier, damit sie nirgends vergessen wird.
*/
export const ZEITZONE = "Europe/Berlin";
function alsDate(wert: Date | string | number): Date {
return wert instanceof Date ? wert : new Date(wert);
}
/** 02.09.2026 */
export function alsDatum(wert: Date | string | number | null | undefined): string {
if (!wert) return "—";
return formatInTimeZone(alsDate(wert), ZEITZONE, "dd.MM.yyyy", { locale: de });
}
/** 02.09.2026, 14:35 */
export function alsDatumZeit(wert: Date | string | number | null | undefined): string {
if (!wert) return "—";
return formatInTimeZone(alsDate(wert), ZEITZONE, "dd.MM.yyyy, HH:mm", { locale: de });
}
/** 14:35 */
export function alsUhrzeit(wert: Date | string | number | null | undefined): string {
if (!wert) return "—";
return formatInTimeZone(alsDate(wert), ZEITZONE, "HH:mm", { locale: de });
}
/** "vor 3 Tagen" */
export function alsRelativeZeit(wert: Date | string | number | null | undefined): string {
if (!wert) return "—";
return formatDistanceToNow(alsDate(wert), { addSuffix: true, locale: de });
}
/** Nachname, Vorname — so stehen Mitglieder in jeder Liste. */
export function personName(
person: { vorname: string; nachname: string } | null | undefined,
): string {
if (!person) return "—";
return `${person.nachname}, ${person.vorname}`;
}
/** Vorname Nachname — für Anreden und Überschriften. */
export function personAnrede(
person: { vorname: string; nachname: string } | null | undefined,
): string {
if (!person) return "—";
return `${person.vorname} ${person.nachname}`;
}
/** Initialen für den Platzhalter im Profilbild. */
export function initialen(
person: { vorname: string; nachname: string } | null | undefined,
): string {
if (!person) return "??";
return `${person.vorname.charAt(0)}${person.nachname.charAt(0)}`.toUpperCase();
}
/** 1,4 MB — deutsche Schreibweise mit Komma. */
export function alsDateigroesse(bytes: number | bigint | null | undefined): string {
if (bytes === null || bytes === undefined) return "—";
const zahl = typeof bytes === "bigint" ? Number(bytes) : bytes;
if (zahl < 1024) return `${zahl} B`;
const einheiten = ["kB", "MB", "GB", "TB"];
let wert = zahl / 1024;
let index = 0;
while (wert >= 1024 && index < einheiten.length - 1) {
wert /= 1024;
index += 1;
}
return `${wert.toLocaleString("de-DE", { maximumFractionDigits: 1 })} ${einheiten[index]}`;
}
/** 1.234,50 € */
export function alsEuro(betrag: number | null | undefined): string {
if (betrag === null || betrag === undefined) return "—";
return betrag.toLocaleString("de-DE", { style: "currency", currency: "EUR" });
}
/** Erzeugt aus einem Namen einen URL-tauglichen Slug. */
export function alsSlug(text: string): string {
return text
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
}
+109
View File
@@ -0,0 +1,109 @@
import "server-only";
import { Queue, type JobsOptions } from "bullmq";
import IORedis from "ioredis";
import { env } from "@/lib/env";
/**
* Warteschlangen für Hintergrundarbeit.
*
* Was hier hineingehört: alles, was länger dauert als eine Anfrage dauern darf
* oder nach Plan laufen soll — Fristenberechnung, Erinnerungen, PDF-Erzeugung,
* Exporte, Aufräumjobs. Der Worker in src/worker/index.ts arbeitet sie ab.
*
* Die Anwendung schreibt nur hinein, der Worker liest. So kann man den Worker
* neu starten, ohne dass die Oberfläche stehen bleibt.
*/
export const WARTESCHLANGE = "florian";
/** Bekannte Auftragsarten. Ab Phase 2 kommen die fachlichen Jobs dazu. */
export const AUFTRAG = {
/** Prüft alle Geräte auf fällige Fristen und setzt den Status. */
FRISTEN_PRUEFEN: "fristen.pruefen",
/** Verschickt Erinnerungen an fällige Prüfungen. */
ERINNERUNG_SENDEN: "erinnerung.senden",
/** Erzeugt ein PDF über Gotenberg. */
PDF_ERZEUGEN: "pdf.erzeugen",
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
} as const;
export type AuftragsArt = (typeof AUFTRAG)[keyof typeof AUFTRAG];
/** Jeder Auftrag trägt die Wehr mit, für die er läuft. */
export interface AuftragsDaten {
organisationId: string;
[feld: string]: unknown;
}
let verbindungCache: IORedis | null = null;
let warteschlangeCache: Queue | null = null;
export function redis(): IORedis {
if (!verbindungCache) {
verbindungCache = new IORedis(env.REDIS_URL, {
// BullMQ verlangt null, sonst bricht es Aufträge bei Netzhängern ab.
maxRetriesPerRequest: null,
enableReadyCheck: false,
});
verbindungCache.on("error", (fehler) => {
console.error("[redis] Verbindungsfehler:", fehler.message);
});
}
return verbindungCache;
}
export function warteschlange(): Queue {
if (!warteschlangeCache) {
warteschlangeCache = new Queue(WARTESCHLANGE, {
connection: redis(),
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 5_000 },
removeOnComplete: { age: 3600, count: 500 },
removeOnFail: { age: 7 * 24 * 3600 },
},
});
}
return warteschlangeCache;
}
/** Stellt einen Auftrag ein. */
export async function beauftrage(
art: AuftragsArt,
daten: AuftragsDaten,
optionen: JobsOptions = {},
): Promise<string | undefined> {
const auftrag = await warteschlange().add(art, daten, optionen);
return auftrag.id;
}
/** Wiederkehrender Auftrag nach Zeitplan (cron-Syntax, Europe/Berlin). */
export async function beauftrageWiederkehrend(
art: AuftragsArt,
daten: AuftragsDaten,
zeitplan: string,
): Promise<void> {
await warteschlange().add(art, daten, {
repeat: { pattern: zeitplan, tz: "Europe/Berlin" },
jobId: `${art}:${daten.organisationId}`,
});
}
/** Erreichbarkeitsprüfung für /api/health. */
export async function warteschlangeErreichbar(): Promise<boolean> {
try {
const antwort = await redis().ping();
return antwort === "PONG";
} catch {
return false;
}
}
/** Verbindungen sauber schließen — für Tests und den Worker-Abbruch. */
export async function verbindungenSchliessen(): Promise<void> {
await warteschlangeCache?.close();
verbindungCache?.disconnect();
warteschlangeCache = null;
verbindungCache = null;
}
+32
View File
@@ -0,0 +1,32 @@
import { aktuelleSitzung } from "@/lib/auth";
import type { Sitzung } from "@/lib/authz";
/**
* Kontext jeder tRPC-Anfrage. Enthält bewusst nur, was für JEDE Prozedur
* sinnvoll ist — die Sitzung und die Angaben zur Anfrage fürs Audit.
* Der mandantengebundene Datenbankzugriff kommt in der angemeldet-Middleware
* dazu, damit er ohne Sitzung gar nicht erst existiert.
*/
export interface Kontext {
sitzung: Sitzung | null;
ipAdresse: string | null;
userAgent: string | null;
}
/** Ermittelt die IP hinter Traefik aus den Weiterleitungs-Kopfzeilen. */
function ipErmitteln(kopfzeilen: Headers): string | null {
const weitergeleitet = kopfzeilen.get("x-forwarded-for");
if (weitergeleitet) {
const erste = weitergeleitet.split(",")[0]?.trim();
if (erste) return erste;
}
return kopfzeilen.get("x-real-ip");
}
export async function kontextErzeugen({ req }: { req: Request }): Promise<Kontext> {
return {
sitzung: await aktuelleSitzung(),
ipAdresse: ipErmitteln(req.headers),
userAgent: req.headers.get("user-agent"),
};
}
+106
View File
@@ -0,0 +1,106 @@
import { z } from "zod";
import { router, oeffentlich, angemeldet, mitRecht } from "@/server/trpc";
import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz";
import { datenbankErreichbar } from "@/lib/db";
import { speicherErreichbar } from "@/lib/storage";
/**
* Wurzel-Router. Ab Phase 1 kommen hier die Fachbereiche als Unter-Router
* dazu (stammdaten, wartung, atemschutz, einsatz, ausbildung, kasse).
* Phase 0 liefert nur, was zum Prüfen des Fundaments nötig ist.
*/
const systemRouter = router({
/** Erreichbarkeit der Randsysteme. Ohne Anmeldung abrufbar. */
status: oeffentlich.query(async () => {
const [datenbank, speicher] = await Promise.all([
datenbankErreichbar(),
speicherErreichbar(),
]);
return {
datenbank,
speicher,
zeitpunkt: new Date(),
zeitzone: "Europe/Berlin",
};
}),
});
const sitzungRouter = router({
/** Wer bin ich, welcher Wehr gehöre ich an, was darf ich? */
ich: angemeldet.query(async ({ ctx }) => {
const person = await ctx.db((db) =>
db.person.findUnique({
where: { id: ctx.sitzung.personId },
select: {
id: true,
vorname: true,
nachname: true,
email: true,
dienstgrad: true,
mitgliedsnummer: true,
letzteAnmeldung: true,
organisation: { select: { id: true, name: true, kurzname: true, slug: true } },
},
}),
);
return {
person,
rollen: ctx.sitzung.rollen,
rollenBezeichnung: ctx.sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]),
rechte: [...rechteVon(ctx.sitzung.rollen)],
anmeldeArt: ctx.sitzung.anmeldeArt,
};
}),
});
const organisationRouter = router({
/** Stammdaten der eigenen Wehr. */
eigene: angemeldet.query(async ({ ctx }) =>
ctx.db((db) =>
db.organisation.findUnique({
where: { id: ctx.sitzung.organisationId },
}),
),
),
/** Kennzahlen für die Startseite. */
kennzahlen: angemeldet.query(async ({ ctx }) =>
ctx.db(async (db) => {
const [mitglieder, fahrzeuge, geraete, pruefungFaellig] = await Promise.all([
db.person.count({ where: { aktiv: true, geloeschtAm: null } }),
db.fahrzeug.count({ where: { aktiv: true, geloeschtAm: null } }),
db.geraet.count({ where: { geloeschtAm: null } }),
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
]);
return { mitglieder, fahrzeuge, geraete, pruefungFaellig };
}),
),
});
const auditRouter = router({
/** Die letzten Einträge im Protokoll — nur mit dem entsprechenden Recht. */
letzte: mitRecht("system.auditlog")
.input(z.object({ anzahl: z.number().int().min(1).max(200).default(50) }).optional())
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.auditLog.findMany({
orderBy: { zeitpunkt: "desc" },
take: input?.anzahl ?? 50,
include: {
akteur: { select: { id: true, vorname: true, nachname: true } },
},
}),
),
),
});
export const appRouter = router({
system: systemRouter,
sitzung: sitzungRouter,
organisation: organisationRouter,
audit: auditRouter,
});
export type AppRouter = typeof appRouter;
+99
View File
@@ -0,0 +1,99 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import type { Kontext } from "@/server/context";
import { KeinZugriffFehler, hatRecht, type Recht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
/**
* tRPC-Grundgerüst.
*
* Drei Arten von Prozeduren:
* oeffentlich — ohne Anmeldung erreichbar (Health, Anmeldestatus)
* angemeldet — verlangt eine Sitzung; stellt db bereit, der bereits auf
* die Wehr des Angemeldeten eingeschränkt ist
* mitRecht(...) — zusätzlich ein bestimmtes Recht aus lib/authz.ts
*
* Wichtig: In den Prozeduren steht ctx.db für den mandantengebundenen Zugriff.
* Wer prismaSystem direkt benutzt, umgeht die Trennung — das soll auffallen.
*/
const t = initTRPC.context<Kontext>().create({
// superjson erhaelt Date, BigInt und Decimal auf dem Weg zum Browser.
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
// Feldfehler aus Zod direkt an die Oberfläche durchreichen.
zodFehler:
error.cause instanceof ZodError ? error.cause.flatten().fieldErrors : null,
},
};
},
});
export const router = t.router;
export const middleware = t.middleware;
export const createCallerFactory = t.createCallerFactory;
/** Ohne Anmeldung erreichbar. */
export const oeffentlich = t.procedure;
/** Übersetzt KeinZugriffFehler in ein sauberes tRPC-FORBIDDEN. */
const fehlerUebersetzen = middleware(async ({ next }) => {
try {
return await next();
} catch (fehler) {
if (fehler instanceof KeinZugriffFehler) {
throw new TRPCError({ code: "FORBIDDEN", message: fehler.message, cause: fehler });
}
throw fehler;
}
});
/** Verlangt eine Sitzung und stellt den mandantengebundenen Zugriff bereit. */
const angemeldetMiddleware = middleware(async ({ ctx, next }) => {
if (!ctx.sitzung) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Bitte zuerst anmelden.",
});
}
const sitzung = ctx.sitzung;
return next({
ctx: {
...ctx,
sitzung,
/**
* Führt eine Abfrage im Mandantenkontext der Sitzung aus.
* @example const personen = await ctx.db((db) => db.person.findMany());
*/
db: <T>(fn: Parameters<typeof fuerOrganisation<T>>[1]) =>
fuerOrganisation(sitzung.organisationId, fn),
},
});
});
export const angemeldet = t.procedure.use(fehlerUebersetzen).use(angemeldetMiddleware);
/**
* Prozedur, die ein bestimmtes Recht verlangt.
* @example export const geraeteAendern = mitRecht("geraet.bearbeiten").mutation(...)
*/
export function mitRecht(recht: Recht) {
return angemeldet.use(
middleware(async ({ ctx, next }) => {
if (!hatRecht(ctx.sitzung, recht)) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Für diesen Vorgang fehlt die Berechtigung "${recht}".`,
});
}
return next({ ctx });
}),
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Rolle, Sitzung } from "@/lib/authz";
/**
* Erweitert die Typen von Auth.js um das, was FLORIAN zusätzlich in der
* Sitzung führt: Person, Wehr, Rollen und die Art der Anmeldung.
*/
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
organisationId: string;
rollen: Rolle[];
anmeldeArt: Sitzung["anmeldeArt"];
};
}
interface User {
organisationId?: string;
rollen?: Rolle[];
anmeldeArt?: Sitzung["anmeldeArt"];
}
}
declare module "next-auth/jwt" {
interface JWT {
personId?: string;
organisationId?: string;
rollen?: Rolle[];
anmeldeArt?: Sitzung["anmeldeArt"];
/** Zeitpunkt, zu dem die Rollen zuletzt aus der Datenbank kamen. */
geladenAm?: number;
}
}
export {};
+94
View File
@@ -0,0 +1,94 @@
import { Worker, type Job } from "bullmq";
import { env } from "@/lib/env";
import { AUFTRAG, WARTESCHLANGE, redis, type AuftragsDaten } from "@/lib/warteschlange";
import { verbindungenTrennen } from "@/lib/db";
/**
* Hintergrundprozess.
*
* Läuft als eigener Container neben der Anwendung und arbeitet die
* Warteschlange ab. In Phase 0 sind die Auftragsarten angelegt, aber fachlich
* noch leer — die Fristenberechnung kommt in Phase 2, die Erinnerungen in
* Phase 7. Was hier schon steht, ist das Gerüst: Aufnahme, Protokollierung,
* Fehlerbehandlung und ein sauberes Herunterfahren.
*/
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
console.info(`[worker] ${nachricht}`, ...weiteres);
}
async function fristenPruefen(auftrag: Job<AuftragsDaten>): Promise<{ geprueft: number }> {
protokoll(`Fristen prüfen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 2: Geräte mit überschrittener Frist auf PRUEFUNG_FAELLIG setzen.
return { geprueft: 0 };
}
async function erinnerungSenden(auftrag: Job<AuftragsDaten>): Promise<{ versendet: number }> {
protokoll(`Erinnerungen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 7: Mail und Telegram über lib/integrations.
return { versendet: 0 };
}
async function pdfErzeugen(auftrag: Job<AuftragsDaten>): Promise<{ erzeugt: boolean }> {
protokoll(`PDF erzeugen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 2: HTML an Gotenberg, Ergebnis nach MinIO über lib/storage.
return { erzeugt: false };
}
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 8: Audit-Log exportieren und danach abräumen,
// ausgetretene Mitglieder nach PERSON_ANONYMISIERUNG_TAGE anonymisieren.
return { entfernt: 0 };
}
const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknown>> = {
[AUFTRAG.FRISTEN_PRUEFEN]: fristenPruefen,
[AUFTRAG.ERINNERUNG_SENDEN]: erinnerungSenden,
[AUFTRAG.PDF_ERZEUGEN]: pdfErzeugen,
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
};
const worker = new Worker<AuftragsDaten>(
WARTESCHLANGE,
async (auftrag) => {
const bearbeiten = bearbeiter[auftrag.name];
if (!bearbeiten) {
throw new Error(`Unbekannte Auftragsart: ${auftrag.name}`);
}
const beginn = Date.now();
const ergebnis = await bearbeiten(auftrag);
protokoll(`${auftrag.name} erledigt in ${Date.now() - beginn} ms`);
return ergebnis;
},
{
connection: redis(),
concurrency: env.WORKER_CONCURRENCY,
},
);
worker.on("failed", (auftrag, fehler) => {
console.error(
`[worker] Auftrag ${auftrag?.name ?? "?"} (${auftrag?.id ?? "?"}) fehlgeschlagen:`,
fehler.message,
);
});
worker.on("ready", () => {
protokoll(
`bereit — Warteschlange "${WARTESCHLANGE}", ${env.WORKER_CONCURRENCY} parallele Aufträge`,
);
});
/** Laufende Aufträge zu Ende bringen, bevor der Prozess endet. */
async function herunterfahren(signal: string): Promise<void> {
protokoll(`${signal} empfangen — fahre herunter ...`);
await worker.close();
await verbindungenTrennen();
redis().disconnect();
protokoll("beendet.");
process.exit(0);
}
process.on("SIGTERM", () => void herunterfahren("SIGTERM"));
process.on("SIGINT", () => void herunterfahren("SIGINT"));
+96
View File
@@ -0,0 +1,96 @@
import { expect, test } from "@playwright/test";
/**
* Beispiel-E2E-Test für Phase 0.
*
* Geprüft wird der Weg, ohne den nichts anderes funktioniert: jemand ruft die
* Anwendung auf, wird zur Anmeldung geschickt, meldet sich an und landet auf
* der Übersicht seiner Wehr.
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
* docker compose up -d && npm run db:migrate && npm run db:seed
*/
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
test.describe("Anmeldung", () => {
test("schickt nicht angemeldete Aufrufe zur Anmeldeseite", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL(/\/anmelden/);
await expect(page.getByRole("heading", { name: "FLORIAN" })).toBeVisible();
});
test("weist eine falsche Kombination ab und nennt den Grund auf Deutsch", async ({ page }) => {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill("definitiv-das-falsche-passwort");
await page.getByRole("button", { name: "Anmelden" }).click();
// Nicht ueber die Rolle "alert" suchen: Next.js haengt einen eigenen
// Routen-Ansager mit derselben Rolle in die Seite.
const meldung = page.getByTestId("anmeldefehler");
await expect(meldung).toBeVisible();
await expect(meldung).toContainText("stimmt nicht");
// Und niemand ist hineingekommen.
await expect(page).toHaveURL(/\/anmelden/);
});
test("lässt den Administrator hinein und zeigt die Übersicht seiner Wehr", async ({ page }) => {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await page.waitForURL("/");
// Die Wehr aus dem Seed.
await expect(
page.getByRole("heading", { name: "Freiwillige Feuerwehr Musterhausen" }),
).toBeVisible();
// Die Kennzahlen aus dem Seed: 15 Mitglieder, 3 Fahrzeuge, 40 Geräte.
await expect(page.getByText("Aktive Mitglieder")).toBeVisible();
await expect(page.getByText("Fahrzeuge", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Geräte", { exact: true }).first()).toBeVisible();
// Der Sicherheitshinweis muss stehen, solange nichts freigegeben ist.
await expect(page.getByRole("note")).toContainText("analoge Überwachung");
});
test("zeigt die Navigation und markiert künftige Module als noch nicht verfügbar", async ({
page,
}) => {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await page.waitForURL("/");
const navigation = page.getByRole("navigation", { name: "Hauptnavigation" });
await expect(navigation.getByRole("link", { name: "Übersicht" })).toBeVisible();
// Atemschutz kommt erst in Phase 3 — der Eintrag ist sichtbar, aber kein Link.
await expect(navigation.getByText("Atemschutz")).toBeVisible();
await expect(navigation.getByRole("link", { name: "Atemschutz" })).toHaveCount(0);
});
});
test.describe("Erreichbarkeit", () => {
test("meldet den Zustand der Randsysteme unter /api/health", async ({ request }) => {
const antwort = await request.get("/api/health");
expect(antwort.status()).toBe(200);
const daten = (await antwort.json()) as {
status: string;
dienste: { datenbank: boolean; speicher: boolean; warteschlange: boolean };
};
expect(daten.status).toBe("ok");
expect(daten.dienste.datenbank).toBe(true);
expect(daten.dienste.speicher).toBe(true);
expect(daten.dienste.warteschlange).toBe(true);
});
});
+32
View File
@@ -0,0 +1,32 @@
import "dotenv/config";
/**
* Wird vor jedem Testlauf ausgeführt.
*
* Setzt eine vollständige, aber unschädliche Umgebung, damit lib/env.ts
* durchläuft, ohne dass eine echte .env vorhanden sein muss.
*
* Ist eine .env vorhanden, wird sie oben geladen — dann laufen auch die Tests
* mit, die eine echte Datenbank brauchen (Mandantentrennung). Ohne .env greifen
* die Platzhalter, und diese Tests überspringen sich selbst.
*/
// NODE_ENV ist in den Node-Typen schreibgeschützt, zur Laufzeit aber setzbar.
const umgebung = process.env as Record<string, string | undefined>;
umgebung.NODE_ENV = "test";
umgebung.TZ = "Europe/Berlin";
umgebung.SKIP_ENV_VALIDATION = "1";
umgebung.APP_URL ??= "http://localhost:3100";
umgebung.DATABASE_URL ??= "postgresql://test:test@localhost:5442/test?schema=public";
umgebung.DIRECT_DATABASE_URL ??= umgebung.DATABASE_URL;
umgebung.REDIS_URL ??= "redis://localhost:6389";
umgebung.S3_ENDPOINT ??= "localhost";
umgebung.S3_ACCESS_KEY ??= "test";
umgebung.S3_SECRET_KEY ??= "test";
umgebung.S3_BUCKET ??= "test";
umgebung.AUTH_SECRET ??= "testgeheimnis_mindestens_32_zeichen_lang_ok";
umgebung.KIOSK_DEVICE_SECRET ??= "testgeraetegeheimnis";
// bcrypt mit weniger Runden — im Test zählt Tempo, nicht Härte.
umgebung.AUTH_BCRYPT_ROUNDS ??= "4";
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from "vitest";
import {
ALLE_RECHTE,
KeinZugriffFehler,
ROLLEN,
ROLLEN_BEZEICHNUNG,
ROLLEN_RECHTE,
alsRolle,
darfPersonSehen,
gehoertZurOrganisation,
hatAlleRechte,
hatEinesDerRechte,
hatRecht,
rechteVon,
verlangeRecht,
type Sitzung,
} from "@/lib/authz";
/**
* Beispieltest für Phase 0.
*
* Geprüft wird das, was am teuersten schiefgeht: dass der Gerätewart nicht in
* die Kasse kommt, dass der Administrator wirklich alles darf, und dass das
* Terminal nur das Nötigste kann.
*/
function sitzung(rollen: Sitzung["rollen"], organisationId = "org-1"): Sitzung {
return {
personId: "person-1",
organisationId,
name: "Testperson",
rollen,
anmeldeArt: "LOKAL",
};
}
describe("Rollenkatalog", () => {
it("hat für jede Rolle eine deutsche Bezeichnung", () => {
for (const rolle of ROLLEN) {
expect(ROLLEN_BEZEICHNUNG[rolle]).toBeTruthy();
}
});
it("vergibt in ROLLEN_RECHTE nur Rechte, die es im Katalog gibt", () => {
for (const [rolle, rechte] of Object.entries(ROLLEN_RECHTE)) {
for (const recht of rechte) {
expect(ALLE_RECHTE, `${rolle} verweist auf unbekanntes Recht ${recht}`).toContain(
recht,
);
}
}
});
});
describe("hatRecht", () => {
it("gibt dem Administrator ausnahmslos alles", () => {
const admin = sitzung(["SUPERADMIN"]);
for (const recht of ALLE_RECHTE) {
expect(hatRecht(admin, recht)).toBe(true);
}
});
it("lässt den Gerätewart Geräte bearbeiten", () => {
expect(hatRecht(sitzung(["GERAETEWART"]), "geraet.bearbeiten")).toBe(true);
expect(hatRecht(sitzung(["GERAETEWART"]), "pruefung.durchfuehren")).toBe(true);
});
it("lässt den Gerätewart NICHT an die Kasse", () => {
expect(hatRecht(sitzung(["GERAETEWART"]), "kasse.bearbeiten")).toBe(false);
expect(hatRecht(sitzung(["GERAETEWART"]), "kasse.abschluss")).toBe(false);
});
it("lässt den Kassier nicht an Geräte und den Atemschutz", () => {
expect(hatRecht(sitzung(["KASSIER"]), "geraet.bearbeiten")).toBe(false);
expect(hatRecht(sitzung(["KASSIER"]), "atemschutz.ueberwachung")).toBe(false);
});
it("gibt dem einfachen Mitglied nur Lese- und eigene Rechte", () => {
const mitglied = sitzung(["MITGLIED"]);
expect(hatRecht(mitglied, "person.eigene")).toBe(true);
expect(hatRecht(mitglied, "geraet.lesen")).toBe(true);
expect(hatRecht(mitglied, "person.bearbeiten")).toBe(false);
expect(hatRecht(mitglied, "geraet.bearbeiten")).toBe(false);
});
it("beschränkt das Terminal auf Anmelden und Erfassen", () => {
const kiosk = sitzung(["KIOSK"]);
expect(hatRecht(kiosk, "kiosk.anmelden")).toBe(true);
expect(hatRecht(kiosk, "kiosk.erfassen")).toBe(true);
expect(hatRecht(kiosk, "person.bearbeiten")).toBe(false);
expect(hatRecht(kiosk, "system.einstellungen")).toBe(false);
expect(hatRecht(kiosk, "kasse.lesen")).toBe(false);
});
it("verweigert ohne Sitzung alles", () => {
for (const recht of ALLE_RECHTE) {
expect(hatRecht(null, recht)).toBe(false);
}
});
it("addiert die Rechte mehrerer Rollen", () => {
const doppelrolle = sitzung(["GERAETEWART", "KASSIER"]);
expect(hatRecht(doppelrolle, "geraet.bearbeiten")).toBe(true);
expect(hatRecht(doppelrolle, "kasse.bearbeiten")).toBe(true);
});
});
describe("rechteVon", () => {
it("liefert für den Administrator den vollständigen Katalog", () => {
expect(rechteVon(["SUPERADMIN"]).size).toBe(ALLE_RECHTE.length);
});
it("liefert für das Terminal deutlich weniger als für den Kommandanten", () => {
expect(rechteVon(["KIOSK"]).size).toBeLessThan(rechteVon(["KOMMANDANT"]).size);
});
});
describe("hatEinesDerRechte / hatAlleRechte", () => {
const geraetewart = sitzung(["GERAETEWART"]);
it("genügt ein Treffer bei hatEinesDerRechte", () => {
expect(hatEinesDerRechte(geraetewart, ["kasse.bearbeiten", "geraet.bearbeiten"])).toBe(
true,
);
});
it("verlangt hatAlleRechte jeden einzelnen Treffer", () => {
expect(hatAlleRechte(geraetewart, ["kasse.bearbeiten", "geraet.bearbeiten"])).toBe(false);
expect(hatAlleRechte(geraetewart, ["geraet.lesen", "geraet.bearbeiten"])).toBe(true);
});
});
describe("verlangeRecht", () => {
it("lässt durch, wenn das Recht vorliegt", () => {
expect(() => verlangeRecht(sitzung(["KOMMANDANT"]), "person.bearbeiten")).not.toThrow();
});
it("wirft KeinZugriffFehler, wenn das Recht fehlt", () => {
expect(() => verlangeRecht(sitzung(["MITGLIED"]), "person.bearbeiten")).toThrow(
KeinZugriffFehler,
);
});
it("nennt im Fehler das fehlende Recht", () => {
try {
verlangeRecht(sitzung(["MITGLIED"]), "kasse.abschluss");
expect.unreachable("hätte werfen müssen");
} catch (fehler) {
expect(fehler).toBeInstanceOf(KeinZugriffFehler);
expect((fehler as Error).message).toContain("kasse.abschluss");
}
});
});
describe("Mandantentrennung", () => {
it("erkennt Datensätze der eigenen Wehr", () => {
expect(gehoertZurOrganisation(sitzung(["MITGLIED"], "org-1"), { organisationId: "org-1" })).toBe(
true,
);
});
it("weist Datensätze einer fremden Wehr ab — auch dem Administrator", () => {
expect(
gehoertZurOrganisation(sitzung(["SUPERADMIN"], "org-1"), { organisationId: "org-2" }),
).toBe(false);
});
it("weist null-Datensätze ab", () => {
expect(gehoertZurOrganisation(sitzung(["KOMMANDANT"]), null)).toBe(false);
});
});
describe("darfPersonSehen", () => {
it("lässt jeden die eigenen Daten sehen, auch ohne person.lesen", () => {
const kiosk = sitzung(["KIOSK"]);
expect(hatRecht(kiosk, "person.lesen")).toBe(false);
expect(darfPersonSehen(kiosk, kiosk.personId)).toBe(true);
});
it("verlangt für fremde Personen das Recht person.lesen", () => {
expect(darfPersonSehen(sitzung(["KIOSK"]), "andere-person")).toBe(false);
expect(darfPersonSehen(sitzung(["MITGLIED"]), "andere-person")).toBe(true);
});
});
describe("alsRolle", () => {
it("erkennt Rollen unabhängig von Groß- und Kleinschreibung", () => {
expect(alsRolle("kommandant")).toBe("KOMMANDANT");
expect(alsRolle(" GeraeteWart ")).toBe("GERAETEWART");
});
it("gibt bei Unsinn null zurück", () => {
expect(alsRolle("oberbrandmeister")).toBeNull();
expect(alsRolle("")).toBeNull();
expect(alsRolle(null)).toBeNull();
});
});
+226
View File
@@ -0,0 +1,226 @@
import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* Prüft die Mandantentrennung dort, wo sie zählt: in der Datenbank.
*
* Der Test legt zwei Wehren an und stellt sicher, dass die Anwendungsrolle
* (florian_app, NOBYPASSRLS) nur die Wehr sieht, die per
* app.current_organisation gesetzt ist — und ohne gesetzten Wert gar nichts.
*
* Er braucht eine laufende Datenbank. Ist keine erreichbar, wird er
* übersprungen statt fehlzuschlagen, damit "npm run test" auch ohne Docker
* durchläuft.
*/
const APP_URL = process.env.DATABASE_URL ?? "";
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
let erreichbar = false;
let orgA = "";
let orgB = "";
const SLUG_A = "test-rls-wehr-a";
const SLUG_B = "test-rls-wehr-b";
beforeAll(async () => {
try {
await system.$queryRaw`SELECT 1`;
erreichbar = true;
} catch {
return;
}
const a = await system.organisation.upsert({
where: { slug: SLUG_A },
update: {},
create: { slug: SLUG_A, name: "Testwehr A", kurzname: "TW A" },
});
const b = await system.organisation.upsert({
where: { slug: SLUG_B },
update: {},
create: { slug: SLUG_B, name: "Testwehr B", kurzname: "TW B" },
});
orgA = a.id;
orgB = b.id;
await system.person.upsert({
where: { organisationId_mitgliedsnummer: { organisationId: orgA, mitgliedsnummer: "T001" } },
update: {},
create: {
organisationId: orgA,
mitgliedsnummer: "T001",
vorname: "Anna",
nachname: "AusWehrA",
},
});
await system.person.upsert({
where: { organisationId_mitgliedsnummer: { organisationId: orgB, mitgliedsnummer: "T001" } },
update: {},
create: {
organisationId: orgB,
mitgliedsnummer: "T001",
vorname: "Bertram",
nachname: "AusWehrB",
},
});
});
afterAll(async () => {
if (erreichbar) {
// Aufräumen über die Systemverbindung. Der Schalter app.audit_bereinigung
// ist nötig, weil die Kaskade auch Audit-Einträge mitnimmt und die sonst
// vom Trigger geschützt sind.
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.organisation.deleteMany({ where: { slug: { in: [SLUG_A, SLUG_B] } } });
});
}
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
});
/** Führt eine Abfrage über die Anwendungsrolle im Kontext einer Wehr aus. */
async function imKontext<T>(
organisationId: string | null,
fn: (db: PrismaClient) => Promise<T>,
): Promise<T> {
return app.$transaction(async (tx) => {
if (organisationId) {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
}
return fn(tx as unknown as PrismaClient);
});
}
describe.runIf(process.env.DATABASE_URL)("Row Level Security", () => {
it("liefert ohne gesetzten Mandantenkontext keine Zeilen", async ({ skip }) => {
if (!erreichbar) return skip();
const personen = await imKontext(null, (db) => db.person.findMany());
expect(personen).toHaveLength(0);
const organisationen = await imKontext(null, (db) => db.organisation.findMany());
expect(organisationen).toHaveLength(0);
});
it("zeigt im Kontext von Wehr A nur Personen aus Wehr A", async ({ skip }) => {
if (!erreichbar) return skip();
const personen = await imKontext(orgA, (db) => db.person.findMany());
expect(personen.length).toBeGreaterThan(0);
expect(personen.every((person) => person.organisationId === orgA)).toBe(true);
expect(personen.some((person) => person.nachname === "AusWehrB")).toBe(false);
});
it("zeigt im Kontext von Wehr B nur Personen aus Wehr B", async ({ skip }) => {
if (!erreichbar) return skip();
const personen = await imKontext(orgB, (db) => db.person.findMany());
expect(personen.every((person) => person.organisationId === orgB)).toBe(true);
expect(personen.some((person) => person.nachname === "AusWehrA")).toBe(false);
});
it("findet einen Datensatz der fremden Wehr auch bei gezielter Abfrage nicht", async ({
skip,
}) => {
if (!erreichbar) return skip();
const fremde = await system.person.findFirst({ where: { organisationId: orgB } });
expect(fremde).not.toBeNull();
// Dieselbe ID, aber im Kontext der anderen Wehr: RLS blendet sie aus.
const versuch = await imKontext(orgA, (db) =>
db.person.findUnique({ where: { id: fremde!.id } }),
);
expect(versuch).toBeNull();
});
it("lässt keinen Datensatz für eine fremde Wehr anlegen", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
imKontext(orgA, (db) =>
db.person.create({
data: {
organisationId: orgB,
mitgliedsnummer: "T999",
vorname: "Schmuggel",
nachname: "Versuch",
},
}),
),
).rejects.toThrow();
});
it("hält den Audit-Log unveränderlich", async ({ skip }) => {
if (!erreichbar) return skip();
const eintrag = await system.auditLog.create({
data: {
organisationId: orgA,
aktion: "ERSTELLT",
entitaet: "Test",
beschreibung: "Testeintrag für die Unveränderlichkeit",
},
});
// Selbst über die Systemverbindung greift der Trigger.
await expect(
system.auditLog.update({
where: { id: eintrag.id },
data: { beschreibung: "nachträglich geändert" },
}),
).rejects.toThrow();
await expect(system.auditLog.delete({ where: { id: eintrag.id } })).rejects.toThrow();
});
it("erlaubt das Löschen nur im gekennzeichneten Aufbewahrungslauf", async ({ skip }) => {
if (!erreichbar) return skip();
const eintrag = await system.auditLog.create({
data: {
organisationId: orgA,
aktion: "ERSTELLT",
entitaet: "Test",
beschreibung: "Eintrag für den Aufbewahrungslauf",
},
});
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.auditLog.delete({ where: { id: eintrag.id } });
});
const weg = await system.auditLog.findUnique({ where: { id: eintrag.id } });
expect(weg).toBeNull();
});
it("lässt auch im Aufbewahrungslauf kein UPDATE zu", async ({ skip }) => {
if (!erreichbar) return skip();
const eintrag = await system.auditLog.create({
data: {
organisationId: orgA,
aktion: "ERSTELLT",
entitaet: "Test",
beschreibung: "Eintrag, der nicht verfälscht werden darf",
},
});
await expect(
system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.auditLog.update({
where: { id: eintrag.id },
data: { beschreibung: "verfälscht" },
});
}),
).rejects.toThrow();
});
});
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "tests/e2e"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
/**
* Unit- und Integrationstests.
* Die End-to-End-Tests laufen unter tests/e2e mit Playwright und sind hier
* ausgeschlossen — sonst würde Vitest versuchen, sie mitzunehmen.
*/
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: "node",
globals: true,
setupFiles: ["./tests/setup.ts"],
include: ["tests/**/*.test.{ts,tsx}"],
exclude: ["node_modules", ".next", "tests/e2e/**"],
coverage: {
provider: "v8",
reportsDirectory: "./coverage",
include: ["src/lib/**", "src/server/**"],
exclude: ["**/*.d.ts"],
},
},
});