Compare commits
10 Commits
e97e16d254
...
fix/stando
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
15
.env.example
15
.env.example
@@ -23,3 +23,18 @@ OSRM_URL=http://osrm:5000
|
|||||||
NOMINATIM_URL=http://nominatim:8080
|
NOMINATIM_URL=http://nominatim:8080
|
||||||
GEO_HTTP_TIMEOUT_MS=4000
|
GEO_HTTP_TIMEOUT_MS=4000
|
||||||
HAVERSINE_KMH=50
|
HAVERSINE_KMH=50
|
||||||
|
|
||||||
|
# Deployment / externes Traefik
|
||||||
|
# APP_HOST ist der öffentliche Hostname (Traefik-Routing + AUTH_URL-Basis).
|
||||||
|
# In Produktion: AUTH_URL=https://${APP_HOST} und AUTH_TRUST_HOST=true setzen.
|
||||||
|
APP_HOST=floriannetz.example.at
|
||||||
|
# Traefik-Zertifikatsauflöser (muss in der externen Traefik-Instanz definiert sein).
|
||||||
|
TRAEFIK_CERTRESOLVER=letsencrypt
|
||||||
|
# Name des externen, von Traefik verwalteten Docker-Netzes.
|
||||||
|
TRAEFIK_NETWORK=traefik
|
||||||
|
# Optionaler Katalog-Seed beim Container-Start (idempotent).
|
||||||
|
RUN_SEED=false
|
||||||
|
# Postgres-Zugangsdaten für den Compose-Postgres-Service.
|
||||||
|
POSTGRES_USER=floriannetz
|
||||||
|
POSTGRES_PASSWORD=floriannetz
|
||||||
|
POSTGRES_DB=floriannetz
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,3 +8,7 @@ node_modules/
|
|||||||
tests/e2e/.auth/
|
tests/e2e/.auth/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Generiertes Artefakt: wird im Docker-builder aus src/db/seed gebündelt
|
||||||
|
# (scripts/build-seed-bundle.mjs), nicht eingecheckt.
|
||||||
|
docker/seed.mjs
|
||||||
|
|||||||
83
Dockerfile
Normal file
83
Dockerfile
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# FlorianNetz — multi-stage Build des Next.js-Standalone-Servers.
|
||||||
|
# Stufen: deps (Abhängigkeiten) -> builder (Build) -> runner (schlankes Laufzeit-Image).
|
||||||
|
# Läuft non-root (UID/GID 1001). Migration + optionaler Seed laufen im Entrypoint
|
||||||
|
# vor dem App-Start (siehe docker/entrypoint.sh).
|
||||||
|
|
||||||
|
ARG NODE_VERSION=22
|
||||||
|
|
||||||
|
# --- deps: Produktions- und Build-Abhängigkeiten installieren -----------------
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
# Nur Manifeste kopieren -> Layer-Cache bleibt stabil, solange sich Deps nicht ändern.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# --- builder: Next.js im Standalone-Modus bauen -------------------------------
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# next.config.ts setzt output:"standalone" -> erzeugt .next/standalone/server.js.
|
||||||
|
RUN npm run build
|
||||||
|
# Katalog-Seed zu einer selbstständigen Plain-ESM-Datei (docker/seed.mjs)
|
||||||
|
# bündeln, damit RUN_SEED=true im Runner (ohne tsx/src) funktioniert.
|
||||||
|
RUN node scripts/build-seed-bundle.mjs
|
||||||
|
|
||||||
|
# --- runner: minimales Laufzeit-Image ----------------------------------------
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
# Postgres-Client (pg_isready/psql) für die Wait-on-DB-Probe im Entrypoint.
|
||||||
|
RUN apk add --no-cache postgresql-client
|
||||||
|
|
||||||
|
# Non-root-Benutzer (feste UID/GID 1001, wie im Plan gefordert).
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# Standalone-Server + statische Assets.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
|
# Migration zur Laufzeit: Drizzle-Journal + Migrator + pg, plus die Migrationen.
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/drizzle-orm ./node_modules/drizzle-orm
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg ./node_modules/pg
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-pool ./node_modules/pg-pool
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-protocol ./node_modules/pg-protocol
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-types ./node_modules/pg-types
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-connection-string ./node_modules/pg-connection-string
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pgpass ./node_modules/pgpass
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-array ./node_modules/postgres-array
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-bytea ./node_modules/postgres-bytea
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-date ./node_modules/postgres-date
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-interval ./node_modules/postgres-interval
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/split2 ./node_modules/split2
|
||||||
|
# Tiefere Transitiv-Abhängigkeiten der pg-Kette mit eagerem require (sonst
|
||||||
|
# ERR_MODULE_NOT_FOUND beim Container-Start in docker/migrate.mjs/seed.mjs):
|
||||||
|
# pg-types/lib/binaryParsers.js -> require('pg-int8')
|
||||||
|
# postgres-interval/index.js -> require('xtend/mutable')
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-int8 ./node_modules/pg-int8
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/xtend ./node_modules/xtend
|
||||||
|
|
||||||
|
# Migrations-Runner (plain ESM, ohne tsx) + gebündelter Seed + Entrypoint.
|
||||||
|
COPY --chown=nextjs:nodejs docker/migrate.mjs ./docker/migrate.mjs
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/docker/seed.mjs ./docker/seed.mjs
|
||||||
|
COPY --chown=nextjs:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
|
||||||
|
RUN chmod +x ./docker/entrypoint.sh
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Liveness-Probe (öffentlich, ohne Fachdaten).
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=5 \
|
||||||
|
CMD wget -q -O - http://127.0.0.1:3000/api/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["./docker/entrypoint.sh"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
38
Makefile
Normal file
38
Makefile
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# FlorianNetz — Deployment-Makefile (externes Traefik).
|
||||||
|
#
|
||||||
|
# Ziele:
|
||||||
|
# make build - baut das App-Image (Next.js standalone, non-root)
|
||||||
|
# make up - startet den Stack (App + Postgres + Geo) hinter Traefik
|
||||||
|
# make down - stoppt den Stack
|
||||||
|
# make logs - folgt den App-Logs
|
||||||
|
# make deploy - build + up (Standard-Deploy)
|
||||||
|
# make data - bereitet die OSRM-Geodaten vor (Download + Preprocessing)
|
||||||
|
# make config - validiert die Compose-Konfiguration
|
||||||
|
#
|
||||||
|
# Hinweis: up/data/deploy benötigen Docker (+ Netzzugriff/RAM/Disk) und werden
|
||||||
|
# NICHT in CI/Sandbox ausgeführt. Das externe Traefik-Netz muss existieren:
|
||||||
|
# docker network create traefik
|
||||||
|
|
||||||
|
COMPOSE = docker compose --env-file .env
|
||||||
|
|
||||||
|
.PHONY: build up down logs deploy data config
|
||||||
|
|
||||||
|
build:
|
||||||
|
$(COMPOSE) build app
|
||||||
|
|
||||||
|
up:
|
||||||
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
|
down:
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
logs:
|
||||||
|
$(COMPOSE) logs -f app
|
||||||
|
|
||||||
|
deploy: build up
|
||||||
|
|
||||||
|
data:
|
||||||
|
./scripts/prepare-osm-data.sh
|
||||||
|
|
||||||
|
config:
|
||||||
|
$(COMPOSE) config --services
|
||||||
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
|
||||||
146
docker-compose.yml
Normal file
146
docker-compose.yml
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# FlorianNetz — Basis-Compose hinter EXTERNEM Traefik.
|
||||||
|
#
|
||||||
|
# Es gibt bewusst KEINEN eigenen Proxy-/Traefik-Service: Routing/TLS übernimmt
|
||||||
|
# eine separat betriebene Traefik-Instanz, die am externen Netz "${TRAEFIK_NETWORK}"
|
||||||
|
# (Default: traefik) lauscht. Dieses Netz muss bereits existieren:
|
||||||
|
# docker network create traefik
|
||||||
|
#
|
||||||
|
# Geo-Dienste (osrm, nominatim) sind hier mit ihren Laufzeit-Verträgen definiert;
|
||||||
|
# das schwergewichtige Daten-Preprocessing/Volume kommt aus docker-compose.geo.yml
|
||||||
|
# (siehe scripts/prepare-osm-data.sh / infra/geo).
|
||||||
|
#
|
||||||
|
# Start:
|
||||||
|
# docker compose --env-file .env up -d
|
||||||
|
# Lokal ohne Traefik/TLS:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.override.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER:-floriannetz}:${POSTGRES_PASSWORD:-floriannetz}@postgres:5432/${POSTGRES_DB:-floriannetz}
|
||||||
|
# Forwarded-Header + sichere Cookies hinter Traefik.
|
||||||
|
AUTH_TRUST_HOST: "true"
|
||||||
|
AUTH_URL: https://${APP_HOST}
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
|
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER}
|
||||||
|
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
|
||||||
|
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
|
||||||
|
OSRM_URL: http://osrm:5000
|
||||||
|
NOMINATIM_URL: http://nominatim:8080
|
||||||
|
GEO_HTTP_TIMEOUT_MS: ${GEO_HTTP_TIMEOUT_MS:-4000}
|
||||||
|
HAVERSINE_KMH: ${HAVERSINE_KMH:-50}
|
||||||
|
RUN_SEED: ${RUN_SEED:-false}
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
- internal
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- "wget -q -O - http://127.0.0.1:3000/api/health | grep -q ok"
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 40s
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}"
|
||||||
|
- "traefik.http.routers.floriannetz.rule=Host(`${APP_HOST}`)"
|
||||||
|
- "traefik.http.routers.floriannetz.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.floriannetz.tls=true"
|
||||||
|
- "traefik.http.routers.floriannetz.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}"
|
||||||
|
- "traefik.http.services.floriannetz.loadbalancer.server.port=3000"
|
||||||
|
# Security-Header-Middleware (zusätzlich zu next.config.ts; defense-in-depth).
|
||||||
|
- "traefik.http.routers.floriannetz.middlewares=floriannetz-sechdrs"
|
||||||
|
- "traefik.http.middlewares.floriannetz-sechdrs.headers.stsSeconds=63072000"
|
||||||
|
- "traefik.http.middlewares.floriannetz-sechdrs.headers.stsIncludeSubdomains=true"
|
||||||
|
- "traefik.http.middlewares.floriannetz-sechdrs.headers.contentTypeNosniff=true"
|
||||||
|
- "traefik.http.middlewares.floriannetz-sechdrs.headers.frameDeny=true"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-floriannetz}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-floriannetz}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-floriannetz}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- "pg_isready -U ${POSTGRES_USER:-floriannetz} -d ${POSTGRES_DB:-floriannetz}"
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
osrm:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/osrm/Dockerfile
|
||||||
|
command: osrm-routed --algorithm mld /data/austria-latest.osrm
|
||||||
|
volumes:
|
||||||
|
- osrm-data:/data
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
expose:
|
||||||
|
- "5000"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- >-
|
||||||
|
wget -q -O - 'http://localhost:5000/table/v1/driving/15.6229,48.2079;16.3738,48.2082?sources=0'
|
||||||
|
| grep -q '"code":"Ok"'
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nominatim:
|
||||||
|
image: mediagis/nominatim:4.4
|
||||||
|
environment:
|
||||||
|
PBF_URL: https://download.geofabrik.de/europe/austria-latest.osm.pbf
|
||||||
|
REPLICATION_URL: https://download.geofabrik.de/europe/austria-updates/
|
||||||
|
IMPORT_STYLE: address
|
||||||
|
NOMINATIM_PASSWORD: ${NOMINATIM_PASSWORD:-nominatim}
|
||||||
|
volumes:
|
||||||
|
- nominatim-data:/var/lib/postgresql/14/main
|
||||||
|
shm_size: 1g
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD-SHELL
|
||||||
|
- "wget -q -O - 'http://localhost:8080/status' | grep -q OK"
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 120s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
osrm-data:
|
||||||
|
nominatim-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# Externes, von der separaten Traefik-Instanz verwaltetes Netz.
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
name: ${TRAEFIK_NETWORK:-traefik}
|
||||||
|
# Internes Netz: Postgres/Geo sind nur app-intern erreichbar, nicht öffentlich.
|
||||||
|
internal:
|
||||||
|
internal: true
|
||||||
49
docker/entrypoint.sh
Normal file
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();
|
||||||
|
}
|
||||||
130
docs/reference/deployment-traefik.md
Normal file
130
docs/reference/deployment-traefik.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Deployment hinter externem Traefik
|
||||||
|
|
||||||
|
FlorianNetz wird als Docker-Compose-Stack betrieben und **hinter einer separat
|
||||||
|
betriebenen Traefik-Instanz** ausgeliefert. Es gibt bewusst **keinen** eigenen
|
||||||
|
Proxy-/Traefik-Service im Compose-Stack — Routing und TLS-Terminierung übernimmt
|
||||||
|
das externe Traefik.
|
||||||
|
|
||||||
|
## Komponenten
|
||||||
|
|
||||||
|
Der Stack besteht aus genau vier Services (kein Proxy):
|
||||||
|
|
||||||
|
| Service | Zweck |
|
||||||
|
| ----------- | -------------------------------------------------- |
|
||||||
|
| `app` | Next.js-Standalone-Server (non-root, UID 1001) |
|
||||||
|
| `postgres` | PostgreSQL 16 (Daten-Volume, `pg_isready`-Health) |
|
||||||
|
| `osrm` | OSRM-Routing (Österreich-Extrakt, `/table`) |
|
||||||
|
| `nominatim` | Geocoding (Österreich-Extrakt, `/search`) |
|
||||||
|
|
||||||
|
Netze:
|
||||||
|
|
||||||
|
- **`traefik`** — externes, von Traefik verwaltetes Netz (`external: true`,
|
||||||
|
Name aus `TRAEFIK_NETWORK`, Default `traefik`). Nur `app` hängt daran.
|
||||||
|
- **`internal`** — internes Netz (`internal: true`); Postgres und die Geo-Dienste
|
||||||
|
sind ausschließlich für die App erreichbar, nie öffentlich.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
Das externe Traefik-Netz muss existieren, bevor der Stack startet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create traefik
|
||||||
|
```
|
||||||
|
|
||||||
|
Die externe Traefik-Instanz muss:
|
||||||
|
|
||||||
|
- einen Entrypoint `websecure` (Port 443) bereitstellen,
|
||||||
|
- einen Zertifikatsauflöser anbieten, dessen Name `TRAEFIK_CERTRESOLVER`
|
||||||
|
entspricht (Default `letsencrypt`),
|
||||||
|
- am Netz `traefik` lauschen (`providers.docker` mit `exposedByDefault=false`).
|
||||||
|
|
||||||
|
Die App-Labels in `docker-compose.yml` setzen Router (`Host(\`${APP_HOST}\`)`,
|
||||||
|
`entrypoints=websecure`, `tls.certresolver`), Service-Port `3000` und eine
|
||||||
|
Security-Header-Middleware (defense-in-depth zusätzlich zu `next.config.ts`).
|
||||||
|
|
||||||
|
## Pflicht-Umgebungsvariablen
|
||||||
|
|
||||||
|
Vollständiger Vertrag in `.env.example`. Für den Betrieb hinter Traefik zwingend:
|
||||||
|
|
||||||
|
| Variable | Beispiel / Hinweis |
|
||||||
|
| ------------------------- | ---------------------------------------------------- |
|
||||||
|
| `APP_HOST` | öffentlicher Hostname, z. B. `floriannetz.example.at` |
|
||||||
|
| `AUTH_URL` | `https://${APP_HOST}` — Basis für Callback + Cookies |
|
||||||
|
| `AUTH_TRUST_HOST` | `true` — Auth.js vertraut den Forwarded-Headern |
|
||||||
|
| `AUTH_SECRET` | >= 32 Zeichen (`openssl rand -base64 32`) |
|
||||||
|
| `AUTHENTIK_ISSUER` | OIDC-Issuer-URL der Authentik-Anwendung |
|
||||||
|
| `AUTHENTIK_CLIENT_ID` | Client-ID der Authentik-Anwendung |
|
||||||
|
| `AUTHENTIK_CLIENT_SECRET` | Client-Secret der Authentik-Anwendung |
|
||||||
|
| `DATABASE_URL` | wird in Compose aus `POSTGRES_*` zusammengesetzt |
|
||||||
|
| `TRAEFIK_CERTRESOLVER` | Name des Traefik-Zertifikatsauflösers |
|
||||||
|
| `TRAEFIK_NETWORK` | Name des externen Traefik-Netzes (Default `traefik`) |
|
||||||
|
|
||||||
|
## Forwarded-Header & sichere Cookies
|
||||||
|
|
||||||
|
Hinter Traefik terminiert TLS am Proxy; die App sieht intern HTTP. Damit
|
||||||
|
Auth.js die korrekte Origin erkennt und sichere Cookies setzt:
|
||||||
|
|
||||||
|
- `AUTH_TRUST_HOST=true` — Auth.js wertet `X-Forwarded-Proto`/`X-Forwarded-Host`
|
||||||
|
aus.
|
||||||
|
- `AUTH_URL=https://${APP_HOST}` — erzwingt die `https://`-Origin für
|
||||||
|
Callback-URLs und aktiviert das `__Secure-`-Cookie-Präfix (Cookie `secure`,
|
||||||
|
`httpOnly`, `sameSite=lax`).
|
||||||
|
|
||||||
|
Bei lokaler HTTP-Entwicklung (`docker-compose.override.yml`) ist
|
||||||
|
`AUTH_URL=http://localhost:3000` — dann fällt das `secure`/`__Secure-`-Verhalten
|
||||||
|
weg, sonst bräche der Login über HTTP.
|
||||||
|
|
||||||
|
## Authentik-Konfiguration
|
||||||
|
|
||||||
|
In der Authentik-Anwendung (OAuth2/OpenID-Provider) als **Redirect-URI**
|
||||||
|
eintragen:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://${APP_HOST}/api/auth/callback/authentik
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Pfad `callback/authentik` entspricht dem NextAuth-Provider-Namen. Bei lokaler
|
||||||
|
Entwicklung zusätzlich `http://localhost:3000/api/auth/callback/authentik`.
|
||||||
|
|
||||||
|
## Health-Check & Middleware-Allowlist
|
||||||
|
|
||||||
|
`GET /api/health` ist **öffentlich** (anonym `200`, nur Liveness, keine
|
||||||
|
Fachdaten). Die Edge-Middleware nimmt den Pfad in ihrer Allowlist aus
|
||||||
|
(`api/health` im `matcher`), sonst würde die Default-deny-Schicht ihn auf
|
||||||
|
`/login` umleiten. Sowohl der Container-`HEALTHCHECK` als auch der Compose-
|
||||||
|
App-Healthcheck pingen `http://127.0.0.1:3000/api/health`.
|
||||||
|
|
||||||
|
## Migration & Seed beim Deploy
|
||||||
|
|
||||||
|
`docker/entrypoint.sh` läuft vor dem App-Start:
|
||||||
|
|
||||||
|
1. wartet via `pg_isready` auf Postgres,
|
||||||
|
2. wendet die Drizzle-Migrationen idempotent an (`node docker/migrate.mjs`),
|
||||||
|
3. optional (`RUN_SEED=true`) den NÖ-Katalog-Seed,
|
||||||
|
4. startet `exec node server.js`.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # Werte setzen (APP_HOST, AUTH_*, AUTHENTIK_*, POSTGRES_*)
|
||||||
|
docker network create traefik # einmalig, falls nicht vorhanden
|
||||||
|
make data # einmalig: OSRM-Geodaten vorbereiten (groß, dauert)
|
||||||
|
make deploy # build + up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lokal ohne Traefik
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp docker-compose.override.yml.example docker-compose.override.yml
|
||||||
|
docker compose up -d
|
||||||
|
# App: http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
- `docker compose -f docker-compose.yml -f docker-compose.geo.yml config --services`
|
||||||
|
→ genau `app postgres osrm nominatim` (kein Proxy).
|
||||||
|
- `docker build -t floriannetz . && docker run --rm floriannetz id -u` → `1001`.
|
||||||
|
- `sh -n docker/entrypoint.sh` → keine Syntaxfehler.
|
||||||
|
- `curl -I https://${APP_HOST}` → `200`/`302` mit gültigem TLS-Zertifikat.
|
||||||
|
- Login über Authentik setzt ein `__Secure-`-Cookie; Callback-URL ist `https://`.
|
||||||
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.
|
||||||
@@ -11,10 +11,14 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx scripts/migrate.ts",
|
"db:migrate": "tsx scripts/migrate.ts",
|
||||||
"db:seed-auth": "tsx scripts/seed-auth.ts",
|
"db:seed-auth": "tsx scripts/seed-auth.ts",
|
||||||
"test:e2e:gating": "playwright test tests/e2e/auth-gating.spec.ts",
|
"db:seed": "tsx src/db/seed/index.ts",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:gating": "playwright test --project=chromium tests/e2e/auth-gating.spec.ts",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:check": "drizzle-kit check"
|
"db:check": "drizzle-kit check"
|
||||||
|
|||||||
@@ -13,12 +13,22 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
reporter: process.env.CI ? "github" : "list",
|
reporter: process.env.CI ? "github" : "list",
|
||||||
|
// Migration + deterministischer Seed (deferred ohne DATABASE_URL).
|
||||||
|
globalSetup: "./tests/e2e/global-setup.ts",
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
// 1. Echter Login je Konto -> storageState (tests/e2e/.auth/*.json).
|
||||||
|
{ name: "setup", testMatch: /fixtures\/auth\.setup\.ts/ },
|
||||||
|
// 2. Eigentliche Suiten; hängen vom Login-Setup ab.
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
testIgnore: /fixtures\/auth\.setup\.ts/,
|
||||||
|
dependencies: ["setup"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
webServer: process.env.E2E_BASE_URL
|
webServer: process.env.E2E_BASE_URL
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
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 s = await requirePlatformAdmin();
|
||||||
const p = userResetSchema.safeParse(input);
|
const p = userResetSchema.safeParse(input);
|
||||||
if (!p.success) return { ok: false, error: "Ungültige ID." };
|
if (!p.success) return { ok: false, error: "Ungültige ID." };
|
||||||
|
try {
|
||||||
const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id);
|
const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id);
|
||||||
revalidatePath("/admin/wehren");
|
revalidatePath("/admin/wehren");
|
||||||
return { ok: true, tempPassword };
|
return { ok: true, tempPassword };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: "Passwort konnte nicht zurückgesetzt werden.",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { merkmale, merkmalValues, vehicleTemplateMerkmale } from "@/db/schema";
|
import { merkmale, merkmalValues, vehicleTemplateMerkmale } from "@/db/schema";
|
||||||
@@ -80,7 +80,40 @@ export async function mergeMerkmal(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
// vehicle_template_merkmale hat den zusammengesetzten PK
|
||||||
|
// (template_id, merkmal_id). Hat eine Vorlage bereits sowohl das
|
||||||
|
// vorgeschlagene als auch das Ziel-Merkmal, würde ein pauschales
|
||||||
|
// Umhängen den PK verletzen. Solche kollidierenden Proposed-Zeilen
|
||||||
|
// werden daher gelöscht statt umgehängt.
|
||||||
|
const proposedVtm = await tx
|
||||||
|
.select({ templateId: vehicleTemplateMerkmale.templateId })
|
||||||
|
.from(vehicleTemplateMerkmale)
|
||||||
|
.where(eq(vehicleTemplateMerkmale.merkmalId, proposed.data));
|
||||||
|
const zielVtm = await tx
|
||||||
|
.select({ templateId: vehicleTemplateMerkmale.templateId })
|
||||||
|
.from(vehicleTemplateMerkmale)
|
||||||
|
.where(eq(vehicleTemplateMerkmale.merkmalId, ziel.data));
|
||||||
|
const zielTemplateIds = new Set(zielVtm.map((r) => r.templateId));
|
||||||
|
const collidingTemplateIds = proposedVtm
|
||||||
|
.map((r) => r.templateId)
|
||||||
|
.filter((id) => zielTemplateIds.has(id));
|
||||||
|
|
||||||
|
if (collidingTemplateIds.length > 0) {
|
||||||
|
await tx
|
||||||
|
.delete(vehicleTemplateMerkmale)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(vehicleTemplateMerkmale.merkmalId, proposed.data),
|
||||||
|
inArray(
|
||||||
|
vehicleTemplateMerkmale.templateId,
|
||||||
|
collidingTemplateIds,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await tx
|
await tx
|
||||||
.update(merkmalValues)
|
.update(merkmalValues)
|
||||||
.set({ merkmalId: ziel.data })
|
.set({ merkmalId: ziel.data })
|
||||||
@@ -99,6 +132,12 @@ export async function mergeMerkmal(input: {
|
|||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Zusammenführen fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/merkmale/proposals");
|
revalidatePath("/admin/merkmale/proposals");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 temp = generateTempPassword();
|
||||||
const hash = await hashPassword(temp);
|
const hash = await hashPassword(temp);
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
const [u] = await tx
|
||||||
|
.select({ authTyp: users.authTyp })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
if (!u || u.authTyp !== "local") {
|
||||||
|
throw new Error("Nur lokale Konten können zurückgesetzt werden.");
|
||||||
|
}
|
||||||
await tx
|
await tx
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ passwortHash: hash })
|
.set({ passwortHash: hash })
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
@@ -55,7 +55,29 @@ export const de = {
|
|||||||
eckdaten: "Eckdaten",
|
eckdaten: "Eckdaten",
|
||||||
beladung: "Beladung",
|
beladung: "Beladung",
|
||||||
keineEckdaten: "Keine Eckdaten erfasst.",
|
keineEckdaten: "Keine Eckdaten erfasst.",
|
||||||
|
keineBeladung: "Keine Beladung zugeordnet.",
|
||||||
imGeraetehaus: "im Gerätehaus",
|
imGeraetehaus: "im Gerätehaus",
|
||||||
|
leerWert: "–",
|
||||||
|
ja: "Ja",
|
||||||
|
nein: "Nein",
|
||||||
|
zugeordnetesFahrzeug: "Zugeordnetes Fahrzeug",
|
||||||
|
kategorie: "Kategorie",
|
||||||
|
fahrzeuge: "Fahrzeuge",
|
||||||
|
keineFahrzeuge: "Keine Fahrzeuge erfasst.",
|
||||||
|
geraeteImHaus: "Geräte im Gerätehaus",
|
||||||
|
keineGeraeteImHaus: "Keine Geräte im Gerätehaus.",
|
||||||
|
nichtGefunden: "Nicht gefunden.",
|
||||||
|
},
|
||||||
|
kontakt: {
|
||||||
|
titel: "Kontakt",
|
||||||
|
anrufen: "Anrufen",
|
||||||
|
email: "E-Mail schreiben",
|
||||||
|
keine: "Keine Kontaktdaten hinterlegt.",
|
||||||
|
betreff: "FlorianNetz – Anfrage",
|
||||||
|
},
|
||||||
|
wehr: {
|
||||||
|
wehrfuehrer: "Wehrführer",
|
||||||
|
adresse: "Adresse",
|
||||||
},
|
},
|
||||||
fehler: {
|
fehler: {
|
||||||
allgemein: "Es ist ein Fehler aufgetreten.",
|
allgemein: "Es ist ein Fehler aufgetreten.",
|
||||||
@@ -125,6 +147,63 @@ export const de = {
|
|||||||
zurueck: "Zurück",
|
zurueck: "Zurück",
|
||||||
weiter: "Weiter",
|
weiter: "Weiter",
|
||||||
},
|
},
|
||||||
|
verwaltung: {
|
||||||
|
titel: "Verwaltung",
|
||||||
|
navProfil: "Profil",
|
||||||
|
navFahrzeuge: "Fahrzeuge",
|
||||||
|
navGeraete: "Geräte",
|
||||||
|
navBenutzer: "Benutzer",
|
||||||
|
speichern: "Speichern",
|
||||||
|
abbrechen: "Abbrechen",
|
||||||
|
loeschen: "Löschen",
|
||||||
|
anlegen: "Anlegen",
|
||||||
|
bearbeiten: "Bearbeiten",
|
||||||
|
neu: "Neu",
|
||||||
|
name: "Name",
|
||||||
|
funkrufname: "Funkrufname",
|
||||||
|
notiz: "Notiz",
|
||||||
|
status: "Status",
|
||||||
|
kategorie: "Kategorie",
|
||||||
|
zuordnung: "Zuordnung",
|
||||||
|
imGeraetehaus: "im Gerätehaus",
|
||||||
|
vorlage: "Fahrzeug-Vorlage",
|
||||||
|
keineVorlage: "Ohne Vorlage (frei)",
|
||||||
|
vorlageWaehlen: "Vorlage wählen",
|
||||||
|
merkmale: "Merkmale",
|
||||||
|
keineMerkmale: "Für diese Auswahl sind keine Merkmale hinterlegt.",
|
||||||
|
fahrzeugAnlegen: "Fahrzeug anlegen",
|
||||||
|
fahrzeugBearbeiten: "Fahrzeug bearbeiten",
|
||||||
|
geraetAnlegen: "Gerät anlegen",
|
||||||
|
geraetBearbeiten: "Gerät bearbeiten",
|
||||||
|
keineFahrzeuge: "Noch keine Fahrzeuge erfasst.",
|
||||||
|
keineGeraete: "Noch keine Geräte erfasst.",
|
||||||
|
keineBenutzer: "Noch keine Benutzer erfasst.",
|
||||||
|
profilTitel: "Wehr-Profil",
|
||||||
|
profilGespeichert: "Profil gespeichert.",
|
||||||
|
geocodeOk: "Adresse geokodiert.",
|
||||||
|
geocodeWarnung:
|
||||||
|
"Adresse konnte nicht geokodiert werden. Daten wurden dennoch gespeichert.",
|
||||||
|
strasse: "Straße",
|
||||||
|
plz: "PLZ",
|
||||||
|
ort: "Ort",
|
||||||
|
email: "E-Mail",
|
||||||
|
telefon: "Telefon",
|
||||||
|
wehrfuehrer: "Wehrführer",
|
||||||
|
funkrufnameSchema: "Funkrufname-Schema",
|
||||||
|
rolle: "Rolle",
|
||||||
|
rolleAdmin: "Wehr-Admin",
|
||||||
|
rolleRead: "Lesend",
|
||||||
|
benutzerAnlegen: "Benutzer anlegen",
|
||||||
|
deaktivieren: "Deaktivieren",
|
||||||
|
aktiv: "aktiv",
|
||||||
|
inaktiv: "inaktiv",
|
||||||
|
authLokal: "lokal",
|
||||||
|
authAuthentik: "Authentik",
|
||||||
|
tempPasswort:
|
||||||
|
"Einmal-Passwort (nur jetzt sichtbar, bitte sicher übergeben):",
|
||||||
|
loeschenBestaetigen: "Wirklich löschen?",
|
||||||
|
pflichtfeld: "Pflichtfeld",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type Leaf = string;
|
type Leaf = string;
|
||||||
|
|||||||
49
src/lib/merkmale/types.ts
Normal file
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
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>;
|
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 { test, expect } from "@playwright/test";
|
||||||
|
import { ROUTES } from "./routes.manifest";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth-Gating-Garantie (Definition of Done, oberstes Prinzip).
|
* KRITISCHE Auth-Gating-Suite (Definition of Done #1, oberstes Prinzip).
|
||||||
|
*
|
||||||
|
* Erzeugt GENAU EINEN Testfall pro Manifest-Eintrag (ROUTES.length):
|
||||||
|
* - Seiten -> Redirect auf /login (mit callbackUrl), kein Daten-Leak.
|
||||||
|
* - API -> 401 ohne Fachdaten im Body.
|
||||||
*
|
*
|
||||||
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
|
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
|
||||||
* `npm run test:e2e:gating` gegen einen laufenden Server ausgeführt.
|
* `npm run test:e2e:gating` gegen einen laufenden Server ausgeführt.
|
||||||
*
|
*
|
||||||
* Kerngarantie (Querschnittsstandard 1–3, default-deny dreifach):
|
* Negativ-Probe (CI): Entfernen eines Layout-Guards oder einer Manifest-Route
|
||||||
* - Anonyme Aufrufe von Seiten -> Redirect auf /login (mit callbackUrl).
|
* muss diese Suite rot machen.
|
||||||
* - Anonyme Aufrufe von API-Routen -> 401 OHNE Daten-Leak.
|
|
||||||
* - Öffentliche Routen (Login, Health) bleiben erreichbar.
|
|
||||||
*
|
|
||||||
* Negativ-Probe (manuell/CI): Entfernen von `requireSession()` aus
|
|
||||||
* `(app)/layout.tsx` muss diese Suite rot machen.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Geschützte Seiten (Redirect-Manifest). Neue Seiten hier ergänzen.
|
// Erzwingt anonymen Zustand: keine gespeicherte Session.
|
||||||
const PROTECTED_PAGES = [
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
"/",
|
|
||||||
"/start",
|
// Fachbegriffe, die in einem 401/Redirect-Body NIE auftauchen dürfen.
|
||||||
"/fahrzeuge",
|
const LEAK_TERMS = [
|
||||||
"/geraete",
|
"funkrufname",
|
||||||
"/wehren",
|
"wehrfuehrer",
|
||||||
"/verwaltung",
|
"einsatzbereit",
|
||||||
"/admin",
|
"passwort",
|
||||||
|
"kennzeichen",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Geschützte API-Routen (401-Manifest). Neue API-Routen hier ergänzen.
|
function assertNoLeak(body: string) {
|
||||||
const PROTECTED_API = ["/api/fahrzeuge", "/api/geraete", "/api/verwaltung"];
|
const lower = body.toLowerCase();
|
||||||
|
for (const term of LEAK_TERMS) {
|
||||||
|
expect(lower, `Daten-Leak: Body enthält "${term}"`).not.toContain(term);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Öffentliche Routen (Middleware-Allowlist).
|
for (const route of ROUTES) {
|
||||||
const PUBLIC_ROUTES = ["/login", "/api/health"];
|
if (route.expectWhenAnon === "redirect") {
|
||||||
|
test(`Seite ${route.path}: anonym -> Redirect auf /login`, async ({
|
||||||
test.describe("Default-deny: geschützte Seiten", () => {
|
|
||||||
for (const path of PROTECTED_PAGES) {
|
|
||||||
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({
|
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(path);
|
const response = await page.goto(route.path);
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
// callbackUrl bewahrt das ursprüngliche Ziel.
|
||||||
|
expect(page.url()).toContain("callbackUrl");
|
||||||
|
const body = await page.content();
|
||||||
|
assertNoLeak(body);
|
||||||
|
// Kein 500 o. ä.
|
||||||
|
if (response) expect(response.status()).toBeLessThan(500);
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
});
|
test(`API ${route.path}: anonym -> 401 ohne Daten-Leak`, async ({
|
||||||
|
|
||||||
test.describe("Default-deny: geschützte API-Routen", () => {
|
|
||||||
for (const path of PROTECTED_API) {
|
|
||||||
test(`anonymer Aufruf von ${path} liefert 401 ohne Daten-Leak`, async ({
|
|
||||||
request,
|
request,
|
||||||
}) => {
|
}) => {
|
||||||
const res = await request.get(path);
|
// /api/geo/geocode ist POST-only; health ist GET. Beide gehen durch apiAuth().
|
||||||
|
const res = route.path.includes("geocode")
|
||||||
|
? await request.post(route.path, { data: { address: "x" } })
|
||||||
|
: await request.get(route.path);
|
||||||
expect(res.status()).toBe(401);
|
expect(res.status()).toBe(401);
|
||||||
const body = await res.text();
|
assertNoLeak(await res.text());
|
||||||
// Kein Daten-Leak: nur eine generische Fehlermeldung.
|
|
||||||
expect(body).not.toContain("brigade");
|
|
||||||
expect(body).not.toContain("passwort");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
test.describe("Öffentliche Routen bleiben erreichbar", () => {
|
test.describe("Öffentliche Routen bleiben anonym erreichbar", () => {
|
||||||
test("Login-Seite ist anonym erreichbar", async ({ page }) => {
|
test("Login-Seite ist anonym 200", async ({ page }) => {
|
||||||
await page.goto("/login");
|
const res = await page.goto("/login");
|
||||||
await expect(
|
expect(res?.status()).toBeLessThan(400);
|
||||||
page.getByRole("heading", { name: "Anmelden" }),
|
await expect(page.getByRole("heading", { name: "Anmelden" })).toBeVisible();
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Health-Check ist anonym 200", async ({ request }) => {
|
test("Container-Health ist anonym 200", async ({ request }) => {
|
||||||
const res = await request.get("/api/health");
|
const res = await request.get("/api/health");
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
expect(await res.json()).toEqual({ status: "ok" });
|
expect(await res.json()).toEqual({ status: "ok" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
void PUBLIC_ROUTES;
|
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
tests/e2e/server-actions-guard.spec.ts
Normal file
23
tests/e2e/server-actions-guard.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { findUnguardedActionsInRepo } from "../support/guard-scan";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default-Deny-Beweis für Server Actions (Definition of Done #2,
|
||||||
|
* Querschnittsstandard 3): JEDE "use server"-Funktion ruft als erste Anweisung
|
||||||
|
* einen Guard (require{Session,Role,OwnBrigade,PlatformAdmin,WehrAdmin}).
|
||||||
|
*
|
||||||
|
* STATISCHER Check — braucht weder Server noch DB; lauffähig offline. Identisch
|
||||||
|
* als Vitest-Unit-Test (tests/unit/server-actions-guard.test.ts) abgesichert.
|
||||||
|
*
|
||||||
|
* Negativ-Probe: Entfernen eines Guards aus einer Action macht diesen Test rot.
|
||||||
|
*
|
||||||
|
* Genuin öffentliche Login-Actions (vor der Authentifizierung) sind in
|
||||||
|
* PUBLIC_ACTION_ALLOWLIST (guard-scan.ts) ausgenommen.
|
||||||
|
*/
|
||||||
|
test('jede "use server"-Funktion ruft einen Guard', () => {
|
||||||
|
const offenders = findUnguardedActionsInRepo();
|
||||||
|
expect(
|
||||||
|
offenders,
|
||||||
|
`Server Actions ohne Guard:\n${offenders.join("\n")}`,
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
64
tests/e2e/verwaltung-fuhrpark.spec.ts
Normal file
64
tests/e2e/verwaltung-fuhrpark.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Happy-Path des Wehr-Bereichs: Fuhrpark, Geräte, Profil, Benutzer
|
||||||
|
* (Workstream 7).
|
||||||
|
*
|
||||||
|
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet einen
|
||||||
|
* angemeldeten `wehr_admin` (Fixture aus dem Test-Workstream) und befüllte
|
||||||
|
* Taxonomie (Seed/Admin).
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe("Verwaltung: Fuhrpark & Benutzer (Happy-Path)", () => {
|
||||||
|
test.skip(
|
||||||
|
!process.env.E2E_WEHR_ADMIN_STATE,
|
||||||
|
"benötigt wehr_admin-Fixture (Test-Workstream)",
|
||||||
|
);
|
||||||
|
test.use({
|
||||||
|
storageState: process.env.E2E_WEHR_ADMIN_STATE ?? { cookies: [], origins: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fahrzeug per Vorlage anlegen befüllt typisierte Merkmale", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/verwaltung/fahrzeuge/neu");
|
||||||
|
// Erste echte Vorlage wählen (Index 0 ist „Ohne Vorlage (frei)").
|
||||||
|
await page.getByLabel("Fahrzeug-Vorlage").selectOption({ index: 1 });
|
||||||
|
// Vorlagen-Merkmale werden nachgeladen (Löschwassertank, Feuerlöschpumpe …).
|
||||||
|
await expect(page.getByText("Löschwassertank")).toBeVisible();
|
||||||
|
await page.getByLabel("Name").fill("HLF 2 Musterdorf");
|
||||||
|
await page.getByRole("button", { name: "Speichern" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/verwaltung\/fahrzeuge$/);
|
||||||
|
await expect(page.getByText("HLF 2 Musterdorf")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Gerät 'im Gerätehaus' anlegen (keine Fahrzeug-Zuordnung)", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/verwaltung/geraete/neu");
|
||||||
|
await page.getByLabel("Name").fill("Tragkraftspritze 1");
|
||||||
|
await page.getByLabel("Kategorie").selectOption({ index: 1 });
|
||||||
|
// Zuordnung bleibt auf 'im Gerätehaus'.
|
||||||
|
await page.getByRole("button", { name: "Speichern" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/verwaltung\/geraete$/);
|
||||||
|
await expect(page.getByText("im Gerätehaus").first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Profil speichern zeigt Bestätigung", async ({ page }) => {
|
||||||
|
await page.goto("/verwaltung/profil");
|
||||||
|
await page.getByLabel("Straße").fill("Hauptstraße 1");
|
||||||
|
await page.getByLabel("PLZ").fill("3100");
|
||||||
|
await page.getByLabel("Ort").fill("St. Pölten");
|
||||||
|
await page.getByRole("button", { name: "Speichern" }).click();
|
||||||
|
await expect(page.getByText(/gespeichert|geokodiert|geokodiert werden/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Benutzer anlegen zeigt Einmal-Passwort", async ({ page }) => {
|
||||||
|
await page.goto("/verwaltung/benutzer");
|
||||||
|
await page.getByLabel("Name").fill("Neue Person");
|
||||||
|
await page.getByLabel("E-Mail").fill("neu@ff-musterdorf.at");
|
||||||
|
await page.getByLabel("Rolle").selectOption("wehr_read");
|
||||||
|
await page.getByRole("button", { name: "Benutzer anlegen" }).click();
|
||||||
|
await expect(page.getByText(/Einmal-Passwort/)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
tests/e2e/verwaltung-scoping.spec.ts
Normal file
67
tests/e2e/verwaltung-scoping.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoping & Gating des Wehr-Bereichs (Workstream 7, Querschnittsstandard 1–3,
|
||||||
|
* default-deny dreifach).
|
||||||
|
*
|
||||||
|
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet:
|
||||||
|
* - anonym: jede /verwaltung/*-Seite -> Redirect auf /login.
|
||||||
|
* - wehr_read: jede /verwaltung/*-Seite -> 403 (forbidden()).
|
||||||
|
* - wehr_admin: erreichbar; fremdes Fahrzeug/Gerät -> 404 (notFound).
|
||||||
|
*
|
||||||
|
* Negativ-Probe: Entfernen von `requireWehrAdmin()` aus (app)/verwaltung/
|
||||||
|
* layout.tsx ODER aus einer Server-Action muss diese Suite rot machen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VERWALTUNG_PAGES = [
|
||||||
|
"/verwaltung/profil",
|
||||||
|
"/verwaltung/fahrzeuge",
|
||||||
|
"/verwaltung/fahrzeuge/neu",
|
||||||
|
"/verwaltung/geraete",
|
||||||
|
"/verwaltung/geraete/neu",
|
||||||
|
"/verwaltung/benutzer",
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe("Verwaltung: anonym -> Redirect auf /login", () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
for (const path of VERWALTUNG_PAGES) {
|
||||||
|
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(path);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Verwaltung: wehr_read -> 403", () => {
|
||||||
|
test.skip(
|
||||||
|
!process.env.E2E_WEHR_READ_STATE,
|
||||||
|
"benötigt wehr_read-Fixture (Test-Workstream)",
|
||||||
|
);
|
||||||
|
test.use({
|
||||||
|
storageState: process.env.E2E_WEHR_READ_STATE ?? { cookies: [], origins: [] },
|
||||||
|
});
|
||||||
|
for (const path of VERWALTUNG_PAGES) {
|
||||||
|
test(`wehr_read-Aufruf von ${path} -> 403`, async ({ page }) => {
|
||||||
|
const res = await page.goto(path);
|
||||||
|
expect(res?.status()).toBe(403);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Verwaltung: fremde Wehr -> 404 (Scoping)", () => {
|
||||||
|
test.skip(
|
||||||
|
!process.env.E2E_WEHR_ADMIN_STATE || !process.env.E2E_FOREIGN_VEHICLE_ID,
|
||||||
|
"benötigt wehr_admin-Fixture + fremde Fahrzeug-ID (Test-Workstream)",
|
||||||
|
);
|
||||||
|
test.use({
|
||||||
|
storageState: process.env.E2E_WEHR_ADMIN_STATE ?? { cookies: [], origins: [] },
|
||||||
|
});
|
||||||
|
test("Aufruf eines fremden Fahrzeugs liefert 404", async ({ page }) => {
|
||||||
|
const res = await page.goto(
|
||||||
|
`/verwaltung/fahrzeuge/${process.env.E2E_FOREIGN_VEHICLE_ID}`,
|
||||||
|
);
|
||||||
|
expect(res?.status()).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
tests/support/guard-scan.ts
Normal file
68
tests/support/guard-scan.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { globSync } from "node:fs";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, relative, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statischer Default-Deny-Scanner für Server Actions (Querschnittsstandard 3).
|
||||||
|
*
|
||||||
|
* Reine Funktionen, damit der Beweis sowohl im Vitest-Unit-Test (offline, ohne
|
||||||
|
* Server/DB) als auch in der Playwright-Suite (`server-actions-guard.spec.ts`)
|
||||||
|
* wiederverwendet werden kann.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|
||||||
|
/** Erkennt einen der fünf kanonischen Guards (guards.ts). */
|
||||||
|
export const GUARD_REGEX =
|
||||||
|
/require(Session|Role|OwnBrigade|PlatformAdmin|WehrAdmin)\s*\(/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genuin öffentliche "use server"-Funktionen (Login VOR der Authentifizierung).
|
||||||
|
* Diese DÜRFEN keinen Session-Guard haben — sie sind der Einstieg. Schlüssel:
|
||||||
|
* `<repo-relativer Pfad>:<Funktionsname>`.
|
||||||
|
*/
|
||||||
|
export const PUBLIC_ACTION_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||||
|
"src/app/(auth)/login/actions.ts:loginAction",
|
||||||
|
"src/app/(auth)/login/actions.ts:authentikLoginAction",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den Quelltext EINER Datei. Gibt eine Liste von Verstößen
|
||||||
|
* (`<file>: <funktionsname>`) zurück. Dateien ohne "use server"-Direktive
|
||||||
|
* liefern nie Verstöße.
|
||||||
|
*/
|
||||||
|
export function findUnguardedActions(
|
||||||
|
file: string,
|
||||||
|
src: string,
|
||||||
|
allowlist: ReadonlySet<string> = PUBLIC_ACTION_ALLOWLIST,
|
||||||
|
): string[] {
|
||||||
|
if (!src.includes('"use server"')) return [];
|
||||||
|
const offenders: string[] = [];
|
||||||
|
const fns = src.split(/export async function /).slice(1);
|
||||||
|
for (const body of fns) {
|
||||||
|
const name = body.slice(0, body.indexOf("(")).trim();
|
||||||
|
if (allowlist.has(`${file}:${name}`)) continue;
|
||||||
|
// Erste ~600 Zeichen des Funktionskörpers müssen einen Guard enthalten.
|
||||||
|
if (!GUARD_REGEX.test(body.slice(0, 600))) {
|
||||||
|
offenders.push(`${file}: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return offenders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scannt das echte Repository (src/**) nach ungeschützten Server Actions.
|
||||||
|
*/
|
||||||
|
export function findUnguardedActionsInRepo(): string[] {
|
||||||
|
const files = globSync("src/**/*.{ts,tsx}", { cwd: REPO_ROOT });
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const rel of files) {
|
||||||
|
const abs = resolve(REPO_ROOT, rel);
|
||||||
|
const src = readFileSync(abs, "utf8");
|
||||||
|
if (!src.includes('"use server"')) continue;
|
||||||
|
const repoRel = relative(REPO_ROOT, abs);
|
||||||
|
offenders.push(...findUnguardedActions(repoRel, src));
|
||||||
|
}
|
||||||
|
return offenders;
|
||||||
|
}
|
||||||
69
tests/support/route-scan.ts
Normal file
69
tests/support/route-scan.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { globSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routen-Discovery aus dem Dateisystem für den Driftschutz (Definition of
|
||||||
|
* Done #1). Reine Logik, offline lauffähig (kein Server/DB).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentliche Präfixe (Middleware-Allowlist). Routen, die mit einem dieser
|
||||||
|
* Präfixe beginnen, brauchen KEINEN Manifest-Eintrag, weil sie absichtlich
|
||||||
|
* anonym erreichbar sind.
|
||||||
|
*/
|
||||||
|
export const PUBLIC_ALLOWLIST: readonly string[] = [
|
||||||
|
"/login",
|
||||||
|
"/api/auth",
|
||||||
|
"/api/health",
|
||||||
|
"/_next",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/robots.txt",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt einen Datei-Pfad (page.tsx | route.ts) in den zugehörigen URL-Pfad
|
||||||
|
* um. Route-Groups `(name)` werden entfernt, dynamische Segmente `[id]`
|
||||||
|
* bleiben als Platzhalter erhalten.
|
||||||
|
*/
|
||||||
|
export function filePathToRoute(filePath: string): string {
|
||||||
|
const withoutPrefix = filePath
|
||||||
|
.replace(/^.*src\/app\//, "")
|
||||||
|
.replace(/^(page|route)\.(tsx?|jsx?)$/, "")
|
||||||
|
.replace(/\/(page|route)\.(tsx?|jsx?)$/, "");
|
||||||
|
const segments = withoutPrefix
|
||||||
|
.split("/")
|
||||||
|
.filter((seg) => seg.length > 0 && !/^\(.*\)$/.test(seg));
|
||||||
|
return "/" + segments.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublic(route: string): boolean {
|
||||||
|
return PUBLIC_ALLOWLIST.some(
|
||||||
|
(p) => route === p || route.startsWith(p + "/") || route.startsWith(p),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findet Routen, die im Dateisystem existieren, aber weder im Manifest
|
||||||
|
* deklariert noch öffentlich sind.
|
||||||
|
*/
|
||||||
|
export function findUndeclaredRoutes(
|
||||||
|
discovered: readonly string[],
|
||||||
|
declared: ReadonlySet<string>,
|
||||||
|
): string[] {
|
||||||
|
return discovered
|
||||||
|
.filter((route) => !isPublic(route))
|
||||||
|
.filter((route) => !declared.has(route));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scannt src/app/** nach page.tsx und route.ts und liefert URL-Pfade. */
|
||||||
|
export function discoverAppRoutes(): string[] {
|
||||||
|
const files = globSync("src/app/**/{page,route}.{ts,tsx}", {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
});
|
||||||
|
const routes = new Set<string>();
|
||||||
|
for (const f of files) routes.add(filePathToRoute(f));
|
||||||
|
return [...routes].sort();
|
||||||
|
}
|
||||||
207
tests/unit/deployment.test.ts
Normal file
207
tests/unit/deployment.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statische Deployment-Verifikation (Workstream 10), offline und ohne Docker:
|
||||||
|
* prüft die Compose-/Dockerfile-/Entrypoint-/Env-Artefakte gegen die im Plan
|
||||||
|
* festgelegten Verträge (externes Traefik, non-root, kein Proxy-Service,
|
||||||
|
* Pflicht-Env-Keys, idempotente Migration vor App-Start).
|
||||||
|
*/
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = resolve(here, "..", "..");
|
||||||
|
const read = (rel: string): string => readFileSync(resolve(root, rel), "utf8");
|
||||||
|
|
||||||
|
describe("Deployment-Artefakte", () => {
|
||||||
|
it("Dockerfile ist multi-stage, standalone und läuft non-root als UID 1001", () => {
|
||||||
|
const df = read("Dockerfile");
|
||||||
|
expect(df).toMatch(/AS\s+deps/);
|
||||||
|
expect(df).toMatch(/AS\s+builder/);
|
||||||
|
expect(df).toMatch(/AS\s+runner/);
|
||||||
|
// Standalone-Server wird kopiert und gestartet.
|
||||||
|
expect(df).toMatch(/\.next\/standalone/);
|
||||||
|
expect(df).toMatch(/server\.js/);
|
||||||
|
// Non-root: dedizierte UID/GID 1001 + USER-Wechsel.
|
||||||
|
expect(df).toMatch(/1001/);
|
||||||
|
expect(df).toMatch(/^USER\s+(nextjs|1001)/m);
|
||||||
|
// Entrypoint übernimmt Migration vor App-Start.
|
||||||
|
expect(df).toMatch(/entrypoint\.sh/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Dockerfile kopiert die VOLLSTÄNDIGE pg/drizzle-orm-Laufzeit-Closure ins Runner-Image", () => {
|
||||||
|
// Reale Laufzeit-Closure aus node_modules berechnen (nur dependencies,
|
||||||
|
// wie sie zur Laufzeit von docker/migrate.mjs + docker/seed.mjs benötigt
|
||||||
|
// wird). Jedes Paket MUSS eine COPY-Zeile im Runner-Stage haben, sonst
|
||||||
|
// crasht der Container-Start mit ERR_MODULE_NOT_FOUND (z. B. pg-int8,
|
||||||
|
// xtend). Schützt vor erneutem Brechen bei pg/drizzle-Updates.
|
||||||
|
const df = read("Dockerfile");
|
||||||
|
const nodeModules = resolve(root, "node_modules");
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const resolvePkg = (name: string, fromDir: string): string | null => {
|
||||||
|
let cur = fromDir;
|
||||||
|
for (;;) {
|
||||||
|
const candidate = resolve(cur, "node_modules", name);
|
||||||
|
if (existsSync(resolve(candidate, "package.json"))) return candidate;
|
||||||
|
const parent = dirname(cur);
|
||||||
|
if (parent === cur) break;
|
||||||
|
cur = parent;
|
||||||
|
}
|
||||||
|
const top = resolve(nodeModules, name);
|
||||||
|
return existsSync(resolve(top, "package.json")) ? top : null;
|
||||||
|
};
|
||||||
|
const walk = (dir: string): void => {
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
readFileSync(resolve(dir, "package.json"), "utf8"),
|
||||||
|
) as { dependencies?: Record<string, string> };
|
||||||
|
for (const dep of Object.keys(pkg.dependencies ?? {})) {
|
||||||
|
if (seen.has(dep)) continue;
|
||||||
|
seen.add(dep);
|
||||||
|
const resolved = resolvePkg(dep, dir);
|
||||||
|
// Nur skalierbar prüfbar, wenn auflösbar; sonst meldet npm ci es ohnehin.
|
||||||
|
if (resolved) walk(resolved);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pgRoot = resolve(nodeModules, "pg");
|
||||||
|
const drizzleRoot = resolve(nodeModules, "drizzle-orm");
|
||||||
|
walk(pgRoot);
|
||||||
|
walk(drizzleRoot);
|
||||||
|
|
||||||
|
// pg + drizzle-orm selbst sind ebenfalls Teil der Closure.
|
||||||
|
const closure = new Set<string>([...seen, "pg", "drizzle-orm"]);
|
||||||
|
|
||||||
|
const missing = [...closure].filter(
|
||||||
|
(name) =>
|
||||||
|
!df.includes(`/app/node_modules/${name} ./node_modules/${name}`),
|
||||||
|
);
|
||||||
|
expect(missing, `nicht ins Runner-Image kopiert: ${missing.join(", ")}`).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Dockerfile bündelt den Seed (build-seed-bundle) und kopiert docker/seed.mjs ins Runner-Image", () => {
|
||||||
|
const df = read("Dockerfile");
|
||||||
|
// Im builder wird der Seed zu Plain-ESM gebündelt ...
|
||||||
|
expect(df).toMatch(/build-seed-bundle\.mjs/);
|
||||||
|
// ... und ins Runner-Image kopiert, damit RUN_SEED=true nicht still no-opt.
|
||||||
|
expect(df).toMatch(/docker\/seed\.mjs/);
|
||||||
|
// Der Bundler-Entrypoint existiert.
|
||||||
|
expect(existsSync(resolve(root, "scripts/build-seed-bundle.mjs"))).toBe(true);
|
||||||
|
// Das Seed-Quellmodul, das gebündelt wird, existiert.
|
||||||
|
expect(existsSync(resolve(root, "src/db/seed/index.ts"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(".dockerignore schließt node_modules, .next und Secrets aus", () => {
|
||||||
|
const di = read(".dockerignore");
|
||||||
|
expect(di).toMatch(/node_modules/);
|
||||||
|
expect(di).toMatch(/\.next/);
|
||||||
|
expect(di).toMatch(/\.env/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entrypoint.sh hat gültige sh-Syntax, wartet auf Postgres, migriert, startet server.js", () => {
|
||||||
|
const ep = resolve(root, "docker/entrypoint.sh");
|
||||||
|
expect(existsSync(ep)).toBe(true);
|
||||||
|
// Syntaxprüfung (offline, kein Ausführen der Logik).
|
||||||
|
execFileSync("sh", ["-n", ep]);
|
||||||
|
const src = read("docker/entrypoint.sh");
|
||||||
|
expect(src).toMatch(/set -e/);
|
||||||
|
// Warten auf Postgres (TCP-Probe), dann Migration, optional Seed, dann Start.
|
||||||
|
expect(src).toMatch(/DATABASE_URL/);
|
||||||
|
expect(src).toMatch(/migrate/);
|
||||||
|
expect(src).toMatch(/RUN_SEED/);
|
||||||
|
expect(src).toMatch(/exec node server\.js/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(".env.example listet alle Pflicht-Env-Keys", () => {
|
||||||
|
const env = read(".env.example");
|
||||||
|
for (const key of [
|
||||||
|
"AUTH_URL",
|
||||||
|
"AUTH_TRUST_HOST",
|
||||||
|
"AUTH_SECRET",
|
||||||
|
"AUTHENTIK_ISSUER",
|
||||||
|
"AUTHENTIK_CLIENT_ID",
|
||||||
|
"AUTHENTIK_CLIENT_SECRET",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"OSRM_URL",
|
||||||
|
"NOMINATIM_URL",
|
||||||
|
"APP_HOST",
|
||||||
|
]) {
|
||||||
|
expect(env, `fehlender Env-Key: ${key}`).toMatch(new RegExp(`^${key}=`, "m"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("docker-compose.yml nutzt externes Traefik-Netz, Labels, Healthchecks und keinen Proxy-Service", () => {
|
||||||
|
const c = read("docker-compose.yml");
|
||||||
|
// Genau die vier Fachdienste; kein eigener Proxy/Traefik-SERVICE.
|
||||||
|
const servicesBlock = c.slice(c.indexOf("\nservices:"), c.indexOf("\nvolumes:"));
|
||||||
|
expect(servicesBlock).toMatch(/^\s{2}app:/m);
|
||||||
|
expect(servicesBlock).toMatch(/^\s{2}postgres:/m);
|
||||||
|
expect(servicesBlock).toMatch(/^\s{2}osrm:/m);
|
||||||
|
expect(servicesBlock).toMatch(/^\s{2}nominatim:/m);
|
||||||
|
expect(servicesBlock).not.toMatch(/^\s{2}traefik:/m);
|
||||||
|
expect(servicesBlock).not.toMatch(/^\s{2}proxy:/m);
|
||||||
|
// Externes Traefik-Netz.
|
||||||
|
expect(c).toMatch(/external:\s*true/);
|
||||||
|
// Traefik-Labels am App-Service.
|
||||||
|
expect(c).toMatch(/traefik\.enable=true/);
|
||||||
|
expect(c).toMatch(/Host\(`\$\{APP_HOST\}`\)/);
|
||||||
|
expect(c).toMatch(/entrypoints=websecure/);
|
||||||
|
expect(c).toMatch(/tls\.certresolver/);
|
||||||
|
// Forwarded-Header / sichere Cookies via Env.
|
||||||
|
expect(c).toMatch(/AUTH_TRUST_HOST/);
|
||||||
|
expect(c).toMatch(/AUTH_URL/);
|
||||||
|
// Postgres-Healthcheck.
|
||||||
|
expect(c).toMatch(/pg_isready/);
|
||||||
|
// App-Healthcheck gegen /api/health.
|
||||||
|
expect(c).toMatch(/\/api\/health/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("docker compose config listet genau app/postgres/osrm/nominatim", () => {
|
||||||
|
// Offline-tauglich: `docker compose config --services` braucht keine Container.
|
||||||
|
let services: string;
|
||||||
|
try {
|
||||||
|
services = execFileSync(
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"compose",
|
||||||
|
"-f",
|
||||||
|
"docker-compose.yml",
|
||||||
|
"-f",
|
||||||
|
"docker-compose.geo.yml",
|
||||||
|
"config",
|
||||||
|
"--services",
|
||||||
|
],
|
||||||
|
{ cwd: root, encoding: "utf8", env: { ...process.env, APP_HOST: "floriannetz.example.at" } },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Kein Docker in der Sandbox: dieser Teilschritt ist deferred.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const set = new Set(services.split("\n").map((s) => s.trim()).filter(Boolean));
|
||||||
|
expect(set).toEqual(new Set(["app", "postgres", "osrm", "nominatim"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Override-Beispiel macht die App lokal auf :3000 ohne TLS erreichbar", () => {
|
||||||
|
const o = read("docker-compose.override.yml.example");
|
||||||
|
expect(o).toMatch(/3000:3000/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Makefile bietet build/up/deploy/data-Ziele", () => {
|
||||||
|
const mk = read("Makefile");
|
||||||
|
expect(mk).toMatch(/^build:/m);
|
||||||
|
expect(mk).toMatch(/^up:/m);
|
||||||
|
expect(mk).toMatch(/^deploy:/m);
|
||||||
|
expect(mk).toMatch(/^data:/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deployment-traefik.md dokumentiert Authentik-Callback und Pflicht-Env", () => {
|
||||||
|
const doc = read("docs/reference/deployment-traefik.md");
|
||||||
|
expect(doc).toMatch(/https:\/\/\$\{APP_HOST\}\/api\/auth\/callback\/authentik/);
|
||||||
|
expect(doc).toMatch(/AUTH_URL/);
|
||||||
|
expect(doc).toMatch(/external/);
|
||||||
|
expect(doc).toMatch(/\/api\/health/);
|
||||||
|
});
|
||||||
|
});
|
||||||
103
tests/unit/routes-manifest.test.ts
Normal file
103
tests/unit/routes-manifest.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
PUBLIC_ALLOWLIST,
|
||||||
|
filePathToRoute,
|
||||||
|
findUndeclaredRoutes,
|
||||||
|
discoverAppRoutes,
|
||||||
|
} from "../support/route-scan";
|
||||||
|
import {
|
||||||
|
DECLARED_ROUTE_TEMPLATES,
|
||||||
|
ROUTES,
|
||||||
|
} from "../e2e/routes.manifest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driftschutz für das Routen-Manifest (Definition of Done #1): jede neue Route
|
||||||
|
* unter src/app/** muss im Manifest geführt (oder explizit öffentlich) sein,
|
||||||
|
* sonst bleibt sie ungetestet im Auth-Gating. Reine Logik, offline lauffähig.
|
||||||
|
*/
|
||||||
|
describe("filePathToRoute", () => {
|
||||||
|
it("mappt page.tsx einer Route-Group auf den URL-Pfad ohne Gruppe", () => {
|
||||||
|
expect(filePathToRoute("src/app/(app)/fahrzeuge/page.tsx")).toBe(
|
||||||
|
"/fahrzeuge",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mappt die Wurzel-Page der (app)-Gruppe auf /", () => {
|
||||||
|
expect(filePathToRoute("src/app/(app)/page.tsx")).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ersetzt dynamische Segmente durch einen Platzhalter", () => {
|
||||||
|
expect(filePathToRoute("src/app/(app)/fahrzeuge/[id]/page.tsx")).toBe(
|
||||||
|
"/fahrzeuge/[id]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mappt route.ts auf den API-Pfad", () => {
|
||||||
|
expect(filePathToRoute("src/app/api/health/route.ts")).toBe("/api/health");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mappt die Root-Page src/app/page.tsx auf /", () => {
|
||||||
|
expect(filePathToRoute("src/app/page.tsx")).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("löst catch-all-Segmente auf", () => {
|
||||||
|
expect(filePathToRoute("src/app/api/auth/[...nextauth]/route.ts")).toBe(
|
||||||
|
"/api/auth/[...nextauth]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findUndeclaredRoutes", () => {
|
||||||
|
it("flaggt eine Route, die weder im Manifest noch öffentlich ist", () => {
|
||||||
|
const declared = new Set(["/fahrzeuge"]);
|
||||||
|
const discovered = ["/fahrzeuge", "/leak"];
|
||||||
|
expect(findUndeclaredRoutes(discovered, declared)).toEqual(["/leak"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignoriert Routen mit öffentlichem Präfix", () => {
|
||||||
|
const declared = new Set<string>();
|
||||||
|
const discovered = ["/login", "/api/health", "/api/auth/[...nextauth]"];
|
||||||
|
expect(findUndeclaredRoutes(discovered, declared)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUBLIC_ALLOWLIST enthält /api/health und /login", () => {
|
||||||
|
expect(PUBLIC_ALLOWLIST).toContain("/api/health");
|
||||||
|
expect(PUBLIC_ALLOWLIST).toContain("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("discoverAppRoutes (echtes Repo)", () => {
|
||||||
|
it("findet die bekannten Seiten", () => {
|
||||||
|
const routes = discoverAppRoutes();
|
||||||
|
expect(routes).toContain("/fahrzeuge");
|
||||||
|
expect(routes).toContain("/admin");
|
||||||
|
expect(routes).toContain("/api/health");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Driftschutz: Manifest deckt alle Routen ab (Definition of Done #1)", () => {
|
||||||
|
it("KEINE Route unter src/app/** fehlt im Manifest oder in der Allowlist", () => {
|
||||||
|
const discovered = discoverAppRoutes();
|
||||||
|
const undeclared = findUndeclaredRoutes(
|
||||||
|
discovered,
|
||||||
|
DECLARED_ROUTE_TEMPLATES,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
undeclared,
|
||||||
|
`Ungetestete Routen (im Manifest ergänzen oder als öffentlich markieren):\n${undeclared.join(
|
||||||
|
"\n",
|
||||||
|
)}`,
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("jeder ROUTES-Eintrag entspricht einer existierenden Route-Vorlage", () => {
|
||||||
|
const discovered = new Set(discoverAppRoutes());
|
||||||
|
for (const template of DECLARED_ROUTE_TEMPLATES) {
|
||||||
|
expect(discovered, `Manifest-Eintrag ${template} fehlt im Dateisystem`).toContain(
|
||||||
|
template,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Sanity: jede konkrete ROUTES-Zeile ist nicht leer.
|
||||||
|
for (const r of ROUTES) expect(r.path.startsWith("/")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
tests/unit/server-actions-guard.test.ts
Normal file
90
tests/unit/server-actions-guard.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
GUARD_REGEX,
|
||||||
|
PUBLIC_ACTION_ALLOWLIST,
|
||||||
|
findUnguardedActions,
|
||||||
|
findUnguardedActionsInRepo,
|
||||||
|
} from "../support/guard-scan";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statischer Default-Deny-Beweis für Server Actions (Querschnittsstandard 3,
|
||||||
|
* Definition of Done #2): JEDE "use server"-Funktion ruft als erste Anweisung
|
||||||
|
* einen Guard. Dieser Test ist OHNE Server/DB lauffähig und prüft das echte
|
||||||
|
* Repository.
|
||||||
|
*/
|
||||||
|
describe("findUnguardedActions (pure)", () => {
|
||||||
|
it("flaggt eine exportierte Action ohne Guard", () => {
|
||||||
|
const src = `"use server";
|
||||||
|
export async function tut_was(x: unknown) {
|
||||||
|
return doThing(x);
|
||||||
|
}`;
|
||||||
|
expect(findUnguardedActions("x.ts", src)).toEqual([
|
||||||
|
"x.ts: tut_was",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("akzeptiert eine Action, die mit requireWehrAdmin beginnt", () => {
|
||||||
|
const src = `"use server";
|
||||||
|
export async function tut_was(x: unknown) {
|
||||||
|
const s = await requireWehrAdmin();
|
||||||
|
return doThing(x, s);
|
||||||
|
}`;
|
||||||
|
expect(findUnguardedActions("x.ts", src)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("akzeptiert requirePlatformAdmin / requireSession / requireRole / requireOwnBrigade", () => {
|
||||||
|
for (const guard of [
|
||||||
|
"requirePlatformAdmin",
|
||||||
|
"requireSession",
|
||||||
|
"requireRole",
|
||||||
|
"requireOwnBrigade",
|
||||||
|
]) {
|
||||||
|
const src = `"use server";
|
||||||
|
export async function f(x: unknown) {
|
||||||
|
await ${guard}();
|
||||||
|
return x;
|
||||||
|
}`;
|
||||||
|
expect(findUnguardedActions("x.ts", src)).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignoriert Dateien ohne \"use server\"-Direktive", () => {
|
||||||
|
const src = `export async function f(x: unknown) { return x; }`;
|
||||||
|
expect(findUnguardedActions("x.ts", src)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respektiert die Allowlist genuin öffentlicher Actions", () => {
|
||||||
|
const src = `"use server";
|
||||||
|
export async function loginAction(x: unknown) { return x; }`;
|
||||||
|
expect(
|
||||||
|
findUnguardedActions(
|
||||||
|
"src/app/(auth)/login/actions.ts",
|
||||||
|
src,
|
||||||
|
new Set(["src/app/(auth)/login/actions.ts:loginAction"]),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hat die Login-Actions in der Default-Allowlist", () => {
|
||||||
|
expect(PUBLIC_ACTION_ALLOWLIST.has("src/app/(auth)/login/actions.ts:loginAction")).toBe(true);
|
||||||
|
expect(
|
||||||
|
PUBLIC_ACTION_ALLOWLIST.has("src/app/(auth)/login/actions.ts:authentikLoginAction"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GUARD_REGEX matcht alle fünf Guard-Namen", () => {
|
||||||
|
expect(GUARD_REGEX.test("requireSession(")).toBe(true);
|
||||||
|
expect(GUARD_REGEX.test("requirePlatformAdmin(")).toBe(true);
|
||||||
|
expect(GUARD_REGEX.test("doSomething(")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findUnguardedActionsInRepo (echtes Repo)", () => {
|
||||||
|
it("findet KEINE ungeschützten Server Actions im echten src/", () => {
|
||||||
|
const offenders = findUnguardedActionsInRepo();
|
||||||
|
expect(
|
||||||
|
offenders,
|
||||||
|
`Server Actions ohne Guard:\n${offenders.join("\n")}`,
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,22 @@ export default defineConfig({
|
|||||||
environment: "node",
|
environment: "node",
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ["./vitest.setup.ts"],
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"],
|
include: [
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/__tests__/**/*.test.ts",
|
||||||
|
"tests/unit/**/*.test.ts",
|
||||||
|
],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
// Querschnitt-Kern muss hoch abgedeckt sein (Definition of Done #7):
|
||||||
|
// src/lib/search und src/lib/geo >= 90 %.
|
||||||
|
include: ["src/lib/search/**", "src/lib/geo/**"],
|
||||||
|
thresholds: {
|
||||||
|
lines: 90,
|
||||||
|
functions: 90,
|
||||||
|
statements: 90,
|
||||||
|
branches: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user