Compare commits
21 Commits
e97e16d254
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb4747cfeb | ||
|
|
0634d8c236 | ||
|
|
987b8c9c8f | ||
|
|
5d4afb5936 | ||
|
|
4863eadcce | ||
|
|
f933ecc19e | ||
|
|
38021cbc51 | ||
|
|
f71cf51eb4 | ||
|
|
f2578cedab | ||
|
|
a8d07ba2ab | ||
|
|
2e56a92b70 | ||
|
|
c099b3acd9 | ||
|
|
9927711192 | ||
|
|
d50ec765ab | ||
|
|
f99c1f1abd | ||
|
|
034fdb175f | ||
|
|
6975679c4e | ||
|
|
44050c7278 | ||
|
|
632ba2b081 | ||
|
|
5cda09c411 | ||
|
|
628d35bfcd |
35
.dockerignore
Normal file
35
.dockerignore
Normal 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
|
||||
26
.env.example
26
.env.example
@@ -4,7 +4,10 @@
|
||||
NODE_ENV=development
|
||||
|
||||
# Datenbank (Postgres)
|
||||
DATABASE_URL=postgres://floriannetz:floriannetz@localhost:5432/floriannetz
|
||||
# Datenbank (Postgres). Format: postgresql://USER:PASSWORT@HOST:PORT/DB
|
||||
# Lokal (Host -> Docker-Postgres via docker-compose.dev.yml): HOST=localhost.
|
||||
# Im Container setzt docker-compose.yml HOST automatisch auf den Service "postgres".
|
||||
DATABASE_URL=postgresql://floriannetz:floriannetz@localhost:5432/floriannetz
|
||||
|
||||
# Auth.js / NextAuth
|
||||
# AUTH_SECRET muss >= 32 Zeichen sein (z. B. `openssl rand -base64 32`)
|
||||
@@ -17,9 +20,30 @@ AUTH_TRUST_HOST=true
|
||||
AUTHENTIK_ISSUER=http://localhost:9000/application/o/floriannetz/
|
||||
AUTHENTIK_CLIENT_ID=floriannetz
|
||||
AUTHENTIK_CLIENT_SECRET=bitte-setzen
|
||||
# Mitglieder dieser Authentik-Gruppe erhalten beim Login automatisch
|
||||
# platform_admin. Wer NICHT in der Gruppe ist, wird vom SSO-Login abgewiesen.
|
||||
# Setup siehe docs/reference/authentik-setup.md.
|
||||
AUTHENTIK_ADMIN_GROUP=floriannetz-admins
|
||||
|
||||
# Geo (interne Dienste; Defaults zeigen auf Docker-Compose-Hostnamen)
|
||||
OSRM_URL=http://osrm:5000
|
||||
NOMINATIM_URL=http://nominatim:8080
|
||||
GEO_HTTP_TIMEOUT_MS=4000
|
||||
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=florian.feuerwehr-rems.at
|
||||
# Traefik-Zertifikatsauflöser (muss in der externen Traefik-Instanz definiert sein).
|
||||
TRAEFIK_CERTRESOLVER=letsencrypt
|
||||
# Name des externen, von Traefik verwalteten Docker-Netzes
|
||||
# (im feuerwehr_dashboard heißt es "frontend"). Muss existieren:
|
||||
# docker network create frontend
|
||||
TRAEFIK_NETWORK=frontend
|
||||
# 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
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -8,3 +8,10 @@ node_modules/
|
||||
tests/e2e/.auth/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
# Coverage-Report (vitest --coverage), generiertes Artefakt.
|
||||
coverage/
|
||||
|
||||
# Generiertes Artefakt: wird im Docker-builder aus src/db/seed gebündelt
|
||||
# (scripts/build-seed-bundle.mjs), nicht eingecheckt.
|
||||
docker/seed.mjs
|
||||
|
||||
102
Dockerfile
Normal file
102
Dockerfile
Normal file
@@ -0,0 +1,102 @@
|
||||
# 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
|
||||
# node:alpine bündelt npm 10, das bei plattformfremden optionalen Transitiv-Deps
|
||||
# (z. B. @node-rs/argon2 -> *-wasm32-wasi / @emnapi) strenger ist. npm 11 wie im
|
||||
# feuerwehr_dashboard verwenden.
|
||||
RUN npm install -g npm@11
|
||||
# .npmrc erzwingt das ÖFFENTLICHE npm-Registry. Der committete Lockfile wurde
|
||||
# gegen einen internen Mirror erzeugt (resolved-URLs zeigen dorthin, daher der
|
||||
# npm-ci-Fehler) und wird im Build bewusst NICHT verwendet — Auflösung frisch aus
|
||||
# der öffentlichen Registry (gleiches Vorgehen wie feuerwehr_dashboard/frontend).
|
||||
COPY package.json .npmrc ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
# --- builder: Next.js im Standalone-Modus bauen -------------------------------
|
||||
FROM node:${NODE_VERSION}-alpine AS builder
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Build-Zeit-Platzhalter: src/lib/env.ts validiert beim Import (Fail-Fast).
|
||||
# `next build` evaluiert beim "Collecting page data" die Server-Routen (u. a.
|
||||
# /api/auth/[...nextauth]) -> ohne gesetzte Variablen bricht der Import ab.
|
||||
# Diese Werte sind NUR für den Build (erfüllen das Zod-Schema); Server-env wird
|
||||
# NICHT ins Bundle inlined und die builder-Stage landet NICHT im Runtime-Image.
|
||||
# Echte Werte kommen zur Laufzeit aus docker-compose.
|
||||
ENV DATABASE_URL=postgresql://build:build@localhost:5432/build \
|
||||
AUTH_SECRET=build_only_placeholder_secret_min_32_chars_long \
|
||||
AUTH_URL=https://build.invalid \
|
||||
AUTHENTIK_ISSUER=https://build.invalid/application/o/floriannetz/ \
|
||||
AUTHENTIK_CLIENT_ID=build \
|
||||
AUTHENTIK_CLIENT_SECRET=build
|
||||
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"]
|
||||
151
Makefile
Normal file
151
Makefile
Normal file
@@ -0,0 +1,151 @@
|
||||
# FlorianNetz — Makefile
|
||||
# Lokale Entwicklung, Datenbank (Migrationen/Seeds) und Deployment (externes Traefik).
|
||||
#
|
||||
# Schnellstart (lokal, Postgres via Docker):
|
||||
# make setup # install + Postgres hoch + migrate + seed-all
|
||||
# make dev # Dev-Server -> http://localhost:3000
|
||||
#
|
||||
# Nur Build + Migrate (z. B. CI / vor Deploy):
|
||||
# make build-app migrate
|
||||
#
|
||||
# Voll-Deploy hinter externem Traefik (Docker):
|
||||
# docker network create frontend # einmalig (externes Traefik-Netz)
|
||||
# make deploy
|
||||
#
|
||||
# `make help` listet alle Ziele.
|
||||
|
||||
SHELL := /bin/bash
|
||||
COMPOSE = docker compose --env-file .env
|
||||
# Lokale DB-Ziele binden den Dev-Override ein (veröffentlicht Postgres-Port 5432).
|
||||
COMPOSE_DEV = docker compose --env-file .env -f docker-compose.yml -f docker-compose.dev.yml
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
.PHONY: help
|
||||
help: ## Diese Übersicht anzeigen
|
||||
@grep -hE '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
# --- Umgebung -------------------------------------------------------------
|
||||
.PHONY: env install
|
||||
env: ## .env aus .env.example erzeugen (falls noch nicht vorhanden)
|
||||
@if [ ! -f .env ]; then cp .env.example .env && echo "→ .env aus .env.example erstellt (Werte anpassen!)"; else echo "→ .env existiert bereits"; fi
|
||||
|
||||
install: ## npm-Abhängigkeiten installieren
|
||||
npm install
|
||||
|
||||
# --- Lokale Entwicklung & Qualität ---------------------------------------
|
||||
.PHONY: dev build-app lint typecheck test test-cov check
|
||||
dev: ## Next.js Dev-Server (http://localhost:3000)
|
||||
npm run dev
|
||||
|
||||
build-app: ## Next.js Production-Build (ohne Docker)
|
||||
npm run build
|
||||
|
||||
lint: ## ESLint
|
||||
npm run lint
|
||||
|
||||
typecheck: ## TypeScript prüfen (tsc --noEmit)
|
||||
npm run typecheck
|
||||
|
||||
test: ## Unit-Tests (Vitest)
|
||||
npm run test
|
||||
|
||||
test-cov: ## Unit-Tests mit Coverage
|
||||
npm run test:coverage
|
||||
|
||||
check: lint typecheck test ## Lint + Typecheck + Unit-Tests (Offline-DoD)
|
||||
|
||||
# --- E2E (braucht laufenden Server + Browser) ----------------------------
|
||||
.PHONY: e2e-install e2e e2e-gating
|
||||
e2e-install: ## Playwright-Browser installieren (einmalig)
|
||||
npx playwright install
|
||||
|
||||
e2e: ## Komplette Playwright-E2E-Suite
|
||||
npm run test:e2e
|
||||
|
||||
e2e-gating: ## Nur die Default-deny-Gating-Suite
|
||||
npm run test:e2e:gating
|
||||
|
||||
# --- Datenbank (lokal; Postgres via Docker, Port auf Host veröffentlicht) -
|
||||
.PHONY: db-up db-down db-wait generate migrate seed-auth seed seed-all db-check studio db-reset
|
||||
db-up: ## Nur Postgres starten (Docker, Port 5432 lokal)
|
||||
$(COMPOSE_DEV) up -d postgres
|
||||
|
||||
db-down: ## Postgres-Container stoppen
|
||||
$(COMPOSE_DEV) stop postgres
|
||||
|
||||
db-wait: ## Warten bis Postgres bereit ist (max ~60s)
|
||||
@echo "→ Warte auf Postgres…"; \
|
||||
for i in $$(seq 1 30); do \
|
||||
if $(COMPOSE_DEV) exec -T postgres sh -c 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"' >/dev/null 2>&1; then \
|
||||
echo "→ Postgres bereit."; exit 0; \
|
||||
fi; sleep 2; \
|
||||
done; echo "✗ Postgres nicht bereit (Timeout)."; exit 1
|
||||
|
||||
generate: ## Drizzle-Migration aus dem Schema generieren
|
||||
npm run db:generate
|
||||
|
||||
migrate: ## Migrationen anwenden (DATABASE_URL aus .env -> localhost:5432)
|
||||
npm run db:migrate
|
||||
|
||||
seed-auth: ## Ersten Platform-Admin anlegen (idempotent)
|
||||
npm run db:seed-auth
|
||||
|
||||
seed: ## NÖ-Katalog seeden: Merkmale/Vorlagen/Kategorien (idempotent)
|
||||
npm run db:seed
|
||||
|
||||
seed-all: seed-auth seed ## Auth- + Katalog-Seed
|
||||
|
||||
db-check: ## Drizzle-Schema-/Migrationskonsistenz prüfen (offline)
|
||||
npm run db:check
|
||||
|
||||
studio: ## Drizzle Studio öffnen (DB-Browser)
|
||||
npm run db:studio
|
||||
|
||||
db-reset: ## ACHTUNG: Postgres-Volume löschen, neu migrieren + seeden
|
||||
$(COMPOSE_DEV) rm -sf postgres
|
||||
-docker volume rm florian-netz_postgres-data
|
||||
$(MAKE) db-up db-wait migrate seed-all
|
||||
|
||||
# --- Erststart (lokal, von 0) --------------------------------------------
|
||||
.PHONY: setup
|
||||
setup: install env db-up db-wait migrate seed-all ## Komplettes lokales Setup von 0
|
||||
@echo ""
|
||||
@echo "✓ Setup fertig. Login-Admin via 'make seed-auth' angelegt. Weiter mit: make dev"
|
||||
|
||||
# --- Deployment (externes Traefik; braucht Docker) -----------------------
|
||||
# Externes Netz muss existieren: docker network create frontend
|
||||
.PHONY: build up up-core down logs ps deploy deploy-core migrate-stack data config
|
||||
build: ## App-Image bauen (Next.js standalone, non-root)
|
||||
$(COMPOSE) build app
|
||||
|
||||
up: ## Stack starten (App + Postgres + Geo) hinter Traefik
|
||||
$(COMPOSE) up -d
|
||||
|
||||
up-core: ## Nur App + Postgres starten (OHNE Geo/OSRM/Nominatim) — wenig RAM nötig
|
||||
$(COMPOSE) up -d --build app postgres
|
||||
|
||||
down: ## Stack stoppen
|
||||
$(COMPOSE) down
|
||||
|
||||
logs: ## App-Logs folgen
|
||||
$(COMPOSE) logs -f app
|
||||
|
||||
ps: ## Status der Stack-Container
|
||||
$(COMPOSE) ps
|
||||
|
||||
deploy: build up ## build + up (voller Stack inkl. Geo; migrate via Entrypoint)
|
||||
|
||||
deploy-core: ## build + up-core (App + Postgres, ohne Geo; Geo später per 'make data' + 'make up')
|
||||
$(MAKE) up-core
|
||||
|
||||
migrate-stack: ## Migrationen im laufenden App-Container ausführen (manuell)
|
||||
$(COMPOSE) exec app node docker/migrate.mjs
|
||||
|
||||
data: ## OSRM-Geodaten vorbereiten (Download + Preprocessing; viel RAM/Disk)
|
||||
./scripts/prepare-osm-data.sh
|
||||
|
||||
config: ## Compose-Konfiguration validieren
|
||||
$(COMPOSE) config --services
|
||||
11
docker-compose.dev.yml
Normal file
11
docker-compose.dev.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
# Lokale Entwicklung: veröffentlicht den Postgres-Port auf dem Host (5432),
|
||||
# damit auf dem HOST laufende Befehle (`make migrate`, `make seed`, `npm run db:*`)
|
||||
# die Datenbank über DATABASE_URL (…@localhost:5432/…) erreichen.
|
||||
#
|
||||
# Wird NUR von den lokalen DB-Zielen des Makefiles eingebunden
|
||||
# (docker compose -f docker-compose.yml -f docker-compose.dev.yml …),
|
||||
# NICHT vom Produktiv-Deploy — dort bleibt Postgres app-intern (kein offener Port).
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
20
docker-compose.override.yml.example
Normal file
20
docker-compose.override.yml.example
Normal 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
|
||||
154
docker-compose.yml
Normal file
154
docker-compose.yml
Normal file
@@ -0,0 +1,154 @@
|
||||
# FlorianNetz — Basis-Compose hinter EXTERNEM Traefik.
|
||||
#
|
||||
# Ausgerichtet auf das bestehende Setup von feuerwehr_dashboard:
|
||||
# - externes, von Traefik verwaltetes Netz heißt "frontend" (external: true)
|
||||
# - Router: entrypoints=websecure, tls + certresolver=letsencrypt
|
||||
# - explizite Router->Service-Bindung, loadbalancer.server.port=3000
|
||||
# - traefik.docker.network = das externe "frontend"-Netz
|
||||
#
|
||||
# Es gibt bewusst KEINEN eigenen Proxy-/Traefik-Service: Routing/TLS übernimmt
|
||||
# die separat betriebene Traefik-Instanz am Netz "${TRAEFIK_NETWORK}" (Default:
|
||||
# frontend). Dieses Netz muss bereits existieren:
|
||||
# docker network create frontend
|
||||
#
|
||||
# Postgres/Geo liegen am internen Bridge-Netz (keine veröffentlichten Ports,
|
||||
# also nicht öffentlich erreichbar) — der App-Container hat über dieses Netz
|
||||
# zugleich Egress (z. B. für den Authentik-OIDC-Token-Austausch).
|
||||
#
|
||||
# Start: docker compose --env-file .env up -d
|
||||
# Lokal: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://${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}
|
||||
AUTHENTIK_ADMIN_GROUP: ${AUTHENTIK_ADMIN_GROUP:-floriannetz-admins}
|
||||
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:
|
||||
- frontend
|
||||
- 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:-frontend}"
|
||||
- "traefik.http.routers.floriannetz.entrypoints=websecure"
|
||||
- "traefik.http.routers.floriannetz.rule=Host(`${APP_HOST}`)"
|
||||
- "traefik.http.routers.floriannetz.tls=true"
|
||||
- "traefik.http.routers.floriannetz.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}"
|
||||
- "traefik.http.routers.floriannetz.service=floriannetz"
|
||||
- "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 (wie im
|
||||
# feuerwehr_dashboard "frontend"). Muss existieren: docker network create frontend
|
||||
frontend:
|
||||
external: true
|
||||
name: ${TRAEFIK_NETWORK:-frontend}
|
||||
# Internes Bridge-Netz: Postgres/Geo ohne veröffentlichte Ports (nicht
|
||||
# öffentlich), zugleich Egress für den App-Container (Authentik-OIDC).
|
||||
internal:
|
||||
driver: bridge
|
||||
49
docker/entrypoint.sh
Normal file
49
docker/entrypoint.sh
Normal 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
28
docker/migrate.mjs
Normal 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();
|
||||
}
|
||||
53
docs/reference/authentik-setup.md
Normal file
53
docs/reference/authentik-setup.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Authentik-Integration & Admin-Zugang (FlorianNetz)
|
||||
|
||||
FlorianNetz nutzt Authentik als **OIDC-Identitätsanbieter**. Der **Admin-Zugang
|
||||
(platform_admin) wird zentral über eine Authentik-Gruppe** gesteuert — nicht über
|
||||
manuell gesetzte DB-Rollen.
|
||||
|
||||
## Wie es funktioniert
|
||||
|
||||
- Anmeldung über Authentik (OIDC). FlorianNetz fordert die Scopes
|
||||
`openid email profile groups` an.
|
||||
- Im `signIn`-Callback (`src/auth.ts`) wird der `groups`-Claim ausgewertet:
|
||||
- **Mitglied der Admin-Gruppe** (`AUTHENTIK_ADMIN_GROUP`, Standard
|
||||
`floriannetz-admins`) → wird (idempotent) als `platform_admin` in `users`
|
||||
angelegt/aktualisiert und eingeloggt.
|
||||
- **Kein Mitglied** → Login wird **abgewiesen** (`return false`).
|
||||
- Folge: Admins werden **in Authentik** verwaltet (Gruppenmitgliedschaft), nicht
|
||||
per `seed-auth`. Ein erstes manuelles Seeding entfällt (kein Henne-Ei-Problem).
|
||||
- **Wehr-Konten** (wehr_admin/wehr_read) bleiben **lokale** App-Konten
|
||||
(E-Mail+Passwort), die Wehr-Admins selbst anlegen — sie nutzen NICHT Authentik.
|
||||
|
||||
## Einrichtung in Authentik
|
||||
|
||||
1. **Gruppe anlegen:** z. B. `floriannetz-admins`; gewünschte Admin-Benutzer
|
||||
hinzufügen. (Muss exakt `AUTHENTIK_ADMIN_GROUP` entsprechen.)
|
||||
2. **Provider anlegen:** OAuth2/OpenID Provider
|
||||
- Redirect-URI: `https://<APP_HOST>/api/auth/callback/authentik`
|
||||
- Signing Key wie üblich; Client-Typ „Confidential".
|
||||
3. **Scopes/Property-Mappings:** dem Provider die Scope-Mappings
|
||||
`openid`, `email`, `profile` **und** das Gruppen-Mapping zuweisen, das den
|
||||
`groups`-Claim liefert (Authentik-Standard: „authentik default OAuth Mapping:
|
||||
OpenID 'groups'"). Ohne dieses Mapping enthält das Token keine `groups` und
|
||||
**niemand** erhält Admin-Zugang.
|
||||
4. **Application anlegen** und mit dem Provider verknüpfen; Slug muss zum
|
||||
`AUTHENTIK_ISSUER` passen (`…/application/o/<slug>/`).
|
||||
5. **Client-ID/-Secret** aus dem Provider übernehmen.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
```
|
||||
AUTHENTIK_ISSUER=https://auth.example.at/application/o/floriannetz/
|
||||
AUTHENTIK_CLIENT_ID=…
|
||||
AUTHENTIK_CLIENT_SECRET=…
|
||||
AUTHENTIK_ADMIN_GROUP=floriannetz-admins
|
||||
```
|
||||
|
||||
## Prüfen
|
||||
|
||||
- Mitglied von `floriannetz-admins` meldet sich an → landet als Admin in
|
||||
`/admin`; in `users` existiert eine Zeile `authTyp='authentik'`,
|
||||
`rolle='platform_admin'`.
|
||||
- Nicht-Mitglied meldet sich an → Login abgewiesen (zurück zu `/login`).
|
||||
- `groups`-Claim fehlt (Mapping nicht zugewiesen) → alle SSO-Logins abgewiesen
|
||||
(erwartetes Fail-safe-Verhalten: kein Claim ⇒ kein Admin).
|
||||
138
docs/reference/deployment-traefik.md
Normal file
138
docs/reference/deployment-traefik.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 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:
|
||||
|
||||
- **`frontend`** — externes, von Traefik verwaltetes Netz (`external: true`,
|
||||
Name aus `TRAEFIK_NETWORK`, Default `frontend` — wie im feuerwehr_dashboard).
|
||||
Nur `app` hängt daran (Proxy↔App).
|
||||
- **`internal`** — internes Bridge-Netz; Postgres und die Geo-Dienste haben
|
||||
**keine veröffentlichten Ports** (nicht öffentlich erreichbar). Über dieses
|
||||
Netz hat der App-Container zugleich **Egress** (z. B. für den
|
||||
Authentik-OIDC-Token-Austausch).
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Das externe Traefik-Netz muss existieren, bevor der Stack startet:
|
||||
|
||||
```bash
|
||||
docker network create frontend
|
||||
```
|
||||
|
||||
Die externe Traefik-Instanz muss:
|
||||
|
||||
- einen Entrypoint `websecure` (Port 443) bereitstellen,
|
||||
- einen Zertifikatsauflöser anbieten, dessen Name `TRAEFIK_CERTRESOLVER`
|
||||
entspricht (Default `letsencrypt`),
|
||||
- am Netz `frontend` 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. `florian.feuerwehr-rems.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 |
|
||||
| `AUTHENTIK_ADMIN_GROUP` | Authentik-Gruppe → platform_admin (Default `floriannetz-admins`; s. authentik-setup.md) |
|
||||
| `DATABASE_URL` | wird in Compose aus `POSTGRES_*` zusammengesetzt |
|
||||
| `TRAEFIK_CERTRESOLVER` | Name des Traefik-Zertifikatsauflösers |
|
||||
| `TRAEFIK_NETWORK` | Name des externen Traefik-Netzes (Default `frontend`) |
|
||||
|
||||
## 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`.
|
||||
|
||||
**Admin-Zugang über Gruppe:** Dem Provider muss das `groups`-Scope-Mapping
|
||||
zugewiesen sein, und es muss die Gruppe aus `AUTHENTIK_ADMIN_GROUP` existieren —
|
||||
nur deren Mitglieder werden `platform_admin`. Details: `authentik-setup.md`.
|
||||
|
||||
## 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 frontend # 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://`.
|
||||
47
docs/reference/sicherheitshaertung-checkliste.md
Normal file
47
docs/reference/sicherheitshaertung-checkliste.md
Normal 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.
|
||||
650
package-lock.json
generated
650
package-lock.json
generated
@@ -34,6 +34,7 @@
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitest/coverage-v8": "^3.2.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"eslint": "^9.21.0",
|
||||
@@ -59,6 +60,20 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://npm.apple.com/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core": {
|
||||
"version": "0.41.2",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@auth/core/-/core-0.41.2.tgz",
|
||||
@@ -88,6 +103,64 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://npm.apple.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://npm.apple.com/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://npm.apple.com/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://npm.apple.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@drizzle-team/brocli": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||
@@ -1687,6 +1760,33 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://npm.apple.com/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||
"strip-ansi": "^7.0.1",
|
||||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||
"wrap-ansi": "^8.1.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://npm.apple.com/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -2211,6 +2311,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://npm.apple.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@playwright/test/-/test-1.60.0.tgz",
|
||||
@@ -4011,6 +4121,39 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://npm.apple.com/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz",
|
||||
"integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"ast-v8-to-istanbul": "^0.3.3",
|
||||
"debug": "^4.4.1",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-lib-source-maps": "^5.0.6",
|
||||
"istanbul-reports": "^3.1.7",
|
||||
"magic-string": "^0.30.17",
|
||||
"magicast": "^0.3.5",
|
||||
"std-env": "^3.9.0",
|
||||
"test-exclude": "^7.0.1",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "3.2.6",
|
||||
"vitest": "3.2.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://npm.apple.com/@vitest/expect/-/expect-3.2.6.tgz",
|
||||
@@ -4160,6 +4303,19 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://npm.apple.com/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://npm.apple.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -4397,6 +4553,25 @@
|
||||
"integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "0.3.12",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
|
||||
"integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://npm.apple.com/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async-function": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://npm.apple.com/async-function/-/async-function-1.0.0.tgz",
|
||||
@@ -5155,6 +5330,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://npm.apple.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.368",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz",
|
||||
@@ -5977,6 +6158,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://npm.apple.com/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://npm.apple.com/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -6176,6 +6373,27 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://npm.apple.com/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://npm.apple.com/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -6188,6 +6406,30 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://npm.apple.com/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://npm.apple.com/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://npm.apple.com/globals/-/globals-14.0.0.tgz",
|
||||
@@ -6317,6 +6559,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://npm.apple.com/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://npm.apple.com/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -6541,6 +6790,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://npm.apple.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://npm.apple.com/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
@@ -6770,6 +7028,58 @@
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://npm.apple.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://npm.apple.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-source-maps": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://npm.apple.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
|
||||
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.23",
|
||||
"debug": "^4.1.1",
|
||||
"istanbul-lib-coverage": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/iterator.prototype": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://npm.apple.com/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
|
||||
@@ -6787,6 +7097,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://npm.apple.com/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -6979,6 +7305,12 @@
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://npm.apple.com/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -6989,6 +7321,33 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/magicast/-/magicast-0.3.5.tgz",
|
||||
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.25.4",
|
||||
"@babel/types": "^7.25.4",
|
||||
"source-map-js": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://npm.apple.com/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -7043,6 +7402,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://npm.apple.com/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://npm.apple.com/ms/-/ms-2.1.3.tgz",
|
||||
@@ -7449,6 +7817,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://npm.apple.com/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -7485,6 +7860,22 @@
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://npm.apple.com/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://npm.apple.com/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -8625,6 +9016,18 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://npm.apple.com/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://npm.apple.com/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -8697,6 +9100,68 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://npm.apple.com/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs": {
|
||||
"name": "string-width",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://npm.apple.com/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://npm.apple.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://npm.apple.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
@@ -8805,6 +9270,46 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://npm.apple.com/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs": {
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://npm.apple.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
@@ -9012,6 +9517,57 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://npm.apple.com/test-exclude/-/test-exclude-7.0.2.tgz",
|
||||
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^10.4.1",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://npm.apple.com/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://npm.apple.com/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://npm.apple.com/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://npm.apple.com/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -10495,6 +11051,7 @@
|
||||
"resolved": "https://npm.apple.com/vitest/-/vitest-3.2.6.tgz",
|
||||
"integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.6",
|
||||
@@ -10702,6 +11259,99 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://npm.apple.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://npm.apple.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://npm.apple.com/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://npm.apple.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -11,10 +11,14 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:unit": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx scripts/migrate.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:studio": "drizzle-kit studio",
|
||||
"db:check": "drizzle-kit check"
|
||||
@@ -46,6 +50,7 @@
|
||||
"@types/pg": "^8.11.11",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitest/coverage-v8": "^3.2.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"eslint": "^9.21.0",
|
||||
|
||||
@@ -13,12 +13,22 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
// Migration + deterministischer Seed (deferred ohne DATABASE_URL).
|
||||
globalSetup: "./tests/e2e/global-setup.ts",
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
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
|
||||
? undefined
|
||||
|
||||
3
public/.gitkeep
Normal file
3
public/.gitkeep
Normal file
@@ -0,0 +1,3 @@
|
||||
# Platzhalter, damit das public/-Verzeichnis existiert und vom Docker-Build
|
||||
# (COPY /app/public ./public) sowie von Next.js (statische Assets) genutzt werden
|
||||
# kann. Statische Dateien (z. B. favicon.ico, robots.txt) hier ablegen.
|
||||
37
scripts/build-seed-bundle.mjs
Normal file
37
scripts/build-seed-bundle.mjs
Normal 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.");
|
||||
144
src/app/(admin)/_actions/__tests__/proposals.test.ts
Normal file
144
src/app/(admin)/_actions/__tests__/proposals.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,17 @@ export async function resetBrigadeUserPassword(
|
||||
const s = await requirePlatformAdmin();
|
||||
const p = userResetSchema.safeParse(input);
|
||||
if (!p.success) return { ok: false, error: "Ungültige ID." };
|
||||
try {
|
||||
const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id);
|
||||
revalidatePath("/admin/wehren");
|
||||
return { ok: true, tempPassword };
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Passwort konnte nicht zurückgesetzt werden.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { merkmale, merkmalValues, vehicleTemplateMerkmale } from "@/db/schema";
|
||||
@@ -80,7 +80,40 @@ export async function mergeMerkmal(input: {
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
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
|
||||
.update(merkmalValues)
|
||||
.set({ merkmalId: ziel.data })
|
||||
@@ -99,6 +132,12 @@ export async function mergeMerkmal(input: {
|
||||
tx,
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Zusammenführen fehlgeschlagen. Bitte erneut versuchen.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidatePath("/admin/merkmale/proposals");
|
||||
return { ok: true };
|
||||
|
||||
14
src/app/(app)/fahrzeuge/[id]/not-found.tsx
Normal file
14
src/app/(app)/fahrzeuge/[id]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/app/(app)/fahrzeuge/[id]/page.tsx
Normal file
48
src/app/(app)/fahrzeuge/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/app/(app)/geraete/[id]/page.tsx
Normal file
58
src/app/(app)/geraete/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/app/(app)/verwaltung/benutzer/page.tsx
Normal file
77
src/app/(app)/verwaltung/benutzer/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/app/(app)/verwaltung/fahrzeuge/[id]/VehicleControls.tsx
Normal file
93
src/app/(app)/verwaltung/fahrzeuge/[id]/VehicleControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/app/(app)/verwaltung/fahrzeuge/[id]/page.tsx
Normal file
50
src/app/(app)/verwaltung/fahrzeuge/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(app)/verwaltung/fahrzeuge/neu/page.tsx
Normal file
23
src/app/(app)/verwaltung/fahrzeuge/neu/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/app/(app)/verwaltung/fahrzeuge/page.tsx
Normal file
55
src/app/(app)/verwaltung/fahrzeuge/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/app/(app)/verwaltung/geraete/[id]/EquipmentControls.tsx
Normal file
89
src/app/(app)/verwaltung/geraete/[id]/EquipmentControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/app/(app)/verwaltung/geraete/[id]/page.tsx
Normal file
60
src/app/(app)/verwaltung/geraete/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/app/(app)/verwaltung/geraete/neu/page.tsx
Normal file
28
src/app/(app)/verwaltung/geraete/neu/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/app/(app)/verwaltung/geraete/page.tsx
Normal file
60
src/app/(app)/verwaltung/geraete/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/app/(app)/verwaltung/layout.tsx
Normal file
25
src/app/(app)/verwaltung/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/app/(app)/verwaltung/profil/page.tsx
Normal file
33
src/app/(app)/verwaltung/profil/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/app/(app)/wehren/[id]/page.tsx
Normal file
106
src/app/(app)/wehren/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,9 @@ export const authConfig = {
|
||||
issuer: process.env.AUTHENTIK_ISSUER!,
|
||||
clientId: process.env.AUTHENTIK_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
|
||||
// `groups`-Claim anfordern, damit der Admin-Zugang über die
|
||||
// Authentik-Gruppenmitgliedschaft gesteuert werden kann (signIn-Callback).
|
||||
authorization: { params: { scope: "openid email profile groups" } },
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
|
||||
66
src/auth.ts
66
src/auth.ts
@@ -7,12 +7,47 @@ import { users } from "@/db/schema";
|
||||
import { authConfig } from "./auth.config";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { checkRateLimit, recordAttempt } from "@/lib/auth/rate-limit";
|
||||
import { extractGroups, isAdminGroupMember } from "@/lib/auth/authentik";
|
||||
import { ROLES } from "@/lib/auth/roles";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
const credSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Stellt sicher, dass ein Authentik-Admin (Mitglied der Admin-Gruppe) als
|
||||
* platform_admin in `users` existiert — für eine stabile id (Audit/FKs).
|
||||
* Idempotent über die eindeutige E-Mail; benötigt KEIN vorheriges Seeding.
|
||||
*/
|
||||
async function upsertAuthentikAdmin(email: string, name: string | null) {
|
||||
const normalized = email.toLowerCase();
|
||||
const rows = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: normalized,
|
||||
name: name ?? normalized,
|
||||
rolle: ROLES.PLATFORM_ADMIN,
|
||||
authTyp: "authentik",
|
||||
aktiv: true,
|
||||
brigadeId: null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: users.email,
|
||||
set: {
|
||||
rolle: ROLES.PLATFORM_ADMIN,
|
||||
authTyp: "authentik",
|
||||
aktiv: true,
|
||||
...(name ? { name } : {}),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("Authentik-Admin-Upsert lieferte keine Zeile");
|
||||
return row;
|
||||
}
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
providers: [
|
||||
@@ -50,17 +85,32 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
],
|
||||
callbacks: {
|
||||
...authConfig.callbacks,
|
||||
// Authentik-Login-Gate: nur vorgemerkte, aktive authentik-Konten zulassen.
|
||||
async signIn({ user, account }) {
|
||||
// Authentik-Login = Admin-Zugang, gesteuert über die Authentik-GRUPPE:
|
||||
// Nur Mitglieder von AUTHENTIK_ADMIN_GROUP dürfen rein und werden
|
||||
// (idempotent) als platform_admin angelegt. Alle anderen werden abgewiesen.
|
||||
async signIn({ user, account, profile }) {
|
||||
if (account?.provider === "authentik") {
|
||||
const email = user.email;
|
||||
if (!email) return false;
|
||||
const u = await db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
});
|
||||
if (!u || !u.aktiv || u.authTyp !== "authentik") return false;
|
||||
if (!email) {
|
||||
console.warn("[auth] Authentik-Login ohne E-Mail abgelehnt.");
|
||||
return false;
|
||||
}
|
||||
const groups = extractGroups(profile);
|
||||
if (!isAdminGroupMember(groups, env.AUTHENTIK_ADMIN_GROUP)) {
|
||||
console.warn(
|
||||
`[auth] Authentik-Login abgelehnt: "${email}" ist nicht in Gruppe ` +
|
||||
`"${env.AUTHENTIK_ADMIN_GROUP}". Erhaltene Gruppen: ${JSON.stringify(groups)}` +
|
||||
(groups.length
|
||||
? ""
|
||||
: " — leer: vermutlich fehlt das 'groups'-Scope-Mapping im " +
|
||||
"Authentik-Provider (oder der 'groups'-Scope wird nicht angefragt)."),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const u = await upsertAuthentikAdmin(email, user.name ?? null);
|
||||
user.id = u.id;
|
||||
user.role = u.rolle;
|
||||
user.brigadeId = u.brigadeId ?? null;
|
||||
user.brigadeId = null;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
39
src/components/detail/BeladungListe.tsx
Normal file
39
src/components/detail/BeladungListe.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/detail/DetailHeader.tsx
Normal file
38
src/components/detail/DetailHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/components/detail/EckdatenGrid.tsx
Normal file
39
src/components/detail/EckdatenGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/components/detail/StatusBadge.tsx
Normal file
6
src/components/detail/StatusBadge.tsx
Normal 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";
|
||||
49
src/components/kontakt/KontaktButton.tsx
Normal file
49
src/components/kontakt/KontaktButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/kontakt/WehrCard.tsx
Normal file
54
src/components/kontakt/WehrCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/components/verwaltung/BrigadeProfileForm.tsx
Normal file
124
src/components/verwaltung/BrigadeProfileForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/components/verwaltung/BrigadeUserForm.tsx
Normal file
138
src/components/verwaltung/BrigadeUserForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
src/components/verwaltung/EquipmentForm.tsx
Normal file
184
src/components/verwaltung/EquipmentForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/components/verwaltung/MerkmalValueEditor.tsx
Normal file
160
src/components/verwaltung/MerkmalValueEditor.tsx
Normal 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);
|
||||
}
|
||||
44
src/components/verwaltung/TemplatePicker.tsx
Normal file
44
src/components/verwaltung/TemplatePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
src/components/verwaltung/VehicleForm.tsx
Normal file
169
src/components/verwaltung/VehicleForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/verwaltung/VerwaltungNav.tsx
Normal file
52
src/components/verwaltung/VerwaltungNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/db/seed/data/equipment-categories.ts
Normal file
34
src/db/seed/data/equipment-categories.ts
Normal 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 },
|
||||
];
|
||||
273
src/db/seed/data/merkmale.ts
Normal file
273
src/db/seed/data/merkmale.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
206
src/db/seed/data/vehicle-templates.ts
Normal file
206
src/db/seed/data/vehicle-templates.ts
Normal 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 800–2.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.000–4.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.000–14.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, 7–14 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
117
src/db/seed/index.ts
Normal 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
205
src/db/seed/seed.test.ts
Normal 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
218
src/db/seed/upsert.ts
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const VALID_ENV = {
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: "postgres://user:pass@localhost:5432/floriannetz",
|
||||
DATABASE_URL: "postgresql://user:pass@localhost:5432/floriannetz",
|
||||
AUTH_SECRET: "x".repeat(32),
|
||||
AUTH_URL: "http://localhost:3000",
|
||||
AUTHENTIK_ISSUER: "http://localhost:9000/application/o/floriannetz/",
|
||||
|
||||
88
src/lib/admin/__tests__/provisioning.test.ts
Normal file
88
src/lib/admin/__tests__/provisioning.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -116,6 +116,13 @@ export async function resetUserPassword(
|
||||
const temp = generateTempPassword();
|
||||
const hash = await hashPassword(temp);
|
||||
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
|
||||
.update(users)
|
||||
.set({ passwortHash: hash })
|
||||
|
||||
38
src/lib/auth/__tests__/authentik.test.ts
Normal file
38
src/lib/auth/__tests__/authentik.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractGroups, isAdminGroupMember } from "@/lib/auth/authentik";
|
||||
|
||||
describe("isAdminGroupMember", () => {
|
||||
it("true, wenn die Admin-Gruppe enthalten ist", () => {
|
||||
expect(
|
||||
isAdminGroupMember(["wehr-x", "floriannetz-admins"], "floriannetz-admins"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("false, wenn die Admin-Gruppe fehlt", () => {
|
||||
expect(isAdminGroupMember(["wehr-x"], "floriannetz-admins")).toBe(false);
|
||||
});
|
||||
|
||||
it("false bei leerer Gruppenliste", () => {
|
||||
expect(isAdminGroupMember([], "floriannetz-admins")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractGroups", () => {
|
||||
it("liest den groups-Claim", () => {
|
||||
expect(extractGroups({ sub: "1", groups: ["a", "b"] })).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("leeres Array, wenn kein groups-Claim vorhanden ist", () => {
|
||||
expect(extractGroups({ sub: "1" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("leeres Array bei undefined/null", () => {
|
||||
expect(extractGroups(undefined)).toEqual([]);
|
||||
expect(extractGroups(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("defensiv: ignoriert nicht-string-Arrays", () => {
|
||||
expect(extractGroups({ groups: "kein-array" })).toEqual([]);
|
||||
expect(extractGroups({ groups: [1, 2, 3] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
25
src/lib/auth/authentik.ts
Normal file
25
src/lib/auth/authentik.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Reine Helfer für die Authentik-Gruppensteuerung des Admin-Zugangs.
|
||||
* Bewusst OHNE DB-/Node-Importe, damit sie isoliert unit-testbar sind und
|
||||
* auch im Edge-Pfad unbedenklich wären.
|
||||
*/
|
||||
|
||||
const profileWithGroups = z
|
||||
.object({ groups: z.array(z.string()).optional() })
|
||||
.passthrough();
|
||||
|
||||
/** Extrahiert den `groups`-Claim aus dem Authentik-OIDC-Profil (defensiv). */
|
||||
export function extractGroups(profile: unknown): string[] {
|
||||
const parsed = profileWithGroups.safeParse(profile);
|
||||
return parsed.success && parsed.data.groups ? parsed.data.groups : [];
|
||||
}
|
||||
|
||||
/** Entscheidung: Ist der Benutzer Mitglied der konfigurierten Admin-Gruppe? */
|
||||
export function isAdminGroupMember(
|
||||
groups: readonly string[],
|
||||
adminGroup: string,
|
||||
): boolean {
|
||||
return groups.includes(adminGroup);
|
||||
}
|
||||
81
src/lib/detail/merkmale.test.ts
Normal file
81
src/lib/detail/merkmale.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
99
src/lib/detail/merkmale.ts
Normal file
99
src/lib/detail/merkmale.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
112
src/lib/detail/queries.test.ts
Normal file
112
src/lib/detail/queries.test.ts
Normal 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
252
src/lib/detail/queries.ts
Normal 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 };
|
||||
}
|
||||
@@ -10,6 +10,8 @@ const serverSchema = z.object({
|
||||
AUTHENTIK_ISSUER: z.string().url(),
|
||||
AUTHENTIK_CLIENT_ID: z.string().min(1),
|
||||
AUTHENTIK_CLIENT_SECRET: z.string().min(1),
|
||||
// Authentik-Gruppe, deren Mitglieder automatisch platform_admin werden.
|
||||
AUTHENTIK_ADMIN_GROUP: z.string().min(1).default("floriannetz-admins"),
|
||||
// Geo:
|
||||
OSRM_URL: z.string().url().default("http://osrm:5000"),
|
||||
NOMINATIM_URL: z.string().url().default("http://nominatim:8080"),
|
||||
|
||||
@@ -61,4 +61,46 @@ describe("orderByEintreffzeit", () => {
|
||||
const result = await orderByEintreffzeit(origin, [a, b], etaTable);
|
||||
expect(result.map((r) => r.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("(f) OSRM ohne Route fuer EINEN Kandidaten: einzelner Haversine-Fallback", async () => {
|
||||
// OSRM liefert eine Tabelle, kennt aber fuer 'fern' keine Route (null).
|
||||
// -> nur dieser Treffer faellt auf Haversine zurueck, 'nah' bleibt osrm.
|
||||
const etaTable = vi.fn().mockResolvedValue({
|
||||
durations: [[0, 120, null]], // origin -> [nah=120s, fern=keine Route]
|
||||
distances: [[0, 3000, null]],
|
||||
});
|
||||
const result = await orderByEintreffzeit(origin, [nah, fern], etaTable);
|
||||
const nahRes = result.find((r) => r.id === "nah")!;
|
||||
const fernRes = result.find((r) => r.id === "fern")!;
|
||||
expect(nahRes.eta.mode).toBe("osrm");
|
||||
expect(nahRes.eta.isFallback).toBe(false);
|
||||
expect(nahRes.eta.durationSec).toBe(120);
|
||||
// 'fern' ohne OSRM-Route -> Haversine-Fallback mit gesetzter Dauer.
|
||||
expect(fernRes.eta.mode).toBe("haversine");
|
||||
expect(fernRes.eta.isFallback).toBe(true);
|
||||
expect(fernRes.eta.durationSec).not.toBeNull();
|
||||
});
|
||||
|
||||
it("(g) nur Kandidaten ohne Koordinaten: OSRM wird nicht gerufen, alle ans Ende", async () => {
|
||||
// coords.length === 0 -> computeEtas ruft etaTable gar nicht auf.
|
||||
const ohne2: Hit = { id: "ohne2", brigadeCoords: null };
|
||||
const etaTable = vi.fn().mockResolvedValue({ durations: [[0]], distances: [[0]] });
|
||||
const result = await orderByEintreffzeit(origin, [ohne, ohne2], etaTable);
|
||||
expect(etaTable).not.toHaveBeenCalled();
|
||||
expect(result.map((r) => r.id)).toEqual(["ohne", "ohne2"]);
|
||||
expect(result.every((r) => r.eta.durationSec === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("(h) OSRM-Tabelle ohne distances-Zeile: distanceMeters faellt auf null", async () => {
|
||||
// distances fehlt komplett -> `?? []` greift, distanceMeters wird null,
|
||||
// durationSec aus durations bleibt jedoch erhalten (mode=osrm).
|
||||
const etaTable = vi.fn().mockResolvedValue({
|
||||
durations: [[0, 90]],
|
||||
// kein distances-Feld
|
||||
});
|
||||
const result = await orderByEintreffzeit(origin, [nah], etaTable);
|
||||
expect(result[0]!.eta.mode).toBe("osrm");
|
||||
expect(result[0]!.eta.durationSec).toBe(90);
|
||||
expect(result[0]!.eta.distanceMeters).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,29 @@ export const de = {
|
||||
eckdaten: "Eckdaten",
|
||||
beladung: "Beladung",
|
||||
keineEckdaten: "Keine Eckdaten erfasst.",
|
||||
keineBeladung: "Keine Beladung zugeordnet.",
|
||||
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: {
|
||||
allgemein: "Es ist ein Fehler aufgetreten.",
|
||||
@@ -125,6 +147,63 @@ export const de = {
|
||||
zurueck: "Zurück",
|
||||
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;
|
||||
|
||||
type Leaf = string;
|
||||
|
||||
49
src/lib/merkmale/types.ts
Normal file
49
src/lib/merkmale/types.ts
Normal 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;
|
||||
}
|
||||
48
src/lib/security/headers.test.ts
Normal file
48
src/lib/security/headers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -2,17 +2,19 @@
|
||||
* Sicherheits-Header, eingehängt in next.config.ts.
|
||||
*
|
||||
* Content-Security-Policy ist der zentrale Querschnitts-Schutz (Implementierungs-
|
||||
* plan Z.1314): in Produktion strikt mit default-src 'self', frame-ancestors 'none'
|
||||
* und form-action 'self'. Im Dev-Modus benötigt Next.js (HMR/React-Refresh) eine
|
||||
* gelockerte script-src/connect-src-Variante ('unsafe-eval' + ws: für den Dev-Socket).
|
||||
* plan Z.1314): default-src 'self', frame-ancestors 'none', form-action 'self',
|
||||
* object-src 'none'. script-src erlaubt 'unsafe-inline' (KEIN 'unsafe-eval' in
|
||||
* Prod), da Next.js (App Router) Inline-Bootstrap-/Hydration-Skripte ohne Nonce
|
||||
* ausliefert — eine strikte nonce-basierte CSP ginge nur über die Middleware
|
||||
* (Hardening-Option). Im Dev zusätzlich 'unsafe-eval' + ws: (HMR/React-Refresh).
|
||||
*/
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
const CSP = [
|
||||
"default-src 'self'",
|
||||
// Dev braucht eval (React Refresh) + inline; Prod bleibt strikt.
|
||||
// Next.js braucht Inline-Skripte (Bootstrap/Hydration, ohne Nonce); Dev zusätzlich eval.
|
||||
isProd
|
||||
? "script-src 'self'"
|
||||
? "script-src 'self' 'unsafe-inline'"
|
||||
: "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
|
||||
37
src/lib/validation/__tests__/brigade-user.test.ts
Normal file
37
src/lib/validation/__tests__/brigade-user.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
34
src/lib/validation/__tests__/equipment.test.ts
Normal file
34
src/lib/validation/__tests__/equipment.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
152
src/lib/validation/__tests__/vehicle.test.ts
Normal file
152
src/lib/validation/__tests__/vehicle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
35
src/lib/validation/brigade-user.ts
Normal file
35
src/lib/validation/brigade-user.ts
Normal 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
|
||||
>;
|
||||
@@ -38,3 +38,37 @@ export const userResetSchema = z.object({
|
||||
});
|
||||
|
||||
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>;
|
||||
|
||||
45
src/lib/validation/equipment.ts
Normal file
45
src/lib/validation/equipment.ts
Normal 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 });
|
||||
181
src/lib/validation/vehicle.ts
Normal file
181
src/lib/validation/vehicle.ts
Normal 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);
|
||||
}
|
||||
131
src/server/actions/brigade-users.ts
Normal file
131
src/server/actions/brigade-users.ts
Normal 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 };
|
||||
}
|
||||
66
src/server/actions/brigade.ts
Normal file
66
src/server/actions/brigade.ts
Normal 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 };
|
||||
}
|
||||
197
src/server/actions/equipment.ts
Normal file
197
src/server/actions/equipment.ts
Normal 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 };
|
||||
}
|
||||
191
src/server/actions/vehicles.ts
Normal file
191
src/server/actions/vehicles.ts
Normal 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 };
|
||||
}
|
||||
34
src/server/data/brigade-users.ts
Normal file
34
src/server/data/brigade-users.ts
Normal 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));
|
||||
}
|
||||
69
src/server/data/equipment.ts
Normal file
69
src/server/data/equipment.ts
Normal 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
149
src/server/data/merkmale.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
88
src/server/data/vehicles.ts
Normal file
88
src/server/data/vehicles.ts
Normal 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;
|
||||
}
|
||||
68
src/server/merkmale/__tests__/upsertValues.test.ts
Normal file
68
src/server/merkmale/__tests__/upsertValues.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
50
src/server/merkmale/upsertValues.ts
Normal file
50
src/server/merkmale/upsertValues.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,76 +1,77 @@
|
||||
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
|
||||
* `npm run test:e2e:gating` gegen einen laufenden Server ausgeführt.
|
||||
*
|
||||
* Kerngarantie (Querschnittsstandard 1–3, default-deny dreifach):
|
||||
* - Anonyme Aufrufe von Seiten -> Redirect auf /login (mit callbackUrl).
|
||||
* - 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.
|
||||
* Negativ-Probe (CI): Entfernen eines Layout-Guards oder einer Manifest-Route
|
||||
* muss diese Suite rot machen.
|
||||
*/
|
||||
|
||||
// Geschützte Seiten (Redirect-Manifest). Neue Seiten hier ergänzen.
|
||||
const PROTECTED_PAGES = [
|
||||
"/",
|
||||
"/start",
|
||||
"/fahrzeuge",
|
||||
"/geraete",
|
||||
"/wehren",
|
||||
"/verwaltung",
|
||||
"/admin",
|
||||
// Erzwingt anonymen Zustand: keine gespeicherte Session.
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
// Fachbegriffe, die in einem 401/Redirect-Body NIE auftauchen dürfen.
|
||||
const LEAK_TERMS = [
|
||||
"funkrufname",
|
||||
"wehrfuehrer",
|
||||
"einsatzbereit",
|
||||
"passwort",
|
||||
"kennzeichen",
|
||||
];
|
||||
|
||||
// Geschützte API-Routen (401-Manifest). Neue API-Routen hier ergänzen.
|
||||
const PROTECTED_API = ["/api/fahrzeuge", "/api/geraete", "/api/verwaltung"];
|
||||
function assertNoLeak(body: string) {
|
||||
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).
|
||||
const PUBLIC_ROUTES = ["/login", "/api/health"];
|
||||
|
||||
test.describe("Default-deny: geschützte Seiten", () => {
|
||||
for (const path of PROTECTED_PAGES) {
|
||||
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({
|
||||
for (const route of ROUTES) {
|
||||
if (route.expectWhenAnon === "redirect") {
|
||||
test(`Seite ${route.path}: anonym -> Redirect auf /login`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(path);
|
||||
const response = await page.goto(route.path);
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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 ({
|
||||
} else {
|
||||
test(`API ${route.path}: anonym -> 401 ohne Daten-Leak`, async ({
|
||||
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);
|
||||
const body = await res.text();
|
||||
// Kein Daten-Leak: nur eine generische Fehlermeldung.
|
||||
expect(body).not.toContain("brigade");
|
||||
expect(body).not.toContain("passwort");
|
||||
assertNoLeak(await res.text());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Öffentliche Routen bleiben erreichbar", () => {
|
||||
test("Login-Seite ist anonym erreichbar", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Anmelden" }),
|
||||
).toBeVisible();
|
||||
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("Health-Check ist anonym 200", async ({ request }) => {
|
||||
test("Container-Health ist anonym 200", async ({ request }) => {
|
||||
const res = await request.get("/api/health");
|
||||
expect(res.status()).toBe(200);
|
||||
expect(await res.json()).toEqual({ status: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
void PUBLIC_ROUTES;
|
||||
|
||||
99
tests/e2e/detail-auth.spec.ts
Normal file
99
tests/e2e/detail-auth.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
63
tests/e2e/fixtures/auth.setup.ts
Normal file
63
tests/e2e/fixtures/auth.setup.ts
Normal 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
36
tests/e2e/global-setup.ts
Normal 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;
|
||||
32
tests/e2e/login-ratelimit.spec.ts
Normal file
32
tests/e2e/login-ratelimit.spec.ts
Normal 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/);
|
||||
}
|
||||
}
|
||||
});
|
||||
64
tests/e2e/rbac-scoping.spec.ts
Normal file
64
tests/e2e/rbac-scoping.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
tests/e2e/routes.manifest.spec.ts
Normal file
24
tests/e2e/routes.manifest.spec.ts
Normal 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([]);
|
||||
});
|
||||
110
tests/e2e/routes.manifest.ts
Normal file
110
tests/e2e/routes.manifest.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Kanonisches Routen-Manifest für die Auth-Gating-Garantie (Definition of
|
||||
* Done #1, Querschnittsstandard 1–3).
|
||||
*
|
||||
* 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",
|
||||
]);
|
||||
55
tests/e2e/search-eta.spec.ts
Normal file
55
tests/e2e/search-eta.spec.ts
Normal 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();
|
||||
});
|
||||
50
tests/e2e/security-headers.spec.ts
Normal file
50
tests/e2e/security-headers.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user