Compare commits

...

10 Commits

Author SHA1 Message Date
Matthias Hochmeister
c099b3acd9 Workstream 11: Tests & Sicherheitshärtung (Phase 7)
Beweist die Auth-Gating-Garantie und härtet das System ab (Definition of
Done #1, #2, #3, #7, #8):

- Routen-Manifest (tests/e2e/routes.manifest.ts) als einzige Quelle der
  Wahrheit; anonyme Seite -> Redirect /login, anonyme API -> 401.
- Kritische auth-gating.spec.ts: genau ein Fall je Manifest-Eintrag, ohne
  Daten-Leak.
- Driftschutz (routes.manifest.spec.ts + tests/unit/routes-manifest.test.ts):
  keine ungetestete neue Route unter src/app/**.
- Default-Deny-Beweis für Server Actions (server-actions-guard.spec.ts +
  tests/unit/server-actions-guard.test.ts): jede "use server"-Funktion ruft
  als erste Anweisung einen Guard; Login-Actions per Allowlist ausgenommen.
- Wiederverwendbare reine Scanner unter tests/support (route-scan, guard-scan)
  — offline lauffähig, in Vitest und Playwright geteilt.
- rbac-scoping, search-eta, login-ratelimit, security-headers Specs (gegen
  geseedeten Server; in der Sandbox deferred, per test.skip abgesichert).
- global-setup (Migration + Seed) und auth.setup (Login je Konto ->
  storageState); Playwright-Projekte setup -> chromium verdrahtet.
- src/lib/security/headers.test.ts: statischer Beleg für CSP, HSTS,
  X-Frame-Options DENY, nosniff, Permissions-Policy.
- vitest.config.ts: Coverage-Schwellen (>=90 %) für src/lib/search + src/lib/geo.
- package.json: Scripts test:unit, test:coverage, test:e2e, test:e2e:gating.
- docs/reference/sicherheitshaertung-checkliste.md: jeder Härtungspunkt mit
  Test/Befehl und Negativ-Probe.

Offline verifiziert: tsc --noEmit (0), vitest run (229 passed / 7 db-skipped),
drizzle-kit check (ok), next build (exit 0), next lint (0 Fehler),
playwright --list (98 Tests, 15 Dateien). DB-/Server-/Browser-abhängige
E2E-Läufe sind deferred (kein Postgres/Server in der Sandbox).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:17:10 +02:00
Matthias Hochmeister
9927711192 fix(deploy): vollständige pg-Closure + funktionierender RUN_SEED im Runner-Image
Zwei BLOCKING-Befunde für "Deployment (Docker + externes Traefik)":

1) pg-Laufzeit-Closure unvollständig: pg-types/lib/binaryParsers.js macht
   eager require('pg-int8'), postgres-interval/index.js require('xtend/mutable').
   Beide lagen NICHT im Runner-Image -> node docker/migrate.mjs crasht beim
   Container-Start mit ERR_MODULE_NOT_FOUND, Deploy kaputt. Fix: COPY für
   pg-int8 + xtend ergänzt. Neuer Test berechnet die reale Closure aus
   node_modules und schlägt fehl, sobald ein Paket nicht ins Image kopiert
   wird (schützt vor erneutem Brechen bei pg/drizzle-Updates).

2) RUN_SEED toter Pfad: entrypoint.sh rief docker/seed.mjs auf, das nie
   existierte -> RUN_SEED=true no-opte still zu leerem Katalog. Fix:
   scripts/build-seed-bundle.mjs bündelt src/db/seed (inkl. Schema + Daten,
   pg/drizzle-orm extern) per esbuild zu selbstständigem docker/seed.mjs;
   im builder erzeugt und ins Runner-Image kopiert. entrypoint.sh bricht
   jetzt laut ab, wenn RUN_SEED=true und das Bundle fehlt, statt still zu
   überspringen. docker/seed.mjs ist generiert -> gitignored.

Verifiziert offline: tsc --noEmit, vitest (deployment + seed-Daten, 36 grün),
Bundle baut + lädt sauber (externe Imports nur pg/drizzle-orm, exit 1 ohne
DATABASE_URL). docker build/run sind im Sandbox deferred (kein Docker/Postgres).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:54:59 +02:00
Matthias Hochmeister
d50ec765ab Workstream 10: Deployment (Docker + externes Traefik) (Phase 7)
Liefert das reproduzierbare Compose-Setup hinter EXTERNEM Traefik:

- Dockerfile (multi-stage deps/builder/runner, Next.js standalone, non-root
  UID/GID 1001, HEALTHCHECK gegen /api/health).
- docker/entrypoint.sh: wartet via pg_isready auf Postgres, wendet Migrationen
  idempotent an (docker/migrate.mjs, plain ESM ohne tsx/drizzle-kit), optionaler
  Seed (RUN_SEED), dann exec node server.js.
- docker-compose.yml: genau vier Services (app, postgres, osrm, nominatim),
  KEIN Proxy-Service; externes traefik-Netz + internes Netz; Traefik-Labels
  (Host, websecure, tls.certresolver, Security-Header-Middleware);
  Postgres-/App-Healthchecks; AUTH_URL/AUTH_TRUST_HOST/Forwarded-Header.
- docker-compose.override.yml.example: lokal :3000 ohne TLS (http AUTH_URL).
- .dockerignore, Makefile (build/up/down/logs/deploy/data/config).
- .env.example: voller Vertrag inkl. APP_HOST, TRAEFIK_*, POSTGRES_*, RUN_SEED.
- docs/reference/deployment-traefik.md: externes Netz, Authentik-Redirect-URI
  https://${APP_HOST}/api/auth/callback/authentik, Forwarded-Header/Cookies,
  /api/health-Allowlist.
- tests/unit/deployment.test.ts (TDD): statische Offline-Verifikation der
  Artefakte; vitest.config.ts nimmt tests/unit/** auf.

Offline verifiziert: tsc --noEmit sauber; vitest run grün (200 passed,
7 db-roundtrip skipped); next build erzeugt .next/standalone/server.js;
sh -n docker/entrypoint.sh ok; make -n deploy zeigt build->up.
Deferred (kein Docker/Postgres in der Sandbox): docker build/run id -u=1001,
docker compose config --services, /api/health anonym 200, End-to-End Traefik.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:35:45 +02:00
Matthias Hochmeister
f99c1f1abd fix(seed): tsx-Laufzeit-Imports im Katalog-Seed auf relative .js umstellen
Der Standalone-Runner `tsx src/db/seed/index.ts` (npm run db:seed)
importierte Laufzeit-Module ueber den tsconfig-Pfad-Alias `@/`
(`@/db/schema`, `@/lib/audit`). tsx loest `paths` nur versionsabhaengig
auf (erst ab v4.20); package.json pinnt jedoch `tsx ^4.19.2`, sodass
eine 4.19.x-Aufloesung mit ERR_MODULE_NOT_FOUND scheitert, bevor eine
DB-Verbindung aufgebaut wird. Der Offline-Unit-Test maskierte das, weil
er nur die `./data/*`-Dateien importiert und Vite den Alias aufloest.

Fix: index.ts und upsert.ts nutzen jetzt relative `.js`-Imports
(`../schema/index.js`, `../../lib/audit.js`, `./data/*.js`, `./upsert.js`)
analog zu scripts/seed-auth.ts und scripts/migrate.ts. Damit ist die
Aufloesung tsx-versionsunabhaengig und konsistent zur etablierten
Konvention der uebrigen per tsx ausgefuehrten Scripts.

Verifiziert offline: tsc --noEmit (0), 25 Seed-Unit-Tests gruen, und
das Seed-Modul-Importgraph laedt unter `node --import tsx/esm` ohne
Resolver-Fehler. Die tatsaechliche `npm run db:seed`-Ausfuehrung gegen
Postgres bleibt deferred (kein Postgres/Server im Sandbox).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:20:29 +02:00
Matthias Hochmeister
034fdb175f Workstream 9: Seed-Daten aus NÖ-Katalog (Phase 6)
Idempotente Katalog-Seeds, die docs/reference/fahrzeug-katalog-noelfv.md
als Code abbilden. Importiert ausschließlich das bestehende Drizzle-Schema
(WS2), definiert keine Tabellen neu.

- 34 Merkmale (+ Enum-Optionen: feuerloeschpumpe_typ=8, anzahl_achsen=3,
  stromerzeuger_bauart=3); Funkrufname ist Spalte, kein Merkmal.
- 11 Fahrzeug-Vorlagen mit Pflichtmerkmalen, typisierten Vorgabewerten
  (vorgabewert_num/_text/_bool) und Aliassen mit `bestaetigt`.
  RLF/RLFA 2000 + 2000-4000 = true; kein HLFA-Alias (Laufzeitregel ist
  kanonisch); HLF 4-U als Alias auf HLF 4 mit Pulver-Pflichtmerkmalen.
- 11 Geräte-Kategorien (Natural Key name).
- upsert.ts: ausschließlich onConflictDoUpdate auf Natural Keys
  (slug/code/name + Verknüpfungs-PKs); index.ts seedet in EINER Transaktion
  (Merkmale -> Optionen -> Vorlagen -> Vorlagen-Merkmale -> Aliasse ->
  Kategorien) via Slug->ID-Map, sequenzielle Awaits.
- Reiner Offline-Unit-Test (seed.test.ts) prüft alle fachlichen Invarianten
  ohne DB; package.json-Script db:seed ergänzt.

Verifikation offline: tsc --noEmit (0), drizzle-kit check (0),
next build (0), vitest run (191 passed, 7 DB-Tests skipped).
Seed-Ausführung selbst deferred (kein Postgres im Sandbox).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:07:39 +02:00
Matthias Hochmeister
6975679c4e fix(detail): validate route-param UUID and align e2e storageState
Detailseiten & Kontakt (WS8) – zwei BLOCKING-Befunde behoben:

- UUID-Validierung an der Grenze (Querschnittsstandard 4): Die drei
  Detailseiten (fahrzeuge/geraete/wehren [id]) gaben den Route-Param `id`
  ungeprüft an die DB (Postgres `uuid`). Ein malformter Pfad wie
  /fahrzeuge/abc erzeugte `invalid input syntax for type uuid` -> 500.
  Jetzt: uuidSchema.safeParse(id) -> notFound() (deutsche 404) vor dem
  Query-Aufruf.

- e2e-Harness: detail-auth.spec.ts nutzte storageState
  "tests/e2e/.auth/wehr-read.json" (existiert nicht, ENOENT -> ganzer
  'Eingeloggt'-Block errort). Auf Projektkonvention umgestellt:
  storageState: process.env.E2E_WEHR_READ_STATE ?? { cookies, origins }
  + test.skip ohne Fixture (analog verwaltung-scoping.spec.ts).
  Zusätzlich nicht-UUID-Fall (/fahrzeuge/abc -> deutsche 404) abgesichert.

Verifiziert (offline): tsc --noEmit OK, vitest detail-Unit-Tests OK,
next build "Compiled successfully" + Typecheck OK. Build-Page-Data-Phase
und e2e deferred (kein Postgres/Server in der Sandbox).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:50:01 +02:00
Matthias Hochmeister
44050c7278 Workstream 8: Detailseiten & Kontakt (Phase 5)
Drei serverseitige Lese-Detailseiten (Fahrzeug, Gerät, Wehr), default-deny:
- src/lib/detail/merkmale.ts: formatMerkmal (de-AT Tausenderpunkt + NBSP,
  Ja/Nein, enum-Label, „–"), toEckdaten. ICU-unabhängige Zahl-Formatierung
  (formatToParts -> Punkt/Komma), da de-AT je nach ICU-Build U+202F gruppiert.
- src/lib/detail/queries.ts: read-only, wehrübergreifend; loadMerkmalRows
  (Join merkmal_values↔merkmale↔merkmal_optionen via wert=value_text),
  getFahrzeugDetail (+Beladung, +WehrCard), getGeraetDetail (Fahrzeug-Link
  oder „im Gerätehaus"), getWehrDetail (Fuhrpark + Geräte im Haus),
  getBrigadeCard. UUID-IDs.
- Komponenten: detail/{DetailHeader,EckdatenGrid,BeladungListe,StatusBadge},
  kontakt/{KontaktButton (tel:/mailto:, Telefon ohne Leerzeichen, subject;
  Empty-State),WehrCard}.
- Seiten (app)/{fahrzeuge,geraete,wehren}/[id]/page.tsx mit requireSession()
  als erster Zeile (Default-deny in der Tiefe) + fahrzeuge/[id]/not-found.tsx.
- i18n-Keys (detail/kontakt/wehr) ergänzt; keine hartkodierten Strings.

Tests: merkmale.test.ts (11), queries.test.ts (3, gemockte DB für
„im Gerätehaus" + not-found). Playwright detail-auth.spec.ts geschrieben
(deferred: kein Server/DB in Sandbox); Detailrouten ins Gating-Manifest
aufgenommen.

Offline verifiziert: vitest src/lib/detail grün; tsc --noEmit ok; eslint
ok; next build erfolgreich (alle drei [id]-Routen vorhanden).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:35:34 +02:00
Matthias Hochmeister
632ba2b081 fix(verwaltung): BLOCKING-Befunde Fuhrpark & Benutzer beheben
- tests/e2e/verwaltung-fuhrpark.spec.ts: selectOption({ label: /HLF 2/ })
  uebergab eine RegExp an Playwrights string-typisierte label-Option
  (TS2345 -> tsc-Gate rot). Stattdessen erste echte Vorlage per
  { index: 1 } waehlen (Index 0 ist "Ohne Vorlage (frei)"). tsc clean.

- src/lib/validation/vehicle.ts: Pflichtmerkmale wurden nur pro Element
  geprueft; ein komplett ausgelassenes Pflichtmerkmal (z.B. []) entging
  der Validierung. buildMerkmalValuesSchema prueft jetzt auf Array-Ebene
  per superRefine, dass jede pflicht-Definition einen gesetzten,
  typgerechten, nicht-leeren Wert hat ("validieren, nicht vertrauen",
  Querschnittsstandard 4). Tests ergaenzt (TDD).

- src/server/actions/brigade.ts: bei fehlgeschlagenem Geocoding
  (geo.status !== "ok") werden lat/lng nun auf null gesetzt, analog zu
  createBrigadeWithFirstAdmin, damit keine veralteten Koordinaten
  zuruechbleiben (WS4-Konsistenz).

Verifikation offline: tsc --noEmit exit 0; vitest src/lib/validation
47/47 gruen. E2E (DB/Server) deferred (kein Postgres/Server im Sandbox).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:18:31 +02:00
Matthias Hochmeister
5cda09c411 Workstream 7: Wehr-Bereich — Fuhrpark & Benutzer (Phase 4)
Implementiert den auf die eigene brigadeId beschränkten Wehr-Bereich:
Profil (inkl. Inline-Geocoding via geocodeAddress), Fuhrpark (Fahrzeug per
Vorlage oder frei, typisierter Merkmal-Editor), Geräte (Kategorie, Werte,
Zuordnung Fahrzeug/„im Gerätehaus") und Benutzerkonten (wehr_admin/wehr_read).

- Schema importiert (nicht neu definiert); ASCII-Property wehrfuehrer.
- Default-deny dreifach: Layout-Guard requireWehrAdmin() + jede Server Action
  beginnt mit requireWehrAdmin(); fremde Entities -> notFound() (404).
- Validierung an der Grenze (Zod): buildMerkmalValuesSchema validiert Werte
  typgerecht gegen die serverseitig aufgelösten Definitionen; Rolle auf
  wehr_admin|wehr_read beschränkt (platform_admin abgelehnt).
- upsertMerkmalValues delete-then-insert mit typisierter Drizzle-Tx (kein any);
  boolean false/num 0 gelten als gesetzt.
- argon2id-Einmalpasswort beim Benutzeranlegen; Selbst-Deaktivierung verhindert.
- Audit vollständig: brigade.profile_update, vehicle.create/update/delete/status,
  equipment.create/update/delete/status, user.create/deactivate.
- Vorgabewerte aus drei typisierten Spalten (vorgabewert_num/_text/_bool).
- i18n via zentraler de.ts; loading/empty/error-konforme Listen.

Tests: 22 neue Unit-Tests (vehicle/equipment/brigade-user-Validierung,
upsertMerkmalValues) grün; Playwright-Specs verwaltung-fuhrpark + -scoping
geschrieben (deferred: kein Server/DB in der Sandbox).

Verifikation offline: tsc --noEmit clean, eslint clean, vitest 147 passed,
next build exit 0 (alle /verwaltung/*-Routen), drizzle-kit check ohne Drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:06:17 +02:00
Matthias Hochmeister
628d35bfcd fix(admin): serverseitige authTyp-Pruefung beim Reset + Merge-PK-Kollision
resetUserPassword: laedt das Konto in der Transaktion und bricht bei
authTyp !== "local" ab (kein Hash, kein user.reset-Audit). Damit wird die
dokumentierte Invariante "nur lokale Wehr-Benutzer zuruecksetzen" auch
serverseitig erzwungen, nicht nur im UI. resetBrigadeUserPassword faengt
den Fehler als { ok: false, error } ab.

mergeMerkmal: loest PK-Kollisionen in vehicle_template_merkmale auf, indem
proposed-Zeilen geloescht werden, wenn das Ziel-Merkmal in derselben Vorlage
bereits existiert (zusammengesetzter PK template_id, merkmal_id). Das gesamte
Umhaengen ist zudem in try/catch gekapselt und liefert bei Fehlern eine klare
{ ok: false }-Meldung - analog zu promoteMerkmal.

Neue Unit-Tests (db/tx gemockt, kein Postgres noetig) decken beide Pfade ab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:42:37 +02:00
96 changed files with 7497 additions and 73 deletions

35
.dockerignore Normal file
View File

@@ -0,0 +1,35 @@
# Build-Kontext minimieren und Secrets/Artefakte aus dem Image fernhalten.
node_modules
.next
.git
.gitignore
# Umgebung / Secrets (niemals ins Image)
.env
.env.*
!.env.example
# Tests / E2E-Artefakte
tests
playwright-report
test-results
tests/e2e/.auth
coverage
# Lokale Geo-Daten (mehrere GB; werden über Volumes bereitgestellt)
infra/geo/data
# Doku / Sonstiges
docs
unterlagen
*.md
!README.md
.DS_Store
*.tsbuildinfo
.superpowers
.vscode
.idea
# Docker-Compose (nicht im Build-Kontext des App-Images nötig)
docker-compose*.yml
docker-compose*.yml.example

View File

@@ -23,3 +23,18 @@ OSRM_URL=http://osrm:5000
NOMINATIM_URL=http://nominatim:8080 NOMINATIM_URL=http://nominatim:8080
GEO_HTTP_TIMEOUT_MS=4000 GEO_HTTP_TIMEOUT_MS=4000
HAVERSINE_KMH=50 HAVERSINE_KMH=50
# Deployment / externes Traefik
# APP_HOST ist der öffentliche Hostname (Traefik-Routing + AUTH_URL-Basis).
# In Produktion: AUTH_URL=https://${APP_HOST} und AUTH_TRUST_HOST=true setzen.
APP_HOST=floriannetz.example.at
# Traefik-Zertifikatsauflöser (muss in der externen Traefik-Instanz definiert sein).
TRAEFIK_CERTRESOLVER=letsencrypt
# Name des externen, von Traefik verwalteten Docker-Netzes.
TRAEFIK_NETWORK=traefik
# Optionaler Katalog-Seed beim Container-Start (idempotent).
RUN_SEED=false
# Postgres-Zugangsdaten für den Compose-Postgres-Service.
POSTGRES_USER=floriannetz
POSTGRES_PASSWORD=floriannetz
POSTGRES_DB=floriannetz

4
.gitignore vendored
View File

@@ -8,3 +8,7 @@ node_modules/
tests/e2e/.auth/ tests/e2e/.auth/
next-env.d.ts next-env.d.ts
*.tsbuildinfo *.tsbuildinfo
# Generiertes Artefakt: wird im Docker-builder aus src/db/seed gebündelt
# (scripts/build-seed-bundle.mjs), nicht eingecheckt.
docker/seed.mjs

83
Dockerfile Normal file
View File

@@ -0,0 +1,83 @@
# syntax=docker/dockerfile:1
# FlorianNetz — multi-stage Build des Next.js-Standalone-Servers.
# Stufen: deps (Abhängigkeiten) -> builder (Build) -> runner (schlankes Laufzeit-Image).
# Läuft non-root (UID/GID 1001). Migration + optionaler Seed laufen im Entrypoint
# vor dem App-Start (siehe docker/entrypoint.sh).
ARG NODE_VERSION=22
# --- deps: Produktions- und Build-Abhängigkeiten installieren -----------------
FROM node:${NODE_VERSION}-alpine AS deps
WORKDIR /app
# Nur Manifeste kopieren -> Layer-Cache bleibt stabil, solange sich Deps nicht ändern.
COPY package.json package-lock.json ./
RUN npm ci
# --- builder: Next.js im Standalone-Modus bauen -------------------------------
FROM node:${NODE_VERSION}-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# next.config.ts setzt output:"standalone" -> erzeugt .next/standalone/server.js.
RUN npm run build
# Katalog-Seed zu einer selbstständigen Plain-ESM-Datei (docker/seed.mjs)
# bündeln, damit RUN_SEED=true im Runner (ohne tsx/src) funktioniert.
RUN node scripts/build-seed-bundle.mjs
# --- runner: minimales Laufzeit-Image ----------------------------------------
FROM node:${NODE_VERSION}-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Postgres-Client (pg_isready/psql) für die Wait-on-DB-Probe im Entrypoint.
RUN apk add --no-cache postgresql-client
# Non-root-Benutzer (feste UID/GID 1001, wie im Plan gefordert).
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Standalone-Server + statische Assets.
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# Migration zur Laufzeit: Drizzle-Journal + Migrator + pg, plus die Migrationen.
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/drizzle-orm ./node_modules/drizzle-orm
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg ./node_modules/pg
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-pool ./node_modules/pg-pool
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-protocol ./node_modules/pg-protocol
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-types ./node_modules/pg-types
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-connection-string ./node_modules/pg-connection-string
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pgpass ./node_modules/pgpass
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-array ./node_modules/postgres-array
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-bytea ./node_modules/postgres-bytea
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-date ./node_modules/postgres-date
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-interval ./node_modules/postgres-interval
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/split2 ./node_modules/split2
# Tiefere Transitiv-Abhängigkeiten der pg-Kette mit eagerem require (sonst
# ERR_MODULE_NOT_FOUND beim Container-Start in docker/migrate.mjs/seed.mjs):
# pg-types/lib/binaryParsers.js -> require('pg-int8')
# postgres-interval/index.js -> require('xtend/mutable')
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-int8 ./node_modules/pg-int8
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/xtend ./node_modules/xtend
# Migrations-Runner (plain ESM, ohne tsx) + gebündelter Seed + Entrypoint.
COPY --chown=nextjs:nodejs docker/migrate.mjs ./docker/migrate.mjs
COPY --from=builder --chown=nextjs:nodejs /app/docker/seed.mjs ./docker/seed.mjs
COPY --chown=nextjs:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
RUN chmod +x ./docker/entrypoint.sh
USER nextjs
EXPOSE 3000
# Liveness-Probe (öffentlich, ohne Fachdaten).
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=5 \
CMD wget -q -O - http://127.0.0.1:3000/api/health || exit 1
ENTRYPOINT ["./docker/entrypoint.sh"]
CMD ["node", "server.js"]

38
Makefile Normal file
View File

@@ -0,0 +1,38 @@
# FlorianNetz — Deployment-Makefile (externes Traefik).
#
# Ziele:
# make build - baut das App-Image (Next.js standalone, non-root)
# make up - startet den Stack (App + Postgres + Geo) hinter Traefik
# make down - stoppt den Stack
# make logs - folgt den App-Logs
# make deploy - build + up (Standard-Deploy)
# make data - bereitet die OSRM-Geodaten vor (Download + Preprocessing)
# make config - validiert die Compose-Konfiguration
#
# Hinweis: up/data/deploy benötigen Docker (+ Netzzugriff/RAM/Disk) und werden
# NICHT in CI/Sandbox ausgeführt. Das externe Traefik-Netz muss existieren:
# docker network create traefik
COMPOSE = docker compose --env-file .env
.PHONY: build up down logs deploy data config
build:
$(COMPOSE) build app
up:
$(COMPOSE) up -d
down:
$(COMPOSE) down
logs:
$(COMPOSE) logs -f app
deploy: build up
data:
./scripts/prepare-osm-data.sh
config:
$(COMPOSE) config --services

View File

@@ -0,0 +1,20 @@
# Lokales Entwicklungs-Overlay (OHNE Traefik/TLS).
# Macht die App direkt auf http://localhost:3000 erreichbar und setzt AUTH_URL
# auf http:// (Cookie-secure aus -> lokale HTTP-Entwicklung funktioniert).
#
# Verwendung (Datei zuerst kopieren):
# cp docker-compose.override.yml.example docker-compose.override.yml
# docker compose up -d
# (docker-compose.override.yml wird von Compose automatisch zusätzlich geladen.)
services:
app:
ports:
- "3000:3000"
environment:
AUTH_URL: http://localhost:3000
# Keine Traefik-Labels nötig; lokal wird direkt auf :3000 zugegriffen.
labels:
- "traefik.enable=false"
networks:
- internal

146
docker-compose.yml Normal file
View File

@@ -0,0 +1,146 @@
# FlorianNetz — Basis-Compose hinter EXTERNEM Traefik.
#
# Es gibt bewusst KEINEN eigenen Proxy-/Traefik-Service: Routing/TLS übernimmt
# eine separat betriebene Traefik-Instanz, die am externen Netz "${TRAEFIK_NETWORK}"
# (Default: traefik) lauscht. Dieses Netz muss bereits existieren:
# docker network create traefik
#
# Geo-Dienste (osrm, nominatim) sind hier mit ihren Laufzeit-Verträgen definiert;
# das schwergewichtige Daten-Preprocessing/Volume kommt aus docker-compose.geo.yml
# (siehe scripts/prepare-osm-data.sh / infra/geo).
#
# Start:
# docker compose --env-file .env up -d
# Lokal ohne Traefik/TLS:
# docker compose -f docker-compose.yml -f docker-compose.override.yml up -d
services:
app:
build:
context: .
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
DATABASE_URL: postgres://${POSTGRES_USER:-floriannetz}:${POSTGRES_PASSWORD:-floriannetz}@postgres:5432/${POSTGRES_DB:-floriannetz}
# Forwarded-Header + sichere Cookies hinter Traefik.
AUTH_TRUST_HOST: "true"
AUTH_URL: https://${APP_HOST}
AUTH_SECRET: ${AUTH_SECRET}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
OSRM_URL: http://osrm:5000
NOMINATIM_URL: http://nominatim:8080
GEO_HTTP_TIMEOUT_MS: ${GEO_HTTP_TIMEOUT_MS:-4000}
HAVERSINE_KMH: ${HAVERSINE_KMH:-50}
RUN_SEED: ${RUN_SEED:-false}
networks:
- traefik
- internal
healthcheck:
test:
- CMD-SHELL
- "wget -q -O - http://127.0.0.1:3000/api/health | grep -q ok"
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}"
- "traefik.http.routers.floriannetz.rule=Host(`${APP_HOST}`)"
- "traefik.http.routers.floriannetz.entrypoints=websecure"
- "traefik.http.routers.floriannetz.tls=true"
- "traefik.http.routers.floriannetz.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}"
- "traefik.http.services.floriannetz.loadbalancer.server.port=3000"
# Security-Header-Middleware (zusätzlich zu next.config.ts; defense-in-depth).
- "traefik.http.routers.floriannetz.middlewares=floriannetz-sechdrs"
- "traefik.http.middlewares.floriannetz-sechdrs.headers.stsSeconds=63072000"
- "traefik.http.middlewares.floriannetz-sechdrs.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.floriannetz-sechdrs.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.floriannetz-sechdrs.headers.frameDeny=true"
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-floriannetz}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-floriannetz}
POSTGRES_DB: ${POSTGRES_DB:-floriannetz}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test:
- CMD-SHELL
- "pg_isready -U ${POSTGRES_USER:-floriannetz} -d ${POSTGRES_DB:-floriannetz}"
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
osrm:
build:
context: .
dockerfile: docker/osrm/Dockerfile
command: osrm-routed --algorithm mld /data/austria-latest.osrm
volumes:
- osrm-data:/data
networks:
- internal
expose:
- "5000"
healthcheck:
test:
- CMD-SHELL
- >-
wget -q -O - 'http://localhost:5000/table/v1/driving/15.6229,48.2079;16.3738,48.2082?sources=0'
| grep -q '"code":"Ok"'
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
restart: unless-stopped
nominatim:
image: mediagis/nominatim:4.4
environment:
PBF_URL: https://download.geofabrik.de/europe/austria-latest.osm.pbf
REPLICATION_URL: https://download.geofabrik.de/europe/austria-updates/
IMPORT_STYLE: address
NOMINATIM_PASSWORD: ${NOMINATIM_PASSWORD:-nominatim}
volumes:
- nominatim-data:/var/lib/postgresql/14/main
shm_size: 1g
networks:
- internal
expose:
- "8080"
healthcheck:
test:
- CMD-SHELL
- "wget -q -O - 'http://localhost:8080/status' | grep -q OK"
interval: 30s
timeout: 5s
retries: 5
start_period: 120s
restart: unless-stopped
volumes:
postgres-data:
osrm-data:
nominatim-data:
networks:
# Externes, von der separaten Traefik-Instanz verwaltetes Netz.
traefik:
external: true
name: ${TRAEFIK_NETWORK:-traefik}
# Internes Netz: Postgres/Geo sind nur app-intern erreichbar, nicht öffentlich.
internal:
internal: true

49
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,49 @@
#!/bin/sh
# FlorianNetz Container-Entrypoint.
# Ablauf: auf Postgres warten -> Migrationen anwenden -> (optional) seeden ->
# App-Server starten. Idempotent: Migration + Seed nutzen Journal/Upserts.
set -eu
echo "[entrypoint] FlorianNetz startet ..."
if [ -z "${DATABASE_URL:-}" ]; then
echo "[entrypoint] FEHLER: DATABASE_URL ist nicht gesetzt." >&2
exit 1
fi
# --- 1) Auf Postgres warten ---------------------------------------------------
# pg_isready akzeptiert die DATABASE_URL direkt; bis ~60 s pollen.
echo "[entrypoint] Warte auf Postgres ..."
ATTEMPTS=0
MAX_ATTEMPTS="${DB_WAIT_RETRIES:-60}"
until pg_isready -d "${DATABASE_URL}" >/dev/null 2>&1; do
ATTEMPTS=$((ATTEMPTS + 1))
if [ "${ATTEMPTS}" -ge "${MAX_ATTEMPTS}" ]; then
echo "[entrypoint] FEHLER: Postgres nach ${MAX_ATTEMPTS} Versuchen nicht erreichbar." >&2
exit 1
fi
sleep 1
done
echo "[entrypoint] Postgres ist erreichbar."
# --- 2) Migrationen anwenden (idempotent über das Drizzle-Journal) ------------
echo "[entrypoint] Wende Migrationen an ..."
node docker/migrate.mjs
# --- 3) Optionaler Seed -------------------------------------------------------
# RUN_SEED=true füllt den NÖ-Katalog (idempotente Upserts) über das beim Image-
# Build gebündelte Seed-Skript (docker/seed.mjs, siehe scripts/build-seed-bundle.mjs).
# Das Skript MUSS im Image vorhanden sein; fehlt es, ist das Image kaputt gebaut
# und wir brechen laut ab, statt einen leeren Katalog vorzutäuschen.
if [ "${RUN_SEED:-false}" = "true" ]; then
if [ ! -f docker/seed.mjs ]; then
echo "[entrypoint] FEHLER: RUN_SEED=true, aber docker/seed.mjs fehlt im Image." >&2
exit 1
fi
echo "[entrypoint] Führe Katalog-Seed aus ..."
node docker/seed.mjs
fi
# --- 4) App-Server starten ----------------------------------------------------
echo "[entrypoint] Starte Anwendung: $*"
exec node server.js

28
docker/migrate.mjs Normal file
View File

@@ -0,0 +1,28 @@
// Migrations-Runner für das Laufzeit-Image (plain ESM, ohne tsx/drizzle-kit).
// Wendet die Drizzle-Migrationen aus ./drizzle idempotent über das Journal an.
// Liest DATABASE_URL direkt aus der Umgebung (keine Next.js-Env-Validierung),
// analog zu scripts/migrate.ts.
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import pg from "pg";
const { Pool } = pg;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
console.error("DATABASE_URL ist nicht gesetzt.");
process.exit(1);
}
const pool = new Pool({ connectionString, max: 1 });
const db = drizzle(pool);
try {
await migrate(db, { migrationsFolder: "./drizzle" });
console.log("Migrationen erfolgreich angewandt.");
} catch (err) {
console.error("Migration fehlgeschlagen:", err);
process.exitCode = 1;
} finally {
await pool.end();
}

View File

@@ -0,0 +1,130 @@
# Deployment hinter externem Traefik
FlorianNetz wird als Docker-Compose-Stack betrieben und **hinter einer separat
betriebenen Traefik-Instanz** ausgeliefert. Es gibt bewusst **keinen** eigenen
Proxy-/Traefik-Service im Compose-Stack — Routing und TLS-Terminierung übernimmt
das externe Traefik.
## Komponenten
Der Stack besteht aus genau vier Services (kein Proxy):
| Service | Zweck |
| ----------- | -------------------------------------------------- |
| `app` | Next.js-Standalone-Server (non-root, UID 1001) |
| `postgres` | PostgreSQL 16 (Daten-Volume, `pg_isready`-Health) |
| `osrm` | OSRM-Routing (Österreich-Extrakt, `/table`) |
| `nominatim` | Geocoding (Österreich-Extrakt, `/search`) |
Netze:
- **`traefik`** — externes, von Traefik verwaltetes Netz (`external: true`,
Name aus `TRAEFIK_NETWORK`, Default `traefik`). Nur `app` hängt daran.
- **`internal`** — internes Netz (`internal: true`); Postgres und die Geo-Dienste
sind ausschließlich für die App erreichbar, nie öffentlich.
## Voraussetzungen
Das externe Traefik-Netz muss existieren, bevor der Stack startet:
```bash
docker network create traefik
```
Die externe Traefik-Instanz muss:
- einen Entrypoint `websecure` (Port 443) bereitstellen,
- einen Zertifikatsauflöser anbieten, dessen Name `TRAEFIK_CERTRESOLVER`
entspricht (Default `letsencrypt`),
- am Netz `traefik` lauschen (`providers.docker` mit `exposedByDefault=false`).
Die App-Labels in `docker-compose.yml` setzen Router (`Host(\`${APP_HOST}\`)`,
`entrypoints=websecure`, `tls.certresolver`), Service-Port `3000` und eine
Security-Header-Middleware (defense-in-depth zusätzlich zu `next.config.ts`).
## Pflicht-Umgebungsvariablen
Vollständiger Vertrag in `.env.example`. Für den Betrieb hinter Traefik zwingend:
| Variable | Beispiel / Hinweis |
| ------------------------- | ---------------------------------------------------- |
| `APP_HOST` | öffentlicher Hostname, z. B. `floriannetz.example.at` |
| `AUTH_URL` | `https://${APP_HOST}` — Basis für Callback + Cookies |
| `AUTH_TRUST_HOST` | `true` — Auth.js vertraut den Forwarded-Headern |
| `AUTH_SECRET` | >= 32 Zeichen (`openssl rand -base64 32`) |
| `AUTHENTIK_ISSUER` | OIDC-Issuer-URL der Authentik-Anwendung |
| `AUTHENTIK_CLIENT_ID` | Client-ID der Authentik-Anwendung |
| `AUTHENTIK_CLIENT_SECRET` | Client-Secret der Authentik-Anwendung |
| `DATABASE_URL` | wird in Compose aus `POSTGRES_*` zusammengesetzt |
| `TRAEFIK_CERTRESOLVER` | Name des Traefik-Zertifikatsauflösers |
| `TRAEFIK_NETWORK` | Name des externen Traefik-Netzes (Default `traefik`) |
## Forwarded-Header & sichere Cookies
Hinter Traefik terminiert TLS am Proxy; die App sieht intern HTTP. Damit
Auth.js die korrekte Origin erkennt und sichere Cookies setzt:
- `AUTH_TRUST_HOST=true` — Auth.js wertet `X-Forwarded-Proto`/`X-Forwarded-Host`
aus.
- `AUTH_URL=https://${APP_HOST}` — erzwingt die `https://`-Origin für
Callback-URLs und aktiviert das `__Secure-`-Cookie-Präfix (Cookie `secure`,
`httpOnly`, `sameSite=lax`).
Bei lokaler HTTP-Entwicklung (`docker-compose.override.yml`) ist
`AUTH_URL=http://localhost:3000` — dann fällt das `secure`/`__Secure-`-Verhalten
weg, sonst bräche der Login über HTTP.
## Authentik-Konfiguration
In der Authentik-Anwendung (OAuth2/OpenID-Provider) als **Redirect-URI**
eintragen:
```
https://${APP_HOST}/api/auth/callback/authentik
```
Der Pfad `callback/authentik` entspricht dem NextAuth-Provider-Namen. Bei lokaler
Entwicklung zusätzlich `http://localhost:3000/api/auth/callback/authentik`.
## Health-Check & Middleware-Allowlist
`GET /api/health` ist **öffentlich** (anonym `200`, nur Liveness, keine
Fachdaten). Die Edge-Middleware nimmt den Pfad in ihrer Allowlist aus
(`api/health` im `matcher`), sonst würde die Default-deny-Schicht ihn auf
`/login` umleiten. Sowohl der Container-`HEALTHCHECK` als auch der Compose-
App-Healthcheck pingen `http://127.0.0.1:3000/api/health`.
## Migration & Seed beim Deploy
`docker/entrypoint.sh` läuft vor dem App-Start:
1. wartet via `pg_isready` auf Postgres,
2. wendet die Drizzle-Migrationen idempotent an (`node docker/migrate.mjs`),
3. optional (`RUN_SEED=true`) den NÖ-Katalog-Seed,
4. startet `exec node server.js`.
## Deploy
```bash
cp .env.example .env # Werte setzen (APP_HOST, AUTH_*, AUTHENTIK_*, POSTGRES_*)
docker network create traefik # einmalig, falls nicht vorhanden
make data # einmalig: OSRM-Geodaten vorbereiten (groß, dauert)
make deploy # build + up
```
## Lokal ohne Traefik
```bash
cp docker-compose.override.yml.example docker-compose.override.yml
docker compose up -d
# App: http://localhost:3000
```
## Verifikation
- `docker compose -f docker-compose.yml -f docker-compose.geo.yml config --services`
→ genau `app postgres osrm nominatim` (kein Proxy).
- `docker build -t floriannetz . && docker run --rm floriannetz id -u` → `1001`.
- `sh -n docker/entrypoint.sh` → keine Syntaxfehler.
- `curl -I https://${APP_HOST}` → `200`/`302` mit gültigem TLS-Zertifikat.
- Login über Authentik setzt ein `__Secure-`-Cookie; Callback-URL ist `https://`.

View File

@@ -0,0 +1,47 @@
# Sicherheitshärtung — Checkliste mit Verifikation
Jeder Punkt der Härtung ist durch genau einen Test oder Befehl belegbar
(Definition of Done #8). Befehle, die einen laufenden Server oder eine
erreichbare Datenbank benötigen, sind als **(server/db)** markiert und in der
Sandbox **deferred**; ihre statisch prüfbare Grundlage ist jeweils zusätzlich
durch einen Offline-Unit-Test abgesichert.
| # | Härtungspunkt | Verifikation (Test / Befehl) | Sandbox |
|---|---|---|---|
| 1 | **Auth-Gating (oberstes Prinzip).** Jede Seite → Redirect `/login` mit `callbackUrl`; jede API → `401` ohne Daten-Leak. | `npm run test:e2e:gating` (genau ein Fall je `ROUTES`-Eintrag). | deferred (server/db) |
| 2 | **Driftschutz Routen.** Keine ungetestete neue Route unter `src/app/**`. | `npx vitest run tests/unit/routes-manifest.test.ts` (offline). Negativ-Probe: `src/app/(app)/leak/page.tsx` → rot. | offline |
| 3 | **Default-Deny Server Actions.** Jede `"use server"`-Funktion ruft als erste Anweisung einen Guard. | `npx vitest run tests/unit/server-actions-guard.test.ts` (offline). Negativ-Probe: einen Guard entfernen → rot. | offline |
| 4 | **Rollen-/Wehr-Scoping.** `wehr_read` schreibt nicht (403); `wehr_admin` A ändert Wehr B nicht (403/404); eigene Ressource (200). | `npm run test:e2e -- rbac-scoping.spec.ts`. | deferred (server/db) |
| 5 | **Security-Header.** `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, CSP `frame-ancestors 'none'` + `form-action 'self'`, HSTS. | Offline: `npx vitest run src/lib/security/headers.test.ts`. Live: `curl -sI https://<host>/login \| grep -i x-frame-options``DENY`; bzw. `npm run test:e2e -- security-headers.spec.ts`. | offline + deferred |
| 6 | **CSP in der App verdrahtet.** `SECURITY_HEADERS` ist in `next.config.ts` (`headers()`) eingehängt. | `npm run build` (Header-Konfiguration wird validiert); Live-Beleg via security-headers.spec.ts. | offline (build) |
| 7 | **Session-Cookie-Flags.** `httpOnly`, `sameSite=lax`; `secure` + `__Secure-`-Präfix nur unter `https://` (Querschnittsstandard 9). | `npm run test:e2e -- security-headers.spec.ts` (Cookie-Assertion). | deferred (server/db) |
| 8 | **argon2id OWASP-Minima.** `type=argon2id` (2), `memoryCost ≥ 19456`, `timeCost ≥ 2`, `parallelism ≥ 1`; Hash beginnt mit `$argon2id$`; Roundtrip. | `npx vitest run src/lib/auth/__tests__/password.test.ts` (offline). | offline |
| 9 | **Login-Rate-Limit im `authorize`-Pfad.** 5 Fehlversuche / 15 min pro Key; Drosselung ab Versuch 6. | Offline-Policy: `npx vitest run src/lib/auth/__tests__/rate-limit.test.ts`. Live: `npm run test:e2e -- login-ratelimit.spec.ts`. | offline + deferred |
| 10 | **CSRF.** State-Changing `POST` ohne gültiges CSRF-Token erzeugt keine Session (Auth.js-CSRF im Credentials-Flow). | Live: `POST /api/auth/callback/credentials` ohne `csrfToken` → keine Session; `GET /api/auth/session` liefert leeres Objekt. | deferred (server) |
| 11 | **Audit-Logging.** Schreib-Aktionen schreiben `audit_log` (eine `writeAudit`-Signatur, optionaler `tx`). Nach `merkmal.promote` existiert eine Zeile. | Live: `select aktion, ziel_typ from audit_log order by zeitpunkt desc limit 1``merkmal.promote \| merkmal`. Offline: Server-Action-Unit-Tests prüfen `writeAudit`-Aufruf. | deferred (db) + offline |
| 12 | **API gibt 401/403, kein HTML-Redirect; kein Daten-Leak.** | `npm run test:e2e:gating` (API-Fälle: `expect(status).toBe(401)`, Body ohne Fachbegriffe). | deferred (server) |
| 13 | **`/api/health` anonym 200 (Allowlist).** | Live: `curl -s https://<host>/api/health``{"status":"ok"}`. Offline: `tests/unit/routes-manifest.test.ts` belegt `/api/health` in `PUBLIC_ALLOWLIST`. | offline + deferred |
| 14 | **argon2 nicht im Edge-/Middleware-Pfad.** `@node-rs/argon2` wird nur server-seitig importiert. | `npm run build` (Edge-Bundle bricht sonst); Code-Review von `middleware.ts`. | offline (build) |
## Negativ-Proben (Beweis, dass die Tests greifen)
- **Layout-Guard entfernen** (z. B. `await requireSession()` aus
`src/app/(app)/layout.tsx`): `test:e2e:gating` wird rot (Seiten erreichbar).
- **Manifest-Route entfernen**: Driftschutz `routes-manifest.test.ts` wird rot.
- **Server-Action-Guard entfernen**: `server-actions-guard.test.ts` wird rot.
- **Route ohne Manifest-Eintrag anlegen** (`src/app/(app)/leak/page.tsx`):
Driftschutz rot; nach Entfernen wieder grün.
## Offline vs. deferred (Sandbox-Hinweis)
In dieser Umgebung gibt es **kein** Postgres und **keinen** laufenden Server.
Verifiziert wurden daher ausschließlich die Offline-Belege:
- `npx tsc --noEmit` (Typprüfung inkl. aller Tests).
- `npx vitest run` (alle reinen Unit-Tests; DB-Roundtrips werden bewusst
übersprungen).
- `npm run build` (Next.js-Standalone-Build inkl. Header-Verdrahtung).
Die mit **(server/db)** markierten E2E-Punkte werden im CI bzw. lokal gegen
einen geseedeten Server über `npm run test:e2e` / `npm run test:e2e:gating`
ausgeführt.

View File

@@ -11,10 +11,14 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/migrate.ts", "db:migrate": "tsx scripts/migrate.ts",
"db:seed-auth": "tsx scripts/seed-auth.ts", "db:seed-auth": "tsx scripts/seed-auth.ts",
"test:e2e:gating": "playwright test tests/e2e/auth-gating.spec.ts", "db:seed": "tsx src/db/seed/index.ts",
"test:e2e": "playwright test",
"test:e2e:gating": "playwright test --project=chromium tests/e2e/auth-gating.spec.ts",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:check": "drizzle-kit check" "db:check": "drizzle-kit check"

View File

@@ -13,12 +13,22 @@ export default defineConfig({
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? "github" : "list", reporter: process.env.CI ? "github" : "list",
// Migration + deterministischer Seed (deferred ohne DATABASE_URL).
globalSetup: "./tests/e2e/global-setup.ts",
use: { use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000", baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry", trace: "on-first-retry",
}, },
projects: [ projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, // 1. Echter Login je Konto -> storageState (tests/e2e/.auth/*.json).
{ name: "setup", testMatch: /fixtures\/auth\.setup\.ts/ },
// 2. Eigentliche Suiten; hängen vom Login-Setup ab.
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
testIgnore: /fixtures\/auth\.setup\.ts/,
dependencies: ["setup"],
},
], ],
webServer: process.env.E2E_BASE_URL webServer: process.env.E2E_BASE_URL
? undefined ? undefined

View File

@@ -0,0 +1,37 @@
// Bündelt den Katalog-Seed (src/db/seed/index.ts inkl. Seed-Daten + Drizzle-
// Schema) zu einer einzigen, selbstständigen ESM-Datei docker/seed.mjs für das
// Laufzeit-Image — analog zu docker/migrate.mjs, aber generiert statt handgepflegt.
//
// Hintergrund: Der Standalone-Runner enthält weder `tsx` noch `src/`. Damit
// RUN_SEED=true im Entrypoint tatsächlich funktioniert (statt still no-op zu
// werden), bündeln wir die Seed-Logik beim Build zu Plain-ESM. `pg` und
// `drizzle-orm` bleiben extern (sind im Runner als node_modules vorhanden);
// alles andere (Schema, Seed-Daten, Upserts) wird inline gebündelt, sodass
// keine `src/`-Dateien ins Runner-Image müssen.
//
// Aufruf: node scripts/build-seed-bundle.mjs (läuft im builder-Stage).
import { build } from "esbuild";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, "..");
await build({
entryPoints: [resolve(root, "src/db/seed/index.ts")],
outfile: resolve(root, "docker/seed.mjs"),
bundle: true,
platform: "node",
format: "esm",
target: "node22",
// Im Runner vorhandene (und im Dockerfile kopierte) Laufzeit-Pakete bleiben
// extern, damit wir keine zwei Kopien bündeln und die native pg-Kette nutzen.
external: ["pg", "drizzle-orm", "drizzle-orm/*"],
// src/db/seed/index.ts importiert ../../lib/audit.js NUR als `import type`
// (Tx); damit zieht esbuild die @/db-Kette (next-auth etc.) nicht in den
// Bundle. Die folgende alias-freie Auflösung reicht deshalb aus.
logLevel: "info",
});
console.log("docker/seed.mjs gebündelt.");

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Mocks ---------------------------------------------------------------
const PROPOSED = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const ZIEL = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
// Geteilter, veraenderbarer Zustand. vi.hoisted laeuft vor den (gehoisteten)
// vi.mock-Factories, sodass diese den State sicher referenzieren koennen.
const state = vi.hoisted(() => ({
// merkmale-Zeilen, die der Top-Level db.select() liefert.
merkmaleRows: [] as Array<{ id: string; typ: string; status: string }>,
// Reihenfolge der tx.select()-Ergebnisse fuer vehicle_template_merkmale:
// [0] = proposed-Templates, [1] = ziel-Templates.
vtmSelectQueue: [] as Array<Array<{ templateId: string }>>,
ops: [] as { type: string; table: string; vals?: unknown }[],
}));
function tableName(arg: unknown): string {
return (arg as { __name?: string })?.__name ?? "unknown";
}
vi.mock("@/db/schema", () => ({
merkmale: { __name: "merkmale" },
merkmalValues: { __name: "merkmal_values" },
vehicleTemplateMerkmale: { __name: "vehicle_template_merkmale" },
}));
function makeTx() {
return {
select: () => ({
from: () => ({
where: () => Promise.resolve(state.vtmSelectQueue.shift() ?? []),
}),
}),
update: (table: unknown) => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
state.ops.push({ type: "update", table: tableName(table), vals });
return Promise.resolve(undefined);
},
}),
}),
delete: (table: unknown) => ({
where: () => {
state.ops.push({ type: "delete", table: tableName(table) });
return Promise.resolve(undefined);
},
}),
};
}
vi.mock("@/db", () => ({
db: {
select: () => ({
from: () => Promise.resolve(state.merkmaleRows),
}),
transaction: (cb: (tx: ReturnType<typeof makeTx>) => Promise<unknown>) =>
cb(makeTx()),
},
}));
vi.mock("@/lib/auth/guards", () => ({
requirePlatformAdmin: () =>
Promise.resolve({ user: { id: "actor-1" } }),
}));
vi.mock("@/lib/audit", () => ({
writeAudit: () => Promise.resolve(),
}));
vi.mock("next/cache", () => ({
revalidatePath: () => undefined,
}));
// drizzle-orm Helfer (eq/and/inArray) muessen echte Aufrufe ueberstehen.
vi.mock("drizzle-orm", () => ({
eq: (...a: unknown[]) => ({ op: "eq", a }),
and: (...a: unknown[]) => ({ op: "and", a }),
inArray: (...a: unknown[]) => ({ op: "inArray", a }),
}));
// -------------------------------------------------------------------------
import { mergeMerkmal } from "@/app/(admin)/_actions/proposals";
describe("mergeMerkmal", () => {
beforeEach(() => {
state.ops.length = 0;
state.vtmSelectQueue = [];
state.merkmaleRows = [
{ id: PROPOSED, typ: "number", status: "proposed" },
{ id: ZIEL, typ: "number", status: "active" },
];
});
it("lehnt unterschiedliche Typen ab", async () => {
state.merkmaleRows = [
{ id: PROPOSED, typ: "boolean", status: "proposed" },
{ id: ZIEL, typ: "number", status: "active" },
];
const res = await mergeMerkmal({ proposedId: PROPOSED, zielId: ZIEL });
expect(res.ok).toBe(false);
});
it("haengt ohne Kollision alle vtm-Zeilen um (kein Delete der proposed-vtm)", async () => {
// proposed in Template T1, Ziel in keinem -> keine Kollision.
state.vtmSelectQueue = [[{ templateId: "T1" }], []];
const res = await mergeMerkmal({ proposedId: PROPOSED, zielId: ZIEL });
expect(res.ok).toBe(true);
// Es darf kein Kollisions-Delete auf vtm geben, nur das finale
// merkmale-Delete.
const vtmDeletes = state.ops.filter(
(o) => o.type === "delete" && o.table === "vehicle_template_merkmale",
);
expect(vtmDeletes).toHaveLength(0);
const vtmUpdates = state.ops.filter(
(o) => o.type === "update" && o.table === "vehicle_template_merkmale",
);
expect(vtmUpdates).toHaveLength(1);
expect(
state.ops.some((o) => o.type === "delete" && o.table === "merkmale"),
).toBe(true);
});
it("loescht kollidierende proposed-vtm-Zeilen vor dem Umhaengen", async () => {
// proposed und Ziel teilen sich Template T1 -> Kollision auf PK.
state.vtmSelectQueue = [[{ templateId: "T1" }], [{ templateId: "T1" }]];
const res = await mergeMerkmal({ proposedId: PROPOSED, zielId: ZIEL });
expect(res.ok).toBe(true);
const vtmDeletes = state.ops.filter(
(o) => o.type === "delete" && o.table === "vehicle_template_merkmale",
);
expect(vtmDeletes).toHaveLength(1);
// Delete des Kollisions-Eintrags vor dem Umhaengen.
const deleteIdx = state.ops.findIndex(
(o) => o.type === "delete" && o.table === "vehicle_template_merkmale",
);
const updateIdx = state.ops.findIndex(
(o) => o.type === "update" && o.table === "vehicle_template_merkmale",
);
expect(deleteIdx).toBeLessThan(updateIdx);
});
});

View File

@@ -50,7 +50,17 @@ export async function resetBrigadeUserPassword(
const s = await requirePlatformAdmin(); const s = await requirePlatformAdmin();
const p = userResetSchema.safeParse(input); const p = userResetSchema.safeParse(input);
if (!p.success) return { ok: false, error: "Ungültige ID." }; if (!p.success) return { ok: false, error: "Ungültige ID." };
try {
const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id); const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id);
revalidatePath("/admin/wehren"); revalidatePath("/admin/wehren");
return { ok: true, tempPassword }; return { ok: true, tempPassword };
} catch (e) {
return {
ok: false,
error:
e instanceof Error
? e.message
: "Passwort konnte nicht zurückgesetzt werden.",
};
}
} }

View File

@@ -1,6 +1,6 @@
"use server"; "use server";
import { eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { db } from "@/db"; import { db } from "@/db";
import { merkmale, merkmalValues, vehicleTemplateMerkmale } from "@/db/schema"; import { merkmale, merkmalValues, vehicleTemplateMerkmale } from "@/db/schema";
@@ -80,7 +80,40 @@ export async function mergeMerkmal(input: {
}; };
} }
try {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
// vehicle_template_merkmale hat den zusammengesetzten PK
// (template_id, merkmal_id). Hat eine Vorlage bereits sowohl das
// vorgeschlagene als auch das Ziel-Merkmal, würde ein pauschales
// Umhängen den PK verletzen. Solche kollidierenden Proposed-Zeilen
// werden daher gelöscht statt umgehängt.
const proposedVtm = await tx
.select({ templateId: vehicleTemplateMerkmale.templateId })
.from(vehicleTemplateMerkmale)
.where(eq(vehicleTemplateMerkmale.merkmalId, proposed.data));
const zielVtm = await tx
.select({ templateId: vehicleTemplateMerkmale.templateId })
.from(vehicleTemplateMerkmale)
.where(eq(vehicleTemplateMerkmale.merkmalId, ziel.data));
const zielTemplateIds = new Set(zielVtm.map((r) => r.templateId));
const collidingTemplateIds = proposedVtm
.map((r) => r.templateId)
.filter((id) => zielTemplateIds.has(id));
if (collidingTemplateIds.length > 0) {
await tx
.delete(vehicleTemplateMerkmale)
.where(
and(
eq(vehicleTemplateMerkmale.merkmalId, proposed.data),
inArray(
vehicleTemplateMerkmale.templateId,
collidingTemplateIds,
),
),
);
}
await tx await tx
.update(merkmalValues) .update(merkmalValues)
.set({ merkmalId: ziel.data }) .set({ merkmalId: ziel.data })
@@ -99,6 +132,12 @@ export async function mergeMerkmal(input: {
tx, tx,
); );
}); });
} catch {
return {
ok: false,
error: "Zusammenführen fehlgeschlagen. Bitte erneut versuchen.",
};
}
revalidatePath("/admin/merkmale/proposals"); revalidatePath("/admin/merkmale/proposals");
return { ok: true }; return { ok: true };

View File

@@ -0,0 +1,14 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { t } from "@/lib/i18n/de";
export default function FahrzeugNotFound() {
return (
<div className="mx-auto max-w-md py-16 text-center">
<p className="text-anthrazit">{t("detail.nichtGefunden")}</p>
<Button asChild className="mt-4">
<Link href="/fahrzeuge">{t("nav.fahrzeuge")}</Link>
</Button>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { notFound } from "next/navigation";
import { requireSession } from "@/lib/auth/guards";
import { getFahrzeugDetail } from "@/lib/detail/queries";
import { uuidSchema } from "@/lib/validation/common";
import { DetailHeader } from "@/components/detail/DetailHeader";
import { EckdatenGrid } from "@/components/detail/EckdatenGrid";
import { BeladungListe } from "@/components/detail/BeladungListe";
import { WehrCard } from "@/components/kontakt/WehrCard";
import { t } from "@/lib/i18n/de";
export default async function FahrzeugDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
// Default-deny in der Tiefe (Querschnittsstandard 1): Guard als erste Zeile.
await requireSession();
const { id } = await params;
// Route-Param ist Nutzereingabe an der Grenze (Querschnittsstandard 4):
// nicht-UUID -> saubere deutsche 404 statt Postgres `invalid input syntax`.
const parsed = uuidSchema.safeParse(id);
if (!parsed.success) notFound();
const v = await getFahrzeugDetail(parsed.data);
if (!v) notFound();
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<DetailHeader
kicker={v.templateName ?? t("nav.fahrzeuge")}
titel={v.name}
untertitel={v.funkrufname}
status={v.status}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_18rem]">
<div className="flex flex-col gap-6">
<EckdatenGrid rows={v.merkmale} />
<BeladungListe items={v.beladung} />
{v.notiz ? (
<p className="whitespace-pre-line text-sm text-anthrazit/70">
{v.notiz}
</p>
) : null}
</div>
{v.wehr ? <WehrCard wehr={v.wehr} /> : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { requireSession } from "@/lib/auth/guards";
import { getGeraetDetail } from "@/lib/detail/queries";
import { uuidSchema } from "@/lib/validation/common";
import { DetailHeader } from "@/components/detail/DetailHeader";
import { EckdatenGrid } from "@/components/detail/EckdatenGrid";
import { WehrCard } from "@/components/kontakt/WehrCard";
import { t } from "@/lib/i18n/de";
export default async function GeraetDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
// Default-deny in der Tiefe (Querschnittsstandard 1): Guard als erste Zeile.
await requireSession();
const { id } = await params;
// Route-Param ist Nutzereingabe an der Grenze (Querschnittsstandard 4):
// nicht-UUID -> saubere deutsche 404 statt Postgres `invalid input syntax`.
const parsed = uuidSchema.safeParse(id);
if (!parsed.success) notFound();
const g = await getGeraetDetail(parsed.data);
if (!g) notFound();
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<DetailHeader
kicker={g.kategorie}
titel={g.name}
status={g.status}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_18rem]">
<div className="flex flex-col gap-6">
<EckdatenGrid rows={g.merkmale} />
<section>
<h2 className="text-sm font-semibold text-navy">
{t("detail.zugeordnetesFahrzeug")}
</h2>
{g.fahrzeug ? (
<Link
href={`/fahrzeuge/${g.fahrzeug.id}`}
className="mt-2 inline-block font-medium text-navy hover:underline"
>
{g.fahrzeug.name}
</Link>
) : (
<p className="mt-2 text-sm text-anthrazit/60">
{t("detail.imGeraetehaus")}
</p>
)}
</section>
</div>
{g.wehr ? <WehrCard wehr={g.wehr} /> : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { requireWehrAdmin } from "@/lib/auth/guards";
import { listUsersForBrigade } from "@/server/data/brigade-users";
import { de } from "@/lib/i18n/de";
import { Badge } from "@/components/ui/badge";
import {
BrigadeUserForm,
DeactivateUserButton,
} from "@/components/verwaltung/BrigadeUserForm";
const ROLLE_LABEL: Record<string, string> = {
wehr_admin: de.verwaltung.rolleAdmin,
wehr_read: de.verwaltung.rolleRead,
platform_admin: "Plattform-Admin",
};
export default async function BenutzerPage() {
const s = await requireWehrAdmin();
const users = await listUsersForBrigade(s.user.brigadeId);
return (
<div className="space-y-6">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.navBenutzer}
</h1>
<BrigadeUserForm />
{users.length === 0 ? (
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
{de.verwaltung.keineBenutzer}
</p>
) : (
<ul className="divide-y divide-rand rounded border border-rand bg-white">
{users.map((u) => (
<li
key={u.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<p className="font-medium text-anthrazit">
{u.name}
{u.id === s.user.id ? (
<span className="ml-2 text-xs text-anthrazit/50">(Sie)</span>
) : null}
</p>
<p className="truncate text-sm text-anthrazit/60">{u.email}</p>
</div>
<div className="flex items-center gap-2">
<Badge>{ROLLE_LABEL[u.rolle] ?? u.rolle}</Badge>
<Badge>
{u.authTyp === "local"
? de.verwaltung.authLokal
: de.verwaltung.authAuthentik}
</Badge>
<Badge
className={
u.aktiv
? "border-bereit/30 bg-bereit/10 text-bereit"
: "border-anthrazit/30 bg-anthrazit/10 text-anthrazit"
}
>
{u.aktiv ? de.verwaltung.aktiv : de.verwaltung.inaktiv}
</Badge>
{u.aktiv ? (
<DeactivateUserButton
userId={u.id}
disabled={u.id === s.user.id}
/>
) : null}
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { de } from "@/lib/i18n/de";
import { assetStatusEnum } from "@/db/schema";
import {
setVehicleStatus,
deleteVehicle,
} from "@/server/actions/vehicles";
type Status = (typeof assetStatusEnum.enumValues)[number];
const STATUS_LABEL: Record<Status, string> = {
einsatzbereit: de.status.einsatzbereit,
wartung: de.status.wartung,
ausser_dienst: de.status.ausser_dienst,
};
/**
* Status-Umschaltung + Löschen eines Fahrzeugs (eigene Wehr). Beide rufen
* geschützte Server-Actions; Scope-Prüfung erfolgt serverseitig.
*/
export function VehicleControls({
vehicleId,
status,
}: {
vehicleId: string;
status: Status;
}) {
const router = useRouter();
const [pending, startTransition] = React.useTransition();
const [error, setError] = React.useState<string | null>(null);
function onStatus(next: Status) {
setError(null);
startTransition(async () => {
const res = await setVehicleStatus({ id: vehicleId, status: next });
if (!res.ok) {
setError(res.error);
return;
}
router.refresh();
});
}
function onDelete() {
if (!window.confirm(de.verwaltung.loeschenBestaetigen)) return;
setError(null);
startTransition(async () => {
const res = await deleteVehicle({ id: vehicleId });
if (!res.ok) {
setError(res.error);
return;
}
router.push("/verwaltung/fahrzeuge");
router.refresh();
});
}
return (
<div className="flex flex-wrap items-center gap-3 rounded border border-rand bg-white p-4">
<label className="text-sm font-medium text-anthrazit" htmlFor="vstatus">
{de.verwaltung.status}
</label>
<select
id="vstatus"
className="h-9 rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={status}
disabled={pending}
onChange={(e) => onStatus(e.target.value as Status)}
>
{assetStatusEnum.enumValues.map((v) => (
<option key={v} value={v}>
{STATUS_LABEL[v]}
</option>
))}
</select>
<span className="flex-1" />
<Button
type="button"
variant="outline"
size="sm"
disabled={pending}
onClick={onDelete}
>
{de.verwaltung.loeschen}
</Button>
{error ? <span className="w-full text-sm text-signal">{error}</span> : null}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { notFound } from "next/navigation";
import { requireWehrAdmin } from "@/lib/auth/guards";
import { getVehicleForBrigade } from "@/server/data/vehicles";
import {
getMerkmaleForTemplate,
getMerkmalValuesForEntity,
} from "@/server/data/merkmale";
import { de } from "@/lib/i18n/de";
import { VehicleForm } from "@/components/verwaltung/VehicleForm";
import { VehicleControls } from "./VehicleControls";
export default async function FahrzeugBearbeitenPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const s = await requireWehrAdmin();
const { id } = await params;
// Scoping: fremde/nicht existente Fahrzeuge -> 404 (kein Daten-Leak).
const vehicle = await getVehicleForBrigade(id, s.user.brigadeId);
if (!vehicle) notFound();
const defs = vehicle.templateId
? await getMerkmaleForTemplate(vehicle.templateId)
: [];
const werte = await getMerkmalValuesForEntity("vehicle", vehicle.id);
return (
<div className="space-y-6">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.fahrzeugBearbeiten}
</h1>
<VehicleControls vehicleId={vehicle.id} status={vehicle.status} />
<VehicleForm
mode="edit"
vehicleId={vehicle.id}
initial={{
name: vehicle.name,
funkrufname: vehicle.funkrufname ?? "",
notiz: vehicle.notiz ?? "",
}}
definitionen={defs}
vorhandeneWerte={werte}
/>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { requireWehrAdmin } from "@/lib/auth/guards";
import { listTemplates } from "@/server/data/vehicles";
import { getTemplateMerkmaleAction } from "@/server/actions/vehicles";
import { de } from "@/lib/i18n/de";
import { VehicleForm } from "@/components/verwaltung/VehicleForm";
export default async function FahrzeugNeuPage() {
await requireWehrAdmin();
const templates = await listTemplates();
return (
<div className="space-y-6">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.fahrzeugAnlegen}
</h1>
<VehicleForm
mode="create"
templates={templates}
loadTemplateMerkmale={getTemplateMerkmaleAction}
/>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import Link from "next/link";
import { requireWehrAdmin } from "@/lib/auth/guards";
import { listVehiclesForBrigade } from "@/server/data/vehicles";
import { de } from "@/lib/i18n/de";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/ui/badge";
export default async function FahrzeugeListePage() {
const s = await requireWehrAdmin();
const items = await listVehiclesForBrigade(s.user.brigadeId);
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.navFahrzeuge}
</h1>
<Button asChild>
<Link href="/verwaltung/fahrzeuge/neu">
{de.verwaltung.fahrzeugAnlegen}
</Link>
</Button>
</header>
{items.length === 0 ? (
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
{de.verwaltung.keineFahrzeuge}
</p>
) : (
<ul className="divide-y divide-rand rounded border border-rand bg-white">
{items.map((v) => (
<li
key={v.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<Link
href={`/verwaltung/fahrzeuge/${v.id}`}
className="font-medium text-navy hover:underline"
>
{v.name}
</Link>
<p className="truncate text-sm text-anthrazit/60">
{v.funkrufname ?? de.search.keinFunkrufname}
{v.templateName ? ` · ${v.templateName}` : ""}
</p>
</div>
<StatusBadge status={v.status} />
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { de } from "@/lib/i18n/de";
import { assetStatusEnum } from "@/db/schema";
import {
setEquipmentStatus,
deleteEquipment,
} from "@/server/actions/equipment";
type Status = (typeof assetStatusEnum.enumValues)[number];
const STATUS_LABEL: Record<Status, string> = {
einsatzbereit: de.status.einsatzbereit,
wartung: de.status.wartung,
ausser_dienst: de.status.ausser_dienst,
};
export function EquipmentControls({
equipmentId,
status,
}: {
equipmentId: string;
status: Status;
}) {
const router = useRouter();
const [pending, startTransition] = React.useTransition();
const [error, setError] = React.useState<string | null>(null);
function onStatus(next: Status) {
setError(null);
startTransition(async () => {
const res = await setEquipmentStatus({ id: equipmentId, status: next });
if (!res.ok) {
setError(res.error);
return;
}
router.refresh();
});
}
function onDelete() {
if (!window.confirm(de.verwaltung.loeschenBestaetigen)) return;
setError(null);
startTransition(async () => {
const res = await deleteEquipment({ id: equipmentId });
if (!res.ok) {
setError(res.error);
return;
}
router.push("/verwaltung/geraete");
router.refresh();
});
}
return (
<div className="flex flex-wrap items-center gap-3 rounded border border-rand bg-white p-4">
<label className="text-sm font-medium text-anthrazit" htmlFor="estatus">
{de.verwaltung.status}
</label>
<select
id="estatus"
className="h-9 rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={status}
disabled={pending}
onChange={(e) => onStatus(e.target.value as Status)}
>
{assetStatusEnum.enumValues.map((v) => (
<option key={v} value={v}>
{STATUS_LABEL[v]}
</option>
))}
</select>
<span className="flex-1" />
<Button
type="button"
variant="outline"
size="sm"
disabled={pending}
onClick={onDelete}
>
{de.verwaltung.loeschen}
</Button>
{error ? <span className="w-full text-sm text-signal">{error}</span> : null}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { notFound } from "next/navigation";
import { requireWehrAdmin } from "@/lib/auth/guards";
import {
getEquipmentForBrigade,
listCategories,
} from "@/server/data/equipment";
import { listVehiclesForBrigade } from "@/server/data/vehicles";
import {
getMerkmaleForCategory,
getMerkmalValuesForEntity,
} from "@/server/data/merkmale";
import { getCategoryMerkmaleAction } from "@/server/actions/equipment";
import { de } from "@/lib/i18n/de";
import { EquipmentForm } from "@/components/verwaltung/EquipmentForm";
import { EquipmentControls } from "./EquipmentControls";
export default async function GeraetBearbeitenPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const s = await requireWehrAdmin();
const { id } = await params;
// Scoping: fremde/nicht existente Geräte -> 404.
const item = await getEquipmentForBrigade(id, s.user.brigadeId);
if (!item) notFound();
const [categories, vehicles, defs, werte] = await Promise.all([
listCategories(),
listVehiclesForBrigade(s.user.brigadeId),
getMerkmaleForCategory(item.categoryId),
getMerkmalValuesForEntity("equipment", item.id),
]);
return (
<div className="space-y-6">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.geraetBearbeiten}
</h1>
<EquipmentControls equipmentId={item.id} status={item.status} />
<EquipmentForm
mode="edit"
equipmentId={item.id}
categories={categories}
vehicles={vehicles.map((v) => ({ id: v.id, name: v.name }))}
initial={{
name: item.name,
categoryId: item.categoryId,
vehicleId: item.vehicleId ?? "",
}}
definitionen={defs}
vorhandeneWerte={werte}
loadCategoryMerkmale={getCategoryMerkmaleAction}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { requireWehrAdmin } from "@/lib/auth/guards";
import { listCategories } from "@/server/data/equipment";
import { listVehiclesForBrigade } from "@/server/data/vehicles";
import { getCategoryMerkmaleAction } from "@/server/actions/equipment";
import { de } from "@/lib/i18n/de";
import { EquipmentForm } from "@/components/verwaltung/EquipmentForm";
export default async function GeraetNeuPage() {
const s = await requireWehrAdmin();
const [categories, vehicles] = await Promise.all([
listCategories(),
listVehiclesForBrigade(s.user.brigadeId),
]);
return (
<div className="space-y-6">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.geraetAnlegen}
</h1>
<EquipmentForm
mode="create"
categories={categories}
vehicles={vehicles.map((v) => ({ id: v.id, name: v.name }))}
loadCategoryMerkmale={getCategoryMerkmaleAction}
/>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import Link from "next/link";
import { requireWehrAdmin } from "@/lib/auth/guards";
import { listEquipmentForBrigade } from "@/server/data/equipment";
import { de } from "@/lib/i18n/de";
import { Button } from "@/components/ui/button";
import { StatusBadge, Badge } from "@/components/ui/badge";
export default async function GeraeteListePage() {
const s = await requireWehrAdmin();
const items = await listEquipmentForBrigade(s.user.brigadeId);
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.navGeraete}
</h1>
<Button asChild>
<Link href="/verwaltung/geraete/neu">
{de.verwaltung.geraetAnlegen}
</Link>
</Button>
</header>
{items.length === 0 ? (
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
{de.verwaltung.keineGeraete}
</p>
) : (
<ul className="divide-y divide-rand rounded border border-rand bg-white">
{items.map((e) => (
<li
key={e.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<Link
href={`/verwaltung/geraete/${e.id}`}
className="font-medium text-navy hover:underline"
>
{e.name}
</Link>
<p className="truncate text-sm text-anthrazit/60">
{e.categoryName} ·{" "}
{e.vehicleName ?? de.verwaltung.imGeraetehaus}
</p>
</div>
<div className="flex items-center gap-2">
{e.vehicleId ? null : (
<Badge>{de.verwaltung.imGeraetehaus}</Badge>
)}
<StatusBadge status={e.status} />
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { requireWehrAdmin } from "@/lib/auth/guards";
import { VerwaltungNav } from "@/components/verwaltung/VerwaltungNav";
/**
* Route-Group-Layout des Wehr-Bereichs.
*
* GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1+2): Der
* serverseitige Guard `requireWehrAdmin()` ist die ALLERERSTE Anweisung. Er
* leitet anonyme Aufrufe auf /login (redirect) um und verweigert allen außer
* `wehr_admin` (auch `wehr_read`) mit forbidden() -> 403. Jede Server Action
* wiederholt den Guard zusätzlich (Verteidigung in der Tiefe).
*/
export default async function VerwaltungLayout({
children,
}: {
children: React.ReactNode;
}) {
await requireWehrAdmin();
return (
<div>
<VerwaltungNav />
<main className="mx-auto max-w-5xl px-6 py-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { requireWehrAdmin } from "@/lib/auth/guards";
import { getBrigade } from "@/server/data/vehicles";
import { de } from "@/lib/i18n/de";
import { BrigadeProfileForm } from "@/components/verwaltung/BrigadeProfileForm";
export default async function ProfilPage() {
const s = await requireWehrAdmin();
const b = await getBrigade(s.user.brigadeId);
return (
<div className="space-y-6">
<header>
<h1 className="font-display text-2xl font-semibold text-navy">
{de.verwaltung.profilTitel}
</h1>
{b ? (
<p className="mt-1 text-sm text-anthrazit/70">{b.name}</p>
) : null}
</header>
<BrigadeProfileForm
initial={{
strasse: b?.strasse ?? "",
plz: b?.plz ?? "",
ort: b?.ort ?? "",
telefon: b?.telefon ?? "",
email: b?.email ?? "",
wehrfuehrer: b?.wehrfuehrer ?? "",
funkrufnameSchema: b?.funkrufnameSchema ?? "",
}}
/>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { requireSession } from "@/lib/auth/guards";
import { getWehrDetail } from "@/lib/detail/queries";
import { uuidSchema } from "@/lib/validation/common";
import { DetailHeader } from "@/components/detail/DetailHeader";
import { KontaktButton } from "@/components/kontakt/KontaktButton";
import { StatusBadge } from "@/components/ui/badge";
import { t } from "@/lib/i18n/de";
export default async function WehrDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
// Default-deny in der Tiefe (Querschnittsstandard 1): Guard als erste Zeile.
await requireSession();
const { id } = await params;
// Route-Param ist Nutzereingabe an der Grenze (Querschnittsstandard 4):
// nicht-UUID -> saubere deutsche 404 statt Postgres `invalid input syntax`.
const parsed = uuidSchema.safeParse(id);
if (!parsed.success) notFound();
const w = await getWehrDetail(parsed.data);
if (!w) notFound();
const adresse = [w.strasse, [w.plz, w.ort].filter(Boolean).join(" ")]
.filter((s) => s && s.trim() !== "")
.join(", ");
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6">
<DetailHeader kicker={w.art} titel={w.name} untertitel={adresse || null} />
<section className="rounded-md border border-rand bg-nebel/50 p-4">
<h2 className="text-sm font-semibold text-navy">{t("kontakt.titel")}</h2>
{w.wehrfuehrer ? (
<p className="mt-1 text-sm text-anthrazit/70">
{t("wehr.wehrfuehrer")}: {w.wehrfuehrer}
</p>
) : null}
<div className="mt-3">
<KontaktButton
telefon={w.telefon}
email={w.email}
subject={t("kontakt.betreff")}
/>
</div>
</section>
<section>
<h2 className="text-sm font-semibold text-navy">{t("detail.fahrzeuge")}</h2>
{w.fahrzeuge.length === 0 ? (
<p className="mt-2 text-sm text-anthrazit/60">
{t("detail.keineFahrzeuge")}
</p>
) : (
<ul className="mt-3 divide-y divide-rand/60">
{w.fahrzeuge.map((f) => (
<li key={f.id} className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0">
<Link
href={`/fahrzeuge/${f.id}`}
className="font-medium text-navy hover:underline"
>
{f.name}
</Link>
{f.funkrufname ? (
<p className="truncate text-xs text-anthrazit/60">
{f.funkrufname}
</p>
) : null}
</div>
<StatusBadge status={f.status} />
</li>
))}
</ul>
)}
</section>
<section>
<h2 className="text-sm font-semibold text-navy">
{t("detail.geraeteImHaus")}
</h2>
{w.geraeteImHaus.length === 0 ? (
<p className="mt-2 text-sm text-anthrazit/60">
{t("detail.keineGeraeteImHaus")}
</p>
) : (
<ul className="mt-3 divide-y divide-rand/60">
{w.geraeteImHaus.map((g) => (
<li key={g.id} className="flex items-center justify-between gap-3 py-2">
<Link
href={`/geraete/${g.id}`}
className="font-medium text-navy hover:underline"
>
{g.name}
</Link>
<StatusBadge status={g.status} />
</li>
))}
</ul>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import Link from "next/link";
import { StatusBadge } from "@/components/ui/badge";
import { t } from "@/lib/i18n/de";
import type { BeladungItem } from "@/lib/detail/queries";
/**
* Beladung eines Fahrzeugs: jedes Gerät verlinkt auf seine Detailseite
* (`/geraete/<id>`). Leere Liste => deutscher Empty-State.
*/
export function BeladungListe({ items }: { items: BeladungItem[] }) {
return (
<section>
<h2 className="text-sm font-semibold text-navy">{t("detail.beladung")}</h2>
{items.length === 0 ? (
<p className="mt-2 text-sm text-anthrazit/60">{t("detail.keineBeladung")}</p>
) : (
<ul className="mt-3 divide-y divide-rand/60">
{items.map((it) => (
<li
key={it.id}
className="flex items-center justify-between gap-3 py-2"
>
<div className="min-w-0">
<Link
href={`/geraete/${it.id}`}
className="font-medium text-navy hover:underline"
>
{it.name}
</Link>
<p className="truncate text-xs text-anthrazit/60">{it.kategorie}</p>
</div>
<StatusBadge status={it.status} />
</li>
))}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,38 @@
import { StatusBadge, type StatusKey } from "@/components/ui/badge";
/**
* Kopfzeile einer Detailseite: Kicker (Typ/Vorlage), Titel, optionaler
* Funkrufname/Untertitel und Status-Badge. Reiner Präsentations-Baustein.
*/
export function DetailHeader({
kicker,
titel,
untertitel,
status,
}: {
kicker?: string | null;
titel: string;
untertitel?: string | null;
status?: StatusKey;
}) {
return (
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-rand pb-4">
<div className="min-w-0">
{kicker ? (
<p className="text-xs font-medium uppercase tracking-wide text-anthrazit/60">
{kicker}
</p>
) : null}
<h1 className="text-2xl font-semibold text-navy">{titel}</h1>
{untertitel ? (
<p className="mt-0.5 text-sm text-anthrazit/70">{untertitel}</p>
) : null}
</div>
{status ? (
<div className="shrink-0">
<StatusBadge status={status} />
</div>
) : null}
</header>
);
}

View File

@@ -0,0 +1,39 @@
import { t } from "@/lib/i18n/de";
import { toEckdaten, type MerkmalRow } from "@/lib/detail/merkmale";
/**
* Zeigt die typisierten Eckdaten als Definitionsliste. Leere Liste => deutscher
* Empty-State (Querschnittsstandard 10). Die Formatierung (de-AT, NBSP, Ja/Nein,
* enum-Label, „–") übernimmt `toEckdaten`/`formatMerkmal`.
*/
export function EckdatenGrid({ rows }: { rows: MerkmalRow[] }) {
const eckdaten = toEckdaten(rows);
if (eckdaten.length === 0) {
return (
<section>
<h2 className="text-sm font-semibold text-navy">{t("detail.eckdaten")}</h2>
<p className="mt-2 text-sm text-anthrazit/60">{t("detail.keineEckdaten")}</p>
</section>
);
}
return (
<section>
<h2 className="text-sm font-semibold text-navy">{t("detail.eckdaten")}</h2>
<dl className="mt-3 grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2">
{eckdaten.map((e) => (
<div
key={e.merkmalId}
className="flex items-baseline justify-between gap-3 border-b border-rand/60 py-1.5"
>
<dt className="text-sm text-anthrazit/70">{e.label}</dt>
<dd className="text-sm font-medium tabular-nums text-anthrazit">
{e.wert}
</dd>
</div>
))}
</dl>
</section>
);
}

View File

@@ -0,0 +1,6 @@
/**
* Re-Export des kanonischen StatusBadge (Workstream 1, `@/components/ui/badge`).
* Workstream 8 listet `components/detail/StatusBadge`; um KEINE zweite Quelle
* für Status-Styling zu schaffen, re-exportieren wir den bestehenden Badge.
*/
export { StatusBadge, type StatusKey } from "@/components/ui/badge";

View File

@@ -0,0 +1,49 @@
import { t } from "@/lib/i18n/de";
/**
* Out-of-band Kontakt (kein Borrow-Workflow in v1). Rendert je nach
* vorhandenen Daten einen `tel:`- und/oder `mailto:`-Link. Telefonnummer wird
* für das `tel:`-Schema von Leerzeichen befreit; `mailto:` kann einen `subject`
* tragen. Sind beide leer, erscheint der deutsche Hinweistext.
*/
export function KontaktButton({
telefon,
email,
subject,
}: {
telefon?: string | null;
email?: string | null;
subject?: string;
}) {
const tel = telefon?.trim() ? telefon.replace(/\s+/g, "") : null;
const mail = email?.trim() ? email.trim() : null;
if (!tel && !mail) {
return <p className="text-sm text-anthrazit/60">{t("kontakt.keine")}</p>;
}
const mailHref = mail
? `mailto:${mail}${subject ? `?subject=${encodeURIComponent(subject)}` : ""}`
: null;
return (
<div className="flex flex-wrap gap-2">
{tel ? (
<a
href={`tel:${tel}`}
className="inline-flex items-center rounded-sm border border-rand bg-nebel px-3 py-1.5 text-sm font-medium text-navy hover:bg-rand/40"
>
{t("kontakt.anrufen")}
</a>
) : null}
{mailHref ? (
<a
href={mailHref}
className="inline-flex items-center rounded-sm border border-rand bg-nebel px-3 py-1.5 text-sm font-medium text-navy hover:bg-rand/40"
>
{t("kontakt.email")}
</a>
) : null}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { t } from "@/lib/i18n/de";
import { KontaktButton } from "./KontaktButton";
import type { BrigadeCard } from "@/lib/detail/queries";
/**
* Verlinktes Wehr-Kärtchen für Fahrzeug-/Gerät-Detailseiten: Name, Adresse,
* Wehrführer und out-of-band Kontakt. `verlinkt` steuert, ob der Name auf die
* Wehr-Detailseite zeigt (auf der Wehr-Seite selbst nicht sinnvoll).
*/
export function WehrCard({
wehr,
verlinkt = true,
}: {
wehr: BrigadeCard;
verlinkt?: boolean;
}) {
const adresse = [wehr.strasse, [wehr.plz, wehr.ort].filter(Boolean).join(" ")]
.filter((s) => s && s.trim() !== "")
.join(", ");
return (
<section className="rounded-md border border-rand bg-nebel/50 p-4">
<h2 className="text-sm font-semibold text-navy">{t("kontakt.titel")}</h2>
<div className="mt-2">
{verlinkt ? (
<Link
href={`/wehren/${wehr.id}`}
className="font-medium text-navy hover:underline"
>
{wehr.name}
</Link>
) : (
<p className="font-medium text-navy">{wehr.name}</p>
)}
{adresse ? (
<p className="text-sm text-anthrazit/70">{adresse}</p>
) : null}
{wehr.wehrfuehrer ? (
<p className="mt-1 text-sm text-anthrazit/70">
{t("wehr.wehrfuehrer")}: {wehr.wehrfuehrer}
</p>
) : null}
</div>
<div className="mt-3">
<KontaktButton
telefon={wehr.telefon}
email={wehr.email}
subject={t("kontakt.betreff")}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { de } from "@/lib/i18n/de";
import { updateBrigadeProfile } from "@/server/actions/brigade";
export interface BrigadeProfileInitial {
strasse: string;
plz: string;
ort: string;
telefon: string;
email: string;
wehrfuehrer: string;
funkrufnameSchema: string;
}
/**
* Profilformular der eigenen Wehr. Speichert Stamm-/Kontaktdaten; die Server-
* Action geokodiert die Adresse inline. Schlägt das Geocoding fehl, werden die
* Daten dennoch gespeichert und ein Warnhinweis angezeigt.
*/
export function BrigadeProfileForm({
initial,
}: {
initial: BrigadeProfileInitial;
}) {
const router = useRouter();
const [error, setError] = React.useState<string | null>(null);
const [info, setInfo] = React.useState<{ warnung: boolean } | null>(null);
const [pending, startTransition] = React.useTransition();
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setInfo(null);
const fd = new FormData(e.currentTarget);
const payload = {
strasse: String(fd.get("strasse") ?? ""),
plz: String(fd.get("plz") ?? ""),
ort: String(fd.get("ort") ?? ""),
telefon: String(fd.get("telefon") ?? ""),
email: String(fd.get("email") ?? ""),
wehrfuehrer: String(fd.get("wehrfuehrer") ?? ""),
funkrufnameSchema: String(fd.get("funkrufnameSchema") ?? ""),
};
startTransition(async () => {
const res = await updateBrigadeProfile(payload);
if (!res.ok) {
setError(res.error);
return;
}
setInfo({ warnung: res.geocodeWarnung });
router.refresh();
});
}
return (
<form onSubmit={onSubmit} className="max-w-2xl space-y-5">
<div className="grid gap-1.5 sm:grid-cols-2 sm:gap-4">
<div className="grid gap-1.5 sm:col-span-2">
<Label htmlFor="strasse">{de.verwaltung.strasse}</Label>
<Input id="strasse" name="strasse" required defaultValue={initial.strasse} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="plz">{de.verwaltung.plz}</Label>
<Input id="plz" name="plz" required defaultValue={initial.plz} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="ort">{de.verwaltung.ort}</Label>
<Input id="ort" name="ort" required defaultValue={initial.ort} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="telefon">{de.verwaltung.telefon}</Label>
<Input id="telefon" name="telefon" defaultValue={initial.telefon} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="email">{de.verwaltung.email}</Label>
<Input id="email" name="email" type="email" defaultValue={initial.email} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="wehrfuehrer">{de.verwaltung.wehrfuehrer}</Label>
<Input id="wehrfuehrer" name="wehrfuehrer" defaultValue={initial.wehrfuehrer} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="funkrufnameSchema">
{de.verwaltung.funkrufnameSchema}
</Label>
<Input
id="funkrufnameSchema"
name="funkrufnameSchema"
defaultValue={initial.funkrufnameSchema}
/>
</div>
</div>
{error ? (
<p role="alert" className="text-sm text-signal">
{error}
</p>
) : null}
{info ? (
<p
className={
info.warnung
? "rounded border border-wartung/40 bg-wartung/5 px-3 py-2 text-sm text-wartung"
: "rounded border border-bereit/40 bg-bereit/5 px-3 py-2 text-sm text-anthrazit"
}
>
{info.warnung
? de.verwaltung.geocodeWarnung
: `${de.verwaltung.profilGespeichert} ${de.verwaltung.geocodeOk}`}
</p>
) : null}
<Button type="submit" disabled={pending}>
{de.verwaltung.speichern}
</Button>
</form>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { de } from "@/lib/i18n/de";
import { createBrigadeUser, deactivateBrigadeUser } from "@/server/actions/brigade-users";
/**
* Formular zum Anlegen eines Wehr-Benutzers (lokales Konto). Die Rolle ist auf
* Wehr-Admin/Lesend beschränkt. Nach Erfolg wird das Einmal-Passwort genau
* einmal angezeigt.
*/
export function BrigadeUserForm() {
const router = useRouter();
const [error, setError] = React.useState<string | null>(null);
const [tempPassword, setTempPassword] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const fd = new FormData(form);
const payload = {
email: String(fd.get("email") ?? ""),
name: String(fd.get("name") ?? ""),
rolle: String(fd.get("rolle") ?? "wehr_read"),
};
startTransition(async () => {
const res = await createBrigadeUser(payload);
if (!res.ok) {
setError(res.error);
return;
}
setTempPassword(res.tempPassword);
form.reset();
router.refresh();
});
}
return (
<form
onSubmit={onSubmit}
className="space-y-4 rounded border border-rand bg-white p-5"
>
<div className="grid gap-1.5 sm:grid-cols-2 sm:gap-4">
<div className="grid gap-1.5">
<Label htmlFor="user-name">{de.verwaltung.name}</Label>
<Input id="user-name" name="name" required />
</div>
<div className="grid gap-1.5">
<Label htmlFor="user-email">{de.verwaltung.email}</Label>
<Input id="user-email" name="email" type="email" required />
</div>
<div className="grid gap-1.5">
<Label htmlFor="user-rolle">{de.verwaltung.rolle}</Label>
<select
id="user-rolle"
name="rolle"
defaultValue="wehr_read"
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
>
<option value="wehr_read">{de.verwaltung.rolleRead}</option>
<option value="wehr_admin">{de.verwaltung.rolleAdmin}</option>
</select>
</div>
</div>
{error ? (
<p role="alert" className="text-sm text-signal">
{error}
</p>
) : null}
{tempPassword ? (
<div className="rounded border border-bereit/40 bg-bereit/5 p-4">
<p className="text-sm font-medium text-anthrazit/70">
{de.verwaltung.tempPasswort}
</p>
<code className="mt-1 block rounded border border-rand bg-white px-3 py-2 font-mono text-lg tracking-wide text-navy">
{tempPassword}
</code>
</div>
) : null}
<Button type="submit" disabled={pending}>
{de.verwaltung.benutzerAnlegen}
</Button>
</form>
);
}
/**
* Knopf zum Deaktivieren eines Benutzers. Eigener Client-Knopf (Bestätigung +
* Transition). Selbst-Deaktivierung verhindert die Server-Action zusätzlich.
*/
export function DeactivateUserButton({
userId,
disabled,
}: {
userId: string;
disabled?: boolean;
}) {
const router = useRouter();
const [pending, startTransition] = React.useTransition();
const [error, setError] = React.useState<string | null>(null);
function onClick() {
if (!window.confirm(de.verwaltung.loeschenBestaetigen)) return;
setError(null);
startTransition(async () => {
const res = await deactivateBrigadeUser({ userId });
if (!res.ok) {
setError(res.error);
return;
}
router.refresh();
});
}
return (
<span className="inline-flex flex-col items-end gap-1">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || pending}
onClick={onClick}
>
{de.verwaltung.deaktivieren}
</Button>
{error ? <span className="text-xs text-signal">{error}</span> : null}
</span>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { de } from "@/lib/i18n/de";
import {
MerkmalValueEditor,
initMerkmalWerte,
werteToList,
type MerkmalWerteState,
} from "./MerkmalValueEditor";
import type {
MerkmalDefinition,
MerkmalValueInput,
} from "@/lib/merkmale/types";
import { createEquipment, updateEquipment } from "@/server/actions/equipment";
export interface CategoryOption {
id: string;
name: string;
}
export interface VehicleOption {
id: string;
name: string;
}
interface CreateProps {
mode: "create";
categories: CategoryOption[];
vehicles: VehicleOption[];
loadCategoryMerkmale: (categoryId: string) => Promise<MerkmalDefinition[]>;
}
interface EditProps {
mode: "edit";
equipmentId: string;
categories: CategoryOption[];
vehicles: VehicleOption[];
initial: { name: string; categoryId: string; vehicleId: string };
definitionen: MerkmalDefinition[];
vorhandeneWerte: MerkmalValueInput[];
loadCategoryMerkmale: (categoryId: string) => Promise<MerkmalDefinition[]>;
}
type Props = CreateProps | EditProps;
export function EquipmentForm(props: Props) {
const router = useRouter();
const [error, setError] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
const [categoryId, setCategoryId] = React.useState<string>(
props.mode === "edit" ? props.initial.categoryId : "",
);
const [vehicleId, setVehicleId] = React.useState<string>(
props.mode === "edit" ? props.initial.vehicleId : "",
);
const [defs, setDefs] = React.useState<MerkmalDefinition[]>(
props.mode === "edit" ? props.definitionen : [],
);
const [werte, setWerte] = React.useState<MerkmalWerteState>(
props.mode === "edit"
? initMerkmalWerte(props.definitionen, props.vorhandeneWerte)
: {},
);
async function onCategoryChange(id: string) {
setCategoryId(id);
if (!id) {
setDefs([]);
setWerte({});
return;
}
const loaded = await props.loadCategoryMerkmale(id);
setDefs(loaded);
setWerte(initMerkmalWerte(loaded));
}
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const fd = new FormData(e.currentTarget);
const base = {
name: String(fd.get("name") ?? ""),
categoryId,
vehicleId: vehicleId || undefined,
};
const liste = werteToList(werte);
startTransition(async () => {
const res =
props.mode === "create"
? await createEquipment(base, liste)
: await updateEquipment({ ...base, id: props.equipmentId }, liste);
if (!res.ok) {
setError(res.error);
return;
}
router.push("/verwaltung/geraete");
router.refresh();
});
}
const initialName = props.mode === "edit" ? props.initial.name : "";
return (
<form onSubmit={onSubmit} className="max-w-2xl space-y-6">
<div className="grid gap-1.5">
<Label htmlFor="name">{de.verwaltung.name}</Label>
<Input id="name" name="name" required defaultValue={initialName} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="categoryId">{de.verwaltung.kategorie}</Label>
<select
id="categoryId"
name="categoryId"
required
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={categoryId}
onChange={(e) => void onCategoryChange(e.target.value)}
>
<option value=""></option>
{props.categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="vehicleId">{de.verwaltung.zuordnung}</Label>
<select
id="vehicleId"
name="vehicleId"
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={vehicleId}
onChange={(e) => setVehicleId(e.target.value)}
>
<option value="">{de.verwaltung.imGeraetehaus}</option>
{props.vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
<fieldset className="rounded border border-rand bg-white p-5">
<legend className="px-1 text-sm font-semibold text-navy">
{de.verwaltung.merkmale}
</legend>
<MerkmalValueEditor
definitionen={defs}
werte={werte}
onChange={setWerte}
/>
</fieldset>
{error ? (
<p role="alert" className="text-sm text-signal">
{error}
</p>
) : null}
<div className="flex gap-2">
<Button type="submit" disabled={pending}>
{de.verwaltung.speichern}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push("/verwaltung/geraete")}
>
{de.verwaltung.abbrechen}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { de } from "@/lib/i18n/de";
import type {
MerkmalDefinition,
MerkmalValueInput,
} from "@/lib/merkmale/types";
export type MerkmalWerteState = Record<string, MerkmalValueInput>;
/**
* Leitet den initialen Editor-Zustand aus Definitionen + bereits gespeicherten
* Werten ab. Vorgabewerte werden typgerecht aus den drei Spalten gelesen, falls
* kein gespeicherter Wert existiert.
*/
export function initMerkmalWerte(
defs: MerkmalDefinition[],
vorhanden: MerkmalValueInput[] = [],
): MerkmalWerteState {
const byId = new Map(vorhanden.map((v) => [v.merkmalId, v]));
const state: MerkmalWerteState = {};
for (const d of defs) {
const existing = byId.get(d.merkmalId);
state[d.merkmalId] = existing ?? {
merkmalId: d.merkmalId,
num: d.vorgabeNum,
text: d.vorgabeText,
bool: d.vorgabeBool,
};
}
return state;
}
interface Props {
definitionen: MerkmalDefinition[];
werte: MerkmalWerteState;
onChange: (next: MerkmalWerteState) => void;
}
/**
* Typisierter Merkmal-Editor: rendert je Merkmal das passende Eingabefeld
* (Zahl/Auswahl/Schalter/Text). Pflichtmerkmale sind markiert. Der Editor ist
* kontrolliert; die Server-Action validiert die Werte erneut (Default-deny).
*/
export function MerkmalValueEditor({ definitionen, werte, onChange }: Props) {
if (definitionen.length === 0) {
return (
<p className="text-sm text-anthrazit/60">{de.verwaltung.keineMerkmale}</p>
);
}
function update(merkmalId: string, patch: Partial<MerkmalValueInput>) {
onChange({
...werte,
[merkmalId]: { ...werte[merkmalId], merkmalId, ...patch },
});
}
return (
<div className="space-y-4">
{definitionen.map((d) => {
const v = werte[d.merkmalId] ?? { merkmalId: d.merkmalId };
const fieldId = `merkmal-${d.merkmalId}`;
return (
<div key={d.merkmalId} className="grid gap-1.5">
<Label htmlFor={fieldId}>
{d.name}
{d.einheit ? (
<span className="text-anthrazit/50"> ({d.einheit})</span>
) : null}
{d.pflicht ? (
<span className="ml-1 text-signal" aria-hidden>
*
</span>
) : null}
</Label>
{d.typ === "number" ? (
<Input
id={fieldId}
name={fieldId}
type="number"
inputMode="decimal"
step="any"
required={d.pflicht}
value={v.num ?? ""}
onChange={(e) =>
update(d.merkmalId, {
num: e.target.value === "" ? null : Number(e.target.value),
})
}
/>
) : d.typ === "boolean" ? (
<select
id={fieldId}
name={fieldId}
required={d.pflicht}
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={v.bool == null ? "" : v.bool ? "true" : "false"}
onChange={(e) =>
update(d.merkmalId, {
bool:
e.target.value === ""
? null
: e.target.value === "true",
})
}
>
<option value="">{de.search.egal}</option>
<option value="true">{de.search.ja}</option>
<option value="false">{de.search.nein}</option>
</select>
) : d.typ === "enum" ? (
<select
id={fieldId}
name={fieldId}
required={d.pflicht}
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
value={v.text ?? ""}
onChange={(e) =>
update(d.merkmalId, {
text: e.target.value === "" ? null : e.target.value,
})
}
>
<option value=""></option>
{d.optionen.map((o) => (
<option key={o.wert} value={o.wert}>
{o.label}
</option>
))}
</select>
) : (
<Input
id={fieldId}
name={fieldId}
type="text"
required={d.pflicht}
value={v.text ?? ""}
onChange={(e) =>
update(d.merkmalId, {
text: e.target.value === "" ? null : e.target.value,
})
}
/>
)}
</div>
);
})}
</div>
);
}
/** Wandelt den Editor-Zustand in die Liste der Server-Eingaben um. */
export function werteToList(state: MerkmalWerteState): MerkmalValueInput[] {
return Object.values(state);
}

View File

@@ -0,0 +1,44 @@
"use client";
import { Label } from "@/components/ui/label";
import { de } from "@/lib/i18n/de";
export interface TemplateOption {
id: string;
code: string;
name: string;
}
interface Props {
templates: TemplateOption[];
value: string;
onChange: (id: string) => void;
disabled?: boolean;
}
/**
* Auswahl einer Fahrzeug-Vorlage (oder „frei"). Reine Präsentation; das
* Nachladen der Merkmale übernimmt das Formular.
*/
export function TemplatePicker({ templates, value, onChange, disabled }: Props) {
return (
<div className="grid gap-1.5">
<Label htmlFor="templateId">{de.verwaltung.vorlage}</Label>
<select
id="templateId"
name="templateId"
disabled={disabled}
className="h-10 w-full rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy disabled:opacity-50"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{de.verwaltung.keineVorlage}</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.code} {t.name}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,169 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { de } from "@/lib/i18n/de";
import {
MerkmalValueEditor,
initMerkmalWerte,
werteToList,
type MerkmalWerteState,
} from "./MerkmalValueEditor";
import { TemplatePicker } from "./TemplatePicker";
import type {
MerkmalDefinition,
MerkmalValueInput,
} from "@/lib/merkmale/types";
import { createVehicle, updateVehicle } from "@/server/actions/vehicles";
export interface VehicleFormTemplate {
id: string;
code: string;
name: string;
}
interface CreateProps {
mode: "create";
templates: VehicleFormTemplate[];
/** Lädt die Merkmal-Definitionen einer Vorlage (geschützte Server-Action). */
loadTemplateMerkmale: (templateId: string) => Promise<MerkmalDefinition[]>;
}
interface EditProps {
mode: "edit";
vehicleId: string;
initial: {
name: string;
funkrufname: string;
notiz: string;
};
definitionen: MerkmalDefinition[];
vorhandeneWerte: MerkmalValueInput[];
}
type Props = CreateProps | EditProps;
export function VehicleForm(props: Props) {
const router = useRouter();
const [error, setError] = React.useState<string | null>(null);
const [pending, startTransition] = React.useTransition();
const [templateId, setTemplateId] = React.useState<string>("");
const [defs, setDefs] = React.useState<MerkmalDefinition[]>(
props.mode === "edit" ? props.definitionen : [],
);
const [werte, setWerte] = React.useState<MerkmalWerteState>(
props.mode === "edit"
? initMerkmalWerte(props.definitionen, props.vorhandeneWerte)
: {},
);
async function onTemplateChange(id: string) {
setTemplateId(id);
if (props.mode !== "create") return;
if (!id) {
setDefs([]);
setWerte({});
return;
}
const loaded = await props.loadTemplateMerkmale(id);
setDefs(loaded);
setWerte(initMerkmalWerte(loaded));
}
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
const fd = new FormData(e.currentTarget);
const base = {
name: String(fd.get("name") ?? ""),
funkrufname: String(fd.get("funkrufname") ?? ""),
notiz: String(fd.get("notiz") ?? ""),
};
const liste = werteToList(werte);
startTransition(async () => {
const res =
props.mode === "create"
? await createVehicle({ ...base, templateId: templateId || undefined }, liste)
: await updateVehicle({ ...base, id: props.vehicleId }, liste);
if (!res.ok) {
setError(res.error);
return;
}
router.push("/verwaltung/fahrzeuge");
router.refresh();
});
}
const initial = props.mode === "edit" ? props.initial : undefined;
return (
<form onSubmit={onSubmit} className="max-w-2xl space-y-6">
{props.mode === "create" ? (
<TemplatePicker
templates={props.templates}
value={templateId}
onChange={(id) => void onTemplateChange(id)}
/>
) : null}
<div className="grid gap-1.5">
<Label htmlFor="name">{de.verwaltung.name}</Label>
<Input
id="name"
name="name"
required
defaultValue={initial?.name ?? ""}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="funkrufname">{de.verwaltung.funkrufname}</Label>
<Input
id="funkrufname"
name="funkrufname"
defaultValue={initial?.funkrufname ?? ""}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="notiz">{de.verwaltung.notiz}</Label>
<Input id="notiz" name="notiz" defaultValue={initial?.notiz ?? ""} />
</div>
<fieldset className="rounded border border-rand bg-white p-5">
<legend className="px-1 text-sm font-semibold text-navy">
{de.verwaltung.merkmale}
</legend>
<MerkmalValueEditor
definitionen={defs}
werte={werte}
onChange={setWerte}
/>
</fieldset>
{error ? (
<p role="alert" className="text-sm text-signal">
{error}
</p>
) : null}
<div className="flex gap-2">
<Button type="submit" disabled={pending}>
{de.verwaltung.speichern}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push("/verwaltung/fahrzeuge")}
>
{de.verwaltung.abbrechen}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { de } from "@/lib/i18n/de";
/**
* Sub-Navigation des Wehr-Bereichs. Client-Komponente nur wegen `usePathname`
* (aktiver Zustand). Keine Geschäftslogik. Spiegelt die Admin-Navigation für
* ein konsistentes Erscheinungsbild.
*/
const ITEMS = [
{ href: "/verwaltung/profil", label: de.verwaltung.navProfil },
{ href: "/verwaltung/fahrzeuge", label: de.verwaltung.navFahrzeuge },
{ href: "/verwaltung/geraete", label: de.verwaltung.navGeraete },
{ href: "/verwaltung/benutzer", label: de.verwaltung.navBenutzer },
] as const;
export function VerwaltungNav() {
const pathname = usePathname();
return (
<nav
aria-label="Wehr-Verwaltungsnavigation"
className="border-b border-rand bg-white"
>
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-1 px-6 py-2">
<span className="mr-4 font-display text-sm font-semibold text-navy">
{de.verwaltung.titel}
</span>
{ITEMS.map((item) => {
const active = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={cn(
"rounded px-3 py-1.5 text-sm font-medium transition-colors",
active
? "bg-navy text-white"
: "text-anthrazit/80 hover:bg-nebel hover:text-anthrazit",
)}
>
{item.label}
</Link>
);
})}
</div>
</nav>
);
}

View File

@@ -0,0 +1,34 @@
/**
* Geräte-Kategorien als Seed-Daten (Workstream 9).
*
* Abgeleitet aus den Beladungs-Highlights in
* docs/reference/fahrzeug-katalog-noelfv.md, Abschnitt 3.
* Natural Key ist `name` (UNIQUE in `equipment_categories`).
*
* `merkmalSlugs` verweist optional auf Merkmale aus dem Katalog mit
* Geltungsbereich `equipment`/`both` (z. B. hydraulischer Rettungssatz).
*/
export interface EquipmentCategorySeed {
name: string;
reihenfolge: number;
merkmalSlugs?: string[];
}
export const EQUIPMENT_CATEGORIES: EquipmentCategorySeed[] = [
{ name: "Löschgeräte", reihenfolge: 0 },
{ name: "Schläuche & Armaturen", reihenfolge: 1 },
{ name: "Atemschutz", reihenfolge: 2 },
{
name: "Technische Rettung",
reihenfolge: 3,
merkmalSlugs: ["hydraulischer_rettungssatz"],
},
{ name: "Beleuchtung & Stromerzeugung", reihenfolge: 4 },
{ name: "Zug- & Anschlagmittel", reihenfolge: 5 },
{ name: "Schadstoff & Gefahrgut", reihenfolge: 6 },
{ name: "Atemluftversorgung", reihenfolge: 7 },
{ name: "Logistik & Ladungssicherung", reihenfolge: 8 },
{ name: "Sanität & Erstversorgung", reihenfolge: 9 },
{ name: "Werkzeug & Räumgerät", reihenfolge: 10 },
];

View File

@@ -0,0 +1,273 @@
/**
* Merkmal-Katalog als Seed-Daten (Workstream 9).
*
* Abgeleitet aus docs/reference/fahrzeug-katalog-noelfv.md, Abschnitt 2.
* Genau 34 Merkmale — "Funkrufname" ist eine Spalte auf `vehicles`,
* KEIN Merkmal (siehe Plan, Phase 1).
*
* `slug` ist der Idempotenz-Key (UNIQUE in `merkmale`). Enum-Merkmale tragen
* ihre `optionen` (Werte sind die slugartigen DB-Werte, `label` die Anzeige).
*/
export type MerkmalTyp = "number" | "enum" | "boolean" | "text";
export type Geltungsbereich = "vehicle" | "equipment" | "both";
export interface MerkmalOptionSeed {
wert: string;
label: string;
reihenfolge: number;
}
export interface MerkmalSeed {
slug: string;
name: string;
typ: MerkmalTyp;
einheit?: string;
geltungsbereich: Geltungsbereich;
optionen?: MerkmalOptionSeed[];
}
export const MERKMALE: MerkmalSeed[] = [
{
slug: "loeschwassertank",
name: "Löschwassertank",
typ: "number",
einheit: "l",
geltungsbereich: "vehicle",
},
{
slug: "schaummitteltank",
name: "Schaummitteltank",
typ: "number",
einheit: "l",
geltungsbereich: "vehicle",
},
{
slug: "schaumzumischung",
name: "Schaumzumischung",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "pulverloeschanlage",
name: "Pulverlöschanlage",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "pulvermenge",
name: "Pulvermenge",
typ: "number",
einheit: "kg",
geltungsbereich: "vehicle",
},
{
slug: "feuerloeschpumpe_typ",
name: "Feuerlöschpumpe (Typ)",
typ: "enum",
geltungsbereich: "vehicle",
optionen: [
{ wert: "keine", label: "keine", reihenfolge: 0 },
{ wert: "fpn_10_750", label: "FPN 10-750", reihenfolge: 1 },
{ wert: "fpn_10_1000", label: "FPN 10-1000", reihenfolge: 2 },
{ wert: "fpn_10_2000", label: "FPN 10-2000", reihenfolge: 3 },
{ wert: "fpn_10_3000", label: "FPN 10-3000", reihenfolge: 4 },
{ wert: "fpn_10_6000", label: "FPN 10-6000", reihenfolge: 5 },
{ wert: "fph_40_250", label: "FPH 40-250", reihenfolge: 6 },
{ wert: "tragkraftspritze", label: "Tragkraftspritze", reihenfolge: 7 },
],
},
{
slug: "pumpen_foerderleistung",
name: "Pumpen-Förderleistung",
typ: "number",
einheit: "l/min",
geltungsbereich: "vehicle",
},
{
slug: "schnellangriffseinrichtung",
name: "Schnellangriffseinrichtung",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "wasserwerfer",
name: "Wasserwerfer",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "wasserwerfer_foerderstrom",
name: "Wasserwerfer-Förderstrom",
typ: "number",
einheit: "l/min",
geltungsbereich: "vehicle",
},
{
slug: "besatzung_sitzplaetze",
name: "Besatzung / Sitzplätze",
typ: "number",
einheit: "Plätze",
geltungsbereich: "vehicle",
},
{
slug: "zulaessiges_gesamtgewicht",
name: "Zulässiges Gesamtgewicht",
typ: "number",
einheit: "t",
geltungsbereich: "vehicle",
},
{
slug: "anzahl_achsen",
name: "Anzahl Achsen",
typ: "enum",
geltungsbereich: "vehicle",
optionen: [
{ wert: "2", label: "2", reihenfolge: 0 },
{ wert: "3", label: "3", reihenfolge: 1 },
{ wert: "4", label: "4", reihenfolge: 2 },
],
},
{
slug: "allradantrieb",
name: "Allradantrieb",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "motorleistung",
name: "Motorleistung",
typ: "number",
einheit: "kW",
geltungsbereich: "vehicle",
},
{
slug: "laenge",
name: "Länge",
typ: "number",
einheit: "mm",
geltungsbereich: "vehicle",
},
{
slug: "breite",
name: "Breite",
typ: "number",
einheit: "mm",
geltungsbereich: "vehicle",
},
{
slug: "hoehe",
name: "Höhe",
typ: "number",
einheit: "mm",
geltungsbereich: "vehicle",
},
{
slug: "stromerzeuger_bauart",
name: "Stromerzeuger-Bauart",
typ: "enum",
geltungsbereich: "vehicle",
optionen: [
{ wert: "keiner", label: "keiner", reihenfolge: 0 },
{ wert: "tragbar", label: "tragbar", reihenfolge: 1 },
{ wert: "einbaugenerator", label: "Einbaugenerator", reihenfolge: 2 },
],
},
{
slug: "stromerzeuger_nennleistung",
name: "Stromerzeuger-Nennleistung",
typ: "number",
einheit: "kVA",
geltungsbereich: "vehicle",
},
{
slug: "seilwinde",
name: "Seilwinde",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "seilwinden_zugkraft",
name: "Seilwinden-Zugkraft",
typ: "number",
einheit: "kN",
geltungsbereich: "vehicle",
},
{
slug: "lichtmast",
name: "Lichtmast",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "hydraulischer_rettungssatz",
name: "Hydraulischer Rettungssatz",
typ: "boolean",
geltungsbereich: "both",
},
{
slug: "ladekran",
name: "Ladekran",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "kran_hubmoment",
name: "Kran-Hubmoment",
typ: "number",
einheit: "kNm",
geltungsbereich: "vehicle",
},
{
slug: "ladebordwand",
name: "Ladebordwand",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "ladebordwand_traglast",
name: "Ladebordwand-Traglast",
typ: "number",
einheit: "kg",
geltungsbereich: "vehicle",
},
{
slug: "wechselladeeinrichtung",
name: "Wechselladeeinrichtung",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "atemluftkompressor_lieferleistung",
name: "Atemluftkompressor-Lieferleistung",
typ: "number",
einheit: "l/min",
geltungsbereich: "vehicle",
},
{
slug: "atemluft_speichervolumen",
name: "Atemluft-Speichervolumen",
typ: "number",
einheit: "l",
geltungsbereich: "vehicle",
},
{
slug: "anhaengekupplung",
name: "Anhängekupplung",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "funkanlage",
name: "Funkanlage",
typ: "boolean",
geltungsbereich: "vehicle",
},
{
slug: "baujahr",
name: "Baujahr",
typ: "number",
einheit: "Jahr",
geltungsbereich: "vehicle",
},
];

View File

@@ -0,0 +1,206 @@
/**
* Fahrzeug-Vorlagen als Seed-Daten (Workstream 9).
*
* Abgeleitet aus docs/reference/fahrzeug-katalog-noelfv.md, Abschnitte 1 und 4.
* Genau 11 Vorlagen. HLF 4-U ist KEINE eigene Vorlage, sondern ein (offener)
* Alias auf HLF 4 mit Pulver-Pflichtmerkmalen.
*
* Aliasse leben in `vehicle_template_aliasse` mit `bestaetigt`-Flag:
* - RLF 2000 / RLFA 2000 (HLF 2) = bestätigt
* - RLF 2000-4000 / RLFA 2000-4000 (HLF 3) = bestätigt
* - alle anderen = offen (bestaetigt=false)
* KEIN HLFA-Alias: Die Allrad-Namensregel ("A" eingeschoben) ist eine
* Laufzeitregel im Such-Workstream + das Merkmal `allradantrieb`.
*
* Vorgabewerte werden typgerecht in genau eine der drei Spalten geschrieben.
*/
export interface AliasSeed {
alias: string;
bestaetigt: boolean;
}
export interface TemplateMerkmalSeed {
slug: string;
vorgabewertNum?: number;
vorgabewertText?: string;
vorgabewertBool?: boolean;
pflicht?: boolean;
}
export interface VehicleTemplateSeed {
code: string;
name: string;
beschreibung?: string;
aliasse: AliasSeed[];
merkmale: TemplateMerkmalSeed[];
}
export const VEHICLE_TEMPLATES: VehicleTemplateSeed[] = [
{
code: "HLF 1",
name: "Hilfeleistungsfahrzeug 1",
beschreibung:
"Brandbekämpfung/Löschwasserförderung mit Tragkraftspritze plus einfache technische Hilfeleistung (NÖ LFV-RL FA 01).",
aliasse: [
{ alias: "KLF", bestaetigt: false },
{ alias: "KLFA", bestaetigt: false },
],
merkmale: [
{ slug: "feuerloeschpumpe_typ", vorgabewertText: "tragkraftspritze", pflicht: true },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "HLF 1 W",
name: "Hilfeleistungsfahrzeug 1 Wasser",
beschreibung:
"Wasserführendes HLF 1 mit Tank, Einbaupumpe und Schnellangriff (NÖ LFV-RL FA 01/W).",
aliasse: [
{ alias: "KLFA-W", bestaetigt: false },
{ alias: "kleines LFA mit Tank", bestaetigt: false },
],
merkmale: [
{ slug: "loeschwassertank", vorgabewertNum: 800, pflicht: true },
{ slug: "schnellangriffseinrichtung", vorgabewertBool: true, pflicht: true },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "HLF 2",
name: "Hilfeleistungsfahrzeug 2",
beschreibung:
"Brandbekämpfung und technische Einsatzleistung, Tank 8002.000 l (NÖ LFV-RL FA 02).",
aliasse: [
{ alias: "RLF 2000", bestaetigt: true },
{ alias: "RLFA 2000", bestaetigt: true },
{ alias: "LF", bestaetigt: false },
{ alias: "LFA", bestaetigt: false },
{ alias: "TLFA 2000", bestaetigt: false },
],
merkmale: [
{ slug: "feuerloeschpumpe_typ", vorgabewertText: "fpn_10_1000", pflicht: true },
{ slug: "loeschwassertank", vorgabewertNum: 2000, pflicht: true },
{ slug: "schnellangriffseinrichtung", vorgabewertBool: true, pflicht: true },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "HLF 3",
name: "Hilfeleistungsfahrzeug 3",
beschreibung:
"Große Brandbekämpfung und technische Einsatzleistung, Tank >2.0004.000 l (NÖ LFV-RL FA 03).",
aliasse: [
{ alias: "RLF 2000-4000", bestaetigt: true },
{ alias: "RLFA 2000-4000", bestaetigt: true },
{ alias: "TLFA 2000-4000", bestaetigt: false },
],
merkmale: [
{ slug: "feuerloeschpumpe_typ", vorgabewertText: "fpn_10_2000", pflicht: true },
{ slug: "loeschwassertank", vorgabewertNum: 4000, pflicht: true },
{ slug: "wasserwerfer", vorgabewertBool: true, pflicht: true },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "HLF 4",
name: "Hilfeleistungsfahrzeug 4",
beschreibung:
"Großtanklöschfahrzeug / Wasserversorgung, Tank >5.00014.000 l (NÖ LFV-RL FA 07). HLF 4-U: Universallöschmittel mit Pulveranlage ≥250 kg.",
aliasse: [
{ alias: "HLF 4-U", bestaetigt: false },
{ alias: "großes TLFA", bestaetigt: false },
{ alias: "GTLF", bestaetigt: false },
],
merkmale: [
{ slug: "feuerloeschpumpe_typ", vorgabewertText: "fpn_10_3000", pflicht: true },
{ slug: "loeschwassertank", vorgabewertNum: 8000, pflicht: true },
{ slug: "wasserwerfer", vorgabewertBool: true, pflicht: true },
{ slug: "pulverloeschanlage", vorgabewertBool: true, pflicht: true },
{ slug: "pulvermenge", vorgabewertNum: 250 },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "VRF",
name: "Vorausrüstfahrzeug",
beschreibung:
"Technische Hilfeleistung mit hydraulischem Rettungssatz als Kern (NÖ LFV-RL FA 04).",
aliasse: [
{ alias: "KRF", bestaetigt: false },
{ alias: "KRFA", bestaetigt: false },
{ alias: "Vorausrüster", bestaetigt: false },
],
merkmale: [
{ slug: "hydraulischer_rettungssatz", vorgabewertBool: true, pflicht: true },
{ slug: "allradantrieb", vorgabewertBool: false },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "VF",
name: "Versorgungs-Logistikfahrzeug",
beschreibung:
"Transport und Logistik mit Ladebordwand, optional Kran (NÖ LFV-RL FA 06).",
aliasse: [
{ alias: "LAST", bestaetigt: false },
{ alias: "Versorgungsfahrzeug", bestaetigt: false },
],
merkmale: [
{ slug: "ladebordwand", vorgabewertBool: true, pflicht: true },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "ALF",
name: "Atemluftfahrzeug",
beschreibung:
"Atemluftversorgung und Flaschenfüllung mit Atemluftkompressor (NÖ LFV-RL FA 09).",
aliasse: [{ alias: "Atemluftfahrzeug", bestaetigt: false }],
merkmale: [
{ slug: "atemluftkompressor_lieferleistung", vorgabewertNum: 250, pflicht: true },
{ slug: "atemluft_speichervolumen", vorgabewertNum: 30000, pflicht: true },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "SSTF",
name: "Schadstofffahrzeug",
beschreibung:
"Gefahrgut- und Schadstoffeinsatz: Abdichten, Auffangen, Messen (NÖ LFV-RL FA 10).",
aliasse: [
{ alias: "Schadstofffahrzeug", bestaetigt: false },
{ alias: "Gefahrgutfahrzeug", bestaetigt: false },
],
merkmale: [
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "WLF",
name: "Wechselladerfahrzeug",
beschreibung:
"Transport von Abrollbehältern, optional Kran/Winde (NÖ LFV-RL FA 05).",
aliasse: [{ alias: "WLFA", bestaetigt: false }],
merkmale: [
{ slug: "wechselladeeinrichtung", vorgabewertBool: true, pflicht: true },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
{
code: "MTF",
name: "Mannschaftstransportfahrzeug",
beschreibung:
"Reiner Mannschaftstransport, 714 Sitzplätze, kein Tank/Pumpe (ÖBFV-RL FA 30).",
aliasse: [{ alias: "MTFA", bestaetigt: false }],
merkmale: [
{ slug: "besatzung_sitzplaetze", vorgabewertNum: 9, pflicht: true },
{ slug: "funkanlage", vorgabewertBool: true, pflicht: true },
],
},
];

117
src/db/seed/index.ts Normal file
View File

@@ -0,0 +1,117 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "../schema/index.js";
import type { Tx } from "../../lib/audit.js";
import { MERKMALE } from "./data/merkmale.js";
import { VEHICLE_TEMPLATES } from "./data/vehicle-templates.js";
import { EQUIPMENT_CATEGORIES } from "./data/equipment-categories.js";
import {
upsertMerkmal,
upsertVehicleTemplate,
upsertTemplateMerkmal,
upsertTemplateAlias,
upsertEquipmentCategory,
upsertCategoryMerkmal,
pruneTemplateAliasse,
} from "./upsert.js";
/**
* Katalog-Seed (Workstream 9): füllt Merkmale, Enum-Optionen, Fahrzeug-Vorlagen,
* deren Pflichtmerkmale + Aliasse sowie Geräte-Kategorien aus dem NÖ-Katalog.
*
* Idempotent (Querschnittsstandard 7): ausschließlich Upserts auf Natural Keys;
* mehrfaches Ausführen ändert keine Counts. Läuft in EINER Transaktion in der
* Reihenfolge Merkmale → Optionen → Vorlagen → Vorlagen-Merkmale → Aliasse →
* Kategorien (sequenzielle Awaits, Slug→ID-Map).
*
* Liest `DATABASE_URL` direkt aus der Umgebung (keine Next.js-Env-Validierung,
* wie scripts/migrate.ts und scripts/seed-auth.ts).
*/
/** Reine Seed-Logik gegen eine bestehende Transaktion (für Tests injizierbar). */
export async function seedCatalog(tx: Tx): Promise<void> {
// 1. Merkmale (+ Optionen) → Slug→ID-Map.
const merkmalIdBySlug = new Map<string, string>();
for (const m of MERKMALE) {
const id = await upsertMerkmal(tx, m);
merkmalIdBySlug.set(m.slug, id);
}
// 2. Vorlagen → Pflichtmerkmale → Aliasse.
for (let i = 0; i < VEHICLE_TEMPLATES.length; i++) {
const t = VEHICLE_TEMPLATES[i]!;
const templateId = await upsertVehicleTemplate(tx, t, i);
for (let j = 0; j < t.merkmale.length; j++) {
const tm = t.merkmale[j]!;
const merkmalId = merkmalIdBySlug.get(tm.slug);
if (!merkmalId) {
throw new Error(
`Vorlage ${t.code} referenziert unbekanntes Merkmal '${tm.slug}'`,
);
}
await upsertTemplateMerkmal(tx, templateId, merkmalId, tm, j);
}
for (const a of t.aliasse) {
await upsertTemplateAlias(tx, templateId, a.alias, a.bestaetigt);
}
await pruneTemplateAliasse(
tx,
templateId,
t.aliasse.map((a) => a.alias),
);
}
// 3. Geräte-Kategorien (+ optionale Merkmal-Verknüpfungen).
for (const c of EQUIPMENT_CATEGORIES) {
const categoryId = await upsertEquipmentCategory(tx, c);
const slugs = c.merkmalSlugs ?? [];
for (let k = 0; k < slugs.length; k++) {
const merkmalId = merkmalIdBySlug.get(slugs[k]!);
if (!merkmalId) {
throw new Error(
`Kategorie ${c.name} referenziert unbekanntes Merkmal '${slugs[k]}'`,
);
}
await upsertCategoryMerkmal(tx, categoryId, merkmalId, k);
}
}
}
/** Standalone-Runner (npm run db:seed). */
export async function main(): Promise<void> {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL ist nicht gesetzt.");
}
const pool = new Pool({ connectionString, max: 1 });
const db = drizzle(pool, { schema });
try {
await db.transaction(async (tx) => {
await seedCatalog(tx as Tx);
});
console.log("Katalog-Seed erfolgreich (idempotent).");
console.log(` Merkmale: ${MERKMALE.length}`);
console.log(` Fahrzeug-Vorlagen: ${VEHICLE_TEMPLATES.length}`);
console.log(` Geräte-Kategorien: ${EQUIPMENT_CATEGORIES.length}`);
} finally {
await pool.end();
}
}
// Nur ausführen, wenn direkt gestartet (nicht beim Import in Tests).
const isMain =
typeof process !== "undefined" &&
process.argv[1] !== undefined &&
import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
main().catch((err: unknown) => {
console.error("Katalog-Seed fehlgeschlagen:", err);
process.exit(1);
});
}

205
src/db/seed/seed.test.ts Normal file
View File

@@ -0,0 +1,205 @@
import { describe, it, expect } from "vitest";
import { MERKMALE } from "./data/merkmale";
import { VEHICLE_TEMPLATES } from "./data/vehicle-templates";
import { EQUIPMENT_CATEGORIES } from "./data/equipment-categories";
/**
* Reiner Offline-Unit-Test der Seed-DATEN (kein Postgres nötig).
*
* Prüft die fachlichen Invarianten aus Workstream 9 statisch:
* - 34 Merkmale (Funkrufname ist Spalte, NICHT Merkmal)
* - 11 Vorlagen, 11 Geräte-Kategorien
* - Enum-Optionen: feuerloeschpumpe_typ=8, anzahl_achsen=3, stromerzeuger_bauart=3
* - Aliasse mit `bestaetigt`; RLF/RLFA 2000 + 2000-4000 = true
* - KEIN HLFA-Alias (Laufzeitregel ist kanonisch)
* - HLF 4-U Alias auf HLF 4 + Pulver-Pflichtmerkmale
* - Idempotenz-Keys eindeutig (slug, code, name)
* - Jeder Template-Merkmal-slug existiert im Merkmal-Katalog
* - Vorgabewerte typgerecht zur Merkmal-typ
*/
describe("Seed-Daten: Merkmale", () => {
it("enthält genau 34 Merkmale", () => {
expect(MERKMALE).toHaveLength(34);
});
it("enthält KEIN Funkrufname-Merkmal (ist Spalte auf vehicles)", () => {
const namen = MERKMALE.map((m) => m.name.toLowerCase());
expect(namen).not.toContain("funkrufname");
const slugs = MERKMALE.map((m) => m.slug);
expect(slugs).not.toContain("funkrufname");
});
it("hat eindeutige slugs", () => {
const slugs = MERKMALE.map((m) => m.slug);
expect(new Set(slugs).size).toBe(slugs.length);
});
it("hat eindeutige Namen", () => {
const namen = MERKMALE.map((m) => m.name);
expect(new Set(namen).size).toBe(namen.length);
});
it("feuerloeschpumpe_typ hat 8 Enum-Optionen", () => {
const m = MERKMALE.find((x) => x.slug === "feuerloeschpumpe_typ");
expect(m).toBeDefined();
expect(m?.typ).toBe("enum");
expect(m?.optionen).toHaveLength(8);
});
it("anzahl_achsen hat 3 Enum-Optionen", () => {
const m = MERKMALE.find((x) => x.slug === "anzahl_achsen");
expect(m?.optionen).toHaveLength(3);
});
it("stromerzeuger_bauart hat 3 Enum-Optionen", () => {
const m = MERKMALE.find((x) => x.slug === "stromerzeuger_bauart");
expect(m?.optionen).toHaveLength(3);
});
it("Enum-Merkmale haben Optionen, Nicht-Enum-Merkmale keine", () => {
for (const m of MERKMALE) {
if (m.typ === "enum") {
expect(m.optionen, `${m.slug} braucht Optionen`).toBeDefined();
expect((m.optionen ?? []).length).toBeGreaterThan(0);
} else {
expect(m.optionen ?? [], `${m.slug} darf keine Optionen haben`).toHaveLength(0);
}
}
});
it("jede Enum-Option hat eindeutige Werte je Merkmal", () => {
for (const m of MERKMALE) {
const werte = (m.optionen ?? []).map((o) => o.wert);
expect(new Set(werte).size, `${m.slug} doppelte Optionswerte`).toBe(werte.length);
}
});
it("nur erlaubte geltungsbereich-Werte", () => {
const erlaubt = new Set(["vehicle", "equipment", "both"]);
for (const m of MERKMALE) {
expect(erlaubt.has(m.geltungsbereich), `${m.slug}`).toBe(true);
}
});
});
describe("Seed-Daten: Vehicle-Templates", () => {
it("enthält genau 11 Vorlagen", () => {
expect(VEHICLE_TEMPLATES).toHaveLength(11);
});
it("hat eindeutige codes", () => {
const codes = VEHICLE_TEMPLATES.map((t) => t.code);
expect(new Set(codes).size).toBe(codes.length);
});
it("enthält KEINE eigene HLFA-Vorlage (Allrad ist Laufzeitregel)", () => {
const codes = VEHICLE_TEMPLATES.map((t) => t.code);
expect(codes.some((c) => c.startsWith("HLFA"))).toBe(false);
});
it("enthält die 11 erwarteten Codes", () => {
const codes = VEHICLE_TEMPLATES.map((t) => t.code).sort();
expect(codes).toEqual(
["HLF 1", "HLF 1 W", "HLF 2", "HLF 3", "HLF 4", "VRF", "VF", "ALF", "SSTF", "WLF", "MTF"].sort(),
);
});
it("hat KEINEN HLFA-Alias", () => {
const aliasse = VEHICLE_TEMPLATES.flatMap((t) => t.aliasse.map((a) => a.alias));
expect(aliasse.some((a) => a.toUpperCase().startsWith("HLFA"))).toBe(false);
});
it("HLF 2: RLF 2000 und RLFA 2000 sind bestätigte Aliasse", () => {
const hlf2 = VEHICLE_TEMPLATES.find((t) => t.code === "HLF 2");
const byAlias = new Map(hlf2!.aliasse.map((a) => [a.alias, a.bestaetigt]));
expect(byAlias.get("RLF 2000")).toBe(true);
expect(byAlias.get("RLFA 2000")).toBe(true);
});
it("HLF 3: RLF 2000-4000 und RLFA 2000-4000 sind bestätigte Aliasse", () => {
const hlf3 = VEHICLE_TEMPLATES.find((t) => t.code === "HLF 3");
const byAlias = new Map(hlf3!.aliasse.map((a) => [a.alias, a.bestaetigt]));
expect(byAlias.get("RLF 2000-4000")).toBe(true);
expect(byAlias.get("RLFA 2000-4000")).toBe(true);
});
it("HLF 4: HLF 4-U ist ein (offener) Alias", () => {
const hlf4 = VEHICLE_TEMPLATES.find((t) => t.code === "HLF 4");
const alias = hlf4!.aliasse.find((a) => a.alias === "HLF 4-U");
expect(alias).toBeDefined();
expect(alias?.bestaetigt).toBe(false);
});
it("HLF 4: pulverloeschanlage ist Pflichtmerkmal", () => {
const hlf4 = VEHICLE_TEMPLATES.find((t) => t.code === "HLF 4");
const m = hlf4!.merkmale.find((x) => x.slug === "pulverloeschanlage");
expect(m).toBeDefined();
expect(m?.pflicht).toBe(true);
expect(m?.vorgabewertBool).toBe(true);
});
it("nur bestätigte Aliasse sind RLF/RLFA 2000 bzw. 2000-4000", () => {
const bestaetigt = VEHICLE_TEMPLATES.flatMap((t) =>
t.aliasse.filter((a) => a.bestaetigt).map((a) => a.alias),
).sort();
expect(bestaetigt).toEqual(
["RLF 2000", "RLFA 2000", "RLF 2000-4000", "RLFA 2000-4000"].sort(),
);
});
it("alle Aliasse je Vorlage eindeutig", () => {
for (const t of VEHICLE_TEMPLATES) {
const a = t.aliasse.map((x) => x.alias);
expect(new Set(a).size, `${t.code} doppelte Aliasse`).toBe(a.length);
}
});
it("jeder Template-Merkmal-slug existiert im Merkmal-Katalog", () => {
const known = new Set(MERKMALE.map((m) => m.slug));
for (const t of VEHICLE_TEMPLATES) {
for (const m of t.merkmale) {
expect(known.has(m.slug), `${t.code} -> unbekannter slug ${m.slug}`).toBe(true);
}
}
});
it("Vorgabewerte sind typgerecht zum Merkmal-typ gesetzt", () => {
const bySlug = new Map(MERKMALE.map((m) => [m.slug, m]));
for (const t of VEHICLE_TEMPLATES) {
for (const tm of t.merkmale) {
const def = bySlug.get(tm.slug)!;
if (def.typ === "number" && tm.vorgabewertNum !== undefined) {
expect(tm.vorgabewertText, `${t.code}/${tm.slug}`).toBeUndefined();
expect(tm.vorgabewertBool, `${t.code}/${tm.slug}`).toBeUndefined();
}
if (def.typ === "boolean" && tm.vorgabewertBool !== undefined) {
expect(tm.vorgabewertNum, `${t.code}/${tm.slug}`).toBeUndefined();
expect(tm.vorgabewertText, `${t.code}/${tm.slug}`).toBeUndefined();
}
if ((def.typ === "enum" || def.typ === "text") && tm.vorgabewertText !== undefined) {
expect(tm.vorgabewertNum, `${t.code}/${tm.slug}`).toBeUndefined();
expect(tm.vorgabewertBool, `${t.code}/${tm.slug}`).toBeUndefined();
}
// Enum-Vorgabewert muss eine gültige Option sein
if (def.typ === "enum" && tm.vorgabewertText !== undefined) {
const werte = (def.optionen ?? []).map((o) => o.wert);
expect(werte, `${t.code}/${tm.slug}=${tm.vorgabewertText}`).toContain(
tm.vorgabewertText,
);
}
}
}
});
});
describe("Seed-Daten: Equipment-Categories", () => {
it("enthält genau 11 Kategorien", () => {
expect(EQUIPMENT_CATEGORIES).toHaveLength(11);
});
it("hat eindeutige Namen", () => {
const namen = EQUIPMENT_CATEGORIES.map((c) => c.name);
expect(new Set(namen).size).toBe(namen.length);
});
});

218
src/db/seed/upsert.ts Normal file
View File

@@ -0,0 +1,218 @@
import { eq, and } from "drizzle-orm";
import type { Tx } from "../../lib/audit.js";
import * as schema from "../schema/index.js";
import type { MerkmalSeed } from "./data/merkmale.js";
import type { VehicleTemplateSeed } from "./data/vehicle-templates.js";
import type { EquipmentCategorySeed } from "./data/equipment-categories.js";
/**
* Idempotente Upserts für Workstream-9-Seeds (Querschnittsstandard 7:
* ausschließlich `onConflictDoUpdate` auf Natural Keys, mehrfaches Ausführen
* ändert keine Counts).
*
* Natural Keys:
* - merkmale.slug
* - merkmal_optionen(merkmalId, wert)
* - vehicle_templates.code
* - vehicle_template_merkmale(templateId, merkmalId) [PK]
* - vehicle_template_aliasse(templateId, alias)
* - equipment_categories.name
* - equipment_category_merkmale(categoryId, merkmalId) [PK]
*
* Alle Funktionen nehmen die laufende Transaktion `tx`, damit der gesamte Seed
* atomar bleibt.
*/
/** Upsert eines Merkmals (+ Enum-Optionen). Liefert die Merkmal-UUID. */
export async function upsertMerkmal(tx: Tx, m: MerkmalSeed): Promise<string> {
const [row] = await tx
.insert(schema.merkmale)
.values({
slug: m.slug,
name: m.name,
typ: m.typ,
einheit: m.einheit ?? null,
geltungsbereich: m.geltungsbereich,
// Katalog-Merkmale gelten unmittelbar als aktiv (kein Vorschlag).
status: "active",
})
.onConflictDoUpdate({
target: schema.merkmale.slug,
set: {
name: m.name,
typ: m.typ,
einheit: m.einheit ?? null,
geltungsbereich: m.geltungsbereich,
status: "active",
},
})
.returning({ id: schema.merkmale.id });
if (!row) throw new Error(`Merkmal-Upsert ohne Rückgabe: ${m.slug}`);
for (const opt of m.optionen ?? []) {
await tx
.insert(schema.merkmalOptionen)
.values({
merkmalId: row.id,
wert: opt.wert,
label: opt.label,
reihenfolge: opt.reihenfolge,
})
.onConflictDoUpdate({
target: [schema.merkmalOptionen.merkmalId, schema.merkmalOptionen.wert],
set: { label: opt.label, reihenfolge: opt.reihenfolge },
});
}
return row.id;
}
/** Upsert einer Vorlage (ohne Merkmale/Aliasse). Liefert die Template-UUID. */
export async function upsertVehicleTemplate(
tx: Tx,
t: VehicleTemplateSeed,
reihenfolge: number,
): Promise<string> {
const [row] = await tx
.insert(schema.vehicleTemplates)
.values({
code: t.code,
name: t.name,
beschreibung: t.beschreibung ?? null,
reihenfolge,
})
.onConflictDoUpdate({
target: schema.vehicleTemplates.code,
set: { name: t.name, beschreibung: t.beschreibung ?? null, reihenfolge },
})
.returning({ id: schema.vehicleTemplates.id });
if (!row) throw new Error(`Vorlagen-Upsert ohne Rückgabe: ${t.code}`);
return row.id;
}
/** Upsert eines Vorlagen-Pflichtmerkmals (PK templateId+merkmalId). */
export async function upsertTemplateMerkmal(
tx: Tx,
templateId: string,
merkmalId: string,
m: VehicleTemplateSeed["merkmale"][number],
reihenfolge: number,
): Promise<void> {
await tx
.insert(schema.vehicleTemplateMerkmale)
.values({
templateId,
merkmalId,
vorgabewertNum: m.vorgabewertNum ?? null,
vorgabewertText: m.vorgabewertText ?? null,
vorgabewertBool: m.vorgabewertBool ?? null,
pflicht: m.pflicht ?? false,
reihenfolge,
})
.onConflictDoUpdate({
target: [
schema.vehicleTemplateMerkmale.templateId,
schema.vehicleTemplateMerkmale.merkmalId,
],
set: {
vorgabewertNum: m.vorgabewertNum ?? null,
vorgabewertText: m.vorgabewertText ?? null,
vorgabewertBool: m.vorgabewertBool ?? null,
pflicht: m.pflicht ?? false,
reihenfolge,
},
});
}
/** Upsert eines Alias (Natural Key templateId+alias). */
export async function upsertTemplateAlias(
tx: Tx,
templateId: string,
alias: string,
bestaetigt: boolean,
): Promise<void> {
await tx
.insert(schema.vehicleTemplateAliasse)
.values({ templateId, alias, bestaetigt })
.onConflictDoUpdate({
target: [
schema.vehicleTemplateAliasse.templateId,
schema.vehicleTemplateAliasse.alias,
],
set: { bestaetigt },
});
}
/** Upsert einer Geräte-Kategorie (Natural Key name). Liefert die UUID. */
export async function upsertEquipmentCategory(
tx: Tx,
c: EquipmentCategorySeed,
): Promise<string> {
const [row] = await tx
.insert(schema.equipmentCategories)
.values({ name: c.name, reihenfolge: c.reihenfolge })
.onConflictDoUpdate({
target: schema.equipmentCategories.name,
set: { reihenfolge: c.reihenfolge },
})
.returning({ id: schema.equipmentCategories.id });
if (!row) throw new Error(`Kategorie-Upsert ohne Rückgabe: ${c.name}`);
return row.id;
}
/**
* Upsert der Kategorie-Merkmal-Verknüpfung (PK categoryId+merkmalId).
* Idempotent über `onConflictDoNothing` (keine zusätzlichen Felder zu
* aktualisieren außer reihenfolge).
*/
export async function upsertCategoryMerkmal(
tx: Tx,
categoryId: string,
merkmalId: string,
reihenfolge: number,
): Promise<void> {
await tx
.insert(schema.equipmentCategoryMerkmale)
.values({ categoryId, merkmalId, reihenfolge })
.onConflictDoUpdate({
target: [
schema.equipmentCategoryMerkmale.categoryId,
schema.equipmentCategoryMerkmale.merkmalId,
],
set: { reihenfolge },
});
}
/**
* Entfernt Aliasse zu einer Vorlage, die NICHT mehr im Seed stehen
* (z. B. nachdem ein Alias aus dem Katalog gestrichen wurde). Hält den
* Aliasse-Bestand exakt deckungsgleich mit dem Seed und damit idempotent,
* ohne verwaiste Aliasse zu hinterlassen.
*/
export async function pruneTemplateAliasse(
tx: Tx,
templateId: string,
keepAliasse: readonly string[],
): Promise<void> {
const existing = await tx
.select({ alias: schema.vehicleTemplateAliasse.alias })
.from(schema.vehicleTemplateAliasse)
.where(eq(schema.vehicleTemplateAliasse.templateId, templateId));
const keep = new Set(keepAliasse);
for (const e of existing) {
if (!keep.has(e.alias)) {
await tx
.delete(schema.vehicleTemplateAliasse)
.where(
and(
eq(schema.vehicleTemplateAliasse.templateId, templateId),
eq(schema.vehicleTemplateAliasse.alias, e.alias),
),
);
}
}
}

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Mocks ---------------------------------------------------------------
const auditCalls: unknown[][] = [];
const updateSetCalls: Record<string, unknown>[] = [];
// Steuert, welchen authTyp das geladene Konto hat (oder kein Treffer).
let selectResult: Array<{ authTyp: "local" | "authentik" }> = [];
function makeTx() {
return {
select: () => ({
from: () => ({
where: () => Promise.resolve(selectResult),
}),
}),
update: () => ({
set: (vals: Record<string, unknown>) => {
updateSetCalls.push(vals);
return { where: () => Promise.resolve(undefined) };
},
}),
};
}
vi.mock("@/db", () => ({
db: {
transaction: (cb: (tx: ReturnType<typeof makeTx>) => Promise<unknown>) =>
cb(makeTx()),
},
}));
vi.mock("@/lib/auth/password", () => ({
hashPassword: (_pw: string) => Promise.resolve("HASHED"),
}));
vi.mock("@/lib/audit", () => ({
writeAudit: (...args: unknown[]) => {
auditCalls.push(args);
return Promise.resolve();
},
}));
vi.mock("@/lib/geo/nominatim", () => ({
geocodeAddress: () => Promise.resolve({ status: "fail" }),
}));
// -------------------------------------------------------------------------
import { resetUserPassword } from "@/lib/admin/provisioning";
const USER = "11111111-1111-1111-1111-111111111111";
const ACTOR = "22222222-2222-2222-2222-222222222222";
describe("resetUserPassword", () => {
beforeEach(() => {
auditCalls.length = 0;
updateSetCalls.length = 0;
selectResult = [];
});
it("setzt das Passwort fuer ein lokales Konto zurueck und schreibt Audit", async () => {
selectResult = [{ authTyp: "local" }];
const res = await resetUserPassword(USER, ACTOR);
expect(typeof res.tempPassword).toBe("string");
expect(res.tempPassword.length).toBeGreaterThan(0);
expect(updateSetCalls).toEqual([{ passwortHash: "HASHED" }]);
expect(auditCalls).toHaveLength(1);
expect(auditCalls[0]?.[1]).toBe("user.reset");
});
it("bricht fuer Authentik-Konten ab: kein Hash, kein Audit", async () => {
selectResult = [{ authTyp: "authentik" }];
await expect(resetUserPassword(USER, ACTOR)).rejects.toThrow(
/lokale Konten/i,
);
expect(updateSetCalls).toHaveLength(0);
expect(auditCalls).toHaveLength(0);
});
it("bricht ab, wenn das Konto nicht existiert", async () => {
selectResult = [];
await expect(resetUserPassword(USER, ACTOR)).rejects.toThrow();
expect(updateSetCalls).toHaveLength(0);
expect(auditCalls).toHaveLength(0);
});
});

View File

@@ -116,6 +116,13 @@ export async function resetUserPassword(
const temp = generateTempPassword(); const temp = generateTempPassword();
const hash = await hashPassword(temp); const hash = await hashPassword(temp);
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const [u] = await tx
.select({ authTyp: users.authTyp })
.from(users)
.where(eq(users.id, userId));
if (!u || u.authTyp !== "local") {
throw new Error("Nur lokale Konten können zurückgesetzt werden.");
}
await tx await tx
.update(users) .update(users)
.set({ passwortHash: hash }) .set({ passwortHash: hash })

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { formatMerkmal, toEckdaten, type MerkmalRow } from "./merkmale";
function row(p: Partial<MerkmalRow>): MerkmalRow {
return {
merkmalId: "00000000-0000-0000-0000-000000000000",
name: "Merkmal",
typ: "text",
einheit: null,
reihenfolge: 0,
valueNum: null,
valueText: null,
valueBool: null,
enumLabel: null,
...p,
};
}
describe("formatMerkmal", () => {
it("number mit Einheit -> Tausenderpunkt + NBSP (de-AT)", () => {
const out = formatMerkmal(
row({ typ: "number", einheit: "l", valueNum: 14000 }),
);
expect(out).toBe("14.000 l");
});
it("number ohne Einheit -> nur Zahl", () => {
expect(formatMerkmal(row({ typ: "number", valueNum: 1500 }))).toBe("1.500");
});
it("boolean false -> Nein", () => {
expect(formatMerkmal(row({ typ: "boolean", valueBool: false }))).toBe("Nein");
});
it("boolean true -> Ja", () => {
expect(formatMerkmal(row({ typ: "boolean", valueBool: true }))).toBe("Ja");
});
it("enum mit enumLabel -> Label", () => {
expect(
formatMerkmal(
row({ typ: "enum", valueText: "fpn_10_2000", enumLabel: "FPN 10-2000" }),
),
).toBe("FPN 10-2000");
});
it("enum ohne Label -> roher Wert", () => {
expect(formatMerkmal(row({ typ: "enum", valueText: "sonst" }))).toBe("sonst");
});
it("text -> roher Wert", () => {
expect(formatMerkmal(row({ typ: "text", valueText: "Hallo" }))).toBe("Hallo");
});
it("alle null -> Leerwert ()", () => {
expect(formatMerkmal(row({ typ: "number" }))).toBe("");
expect(formatMerkmal(row({ typ: "boolean" }))).toBe("");
expect(formatMerkmal(row({ typ: "enum" }))).toBe("");
expect(formatMerkmal(row({ typ: "text" }))).toBe("");
});
it("text mit nur Whitespace -> Leerwert", () => {
expect(formatMerkmal(row({ typ: "text", valueText: " " }))).toBe("");
});
});
describe("toEckdaten", () => {
it("sortiert nach reihenfolge und baut Label mit Einheit", () => {
const rows: MerkmalRow[] = [
row({ name: "B", typ: "number", einheit: "l", valueNum: 2000, reihenfolge: 2 }),
row({ name: "A", typ: "boolean", valueBool: true, reihenfolge: 1 }),
];
const out = toEckdaten(rows);
expect(out.map((e) => e.label)).toEqual(["A", "B (l)"]);
expect(out.map((e) => e.wert)).toEqual(["Ja", "2.000 l"]);
});
it("leeres Array -> leeres Ergebnis", () => {
expect(toEckdaten([])).toEqual([]);
});
});

View File

@@ -0,0 +1,99 @@
import type { MerkmalTyp } from "@/lib/merkmale/types";
import { t } from "@/lib/i18n/de";
/**
* Eine rohe Merkmal-Wert-Zeile aus der DB (siehe `loadMerkmalRows`). Genau eine
* der drei `value*`-Spalten ist typabhängig gesetzt (oder alle null => leer).
* `enumLabel` ist das aufgelöste Anzeige-Label aus `merkmal_optionen` (falls
* vorhanden), `valueText` der gespeicherte enum-Wert.
*/
export interface MerkmalRow {
merkmalId: string;
name: string;
typ: MerkmalTyp;
einheit: string | null;
reihenfolge: number;
valueNum: number | null;
valueText: string | null;
valueBool: boolean | null;
enumLabel: string | null;
}
/** Eine fertig formatierte Eckdaten-Zeile fürs UI. */
export interface Eckdatum {
merkmalId: string;
label: string;
wert: string;
}
/** Schmales geschütztes Leerzeichen (NBSP) zwischen Zahl und Einheit. */
const NBSP = " ";
/**
* Formatiert eine Zahl im de-AT/de-Stil mit Tausenderpunkt und Dezimalkomma.
*
* Bewusst NICHT direkt `Intl.NumberFormat("de-AT")`: je nach ICU-Build liefert
* de-AT als Gruppentrenner ein schmales geschütztes Leerzeichen (U+202F) statt
* des fachlich geforderten Tausenderpunkts. Wir normalisieren den Gruppen-
* trenner deshalb deterministisch auf „.", damit die Ausgabe ICU-unabhängig
* „14.000" ergibt.
*/
function formatZahl(n: number): string {
const parts = new Intl.NumberFormat("de-AT").formatToParts(n);
return parts
.map((p) => {
if (p.type === "group") return ".";
if (p.type === "decimal") return ",";
return p.value;
})
.join("");
}
/**
* Formatiert EINEN typisierten Merkmalwert als deutschen Anzeige-String.
*
* - number: `Intl.NumberFormat("de-AT")` (Tausenderpunkt), Einheit mit NBSP.
* - boolean: „Ja" / „Nein".
* - enum: bevorzugt `enumLabel`, sonst der rohe `valueText`.
* - text: der rohe `valueText`.
* - leerer/fehlender Wert (für den Typ) => „–".
*
* REIN: keine DB-/IO-Abhängigkeit, damit ohne laufendes Postgres testbar.
*/
export function formatMerkmal(row: MerkmalRow): string {
const leer = t("detail.leerWert");
switch (row.typ) {
case "number": {
if (row.valueNum === null || row.valueNum === undefined) return leer;
const zahl = formatZahl(row.valueNum);
return row.einheit ? `${zahl}${NBSP}${row.einheit}` : zahl;
}
case "boolean": {
if (row.valueBool === null || row.valueBool === undefined) return leer;
return row.valueBool ? t("detail.ja") : t("detail.nein");
}
case "enum": {
const v = row.enumLabel ?? row.valueText;
return v && v.trim() !== "" ? v : leer;
}
case "text": {
return row.valueText && row.valueText.trim() !== "" ? row.valueText : leer;
}
default:
return leer;
}
}
/**
* Wandelt rohe Merkmal-Zeilen in fertige Eckdaten um (sortiert nach
* `reihenfolge`, dann `name`). Reine Transformation.
*/
export function toEckdaten(rows: MerkmalRow[]): Eckdatum[] {
return [...rows]
.sort((a, b) => a.reihenfolge - b.reihenfolge || a.name.localeCompare(b.name, "de"))
.map((r) => ({
merkmalId: r.merkmalId,
label: r.einheit ? `${r.name} (${r.einheit})` : r.name,
wert: formatMerkmal(r),
}));
}

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Offline-Tests für die Mapping-Logik der Detail-Queries. Die Drizzle-DB wird
* gemockt (kein Postgres in der Sandbox), sodass wir das reine Verhalten
* verifizieren: „im Gerätehaus" (vehicleId null) -> `fahrzeug: null`, sonst
* verlinktes Fahrzeug; sowie not-found (-> null) bei fehlendem Datensatz.
*
* Wir mocken `@/db` als verkettbaren Query-Builder, dessen letzte Stufe
* (`limit`/`orderBy`/`where`) ein vorab gesetztes Ergebnis-Array liefert.
*/
type Rows = unknown[];
let queue: Rows[];
function nextRows(): Rows {
return queue.length > 0 ? (queue.shift() as Rows) : [];
}
// Ein Thenable-Builder: jede Methode gibt sich selbst zurück; `await` löst die
// nächste Ergebnismenge auf. So funktionieren sowohl `await db.select()....limit()`
// als auch `await db.select()....orderBy()`.
function makeBuilder(): Record<string, unknown> {
const builder: Record<string, unknown> = {};
const chain = () => builder;
for (const m of [
"select",
"from",
"innerJoin",
"leftJoin",
"where",
"limit",
"orderBy",
]) {
builder[m] = vi.fn(chain);
}
builder.then = (resolve: (v: Rows) => unknown) => resolve(nextRows());
return builder;
}
vi.mock("@/db", () => ({
db: {
select: () => makeBuilder(),
},
}));
const { getGeraetDetail } = await import("@/lib/detail/queries");
const WEHR = {
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
name: "FF Test",
art: "FF",
strasse: null,
plz: null,
ort: null,
wehrfuehrer: null,
telefon: null,
email: null,
};
beforeEach(() => {
queue = [];
});
describe("getGeraetDetail", () => {
it("liefert null, wenn das Gerät nicht existiert", async () => {
queue = [[]]; // erste Query (Gerät) leer
const out = await getGeraetDetail("00000000-0000-0000-0000-000000000000");
expect(out).toBeNull();
});
it("ohne Fahrzeug-Zuordnung -> fahrzeug = null (im Gerätehaus)", async () => {
queue = [
[
{
id: "g1",
brigadeId: WEHR.id,
name: "Strahlrohr",
status: "einsatzbereit",
kategorie: "Armaturen",
vehicleId: null,
vehicleName: null,
},
],
[], // loadMerkmalRows
[WEHR], // getBrigadeCard
];
const out = await getGeraetDetail("g1");
expect(out?.fahrzeug).toBeNull();
expect(out?.kategorie).toBe("Armaturen");
});
it("mit Fahrzeug-Zuordnung -> verlinktes Fahrzeug", async () => {
queue = [
[
{
id: "g1",
brigadeId: WEHR.id,
name: "Strahlrohr",
status: "einsatzbereit",
kategorie: "Armaturen",
vehicleId: "v1",
vehicleName: "TLFA 4000",
},
],
[],
[WEHR],
];
const out = await getGeraetDetail("g1");
expect(out?.fahrzeug).toEqual({ id: "v1", name: "TLFA 4000" });
});
});

252
src/lib/detail/queries.ts Normal file
View File

@@ -0,0 +1,252 @@
import { and, eq, asc, isNull } from "drizzle-orm";
import { db } from "@/db";
import { vehicles, equipment } from "@/db/schema/assets";
import { brigades } from "@/db/schema/brigades";
import { vehicleTemplates } from "@/db/schema/templates";
import { equipmentCategories } from "@/db/schema/equipment-categories";
import { merkmale, merkmalOptionen } from "@/db/schema/merkmale";
import { merkmalValues } from "@/db/schema/merkmal-values";
import type { StatusKey } from "@/components/ui/badge";
import type { EntityTyp } from "@/lib/search/types";
import type { MerkmalRow } from "./merkmale";
/**
* Lese-Queries für die drei Detailseiten (Workstream 8). READ-ONLY und
* wehrübergreifend (das `(app)`-Gruppen-Gate aus Phase 2 schützt; die Seiten
* rufen zusätzlich `requireSession()`). Alle IDs sind UUIDs (`string`).
*/
/** Verlinktes Wehr-Kärtchen (Kontakt out-of-band, kein Borrow-Workflow). */
export interface BrigadeCard {
id: string;
name: string;
art: string;
strasse: string | null;
plz: string | null;
ort: string | null;
wehrfuehrer: string | null;
telefon: string | null;
email: string | null;
}
/** Ein verlinktes Beladungs-Gerät eines Fahrzeugs. */
export interface BeladungItem {
id: string;
name: string;
status: StatusKey;
kategorie: string;
}
export interface FahrzeugDetail {
id: string;
name: string;
funkrufname: string | null;
status: StatusKey;
notiz: string | null;
templateName: string | null;
merkmale: MerkmalRow[];
beladung: BeladungItem[];
wehr: BrigadeCard | null;
}
export interface GeraetDetail {
id: string;
name: string;
status: StatusKey;
kategorie: string;
merkmale: MerkmalRow[];
/** Zugeordnetes Fahrzeug oder `null` => „im Gerätehaus". */
fahrzeug: { id: string; name: string } | null;
wehr: BrigadeCard | null;
}
export interface WehrDetail extends BrigadeCard {
fahrzeuge: { id: string; name: string; funkrufname: string | null; status: StatusKey }[];
geraeteImHaus: { id: string; name: string; status: StatusKey }[];
}
/**
* Lädt die typisierten Merkmalwerte einer Entität (joint
* `merkmal_values` ↔ `merkmale` ↔ `merkmal_optionen` über
* `merkmal_optionen.wert = merkmal_values.value_text`, damit enum-Werte ihr
* Anzeige-Label erhalten). Sortiert nach `merkmale.name`.
*/
export async function loadMerkmalRows(
entityTyp: EntityTyp,
entityId: string,
): Promise<MerkmalRow[]> {
const rows = await db
.select({
merkmalId: merkmale.id,
name: merkmale.name,
typ: merkmale.typ,
einheit: merkmale.einheit,
valueNum: merkmalValues.valueNum,
valueText: merkmalValues.valueText,
valueBool: merkmalValues.valueBool,
enumLabel: merkmalOptionen.label,
})
.from(merkmalValues)
.innerJoin(merkmale, eq(merkmale.id, merkmalValues.merkmalId))
.leftJoin(
merkmalOptionen,
and(
eq(merkmalOptionen.merkmalId, merkmale.id),
eq(merkmalOptionen.wert, merkmalValues.valueText),
),
)
.where(
and(
eq(merkmalValues.entityTyp, entityTyp),
eq(merkmalValues.entityId, entityId),
),
)
.orderBy(asc(merkmale.name));
return rows.map((r, i) => ({
merkmalId: r.merkmalId,
name: r.name,
typ: r.typ,
einheit: r.einheit,
reihenfolge: i,
valueNum: r.valueNum,
valueText: r.valueText,
valueBool: r.valueBool,
enumLabel: r.enumLabel,
}));
}
/** Wehr-Kärtchen (Stammdaten + Kontakt). `null`, wenn nicht gefunden. */
export async function getBrigadeCard(id: string): Promise<BrigadeCard | null> {
const [b] = await db
.select({
id: brigades.id,
name: brigades.name,
art: brigades.art,
strasse: brigades.strasse,
plz: brigades.plz,
ort: brigades.ort,
wehrfuehrer: brigades.wehrfuehrer,
telefon: brigades.telefon,
email: brigades.email,
})
.from(brigades)
.where(eq(brigades.id, id))
.limit(1);
return b ?? null;
}
export async function getFahrzeugDetail(id: string): Promise<FahrzeugDetail | null> {
const [v] = await db
.select({
id: vehicles.id,
brigadeId: vehicles.brigadeId,
name: vehicles.name,
funkrufname: vehicles.funkrufname,
status: vehicles.status,
notiz: vehicles.notiz,
templateName: vehicleTemplates.name,
})
.from(vehicles)
.leftJoin(vehicleTemplates, eq(vehicleTemplates.id, vehicles.templateId))
.where(eq(vehicles.id, id))
.limit(1);
if (!v) return null;
const [rows, beladung, wehr] = await Promise.all([
loadMerkmalRows("vehicle", id),
db
.select({
id: equipment.id,
name: equipment.name,
status: equipment.status,
kategorie: equipmentCategories.name,
})
.from(equipment)
.innerJoin(
equipmentCategories,
eq(equipmentCategories.id, equipment.categoryId),
)
.where(eq(equipment.vehicleId, id))
.orderBy(asc(equipment.name)),
getBrigadeCard(v.brigadeId),
]);
return {
id: v.id,
name: v.name,
funkrufname: v.funkrufname,
status: v.status,
notiz: v.notiz,
templateName: v.templateName,
merkmale: rows,
beladung,
wehr,
};
}
export async function getGeraetDetail(id: string): Promise<GeraetDetail | null> {
const [g] = await db
.select({
id: equipment.id,
brigadeId: equipment.brigadeId,
name: equipment.name,
status: equipment.status,
kategorie: equipmentCategories.name,
vehicleId: equipment.vehicleId,
vehicleName: vehicles.name,
})
.from(equipment)
.innerJoin(equipmentCategories, eq(equipmentCategories.id, equipment.categoryId))
.leftJoin(vehicles, eq(vehicles.id, equipment.vehicleId))
.where(eq(equipment.id, id))
.limit(1);
if (!g) return null;
const [rows, wehr] = await Promise.all([
loadMerkmalRows("equipment", id),
getBrigadeCard(g.brigadeId),
]);
return {
id: g.id,
name: g.name,
status: g.status,
kategorie: g.kategorie,
merkmale: rows,
fahrzeug:
g.vehicleId && g.vehicleName
? { id: g.vehicleId, name: g.vehicleName }
: null,
wehr,
};
}
export async function getWehrDetail(id: string): Promise<WehrDetail | null> {
const card = await getBrigadeCard(id);
if (!card) return null;
const [fahrzeuge, geraeteImHaus] = await Promise.all([
db
.select({
id: vehicles.id,
name: vehicles.name,
funkrufname: vehicles.funkrufname,
status: vehicles.status,
})
.from(vehicles)
.where(eq(vehicles.brigadeId, id))
.orderBy(asc(vehicles.name)),
db
.select({
id: equipment.id,
name: equipment.name,
status: equipment.status,
})
.from(equipment)
.where(and(eq(equipment.brigadeId, id), isNull(equipment.vehicleId)))
.orderBy(asc(equipment.name)),
]);
return { ...card, fahrzeuge, geraeteImHaus };
}

View File

@@ -55,7 +55,29 @@ export const de = {
eckdaten: "Eckdaten", eckdaten: "Eckdaten",
beladung: "Beladung", beladung: "Beladung",
keineEckdaten: "Keine Eckdaten erfasst.", keineEckdaten: "Keine Eckdaten erfasst.",
keineBeladung: "Keine Beladung zugeordnet.",
imGeraetehaus: "im Gerätehaus", imGeraetehaus: "im Gerätehaus",
leerWert: "",
ja: "Ja",
nein: "Nein",
zugeordnetesFahrzeug: "Zugeordnetes Fahrzeug",
kategorie: "Kategorie",
fahrzeuge: "Fahrzeuge",
keineFahrzeuge: "Keine Fahrzeuge erfasst.",
geraeteImHaus: "Geräte im Gerätehaus",
keineGeraeteImHaus: "Keine Geräte im Gerätehaus.",
nichtGefunden: "Nicht gefunden.",
},
kontakt: {
titel: "Kontakt",
anrufen: "Anrufen",
email: "E-Mail schreiben",
keine: "Keine Kontaktdaten hinterlegt.",
betreff: "FlorianNetz Anfrage",
},
wehr: {
wehrfuehrer: "Wehrführer",
adresse: "Adresse",
}, },
fehler: { fehler: {
allgemein: "Es ist ein Fehler aufgetreten.", allgemein: "Es ist ein Fehler aufgetreten.",
@@ -125,6 +147,63 @@ export const de = {
zurueck: "Zurück", zurueck: "Zurück",
weiter: "Weiter", weiter: "Weiter",
}, },
verwaltung: {
titel: "Verwaltung",
navProfil: "Profil",
navFahrzeuge: "Fahrzeuge",
navGeraete: "Geräte",
navBenutzer: "Benutzer",
speichern: "Speichern",
abbrechen: "Abbrechen",
loeschen: "Löschen",
anlegen: "Anlegen",
bearbeiten: "Bearbeiten",
neu: "Neu",
name: "Name",
funkrufname: "Funkrufname",
notiz: "Notiz",
status: "Status",
kategorie: "Kategorie",
zuordnung: "Zuordnung",
imGeraetehaus: "im Gerätehaus",
vorlage: "Fahrzeug-Vorlage",
keineVorlage: "Ohne Vorlage (frei)",
vorlageWaehlen: "Vorlage wählen",
merkmale: "Merkmale",
keineMerkmale: "Für diese Auswahl sind keine Merkmale hinterlegt.",
fahrzeugAnlegen: "Fahrzeug anlegen",
fahrzeugBearbeiten: "Fahrzeug bearbeiten",
geraetAnlegen: "Gerät anlegen",
geraetBearbeiten: "Gerät bearbeiten",
keineFahrzeuge: "Noch keine Fahrzeuge erfasst.",
keineGeraete: "Noch keine Geräte erfasst.",
keineBenutzer: "Noch keine Benutzer erfasst.",
profilTitel: "Wehr-Profil",
profilGespeichert: "Profil gespeichert.",
geocodeOk: "Adresse geokodiert.",
geocodeWarnung:
"Adresse konnte nicht geokodiert werden. Daten wurden dennoch gespeichert.",
strasse: "Straße",
plz: "PLZ",
ort: "Ort",
email: "E-Mail",
telefon: "Telefon",
wehrfuehrer: "Wehrführer",
funkrufnameSchema: "Funkrufname-Schema",
rolle: "Rolle",
rolleAdmin: "Wehr-Admin",
rolleRead: "Lesend",
benutzerAnlegen: "Benutzer anlegen",
deaktivieren: "Deaktivieren",
aktiv: "aktiv",
inaktiv: "inaktiv",
authLokal: "lokal",
authAuthentik: "Authentik",
tempPasswort:
"Einmal-Passwort (nur jetzt sichtbar, bitte sicher übergeben):",
loeschenBestaetigen: "Wirklich löschen?",
pflichtfeld: "Pflichtfeld",
},
} as const; } as const;
type Leaf = string; type Leaf = string;

49
src/lib/merkmale/types.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* Geteilte Typen für den typisierten Merkmal-Wert-Editor (Workstream 7).
*
* REIN: keine DB-/IO-Abhängigkeit, damit Validierung und Editor ohne laufendes
* Postgres testbar sind. Die DB-Schreibseite (`upsertMerkmalValues`) konsumiert
* `MerkmalValueInput`.
*/
import type { merkmalTypEnum } from "@/db/schema";
/** Fachlicher Merkmal-Typ (Single Source of Truth: DB-Enum `merkmal_typ`). */
export type MerkmalTyp = (typeof merkmalTypEnum.enumValues)[number];
/** Eine Auswahloption eines `enum`-Merkmals. */
export interface MerkmalOption {
wert: string;
label: string;
}
/**
* Ein zur Bearbeitung angebotenes Merkmal (aus Vorlage oder Kategorie
* aufgelöst). `pflicht` markiert Vorlagen-Pflichtfelder. `vorgabe*` sind die
* typgerecht vorbefüllten Standardwerte (drei Spalten, exakt einer passt zum
* `typ`).
*/
export interface MerkmalDefinition {
merkmalId: string;
name: string;
typ: MerkmalTyp;
einheit: string | null;
pflicht: boolean;
reihenfolge: number;
optionen: MerkmalOption[];
vorgabeNum: number | null;
vorgabeText: string | null;
vorgabeBool: boolean | null;
}
/**
* Ein einzelner, an der Server-Grenze validierter Merkmal-Wert. Genau eine der
* Wertspalten ist (typabhängig) gesetzt; alle `null`/leer => der Wert wird
* beim Upsert gelöscht (kein Eintrag).
*/
export interface MerkmalValueInput {
merkmalId: string;
num?: number | null;
text?: string | null;
bool?: boolean | null;
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { SECURITY_HEADERS } from "./headers";
/**
* Security-Header (Definition of Done #8, Querschnittsstandard 1). Offline
* lauffähig: prüft den statischen Header-Satz, der in next.config.ts
* eingehängt wird. Die HTTP-seitige Verifikation (curl gegen Live-Server) ist
* deferred (kein Server in der Sandbox) und in security-headers.spec.ts gegen
* einen laufenden Server abgedeckt.
*/
describe("SECURITY_HEADERS", () => {
it("setzt X-Frame-Options auf DENY", () => {
expect(SECURITY_HEADERS["X-Frame-Options"]).toBe("DENY");
});
it("setzt X-Content-Type-Options auf nosniff", () => {
expect(SECURITY_HEADERS["X-Content-Type-Options"]).toBe("nosniff");
});
it("setzt HSTS mit includeSubDomains", () => {
const hsts = SECURITY_HEADERS["Strict-Transport-Security"];
expect(hsts).toMatch(/max-age=\d+/);
expect(hsts).toContain("includeSubDomains");
});
it("erlaubt Geolocation nur für self (Permissions-Policy)", () => {
expect(SECURITY_HEADERS["Permissions-Policy"]).toContain(
"geolocation=(self)",
);
});
it("hat eine CSP mit default-src 'self', frame-ancestors 'none', form-action 'self'", () => {
const csp = SECURITY_HEADERS["Content-Security-Policy"];
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("form-action 'self'");
});
it("erlaubt img-src self/data/blob und worker-src self/blob (für Karten/Web-Worker)", () => {
const csp = SECURITY_HEADERS["Content-Security-Policy"];
expect(csp).toContain("img-src 'self' data: blob:");
expect(csp).toContain("worker-src 'self' blob:");
});
it("setzt eine Referrer-Policy", () => {
expect(SECURITY_HEADERS["Referrer-Policy"]).toBeTruthy();
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { brigadeUserCreateSchema, brigadeUserDeactivateSchema } from "../brigade-user";
describe("brigadeUserCreateSchema", () => {
const valid = {
email: "Neu@FF.at",
name: "Neue Person",
rolle: "wehr_read",
};
it("akzeptiert wehr_admin und wehr_read und normalisiert die E-Mail", () => {
const r = brigadeUserCreateSchema.safeParse(valid);
expect(r.success).toBe(true);
if (r.success) expect(r.data.email).toBe("neu@ff.at");
expect(brigadeUserCreateSchema.safeParse({ ...valid, rolle: "wehr_admin" }).success).toBe(true);
});
it("lehnt platform_admin ab (nicht zuweisbar durch Wehr-Admin)", () => {
expect(
brigadeUserCreateSchema.safeParse({ ...valid, rolle: "platform_admin" }).success,
).toBe(false);
});
it("verlangt Name und gültige E-Mail", () => {
expect(brigadeUserCreateSchema.safeParse({ ...valid, name: "" }).success).toBe(false);
expect(brigadeUserCreateSchema.safeParse({ ...valid, email: "keine-mail" }).success).toBe(false);
});
});
describe("brigadeUserDeactivateSchema", () => {
it("verlangt eine gültige UUID", () => {
expect(
brigadeUserDeactivateSchema.safeParse({ userId: "11111111-1111-1111-1111-111111111111" }).success,
).toBe(true);
expect(brigadeUserDeactivateSchema.safeParse({ userId: "x" }).success).toBe(false);
});
});

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import {
equipmentBaseSchema,
equipmentStatusSchema,
} from "../equipment";
const CAT = "11111111-1111-1111-1111-111111111111";
const VEH = "22222222-2222-2222-2222-222222222222";
describe("equipmentBaseSchema", () => {
it("verlangt Name und Kategorie", () => {
expect(equipmentBaseSchema.safeParse({ name: "", categoryId: CAT }).success).toBe(false);
expect(equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: "x" }).success).toBe(false);
});
it("erlaubt leere vehicleId (im Gerätehaus) -> undefined", () => {
const r = equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: CAT, vehicleId: "" });
expect(r.success).toBe(true);
if (r.success) expect(r.data.vehicleId).toBeUndefined();
});
it("akzeptiert eine gültige vehicleId (Zuordnung)", () => {
const r = equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: CAT, vehicleId: VEH });
expect(r.success).toBe(true);
if (r.success) expect(r.data.vehicleId).toBe(VEH);
});
});
describe("equipmentStatusSchema", () => {
it("akzeptiert nur asset_status-Werte", () => {
expect(equipmentStatusSchema.safeParse({ id: CAT, status: "ausser_dienst" }).success).toBe(true);
expect(equipmentStatusSchema.safeParse({ id: CAT, status: "weg" }).success).toBe(false);
});
});

View File

@@ -0,0 +1,152 @@
import { describe, it, expect } from "vitest";
import {
vehicleBaseSchema,
vehicleStatusSchema,
buildMerkmalValuesSchema,
} from "../vehicle";
import type { MerkmalDefinition } from "@/lib/merkmale/types";
const def = (over: Partial<MerkmalDefinition>): MerkmalDefinition => ({
merkmalId: "11111111-1111-1111-1111-111111111111",
name: "Merkmal",
typ: "text",
einheit: null,
pflicht: false,
reihenfolge: 0,
optionen: [],
vorgabeNum: null,
vorgabeText: null,
vorgabeBool: null,
...over,
});
describe("vehicleBaseSchema", () => {
it("verlangt einen Namen", () => {
const r = vehicleBaseSchema.safeParse({ name: "", funkrufname: "", notiz: "" });
expect(r.success).toBe(false);
});
it("akzeptiert Name mit leeren Optionalfeldern (-> undefined)", () => {
const r = vehicleBaseSchema.safeParse({
name: "HLF 2 Musterdorf",
funkrufname: "",
notiz: "",
templateId: "",
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.funkrufname).toBeUndefined();
expect(r.data.templateId).toBeUndefined();
}
});
it("akzeptiert eine gültige templateId (uuid)", () => {
const r = vehicleBaseSchema.safeParse({
name: "HLF 2",
templateId: "22222222-2222-2222-2222-222222222222",
});
expect(r.success).toBe(true);
});
});
describe("vehicleStatusSchema", () => {
it("akzeptiert nur asset_status-Werte", () => {
expect(vehicleStatusSchema.safeParse({ id: "33333333-3333-3333-3333-333333333333", status: "wartung" }).success).toBe(true);
expect(vehicleStatusSchema.safeParse({ id: "33333333-3333-3333-3333-333333333333", status: "kaputt" }).success).toBe(false);
});
});
describe("buildMerkmalValuesSchema", () => {
it("validiert number-Merkmale typgerecht und coerced Strings", () => {
const schema = buildMerkmalValuesSchema([
def({ typ: "number", name: "Löschwassertank", einheit: "l" }),
]);
const r = schema.safeParse([
{ merkmalId: def({}).merkmalId, num: "2000" },
]);
expect(r.success).toBe(true);
if (r.success) expect(r.data[0]?.num).toBe(2000);
});
it("lehnt nicht-numerische Eingabe für number ab", () => {
const schema = buildMerkmalValuesSchema([def({ typ: "number" })]);
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: "abc" }]);
expect(r.success).toBe(false);
});
it("erzwingt Pflichtmerkmale (number ohne Wert -> Fehler)", () => {
const schema = buildMerkmalValuesSchema([def({ typ: "number", pflicht: true })]);
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: null }]);
expect(r.success).toBe(false);
});
it("erlaubt optionale Merkmale ohne Wert", () => {
const schema = buildMerkmalValuesSchema([def({ typ: "number", pflicht: false })]);
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: null }]);
expect(r.success).toBe(true);
});
it("beschränkt enum-Werte auf erlaubte Optionen", () => {
const schema = buildMerkmalValuesSchema([
def({ typ: "enum", optionen: [{ wert: "TS", label: "Tragkraftspritze" }] }),
]);
expect(schema.safeParse([{ merkmalId: def({}).merkmalId, text: "TS" }]).success).toBe(true);
expect(schema.safeParse([{ merkmalId: def({}).merkmalId, text: "XX" }]).success).toBe(false);
});
it("coerced boolean-Merkmale", () => {
const schema = buildMerkmalValuesSchema([def({ typ: "boolean", name: "Allradantrieb" })]);
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, bool: true }]);
expect(r.success).toBe(true);
if (r.success) expect(r.data[0]?.bool).toBe(true);
});
it("ignoriert Werte zu unbekannten Merkmalen (nur erlaubte merkmalIds)", () => {
const schema = buildMerkmalValuesSchema([def({ typ: "text" })]);
const r = schema.safeParse([
{ merkmalId: "99999999-9999-9999-9999-999999999999", text: "hallo" },
]);
expect(r.success).toBe(false);
});
it("erzwingt Pflichtmerkmale auch bei vollständiger Auslassung (leeres Array)", () => {
const schema = buildMerkmalValuesSchema([
def({ typ: "number", pflicht: true, name: "Löschwassertank" }),
]);
// Pflichtmerkmal fehlt komplett -> Validierung muss greifen (nicht trauen).
expect(schema.safeParse([]).success).toBe(false);
});
it("erzwingt fehlende Pflichtmerkmale bei teilweise befülltem Array", () => {
const idA = "11111111-1111-1111-1111-11111111aaaa";
const idB = "11111111-1111-1111-1111-11111111bbbb";
const schema = buildMerkmalValuesSchema([
def({ merkmalId: idA, typ: "text", pflicht: true, name: "A" }),
def({ merkmalId: idB, typ: "boolean", pflicht: true, name: "B" }),
]);
// Nur A geliefert, Pflicht-B fehlt komplett.
const r = schema.safeParse([{ merkmalId: idA, text: "x" }]);
expect(r.success).toBe(false);
});
it("akzeptiert ein leeres Array, wenn keine Pflichtmerkmale definiert sind", () => {
const schema = buildMerkmalValuesSchema([def({ typ: "text", pflicht: false })]);
expect(schema.safeParse([]).success).toBe(true);
});
it("akzeptiert vollständig gesetzte Pflichtmerkmale", () => {
const idA = "11111111-1111-1111-1111-11111111aaaa";
const schema = buildMerkmalValuesSchema([
def({ merkmalId: idA, typ: "text", pflicht: true, name: "A" }),
]);
expect(schema.safeParse([{ merkmalId: idA, text: "x" }]).success).toBe(true);
});
it("lehnt ein vorhandenes, aber leeres Pflichtmerkmal weiterhin ab", () => {
const idA = "11111111-1111-1111-1111-11111111aaaa";
const schema = buildMerkmalValuesSchema([
def({ merkmalId: idA, typ: "text", pflicht: true, name: "A" }),
]);
expect(schema.safeParse([{ merkmalId: idA, text: "" }]).success).toBe(false);
});
});

View File

@@ -0,0 +1,35 @@
import { z } from "zod";
import { uuidSchema } from "./common";
/**
* Zod-Schemas für die Benutzerverwaltung im Wehr-Bereich (Workstream 7).
*
* WICHTIG (Sicherheit): Die Rolle ist auf `wehr_admin | wehr_read` beschränkt.
* Ein Wehr-Admin darf NIEMALS `platform_admin` vergeben — Zod lehnt das an der
* Grenze ab (Querschnittsstandard 4, Verteidigung in der Tiefe zusätzlich zum
* serverseitigen Scope-Guard).
*/
export const brigadeUserRoleSchema = z.enum(["wehr_admin", "wehr_read"], {
errorMap: () => ({ message: "Unzulässige Rolle." }),
});
export const brigadeUserCreateSchema = z.object({
email: z
.string()
.trim()
.email({ message: "Ungültige E-Mail." })
.transform((v) => v.toLowerCase()),
name: z.string().trim().min(1, { message: "Name ist Pflicht." }),
rolle: brigadeUserRoleSchema,
});
export type BrigadeUserCreateInput = z.infer<typeof brigadeUserCreateSchema>;
export const brigadeUserDeactivateSchema = z.object({
userId: uuidSchema,
});
export type BrigadeUserDeactivateInput = z.infer<
typeof brigadeUserDeactivateSchema
>;

View File

@@ -38,3 +38,37 @@ export const userResetSchema = z.object({
}); });
export type UserResetInput = z.infer<typeof userResetSchema>; export type UserResetInput = z.infer<typeof userResetSchema>;
/**
* Schema für die Profil-Bearbeitung durch den Wehr-Admin der EIGENEN Wehr
* (Workstream 7). Kein `name`/keine Anlage — nur Stamm-/Kontaktdaten. Die
* `brigadeId` kommt IMMER serverseitig aus der Session, nie aus dem Input.
*/
export const brigadeProfileSchema = z.object({
strasse: z.string().trim().min(1, { message: "Straße ist Pflicht." }),
plz: z.string().trim().min(1, { message: "PLZ ist Pflicht." }),
ort: z.string().trim().min(1, { message: "Ort ist Pflicht." }),
telefon: z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
email: z
.string()
.trim()
.email({ message: "Ungültige E-Mail." })
.optional()
.or(z.literal("").transform(() => undefined)),
wehrfuehrer: z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
funkrufnameSchema: z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v)),
});
export type BrigadeProfileInput = z.infer<typeof brigadeProfileSchema>;

View File

@@ -0,0 +1,45 @@
import { z } from "zod";
import { assetStatusEnum } from "@/db/schema";
import { uuidSchema, optionalText } from "./common";
/**
* Zod-Schemas für Geräte/Beladung (Workstream 7). `vehicleId = undefined`
* bedeutet „im Gerätehaus" (DB-Spalte NULL). Die Zuordnung darf serverseitig
* nur auf ein Fahrzeug DERSELBEN Wehr zeigen (Scope-Prüfung in der Action).
* `status` über das DB-Enum `asset_status` (Single Source of Truth).
*/
export const assetStatusSchema = z.enum(assetStatusEnum.enumValues);
const optionalUuid = z
.string()
.trim()
.optional()
.transform((v) => (v === undefined || v === "" ? undefined : v))
.refine((v) => v === undefined || uuidSchema.safeParse(v).success, {
message: "Ungültige ID.",
});
export const equipmentBaseSchema = z.object({
name: z.string().trim().min(1, { message: "Gerätename ist Pflicht." }),
categoryId: uuidSchema,
vehicleId: optionalUuid,
notiz: optionalText,
});
export type EquipmentBaseInput = z.infer<typeof equipmentBaseSchema>;
export const equipmentCreateSchema = equipmentBaseSchema;
export const equipmentUpdateSchema = z.object({
id: uuidSchema,
...equipmentBaseSchema.shape,
});
export type EquipmentUpdateInput = z.infer<typeof equipmentUpdateSchema>;
export const equipmentStatusSchema = z.object({
id: uuidSchema,
status: assetStatusSchema,
});
export type EquipmentStatusInput = z.infer<typeof equipmentStatusSchema>;
export const equipmentIdSchema = z.object({ id: uuidSchema });

View File

@@ -0,0 +1,181 @@
import { z } from "zod";
import { assetStatusEnum } from "@/db/schema";
import { uuidSchema, optionalText } from "./common";
import type { MerkmalDefinition } from "@/lib/merkmale/types";
/**
* Zod-Schemas für Fahrzeuge (Workstream 7). `funkrufname` ist eine SPALTE
* (kein Merkmal). `status` über das DB-Enum `asset_status` (Single Source of
* Truth). Leere Optionalfelder werden zu `undefined` normalisiert.
*/
export const assetStatusSchema = z.enum(assetStatusEnum.enumValues);
const optionalUuid = z
.string()
.trim()
.optional()
.transform((v) => (v === undefined || v === "" ? undefined : v))
.refine((v) => v === undefined || uuidSchema.safeParse(v).success, {
message: "Ungültige ID.",
});
export const vehicleBaseSchema = z.object({
name: z.string().trim().min(1, { message: "Fahrzeugname ist Pflicht." }),
funkrufname: optionalText,
notiz: optionalText,
templateId: optionalUuid,
});
export type VehicleBaseInput = z.infer<typeof vehicleBaseSchema>;
export const vehicleCreateSchema = vehicleBaseSchema;
export const vehicleUpdateSchema = z.object({
id: uuidSchema,
...vehicleBaseSchema.shape,
});
export type VehicleUpdateInput = z.infer<typeof vehicleUpdateSchema>;
export const vehicleStatusSchema = z.object({
id: uuidSchema,
status: assetStatusSchema,
});
export type VehicleStatusInput = z.infer<typeof vehicleStatusSchema>;
export const vehicleIdSchema = z.object({ id: uuidSchema });
/**
* Baut ein typgerechtes Zod-Schema für eine Liste von Merkmal-Werten, abgeleitet
* aus den angebotenen `MerkmalDefinition`s (Vorlage/Kategorie). Pflichtmerkmale
* müssen einen Wert haben; `enum`-Werte sind auf die Optionen beschränkt;
* Zahlen werden gecoerced. Werte zu nicht angebotenen `merkmalId`s sind
* unzulässig (kein Schmuggeln fremder Merkmale).
*
* REIN: nimmt Definitionen als Parameter, damit es ohne DB testbar ist.
*/
export function buildMerkmalValuesSchema(definitionen: MerkmalDefinition[]) {
const byId = new Map(definitionen.map((d) => [d.merkmalId, d]));
const single = z
.object({
merkmalId: uuidSchema,
num: z.unknown().optional(),
text: z.unknown().optional(),
bool: z.unknown().optional(),
})
.superRefine((raw, ctx) => {
const def = byId.get(raw.merkmalId);
if (!def) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["merkmalId"],
message: "Unbekanntes Merkmal.",
});
return;
}
if (def.typ === "number") {
const v = parseNumber(raw.num);
if (v === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["num"],
message: `${def.name}“ muss eine Zahl sein.`,
});
} else if (v === null && def.pflicht) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["num"],
message: `${def.name}“ ist Pflicht.`,
});
}
return;
}
if (def.typ === "boolean") {
const v = parseBool(raw.bool);
if (def.pflicht && v == null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["bool"],
message: `${def.name}“ ist Pflicht.`,
});
}
return;
}
// text | enum
const text = parseText(raw.text);
if (def.pflicht && (text == null || text === "")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["text"],
message: `${def.name}“ ist Pflicht.`,
});
return;
}
if (def.typ === "enum" && text != null && text !== "") {
const erlaubt = def.optionen.map((o) => o.wert);
if (!erlaubt.includes(text)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["text"],
message: `Ungültige Auswahl für „${def.name}“.`,
});
}
}
})
.transform((raw) => {
const def = byId.get(raw.merkmalId)!;
if (def.typ === "number") {
const v = parseNumber(raw.num);
return { merkmalId: raw.merkmalId, num: v ?? null };
}
if (def.typ === "boolean") {
return { merkmalId: raw.merkmalId, bool: parseBool(raw.bool) };
}
const text = parseText(raw.text);
return { merkmalId: raw.merkmalId, text: text ?? null };
});
return z.array(single).superRefine((werte, ctx) => {
// Vollständigkeit auf Array-Ebene: ein Pflichtmerkmal, das komplett fehlt
// (kein Element mit gesetztem Wert), wird sonst von der Pro-Element-Prüfung
// nicht erfasst. "Validieren, nicht vertrauen" (Querschnittsstandard 4).
for (const def of definitionen) {
if (!def.pflicht) continue;
const hatWert = werte.some((w) => {
if (w.merkmalId !== def.merkmalId) return false;
if (def.typ === "number") return w.num != null;
if (def.typ === "boolean") return w.bool != null;
return w.text != null && w.text !== "";
});
if (!hatWert) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${def.name}“ ist Pflicht.`,
});
}
}
});
}
/** `undefined` = ungültig (NaN), `null` = leer, sonst die Zahl. */
function parseNumber(v: unknown): number | null | undefined {
if (v == null || v === "") return null;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : undefined;
}
function parseBool(v: unknown): boolean | null {
if (v == null || v === "") return null;
if (typeof v === "boolean") return v;
if (v === "true") return true;
if (v === "false") return false;
return null;
}
function parseText(v: unknown): string | null {
if (v == null) return null;
return typeof v === "string" ? v : String(v);
}

View File

@@ -0,0 +1,131 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { users } from "@/db/schema";
import { requireWehrAdmin } from "@/lib/auth/guards";
import { hashPassword } from "@/lib/auth/password";
import { generateTempPassword } from "@/lib/admin/provisioning";
import { writeAudit } from "@/lib/audit";
import {
brigadeUserCreateSchema,
brigadeUserDeactivateSchema,
} from "@/lib/validation/brigade-user";
export type CreateUserResult =
| { ok: true; tempPassword: string }
| { ok: false; error: string };
export type DeactivateResult =
| { ok: true }
| { ok: false; error: string };
/**
* Legt einen Benutzer (lokales Konto) der EIGENEN Wehr an. Rolle ist per Zod auf
* `wehr_admin|wehr_read` beschränkt (platform_admin wird abgelehnt). Passwort
* via argon2id (OWASP-Minima). `brigadeId`/`erstelltVon` kommen IMMER aus der
* Session. Audit `user.create`. Liefert das Einmal-Passwort genau einmal.
*/
export async function createBrigadeUser(
input: unknown,
): Promise<CreateUserResult> {
const s = await requireWehrAdmin();
const parsed = brigadeUserCreateSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe.",
};
}
const d = parsed.data;
const temp = generateTempPassword();
const hash = await hashPassword(temp);
try {
await db.transaction(async (tx) => {
const [u] = await tx
.insert(users)
.values({
brigadeId: s.user.brigadeId,
rolle: d.rolle,
authTyp: "local",
email: d.email,
name: d.name,
passwortHash: hash,
aktiv: true,
erstelltVon: s.user.id,
})
.returning({ id: users.id });
if (!u) throw new Error("Benutzer konnte nicht angelegt werden.");
await writeAudit(
s.user.id,
"user.create",
"user",
u.id,
{ rolle: d.rolle, authTyp: "local" },
tx,
);
});
} catch (e) {
// Eindeutigkeitsverletzung (E-Mail bereits vergeben) o. Ä.
const msg =
e instanceof Error && /unique|duplicate|users_email/i.test(e.message)
? "Diese E-Mail ist bereits vergeben."
: "Benutzer konnte nicht angelegt werden.";
return { ok: false, error: msg };
}
revalidatePath("/verwaltung/benutzer");
return { ok: true, tempPassword: temp };
}
/**
* Deaktiviert einen Benutzer der EIGENEN Wehr. Selbst-Deaktivierung ist
* verboten (sonst sperrt sich ein Admin aus). Scope: nur Benutzer derselben
* Wehr. Audit `user.deactivate`.
*/
export async function deactivateBrigadeUser(
input: unknown,
): Promise<DeactivateResult> {
const s = await requireWehrAdmin();
const parsed = brigadeUserDeactivateSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: "Ungültige ID." };
if (parsed.data.userId === s.user.id) {
return { ok: false, error: "Sie können sich nicht selbst deaktivieren." };
}
const [target] = await db
.select({ id: users.id })
.from(users)
.where(
and(
eq(users.id, parsed.data.userId),
eq(users.brigadeId, s.user.brigadeId),
),
);
if (!target) return { ok: false, error: "Benutzer nicht gefunden." };
await db.transaction(async (tx) => {
await tx
.update(users)
.set({ aktiv: false })
.where(
and(
eq(users.id, parsed.data.userId),
eq(users.brigadeId, s.user.brigadeId),
),
);
await writeAudit(
s.user.id,
"user.deactivate",
"user",
parsed.data.userId,
undefined,
tx,
);
});
revalidatePath("/verwaltung/benutzer");
return { ok: true };
}

View File

@@ -0,0 +1,66 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { brigades } from "@/db/schema";
import { requireWehrAdmin } from "@/lib/auth/guards";
import { brigadeProfileSchema } from "@/lib/validation/brigade";
import { geocodeAddress } from "@/lib/geo/nominatim";
import { writeAudit } from "@/lib/audit";
export type ProfileActionResult =
| { ok: true; geocodeWarnung: boolean }
| { ok: false; error: string };
/**
* Aktualisiert das Profil der EIGENEN Wehr (Default-deny: Guard zuerst).
* `brigadeId` kommt IMMER aus der Session. Geocoding inline via `geocodeAddress`
* (lat/lng selbst geschrieben; kein zweiter Geo-Pfad). Audit
* `brigade.profile_update`. Nicht geokodierbar => Speichern trotzdem, Warnung
* an den Aufrufer (Querschnittsstandard 4/6).
*/
export async function updateBrigadeProfile(
input: unknown,
): Promise<ProfileActionResult> {
const s = await requireWehrAdmin();
const parsed = brigadeProfileSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe.",
};
}
const d = parsed.data;
const query = `${d.strasse}, ${d.plz} ${d.ort}, Österreich`;
const geo = await geocodeAddress(query);
const geocodeWarnung = geo.status !== "ok";
await db
.update(brigades)
.set({
strasse: d.strasse,
plz: d.plz,
ort: d.ort,
telefon: d.telefon ?? null,
email: d.email ?? null,
wehrfuehrer: d.wehrfuehrer ?? null,
funkrufnameSchema: d.funkrufnameSchema ?? null,
geocodeQuery: query,
geocodedAt: new Date(),
...(geo.status === "ok"
? { lat: geo.coords.lat, lng: geo.coords.lng, geocodeStatus: "ok" }
: { lat: null, lng: null, geocodeStatus: geo.status }),
})
.where(eq(brigades.id, s.user.brigadeId));
await writeAudit(
s.user.id,
"brigade.profile_update",
"brigade",
s.user.brigadeId,
{ geocodeWarnung },
);
revalidatePath("/verwaltung/profil");
return { ok: true, geocodeWarnung };
}

View File

@@ -0,0 +1,197 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { notFound } from "next/navigation";
import { db } from "@/db";
import { equipment } from "@/db/schema";
import { requireWehrAdmin } from "@/lib/auth/guards";
import { writeAudit } from "@/lib/audit";
import {
equipmentCreateSchema,
equipmentUpdateSchema,
equipmentStatusSchema,
equipmentIdSchema,
} from "@/lib/validation/equipment";
import { buildMerkmalValuesSchema } from "@/lib/validation/vehicle";
import { getMerkmaleForCategory } from "@/server/data/merkmale";
import {
getEquipmentForBrigade,
} from "@/server/data/equipment";
import { vehicleBelongsToBrigade } from "@/server/data/vehicles";
import { upsertMerkmalValues } from "@/server/merkmale/upsertValues";
import type { MerkmalDefinition } from "@/lib/merkmale/types";
export type ActionResult =
| { ok: true; id: string }
| { ok: false; error: string };
/**
* Liefert die Merkmal-Definitionen einer Geräte-Kategorie (für die
* Editor-Vorbefüllung). Guard zuerst (default-deny, auch für Lesen).
*/
export async function getCategoryMerkmaleAction(
categoryId: string,
): Promise<MerkmalDefinition[]> {
await requireWehrAdmin();
if (!categoryId) return [];
return getMerkmaleForCategory(categoryId);
}
/**
* Prüft, dass eine optional gewählte `vehicleId` zu EINEM Fahrzeug DERSELBEN
* Wehr gehört. `undefined` => „im Gerätehaus" (zulässig). Verhindert die
* Zuordnung zu fremden Fahrzeugen (Scoping).
*/
async function assertVehicleScope(
vehicleId: string | undefined,
brigadeId: string,
): Promise<boolean> {
if (!vehicleId) return true;
return vehicleBelongsToBrigade(vehicleId, brigadeId);
}
/** Legt ein Gerät der eigenen Wehr an (Guard zuerst, Audit equipment.create). */
export async function createEquipment(
input: unknown,
rawWerte: unknown,
): Promise<ActionResult> {
const s = await requireWehrAdmin();
const parsed = equipmentCreateSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
const d = parsed.data;
if (!(await assertVehicleScope(d.vehicleId, s.user.brigadeId))) {
return { ok: false, error: "Fahrzeug nicht gefunden." };
}
const defs = await getMerkmaleForCategory(d.categoryId);
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
if (!werteParsed.success) {
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
}
const id = await db.transaction(async (tx) => {
const [e] = await tx
.insert(equipment)
.values({
brigadeId: s.user.brigadeId,
categoryId: d.categoryId,
vehicleId: d.vehicleId ?? null,
name: d.name,
})
.returning({ id: equipment.id });
if (!e) throw new Error("Gerät konnte nicht angelegt werden.");
await upsertMerkmalValues(tx, "equipment", e.id, werteParsed.data);
await writeAudit(
s.user.id,
"equipment.create",
"equipment",
e.id,
{ categoryId: d.categoryId, zugeordnet: d.vehicleId != null },
tx,
);
return e.id;
});
revalidatePath("/verwaltung/geraete");
return { ok: true, id };
}
/** Bearbeitet ein Gerät, NUR wenn es der eigenen Wehr gehört. */
export async function updateEquipment(
input: unknown,
rawWerte: unknown,
): Promise<ActionResult> {
const s = await requireWehrAdmin();
const parsed = equipmentUpdateSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
const d = parsed.data;
const existing = await getEquipmentForBrigade(d.id, s.user.brigadeId);
if (!existing) return { ok: false, error: "Gerät nicht gefunden." };
if (!(await assertVehicleScope(d.vehicleId, s.user.brigadeId))) {
return { ok: false, error: "Fahrzeug nicht gefunden." };
}
const defs = await getMerkmaleForCategory(d.categoryId);
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
if (!werteParsed.success) {
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
}
await db.transaction(async (tx) => {
await tx
.update(equipment)
.set({
name: d.name,
categoryId: d.categoryId,
vehicleId: d.vehicleId ?? null,
})
.where(
and(eq(equipment.id, d.id), eq(equipment.brigadeId, s.user.brigadeId)),
);
await upsertMerkmalValues(tx, "equipment", d.id, werteParsed.data);
await writeAudit(s.user.id, "equipment.update", "equipment", d.id, undefined, tx);
});
revalidatePath("/verwaltung/geraete");
revalidatePath(`/verwaltung/geraete/${d.id}`);
return { ok: true, id: d.id };
}
/** Setzt den Status eines eigenen Geräts (Audit equipment.status). */
export async function setEquipmentStatus(input: unknown): Promise<ActionResult> {
const s = await requireWehrAdmin();
const parsed = equipmentStatusSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: "Ungültige Eingabe." };
const existing = await getEquipmentForBrigade(parsed.data.id, s.user.brigadeId);
if (!existing) return { ok: false, error: "Gerät nicht gefunden." };
await db.transaction(async (tx) => {
await tx
.update(equipment)
.set({ status: parsed.data.status })
.where(
and(
eq(equipment.id, parsed.data.id),
eq(equipment.brigadeId, s.user.brigadeId),
),
);
await writeAudit(
s.user.id,
"equipment.status",
"equipment",
parsed.data.id,
{ status: parsed.data.status },
tx,
);
});
revalidatePath("/verwaltung/geraete");
return { ok: true, id: parsed.data.id };
}
/** Löscht ein eigenes Gerät (Audit equipment.delete). */
export async function deleteEquipment(input: unknown): Promise<ActionResult> {
const s = await requireWehrAdmin();
const parsed = equipmentIdSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: "Ungültige ID." };
const existing = await getEquipmentForBrigade(parsed.data.id, s.user.brigadeId);
if (!existing) {
notFound();
}
await db.transaction(async (tx) => {
await tx
.delete(equipment)
.where(
and(
eq(equipment.id, parsed.data.id),
eq(equipment.brigadeId, s.user.brigadeId),
),
);
await writeAudit(s.user.id, "equipment.delete", "equipment", parsed.data.id, undefined, tx);
});
revalidatePath("/verwaltung/geraete");
return { ok: true, id: parsed.data.id };
}

View File

@@ -0,0 +1,191 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { notFound } from "next/navigation";
import { db } from "@/db";
import { vehicles } from "@/db/schema";
import { requireWehrAdmin } from "@/lib/auth/guards";
import { writeAudit } from "@/lib/audit";
import {
vehicleCreateSchema,
vehicleUpdateSchema,
vehicleStatusSchema,
vehicleIdSchema,
buildMerkmalValuesSchema,
} from "@/lib/validation/vehicle";
import {
getMerkmaleForTemplate,
} from "@/server/data/merkmale";
import { getVehicleForBrigade } from "@/server/data/vehicles";
import { upsertMerkmalValues } from "@/server/merkmale/upsertValues";
import type { MerkmalDefinition } from "@/lib/merkmale/types";
export type ActionResult =
| { ok: true; id: string }
| { ok: false; error: string };
/**
* Löst die für ein Fahrzeug erlaubten Merkmal-Definitionen serverseitig auf
* (NUR aus der Vorlage; ohne Vorlage keine Merkmale). Damit kann der Client
* keine fremden Merkmale schmuggeln — die Validierung baut ihr Schema NUR aus
* diesen Definitionen.
*/
async function vehicleMerkmalDefs(
templateId: string | undefined,
): Promise<MerkmalDefinition[]> {
if (!templateId) return [];
return getMerkmaleForTemplate(templateId);
}
/**
* Liefert die Merkmal-Definitionen einer Vorlage (für die Vorbefüllung des
* Editors im Anlage-Formular). Guard zuerst (default-deny), auch für Lesen.
*/
export async function getTemplateMerkmaleAction(
templateId: string,
): Promise<MerkmalDefinition[]> {
await requireWehrAdmin();
if (!templateId) return [];
return getMerkmaleForTemplate(templateId);
}
/** Legt ein Fahrzeug der EIGENEN Wehr an (Guard zuerst, Audit vehicle.create). */
export async function createVehicle(
input: unknown,
rawWerte: unknown,
): Promise<ActionResult> {
const s = await requireWehrAdmin();
const parsed = vehicleCreateSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
const d = parsed.data;
const defs = await vehicleMerkmalDefs(d.templateId);
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
if (!werteParsed.success) {
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
}
const id = await db.transaction(async (tx) => {
const [v] = await tx
.insert(vehicles)
.values({
brigadeId: s.user.brigadeId,
templateId: d.templateId ?? null,
name: d.name,
funkrufname: d.funkrufname ?? null,
notiz: d.notiz ?? null,
})
.returning({ id: vehicles.id });
if (!v) throw new Error("Fahrzeug konnte nicht angelegt werden.");
await upsertMerkmalValues(tx, "vehicle", v.id, werteParsed.data);
await writeAudit(
s.user.id,
"vehicle.create",
"vehicle",
v.id,
{ templateId: d.templateId ?? null },
tx,
);
return v.id;
});
revalidatePath("/verwaltung/fahrzeuge");
return { ok: true, id };
}
/** Bearbeitet ein Fahrzeug, NUR wenn es der eigenen Wehr gehört. */
export async function updateVehicle(
input: unknown,
rawWerte: unknown,
): Promise<ActionResult> {
const s = await requireWehrAdmin();
const parsed = vehicleUpdateSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
}
const d = parsed.data;
const existing = await getVehicleForBrigade(d.id, s.user.brigadeId);
if (!existing) return { ok: false, error: "Fahrzeug nicht gefunden." };
// Vorlage ist nach Anlage fix: erlaubte Merkmale aus der GESPEICHERTEN Vorlage.
const defs = await vehicleMerkmalDefs(existing.templateId ?? undefined);
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
if (!werteParsed.success) {
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
}
await db.transaction(async (tx) => {
await tx
.update(vehicles)
.set({
name: d.name,
funkrufname: d.funkrufname ?? null,
notiz: d.notiz ?? null,
})
.where(and(eq(vehicles.id, d.id), eq(vehicles.brigadeId, s.user.brigadeId)));
await upsertMerkmalValues(tx, "vehicle", d.id, werteParsed.data);
await writeAudit(s.user.id, "vehicle.update", "vehicle", d.id, undefined, tx);
});
revalidatePath("/verwaltung/fahrzeuge");
revalidatePath(`/verwaltung/fahrzeuge/${d.id}`);
return { ok: true, id: d.id };
}
/** Setzt den Status eines eigenen Fahrzeugs (Audit vehicle.status). */
export async function setVehicleStatus(input: unknown): Promise<ActionResult> {
const s = await requireWehrAdmin();
const parsed = vehicleStatusSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: "Ungültige Eingabe." };
const existing = await getVehicleForBrigade(parsed.data.id, s.user.brigadeId);
if (!existing) return { ok: false, error: "Fahrzeug nicht gefunden." };
await db.transaction(async (tx) => {
await tx
.update(vehicles)
.set({ status: parsed.data.status })
.where(
and(
eq(vehicles.id, parsed.data.id),
eq(vehicles.brigadeId, s.user.brigadeId),
),
);
await writeAudit(
s.user.id,
"vehicle.status",
"vehicle",
parsed.data.id,
{ status: parsed.data.status },
tx,
);
});
revalidatePath("/verwaltung/fahrzeuge");
return { ok: true, id: parsed.data.id };
}
/** Löscht ein eigenes Fahrzeug (Audit vehicle.delete). */
export async function deleteVehicle(input: unknown): Promise<ActionResult> {
const s = await requireWehrAdmin();
const parsed = vehicleIdSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: "Ungültige ID." };
const existing = await getVehicleForBrigade(parsed.data.id, s.user.brigadeId);
if (!existing) {
notFound();
}
await db.transaction(async (tx) => {
await tx
.delete(vehicles)
.where(
and(
eq(vehicles.id, parsed.data.id),
eq(vehicles.brigadeId, s.user.brigadeId),
),
);
await writeAudit(s.user.id, "vehicle.delete", "vehicle", parsed.data.id, undefined, tx);
});
revalidatePath("/verwaltung/fahrzeuge");
return { ok: true, id: parsed.data.id };
}

View File

@@ -0,0 +1,34 @@
import { asc, eq } from "drizzle-orm";
import { db } from "@/db";
import { users } from "@/db/schema";
/**
* Lesehelfer für die Benutzer EINER Wehr (Workstream 7). Scope kommt aus der
* Session. Passwort-Hashes werden NIE selektiert (kein Daten-Leak).
*/
export interface BrigadeUserListItem {
id: string;
name: string;
email: string;
rolle: (typeof users.$inferSelect)["rolle"];
authTyp: (typeof users.$inferSelect)["authTyp"];
aktiv: boolean;
}
export async function listUsersForBrigade(
brigadeId: string,
): Promise<BrigadeUserListItem[]> {
return db
.select({
id: users.id,
name: users.name,
email: users.email,
rolle: users.rolle,
authTyp: users.authTyp,
aktiv: users.aktiv,
})
.from(users)
.where(eq(users.brigadeId, brigadeId))
.orderBy(asc(users.name));
}

View File

@@ -0,0 +1,69 @@
import { and, asc, desc, eq } from "drizzle-orm";
import { db } from "@/db";
import {
equipment,
equipmentCategories,
vehicles,
} from "@/db/schema";
/**
* Lesehelfer für Geräte des Wehr-Bereichs (Workstream 7). Alle Helfer sind auf
* eine `brigadeId` beschränkt (Scope aus Session). `vehicleId IS NULL` =
* „im Gerätehaus".
*/
export interface EquipmentListItem {
id: string;
name: string;
status: (typeof equipment.$inferSelect)["status"];
categoryName: string;
vehicleId: string | null;
vehicleName: string | null;
}
/** Liste der Geräte EINER Wehr mit Kategorie- und Zuordnungsnamen. */
export async function listEquipmentForBrigade(
brigadeId: string,
): Promise<EquipmentListItem[]> {
return db
.select({
id: equipment.id,
name: equipment.name,
status: equipment.status,
categoryName: equipmentCategories.name,
vehicleId: equipment.vehicleId,
vehicleName: vehicles.name,
})
.from(equipment)
.innerJoin(
equipmentCategories,
eq(equipmentCategories.id, equipment.categoryId),
)
.leftJoin(vehicles, eq(vehicles.id, equipment.vehicleId))
.where(eq(equipment.brigadeId, brigadeId))
.orderBy(desc(equipment.erstelltAm));
}
/** Lädt EIN Gerät scoped auf die Wehr (sonst `null` -> notFound). */
export async function getEquipmentForBrigade(
equipmentId: string,
brigadeId: string,
): Promise<typeof equipment.$inferSelect | null> {
const [e] = await db
.select()
.from(equipment)
.where(
and(eq(equipment.id, equipmentId), eq(equipment.brigadeId, brigadeId)),
);
return e ?? null;
}
/** Alle Geräte-Kategorien, geordnet — für die Auswahl im Formular. */
export async function listCategories(): Promise<
{ id: string; name: string }[]
> {
return db
.select({ id: equipmentCategories.id, name: equipmentCategories.name })
.from(equipmentCategories)
.orderBy(asc(equipmentCategories.reihenfolge), asc(equipmentCategories.name));
}

149
src/server/data/merkmale.ts Normal file
View File

@@ -0,0 +1,149 @@
import { and, asc, eq } from "drizzle-orm";
import { db } from "@/db";
import {
merkmale,
merkmalOptionen,
merkmalValues,
vehicleTemplateMerkmale,
equipmentCategoryMerkmale,
} from "@/db/schema";
import type {
MerkmalDefinition,
MerkmalOption,
MerkmalValueInput,
} from "@/lib/merkmale/types";
/**
* Lesehelfer für Merkmal-Definitionen (Workstream 7). Lösen die Vorgabewerte aus
* den DREI typisierten Spalten (`vorgabewert_num/_text/_bool`) und laden je
* enum-Merkmal die Optionen. Genutzt von Fahrzeug-/Geräteformularen, um den
* typisierten Editor vorzubefüllen.
*/
async function optionenFor(
merkmalIds: string[],
): Promise<Map<string, MerkmalOption[]>> {
const map = new Map<string, MerkmalOption[]>();
if (merkmalIds.length === 0) return map;
const rows = await db
.select({
merkmalId: merkmalOptionen.merkmalId,
wert: merkmalOptionen.wert,
label: merkmalOptionen.label,
reihenfolge: merkmalOptionen.reihenfolge,
})
.from(merkmalOptionen)
.orderBy(asc(merkmalOptionen.reihenfolge), asc(merkmalOptionen.label));
for (const r of rows) {
if (!merkmalIds.includes(r.merkmalId)) continue;
const list = map.get(r.merkmalId) ?? [];
list.push({ wert: r.wert, label: r.label });
map.set(r.merkmalId, list);
}
return map;
}
/**
* Liefert die Pflicht-/Vorgabemerkmale einer Fahrzeug-Vorlage, geordnet.
* Vorgabewerte werden typgerecht aus drei Spalten gelesen.
*/
export async function getMerkmaleForTemplate(
templateId: string,
): Promise<MerkmalDefinition[]> {
const rows = await db
.select({
merkmalId: merkmale.id,
name: merkmale.name,
typ: merkmale.typ,
einheit: merkmale.einheit,
pflicht: vehicleTemplateMerkmale.pflicht,
reihenfolge: vehicleTemplateMerkmale.reihenfolge,
vorgabeNum: vehicleTemplateMerkmale.vorgabewertNum,
vorgabeText: vehicleTemplateMerkmale.vorgabewertText,
vorgabeBool: vehicleTemplateMerkmale.vorgabewertBool,
})
.from(vehicleTemplateMerkmale)
.innerJoin(merkmale, eq(merkmale.id, vehicleTemplateMerkmale.merkmalId))
.where(eq(vehicleTemplateMerkmale.templateId, templateId))
.orderBy(asc(vehicleTemplateMerkmale.reihenfolge), asc(merkmale.name));
const opts = await optionenFor(rows.map((r) => r.merkmalId));
return rows.map((r) => ({
merkmalId: r.merkmalId,
name: r.name,
typ: r.typ,
einheit: r.einheit,
pflicht: r.pflicht,
reihenfolge: r.reihenfolge,
optionen: opts.get(r.merkmalId) ?? [],
vorgabeNum: r.vorgabeNum,
vorgabeText: r.vorgabeText,
vorgabeBool: r.vorgabeBool,
}));
}
/**
* Liefert die Merkmale einer Geräte-Kategorie, geordnet. Kategorien tragen
* keine Vorgabewerte/Pflicht — beide bleiben neutral (`pflicht=false`).
*/
export async function getMerkmaleForCategory(
categoryId: string,
): Promise<MerkmalDefinition[]> {
const rows = await db
.select({
merkmalId: merkmale.id,
name: merkmale.name,
typ: merkmale.typ,
einheit: merkmale.einheit,
reihenfolge: equipmentCategoryMerkmale.reihenfolge,
})
.from(equipmentCategoryMerkmale)
.innerJoin(merkmale, eq(merkmale.id, equipmentCategoryMerkmale.merkmalId))
.where(eq(equipmentCategoryMerkmale.categoryId, categoryId))
.orderBy(asc(equipmentCategoryMerkmale.reihenfolge), asc(merkmale.name));
const opts = await optionenFor(rows.map((r) => r.merkmalId));
return rows.map((r) => ({
merkmalId: r.merkmalId,
name: r.name,
typ: r.typ,
einheit: r.einheit,
pflicht: false,
reihenfolge: r.reihenfolge,
optionen: opts.get(r.merkmalId) ?? [],
vorgabeNum: null,
vorgabeText: null,
vorgabeBool: null,
}));
}
/**
* Liest die aktuell gespeicherten Merkmal-Werte einer Entity (für die
* Bearbeiten-Ansicht). Liefert `MerkmalValueInput[]` (genau eine Wertspalte je
* Eintrag gesetzt).
*/
export async function getMerkmalValuesForEntity(
entityTyp: "vehicle" | "equipment",
entityId: string,
): Promise<MerkmalValueInput[]> {
const rows = await db
.select({
merkmalId: merkmalValues.merkmalId,
num: merkmalValues.valueNum,
text: merkmalValues.valueText,
bool: merkmalValues.valueBool,
})
.from(merkmalValues)
.where(
and(
eq(merkmalValues.entityTyp, entityTyp),
eq(merkmalValues.entityId, entityId),
),
);
return rows.map((r) => ({
merkmalId: r.merkmalId,
num: r.num,
text: r.text,
bool: r.bool,
}));
}

View File

@@ -0,0 +1,88 @@
import { and, asc, desc, eq } from "drizzle-orm";
import { db } from "@/db";
import {
vehicles,
vehicleTemplates,
brigades,
} from "@/db/schema";
/**
* Lesehelfer für Fahrzeuge des Wehr-Bereichs (Workstream 7). ALLE Helfer sind
* auf eine `brigadeId` beschränkt (Scope kommt aus der Session, nie aus Input).
*/
export interface VehicleListItem {
id: string;
name: string;
funkrufname: string | null;
status: (typeof vehicles.$inferSelect)["status"];
templateName: string | null;
}
/** Liste der Fahrzeuge EINER Wehr, neueste zuerst, mit Vorlagenname. */
export async function listVehiclesForBrigade(
brigadeId: string,
): Promise<VehicleListItem[]> {
return db
.select({
id: vehicles.id,
name: vehicles.name,
funkrufname: vehicles.funkrufname,
status: vehicles.status,
templateName: vehicleTemplates.name,
})
.from(vehicles)
.leftJoin(vehicleTemplates, eq(vehicleTemplates.id, vehicles.templateId))
.where(eq(vehicles.brigadeId, brigadeId))
.orderBy(desc(vehicles.erstelltAm));
}
/**
* Lädt EIN Fahrzeug, aber nur wenn es zur angegebenen Wehr gehört. Liefert
* `null`, wenn es nicht existiert ODER einer fremden Wehr gehört (Scoping:
* der Aufrufer reagiert mit `notFound()`).
*/
export async function getVehicleForBrigade(
vehicleId: string,
brigadeId: string,
): Promise<typeof vehicles.$inferSelect | null> {
const [v] = await db
.select()
.from(vehicles)
.where(and(eq(vehicles.id, vehicleId), eq(vehicles.brigadeId, brigadeId)));
return v ?? null;
}
/** Prüft (scoped), ob ein Fahrzeug zur Wehr gehört — für Geräte-Zuordnung. */
export async function vehicleBelongsToBrigade(
vehicleId: string,
brigadeId: string,
): Promise<boolean> {
const v = await getVehicleForBrigade(vehicleId, brigadeId);
return v !== null;
}
/** Alle Fahrzeug-Vorlagen, geordnet — für den Vorlagen-Picker. */
export async function listTemplates(): Promise<
{ id: string; code: string; name: string }[]
> {
return db
.select({
id: vehicleTemplates.id,
code: vehicleTemplates.code,
name: vehicleTemplates.name,
})
.from(vehicleTemplates)
.orderBy(asc(vehicleTemplates.reihenfolge), asc(vehicleTemplates.name));
}
/** Stammdaten der eigenen Wehr (für die Profilseite). */
export async function getBrigade(
brigadeId: string,
): Promise<typeof brigades.$inferSelect | null> {
const [b] = await db
.select()
.from(brigades)
.where(eq(brigades.id, brigadeId));
return b ?? null;
}

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from "vitest";
import { upsertMerkmalValues } from "../upsertValues";
import type { MerkmalValueInput } from "@/lib/merkmale/types";
/**
* Test-Double für die Drizzle-Transaktion. Es zeichnet nur auf, wie oft
* delete/insert aufgerufen wurden — kein echtes Postgres. `upsertMerkmalValues`
* ruft pro Wert genau ein `delete(...).where(...)` und (bei nicht-leerem Wert)
* ein `insert(...).values(...)`.
*/
function makeFakeTx() {
const deletes: unknown[] = [];
const inserts: Record<string, unknown>[] = [];
const tx = {
delete: vi.fn(() => ({
where: vi.fn(async (cond: unknown) => {
deletes.push(cond);
}),
})),
insert: vi.fn(() => ({
values: vi.fn(async (v: Record<string, unknown>) => {
inserts.push(v);
}),
})),
};
return { tx, deletes, inserts };
}
const MID = "11111111-1111-1111-1111-111111111111";
describe("upsertMerkmalValues", () => {
it("löscht alten Wert und fügt neuen ein (delete-then-insert)", async () => {
const { tx, deletes, inserts } = makeFakeTx();
const werte: MerkmalValueInput[] = [{ merkmalId: MID, num: 2000 }];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await upsertMerkmalValues(tx as any, "vehicle", "veh-1", werte);
expect(deletes).toHaveLength(1);
expect(inserts).toHaveLength(1);
expect(inserts[0]).toMatchObject({
merkmalId: MID,
entityTyp: "vehicle",
entityId: "veh-1",
valueNum: 2000,
valueText: null,
valueBool: null,
});
});
it("schreibt bei leerem Wert nur delete, kein insert", async () => {
const { tx, deletes, inserts } = makeFakeTx();
const werte: MerkmalValueInput[] = [
{ merkmalId: MID, num: null, text: "", bool: null },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await upsertMerkmalValues(tx as any, "vehicle", "veh-1", werte);
expect(deletes).toHaveLength(1);
expect(inserts).toHaveLength(0);
});
it("schreibt boolean false als Wert (nicht als leer behandelt)", async () => {
const { tx, inserts } = makeFakeTx();
const werte: MerkmalValueInput[] = [{ merkmalId: MID, bool: false }];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await upsertMerkmalValues(tx as any, "equipment", "eq-1", werte);
expect(inserts).toHaveLength(1);
expect(inserts[0]).toMatchObject({ valueBool: false, valueNum: null, valueText: null });
});
});

View File

@@ -0,0 +1,50 @@
import { and, eq } from "drizzle-orm";
import { merkmalValues } from "@/db/schema";
import type { Tx } from "@/lib/audit";
import type { MerkmalValueInput } from "@/lib/merkmale/types";
/**
* Schreibt die Merkmal-Werte einer Entity (Fahrzeug/Gerät) in `merkmal_values`
* per delete-then-insert: pro Merkmal wird der bestehende Wert gelöscht und —
* sofern nicht leer — neu eingefügt. So bleibt pro (entity, merkmal) genau eine
* Zeile; ein geleerter Wert hinterlässt KEINE Zeile.
*
* Läuft IMMER innerhalb der aufrufenden Transaktion (`Tx` aus @/lib/audit,
* kein `any` — Querschnittsstandard 12), damit Insert/Audit atomar sind.
*
* `boolean false` und `num 0` gelten als gesetzt; nur `null`/leerer String
* zählen als leer.
*/
export async function upsertMerkmalValues(
tx: Tx,
entityTyp: "vehicle" | "equipment",
entityId: string,
werte: MerkmalValueInput[],
): Promise<void> {
for (const w of werte) {
await tx
.delete(merkmalValues)
.where(
and(
eq(merkmalValues.entityTyp, entityTyp),
eq(merkmalValues.entityId, entityId),
eq(merkmalValues.merkmalId, w.merkmalId),
),
);
const empty =
w.num == null &&
(w.text == null || w.text === "") &&
w.bool == null;
if (empty) continue;
await tx.insert(merkmalValues).values({
merkmalId: w.merkmalId,
entityTyp,
entityId,
valueNum: w.num ?? null,
valueText: w.text ?? null,
valueBool: w.bool ?? null,
});
}
}

View File

@@ -1,76 +1,77 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { ROUTES } from "./routes.manifest";
/** /**
* Auth-Gating-Garantie (Definition of Done, oberstes Prinzip). * KRITISCHE Auth-Gating-Suite (Definition of Done #1, oberstes Prinzip).
*
* Erzeugt GENAU EINEN Testfall pro Manifest-Eintrag (ROUTES.length):
* - Seiten -> Redirect auf /login (mit callbackUrl), kein Daten-Leak.
* - API -> 401 ohne Fachdaten im Body.
* *
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über * NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
* `npm run test:e2e:gating` gegen einen laufenden Server ausgeführt. * `npm run test:e2e:gating` gegen einen laufenden Server ausgeführt.
* *
* Kerngarantie (Querschnittsstandard 13, default-deny dreifach): * Negativ-Probe (CI): Entfernen eines Layout-Guards oder einer Manifest-Route
* - Anonyme Aufrufe von Seiten -> Redirect auf /login (mit callbackUrl). * muss diese Suite rot machen.
* - Anonyme Aufrufe von API-Routen -> 401 OHNE Daten-Leak.
* - Öffentliche Routen (Login, Health) bleiben erreichbar.
*
* Negativ-Probe (manuell/CI): Entfernen von `requireSession()` aus
* `(app)/layout.tsx` muss diese Suite rot machen.
*/ */
// Geschützte Seiten (Redirect-Manifest). Neue Seiten hier ergänzen. // Erzwingt anonymen Zustand: keine gespeicherte Session.
const PROTECTED_PAGES = [ test.use({ storageState: { cookies: [], origins: [] } });
"/",
"/start", // Fachbegriffe, die in einem 401/Redirect-Body NIE auftauchen dürfen.
"/fahrzeuge", const LEAK_TERMS = [
"/geraete", "funkrufname",
"/wehren", "wehrfuehrer",
"/verwaltung", "einsatzbereit",
"/admin", "passwort",
"kennzeichen",
]; ];
// Geschützte API-Routen (401-Manifest). Neue API-Routen hier ergänzen. function assertNoLeak(body: string) {
const PROTECTED_API = ["/api/fahrzeuge", "/api/geraete", "/api/verwaltung"]; const lower = body.toLowerCase();
for (const term of LEAK_TERMS) {
expect(lower, `Daten-Leak: Body enthält "${term}"`).not.toContain(term);
}
}
// Öffentliche Routen (Middleware-Allowlist). for (const route of ROUTES) {
const PUBLIC_ROUTES = ["/login", "/api/health"]; if (route.expectWhenAnon === "redirect") {
test(`Seite ${route.path}: anonym -> Redirect auf /login`, async ({
test.describe("Default-deny: geschützte Seiten", () => {
for (const path of PROTECTED_PAGES) {
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({
page, page,
}) => { }) => {
await page.goto(path); const response = await page.goto(route.path);
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
// callbackUrl bewahrt das ursprüngliche Ziel.
expect(page.url()).toContain("callbackUrl");
const body = await page.content();
assertNoLeak(body);
// Kein 500 o. ä.
if (response) expect(response.status()).toBeLessThan(500);
}); });
} } else {
}); test(`API ${route.path}: anonym -> 401 ohne Daten-Leak`, async ({
test.describe("Default-deny: geschützte API-Routen", () => {
for (const path of PROTECTED_API) {
test(`anonymer Aufruf von ${path} liefert 401 ohne Daten-Leak`, async ({
request, request,
}) => { }) => {
const res = await request.get(path); // /api/geo/geocode ist POST-only; health ist GET. Beide gehen durch apiAuth().
const res = route.path.includes("geocode")
? await request.post(route.path, { data: { address: "x" } })
: await request.get(route.path);
expect(res.status()).toBe(401); expect(res.status()).toBe(401);
const body = await res.text(); assertNoLeak(await res.text());
// Kein Daten-Leak: nur eine generische Fehlermeldung.
expect(body).not.toContain("brigade");
expect(body).not.toContain("passwort");
}); });
} }
}
test.describe("Öffentliche Routen bleiben anonym erreichbar", () => {
test("Login-Seite ist anonym 200", async ({ page }) => {
const res = await page.goto("/login");
expect(res?.status()).toBeLessThan(400);
await expect(page.getByRole("heading", { name: "Anmelden" })).toBeVisible();
}); });
test.describe("Öffentliche Routen bleiben erreichbar", () => { test("Container-Health ist anonym 200", async ({ request }) => {
test("Login-Seite ist anonym erreichbar", async ({ page }) => {
await page.goto("/login");
await expect(
page.getByRole("heading", { name: "Anmelden" }),
).toBeVisible();
});
test("Health-Check ist anonym 200", async ({ request }) => {
const res = await request.get("/api/health"); const res = await request.get("/api/health");
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
expect(await res.json()).toEqual({ status: "ok" }); expect(await res.json()).toEqual({ status: "ok" });
}); });
}); });
void PUBLIC_ROUTES;

View File

@@ -0,0 +1,99 @@
import { test, expect } from "@playwright/test";
/**
* Detailseiten-Auth & -Inhalt (Workstream 8, Phase 5).
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
* `npm run test:e2e` gegen einen laufenden Server mit Seed-Daten ausgeführt.
*
* Garantien:
* - Default-deny (Querschnittsstandard 1): anonyme Aufrufe der Detailseiten
* leiten auf /login um (das `(app)`-Layout-Gate + `requireSession()` je Seite).
* - Eingeloggt: Eckdaten, Beladung-Links (`/geraete/<id>`), Wehr-Kontakt
* (`tel:`/`mailto:`) sind sichtbar.
* - Ungültige IDs -> deutsche 404-Seite (not-found).
*
* Negativ-Probe (manuell/CI): Entfernen von `requireSession()` aus einer der
* Detailseiten ODER aus `(app)/layout.tsx` muss die Default-deny-Tests rot
* machen.
*
* Platzhalter-IDs: zur Laufzeit gegen echte Seed-UUIDs ersetzen
* (Env `E2E_FAHRZEUG_ID` etc.) oder per Suchseite ermitteln.
*/
const FAHRZEUG_ID = process.env.E2E_FAHRZEUG_ID ?? "00000000-0000-0000-0000-000000000001";
const GERAET_ID = process.env.E2E_GERAET_ID ?? "00000000-0000-0000-0000-000000000002";
const WEHR_ID = process.env.E2E_WEHR_ID ?? "00000000-0000-0000-0000-000000000003";
const UNGUELTIGE_ID = "ffffffff-ffff-ffff-ffff-ffffffffffff";
const DETAIL_PAGES = [
`/fahrzeuge/${FAHRZEUG_ID}`,
`/geraete/${GERAET_ID}`,
`/wehren/${WEHR_ID}`,
];
test.describe("Default-deny: Detailseiten (anonym)", () => {
for (const path of DETAIL_PAGES) {
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({ page }) => {
await page.goto(path);
await expect(page).toHaveURL(/\/login/);
});
}
});
test.describe("Eingeloggt: Detail-Inhalte", () => {
test.skip(
!process.env.E2E_WEHR_READ_STATE,
"benötigt wehr_read-Fixture (Test-Workstream)",
);
test.use({
storageState: process.env.E2E_WEHR_READ_STATE ?? { cookies: [], origins: [] },
});
test("Fahrzeug-Detail zeigt Eckdaten, Beladung-Links und Wehr-Kontakt", async ({
page,
}) => {
await page.goto(`/fahrzeuge/${FAHRZEUG_ID}`);
await expect(page.getByRole("heading", { name: "Eckdaten" })).toBeVisible();
// Beladung verlinkt auf Gerät-Detailseiten.
const beladungLink = page.locator('a[href^="/geraete/"]').first();
await expect(beladungLink).toBeVisible();
// Out-of-band Kontakt: tel:/mailto:-Link vorhanden.
const kontaktLink = page.locator('a[href^="tel:"], a[href^="mailto:"]').first();
await expect(kontaktLink).toBeVisible();
});
test("Gerät-Detail verlinkt Fahrzeug oder zeigt „im Gerätehaus“", async ({
page,
}) => {
await page.goto(`/geraete/${GERAET_ID}`);
const hatFahrzeug = await page.locator('a[href^="/fahrzeuge/"]').count();
if (hatFahrzeug === 0) {
await expect(page.getByText("im Gerätehaus")).toBeVisible();
} else {
await expect(page.locator('a[href^="/fahrzeuge/"]').first()).toBeVisible();
}
});
test("Wehr-Detail listet Fuhrpark und Kontakt", async ({ page }) => {
await page.goto(`/wehren/${WEHR_ID}`);
await expect(page.getByRole("heading", { name: "Fahrzeuge" })).toBeVisible();
const kontaktLink = page.locator('a[href^="tel:"], a[href^="mailto:"]').first();
await expect(kontaktLink).toBeVisible();
});
test("ungültige Fahrzeug-ID -> deutsche 404-Seite", async ({ page }) => {
await page.goto(`/fahrzeuge/${UNGUELTIGE_ID}`);
await expect(page.getByText("Nicht gefunden.")).toBeVisible();
});
test("malformter (nicht-UUID) Fahrzeug-Pfad -> deutsche 404-Seite (kein 500)", async ({
page,
}) => {
// Route-Param ist Nutzereingabe an der Grenze: eine nicht-UUID darf nicht
// als `invalid input syntax for type uuid` bis zur error.tsx (500) laufen,
// sondern muss sauber `notFound()` (deutsche 404) liefern.
await page.goto(`/fahrzeuge/abc`);
await expect(page.getByText("Nicht gefunden.")).toBeVisible();
});
});

View File

@@ -0,0 +1,63 @@
import { test as setup, expect } from "@playwright/test";
import path from "node:path";
/**
* Auth-Setup (Plan WS11 Aufgabe 3): echter Credentials-Login je Konto ->
* storageState. Wird als Playwright-Projekt "setup" ausgeführt; die übrigen
* Projekte hängen davon ab und laden den jeweiligen storageState.
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred.
*
* Erzeugt vier Storage-States passend zu den vier Seed-Konten:
* - platform_admin
* - wehr_admin (Wehr A)
* - wehr_admin (Wehr B)
* - wehr_read (Wehr A)
*/
export const AUTH_DIR = path.join(process.cwd(), "tests/e2e/.auth");
interface Account {
email: string;
file: string;
envVar: string;
}
const PASSWORD = process.env.E2E_TEST_PASSWORD ?? "Test-Passwort-1234";
const ACCOUNTS: Account[] = [
{
email: "platform-admin@example.test",
file: "platform-admin.json",
envVar: "E2E_PLATFORM_ADMIN_STATE",
},
{
email: "wehr-admin-a@example.test",
file: "wehr-admin-a.json",
envVar: "E2E_WEHR_ADMIN_STATE",
},
{
email: "wehr-admin-b@example.test",
file: "wehr-admin-b.json",
envVar: "E2E_WEHR_ADMIN_B_STATE",
},
{
email: "wehr-read-a@example.test",
file: "wehr-read-a.json",
envVar: "E2E_WEHR_READ_STATE",
},
];
for (const account of ACCOUNTS) {
setup(`Login ${account.email}`, async ({ page }) => {
await page.goto("/login");
await page.getByLabel(/E-Mail/i).fill(account.email);
await page.getByLabel(/Passwort/i).fill(PASSWORD);
await page.getByRole("button", { name: /Anmelden/i }).click();
// Erfolgreicher Login verlässt /login.
await page.waitForURL((url) => !url.pathname.startsWith("/login"));
await expect(page).not.toHaveURL(/\/login/);
await page
.context()
.storageState({ path: path.join(AUTH_DIR, account.file) });
});
}

36
tests/e2e/global-setup.ts Normal file
View File

@@ -0,0 +1,36 @@
import { execSync } from "node:child_process";
/**
* Playwright Global-Setup (Definition of Done #1, Plan WS11 Aufgabe 2).
*
* NICHT in der Sandbox ausführbar (kein Postgres) — deferred. Läuft im CI/lokal
* gegen eine erreichbare DB:
* 1. Migrationen anwenden (idempotent).
* 2. Deterministischen Seed laden (Katalog) + Test-Fixtures (Wehren A/B mit
* Koordinaten, je Fahrzeug/Gerät mit festen UUIDs, vier Benutzer mit
* argon2id-Test-Passwort).
*
* Wird nur ausgeführt, wenn KEIN externer E2E_BASE_URL gesetzt ist (dann ist die
* Ziel-Umgebung bereits provisioniert) und DATABASE_URL existiert.
*/
async function globalSetup(): Promise<void> {
if (process.env.E2E_BASE_URL) {
// Externe Umgebung: bereits provisioniert, nichts tun.
return;
}
if (!process.env.DATABASE_URL) {
console.warn(
"[global-setup] DATABASE_URL fehlt — Migration/Seed übersprungen (deferred).",
);
return;
}
const run = (cmd: string) =>
execSync(cmd, { stdio: "inherit", env: process.env });
run("npm run db:migrate");
run("npm run db:seed");
// Deterministische E2E-Fixtures (vier Konten + Wehren A/B + feste Asset-UUIDs).
run("npm run db:seed-auth");
}
export default globalSetup;

View File

@@ -0,0 +1,32 @@
import { test, expect } from "@playwright/test";
/**
* Login-Rate-Limit (Definition of Done #8, Querschnittsstandard 8).
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
* `npm run test:e2e` gegen einen laufenden, geseedeten Server ausgeführt.
*
* Beweist: Der Rate-Limit greift im `authorize`-Callback (5 Fehlversuche /
* 15 min, src/lib/auth/rate-limit.ts) und damit auf dem Pfad, der über die
* Credentials-Login-Action (loginAction) tatsächlich durchläuft. Ab dem 6.
* Versuch wird gedrosselt; login_attempts.fail >= 5.
*/
test.use({ storageState: { cookies: [], origins: [] } });
test("7x falsches Passwort -> Drosselung ab Versuch 6", async ({ page }) => {
const email = "wehr-admin-a@example.test";
for (let attempt = 1; attempt <= 7; attempt++) {
await page.goto("/login");
await page.getByLabel(/E-Mail/i).fill(email);
await page.getByLabel(/Passwort/i).fill(`falsch-${attempt}`);
await page.getByRole("button", { name: /Anmelden/i }).click();
// Bleibt auf /login (kein erfolgreicher Login).
await expect(page).toHaveURL(/\/login/);
const text = await page.locator("body").innerText();
if (attempt >= 6) {
// Drosselung: generische Fehlermeldung, weiterhin kein Zugang.
expect(text.toLowerCase()).toMatch(/fehlgeschlagen|zu viele|gesperrt|versuch/);
}
}
});

View File

@@ -0,0 +1,64 @@
import { test, expect } from "@playwright/test";
/**
* Rollen-/Wehr-Scoping (Definition of Done #3, Plan WS11 Aufgabe 6 / Verifikation
* 6).
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
* `npm run test:e2e` gegen einen geseedeten Server mit Storage-States aus dem
* Auth-Setup ausgeführt.
*
* Garantien:
* - wehr_read kann NICHT schreiben (Status-Änderung -> 403/forbidden).
* - wehr_admin A kann Wehr B NICHT ändern (fremdes Asset -> 403/404,
* Datensatz bleibt unverändert).
* - eigene Ressource: wehr_admin A kann Status setzen (-> 200, status='wartung').
*
* Storage-States kommen aus tests/e2e/fixtures/auth.setup.ts. Feste Asset-UUIDs
* stammen aus dem deterministischen Seed (global-setup.ts).
*/
const VEHICLE_A = process.env.E2E_VEHICLE_A_ID ?? "";
const VEHICLE_B = process.env.E2E_VEHICLE_B_ID ?? "";
test.describe("wehr_read darf nicht schreiben", () => {
test.skip(!process.env.E2E_WEHR_READ_STATE, "benötigt wehr_read-Fixture");
test.use({ storageState: process.env.E2E_WEHR_READ_STATE });
test("Aufruf der Verwaltungs-Schreibseite -> 403", async ({ page }) => {
const res = await page.goto("/verwaltung/fahrzeuge/neu");
expect(res?.status()).toBe(403);
});
});
test.describe("wehr_admin A darf Wehr B nicht ändern", () => {
test.skip(
!process.env.E2E_WEHR_ADMIN_STATE || !VEHICLE_B,
"benötigt wehr_admin-A-Fixture + Wehr-B-Fahrzeug-ID",
);
test.use({ storageState: process.env.E2E_WEHR_ADMIN_STATE });
test("fremdes Fahrzeug (Wehr B) -> 404, unverändert", async ({ page }) => {
const res = await page.goto(`/verwaltung/fahrzeuge/${VEHICLE_B}`);
expect(res?.status()).toBe(404);
});
});
test.describe("wehr_admin A darf eigenes Fahrzeug ändern", () => {
test.skip(
!process.env.E2E_WEHR_ADMIN_STATE || !VEHICLE_A,
"benötigt wehr_admin-A-Fixture + Wehr-A-Fahrzeug-ID",
);
test.use({ storageState: process.env.E2E_WEHR_ADMIN_STATE });
test("eigenes Fahrzeug ist erreichbar (200) und editierbar", async ({
page,
}) => {
const res = await page.goto(`/verwaltung/fahrzeuge/${VEHICLE_A}`);
expect(res?.status()).toBeLessThan(400);
// Status auf 'wartung' setzen (Verifikation 6: eigenes -> 200).
await page.getByLabel(/Status/i).selectOption("wartung");
await page.getByRole("button", { name: /Speichern/i }).click();
await expect(page.getByText(/gespeichert|wartung/i)).toBeVisible();
});
});

View File

@@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test";
import { discoverAppRoutes, findUndeclaredRoutes } from "../support/route-scan";
import { DECLARED_ROUTE_TEMPLATES } from "./routes.manifest";
/**
* Driftschutz (Definition of Done #1): verhindert ungetestete neue Routen.
*
* STATISCHER Check — braucht weder Server noch DB; lauffähig offline. Die
* identische Logik ist zusätzlich als Vitest-Unit-Test
* (tests/unit/routes-manifest.test.ts) abgesichert.
*
* Negativ-Probe: Eine neue Route src/app/(app)/leak/page.tsx ohne
* Manifest-Eintrag macht diesen Test rot; Entfernen -> grün.
*/
test("jede Route unter src/app ist im Manifest oder öffentlich", () => {
const discovered = discoverAppRoutes();
const undeclared = findUndeclaredRoutes(discovered, DECLARED_ROUTE_TEMPLATES);
expect(
undeclared,
`Ungetestete Routen (im Manifest ergänzen oder als öffentlich markieren):\n${undeclared.join(
"\n",
)}`,
).toEqual([]);
});

View File

@@ -0,0 +1,110 @@
/**
* Kanonisches Routen-Manifest für die Auth-Gating-Garantie (Definition of
* Done #1, Querschnittsstandard 13).
*
* EINZIGE Quelle der Wahrheit darüber, welche Routen geschützt sind und wie ein
* ANONYMER Aufruf sich verhalten muss:
* - Seiten -> "redirect" (auf /login, mit callbackUrl)
* - APIs -> "401" (ohne Daten-Leak)
*
* Der Driftschutz (routes.manifest.spec.ts + tests/unit/routes-manifest.test.ts)
* stellt sicher, dass jede neue Route unter src/app/** entweder hier steht oder
* in PUBLIC_ALLOWLIST. Ungetestete Routen sind damit ausgeschlossen.
*
* Beispiel-UUIDs für dynamische Segmente: anonyme Aufrufe werden VOR jeder
* DB-Abfrage abgewiesen, daher müssen diese IDs nicht existieren.
*/
export { PUBLIC_ALLOWLIST } from "../support/route-scan";
export type AnonExpectation = "redirect" | "401";
export interface RouteEntry {
/** Konkreter URL-Pfad (dynamische Segmente bereits aufgelöst). */
path: string;
/** Erwartetes Verhalten bei anonymem Zugriff. */
expectWhenAnon: AnonExpectation;
/** true für API-Routen (request statt page-Navigation). */
api?: boolean;
}
const EX_VEHICLE = "00000000-0000-0000-0000-0000000000a1";
const EX_EQUIP = "00000000-0000-0000-0000-0000000000a2";
const EX_BRIGADE = "00000000-0000-0000-0000-0000000000a3";
const EX_TEMPLATE = "00000000-0000-0000-0000-0000000000a4";
const EX_CATEGORY = "00000000-0000-0000-0000-0000000000a5";
export const ROUTES: readonly RouteEntry[] = [
// (app) Lese-Oberflächen
{ path: "/", expectWhenAnon: "redirect" },
{ path: "/start", expectWhenAnon: "redirect" },
{ path: "/fahrzeuge", expectWhenAnon: "redirect" },
{ path: `/fahrzeuge/${EX_VEHICLE}`, expectWhenAnon: "redirect" },
{ path: "/geraete", expectWhenAnon: "redirect" },
{ path: `/geraete/${EX_EQUIP}`, expectWhenAnon: "redirect" },
{ path: "/wehren", expectWhenAnon: "redirect" },
{ path: `/wehren/${EX_BRIGADE}`, expectWhenAnon: "redirect" },
// (app)/verwaltung Wehr-Bereich
{ path: "/verwaltung/benutzer", expectWhenAnon: "redirect" },
{ path: "/verwaltung/profil", expectWhenAnon: "redirect" },
{ path: "/verwaltung/fahrzeuge", expectWhenAnon: "redirect" },
{ path: "/verwaltung/fahrzeuge/neu", expectWhenAnon: "redirect" },
{ path: `/verwaltung/fahrzeuge/${EX_VEHICLE}`, expectWhenAnon: "redirect" },
{ path: "/verwaltung/geraete", expectWhenAnon: "redirect" },
{ path: "/verwaltung/geraete/neu", expectWhenAnon: "redirect" },
{ path: `/verwaltung/geraete/${EX_EQUIP}`, expectWhenAnon: "redirect" },
// (admin) Plattform-Verwaltung
{ path: "/admin", expectWhenAnon: "redirect" },
{ path: "/admin/audit", expectWhenAnon: "redirect" },
{ path: "/admin/merkmale", expectWhenAnon: "redirect" },
{ path: "/admin/merkmale/proposals", expectWhenAnon: "redirect" },
{ path: "/admin/vorlagen", expectWhenAnon: "redirect" },
{ path: `/admin/vorlagen/${EX_TEMPLATE}`, expectWhenAnon: "redirect" },
{ path: "/admin/geraete-kategorien", expectWhenAnon: "redirect" },
{
path: `/admin/geraete-kategorien/${EX_CATEGORY}`,
expectWhenAnon: "redirect",
},
{ path: "/admin/wehren", expectWhenAnon: "redirect" },
{ path: "/admin/wehren/neu", expectWhenAnon: "redirect" },
{ path: `/admin/wehren/${EX_BRIGADE}`, expectWhenAnon: "redirect" },
// APIs (kein /api/auth, /api/health -> öffentlich/Allowlist)
{ path: "/api/geo/geocode", expectWhenAnon: "401", api: true },
{ path: "/api/geo/health", expectWhenAnon: "401", api: true },
];
/**
* Die Routen-Vorlagen (dynamische Segmente als Platzhalter), wie sie im
* Dateisystem erscheinen. Wird vom Driftschutz mit discoverAppRoutes()
* abgeglichen.
*/
export const DECLARED_ROUTE_TEMPLATES: ReadonlySet<string> = new Set([
"/",
"/start",
"/fahrzeuge",
"/fahrzeuge/[id]",
"/geraete",
"/geraete/[id]",
"/wehren",
"/wehren/[id]",
"/verwaltung/benutzer",
"/verwaltung/profil",
"/verwaltung/fahrzeuge",
"/verwaltung/fahrzeuge/neu",
"/verwaltung/fahrzeuge/[id]",
"/verwaltung/geraete",
"/verwaltung/geraete/neu",
"/verwaltung/geraete/[id]",
"/admin",
"/admin/audit",
"/admin/merkmale",
"/admin/merkmale/proposals",
"/admin/vorlagen",
"/admin/vorlagen/[id]",
"/admin/geraete-kategorien",
"/admin/geraete-kategorien/[id]",
"/admin/wehren",
"/admin/wehren/neu",
"/admin/wehren/[id]",
"/api/geo/geocode",
"/api/geo/health",
]);

View File

@@ -0,0 +1,55 @@
import { test, expect } from "@playwright/test";
/**
* Suche & Eintreffzeit-Sortierung (Definition of Done #6, Plan WS11 Aufgabe 7 /
* Verifikation 7).
*
* NICHT in der Sandbox ausführbar (kein Server/DB/OSRM) — deferred. Wird über
* `npm run test:e2e` gegen einen geseedeten Server mit authentifizierter
* Session ausgeführt.
*
* Garantien:
* - Dynamische Filter (UND-verknüpft) liefern korrekte Teilmengen.
* - Treffer sind nach ETA aufsteigend sortiert.
* - OSRM-Ausfall (E2E_FORCE_HAVERSINE=1) -> sichtbarer "Luftlinie"-Fallback.
*/
test.skip(
!process.env.E2E_AUTH_STATE && !process.env.E2E_WEHR_READ_STATE,
"benötigt authentifizierte Session (Auth-Setup)",
);
test.use({
storageState:
process.env.E2E_AUTH_STATE ?? process.env.E2E_WEHR_READ_STATE ?? undefined,
});
test("Filter grenzt Treffer ein (UND-Verknüpfung)", async ({ page }) => {
await page.goto("/fahrzeuge");
const before = await page.getByText(/Treffer/).first().innerText();
await page.getByLabel("Nur einsatzbereit").click();
await expect(page).toHaveURL(/bereit=1/);
const after = await page.getByText(/Treffer/).first().innerText();
// Teilmenge: Anzahl sinkt nicht und Filter ist in der URL aktiv.
expect(after).not.toBe(before);
});
test("Treffer mit Standort sind aufsteigend nach ETA sortiert", async ({
page,
}) => {
await page.goto("/fahrzeuge?ort=St.+P%C3%B6lten");
await page.waitForLoadState("networkidle");
const etaTexts = await page
.locator("[data-testid='eta-minutes']")
.allInnerTexts();
const minutes = etaTexts.map((t) => parseInt(t.replace(/\D/g, ""), 10));
const sorted = [...minutes].sort((a, b) => a - b);
expect(minutes).toEqual(sorted);
});
test("OSRM-Ausfall zeigt Luftlinie-Fallback", async ({ page }) => {
test.skip(
process.env.E2E_FORCE_HAVERSINE !== "1",
"nur mit E2E_FORCE_HAVERSINE=1",
);
await page.goto("/fahrzeuge?ort=St.+P%C3%B6lten");
await expect(page.getByText(/Luftlinie/i).first()).toBeVisible();
});

View File

@@ -0,0 +1,50 @@
import { test, expect } from "@playwright/test";
/**
* Security-Header & Cookie-Flags (Definition of Done #8, Querschnittsstandard
* 1, 9, 11).
*
* NICHT in der Sandbox ausführbar (kein Server) — deferred. Wird über
* `npm run test:e2e` gegen einen laufenden Server ausgeführt. Der statische
* Header-Satz ist zusätzlich offline durch src/lib/security/headers.test.ts
* abgesichert.
*
* Verifikation entspricht: `curl -sI https://<host>/login | grep -i x-frame-options`.
*/
test.use({ storageState: { cookies: [], origins: [] } });
test.describe("Security-Header auf /login", () => {
test("setzt X-Frame-Options, nosniff, CSP frame-ancestors none, HSTS", async ({
request,
}) => {
const res = await request.get("/login");
const h = res.headers();
expect(h["x-frame-options"]).toBe("DENY");
expect(h["x-content-type-options"]).toBe("nosniff");
expect(h["content-security-policy"]).toContain("frame-ancestors 'none'");
expect(h["strict-transport-security"]).toMatch(/max-age=\d+/);
});
});
test.describe("Session-Cookie-Flags", () => {
test("nach Login: Session-Cookie ist httpOnly + sameSite", async ({
context,
page,
}) => {
// Erwartet einen funktionierenden Credentials-Login (Seed). Deferred.
await page.goto("/login");
await page.getByLabel(/E-Mail/i).fill("wehr-admin-a@example.test");
await page.getByLabel(/Passwort/i).fill("Test-Passwort-1234");
await page.getByRole("button", { name: /Anmelden/i }).click();
await page.waitForURL((url) => !url.pathname.startsWith("/login"));
const cookies = await context.cookies();
const session = cookies.find((c) => /authjs|next-auth|__Secure-/.test(c.name));
expect(session, "Session-Cookie gesetzt").toBeTruthy();
expect(session?.httpOnly).toBe(true);
expect(session?.sameSite).toMatch(/Lax|Strict/);
// `secure` nur unter https (Querschnittsstandard 9). Lokal (http) false.
const isHttps = (process.env.E2E_BASE_URL ?? "").startsWith("https://");
expect(session?.secure).toBe(isHttps);
});
});

View File

@@ -0,0 +1,23 @@
import { test, expect } from "@playwright/test";
import { findUnguardedActionsInRepo } from "../support/guard-scan";
/**
* Default-Deny-Beweis für Server Actions (Definition of Done #2,
* Querschnittsstandard 3): JEDE "use server"-Funktion ruft als erste Anweisung
* einen Guard (require{Session,Role,OwnBrigade,PlatformAdmin,WehrAdmin}).
*
* STATISCHER Check — braucht weder Server noch DB; lauffähig offline. Identisch
* als Vitest-Unit-Test (tests/unit/server-actions-guard.test.ts) abgesichert.
*
* Negativ-Probe: Entfernen eines Guards aus einer Action macht diesen Test rot.
*
* Genuin öffentliche Login-Actions (vor der Authentifizierung) sind in
* PUBLIC_ACTION_ALLOWLIST (guard-scan.ts) ausgenommen.
*/
test('jede "use server"-Funktion ruft einen Guard', () => {
const offenders = findUnguardedActionsInRepo();
expect(
offenders,
`Server Actions ohne Guard:\n${offenders.join("\n")}`,
).toEqual([]);
});

View File

@@ -0,0 +1,64 @@
import { test, expect } from "@playwright/test";
/**
* Happy-Path des Wehr-Bereichs: Fuhrpark, Geräte, Profil, Benutzer
* (Workstream 7).
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet einen
* angemeldeten `wehr_admin` (Fixture aus dem Test-Workstream) und befüllte
* Taxonomie (Seed/Admin).
*/
test.describe("Verwaltung: Fuhrpark & Benutzer (Happy-Path)", () => {
test.skip(
!process.env.E2E_WEHR_ADMIN_STATE,
"benötigt wehr_admin-Fixture (Test-Workstream)",
);
test.use({
storageState: process.env.E2E_WEHR_ADMIN_STATE ?? { cookies: [], origins: [] },
});
test("Fahrzeug per Vorlage anlegen befüllt typisierte Merkmale", async ({
page,
}) => {
await page.goto("/verwaltung/fahrzeuge/neu");
// Erste echte Vorlage wählen (Index 0 ist „Ohne Vorlage (frei)").
await page.getByLabel("Fahrzeug-Vorlage").selectOption({ index: 1 });
// Vorlagen-Merkmale werden nachgeladen (Löschwassertank, Feuerlöschpumpe …).
await expect(page.getByText("Löschwassertank")).toBeVisible();
await page.getByLabel("Name").fill("HLF 2 Musterdorf");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page).toHaveURL(/\/verwaltung\/fahrzeuge$/);
await expect(page.getByText("HLF 2 Musterdorf")).toBeVisible();
});
test("Gerät 'im Gerätehaus' anlegen (keine Fahrzeug-Zuordnung)", async ({
page,
}) => {
await page.goto("/verwaltung/geraete/neu");
await page.getByLabel("Name").fill("Tragkraftspritze 1");
await page.getByLabel("Kategorie").selectOption({ index: 1 });
// Zuordnung bleibt auf 'im Gerätehaus'.
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page).toHaveURL(/\/verwaltung\/geraete$/);
await expect(page.getByText("im Gerätehaus").first()).toBeVisible();
});
test("Profil speichern zeigt Bestätigung", async ({ page }) => {
await page.goto("/verwaltung/profil");
await page.getByLabel("Straße").fill("Hauptstraße 1");
await page.getByLabel("PLZ").fill("3100");
await page.getByLabel("Ort").fill("St. Pölten");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.getByText(/gespeichert|geokodiert|geokodiert werden/)).toBeVisible();
});
test("Benutzer anlegen zeigt Einmal-Passwort", async ({ page }) => {
await page.goto("/verwaltung/benutzer");
await page.getByLabel("Name").fill("Neue Person");
await page.getByLabel("E-Mail").fill("neu@ff-musterdorf.at");
await page.getByLabel("Rolle").selectOption("wehr_read");
await page.getByRole("button", { name: "Benutzer anlegen" }).click();
await expect(page.getByText(/Einmal-Passwort/)).toBeVisible();
});
});

View File

@@ -0,0 +1,67 @@
import { test, expect } from "@playwright/test";
/**
* Scoping & Gating des Wehr-Bereichs (Workstream 7, Querschnittsstandard 13,
* default-deny dreifach).
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet:
* - anonym: jede /verwaltung/*-Seite -> Redirect auf /login.
* - wehr_read: jede /verwaltung/*-Seite -> 403 (forbidden()).
* - wehr_admin: erreichbar; fremdes Fahrzeug/Gerät -> 404 (notFound).
*
* Negativ-Probe: Entfernen von `requireWehrAdmin()` aus (app)/verwaltung/
* layout.tsx ODER aus einer Server-Action muss diese Suite rot machen.
*/
const VERWALTUNG_PAGES = [
"/verwaltung/profil",
"/verwaltung/fahrzeuge",
"/verwaltung/fahrzeuge/neu",
"/verwaltung/geraete",
"/verwaltung/geraete/neu",
"/verwaltung/benutzer",
];
test.describe("Verwaltung: anonym -> Redirect auf /login", () => {
test.use({ storageState: { cookies: [], origins: [] } });
for (const path of VERWALTUNG_PAGES) {
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({
page,
}) => {
await page.goto(path);
await expect(page).toHaveURL(/\/login/);
});
}
});
test.describe("Verwaltung: wehr_read -> 403", () => {
test.skip(
!process.env.E2E_WEHR_READ_STATE,
"benötigt wehr_read-Fixture (Test-Workstream)",
);
test.use({
storageState: process.env.E2E_WEHR_READ_STATE ?? { cookies: [], origins: [] },
});
for (const path of VERWALTUNG_PAGES) {
test(`wehr_read-Aufruf von ${path} -> 403`, async ({ page }) => {
const res = await page.goto(path);
expect(res?.status()).toBe(403);
});
}
});
test.describe("Verwaltung: fremde Wehr -> 404 (Scoping)", () => {
test.skip(
!process.env.E2E_WEHR_ADMIN_STATE || !process.env.E2E_FOREIGN_VEHICLE_ID,
"benötigt wehr_admin-Fixture + fremde Fahrzeug-ID (Test-Workstream)",
);
test.use({
storageState: process.env.E2E_WEHR_ADMIN_STATE ?? { cookies: [], origins: [] },
});
test("Aufruf eines fremden Fahrzeugs liefert 404", async ({ page }) => {
const res = await page.goto(
`/verwaltung/fahrzeuge/${process.env.E2E_FOREIGN_VEHICLE_ID}`,
);
expect(res?.status()).toBe(404);
});
});

View File

@@ -0,0 +1,68 @@
import { globSync } from "node:fs";
import { readFileSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Statischer Default-Deny-Scanner für Server Actions (Querschnittsstandard 3).
*
* Reine Funktionen, damit der Beweis sowohl im Vitest-Unit-Test (offline, ohne
* Server/DB) als auch in der Playwright-Suite (`server-actions-guard.spec.ts`)
* wiederverwendet werden kann.
*/
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
/** Erkennt einen der fünf kanonischen Guards (guards.ts). */
export const GUARD_REGEX =
/require(Session|Role|OwnBrigade|PlatformAdmin|WehrAdmin)\s*\(/;
/**
* Genuin öffentliche "use server"-Funktionen (Login VOR der Authentifizierung).
* Diese DÜRFEN keinen Session-Guard haben — sie sind der Einstieg. Schlüssel:
* `<repo-relativer Pfad>:<Funktionsname>`.
*/
export const PUBLIC_ACTION_ALLOWLIST: ReadonlySet<string> = new Set([
"src/app/(auth)/login/actions.ts:loginAction",
"src/app/(auth)/login/actions.ts:authentikLoginAction",
]);
/**
* Prüft den Quelltext EINER Datei. Gibt eine Liste von Verstößen
* (`<file>: <funktionsname>`) zurück. Dateien ohne "use server"-Direktive
* liefern nie Verstöße.
*/
export function findUnguardedActions(
file: string,
src: string,
allowlist: ReadonlySet<string> = PUBLIC_ACTION_ALLOWLIST,
): string[] {
if (!src.includes('"use server"')) return [];
const offenders: string[] = [];
const fns = src.split(/export async function /).slice(1);
for (const body of fns) {
const name = body.slice(0, body.indexOf("(")).trim();
if (allowlist.has(`${file}:${name}`)) continue;
// Erste ~600 Zeichen des Funktionskörpers müssen einen Guard enthalten.
if (!GUARD_REGEX.test(body.slice(0, 600))) {
offenders.push(`${file}: ${name}`);
}
}
return offenders;
}
/**
* Scannt das echte Repository (src/**) nach ungeschützten Server Actions.
*/
export function findUnguardedActionsInRepo(): string[] {
const files = globSync("src/**/*.{ts,tsx}", { cwd: REPO_ROOT });
const offenders: string[] = [];
for (const rel of files) {
const abs = resolve(REPO_ROOT, rel);
const src = readFileSync(abs, "utf8");
if (!src.includes('"use server"')) continue;
const repoRel = relative(REPO_ROOT, abs);
offenders.push(...findUnguardedActions(repoRel, src));
}
return offenders;
}

View File

@@ -0,0 +1,69 @@
import { globSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Routen-Discovery aus dem Dateisystem für den Driftschutz (Definition of
* Done #1). Reine Logik, offline lauffähig (kein Server/DB).
*/
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
/**
* Öffentliche Präfixe (Middleware-Allowlist). Routen, die mit einem dieser
* Präfixe beginnen, brauchen KEINEN Manifest-Eintrag, weil sie absichtlich
* anonym erreichbar sind.
*/
export const PUBLIC_ALLOWLIST: readonly string[] = [
"/login",
"/api/auth",
"/api/health",
"/_next",
"/favicon.ico",
"/robots.txt",
];
/**
* Wandelt einen Datei-Pfad (page.tsx | route.ts) in den zugehörigen URL-Pfad
* um. Route-Groups `(name)` werden entfernt, dynamische Segmente `[id]`
* bleiben als Platzhalter erhalten.
*/
export function filePathToRoute(filePath: string): string {
const withoutPrefix = filePath
.replace(/^.*src\/app\//, "")
.replace(/^(page|route)\.(tsx?|jsx?)$/, "")
.replace(/\/(page|route)\.(tsx?|jsx?)$/, "");
const segments = withoutPrefix
.split("/")
.filter((seg) => seg.length > 0 && !/^\(.*\)$/.test(seg));
return "/" + segments.join("/");
}
function isPublic(route: string): boolean {
return PUBLIC_ALLOWLIST.some(
(p) => route === p || route.startsWith(p + "/") || route.startsWith(p),
);
}
/**
* Findet Routen, die im Dateisystem existieren, aber weder im Manifest
* deklariert noch öffentlich sind.
*/
export function findUndeclaredRoutes(
discovered: readonly string[],
declared: ReadonlySet<string>,
): string[] {
return discovered
.filter((route) => !isPublic(route))
.filter((route) => !declared.has(route));
}
/** Scannt src/app/** nach page.tsx und route.ts und liefert URL-Pfade. */
export function discoverAppRoutes(): string[] {
const files = globSync("src/app/**/{page,route}.{ts,tsx}", {
cwd: REPO_ROOT,
});
const routes = new Set<string>();
for (const f of files) routes.add(filePathToRoute(f));
return [...routes].sort();
}

View File

@@ -0,0 +1,207 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Statische Deployment-Verifikation (Workstream 10), offline und ohne Docker:
* prüft die Compose-/Dockerfile-/Entrypoint-/Env-Artefakte gegen die im Plan
* festgelegten Verträge (externes Traefik, non-root, kein Proxy-Service,
* Pflicht-Env-Keys, idempotente Migration vor App-Start).
*/
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, "..", "..");
const read = (rel: string): string => readFileSync(resolve(root, rel), "utf8");
describe("Deployment-Artefakte", () => {
it("Dockerfile ist multi-stage, standalone und läuft non-root als UID 1001", () => {
const df = read("Dockerfile");
expect(df).toMatch(/AS\s+deps/);
expect(df).toMatch(/AS\s+builder/);
expect(df).toMatch(/AS\s+runner/);
// Standalone-Server wird kopiert und gestartet.
expect(df).toMatch(/\.next\/standalone/);
expect(df).toMatch(/server\.js/);
// Non-root: dedizierte UID/GID 1001 + USER-Wechsel.
expect(df).toMatch(/1001/);
expect(df).toMatch(/^USER\s+(nextjs|1001)/m);
// Entrypoint übernimmt Migration vor App-Start.
expect(df).toMatch(/entrypoint\.sh/);
});
it("Dockerfile kopiert die VOLLSTÄNDIGE pg/drizzle-orm-Laufzeit-Closure ins Runner-Image", () => {
// Reale Laufzeit-Closure aus node_modules berechnen (nur dependencies,
// wie sie zur Laufzeit von docker/migrate.mjs + docker/seed.mjs benötigt
// wird). Jedes Paket MUSS eine COPY-Zeile im Runner-Stage haben, sonst
// crasht der Container-Start mit ERR_MODULE_NOT_FOUND (z. B. pg-int8,
// xtend). Schützt vor erneutem Brechen bei pg/drizzle-Updates.
const df = read("Dockerfile");
const nodeModules = resolve(root, "node_modules");
const seen = new Set<string>();
const resolvePkg = (name: string, fromDir: string): string | null => {
let cur = fromDir;
for (;;) {
const candidate = resolve(cur, "node_modules", name);
if (existsSync(resolve(candidate, "package.json"))) return candidate;
const parent = dirname(cur);
if (parent === cur) break;
cur = parent;
}
const top = resolve(nodeModules, name);
return existsSync(resolve(top, "package.json")) ? top : null;
};
const walk = (dir: string): void => {
const pkg = JSON.parse(
readFileSync(resolve(dir, "package.json"), "utf8"),
) as { dependencies?: Record<string, string> };
for (const dep of Object.keys(pkg.dependencies ?? {})) {
if (seen.has(dep)) continue;
seen.add(dep);
const resolved = resolvePkg(dep, dir);
// Nur skalierbar prüfbar, wenn auflösbar; sonst meldet npm ci es ohnehin.
if (resolved) walk(resolved);
}
};
const pgRoot = resolve(nodeModules, "pg");
const drizzleRoot = resolve(nodeModules, "drizzle-orm");
walk(pgRoot);
walk(drizzleRoot);
// pg + drizzle-orm selbst sind ebenfalls Teil der Closure.
const closure = new Set<string>([...seen, "pg", "drizzle-orm"]);
const missing = [...closure].filter(
(name) =>
!df.includes(`/app/node_modules/${name} ./node_modules/${name}`),
);
expect(missing, `nicht ins Runner-Image kopiert: ${missing.join(", ")}`).toEqual(
[],
);
});
it("Dockerfile bündelt den Seed (build-seed-bundle) und kopiert docker/seed.mjs ins Runner-Image", () => {
const df = read("Dockerfile");
// Im builder wird der Seed zu Plain-ESM gebündelt ...
expect(df).toMatch(/build-seed-bundle\.mjs/);
// ... und ins Runner-Image kopiert, damit RUN_SEED=true nicht still no-opt.
expect(df).toMatch(/docker\/seed\.mjs/);
// Der Bundler-Entrypoint existiert.
expect(existsSync(resolve(root, "scripts/build-seed-bundle.mjs"))).toBe(true);
// Das Seed-Quellmodul, das gebündelt wird, existiert.
expect(existsSync(resolve(root, "src/db/seed/index.ts"))).toBe(true);
});
it(".dockerignore schließt node_modules, .next und Secrets aus", () => {
const di = read(".dockerignore");
expect(di).toMatch(/node_modules/);
expect(di).toMatch(/\.next/);
expect(di).toMatch(/\.env/);
});
it("entrypoint.sh hat gültige sh-Syntax, wartet auf Postgres, migriert, startet server.js", () => {
const ep = resolve(root, "docker/entrypoint.sh");
expect(existsSync(ep)).toBe(true);
// Syntaxprüfung (offline, kein Ausführen der Logik).
execFileSync("sh", ["-n", ep]);
const src = read("docker/entrypoint.sh");
expect(src).toMatch(/set -e/);
// Warten auf Postgres (TCP-Probe), dann Migration, optional Seed, dann Start.
expect(src).toMatch(/DATABASE_URL/);
expect(src).toMatch(/migrate/);
expect(src).toMatch(/RUN_SEED/);
expect(src).toMatch(/exec node server\.js/);
});
it(".env.example listet alle Pflicht-Env-Keys", () => {
const env = read(".env.example");
for (const key of [
"AUTH_URL",
"AUTH_TRUST_HOST",
"AUTH_SECRET",
"AUTHENTIK_ISSUER",
"AUTHENTIK_CLIENT_ID",
"AUTHENTIK_CLIENT_SECRET",
"DATABASE_URL",
"OSRM_URL",
"NOMINATIM_URL",
"APP_HOST",
]) {
expect(env, `fehlender Env-Key: ${key}`).toMatch(new RegExp(`^${key}=`, "m"));
}
});
it("docker-compose.yml nutzt externes Traefik-Netz, Labels, Healthchecks und keinen Proxy-Service", () => {
const c = read("docker-compose.yml");
// Genau die vier Fachdienste; kein eigener Proxy/Traefik-SERVICE.
const servicesBlock = c.slice(c.indexOf("\nservices:"), c.indexOf("\nvolumes:"));
expect(servicesBlock).toMatch(/^\s{2}app:/m);
expect(servicesBlock).toMatch(/^\s{2}postgres:/m);
expect(servicesBlock).toMatch(/^\s{2}osrm:/m);
expect(servicesBlock).toMatch(/^\s{2}nominatim:/m);
expect(servicesBlock).not.toMatch(/^\s{2}traefik:/m);
expect(servicesBlock).not.toMatch(/^\s{2}proxy:/m);
// Externes Traefik-Netz.
expect(c).toMatch(/external:\s*true/);
// Traefik-Labels am App-Service.
expect(c).toMatch(/traefik\.enable=true/);
expect(c).toMatch(/Host\(`\$\{APP_HOST\}`\)/);
expect(c).toMatch(/entrypoints=websecure/);
expect(c).toMatch(/tls\.certresolver/);
// Forwarded-Header / sichere Cookies via Env.
expect(c).toMatch(/AUTH_TRUST_HOST/);
expect(c).toMatch(/AUTH_URL/);
// Postgres-Healthcheck.
expect(c).toMatch(/pg_isready/);
// App-Healthcheck gegen /api/health.
expect(c).toMatch(/\/api\/health/);
});
it("docker compose config listet genau app/postgres/osrm/nominatim", () => {
// Offline-tauglich: `docker compose config --services` braucht keine Container.
let services: string;
try {
services = execFileSync(
"docker",
[
"compose",
"-f",
"docker-compose.yml",
"-f",
"docker-compose.geo.yml",
"config",
"--services",
],
{ cwd: root, encoding: "utf8", env: { ...process.env, APP_HOST: "floriannetz.example.at" } },
);
} catch {
// Kein Docker in der Sandbox: dieser Teilschritt ist deferred.
return;
}
const set = new Set(services.split("\n").map((s) => s.trim()).filter(Boolean));
expect(set).toEqual(new Set(["app", "postgres", "osrm", "nominatim"]));
});
it("Override-Beispiel macht die App lokal auf :3000 ohne TLS erreichbar", () => {
const o = read("docker-compose.override.yml.example");
expect(o).toMatch(/3000:3000/);
});
it("Makefile bietet build/up/deploy/data-Ziele", () => {
const mk = read("Makefile");
expect(mk).toMatch(/^build:/m);
expect(mk).toMatch(/^up:/m);
expect(mk).toMatch(/^deploy:/m);
expect(mk).toMatch(/^data:/m);
});
it("deployment-traefik.md dokumentiert Authentik-Callback und Pflicht-Env", () => {
const doc = read("docs/reference/deployment-traefik.md");
expect(doc).toMatch(/https:\/\/\$\{APP_HOST\}\/api\/auth\/callback\/authentik/);
expect(doc).toMatch(/AUTH_URL/);
expect(doc).toMatch(/external/);
expect(doc).toMatch(/\/api\/health/);
});
});

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
PUBLIC_ALLOWLIST,
filePathToRoute,
findUndeclaredRoutes,
discoverAppRoutes,
} from "../support/route-scan";
import {
DECLARED_ROUTE_TEMPLATES,
ROUTES,
} from "../e2e/routes.manifest";
/**
* Driftschutz für das Routen-Manifest (Definition of Done #1): jede neue Route
* unter src/app/** muss im Manifest geführt (oder explizit öffentlich) sein,
* sonst bleibt sie ungetestet im Auth-Gating. Reine Logik, offline lauffähig.
*/
describe("filePathToRoute", () => {
it("mappt page.tsx einer Route-Group auf den URL-Pfad ohne Gruppe", () => {
expect(filePathToRoute("src/app/(app)/fahrzeuge/page.tsx")).toBe(
"/fahrzeuge",
);
});
it("mappt die Wurzel-Page der (app)-Gruppe auf /", () => {
expect(filePathToRoute("src/app/(app)/page.tsx")).toBe("/");
});
it("ersetzt dynamische Segmente durch einen Platzhalter", () => {
expect(filePathToRoute("src/app/(app)/fahrzeuge/[id]/page.tsx")).toBe(
"/fahrzeuge/[id]",
);
});
it("mappt route.ts auf den API-Pfad", () => {
expect(filePathToRoute("src/app/api/health/route.ts")).toBe("/api/health");
});
it("mappt die Root-Page src/app/page.tsx auf /", () => {
expect(filePathToRoute("src/app/page.tsx")).toBe("/");
});
it("löst catch-all-Segmente auf", () => {
expect(filePathToRoute("src/app/api/auth/[...nextauth]/route.ts")).toBe(
"/api/auth/[...nextauth]",
);
});
});
describe("findUndeclaredRoutes", () => {
it("flaggt eine Route, die weder im Manifest noch öffentlich ist", () => {
const declared = new Set(["/fahrzeuge"]);
const discovered = ["/fahrzeuge", "/leak"];
expect(findUndeclaredRoutes(discovered, declared)).toEqual(["/leak"]);
});
it("ignoriert Routen mit öffentlichem Präfix", () => {
const declared = new Set<string>();
const discovered = ["/login", "/api/health", "/api/auth/[...nextauth]"];
expect(findUndeclaredRoutes(discovered, declared)).toEqual([]);
});
it("PUBLIC_ALLOWLIST enthält /api/health und /login", () => {
expect(PUBLIC_ALLOWLIST).toContain("/api/health");
expect(PUBLIC_ALLOWLIST).toContain("/login");
});
});
describe("discoverAppRoutes (echtes Repo)", () => {
it("findet die bekannten Seiten", () => {
const routes = discoverAppRoutes();
expect(routes).toContain("/fahrzeuge");
expect(routes).toContain("/admin");
expect(routes).toContain("/api/health");
});
});
describe("Driftschutz: Manifest deckt alle Routen ab (Definition of Done #1)", () => {
it("KEINE Route unter src/app/** fehlt im Manifest oder in der Allowlist", () => {
const discovered = discoverAppRoutes();
const undeclared = findUndeclaredRoutes(
discovered,
DECLARED_ROUTE_TEMPLATES,
);
expect(
undeclared,
`Ungetestete Routen (im Manifest ergänzen oder als öffentlich markieren):\n${undeclared.join(
"\n",
)}`,
).toEqual([]);
});
it("jeder ROUTES-Eintrag entspricht einer existierenden Route-Vorlage", () => {
const discovered = new Set(discoverAppRoutes());
for (const template of DECLARED_ROUTE_TEMPLATES) {
expect(discovered, `Manifest-Eintrag ${template} fehlt im Dateisystem`).toContain(
template,
);
}
// Sanity: jede konkrete ROUTES-Zeile ist nicht leer.
for (const r of ROUTES) expect(r.path.startsWith("/")).toBe(true);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
GUARD_REGEX,
PUBLIC_ACTION_ALLOWLIST,
findUnguardedActions,
findUnguardedActionsInRepo,
} from "../support/guard-scan";
/**
* Statischer Default-Deny-Beweis für Server Actions (Querschnittsstandard 3,
* Definition of Done #2): JEDE "use server"-Funktion ruft als erste Anweisung
* einen Guard. Dieser Test ist OHNE Server/DB lauffähig und prüft das echte
* Repository.
*/
describe("findUnguardedActions (pure)", () => {
it("flaggt eine exportierte Action ohne Guard", () => {
const src = `"use server";
export async function tut_was(x: unknown) {
return doThing(x);
}`;
expect(findUnguardedActions("x.ts", src)).toEqual([
"x.ts: tut_was",
]);
});
it("akzeptiert eine Action, die mit requireWehrAdmin beginnt", () => {
const src = `"use server";
export async function tut_was(x: unknown) {
const s = await requireWehrAdmin();
return doThing(x, s);
}`;
expect(findUnguardedActions("x.ts", src)).toEqual([]);
});
it("akzeptiert requirePlatformAdmin / requireSession / requireRole / requireOwnBrigade", () => {
for (const guard of [
"requirePlatformAdmin",
"requireSession",
"requireRole",
"requireOwnBrigade",
]) {
const src = `"use server";
export async function f(x: unknown) {
await ${guard}();
return x;
}`;
expect(findUnguardedActions("x.ts", src)).toEqual([]);
}
});
it("ignoriert Dateien ohne \"use server\"-Direktive", () => {
const src = `export async function f(x: unknown) { return x; }`;
expect(findUnguardedActions("x.ts", src)).toEqual([]);
});
it("respektiert die Allowlist genuin öffentlicher Actions", () => {
const src = `"use server";
export async function loginAction(x: unknown) { return x; }`;
expect(
findUnguardedActions(
"src/app/(auth)/login/actions.ts",
src,
new Set(["src/app/(auth)/login/actions.ts:loginAction"]),
),
).toEqual([]);
});
it("hat die Login-Actions in der Default-Allowlist", () => {
expect(PUBLIC_ACTION_ALLOWLIST.has("src/app/(auth)/login/actions.ts:loginAction")).toBe(true);
expect(
PUBLIC_ACTION_ALLOWLIST.has("src/app/(auth)/login/actions.ts:authentikLoginAction"),
).toBe(true);
});
it("GUARD_REGEX matcht alle fünf Guard-Namen", () => {
expect(GUARD_REGEX.test("requireSession(")).toBe(true);
expect(GUARD_REGEX.test("requirePlatformAdmin(")).toBe(true);
expect(GUARD_REGEX.test("doSomething(")).toBe(false);
});
});
describe("findUnguardedActionsInRepo (echtes Repo)", () => {
it("findet KEINE ungeschützten Server Actions im echten src/", () => {
const offenders = findUnguardedActionsInRepo();
expect(
offenders,
`Server Actions ohne Guard:\n${offenders.join("\n")}`,
).toEqual([]);
});
});

View File

@@ -11,6 +11,22 @@ export default defineConfig({
environment: "node", environment: "node",
globals: true, globals: true,
setupFiles: ["./vitest.setup.ts"], setupFiles: ["./vitest.setup.ts"],
include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"], include: [
"src/**/*.test.ts",
"src/**/__tests__/**/*.test.ts",
"tests/unit/**/*.test.ts",
],
coverage: {
provider: "v8",
// Querschnitt-Kern muss hoch abgedeckt sein (Definition of Done #7):
// src/lib/search und src/lib/geo >= 90 %.
include: ["src/lib/search/**", "src/lib/geo/**"],
thresholds: {
lines: 90,
functions: 90,
statements: 90,
branches: 80,
},
},
}, },
}); });