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:
@@ -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
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Keine Telemetrie, keine Audit-Calls beim Container-Build.
|
||||||
|
audit=false
|
||||||
|
fund=false
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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 "$@"
|
||||||
@@ -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();
|
||||||
@@ -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)."
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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 |
@@ -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;
|
||||||
Generated
+12557
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+38
@@ -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 {};
|
||||||
@@ -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"));
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user