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
|
||||
GEO_HTTP_TIMEOUT_MS=4000
|
||||
HAVERSINE_KMH=50
|
||||
|
||||
# Deployment / externes Traefik
|
||||
# APP_HOST ist der öffentliche Hostname (Traefik-Routing + AUTH_URL-Basis).
|
||||
# In Produktion: AUTH_URL=https://${APP_HOST} und AUTH_TRUST_HOST=true setzen.
|
||||
APP_HOST=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/
|
||||
next-env.d.ts
|
||||
*.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",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:unit": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx scripts/migrate.ts",
|
||||
"db:seed-auth": "tsx scripts/seed-auth.ts",
|
||||
"test:e2e:gating": "playwright test tests/e2e/auth-gating.spec.ts",
|
||||
"db:seed": "tsx src/db/seed/index.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:gating": "playwright test --project=chromium tests/e2e/auth-gating.spec.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:check": "drizzle-kit check"
|
||||
|
||||
@@ -13,12 +13,22 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
// Migration + deterministischer Seed (deferred ohne DATABASE_URL).
|
||||
globalSetup: "./tests/e2e/global-setup.ts",
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
// 1. Echter Login je Konto -> storageState (tests/e2e/.auth/*.json).
|
||||
{ name: "setup", testMatch: /fixtures\/auth\.setup\.ts/ },
|
||||
// 2. Eigentliche Suiten; hängen vom Login-Setup ab.
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
testIgnore: /fixtures\/auth\.setup\.ts/,
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
webServer: process.env.E2E_BASE_URL
|
||||
? undefined
|
||||
|
||||
37
scripts/build-seed-bundle.mjs
Normal file
37
scripts/build-seed-bundle.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
// Bündelt den Katalog-Seed (src/db/seed/index.ts inkl. Seed-Daten + Drizzle-
|
||||
// Schema) zu einer einzigen, selbstständigen ESM-Datei docker/seed.mjs für das
|
||||
// Laufzeit-Image — analog zu docker/migrate.mjs, aber generiert statt handgepflegt.
|
||||
//
|
||||
// Hintergrund: Der Standalone-Runner enthält weder `tsx` noch `src/`. Damit
|
||||
// RUN_SEED=true im Entrypoint tatsächlich funktioniert (statt still no-op zu
|
||||
// werden), bündeln wir die Seed-Logik beim Build zu Plain-ESM. `pg` und
|
||||
// `drizzle-orm` bleiben extern (sind im Runner als node_modules vorhanden);
|
||||
// alles andere (Schema, Seed-Daten, Upserts) wird inline gebündelt, sodass
|
||||
// keine `src/`-Dateien ins Runner-Image müssen.
|
||||
//
|
||||
// Aufruf: node scripts/build-seed-bundle.mjs (läuft im builder-Stage).
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(here, "..");
|
||||
|
||||
await build({
|
||||
entryPoints: [resolve(root, "src/db/seed/index.ts")],
|
||||
outfile: resolve(root, "docker/seed.mjs"),
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node22",
|
||||
// Im Runner vorhandene (und im Dockerfile kopierte) Laufzeit-Pakete bleiben
|
||||
// extern, damit wir keine zwei Kopien bündeln und die native pg-Kette nutzen.
|
||||
external: ["pg", "drizzle-orm", "drizzle-orm/*"],
|
||||
// src/db/seed/index.ts importiert ../../lib/audit.js NUR als `import type`
|
||||
// (Tx); damit zieht esbuild die @/db-Kette (next-auth etc.) nicht in den
|
||||
// Bundle. Die folgende alias-freie Auflösung reicht deshalb aus.
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
console.log("docker/seed.mjs gebündelt.");
|
||||
144
src/app/(admin)/_actions/__tests__/proposals.test.ts
Normal file
144
src/app/(admin)/_actions/__tests__/proposals.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// --- Mocks ---------------------------------------------------------------
|
||||
|
||||
const PROPOSED = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
const ZIEL = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||
|
||||
// Geteilter, veraenderbarer Zustand. vi.hoisted laeuft vor den (gehoisteten)
|
||||
// vi.mock-Factories, sodass diese den State sicher referenzieren koennen.
|
||||
const state = vi.hoisted(() => ({
|
||||
// merkmale-Zeilen, die der Top-Level db.select() liefert.
|
||||
merkmaleRows: [] as Array<{ id: string; typ: string; status: string }>,
|
||||
// Reihenfolge der tx.select()-Ergebnisse fuer vehicle_template_merkmale:
|
||||
// [0] = proposed-Templates, [1] = ziel-Templates.
|
||||
vtmSelectQueue: [] as Array<Array<{ templateId: string }>>,
|
||||
ops: [] as { type: string; table: string; vals?: unknown }[],
|
||||
}));
|
||||
|
||||
function tableName(arg: unknown): string {
|
||||
return (arg as { __name?: string })?.__name ?? "unknown";
|
||||
}
|
||||
|
||||
vi.mock("@/db/schema", () => ({
|
||||
merkmale: { __name: "merkmale" },
|
||||
merkmalValues: { __name: "merkmal_values" },
|
||||
vehicleTemplateMerkmale: { __name: "vehicle_template_merkmale" },
|
||||
}));
|
||||
|
||||
function makeTx() {
|
||||
return {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => Promise.resolve(state.vtmSelectQueue.shift() ?? []),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
state.ops.push({ type: "update", table: tableName(table), vals });
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
delete: (table: unknown) => ({
|
||||
where: () => {
|
||||
state.ops.push({ type: "delete", table: tableName(table) });
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/db", () => ({
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => Promise.resolve(state.merkmaleRows),
|
||||
}),
|
||||
transaction: (cb: (tx: ReturnType<typeof makeTx>) => Promise<unknown>) =>
|
||||
cb(makeTx()),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/guards", () => ({
|
||||
requirePlatformAdmin: () =>
|
||||
Promise.resolve({ user: { id: "actor-1" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/audit", () => ({
|
||||
writeAudit: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: () => undefined,
|
||||
}));
|
||||
|
||||
// drizzle-orm Helfer (eq/and/inArray) muessen echte Aufrufe ueberstehen.
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: (...a: unknown[]) => ({ op: "eq", a }),
|
||||
and: (...a: unknown[]) => ({ op: "and", a }),
|
||||
inArray: (...a: unknown[]) => ({ op: "inArray", a }),
|
||||
}));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
import { mergeMerkmal } from "@/app/(admin)/_actions/proposals";
|
||||
|
||||
describe("mergeMerkmal", () => {
|
||||
beforeEach(() => {
|
||||
state.ops.length = 0;
|
||||
state.vtmSelectQueue = [];
|
||||
state.merkmaleRows = [
|
||||
{ id: PROPOSED, typ: "number", status: "proposed" },
|
||||
{ id: ZIEL, typ: "number", status: "active" },
|
||||
];
|
||||
});
|
||||
|
||||
it("lehnt unterschiedliche Typen ab", async () => {
|
||||
state.merkmaleRows = [
|
||||
{ id: PROPOSED, typ: "boolean", status: "proposed" },
|
||||
{ id: ZIEL, typ: "number", status: "active" },
|
||||
];
|
||||
const res = await mergeMerkmal({ proposedId: PROPOSED, zielId: ZIEL });
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("haengt ohne Kollision alle vtm-Zeilen um (kein Delete der proposed-vtm)", async () => {
|
||||
// proposed in Template T1, Ziel in keinem -> keine Kollision.
|
||||
state.vtmSelectQueue = [[{ templateId: "T1" }], []];
|
||||
const res = await mergeMerkmal({ proposedId: PROPOSED, zielId: ZIEL });
|
||||
expect(res.ok).toBe(true);
|
||||
// Es darf kein Kollisions-Delete auf vtm geben, nur das finale
|
||||
// merkmale-Delete.
|
||||
const vtmDeletes = state.ops.filter(
|
||||
(o) => o.type === "delete" && o.table === "vehicle_template_merkmale",
|
||||
);
|
||||
expect(vtmDeletes).toHaveLength(0);
|
||||
const vtmUpdates = state.ops.filter(
|
||||
(o) => o.type === "update" && o.table === "vehicle_template_merkmale",
|
||||
);
|
||||
expect(vtmUpdates).toHaveLength(1);
|
||||
expect(
|
||||
state.ops.some((o) => o.type === "delete" && o.table === "merkmale"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("loescht kollidierende proposed-vtm-Zeilen vor dem Umhaengen", async () => {
|
||||
// proposed und Ziel teilen sich Template T1 -> Kollision auf PK.
|
||||
state.vtmSelectQueue = [[{ templateId: "T1" }], [{ templateId: "T1" }]];
|
||||
const res = await mergeMerkmal({ proposedId: PROPOSED, zielId: ZIEL });
|
||||
expect(res.ok).toBe(true);
|
||||
const vtmDeletes = state.ops.filter(
|
||||
(o) => o.type === "delete" && o.table === "vehicle_template_merkmale",
|
||||
);
|
||||
expect(vtmDeletes).toHaveLength(1);
|
||||
// Delete des Kollisions-Eintrags vor dem Umhaengen.
|
||||
const deleteIdx = state.ops.findIndex(
|
||||
(o) => o.type === "delete" && o.table === "vehicle_template_merkmale",
|
||||
);
|
||||
const updateIdx = state.ops.findIndex(
|
||||
(o) => o.type === "update" && o.table === "vehicle_template_merkmale",
|
||||
);
|
||||
expect(deleteIdx).toBeLessThan(updateIdx);
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,17 @@ export async function resetBrigadeUserPassword(
|
||||
const s = await requirePlatformAdmin();
|
||||
const p = userResetSchema.safeParse(input);
|
||||
if (!p.success) return { ok: false, error: "Ungültige ID." };
|
||||
try {
|
||||
const { tempPassword } = await resetUserPassword(p.data.userId, s.user.id);
|
||||
revalidatePath("/admin/wehren");
|
||||
return { ok: true, tempPassword };
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Passwort konnte nicht zurückgesetzt werden.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { merkmale, merkmalValues, vehicleTemplateMerkmale } from "@/db/schema";
|
||||
@@ -80,7 +80,40 @@ export async function mergeMerkmal(input: {
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
// vehicle_template_merkmale hat den zusammengesetzten PK
|
||||
// (template_id, merkmal_id). Hat eine Vorlage bereits sowohl das
|
||||
// vorgeschlagene als auch das Ziel-Merkmal, würde ein pauschales
|
||||
// Umhängen den PK verletzen. Solche kollidierenden Proposed-Zeilen
|
||||
// werden daher gelöscht statt umgehängt.
|
||||
const proposedVtm = await tx
|
||||
.select({ templateId: vehicleTemplateMerkmale.templateId })
|
||||
.from(vehicleTemplateMerkmale)
|
||||
.where(eq(vehicleTemplateMerkmale.merkmalId, proposed.data));
|
||||
const zielVtm = await tx
|
||||
.select({ templateId: vehicleTemplateMerkmale.templateId })
|
||||
.from(vehicleTemplateMerkmale)
|
||||
.where(eq(vehicleTemplateMerkmale.merkmalId, ziel.data));
|
||||
const zielTemplateIds = new Set(zielVtm.map((r) => r.templateId));
|
||||
const collidingTemplateIds = proposedVtm
|
||||
.map((r) => r.templateId)
|
||||
.filter((id) => zielTemplateIds.has(id));
|
||||
|
||||
if (collidingTemplateIds.length > 0) {
|
||||
await tx
|
||||
.delete(vehicleTemplateMerkmale)
|
||||
.where(
|
||||
and(
|
||||
eq(vehicleTemplateMerkmale.merkmalId, proposed.data),
|
||||
inArray(
|
||||
vehicleTemplateMerkmale.templateId,
|
||||
collidingTemplateIds,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(merkmalValues)
|
||||
.set({ merkmalId: ziel.data })
|
||||
@@ -99,6 +132,12 @@ export async function mergeMerkmal(input: {
|
||||
tx,
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Zusammenführen fehlgeschlagen. Bitte erneut versuchen.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidatePath("/admin/merkmale/proposals");
|
||||
return { ok: true };
|
||||
|
||||
14
src/app/(app)/fahrzeuge/[id]/not-found.tsx
Normal file
14
src/app/(app)/fahrzeuge/[id]/not-found.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { t } from "@/lib/i18n/de";
|
||||
|
||||
export default function FahrzeugNotFound() {
|
||||
return (
|
||||
<div className="mx-auto max-w-md py-16 text-center">
|
||||
<p className="text-anthrazit">{t("detail.nichtGefunden")}</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/fahrzeuge">{t("nav.fahrzeuge")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/app/(app)/fahrzeuge/[id]/page.tsx
Normal file
48
src/app/(app)/fahrzeuge/[id]/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireSession } from "@/lib/auth/guards";
|
||||
import { getFahrzeugDetail } from "@/lib/detail/queries";
|
||||
import { uuidSchema } from "@/lib/validation/common";
|
||||
import { DetailHeader } from "@/components/detail/DetailHeader";
|
||||
import { EckdatenGrid } from "@/components/detail/EckdatenGrid";
|
||||
import { BeladungListe } from "@/components/detail/BeladungListe";
|
||||
import { WehrCard } from "@/components/kontakt/WehrCard";
|
||||
import { t } from "@/lib/i18n/de";
|
||||
|
||||
export default async function FahrzeugDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
// Default-deny in der Tiefe (Querschnittsstandard 1): Guard als erste Zeile.
|
||||
await requireSession();
|
||||
const { id } = await params;
|
||||
// Route-Param ist Nutzereingabe an der Grenze (Querschnittsstandard 4):
|
||||
// nicht-UUID -> saubere deutsche 404 statt Postgres `invalid input syntax`.
|
||||
const parsed = uuidSchema.safeParse(id);
|
||||
if (!parsed.success) notFound();
|
||||
const v = await getFahrzeugDetail(parsed.data);
|
||||
if (!v) notFound();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<DetailHeader
|
||||
kicker={v.templateName ?? t("nav.fahrzeuge")}
|
||||
titel={v.name}
|
||||
untertitel={v.funkrufname}
|
||||
status={v.status}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_18rem]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<EckdatenGrid rows={v.merkmale} />
|
||||
<BeladungListe items={v.beladung} />
|
||||
{v.notiz ? (
|
||||
<p className="whitespace-pre-line text-sm text-anthrazit/70">
|
||||
{v.notiz}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{v.wehr ? <WehrCard wehr={v.wehr} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/app/(app)/geraete/[id]/page.tsx
Normal file
58
src/app/(app)/geraete/[id]/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireSession } from "@/lib/auth/guards";
|
||||
import { getGeraetDetail } from "@/lib/detail/queries";
|
||||
import { uuidSchema } from "@/lib/validation/common";
|
||||
import { DetailHeader } from "@/components/detail/DetailHeader";
|
||||
import { EckdatenGrid } from "@/components/detail/EckdatenGrid";
|
||||
import { WehrCard } from "@/components/kontakt/WehrCard";
|
||||
import { t } from "@/lib/i18n/de";
|
||||
|
||||
export default async function GeraetDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
// Default-deny in der Tiefe (Querschnittsstandard 1): Guard als erste Zeile.
|
||||
await requireSession();
|
||||
const { id } = await params;
|
||||
// Route-Param ist Nutzereingabe an der Grenze (Querschnittsstandard 4):
|
||||
// nicht-UUID -> saubere deutsche 404 statt Postgres `invalid input syntax`.
|
||||
const parsed = uuidSchema.safeParse(id);
|
||||
if (!parsed.success) notFound();
|
||||
const g = await getGeraetDetail(parsed.data);
|
||||
if (!g) notFound();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<DetailHeader
|
||||
kicker={g.kategorie}
|
||||
titel={g.name}
|
||||
status={g.status}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_18rem]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<EckdatenGrid rows={g.merkmale} />
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-navy">
|
||||
{t("detail.zugeordnetesFahrzeug")}
|
||||
</h2>
|
||||
{g.fahrzeug ? (
|
||||
<Link
|
||||
href={`/fahrzeuge/${g.fahrzeug.id}`}
|
||||
className="mt-2 inline-block font-medium text-navy hover:underline"
|
||||
>
|
||||
{g.fahrzeug.name}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-anthrazit/60">
|
||||
{t("detail.imGeraetehaus")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
{g.wehr ? <WehrCard wehr={g.wehr} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/app/(app)/verwaltung/benutzer/page.tsx
Normal file
77
src/app/(app)/verwaltung/benutzer/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { listUsersForBrigade } from "@/server/data/brigade-users";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
BrigadeUserForm,
|
||||
DeactivateUserButton,
|
||||
} from "@/components/verwaltung/BrigadeUserForm";
|
||||
|
||||
const ROLLE_LABEL: Record<string, string> = {
|
||||
wehr_admin: de.verwaltung.rolleAdmin,
|
||||
wehr_read: de.verwaltung.rolleRead,
|
||||
platform_admin: "Plattform-Admin",
|
||||
};
|
||||
|
||||
export default async function BenutzerPage() {
|
||||
const s = await requireWehrAdmin();
|
||||
const users = await listUsersForBrigade(s.user.brigadeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.navBenutzer}
|
||||
</h1>
|
||||
|
||||
<BrigadeUserForm />
|
||||
|
||||
{users.length === 0 ? (
|
||||
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
|
||||
{de.verwaltung.keineBenutzer}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-rand rounded border border-rand bg-white">
|
||||
{users.map((u) => (
|
||||
<li
|
||||
key={u.id}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-anthrazit">
|
||||
{u.name}
|
||||
{u.id === s.user.id ? (
|
||||
<span className="ml-2 text-xs text-anthrazit/50">(Sie)</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="truncate text-sm text-anthrazit/60">{u.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{ROLLE_LABEL[u.rolle] ?? u.rolle}</Badge>
|
||||
<Badge>
|
||||
{u.authTyp === "local"
|
||||
? de.verwaltung.authLokal
|
||||
: de.verwaltung.authAuthentik}
|
||||
</Badge>
|
||||
<Badge
|
||||
className={
|
||||
u.aktiv
|
||||
? "border-bereit/30 bg-bereit/10 text-bereit"
|
||||
: "border-anthrazit/30 bg-anthrazit/10 text-anthrazit"
|
||||
}
|
||||
>
|
||||
{u.aktiv ? de.verwaltung.aktiv : de.verwaltung.inaktiv}
|
||||
</Badge>
|
||||
{u.aktiv ? (
|
||||
<DeactivateUserButton
|
||||
userId={u.id}
|
||||
disabled={u.id === s.user.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/app/(app)/verwaltung/fahrzeuge/[id]/VehicleControls.tsx
Normal file
93
src/app/(app)/verwaltung/fahrzeuge/[id]/VehicleControls.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { assetStatusEnum } from "@/db/schema";
|
||||
import {
|
||||
setVehicleStatus,
|
||||
deleteVehicle,
|
||||
} from "@/server/actions/vehicles";
|
||||
|
||||
type Status = (typeof assetStatusEnum.enumValues)[number];
|
||||
|
||||
const STATUS_LABEL: Record<Status, string> = {
|
||||
einsatzbereit: de.status.einsatzbereit,
|
||||
wartung: de.status.wartung,
|
||||
ausser_dienst: de.status.ausser_dienst,
|
||||
};
|
||||
|
||||
/**
|
||||
* Status-Umschaltung + Löschen eines Fahrzeugs (eigene Wehr). Beide rufen
|
||||
* geschützte Server-Actions; Scope-Prüfung erfolgt serverseitig.
|
||||
*/
|
||||
export function VehicleControls({
|
||||
vehicleId,
|
||||
status,
|
||||
}: {
|
||||
vehicleId: string;
|
||||
status: Status;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = React.useTransition();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
function onStatus(next: Status) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await setVehicleStatus({ id: vehicleId, status: next });
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!window.confirm(de.verwaltung.loeschenBestaetigen)) return;
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await deleteVehicle({ id: vehicleId });
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
router.push("/verwaltung/fahrzeuge");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded border border-rand bg-white p-4">
|
||||
<label className="text-sm font-medium text-anthrazit" htmlFor="vstatus">
|
||||
{de.verwaltung.status}
|
||||
</label>
|
||||
<select
|
||||
id="vstatus"
|
||||
className="h-9 rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
|
||||
value={status}
|
||||
disabled={pending}
|
||||
onChange={(e) => onStatus(e.target.value as Status)}
|
||||
>
|
||||
{assetStatusEnum.enumValues.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{STATUS_LABEL[v]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{de.verwaltung.loeschen}
|
||||
</Button>
|
||||
{error ? <span className="w-full text-sm text-signal">{error}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/app/(app)/verwaltung/fahrzeuge/[id]/page.tsx
Normal file
50
src/app/(app)/verwaltung/fahrzeuge/[id]/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { getVehicleForBrigade } from "@/server/data/vehicles";
|
||||
import {
|
||||
getMerkmaleForTemplate,
|
||||
getMerkmalValuesForEntity,
|
||||
} from "@/server/data/merkmale";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { VehicleForm } from "@/components/verwaltung/VehicleForm";
|
||||
import { VehicleControls } from "./VehicleControls";
|
||||
|
||||
export default async function FahrzeugBearbeitenPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const s = await requireWehrAdmin();
|
||||
const { id } = await params;
|
||||
|
||||
// Scoping: fremde/nicht existente Fahrzeuge -> 404 (kein Daten-Leak).
|
||||
const vehicle = await getVehicleForBrigade(id, s.user.brigadeId);
|
||||
if (!vehicle) notFound();
|
||||
|
||||
const defs = vehicle.templateId
|
||||
? await getMerkmaleForTemplate(vehicle.templateId)
|
||||
: [];
|
||||
const werte = await getMerkmalValuesForEntity("vehicle", vehicle.id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.fahrzeugBearbeiten}
|
||||
</h1>
|
||||
|
||||
<VehicleControls vehicleId={vehicle.id} status={vehicle.status} />
|
||||
|
||||
<VehicleForm
|
||||
mode="edit"
|
||||
vehicleId={vehicle.id}
|
||||
initial={{
|
||||
name: vehicle.name,
|
||||
funkrufname: vehicle.funkrufname ?? "",
|
||||
notiz: vehicle.notiz ?? "",
|
||||
}}
|
||||
definitionen={defs}
|
||||
vorhandeneWerte={werte}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/app/(app)/verwaltung/fahrzeuge/neu/page.tsx
Normal file
23
src/app/(app)/verwaltung/fahrzeuge/neu/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { listTemplates } from "@/server/data/vehicles";
|
||||
import { getTemplateMerkmaleAction } from "@/server/actions/vehicles";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { VehicleForm } from "@/components/verwaltung/VehicleForm";
|
||||
|
||||
export default async function FahrzeugNeuPage() {
|
||||
await requireWehrAdmin();
|
||||
const templates = await listTemplates();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.fahrzeugAnlegen}
|
||||
</h1>
|
||||
<VehicleForm
|
||||
mode="create"
|
||||
templates={templates}
|
||||
loadTemplateMerkmale={getTemplateMerkmaleAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/app/(app)/verwaltung/fahrzeuge/page.tsx
Normal file
55
src/app/(app)/verwaltung/fahrzeuge/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import Link from "next/link";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { listVehiclesForBrigade } from "@/server/data/vehicles";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/ui/badge";
|
||||
|
||||
export default async function FahrzeugeListePage() {
|
||||
const s = await requireWehrAdmin();
|
||||
const items = await listVehiclesForBrigade(s.user.brigadeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.navFahrzeuge}
|
||||
</h1>
|
||||
<Button asChild>
|
||||
<Link href="/verwaltung/fahrzeuge/neu">
|
||||
{de.verwaltung.fahrzeugAnlegen}
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
|
||||
{de.verwaltung.keineFahrzeuge}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-rand rounded border border-rand bg-white">
|
||||
{items.map((v) => (
|
||||
<li
|
||||
key={v.id}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/verwaltung/fahrzeuge/${v.id}`}
|
||||
className="font-medium text-navy hover:underline"
|
||||
>
|
||||
{v.name}
|
||||
</Link>
|
||||
<p className="truncate text-sm text-anthrazit/60">
|
||||
{v.funkrufname ?? de.search.keinFunkrufname}
|
||||
{v.templateName ? ` · ${v.templateName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={v.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/app/(app)/verwaltung/geraete/[id]/EquipmentControls.tsx
Normal file
89
src/app/(app)/verwaltung/geraete/[id]/EquipmentControls.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { assetStatusEnum } from "@/db/schema";
|
||||
import {
|
||||
setEquipmentStatus,
|
||||
deleteEquipment,
|
||||
} from "@/server/actions/equipment";
|
||||
|
||||
type Status = (typeof assetStatusEnum.enumValues)[number];
|
||||
|
||||
const STATUS_LABEL: Record<Status, string> = {
|
||||
einsatzbereit: de.status.einsatzbereit,
|
||||
wartung: de.status.wartung,
|
||||
ausser_dienst: de.status.ausser_dienst,
|
||||
};
|
||||
|
||||
export function EquipmentControls({
|
||||
equipmentId,
|
||||
status,
|
||||
}: {
|
||||
equipmentId: string;
|
||||
status: Status;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = React.useTransition();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
function onStatus(next: Status) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await setEquipmentStatus({ id: equipmentId, status: next });
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (!window.confirm(de.verwaltung.loeschenBestaetigen)) return;
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await deleteEquipment({ id: equipmentId });
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
router.push("/verwaltung/geraete");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded border border-rand bg-white p-4">
|
||||
<label className="text-sm font-medium text-anthrazit" htmlFor="estatus">
|
||||
{de.verwaltung.status}
|
||||
</label>
|
||||
<select
|
||||
id="estatus"
|
||||
className="h-9 rounded border border-rand bg-white px-3 text-sm text-anthrazit focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy"
|
||||
value={status}
|
||||
disabled={pending}
|
||||
onChange={(e) => onStatus(e.target.value as Status)}
|
||||
>
|
||||
{assetStatusEnum.enumValues.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{STATUS_LABEL[v]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{de.verwaltung.loeschen}
|
||||
</Button>
|
||||
{error ? <span className="w-full text-sm text-signal">{error}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/app/(app)/verwaltung/geraete/[id]/page.tsx
Normal file
60
src/app/(app)/verwaltung/geraete/[id]/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import {
|
||||
getEquipmentForBrigade,
|
||||
listCategories,
|
||||
} from "@/server/data/equipment";
|
||||
import { listVehiclesForBrigade } from "@/server/data/vehicles";
|
||||
import {
|
||||
getMerkmaleForCategory,
|
||||
getMerkmalValuesForEntity,
|
||||
} from "@/server/data/merkmale";
|
||||
import { getCategoryMerkmaleAction } from "@/server/actions/equipment";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { EquipmentForm } from "@/components/verwaltung/EquipmentForm";
|
||||
import { EquipmentControls } from "./EquipmentControls";
|
||||
|
||||
export default async function GeraetBearbeitenPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const s = await requireWehrAdmin();
|
||||
const { id } = await params;
|
||||
|
||||
// Scoping: fremde/nicht existente Geräte -> 404.
|
||||
const item = await getEquipmentForBrigade(id, s.user.brigadeId);
|
||||
if (!item) notFound();
|
||||
|
||||
const [categories, vehicles, defs, werte] = await Promise.all([
|
||||
listCategories(),
|
||||
listVehiclesForBrigade(s.user.brigadeId),
|
||||
getMerkmaleForCategory(item.categoryId),
|
||||
getMerkmalValuesForEntity("equipment", item.id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.geraetBearbeiten}
|
||||
</h1>
|
||||
|
||||
<EquipmentControls equipmentId={item.id} status={item.status} />
|
||||
|
||||
<EquipmentForm
|
||||
mode="edit"
|
||||
equipmentId={item.id}
|
||||
categories={categories}
|
||||
vehicles={vehicles.map((v) => ({ id: v.id, name: v.name }))}
|
||||
initial={{
|
||||
name: item.name,
|
||||
categoryId: item.categoryId,
|
||||
vehicleId: item.vehicleId ?? "",
|
||||
}}
|
||||
definitionen={defs}
|
||||
vorhandeneWerte={werte}
|
||||
loadCategoryMerkmale={getCategoryMerkmaleAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/(app)/verwaltung/geraete/neu/page.tsx
Normal file
28
src/app/(app)/verwaltung/geraete/neu/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { listCategories } from "@/server/data/equipment";
|
||||
import { listVehiclesForBrigade } from "@/server/data/vehicles";
|
||||
import { getCategoryMerkmaleAction } from "@/server/actions/equipment";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { EquipmentForm } from "@/components/verwaltung/EquipmentForm";
|
||||
|
||||
export default async function GeraetNeuPage() {
|
||||
const s = await requireWehrAdmin();
|
||||
const [categories, vehicles] = await Promise.all([
|
||||
listCategories(),
|
||||
listVehiclesForBrigade(s.user.brigadeId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.geraetAnlegen}
|
||||
</h1>
|
||||
<EquipmentForm
|
||||
mode="create"
|
||||
categories={categories}
|
||||
vehicles={vehicles.map((v) => ({ id: v.id, name: v.name }))}
|
||||
loadCategoryMerkmale={getCategoryMerkmaleAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/app/(app)/verwaltung/geraete/page.tsx
Normal file
60
src/app/(app)/verwaltung/geraete/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import Link from "next/link";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { listEquipmentForBrigade } from "@/server/data/equipment";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge, Badge } from "@/components/ui/badge";
|
||||
|
||||
export default async function GeraeteListePage() {
|
||||
const s = await requireWehrAdmin();
|
||||
const items = await listEquipmentForBrigade(s.user.brigadeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.navGeraete}
|
||||
</h1>
|
||||
<Button asChild>
|
||||
<Link href="/verwaltung/geraete/neu">
|
||||
{de.verwaltung.geraetAnlegen}
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="rounded border border-rand bg-white p-6 text-sm text-anthrazit/60">
|
||||
{de.verwaltung.keineGeraete}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-rand rounded border border-rand bg-white">
|
||||
{items.map((e) => (
|
||||
<li
|
||||
key={e.id}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/verwaltung/geraete/${e.id}`}
|
||||
className="font-medium text-navy hover:underline"
|
||||
>
|
||||
{e.name}
|
||||
</Link>
|
||||
<p className="truncate text-sm text-anthrazit/60">
|
||||
{e.categoryName} ·{" "}
|
||||
{e.vehicleName ?? de.verwaltung.imGeraetehaus}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{e.vehicleId ? null : (
|
||||
<Badge>{de.verwaltung.imGeraetehaus}</Badge>
|
||||
)}
|
||||
<StatusBadge status={e.status} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/app/(app)/verwaltung/layout.tsx
Normal file
25
src/app/(app)/verwaltung/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { VerwaltungNav } from "@/components/verwaltung/VerwaltungNav";
|
||||
|
||||
/**
|
||||
* Route-Group-Layout des Wehr-Bereichs.
|
||||
*
|
||||
* GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1+2): Der
|
||||
* serverseitige Guard `requireWehrAdmin()` ist die ALLERERSTE Anweisung. Er
|
||||
* leitet anonyme Aufrufe auf /login (redirect) um und verweigert allen außer
|
||||
* `wehr_admin` (auch `wehr_read`) mit forbidden() -> 403. Jede Server Action
|
||||
* wiederholt den Guard zusätzlich (Verteidigung in der Tiefe).
|
||||
*/
|
||||
export default async function VerwaltungLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
await requireWehrAdmin();
|
||||
return (
|
||||
<div>
|
||||
<VerwaltungNav />
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/app/(app)/verwaltung/profil/page.tsx
Normal file
33
src/app/(app)/verwaltung/profil/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { getBrigade } from "@/server/data/vehicles";
|
||||
import { de } from "@/lib/i18n/de";
|
||||
import { BrigadeProfileForm } from "@/components/verwaltung/BrigadeProfileForm";
|
||||
|
||||
export default async function ProfilPage() {
|
||||
const s = await requireWehrAdmin();
|
||||
const b = await getBrigade(s.user.brigadeId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="font-display text-2xl font-semibold text-navy">
|
||||
{de.verwaltung.profilTitel}
|
||||
</h1>
|
||||
{b ? (
|
||||
<p className="mt-1 text-sm text-anthrazit/70">{b.name}</p>
|
||||
) : null}
|
||||
</header>
|
||||
<BrigadeProfileForm
|
||||
initial={{
|
||||
strasse: b?.strasse ?? "",
|
||||
plz: b?.plz ?? "",
|
||||
ort: b?.ort ?? "",
|
||||
telefon: b?.telefon ?? "",
|
||||
email: b?.email ?? "",
|
||||
wehrfuehrer: b?.wehrfuehrer ?? "",
|
||||
funkrufnameSchema: b?.funkrufnameSchema ?? "",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/app/(app)/wehren/[id]/page.tsx
Normal file
106
src/app/(app)/wehren/[id]/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { requireSession } from "@/lib/auth/guards";
|
||||
import { getWehrDetail } from "@/lib/detail/queries";
|
||||
import { uuidSchema } from "@/lib/validation/common";
|
||||
import { DetailHeader } from "@/components/detail/DetailHeader";
|
||||
import { KontaktButton } from "@/components/kontakt/KontaktButton";
|
||||
import { StatusBadge } from "@/components/ui/badge";
|
||||
import { t } from "@/lib/i18n/de";
|
||||
|
||||
export default async function WehrDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
// Default-deny in der Tiefe (Querschnittsstandard 1): Guard als erste Zeile.
|
||||
await requireSession();
|
||||
const { id } = await params;
|
||||
// Route-Param ist Nutzereingabe an der Grenze (Querschnittsstandard 4):
|
||||
// nicht-UUID -> saubere deutsche 404 statt Postgres `invalid input syntax`.
|
||||
const parsed = uuidSchema.safeParse(id);
|
||||
if (!parsed.success) notFound();
|
||||
const w = await getWehrDetail(parsed.data);
|
||||
if (!w) notFound();
|
||||
|
||||
const adresse = [w.strasse, [w.plz, w.ort].filter(Boolean).join(" ")]
|
||||
.filter((s) => s && s.trim() !== "")
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<DetailHeader kicker={w.art} titel={w.name} untertitel={adresse || null} />
|
||||
|
||||
<section className="rounded-md border border-rand bg-nebel/50 p-4">
|
||||
<h2 className="text-sm font-semibold text-navy">{t("kontakt.titel")}</h2>
|
||||
{w.wehrfuehrer ? (
|
||||
<p className="mt-1 text-sm text-anthrazit/70">
|
||||
{t("wehr.wehrfuehrer")}: {w.wehrfuehrer}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<KontaktButton
|
||||
telefon={w.telefon}
|
||||
email={w.email}
|
||||
subject={t("kontakt.betreff")}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-navy">{t("detail.fahrzeuge")}</h2>
|
||||
{w.fahrzeuge.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-anthrazit/60">
|
||||
{t("detail.keineFahrzeuge")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-rand/60">
|
||||
{w.fahrzeuge.map((f) => (
|
||||
<li key={f.id} className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/fahrzeuge/${f.id}`}
|
||||
className="font-medium text-navy hover:underline"
|
||||
>
|
||||
{f.name}
|
||||
</Link>
|
||||
{f.funkrufname ? (
|
||||
<p className="truncate text-xs text-anthrazit/60">
|
||||
{f.funkrufname}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusBadge status={f.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-navy">
|
||||
{t("detail.geraeteImHaus")}
|
||||
</h2>
|
||||
{w.geraeteImHaus.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-anthrazit/60">
|
||||
{t("detail.keineGeraeteImHaus")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-rand/60">
|
||||
{w.geraeteImHaus.map((g) => (
|
||||
<li key={g.id} className="flex items-center justify-between gap-3 py-2">
|
||||
<Link
|
||||
href={`/geraete/${g.id}`}
|
||||
className="font-medium text-navy hover:underline"
|
||||
>
|
||||
{g.name}
|
||||
</Link>
|
||||
<StatusBadge status={g.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 hash = await hashPassword(temp);
|
||||
await db.transaction(async (tx) => {
|
||||
const [u] = await tx
|
||||
.select({ authTyp: users.authTyp })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
if (!u || u.authTyp !== "local") {
|
||||
throw new Error("Nur lokale Konten können zurückgesetzt werden.");
|
||||
}
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ passwortHash: hash })
|
||||
|
||||
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",
|
||||
beladung: "Beladung",
|
||||
keineEckdaten: "Keine Eckdaten erfasst.",
|
||||
keineBeladung: "Keine Beladung zugeordnet.",
|
||||
imGeraetehaus: "im Gerätehaus",
|
||||
leerWert: "–",
|
||||
ja: "Ja",
|
||||
nein: "Nein",
|
||||
zugeordnetesFahrzeug: "Zugeordnetes Fahrzeug",
|
||||
kategorie: "Kategorie",
|
||||
fahrzeuge: "Fahrzeuge",
|
||||
keineFahrzeuge: "Keine Fahrzeuge erfasst.",
|
||||
geraeteImHaus: "Geräte im Gerätehaus",
|
||||
keineGeraeteImHaus: "Keine Geräte im Gerätehaus.",
|
||||
nichtGefunden: "Nicht gefunden.",
|
||||
},
|
||||
kontakt: {
|
||||
titel: "Kontakt",
|
||||
anrufen: "Anrufen",
|
||||
email: "E-Mail schreiben",
|
||||
keine: "Keine Kontaktdaten hinterlegt.",
|
||||
betreff: "FlorianNetz – Anfrage",
|
||||
},
|
||||
wehr: {
|
||||
wehrfuehrer: "Wehrführer",
|
||||
adresse: "Adresse",
|
||||
},
|
||||
fehler: {
|
||||
allgemein: "Es ist ein Fehler aufgetreten.",
|
||||
@@ -125,6 +147,63 @@ export const de = {
|
||||
zurueck: "Zurück",
|
||||
weiter: "Weiter",
|
||||
},
|
||||
verwaltung: {
|
||||
titel: "Verwaltung",
|
||||
navProfil: "Profil",
|
||||
navFahrzeuge: "Fahrzeuge",
|
||||
navGeraete: "Geräte",
|
||||
navBenutzer: "Benutzer",
|
||||
speichern: "Speichern",
|
||||
abbrechen: "Abbrechen",
|
||||
loeschen: "Löschen",
|
||||
anlegen: "Anlegen",
|
||||
bearbeiten: "Bearbeiten",
|
||||
neu: "Neu",
|
||||
name: "Name",
|
||||
funkrufname: "Funkrufname",
|
||||
notiz: "Notiz",
|
||||
status: "Status",
|
||||
kategorie: "Kategorie",
|
||||
zuordnung: "Zuordnung",
|
||||
imGeraetehaus: "im Gerätehaus",
|
||||
vorlage: "Fahrzeug-Vorlage",
|
||||
keineVorlage: "Ohne Vorlage (frei)",
|
||||
vorlageWaehlen: "Vorlage wählen",
|
||||
merkmale: "Merkmale",
|
||||
keineMerkmale: "Für diese Auswahl sind keine Merkmale hinterlegt.",
|
||||
fahrzeugAnlegen: "Fahrzeug anlegen",
|
||||
fahrzeugBearbeiten: "Fahrzeug bearbeiten",
|
||||
geraetAnlegen: "Gerät anlegen",
|
||||
geraetBearbeiten: "Gerät bearbeiten",
|
||||
keineFahrzeuge: "Noch keine Fahrzeuge erfasst.",
|
||||
keineGeraete: "Noch keine Geräte erfasst.",
|
||||
keineBenutzer: "Noch keine Benutzer erfasst.",
|
||||
profilTitel: "Wehr-Profil",
|
||||
profilGespeichert: "Profil gespeichert.",
|
||||
geocodeOk: "Adresse geokodiert.",
|
||||
geocodeWarnung:
|
||||
"Adresse konnte nicht geokodiert werden. Daten wurden dennoch gespeichert.",
|
||||
strasse: "Straße",
|
||||
plz: "PLZ",
|
||||
ort: "Ort",
|
||||
email: "E-Mail",
|
||||
telefon: "Telefon",
|
||||
wehrfuehrer: "Wehrführer",
|
||||
funkrufnameSchema: "Funkrufname-Schema",
|
||||
rolle: "Rolle",
|
||||
rolleAdmin: "Wehr-Admin",
|
||||
rolleRead: "Lesend",
|
||||
benutzerAnlegen: "Benutzer anlegen",
|
||||
deaktivieren: "Deaktivieren",
|
||||
aktiv: "aktiv",
|
||||
inaktiv: "inaktiv",
|
||||
authLokal: "lokal",
|
||||
authAuthentik: "Authentik",
|
||||
tempPasswort:
|
||||
"Einmal-Passwort (nur jetzt sichtbar, bitte sicher übergeben):",
|
||||
loeschenBestaetigen: "Wirklich löschen?",
|
||||
pflichtfeld: "Pflichtfeld",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type Leaf = string;
|
||||
|
||||
49
src/lib/merkmale/types.ts
Normal file
49
src/lib/merkmale/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Geteilte Typen für den typisierten Merkmal-Wert-Editor (Workstream 7).
|
||||
*
|
||||
* REIN: keine DB-/IO-Abhängigkeit, damit Validierung und Editor ohne laufendes
|
||||
* Postgres testbar sind. Die DB-Schreibseite (`upsertMerkmalValues`) konsumiert
|
||||
* `MerkmalValueInput`.
|
||||
*/
|
||||
|
||||
import type { merkmalTypEnum } from "@/db/schema";
|
||||
|
||||
/** Fachlicher Merkmal-Typ (Single Source of Truth: DB-Enum `merkmal_typ`). */
|
||||
export type MerkmalTyp = (typeof merkmalTypEnum.enumValues)[number];
|
||||
|
||||
/** Eine Auswahloption eines `enum`-Merkmals. */
|
||||
export interface MerkmalOption {
|
||||
wert: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein zur Bearbeitung angebotenes Merkmal (aus Vorlage oder Kategorie
|
||||
* aufgelöst). `pflicht` markiert Vorlagen-Pflichtfelder. `vorgabe*` sind die
|
||||
* typgerecht vorbefüllten Standardwerte (drei Spalten, exakt einer passt zum
|
||||
* `typ`).
|
||||
*/
|
||||
export interface MerkmalDefinition {
|
||||
merkmalId: string;
|
||||
name: string;
|
||||
typ: MerkmalTyp;
|
||||
einheit: string | null;
|
||||
pflicht: boolean;
|
||||
reihenfolge: number;
|
||||
optionen: MerkmalOption[];
|
||||
vorgabeNum: number | null;
|
||||
vorgabeText: string | null;
|
||||
vorgabeBool: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein einzelner, an der Server-Grenze validierter Merkmal-Wert. Genau eine der
|
||||
* Wertspalten ist (typabhängig) gesetzt; alle `null`/leer => der Wert wird
|
||||
* beim Upsert gelöscht (kein Eintrag).
|
||||
*/
|
||||
export interface MerkmalValueInput {
|
||||
merkmalId: string;
|
||||
num?: number | null;
|
||||
text?: string | null;
|
||||
bool?: boolean | null;
|
||||
}
|
||||
48
src/lib/security/headers.test.ts
Normal file
48
src/lib/security/headers.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SECURITY_HEADERS } from "./headers";
|
||||
|
||||
/**
|
||||
* Security-Header (Definition of Done #8, Querschnittsstandard 1). Offline
|
||||
* lauffähig: prüft den statischen Header-Satz, der in next.config.ts
|
||||
* eingehängt wird. Die HTTP-seitige Verifikation (curl gegen Live-Server) ist
|
||||
* deferred (kein Server in der Sandbox) und in security-headers.spec.ts gegen
|
||||
* einen laufenden Server abgedeckt.
|
||||
*/
|
||||
describe("SECURITY_HEADERS", () => {
|
||||
it("setzt X-Frame-Options auf DENY", () => {
|
||||
expect(SECURITY_HEADERS["X-Frame-Options"]).toBe("DENY");
|
||||
});
|
||||
|
||||
it("setzt X-Content-Type-Options auf nosniff", () => {
|
||||
expect(SECURITY_HEADERS["X-Content-Type-Options"]).toBe("nosniff");
|
||||
});
|
||||
|
||||
it("setzt HSTS mit includeSubDomains", () => {
|
||||
const hsts = SECURITY_HEADERS["Strict-Transport-Security"];
|
||||
expect(hsts).toMatch(/max-age=\d+/);
|
||||
expect(hsts).toContain("includeSubDomains");
|
||||
});
|
||||
|
||||
it("erlaubt Geolocation nur für self (Permissions-Policy)", () => {
|
||||
expect(SECURITY_HEADERS["Permissions-Policy"]).toContain(
|
||||
"geolocation=(self)",
|
||||
);
|
||||
});
|
||||
|
||||
it("hat eine CSP mit default-src 'self', frame-ancestors 'none', form-action 'self'", () => {
|
||||
const csp = SECURITY_HEADERS["Content-Security-Policy"];
|
||||
expect(csp).toContain("default-src 'self'");
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
expect(csp).toContain("form-action 'self'");
|
||||
});
|
||||
|
||||
it("erlaubt img-src self/data/blob und worker-src self/blob (für Karten/Web-Worker)", () => {
|
||||
const csp = SECURITY_HEADERS["Content-Security-Policy"];
|
||||
expect(csp).toContain("img-src 'self' data: blob:");
|
||||
expect(csp).toContain("worker-src 'self' blob:");
|
||||
});
|
||||
|
||||
it("setzt eine Referrer-Policy", () => {
|
||||
expect(SECURITY_HEADERS["Referrer-Policy"]).toBeTruthy();
|
||||
});
|
||||
});
|
||||
37
src/lib/validation/__tests__/brigade-user.test.ts
Normal file
37
src/lib/validation/__tests__/brigade-user.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { brigadeUserCreateSchema, brigadeUserDeactivateSchema } from "../brigade-user";
|
||||
|
||||
describe("brigadeUserCreateSchema", () => {
|
||||
const valid = {
|
||||
email: "Neu@FF.at",
|
||||
name: "Neue Person",
|
||||
rolle: "wehr_read",
|
||||
};
|
||||
|
||||
it("akzeptiert wehr_admin und wehr_read und normalisiert die E-Mail", () => {
|
||||
const r = brigadeUserCreateSchema.safeParse(valid);
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.email).toBe("neu@ff.at");
|
||||
expect(brigadeUserCreateSchema.safeParse({ ...valid, rolle: "wehr_admin" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("lehnt platform_admin ab (nicht zuweisbar durch Wehr-Admin)", () => {
|
||||
expect(
|
||||
brigadeUserCreateSchema.safeParse({ ...valid, rolle: "platform_admin" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("verlangt Name und gültige E-Mail", () => {
|
||||
expect(brigadeUserCreateSchema.safeParse({ ...valid, name: "" }).success).toBe(false);
|
||||
expect(brigadeUserCreateSchema.safeParse({ ...valid, email: "keine-mail" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("brigadeUserDeactivateSchema", () => {
|
||||
it("verlangt eine gültige UUID", () => {
|
||||
expect(
|
||||
brigadeUserDeactivateSchema.safeParse({ userId: "11111111-1111-1111-1111-111111111111" }).success,
|
||||
).toBe(true);
|
||||
expect(brigadeUserDeactivateSchema.safeParse({ userId: "x" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
34
src/lib/validation/__tests__/equipment.test.ts
Normal file
34
src/lib/validation/__tests__/equipment.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
equipmentBaseSchema,
|
||||
equipmentStatusSchema,
|
||||
} from "../equipment";
|
||||
|
||||
const CAT = "11111111-1111-1111-1111-111111111111";
|
||||
const VEH = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
describe("equipmentBaseSchema", () => {
|
||||
it("verlangt Name und Kategorie", () => {
|
||||
expect(equipmentBaseSchema.safeParse({ name: "", categoryId: CAT }).success).toBe(false);
|
||||
expect(equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: "x" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("erlaubt leere vehicleId (im Gerätehaus) -> undefined", () => {
|
||||
const r = equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: CAT, vehicleId: "" });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.vehicleId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("akzeptiert eine gültige vehicleId (Zuordnung)", () => {
|
||||
const r = equipmentBaseSchema.safeParse({ name: "Pumpe", categoryId: CAT, vehicleId: VEH });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.vehicleId).toBe(VEH);
|
||||
});
|
||||
});
|
||||
|
||||
describe("equipmentStatusSchema", () => {
|
||||
it("akzeptiert nur asset_status-Werte", () => {
|
||||
expect(equipmentStatusSchema.safeParse({ id: CAT, status: "ausser_dienst" }).success).toBe(true);
|
||||
expect(equipmentStatusSchema.safeParse({ id: CAT, status: "weg" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
152
src/lib/validation/__tests__/vehicle.test.ts
Normal file
152
src/lib/validation/__tests__/vehicle.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
vehicleBaseSchema,
|
||||
vehicleStatusSchema,
|
||||
buildMerkmalValuesSchema,
|
||||
} from "../vehicle";
|
||||
import type { MerkmalDefinition } from "@/lib/merkmale/types";
|
||||
|
||||
const def = (over: Partial<MerkmalDefinition>): MerkmalDefinition => ({
|
||||
merkmalId: "11111111-1111-1111-1111-111111111111",
|
||||
name: "Merkmal",
|
||||
typ: "text",
|
||||
einheit: null,
|
||||
pflicht: false,
|
||||
reihenfolge: 0,
|
||||
optionen: [],
|
||||
vorgabeNum: null,
|
||||
vorgabeText: null,
|
||||
vorgabeBool: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("vehicleBaseSchema", () => {
|
||||
it("verlangt einen Namen", () => {
|
||||
const r = vehicleBaseSchema.safeParse({ name: "", funkrufname: "", notiz: "" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("akzeptiert Name mit leeren Optionalfeldern (-> undefined)", () => {
|
||||
const r = vehicleBaseSchema.safeParse({
|
||||
name: "HLF 2 Musterdorf",
|
||||
funkrufname: "",
|
||||
notiz: "",
|
||||
templateId: "",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.funkrufname).toBeUndefined();
|
||||
expect(r.data.templateId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("akzeptiert eine gültige templateId (uuid)", () => {
|
||||
const r = vehicleBaseSchema.safeParse({
|
||||
name: "HLF 2",
|
||||
templateId: "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vehicleStatusSchema", () => {
|
||||
it("akzeptiert nur asset_status-Werte", () => {
|
||||
expect(vehicleStatusSchema.safeParse({ id: "33333333-3333-3333-3333-333333333333", status: "wartung" }).success).toBe(true);
|
||||
expect(vehicleStatusSchema.safeParse({ id: "33333333-3333-3333-3333-333333333333", status: "kaputt" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMerkmalValuesSchema", () => {
|
||||
it("validiert number-Merkmale typgerecht und coerced Strings", () => {
|
||||
const schema = buildMerkmalValuesSchema([
|
||||
def({ typ: "number", name: "Löschwassertank", einheit: "l" }),
|
||||
]);
|
||||
const r = schema.safeParse([
|
||||
{ merkmalId: def({}).merkmalId, num: "2000" },
|
||||
]);
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data[0]?.num).toBe(2000);
|
||||
});
|
||||
|
||||
it("lehnt nicht-numerische Eingabe für number ab", () => {
|
||||
const schema = buildMerkmalValuesSchema([def({ typ: "number" })]);
|
||||
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: "abc" }]);
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("erzwingt Pflichtmerkmale (number ohne Wert -> Fehler)", () => {
|
||||
const schema = buildMerkmalValuesSchema([def({ typ: "number", pflicht: true })]);
|
||||
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: null }]);
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("erlaubt optionale Merkmale ohne Wert", () => {
|
||||
const schema = buildMerkmalValuesSchema([def({ typ: "number", pflicht: false })]);
|
||||
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, num: null }]);
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("beschränkt enum-Werte auf erlaubte Optionen", () => {
|
||||
const schema = buildMerkmalValuesSchema([
|
||||
def({ typ: "enum", optionen: [{ wert: "TS", label: "Tragkraftspritze" }] }),
|
||||
]);
|
||||
expect(schema.safeParse([{ merkmalId: def({}).merkmalId, text: "TS" }]).success).toBe(true);
|
||||
expect(schema.safeParse([{ merkmalId: def({}).merkmalId, text: "XX" }]).success).toBe(false);
|
||||
});
|
||||
|
||||
it("coerced boolean-Merkmale", () => {
|
||||
const schema = buildMerkmalValuesSchema([def({ typ: "boolean", name: "Allradantrieb" })]);
|
||||
const r = schema.safeParse([{ merkmalId: def({}).merkmalId, bool: true }]);
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data[0]?.bool).toBe(true);
|
||||
});
|
||||
|
||||
it("ignoriert Werte zu unbekannten Merkmalen (nur erlaubte merkmalIds)", () => {
|
||||
const schema = buildMerkmalValuesSchema([def({ typ: "text" })]);
|
||||
const r = schema.safeParse([
|
||||
{ merkmalId: "99999999-9999-9999-9999-999999999999", text: "hallo" },
|
||||
]);
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("erzwingt Pflichtmerkmale auch bei vollständiger Auslassung (leeres Array)", () => {
|
||||
const schema = buildMerkmalValuesSchema([
|
||||
def({ typ: "number", pflicht: true, name: "Löschwassertank" }),
|
||||
]);
|
||||
// Pflichtmerkmal fehlt komplett -> Validierung muss greifen (nicht trauen).
|
||||
expect(schema.safeParse([]).success).toBe(false);
|
||||
});
|
||||
|
||||
it("erzwingt fehlende Pflichtmerkmale bei teilweise befülltem Array", () => {
|
||||
const idA = "11111111-1111-1111-1111-11111111aaaa";
|
||||
const idB = "11111111-1111-1111-1111-11111111bbbb";
|
||||
const schema = buildMerkmalValuesSchema([
|
||||
def({ merkmalId: idA, typ: "text", pflicht: true, name: "A" }),
|
||||
def({ merkmalId: idB, typ: "boolean", pflicht: true, name: "B" }),
|
||||
]);
|
||||
// Nur A geliefert, Pflicht-B fehlt komplett.
|
||||
const r = schema.safeParse([{ merkmalId: idA, text: "x" }]);
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("akzeptiert ein leeres Array, wenn keine Pflichtmerkmale definiert sind", () => {
|
||||
const schema = buildMerkmalValuesSchema([def({ typ: "text", pflicht: false })]);
|
||||
expect(schema.safeParse([]).success).toBe(true);
|
||||
});
|
||||
|
||||
it("akzeptiert vollständig gesetzte Pflichtmerkmale", () => {
|
||||
const idA = "11111111-1111-1111-1111-11111111aaaa";
|
||||
const schema = buildMerkmalValuesSchema([
|
||||
def({ merkmalId: idA, typ: "text", pflicht: true, name: "A" }),
|
||||
]);
|
||||
expect(schema.safeParse([{ merkmalId: idA, text: "x" }]).success).toBe(true);
|
||||
});
|
||||
|
||||
it("lehnt ein vorhandenes, aber leeres Pflichtmerkmal weiterhin ab", () => {
|
||||
const idA = "11111111-1111-1111-1111-11111111aaaa";
|
||||
const schema = buildMerkmalValuesSchema([
|
||||
def({ merkmalId: idA, typ: "text", pflicht: true, name: "A" }),
|
||||
]);
|
||||
expect(schema.safeParse([{ merkmalId: idA, text: "" }]).success).toBe(false);
|
||||
});
|
||||
});
|
||||
35
src/lib/validation/brigade-user.ts
Normal file
35
src/lib/validation/brigade-user.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
import { uuidSchema } from "./common";
|
||||
|
||||
/**
|
||||
* Zod-Schemas für die Benutzerverwaltung im Wehr-Bereich (Workstream 7).
|
||||
*
|
||||
* WICHTIG (Sicherheit): Die Rolle ist auf `wehr_admin | wehr_read` beschränkt.
|
||||
* Ein Wehr-Admin darf NIEMALS `platform_admin` vergeben — Zod lehnt das an der
|
||||
* Grenze ab (Querschnittsstandard 4, Verteidigung in der Tiefe zusätzlich zum
|
||||
* serverseitigen Scope-Guard).
|
||||
*/
|
||||
|
||||
export const brigadeUserRoleSchema = z.enum(["wehr_admin", "wehr_read"], {
|
||||
errorMap: () => ({ message: "Unzulässige Rolle." }),
|
||||
});
|
||||
|
||||
export const brigadeUserCreateSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.email({ message: "Ungültige E-Mail." })
|
||||
.transform((v) => v.toLowerCase()),
|
||||
name: z.string().trim().min(1, { message: "Name ist Pflicht." }),
|
||||
rolle: brigadeUserRoleSchema,
|
||||
});
|
||||
|
||||
export type BrigadeUserCreateInput = z.infer<typeof brigadeUserCreateSchema>;
|
||||
|
||||
export const brigadeUserDeactivateSchema = z.object({
|
||||
userId: uuidSchema,
|
||||
});
|
||||
|
||||
export type BrigadeUserDeactivateInput = z.infer<
|
||||
typeof brigadeUserDeactivateSchema
|
||||
>;
|
||||
@@ -38,3 +38,37 @@ export const userResetSchema = z.object({
|
||||
});
|
||||
|
||||
export type UserResetInput = z.infer<typeof userResetSchema>;
|
||||
|
||||
/**
|
||||
* Schema für die Profil-Bearbeitung durch den Wehr-Admin der EIGENEN Wehr
|
||||
* (Workstream 7). Kein `name`/keine Anlage — nur Stamm-/Kontaktdaten. Die
|
||||
* `brigadeId` kommt IMMER serverseitig aus der Session, nie aus dem Input.
|
||||
*/
|
||||
export const brigadeProfileSchema = z.object({
|
||||
strasse: z.string().trim().min(1, { message: "Straße ist Pflicht." }),
|
||||
plz: z.string().trim().min(1, { message: "PLZ ist Pflicht." }),
|
||||
ort: z.string().trim().min(1, { message: "Ort ist Pflicht." }),
|
||||
telefon: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.email({ message: "Ungültige E-Mail." })
|
||||
.optional()
|
||||
.or(z.literal("").transform(() => undefined)),
|
||||
wehrfuehrer: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
funkrufnameSchema: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
});
|
||||
|
||||
export type BrigadeProfileInput = z.infer<typeof brigadeProfileSchema>;
|
||||
|
||||
45
src/lib/validation/equipment.ts
Normal file
45
src/lib/validation/equipment.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import { assetStatusEnum } from "@/db/schema";
|
||||
import { uuidSchema, optionalText } from "./common";
|
||||
|
||||
/**
|
||||
* Zod-Schemas für Geräte/Beladung (Workstream 7). `vehicleId = undefined`
|
||||
* bedeutet „im Gerätehaus" (DB-Spalte NULL). Die Zuordnung darf serverseitig
|
||||
* nur auf ein Fahrzeug DERSELBEN Wehr zeigen (Scope-Prüfung in der Action).
|
||||
* `status` über das DB-Enum `asset_status` (Single Source of Truth).
|
||||
*/
|
||||
|
||||
export const assetStatusSchema = z.enum(assetStatusEnum.enumValues);
|
||||
|
||||
const optionalUuid = z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === undefined || v === "" ? undefined : v))
|
||||
.refine((v) => v === undefined || uuidSchema.safeParse(v).success, {
|
||||
message: "Ungültige ID.",
|
||||
});
|
||||
|
||||
export const equipmentBaseSchema = z.object({
|
||||
name: z.string().trim().min(1, { message: "Gerätename ist Pflicht." }),
|
||||
categoryId: uuidSchema,
|
||||
vehicleId: optionalUuid,
|
||||
notiz: optionalText,
|
||||
});
|
||||
|
||||
export type EquipmentBaseInput = z.infer<typeof equipmentBaseSchema>;
|
||||
|
||||
export const equipmentCreateSchema = equipmentBaseSchema;
|
||||
export const equipmentUpdateSchema = z.object({
|
||||
id: uuidSchema,
|
||||
...equipmentBaseSchema.shape,
|
||||
});
|
||||
export type EquipmentUpdateInput = z.infer<typeof equipmentUpdateSchema>;
|
||||
|
||||
export const equipmentStatusSchema = z.object({
|
||||
id: uuidSchema,
|
||||
status: assetStatusSchema,
|
||||
});
|
||||
export type EquipmentStatusInput = z.infer<typeof equipmentStatusSchema>;
|
||||
|
||||
export const equipmentIdSchema = z.object({ id: uuidSchema });
|
||||
181
src/lib/validation/vehicle.ts
Normal file
181
src/lib/validation/vehicle.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { z } from "zod";
|
||||
import { assetStatusEnum } from "@/db/schema";
|
||||
import { uuidSchema, optionalText } from "./common";
|
||||
import type { MerkmalDefinition } from "@/lib/merkmale/types";
|
||||
|
||||
/**
|
||||
* Zod-Schemas für Fahrzeuge (Workstream 7). `funkrufname` ist eine SPALTE
|
||||
* (kein Merkmal). `status` über das DB-Enum `asset_status` (Single Source of
|
||||
* Truth). Leere Optionalfelder werden zu `undefined` normalisiert.
|
||||
*/
|
||||
|
||||
export const assetStatusSchema = z.enum(assetStatusEnum.enumValues);
|
||||
|
||||
const optionalUuid = z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((v) => (v === undefined || v === "" ? undefined : v))
|
||||
.refine((v) => v === undefined || uuidSchema.safeParse(v).success, {
|
||||
message: "Ungültige ID.",
|
||||
});
|
||||
|
||||
export const vehicleBaseSchema = z.object({
|
||||
name: z.string().trim().min(1, { message: "Fahrzeugname ist Pflicht." }),
|
||||
funkrufname: optionalText,
|
||||
notiz: optionalText,
|
||||
templateId: optionalUuid,
|
||||
});
|
||||
|
||||
export type VehicleBaseInput = z.infer<typeof vehicleBaseSchema>;
|
||||
|
||||
export const vehicleCreateSchema = vehicleBaseSchema;
|
||||
export const vehicleUpdateSchema = z.object({
|
||||
id: uuidSchema,
|
||||
...vehicleBaseSchema.shape,
|
||||
});
|
||||
export type VehicleUpdateInput = z.infer<typeof vehicleUpdateSchema>;
|
||||
|
||||
export const vehicleStatusSchema = z.object({
|
||||
id: uuidSchema,
|
||||
status: assetStatusSchema,
|
||||
});
|
||||
export type VehicleStatusInput = z.infer<typeof vehicleStatusSchema>;
|
||||
|
||||
export const vehicleIdSchema = z.object({ id: uuidSchema });
|
||||
|
||||
/**
|
||||
* Baut ein typgerechtes Zod-Schema für eine Liste von Merkmal-Werten, abgeleitet
|
||||
* aus den angebotenen `MerkmalDefinition`s (Vorlage/Kategorie). Pflichtmerkmale
|
||||
* müssen einen Wert haben; `enum`-Werte sind auf die Optionen beschränkt;
|
||||
* Zahlen werden gecoerced. Werte zu nicht angebotenen `merkmalId`s sind
|
||||
* unzulässig (kein Schmuggeln fremder Merkmale).
|
||||
*
|
||||
* REIN: nimmt Definitionen als Parameter, damit es ohne DB testbar ist.
|
||||
*/
|
||||
export function buildMerkmalValuesSchema(definitionen: MerkmalDefinition[]) {
|
||||
const byId = new Map(definitionen.map((d) => [d.merkmalId, d]));
|
||||
|
||||
const single = z
|
||||
.object({
|
||||
merkmalId: uuidSchema,
|
||||
num: z.unknown().optional(),
|
||||
text: z.unknown().optional(),
|
||||
bool: z.unknown().optional(),
|
||||
})
|
||||
.superRefine((raw, ctx) => {
|
||||
const def = byId.get(raw.merkmalId);
|
||||
if (!def) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["merkmalId"],
|
||||
message: "Unbekanntes Merkmal.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (def.typ === "number") {
|
||||
const v = parseNumber(raw.num);
|
||||
if (v === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["num"],
|
||||
message: `„${def.name}“ muss eine Zahl sein.`,
|
||||
});
|
||||
} else if (v === null && def.pflicht) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["num"],
|
||||
message: `„${def.name}“ ist Pflicht.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (def.typ === "boolean") {
|
||||
const v = parseBool(raw.bool);
|
||||
if (def.pflicht && v == null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["bool"],
|
||||
message: `„${def.name}“ ist Pflicht.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// text | enum
|
||||
const text = parseText(raw.text);
|
||||
if (def.pflicht && (text == null || text === "")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["text"],
|
||||
message: `„${def.name}“ ist Pflicht.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (def.typ === "enum" && text != null && text !== "") {
|
||||
const erlaubt = def.optionen.map((o) => o.wert);
|
||||
if (!erlaubt.includes(text)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["text"],
|
||||
message: `Ungültige Auswahl für „${def.name}“.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.transform((raw) => {
|
||||
const def = byId.get(raw.merkmalId)!;
|
||||
if (def.typ === "number") {
|
||||
const v = parseNumber(raw.num);
|
||||
return { merkmalId: raw.merkmalId, num: v ?? null };
|
||||
}
|
||||
if (def.typ === "boolean") {
|
||||
return { merkmalId: raw.merkmalId, bool: parseBool(raw.bool) };
|
||||
}
|
||||
const text = parseText(raw.text);
|
||||
return { merkmalId: raw.merkmalId, text: text ?? null };
|
||||
});
|
||||
|
||||
return z.array(single).superRefine((werte, ctx) => {
|
||||
// Vollständigkeit auf Array-Ebene: ein Pflichtmerkmal, das komplett fehlt
|
||||
// (kein Element mit gesetztem Wert), wird sonst von der Pro-Element-Prüfung
|
||||
// nicht erfasst. "Validieren, nicht vertrauen" (Querschnittsstandard 4).
|
||||
for (const def of definitionen) {
|
||||
if (!def.pflicht) continue;
|
||||
const hatWert = werte.some((w) => {
|
||||
if (w.merkmalId !== def.merkmalId) return false;
|
||||
if (def.typ === "number") return w.num != null;
|
||||
if (def.typ === "boolean") return w.bool != null;
|
||||
return w.text != null && w.text !== "";
|
||||
});
|
||||
if (!hatWert) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `„${def.name}“ ist Pflicht.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** `undefined` = ungültig (NaN), `null` = leer, sonst die Zahl. */
|
||||
function parseNumber(v: unknown): number | null | undefined {
|
||||
if (v == null || v === "") return null;
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function parseBool(v: unknown): boolean | null {
|
||||
if (v == null || v === "") return null;
|
||||
if (typeof v === "boolean") return v;
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseText(v: unknown): string | null {
|
||||
if (v == null) return null;
|
||||
return typeof v === "string" ? v : String(v);
|
||||
}
|
||||
131
src/server/actions/brigade-users.ts
Normal file
131
src/server/actions/brigade-users.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { generateTempPassword } from "@/lib/admin/provisioning";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import {
|
||||
brigadeUserCreateSchema,
|
||||
brigadeUserDeactivateSchema,
|
||||
} from "@/lib/validation/brigade-user";
|
||||
|
||||
export type CreateUserResult =
|
||||
| { ok: true; tempPassword: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type DeactivateResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Legt einen Benutzer (lokales Konto) der EIGENEN Wehr an. Rolle ist per Zod auf
|
||||
* `wehr_admin|wehr_read` beschränkt (platform_admin wird abgelehnt). Passwort
|
||||
* via argon2id (OWASP-Minima). `brigadeId`/`erstelltVon` kommen IMMER aus der
|
||||
* Session. Audit `user.create`. Liefert das Einmal-Passwort genau einmal.
|
||||
*/
|
||||
export async function createBrigadeUser(
|
||||
input: unknown,
|
||||
): Promise<CreateUserResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = brigadeUserCreateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe.",
|
||||
};
|
||||
}
|
||||
const d = parsed.data;
|
||||
const temp = generateTempPassword();
|
||||
const hash = await hashPassword(temp);
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const [u] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
brigadeId: s.user.brigadeId,
|
||||
rolle: d.rolle,
|
||||
authTyp: "local",
|
||||
email: d.email,
|
||||
name: d.name,
|
||||
passwortHash: hash,
|
||||
aktiv: true,
|
||||
erstelltVon: s.user.id,
|
||||
})
|
||||
.returning({ id: users.id });
|
||||
if (!u) throw new Error("Benutzer konnte nicht angelegt werden.");
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"user.create",
|
||||
"user",
|
||||
u.id,
|
||||
{ rolle: d.rolle, authTyp: "local" },
|
||||
tx,
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
// Eindeutigkeitsverletzung (E-Mail bereits vergeben) o. Ä.
|
||||
const msg =
|
||||
e instanceof Error && /unique|duplicate|users_email/i.test(e.message)
|
||||
? "Diese E-Mail ist bereits vergeben."
|
||||
: "Benutzer konnte nicht angelegt werden.";
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
|
||||
revalidatePath("/verwaltung/benutzer");
|
||||
return { ok: true, tempPassword: temp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deaktiviert einen Benutzer der EIGENEN Wehr. Selbst-Deaktivierung ist
|
||||
* verboten (sonst sperrt sich ein Admin aus). Scope: nur Benutzer derselben
|
||||
* Wehr. Audit `user.deactivate`.
|
||||
*/
|
||||
export async function deactivateBrigadeUser(
|
||||
input: unknown,
|
||||
): Promise<DeactivateResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = brigadeUserDeactivateSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Ungültige ID." };
|
||||
if (parsed.data.userId === s.user.id) {
|
||||
return { ok: false, error: "Sie können sich nicht selbst deaktivieren." };
|
||||
}
|
||||
|
||||
const [target] = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.id, parsed.data.userId),
|
||||
eq(users.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
if (!target) return { ok: false, error: "Benutzer nicht gefunden." };
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ aktiv: false })
|
||||
.where(
|
||||
and(
|
||||
eq(users.id, parsed.data.userId),
|
||||
eq(users.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"user.deactivate",
|
||||
"user",
|
||||
parsed.data.userId,
|
||||
undefined,
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
revalidatePath("/verwaltung/benutzer");
|
||||
return { ok: true };
|
||||
}
|
||||
66
src/server/actions/brigade.ts
Normal file
66
src/server/actions/brigade.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { brigades } from "@/db/schema";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { brigadeProfileSchema } from "@/lib/validation/brigade";
|
||||
import { geocodeAddress } from "@/lib/geo/nominatim";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
|
||||
export type ProfileActionResult =
|
||||
| { ok: true; geocodeWarnung: boolean }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Aktualisiert das Profil der EIGENEN Wehr (Default-deny: Guard zuerst).
|
||||
* `brigadeId` kommt IMMER aus der Session. Geocoding inline via `geocodeAddress`
|
||||
* (lat/lng selbst geschrieben; kein zweiter Geo-Pfad). Audit
|
||||
* `brigade.profile_update`. Nicht geokodierbar => Speichern trotzdem, Warnung
|
||||
* an den Aufrufer (Querschnittsstandard 4/6).
|
||||
*/
|
||||
export async function updateBrigadeProfile(
|
||||
input: unknown,
|
||||
): Promise<ProfileActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = brigadeProfileSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe.",
|
||||
};
|
||||
}
|
||||
const d = parsed.data;
|
||||
const query = `${d.strasse}, ${d.plz} ${d.ort}, Österreich`;
|
||||
const geo = await geocodeAddress(query);
|
||||
const geocodeWarnung = geo.status !== "ok";
|
||||
|
||||
await db
|
||||
.update(brigades)
|
||||
.set({
|
||||
strasse: d.strasse,
|
||||
plz: d.plz,
|
||||
ort: d.ort,
|
||||
telefon: d.telefon ?? null,
|
||||
email: d.email ?? null,
|
||||
wehrfuehrer: d.wehrfuehrer ?? null,
|
||||
funkrufnameSchema: d.funkrufnameSchema ?? null,
|
||||
geocodeQuery: query,
|
||||
geocodedAt: new Date(),
|
||||
...(geo.status === "ok"
|
||||
? { lat: geo.coords.lat, lng: geo.coords.lng, geocodeStatus: "ok" }
|
||||
: { lat: null, lng: null, geocodeStatus: geo.status }),
|
||||
})
|
||||
.where(eq(brigades.id, s.user.brigadeId));
|
||||
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"brigade.profile_update",
|
||||
"brigade",
|
||||
s.user.brigadeId,
|
||||
{ geocodeWarnung },
|
||||
);
|
||||
revalidatePath("/verwaltung/profil");
|
||||
return { ok: true, geocodeWarnung };
|
||||
}
|
||||
197
src/server/actions/equipment.ts
Normal file
197
src/server/actions/equipment.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { equipment } from "@/db/schema";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import {
|
||||
equipmentCreateSchema,
|
||||
equipmentUpdateSchema,
|
||||
equipmentStatusSchema,
|
||||
equipmentIdSchema,
|
||||
} from "@/lib/validation/equipment";
|
||||
import { buildMerkmalValuesSchema } from "@/lib/validation/vehicle";
|
||||
import { getMerkmaleForCategory } from "@/server/data/merkmale";
|
||||
import {
|
||||
getEquipmentForBrigade,
|
||||
} from "@/server/data/equipment";
|
||||
import { vehicleBelongsToBrigade } from "@/server/data/vehicles";
|
||||
import { upsertMerkmalValues } from "@/server/merkmale/upsertValues";
|
||||
import type { MerkmalDefinition } from "@/lib/merkmale/types";
|
||||
|
||||
export type ActionResult =
|
||||
| { ok: true; id: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Liefert die Merkmal-Definitionen einer Geräte-Kategorie (für die
|
||||
* Editor-Vorbefüllung). Guard zuerst (default-deny, auch für Lesen).
|
||||
*/
|
||||
export async function getCategoryMerkmaleAction(
|
||||
categoryId: string,
|
||||
): Promise<MerkmalDefinition[]> {
|
||||
await requireWehrAdmin();
|
||||
if (!categoryId) return [];
|
||||
return getMerkmaleForCategory(categoryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, dass eine optional gewählte `vehicleId` zu EINEM Fahrzeug DERSELBEN
|
||||
* Wehr gehört. `undefined` => „im Gerätehaus" (zulässig). Verhindert die
|
||||
* Zuordnung zu fremden Fahrzeugen (Scoping).
|
||||
*/
|
||||
async function assertVehicleScope(
|
||||
vehicleId: string | undefined,
|
||||
brigadeId: string,
|
||||
): Promise<boolean> {
|
||||
if (!vehicleId) return true;
|
||||
return vehicleBelongsToBrigade(vehicleId, brigadeId);
|
||||
}
|
||||
|
||||
/** Legt ein Gerät der eigenen Wehr an (Guard zuerst, Audit equipment.create). */
|
||||
export async function createEquipment(
|
||||
input: unknown,
|
||||
rawWerte: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = equipmentCreateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||
}
|
||||
const d = parsed.data;
|
||||
if (!(await assertVehicleScope(d.vehicleId, s.user.brigadeId))) {
|
||||
return { ok: false, error: "Fahrzeug nicht gefunden." };
|
||||
}
|
||||
const defs = await getMerkmaleForCategory(d.categoryId);
|
||||
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
|
||||
if (!werteParsed.success) {
|
||||
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
|
||||
}
|
||||
|
||||
const id = await db.transaction(async (tx) => {
|
||||
const [e] = await tx
|
||||
.insert(equipment)
|
||||
.values({
|
||||
brigadeId: s.user.brigadeId,
|
||||
categoryId: d.categoryId,
|
||||
vehicleId: d.vehicleId ?? null,
|
||||
name: d.name,
|
||||
})
|
||||
.returning({ id: equipment.id });
|
||||
if (!e) throw new Error("Gerät konnte nicht angelegt werden.");
|
||||
await upsertMerkmalValues(tx, "equipment", e.id, werteParsed.data);
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"equipment.create",
|
||||
"equipment",
|
||||
e.id,
|
||||
{ categoryId: d.categoryId, zugeordnet: d.vehicleId != null },
|
||||
tx,
|
||||
);
|
||||
return e.id;
|
||||
});
|
||||
|
||||
revalidatePath("/verwaltung/geraete");
|
||||
return { ok: true, id };
|
||||
}
|
||||
|
||||
/** Bearbeitet ein Gerät, NUR wenn es der eigenen Wehr gehört. */
|
||||
export async function updateEquipment(
|
||||
input: unknown,
|
||||
rawWerte: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = equipmentUpdateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||
}
|
||||
const d = parsed.data;
|
||||
const existing = await getEquipmentForBrigade(d.id, s.user.brigadeId);
|
||||
if (!existing) return { ok: false, error: "Gerät nicht gefunden." };
|
||||
if (!(await assertVehicleScope(d.vehicleId, s.user.brigadeId))) {
|
||||
return { ok: false, error: "Fahrzeug nicht gefunden." };
|
||||
}
|
||||
const defs = await getMerkmaleForCategory(d.categoryId);
|
||||
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
|
||||
if (!werteParsed.success) {
|
||||
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(equipment)
|
||||
.set({
|
||||
name: d.name,
|
||||
categoryId: d.categoryId,
|
||||
vehicleId: d.vehicleId ?? null,
|
||||
})
|
||||
.where(
|
||||
and(eq(equipment.id, d.id), eq(equipment.brigadeId, s.user.brigadeId)),
|
||||
);
|
||||
await upsertMerkmalValues(tx, "equipment", d.id, werteParsed.data);
|
||||
await writeAudit(s.user.id, "equipment.update", "equipment", d.id, undefined, tx);
|
||||
});
|
||||
|
||||
revalidatePath("/verwaltung/geraete");
|
||||
revalidatePath(`/verwaltung/geraete/${d.id}`);
|
||||
return { ok: true, id: d.id };
|
||||
}
|
||||
|
||||
/** Setzt den Status eines eigenen Geräts (Audit equipment.status). */
|
||||
export async function setEquipmentStatus(input: unknown): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = equipmentStatusSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Ungültige Eingabe." };
|
||||
const existing = await getEquipmentForBrigade(parsed.data.id, s.user.brigadeId);
|
||||
if (!existing) return { ok: false, error: "Gerät nicht gefunden." };
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(equipment)
|
||||
.set({ status: parsed.data.status })
|
||||
.where(
|
||||
and(
|
||||
eq(equipment.id, parsed.data.id),
|
||||
eq(equipment.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"equipment.status",
|
||||
"equipment",
|
||||
parsed.data.id,
|
||||
{ status: parsed.data.status },
|
||||
tx,
|
||||
);
|
||||
});
|
||||
revalidatePath("/verwaltung/geraete");
|
||||
return { ok: true, id: parsed.data.id };
|
||||
}
|
||||
|
||||
/** Löscht ein eigenes Gerät (Audit equipment.delete). */
|
||||
export async function deleteEquipment(input: unknown): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = equipmentIdSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Ungültige ID." };
|
||||
const existing = await getEquipmentForBrigade(parsed.data.id, s.user.brigadeId);
|
||||
if (!existing) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(equipment)
|
||||
.where(
|
||||
and(
|
||||
eq(equipment.id, parsed.data.id),
|
||||
eq(equipment.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
await writeAudit(s.user.id, "equipment.delete", "equipment", parsed.data.id, undefined, tx);
|
||||
});
|
||||
revalidatePath("/verwaltung/geraete");
|
||||
return { ok: true, id: parsed.data.id };
|
||||
}
|
||||
191
src/server/actions/vehicles.ts
Normal file
191
src/server/actions/vehicles.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { vehicles } from "@/db/schema";
|
||||
import { requireWehrAdmin } from "@/lib/auth/guards";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import {
|
||||
vehicleCreateSchema,
|
||||
vehicleUpdateSchema,
|
||||
vehicleStatusSchema,
|
||||
vehicleIdSchema,
|
||||
buildMerkmalValuesSchema,
|
||||
} from "@/lib/validation/vehicle";
|
||||
import {
|
||||
getMerkmaleForTemplate,
|
||||
} from "@/server/data/merkmale";
|
||||
import { getVehicleForBrigade } from "@/server/data/vehicles";
|
||||
import { upsertMerkmalValues } from "@/server/merkmale/upsertValues";
|
||||
import type { MerkmalDefinition } from "@/lib/merkmale/types";
|
||||
|
||||
export type ActionResult =
|
||||
| { ok: true; id: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Löst die für ein Fahrzeug erlaubten Merkmal-Definitionen serverseitig auf
|
||||
* (NUR aus der Vorlage; ohne Vorlage keine Merkmale). Damit kann der Client
|
||||
* keine fremden Merkmale schmuggeln — die Validierung baut ihr Schema NUR aus
|
||||
* diesen Definitionen.
|
||||
*/
|
||||
async function vehicleMerkmalDefs(
|
||||
templateId: string | undefined,
|
||||
): Promise<MerkmalDefinition[]> {
|
||||
if (!templateId) return [];
|
||||
return getMerkmaleForTemplate(templateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Merkmal-Definitionen einer Vorlage (für die Vorbefüllung des
|
||||
* Editors im Anlage-Formular). Guard zuerst (default-deny), auch für Lesen.
|
||||
*/
|
||||
export async function getTemplateMerkmaleAction(
|
||||
templateId: string,
|
||||
): Promise<MerkmalDefinition[]> {
|
||||
await requireWehrAdmin();
|
||||
if (!templateId) return [];
|
||||
return getMerkmaleForTemplate(templateId);
|
||||
}
|
||||
|
||||
/** Legt ein Fahrzeug der EIGENEN Wehr an (Guard zuerst, Audit vehicle.create). */
|
||||
export async function createVehicle(
|
||||
input: unknown,
|
||||
rawWerte: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = vehicleCreateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||
}
|
||||
const d = parsed.data;
|
||||
const defs = await vehicleMerkmalDefs(d.templateId);
|
||||
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
|
||||
if (!werteParsed.success) {
|
||||
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
|
||||
}
|
||||
|
||||
const id = await db.transaction(async (tx) => {
|
||||
const [v] = await tx
|
||||
.insert(vehicles)
|
||||
.values({
|
||||
brigadeId: s.user.brigadeId,
|
||||
templateId: d.templateId ?? null,
|
||||
name: d.name,
|
||||
funkrufname: d.funkrufname ?? null,
|
||||
notiz: d.notiz ?? null,
|
||||
})
|
||||
.returning({ id: vehicles.id });
|
||||
if (!v) throw new Error("Fahrzeug konnte nicht angelegt werden.");
|
||||
await upsertMerkmalValues(tx, "vehicle", v.id, werteParsed.data);
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"vehicle.create",
|
||||
"vehicle",
|
||||
v.id,
|
||||
{ templateId: d.templateId ?? null },
|
||||
tx,
|
||||
);
|
||||
return v.id;
|
||||
});
|
||||
|
||||
revalidatePath("/verwaltung/fahrzeuge");
|
||||
return { ok: true, id };
|
||||
}
|
||||
|
||||
/** Bearbeitet ein Fahrzeug, NUR wenn es der eigenen Wehr gehört. */
|
||||
export async function updateVehicle(
|
||||
input: unknown,
|
||||
rawWerte: unknown,
|
||||
): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = vehicleUpdateSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||
}
|
||||
const d = parsed.data;
|
||||
const existing = await getVehicleForBrigade(d.id, s.user.brigadeId);
|
||||
if (!existing) return { ok: false, error: "Fahrzeug nicht gefunden." };
|
||||
|
||||
// Vorlage ist nach Anlage fix: erlaubte Merkmale aus der GESPEICHERTEN Vorlage.
|
||||
const defs = await vehicleMerkmalDefs(existing.templateId ?? undefined);
|
||||
const werteParsed = buildMerkmalValuesSchema(defs).safeParse(rawWerte ?? []);
|
||||
if (!werteParsed.success) {
|
||||
return { ok: false, error: werteParsed.error.issues[0]?.message ?? "Ungültige Merkmal-Werte." };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(vehicles)
|
||||
.set({
|
||||
name: d.name,
|
||||
funkrufname: d.funkrufname ?? null,
|
||||
notiz: d.notiz ?? null,
|
||||
})
|
||||
.where(and(eq(vehicles.id, d.id), eq(vehicles.brigadeId, s.user.brigadeId)));
|
||||
await upsertMerkmalValues(tx, "vehicle", d.id, werteParsed.data);
|
||||
await writeAudit(s.user.id, "vehicle.update", "vehicle", d.id, undefined, tx);
|
||||
});
|
||||
|
||||
revalidatePath("/verwaltung/fahrzeuge");
|
||||
revalidatePath(`/verwaltung/fahrzeuge/${d.id}`);
|
||||
return { ok: true, id: d.id };
|
||||
}
|
||||
|
||||
/** Setzt den Status eines eigenen Fahrzeugs (Audit vehicle.status). */
|
||||
export async function setVehicleStatus(input: unknown): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = vehicleStatusSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Ungültige Eingabe." };
|
||||
const existing = await getVehicleForBrigade(parsed.data.id, s.user.brigadeId);
|
||||
if (!existing) return { ok: false, error: "Fahrzeug nicht gefunden." };
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(vehicles)
|
||||
.set({ status: parsed.data.status })
|
||||
.where(
|
||||
and(
|
||||
eq(vehicles.id, parsed.data.id),
|
||||
eq(vehicles.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
await writeAudit(
|
||||
s.user.id,
|
||||
"vehicle.status",
|
||||
"vehicle",
|
||||
parsed.data.id,
|
||||
{ status: parsed.data.status },
|
||||
tx,
|
||||
);
|
||||
});
|
||||
revalidatePath("/verwaltung/fahrzeuge");
|
||||
return { ok: true, id: parsed.data.id };
|
||||
}
|
||||
|
||||
/** Löscht ein eigenes Fahrzeug (Audit vehicle.delete). */
|
||||
export async function deleteVehicle(input: unknown): Promise<ActionResult> {
|
||||
const s = await requireWehrAdmin();
|
||||
const parsed = vehicleIdSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Ungültige ID." };
|
||||
const existing = await getVehicleForBrigade(parsed.data.id, s.user.brigadeId);
|
||||
if (!existing) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(vehicles)
|
||||
.where(
|
||||
and(
|
||||
eq(vehicles.id, parsed.data.id),
|
||||
eq(vehicles.brigadeId, s.user.brigadeId),
|
||||
),
|
||||
);
|
||||
await writeAudit(s.user.id, "vehicle.delete", "vehicle", parsed.data.id, undefined, tx);
|
||||
});
|
||||
revalidatePath("/verwaltung/fahrzeuge");
|
||||
return { ok: true, id: parsed.data.id };
|
||||
}
|
||||
34
src/server/data/brigade-users.ts
Normal file
34
src/server/data/brigade-users.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Lesehelfer für die Benutzer EINER Wehr (Workstream 7). Scope kommt aus der
|
||||
* Session. Passwort-Hashes werden NIE selektiert (kein Daten-Leak).
|
||||
*/
|
||||
|
||||
export interface BrigadeUserListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
rolle: (typeof users.$inferSelect)["rolle"];
|
||||
authTyp: (typeof users.$inferSelect)["authTyp"];
|
||||
aktiv: boolean;
|
||||
}
|
||||
|
||||
export async function listUsersForBrigade(
|
||||
brigadeId: string,
|
||||
): Promise<BrigadeUserListItem[]> {
|
||||
return db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
rolle: users.rolle,
|
||||
authTyp: users.authTyp,
|
||||
aktiv: users.aktiv,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.brigadeId, brigadeId))
|
||||
.orderBy(asc(users.name));
|
||||
}
|
||||
69
src/server/data/equipment.ts
Normal file
69
src/server/data/equipment.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
equipment,
|
||||
equipmentCategories,
|
||||
vehicles,
|
||||
} from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Lesehelfer für Geräte des Wehr-Bereichs (Workstream 7). Alle Helfer sind auf
|
||||
* eine `brigadeId` beschränkt (Scope aus Session). `vehicleId IS NULL` =
|
||||
* „im Gerätehaus".
|
||||
*/
|
||||
|
||||
export interface EquipmentListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: (typeof equipment.$inferSelect)["status"];
|
||||
categoryName: string;
|
||||
vehicleId: string | null;
|
||||
vehicleName: string | null;
|
||||
}
|
||||
|
||||
/** Liste der Geräte EINER Wehr mit Kategorie- und Zuordnungsnamen. */
|
||||
export async function listEquipmentForBrigade(
|
||||
brigadeId: string,
|
||||
): Promise<EquipmentListItem[]> {
|
||||
return db
|
||||
.select({
|
||||
id: equipment.id,
|
||||
name: equipment.name,
|
||||
status: equipment.status,
|
||||
categoryName: equipmentCategories.name,
|
||||
vehicleId: equipment.vehicleId,
|
||||
vehicleName: vehicles.name,
|
||||
})
|
||||
.from(equipment)
|
||||
.innerJoin(
|
||||
equipmentCategories,
|
||||
eq(equipmentCategories.id, equipment.categoryId),
|
||||
)
|
||||
.leftJoin(vehicles, eq(vehicles.id, equipment.vehicleId))
|
||||
.where(eq(equipment.brigadeId, brigadeId))
|
||||
.orderBy(desc(equipment.erstelltAm));
|
||||
}
|
||||
|
||||
/** Lädt EIN Gerät scoped auf die Wehr (sonst `null` -> notFound). */
|
||||
export async function getEquipmentForBrigade(
|
||||
equipmentId: string,
|
||||
brigadeId: string,
|
||||
): Promise<typeof equipment.$inferSelect | null> {
|
||||
const [e] = await db
|
||||
.select()
|
||||
.from(equipment)
|
||||
.where(
|
||||
and(eq(equipment.id, equipmentId), eq(equipment.brigadeId, brigadeId)),
|
||||
);
|
||||
return e ?? null;
|
||||
}
|
||||
|
||||
/** Alle Geräte-Kategorien, geordnet — für die Auswahl im Formular. */
|
||||
export async function listCategories(): Promise<
|
||||
{ id: string; name: string }[]
|
||||
> {
|
||||
return db
|
||||
.select({ id: equipmentCategories.id, name: equipmentCategories.name })
|
||||
.from(equipmentCategories)
|
||||
.orderBy(asc(equipmentCategories.reihenfolge), asc(equipmentCategories.name));
|
||||
}
|
||||
149
src/server/data/merkmale.ts
Normal file
149
src/server/data/merkmale.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
merkmale,
|
||||
merkmalOptionen,
|
||||
merkmalValues,
|
||||
vehicleTemplateMerkmale,
|
||||
equipmentCategoryMerkmale,
|
||||
} from "@/db/schema";
|
||||
import type {
|
||||
MerkmalDefinition,
|
||||
MerkmalOption,
|
||||
MerkmalValueInput,
|
||||
} from "@/lib/merkmale/types";
|
||||
|
||||
/**
|
||||
* Lesehelfer für Merkmal-Definitionen (Workstream 7). Lösen die Vorgabewerte aus
|
||||
* den DREI typisierten Spalten (`vorgabewert_num/_text/_bool`) und laden je
|
||||
* enum-Merkmal die Optionen. Genutzt von Fahrzeug-/Geräteformularen, um den
|
||||
* typisierten Editor vorzubefüllen.
|
||||
*/
|
||||
|
||||
async function optionenFor(
|
||||
merkmalIds: string[],
|
||||
): Promise<Map<string, MerkmalOption[]>> {
|
||||
const map = new Map<string, MerkmalOption[]>();
|
||||
if (merkmalIds.length === 0) return map;
|
||||
const rows = await db
|
||||
.select({
|
||||
merkmalId: merkmalOptionen.merkmalId,
|
||||
wert: merkmalOptionen.wert,
|
||||
label: merkmalOptionen.label,
|
||||
reihenfolge: merkmalOptionen.reihenfolge,
|
||||
})
|
||||
.from(merkmalOptionen)
|
||||
.orderBy(asc(merkmalOptionen.reihenfolge), asc(merkmalOptionen.label));
|
||||
for (const r of rows) {
|
||||
if (!merkmalIds.includes(r.merkmalId)) continue;
|
||||
const list = map.get(r.merkmalId) ?? [];
|
||||
list.push({ wert: r.wert, label: r.label });
|
||||
map.set(r.merkmalId, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Pflicht-/Vorgabemerkmale einer Fahrzeug-Vorlage, geordnet.
|
||||
* Vorgabewerte werden typgerecht aus drei Spalten gelesen.
|
||||
*/
|
||||
export async function getMerkmaleForTemplate(
|
||||
templateId: string,
|
||||
): Promise<MerkmalDefinition[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
merkmalId: merkmale.id,
|
||||
name: merkmale.name,
|
||||
typ: merkmale.typ,
|
||||
einheit: merkmale.einheit,
|
||||
pflicht: vehicleTemplateMerkmale.pflicht,
|
||||
reihenfolge: vehicleTemplateMerkmale.reihenfolge,
|
||||
vorgabeNum: vehicleTemplateMerkmale.vorgabewertNum,
|
||||
vorgabeText: vehicleTemplateMerkmale.vorgabewertText,
|
||||
vorgabeBool: vehicleTemplateMerkmale.vorgabewertBool,
|
||||
})
|
||||
.from(vehicleTemplateMerkmale)
|
||||
.innerJoin(merkmale, eq(merkmale.id, vehicleTemplateMerkmale.merkmalId))
|
||||
.where(eq(vehicleTemplateMerkmale.templateId, templateId))
|
||||
.orderBy(asc(vehicleTemplateMerkmale.reihenfolge), asc(merkmale.name));
|
||||
|
||||
const opts = await optionenFor(rows.map((r) => r.merkmalId));
|
||||
return rows.map((r) => ({
|
||||
merkmalId: r.merkmalId,
|
||||
name: r.name,
|
||||
typ: r.typ,
|
||||
einheit: r.einheit,
|
||||
pflicht: r.pflicht,
|
||||
reihenfolge: r.reihenfolge,
|
||||
optionen: opts.get(r.merkmalId) ?? [],
|
||||
vorgabeNum: r.vorgabeNum,
|
||||
vorgabeText: r.vorgabeText,
|
||||
vorgabeBool: r.vorgabeBool,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die Merkmale einer Geräte-Kategorie, geordnet. Kategorien tragen
|
||||
* keine Vorgabewerte/Pflicht — beide bleiben neutral (`pflicht=false`).
|
||||
*/
|
||||
export async function getMerkmaleForCategory(
|
||||
categoryId: string,
|
||||
): Promise<MerkmalDefinition[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
merkmalId: merkmale.id,
|
||||
name: merkmale.name,
|
||||
typ: merkmale.typ,
|
||||
einheit: merkmale.einheit,
|
||||
reihenfolge: equipmentCategoryMerkmale.reihenfolge,
|
||||
})
|
||||
.from(equipmentCategoryMerkmale)
|
||||
.innerJoin(merkmale, eq(merkmale.id, equipmentCategoryMerkmale.merkmalId))
|
||||
.where(eq(equipmentCategoryMerkmale.categoryId, categoryId))
|
||||
.orderBy(asc(equipmentCategoryMerkmale.reihenfolge), asc(merkmale.name));
|
||||
|
||||
const opts = await optionenFor(rows.map((r) => r.merkmalId));
|
||||
return rows.map((r) => ({
|
||||
merkmalId: r.merkmalId,
|
||||
name: r.name,
|
||||
typ: r.typ,
|
||||
einheit: r.einheit,
|
||||
pflicht: false,
|
||||
reihenfolge: r.reihenfolge,
|
||||
optionen: opts.get(r.merkmalId) ?? [],
|
||||
vorgabeNum: null,
|
||||
vorgabeText: null,
|
||||
vorgabeBool: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die aktuell gespeicherten Merkmal-Werte einer Entity (für die
|
||||
* Bearbeiten-Ansicht). Liefert `MerkmalValueInput[]` (genau eine Wertspalte je
|
||||
* Eintrag gesetzt).
|
||||
*/
|
||||
export async function getMerkmalValuesForEntity(
|
||||
entityTyp: "vehicle" | "equipment",
|
||||
entityId: string,
|
||||
): Promise<MerkmalValueInput[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
merkmalId: merkmalValues.merkmalId,
|
||||
num: merkmalValues.valueNum,
|
||||
text: merkmalValues.valueText,
|
||||
bool: merkmalValues.valueBool,
|
||||
})
|
||||
.from(merkmalValues)
|
||||
.where(
|
||||
and(
|
||||
eq(merkmalValues.entityTyp, entityTyp),
|
||||
eq(merkmalValues.entityId, entityId),
|
||||
),
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
merkmalId: r.merkmalId,
|
||||
num: r.num,
|
||||
text: r.text,
|
||||
bool: r.bool,
|
||||
}));
|
||||
}
|
||||
88
src/server/data/vehicles.ts
Normal file
88
src/server/data/vehicles.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
vehicles,
|
||||
vehicleTemplates,
|
||||
brigades,
|
||||
} from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Lesehelfer für Fahrzeuge des Wehr-Bereichs (Workstream 7). ALLE Helfer sind
|
||||
* auf eine `brigadeId` beschränkt (Scope kommt aus der Session, nie aus Input).
|
||||
*/
|
||||
|
||||
export interface VehicleListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
funkrufname: string | null;
|
||||
status: (typeof vehicles.$inferSelect)["status"];
|
||||
templateName: string | null;
|
||||
}
|
||||
|
||||
/** Liste der Fahrzeuge EINER Wehr, neueste zuerst, mit Vorlagenname. */
|
||||
export async function listVehiclesForBrigade(
|
||||
brigadeId: string,
|
||||
): Promise<VehicleListItem[]> {
|
||||
return db
|
||||
.select({
|
||||
id: vehicles.id,
|
||||
name: vehicles.name,
|
||||
funkrufname: vehicles.funkrufname,
|
||||
status: vehicles.status,
|
||||
templateName: vehicleTemplates.name,
|
||||
})
|
||||
.from(vehicles)
|
||||
.leftJoin(vehicleTemplates, eq(vehicleTemplates.id, vehicles.templateId))
|
||||
.where(eq(vehicles.brigadeId, brigadeId))
|
||||
.orderBy(desc(vehicles.erstelltAm));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt EIN Fahrzeug, aber nur wenn es zur angegebenen Wehr gehört. Liefert
|
||||
* `null`, wenn es nicht existiert ODER einer fremden Wehr gehört (Scoping:
|
||||
* der Aufrufer reagiert mit `notFound()`).
|
||||
*/
|
||||
export async function getVehicleForBrigade(
|
||||
vehicleId: string,
|
||||
brigadeId: string,
|
||||
): Promise<typeof vehicles.$inferSelect | null> {
|
||||
const [v] = await db
|
||||
.select()
|
||||
.from(vehicles)
|
||||
.where(and(eq(vehicles.id, vehicleId), eq(vehicles.brigadeId, brigadeId)));
|
||||
return v ?? null;
|
||||
}
|
||||
|
||||
/** Prüft (scoped), ob ein Fahrzeug zur Wehr gehört — für Geräte-Zuordnung. */
|
||||
export async function vehicleBelongsToBrigade(
|
||||
vehicleId: string,
|
||||
brigadeId: string,
|
||||
): Promise<boolean> {
|
||||
const v = await getVehicleForBrigade(vehicleId, brigadeId);
|
||||
return v !== null;
|
||||
}
|
||||
|
||||
/** Alle Fahrzeug-Vorlagen, geordnet — für den Vorlagen-Picker. */
|
||||
export async function listTemplates(): Promise<
|
||||
{ id: string; code: string; name: string }[]
|
||||
> {
|
||||
return db
|
||||
.select({
|
||||
id: vehicleTemplates.id,
|
||||
code: vehicleTemplates.code,
|
||||
name: vehicleTemplates.name,
|
||||
})
|
||||
.from(vehicleTemplates)
|
||||
.orderBy(asc(vehicleTemplates.reihenfolge), asc(vehicleTemplates.name));
|
||||
}
|
||||
|
||||
/** Stammdaten der eigenen Wehr (für die Profilseite). */
|
||||
export async function getBrigade(
|
||||
brigadeId: string,
|
||||
): Promise<typeof brigades.$inferSelect | null> {
|
||||
const [b] = await db
|
||||
.select()
|
||||
.from(brigades)
|
||||
.where(eq(brigades.id, brigadeId));
|
||||
return b ?? null;
|
||||
}
|
||||
68
src/server/merkmale/__tests__/upsertValues.test.ts
Normal file
68
src/server/merkmale/__tests__/upsertValues.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { upsertMerkmalValues } from "../upsertValues";
|
||||
import type { MerkmalValueInput } from "@/lib/merkmale/types";
|
||||
|
||||
/**
|
||||
* Test-Double für die Drizzle-Transaktion. Es zeichnet nur auf, wie oft
|
||||
* delete/insert aufgerufen wurden — kein echtes Postgres. `upsertMerkmalValues`
|
||||
* ruft pro Wert genau ein `delete(...).where(...)` und (bei nicht-leerem Wert)
|
||||
* ein `insert(...).values(...)`.
|
||||
*/
|
||||
function makeFakeTx() {
|
||||
const deletes: unknown[] = [];
|
||||
const inserts: Record<string, unknown>[] = [];
|
||||
const tx = {
|
||||
delete: vi.fn(() => ({
|
||||
where: vi.fn(async (cond: unknown) => {
|
||||
deletes.push(cond);
|
||||
}),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(async (v: Record<string, unknown>) => {
|
||||
inserts.push(v);
|
||||
}),
|
||||
})),
|
||||
};
|
||||
return { tx, deletes, inserts };
|
||||
}
|
||||
|
||||
const MID = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
describe("upsertMerkmalValues", () => {
|
||||
it("löscht alten Wert und fügt neuen ein (delete-then-insert)", async () => {
|
||||
const { tx, deletes, inserts } = makeFakeTx();
|
||||
const werte: MerkmalValueInput[] = [{ merkmalId: MID, num: 2000 }];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await upsertMerkmalValues(tx as any, "vehicle", "veh-1", werte);
|
||||
expect(deletes).toHaveLength(1);
|
||||
expect(inserts).toHaveLength(1);
|
||||
expect(inserts[0]).toMatchObject({
|
||||
merkmalId: MID,
|
||||
entityTyp: "vehicle",
|
||||
entityId: "veh-1",
|
||||
valueNum: 2000,
|
||||
valueText: null,
|
||||
valueBool: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("schreibt bei leerem Wert nur delete, kein insert", async () => {
|
||||
const { tx, deletes, inserts } = makeFakeTx();
|
||||
const werte: MerkmalValueInput[] = [
|
||||
{ merkmalId: MID, num: null, text: "", bool: null },
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await upsertMerkmalValues(tx as any, "vehicle", "veh-1", werte);
|
||||
expect(deletes).toHaveLength(1);
|
||||
expect(inserts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("schreibt boolean false als Wert (nicht als leer behandelt)", async () => {
|
||||
const { tx, inserts } = makeFakeTx();
|
||||
const werte: MerkmalValueInput[] = [{ merkmalId: MID, bool: false }];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await upsertMerkmalValues(tx as any, "equipment", "eq-1", werte);
|
||||
expect(inserts).toHaveLength(1);
|
||||
expect(inserts[0]).toMatchObject({ valueBool: false, valueNum: null, valueText: null });
|
||||
});
|
||||
});
|
||||
50
src/server/merkmale/upsertValues.ts
Normal file
50
src/server/merkmale/upsertValues.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { merkmalValues } from "@/db/schema";
|
||||
import type { Tx } from "@/lib/audit";
|
||||
import type { MerkmalValueInput } from "@/lib/merkmale/types";
|
||||
|
||||
/**
|
||||
* Schreibt die Merkmal-Werte einer Entity (Fahrzeug/Gerät) in `merkmal_values`
|
||||
* per delete-then-insert: pro Merkmal wird der bestehende Wert gelöscht und —
|
||||
* sofern nicht leer — neu eingefügt. So bleibt pro (entity, merkmal) genau eine
|
||||
* Zeile; ein geleerter Wert hinterlässt KEINE Zeile.
|
||||
*
|
||||
* Läuft IMMER innerhalb der aufrufenden Transaktion (`Tx` aus @/lib/audit,
|
||||
* kein `any` — Querschnittsstandard 12), damit Insert/Audit atomar sind.
|
||||
*
|
||||
* `boolean false` und `num 0` gelten als gesetzt; nur `null`/leerer String
|
||||
* zählen als leer.
|
||||
*/
|
||||
export async function upsertMerkmalValues(
|
||||
tx: Tx,
|
||||
entityTyp: "vehicle" | "equipment",
|
||||
entityId: string,
|
||||
werte: MerkmalValueInput[],
|
||||
): Promise<void> {
|
||||
for (const w of werte) {
|
||||
await tx
|
||||
.delete(merkmalValues)
|
||||
.where(
|
||||
and(
|
||||
eq(merkmalValues.entityTyp, entityTyp),
|
||||
eq(merkmalValues.entityId, entityId),
|
||||
eq(merkmalValues.merkmalId, w.merkmalId),
|
||||
),
|
||||
);
|
||||
|
||||
const empty =
|
||||
w.num == null &&
|
||||
(w.text == null || w.text === "") &&
|
||||
w.bool == null;
|
||||
if (empty) continue;
|
||||
|
||||
await tx.insert(merkmalValues).values({
|
||||
merkmalId: w.merkmalId,
|
||||
entityTyp,
|
||||
entityId,
|
||||
valueNum: w.num ?? null,
|
||||
valueText: w.text ?? null,
|
||||
valueBool: w.bool ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,76 +1,77 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { ROUTES } from "./routes.manifest";
|
||||
|
||||
/**
|
||||
* Auth-Gating-Garantie (Definition of Done, oberstes Prinzip).
|
||||
* KRITISCHE Auth-Gating-Suite (Definition of Done #1, oberstes Prinzip).
|
||||
*
|
||||
* Erzeugt GENAU EINEN Testfall pro Manifest-Eintrag (ROUTES.length):
|
||||
* - Seiten -> Redirect auf /login (mit callbackUrl), kein Daten-Leak.
|
||||
* - API -> 401 ohne Fachdaten im Body.
|
||||
*
|
||||
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
|
||||
* `npm run test:e2e:gating` gegen einen laufenden Server ausgeführt.
|
||||
*
|
||||
* Kerngarantie (Querschnittsstandard 1–3, default-deny dreifach):
|
||||
* - Anonyme Aufrufe von Seiten -> Redirect auf /login (mit callbackUrl).
|
||||
* - Anonyme Aufrufe von API-Routen -> 401 OHNE Daten-Leak.
|
||||
* - Öffentliche Routen (Login, Health) bleiben erreichbar.
|
||||
*
|
||||
* Negativ-Probe (manuell/CI): Entfernen von `requireSession()` aus
|
||||
* `(app)/layout.tsx` muss diese Suite rot machen.
|
||||
* Negativ-Probe (CI): Entfernen eines Layout-Guards oder einer Manifest-Route
|
||||
* muss diese Suite rot machen.
|
||||
*/
|
||||
|
||||
// Geschützte Seiten (Redirect-Manifest). Neue Seiten hier ergänzen.
|
||||
const PROTECTED_PAGES = [
|
||||
"/",
|
||||
"/start",
|
||||
"/fahrzeuge",
|
||||
"/geraete",
|
||||
"/wehren",
|
||||
"/verwaltung",
|
||||
"/admin",
|
||||
// Erzwingt anonymen Zustand: keine gespeicherte Session.
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
// Fachbegriffe, die in einem 401/Redirect-Body NIE auftauchen dürfen.
|
||||
const LEAK_TERMS = [
|
||||
"funkrufname",
|
||||
"wehrfuehrer",
|
||||
"einsatzbereit",
|
||||
"passwort",
|
||||
"kennzeichen",
|
||||
];
|
||||
|
||||
// Geschützte API-Routen (401-Manifest). Neue API-Routen hier ergänzen.
|
||||
const PROTECTED_API = ["/api/fahrzeuge", "/api/geraete", "/api/verwaltung"];
|
||||
function assertNoLeak(body: string) {
|
||||
const lower = body.toLowerCase();
|
||||
for (const term of LEAK_TERMS) {
|
||||
expect(lower, `Daten-Leak: Body enthält "${term}"`).not.toContain(term);
|
||||
}
|
||||
}
|
||||
|
||||
// Öffentliche Routen (Middleware-Allowlist).
|
||||
const PUBLIC_ROUTES = ["/login", "/api/health"];
|
||||
|
||||
test.describe("Default-deny: geschützte Seiten", () => {
|
||||
for (const path of PROTECTED_PAGES) {
|
||||
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({
|
||||
for (const route of ROUTES) {
|
||||
if (route.expectWhenAnon === "redirect") {
|
||||
test(`Seite ${route.path}: anonym -> Redirect auf /login`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(path);
|
||||
const response = await page.goto(route.path);
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
// callbackUrl bewahrt das ursprüngliche Ziel.
|
||||
expect(page.url()).toContain("callbackUrl");
|
||||
const body = await page.content();
|
||||
assertNoLeak(body);
|
||||
// Kein 500 o. ä.
|
||||
if (response) expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Default-deny: geschützte API-Routen", () => {
|
||||
for (const path of PROTECTED_API) {
|
||||
test(`anonymer Aufruf von ${path} liefert 401 ohne Daten-Leak`, async ({
|
||||
} else {
|
||||
test(`API ${route.path}: anonym -> 401 ohne Daten-Leak`, async ({
|
||||
request,
|
||||
}) => {
|
||||
const res = await request.get(path);
|
||||
// /api/geo/geocode ist POST-only; health ist GET. Beide gehen durch apiAuth().
|
||||
const res = route.path.includes("geocode")
|
||||
? await request.post(route.path, { data: { address: "x" } })
|
||||
: await request.get(route.path);
|
||||
expect(res.status()).toBe(401);
|
||||
const body = await res.text();
|
||||
// Kein Daten-Leak: nur eine generische Fehlermeldung.
|
||||
expect(body).not.toContain("brigade");
|
||||
expect(body).not.toContain("passwort");
|
||||
assertNoLeak(await res.text());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Öffentliche Routen bleiben anonym erreichbar", () => {
|
||||
test("Login-Seite ist anonym 200", async ({ page }) => {
|
||||
const res = await page.goto("/login");
|
||||
expect(res?.status()).toBeLessThan(400);
|
||||
await expect(page.getByRole("heading", { name: "Anmelden" })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("Öffentliche Routen bleiben erreichbar", () => {
|
||||
test("Login-Seite ist anonym erreichbar", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Anmelden" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Health-Check ist anonym 200", async ({ request }) => {
|
||||
test("Container-Health ist anonym 200", async ({ request }) => {
|
||||
const res = await request.get("/api/health");
|
||||
expect(res.status()).toBe(200);
|
||||
expect(await res.json()).toEqual({ status: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
void PUBLIC_ROUTES;
|
||||
|
||||
99
tests/e2e/detail-auth.spec.ts
Normal file
99
tests/e2e/detail-auth.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Detailseiten-Auth & -Inhalt (Workstream 8, Phase 5).
|
||||
*
|
||||
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
|
||||
* `npm run test:e2e` gegen einen laufenden Server mit Seed-Daten ausgeführt.
|
||||
*
|
||||
* Garantien:
|
||||
* - Default-deny (Querschnittsstandard 1): anonyme Aufrufe der Detailseiten
|
||||
* leiten auf /login um (das `(app)`-Layout-Gate + `requireSession()` je Seite).
|
||||
* - Eingeloggt: Eckdaten, Beladung-Links (`/geraete/<id>`), Wehr-Kontakt
|
||||
* (`tel:`/`mailto:`) sind sichtbar.
|
||||
* - Ungültige IDs -> deutsche 404-Seite (not-found).
|
||||
*
|
||||
* Negativ-Probe (manuell/CI): Entfernen von `requireSession()` aus einer der
|
||||
* Detailseiten ODER aus `(app)/layout.tsx` muss die Default-deny-Tests rot
|
||||
* machen.
|
||||
*
|
||||
* Platzhalter-IDs: zur Laufzeit gegen echte Seed-UUIDs ersetzen
|
||||
* (Env `E2E_FAHRZEUG_ID` etc.) oder per Suchseite ermitteln.
|
||||
*/
|
||||
|
||||
const FAHRZEUG_ID = process.env.E2E_FAHRZEUG_ID ?? "00000000-0000-0000-0000-000000000001";
|
||||
const GERAET_ID = process.env.E2E_GERAET_ID ?? "00000000-0000-0000-0000-000000000002";
|
||||
const WEHR_ID = process.env.E2E_WEHR_ID ?? "00000000-0000-0000-0000-000000000003";
|
||||
const UNGUELTIGE_ID = "ffffffff-ffff-ffff-ffff-ffffffffffff";
|
||||
|
||||
const DETAIL_PAGES = [
|
||||
`/fahrzeuge/${FAHRZEUG_ID}`,
|
||||
`/geraete/${GERAET_ID}`,
|
||||
`/wehren/${WEHR_ID}`,
|
||||
];
|
||||
|
||||
test.describe("Default-deny: Detailseiten (anonym)", () => {
|
||||
for (const path of DETAIL_PAGES) {
|
||||
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Eingeloggt: Detail-Inhalte", () => {
|
||||
test.skip(
|
||||
!process.env.E2E_WEHR_READ_STATE,
|
||||
"benötigt wehr_read-Fixture (Test-Workstream)",
|
||||
);
|
||||
test.use({
|
||||
storageState: process.env.E2E_WEHR_READ_STATE ?? { cookies: [], origins: [] },
|
||||
});
|
||||
|
||||
test("Fahrzeug-Detail zeigt Eckdaten, Beladung-Links und Wehr-Kontakt", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/fahrzeuge/${FAHRZEUG_ID}`);
|
||||
await expect(page.getByRole("heading", { name: "Eckdaten" })).toBeVisible();
|
||||
// Beladung verlinkt auf Gerät-Detailseiten.
|
||||
const beladungLink = page.locator('a[href^="/geraete/"]').first();
|
||||
await expect(beladungLink).toBeVisible();
|
||||
// Out-of-band Kontakt: tel:/mailto:-Link vorhanden.
|
||||
const kontaktLink = page.locator('a[href^="tel:"], a[href^="mailto:"]').first();
|
||||
await expect(kontaktLink).toBeVisible();
|
||||
});
|
||||
|
||||
test("Gerät-Detail verlinkt Fahrzeug oder zeigt „im Gerätehaus“", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/geraete/${GERAET_ID}`);
|
||||
const hatFahrzeug = await page.locator('a[href^="/fahrzeuge/"]').count();
|
||||
if (hatFahrzeug === 0) {
|
||||
await expect(page.getByText("im Gerätehaus")).toBeVisible();
|
||||
} else {
|
||||
await expect(page.locator('a[href^="/fahrzeuge/"]').first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("Wehr-Detail listet Fuhrpark und Kontakt", async ({ page }) => {
|
||||
await page.goto(`/wehren/${WEHR_ID}`);
|
||||
await expect(page.getByRole("heading", { name: "Fahrzeuge" })).toBeVisible();
|
||||
const kontaktLink = page.locator('a[href^="tel:"], a[href^="mailto:"]').first();
|
||||
await expect(kontaktLink).toBeVisible();
|
||||
});
|
||||
|
||||
test("ungültige Fahrzeug-ID -> deutsche 404-Seite", async ({ page }) => {
|
||||
await page.goto(`/fahrzeuge/${UNGUELTIGE_ID}`);
|
||||
await expect(page.getByText("Nicht gefunden.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("malformter (nicht-UUID) Fahrzeug-Pfad -> deutsche 404-Seite (kein 500)", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Route-Param ist Nutzereingabe an der Grenze: eine nicht-UUID darf nicht
|
||||
// als `invalid input syntax for type uuid` bis zur error.tsx (500) laufen,
|
||||
// sondern muss sauber `notFound()` (deutsche 404) liefern.
|
||||
await page.goto(`/fahrzeuge/abc`);
|
||||
await expect(page.getByText("Nicht gefunden.")).toBeVisible();
|
||||
});
|
||||
});
|
||||
63
tests/e2e/fixtures/auth.setup.ts
Normal file
63
tests/e2e/fixtures/auth.setup.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { test as setup, expect } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Auth-Setup (Plan WS11 Aufgabe 3): echter Credentials-Login je Konto ->
|
||||
* storageState. Wird als Playwright-Projekt "setup" ausgeführt; die übrigen
|
||||
* Projekte hängen davon ab und laden den jeweiligen storageState.
|
||||
*
|
||||
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred.
|
||||
*
|
||||
* Erzeugt vier Storage-States passend zu den vier Seed-Konten:
|
||||
* - platform_admin
|
||||
* - wehr_admin (Wehr A)
|
||||
* - wehr_admin (Wehr B)
|
||||
* - wehr_read (Wehr A)
|
||||
*/
|
||||
export const AUTH_DIR = path.join(process.cwd(), "tests/e2e/.auth");
|
||||
|
||||
interface Account {
|
||||
email: string;
|
||||
file: string;
|
||||
envVar: string;
|
||||
}
|
||||
|
||||
const PASSWORD = process.env.E2E_TEST_PASSWORD ?? "Test-Passwort-1234";
|
||||
|
||||
const ACCOUNTS: Account[] = [
|
||||
{
|
||||
email: "platform-admin@example.test",
|
||||
file: "platform-admin.json",
|
||||
envVar: "E2E_PLATFORM_ADMIN_STATE",
|
||||
},
|
||||
{
|
||||
email: "wehr-admin-a@example.test",
|
||||
file: "wehr-admin-a.json",
|
||||
envVar: "E2E_WEHR_ADMIN_STATE",
|
||||
},
|
||||
{
|
||||
email: "wehr-admin-b@example.test",
|
||||
file: "wehr-admin-b.json",
|
||||
envVar: "E2E_WEHR_ADMIN_B_STATE",
|
||||
},
|
||||
{
|
||||
email: "wehr-read-a@example.test",
|
||||
file: "wehr-read-a.json",
|
||||
envVar: "E2E_WEHR_READ_STATE",
|
||||
},
|
||||
];
|
||||
|
||||
for (const account of ACCOUNTS) {
|
||||
setup(`Login ${account.email}`, async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/E-Mail/i).fill(account.email);
|
||||
await page.getByLabel(/Passwort/i).fill(PASSWORD);
|
||||
await page.getByRole("button", { name: /Anmelden/i }).click();
|
||||
// Erfolgreicher Login verlässt /login.
|
||||
await page.waitForURL((url) => !url.pathname.startsWith("/login"));
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
await page
|
||||
.context()
|
||||
.storageState({ path: path.join(AUTH_DIR, account.file) });
|
||||
});
|
||||
}
|
||||
36
tests/e2e/global-setup.ts
Normal file
36
tests/e2e/global-setup.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Playwright Global-Setup (Definition of Done #1, Plan WS11 Aufgabe 2).
|
||||
*
|
||||
* NICHT in der Sandbox ausführbar (kein Postgres) — deferred. Läuft im CI/lokal
|
||||
* gegen eine erreichbare DB:
|
||||
* 1. Migrationen anwenden (idempotent).
|
||||
* 2. Deterministischen Seed laden (Katalog) + Test-Fixtures (Wehren A/B mit
|
||||
* Koordinaten, je Fahrzeug/Gerät mit festen UUIDs, vier Benutzer mit
|
||||
* argon2id-Test-Passwort).
|
||||
*
|
||||
* Wird nur ausgeführt, wenn KEIN externer E2E_BASE_URL gesetzt ist (dann ist die
|
||||
* Ziel-Umgebung bereits provisioniert) und DATABASE_URL existiert.
|
||||
*/
|
||||
async function globalSetup(): Promise<void> {
|
||||
if (process.env.E2E_BASE_URL) {
|
||||
// Externe Umgebung: bereits provisioniert, nichts tun.
|
||||
return;
|
||||
}
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.warn(
|
||||
"[global-setup] DATABASE_URL fehlt — Migration/Seed übersprungen (deferred).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const run = (cmd: string) =>
|
||||
execSync(cmd, { stdio: "inherit", env: process.env });
|
||||
|
||||
run("npm run db:migrate");
|
||||
run("npm run db:seed");
|
||||
// Deterministische E2E-Fixtures (vier Konten + Wehren A/B + feste Asset-UUIDs).
|
||||
run("npm run db:seed-auth");
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
32
tests/e2e/login-ratelimit.spec.ts
Normal file
32
tests/e2e/login-ratelimit.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Login-Rate-Limit (Definition of Done #8, Querschnittsstandard 8).
|
||||
*
|
||||
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
|
||||
* `npm run test:e2e` gegen einen laufenden, geseedeten Server ausgeführt.
|
||||
*
|
||||
* Beweist: Der Rate-Limit greift im `authorize`-Callback (5 Fehlversuche /
|
||||
* 15 min, src/lib/auth/rate-limit.ts) und damit auf dem Pfad, der über die
|
||||
* Credentials-Login-Action (loginAction) tatsächlich durchläuft. Ab dem 6.
|
||||
* Versuch wird gedrosselt; login_attempts.fail >= 5.
|
||||
*/
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test("7x falsches Passwort -> Drosselung ab Versuch 6", async ({ page }) => {
|
||||
const email = "wehr-admin-a@example.test";
|
||||
for (let attempt = 1; attempt <= 7; attempt++) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/E-Mail/i).fill(email);
|
||||
await page.getByLabel(/Passwort/i).fill(`falsch-${attempt}`);
|
||||
await page.getByRole("button", { name: /Anmelden/i }).click();
|
||||
|
||||
// Bleibt auf /login (kein erfolgreicher Login).
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
const text = await page.locator("body").innerText();
|
||||
if (attempt >= 6) {
|
||||
// Drosselung: generische Fehlermeldung, weiterhin kein Zugang.
|
||||
expect(text.toLowerCase()).toMatch(/fehlgeschlagen|zu viele|gesperrt|versuch/);
|
||||
}
|
||||
}
|
||||
});
|
||||
64
tests/e2e/rbac-scoping.spec.ts
Normal file
64
tests/e2e/rbac-scoping.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Rollen-/Wehr-Scoping (Definition of Done #3, Plan WS11 Aufgabe 6 / Verifikation
|
||||
* 6).
|
||||
*
|
||||
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
|
||||
* `npm run test:e2e` gegen einen geseedeten Server mit Storage-States aus dem
|
||||
* Auth-Setup ausgeführt.
|
||||
*
|
||||
* Garantien:
|
||||
* - wehr_read kann NICHT schreiben (Status-Änderung -> 403/forbidden).
|
||||
* - wehr_admin A kann Wehr B NICHT ändern (fremdes Asset -> 403/404,
|
||||
* Datensatz bleibt unverändert).
|
||||
* - eigene Ressource: wehr_admin A kann Status setzen (-> 200, status='wartung').
|
||||
*
|
||||
* Storage-States kommen aus tests/e2e/fixtures/auth.setup.ts. Feste Asset-UUIDs
|
||||
* stammen aus dem deterministischen Seed (global-setup.ts).
|
||||
*/
|
||||
|
||||
const VEHICLE_A = process.env.E2E_VEHICLE_A_ID ?? "";
|
||||
const VEHICLE_B = process.env.E2E_VEHICLE_B_ID ?? "";
|
||||
|
||||
test.describe("wehr_read darf nicht schreiben", () => {
|
||||
test.skip(!process.env.E2E_WEHR_READ_STATE, "benötigt wehr_read-Fixture");
|
||||
test.use({ storageState: process.env.E2E_WEHR_READ_STATE });
|
||||
|
||||
test("Aufruf der Verwaltungs-Schreibseite -> 403", async ({ page }) => {
|
||||
const res = await page.goto("/verwaltung/fahrzeuge/neu");
|
||||
expect(res?.status()).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("wehr_admin A darf Wehr B nicht ändern", () => {
|
||||
test.skip(
|
||||
!process.env.E2E_WEHR_ADMIN_STATE || !VEHICLE_B,
|
||||
"benötigt wehr_admin-A-Fixture + Wehr-B-Fahrzeug-ID",
|
||||
);
|
||||
test.use({ storageState: process.env.E2E_WEHR_ADMIN_STATE });
|
||||
|
||||
test("fremdes Fahrzeug (Wehr B) -> 404, unverändert", async ({ page }) => {
|
||||
const res = await page.goto(`/verwaltung/fahrzeuge/${VEHICLE_B}`);
|
||||
expect(res?.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("wehr_admin A darf eigenes Fahrzeug ändern", () => {
|
||||
test.skip(
|
||||
!process.env.E2E_WEHR_ADMIN_STATE || !VEHICLE_A,
|
||||
"benötigt wehr_admin-A-Fixture + Wehr-A-Fahrzeug-ID",
|
||||
);
|
||||
test.use({ storageState: process.env.E2E_WEHR_ADMIN_STATE });
|
||||
|
||||
test("eigenes Fahrzeug ist erreichbar (200) und editierbar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const res = await page.goto(`/verwaltung/fahrzeuge/${VEHICLE_A}`);
|
||||
expect(res?.status()).toBeLessThan(400);
|
||||
// Status auf 'wartung' setzen (Verifikation 6: eigenes -> 200).
|
||||
await page.getByLabel(/Status/i).selectOption("wartung");
|
||||
await page.getByRole("button", { name: /Speichern/i }).click();
|
||||
await expect(page.getByText(/gespeichert|wartung/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
24
tests/e2e/routes.manifest.spec.ts
Normal file
24
tests/e2e/routes.manifest.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { discoverAppRoutes, findUndeclaredRoutes } from "../support/route-scan";
|
||||
import { DECLARED_ROUTE_TEMPLATES } from "./routes.manifest";
|
||||
|
||||
/**
|
||||
* Driftschutz (Definition of Done #1): verhindert ungetestete neue Routen.
|
||||
*
|
||||
* STATISCHER Check — braucht weder Server noch DB; lauffähig offline. Die
|
||||
* identische Logik ist zusätzlich als Vitest-Unit-Test
|
||||
* (tests/unit/routes-manifest.test.ts) abgesichert.
|
||||
*
|
||||
* Negativ-Probe: Eine neue Route src/app/(app)/leak/page.tsx ohne
|
||||
* Manifest-Eintrag macht diesen Test rot; Entfernen -> grün.
|
||||
*/
|
||||
test("jede Route unter src/app ist im Manifest oder öffentlich", () => {
|
||||
const discovered = discoverAppRoutes();
|
||||
const undeclared = findUndeclaredRoutes(discovered, DECLARED_ROUTE_TEMPLATES);
|
||||
expect(
|
||||
undeclared,
|
||||
`Ungetestete Routen (im Manifest ergänzen oder als öffentlich markieren):\n${undeclared.join(
|
||||
"\n",
|
||||
)}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
110
tests/e2e/routes.manifest.ts
Normal file
110
tests/e2e/routes.manifest.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Kanonisches Routen-Manifest für die Auth-Gating-Garantie (Definition of
|
||||
* Done #1, Querschnittsstandard 1–3).
|
||||
*
|
||||
* EINZIGE Quelle der Wahrheit darüber, welche Routen geschützt sind und wie ein
|
||||
* ANONYMER Aufruf sich verhalten muss:
|
||||
* - Seiten -> "redirect" (auf /login, mit callbackUrl)
|
||||
* - APIs -> "401" (ohne Daten-Leak)
|
||||
*
|
||||
* Der Driftschutz (routes.manifest.spec.ts + tests/unit/routes-manifest.test.ts)
|
||||
* stellt sicher, dass jede neue Route unter src/app/** entweder hier steht oder
|
||||
* in PUBLIC_ALLOWLIST. Ungetestete Routen sind damit ausgeschlossen.
|
||||
*
|
||||
* Beispiel-UUIDs für dynamische Segmente: anonyme Aufrufe werden VOR jeder
|
||||
* DB-Abfrage abgewiesen, daher müssen diese IDs nicht existieren.
|
||||
*/
|
||||
export { PUBLIC_ALLOWLIST } from "../support/route-scan";
|
||||
|
||||
export type AnonExpectation = "redirect" | "401";
|
||||
|
||||
export interface RouteEntry {
|
||||
/** Konkreter URL-Pfad (dynamische Segmente bereits aufgelöst). */
|
||||
path: string;
|
||||
/** Erwartetes Verhalten bei anonymem Zugriff. */
|
||||
expectWhenAnon: AnonExpectation;
|
||||
/** true für API-Routen (request statt page-Navigation). */
|
||||
api?: boolean;
|
||||
}
|
||||
|
||||
const EX_VEHICLE = "00000000-0000-0000-0000-0000000000a1";
|
||||
const EX_EQUIP = "00000000-0000-0000-0000-0000000000a2";
|
||||
const EX_BRIGADE = "00000000-0000-0000-0000-0000000000a3";
|
||||
const EX_TEMPLATE = "00000000-0000-0000-0000-0000000000a4";
|
||||
const EX_CATEGORY = "00000000-0000-0000-0000-0000000000a5";
|
||||
|
||||
export const ROUTES: readonly RouteEntry[] = [
|
||||
// (app) – Lese-Oberflächen
|
||||
{ path: "/", expectWhenAnon: "redirect" },
|
||||
{ path: "/start", expectWhenAnon: "redirect" },
|
||||
{ path: "/fahrzeuge", expectWhenAnon: "redirect" },
|
||||
{ path: `/fahrzeuge/${EX_VEHICLE}`, expectWhenAnon: "redirect" },
|
||||
{ path: "/geraete", expectWhenAnon: "redirect" },
|
||||
{ path: `/geraete/${EX_EQUIP}`, expectWhenAnon: "redirect" },
|
||||
{ path: "/wehren", expectWhenAnon: "redirect" },
|
||||
{ path: `/wehren/${EX_BRIGADE}`, expectWhenAnon: "redirect" },
|
||||
// (app)/verwaltung – Wehr-Bereich
|
||||
{ path: "/verwaltung/benutzer", expectWhenAnon: "redirect" },
|
||||
{ path: "/verwaltung/profil", expectWhenAnon: "redirect" },
|
||||
{ path: "/verwaltung/fahrzeuge", expectWhenAnon: "redirect" },
|
||||
{ path: "/verwaltung/fahrzeuge/neu", expectWhenAnon: "redirect" },
|
||||
{ path: `/verwaltung/fahrzeuge/${EX_VEHICLE}`, expectWhenAnon: "redirect" },
|
||||
{ path: "/verwaltung/geraete", expectWhenAnon: "redirect" },
|
||||
{ path: "/verwaltung/geraete/neu", expectWhenAnon: "redirect" },
|
||||
{ path: `/verwaltung/geraete/${EX_EQUIP}`, expectWhenAnon: "redirect" },
|
||||
// (admin) – Plattform-Verwaltung
|
||||
{ path: "/admin", expectWhenAnon: "redirect" },
|
||||
{ path: "/admin/audit", expectWhenAnon: "redirect" },
|
||||
{ path: "/admin/merkmale", expectWhenAnon: "redirect" },
|
||||
{ path: "/admin/merkmale/proposals", expectWhenAnon: "redirect" },
|
||||
{ path: "/admin/vorlagen", expectWhenAnon: "redirect" },
|
||||
{ path: `/admin/vorlagen/${EX_TEMPLATE}`, expectWhenAnon: "redirect" },
|
||||
{ path: "/admin/geraete-kategorien", expectWhenAnon: "redirect" },
|
||||
{
|
||||
path: `/admin/geraete-kategorien/${EX_CATEGORY}`,
|
||||
expectWhenAnon: "redirect",
|
||||
},
|
||||
{ path: "/admin/wehren", expectWhenAnon: "redirect" },
|
||||
{ path: "/admin/wehren/neu", expectWhenAnon: "redirect" },
|
||||
{ path: `/admin/wehren/${EX_BRIGADE}`, expectWhenAnon: "redirect" },
|
||||
// APIs (kein /api/auth, /api/health -> öffentlich/Allowlist)
|
||||
{ path: "/api/geo/geocode", expectWhenAnon: "401", api: true },
|
||||
{ path: "/api/geo/health", expectWhenAnon: "401", api: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Die Routen-Vorlagen (dynamische Segmente als Platzhalter), wie sie im
|
||||
* Dateisystem erscheinen. Wird vom Driftschutz mit discoverAppRoutes()
|
||||
* abgeglichen.
|
||||
*/
|
||||
export const DECLARED_ROUTE_TEMPLATES: ReadonlySet<string> = new Set([
|
||||
"/",
|
||||
"/start",
|
||||
"/fahrzeuge",
|
||||
"/fahrzeuge/[id]",
|
||||
"/geraete",
|
||||
"/geraete/[id]",
|
||||
"/wehren",
|
||||
"/wehren/[id]",
|
||||
"/verwaltung/benutzer",
|
||||
"/verwaltung/profil",
|
||||
"/verwaltung/fahrzeuge",
|
||||
"/verwaltung/fahrzeuge/neu",
|
||||
"/verwaltung/fahrzeuge/[id]",
|
||||
"/verwaltung/geraete",
|
||||
"/verwaltung/geraete/neu",
|
||||
"/verwaltung/geraete/[id]",
|
||||
"/admin",
|
||||
"/admin/audit",
|
||||
"/admin/merkmale",
|
||||
"/admin/merkmale/proposals",
|
||||
"/admin/vorlagen",
|
||||
"/admin/vorlagen/[id]",
|
||||
"/admin/geraete-kategorien",
|
||||
"/admin/geraete-kategorien/[id]",
|
||||
"/admin/wehren",
|
||||
"/admin/wehren/neu",
|
||||
"/admin/wehren/[id]",
|
||||
"/api/geo/geocode",
|
||||
"/api/geo/health",
|
||||
]);
|
||||
55
tests/e2e/search-eta.spec.ts
Normal file
55
tests/e2e/search-eta.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Suche & Eintreffzeit-Sortierung (Definition of Done #6, Plan WS11 Aufgabe 7 /
|
||||
* Verifikation 7).
|
||||
*
|
||||
* NICHT in der Sandbox ausführbar (kein Server/DB/OSRM) — deferred. Wird über
|
||||
* `npm run test:e2e` gegen einen geseedeten Server mit authentifizierter
|
||||
* Session ausgeführt.
|
||||
*
|
||||
* Garantien:
|
||||
* - Dynamische Filter (UND-verknüpft) liefern korrekte Teilmengen.
|
||||
* - Treffer sind nach ETA aufsteigend sortiert.
|
||||
* - OSRM-Ausfall (E2E_FORCE_HAVERSINE=1) -> sichtbarer "Luftlinie"-Fallback.
|
||||
*/
|
||||
test.skip(
|
||||
!process.env.E2E_AUTH_STATE && !process.env.E2E_WEHR_READ_STATE,
|
||||
"benötigt authentifizierte Session (Auth-Setup)",
|
||||
);
|
||||
test.use({
|
||||
storageState:
|
||||
process.env.E2E_AUTH_STATE ?? process.env.E2E_WEHR_READ_STATE ?? undefined,
|
||||
});
|
||||
|
||||
test("Filter grenzt Treffer ein (UND-Verknüpfung)", async ({ page }) => {
|
||||
await page.goto("/fahrzeuge");
|
||||
const before = await page.getByText(/Treffer/).first().innerText();
|
||||
await page.getByLabel("Nur einsatzbereit").click();
|
||||
await expect(page).toHaveURL(/bereit=1/);
|
||||
const after = await page.getByText(/Treffer/).first().innerText();
|
||||
// Teilmenge: Anzahl sinkt nicht und Filter ist in der URL aktiv.
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
test("Treffer mit Standort sind aufsteigend nach ETA sortiert", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/fahrzeuge?ort=St.+P%C3%B6lten");
|
||||
await page.waitForLoadState("networkidle");
|
||||
const etaTexts = await page
|
||||
.locator("[data-testid='eta-minutes']")
|
||||
.allInnerTexts();
|
||||
const minutes = etaTexts.map((t) => parseInt(t.replace(/\D/g, ""), 10));
|
||||
const sorted = [...minutes].sort((a, b) => a - b);
|
||||
expect(minutes).toEqual(sorted);
|
||||
});
|
||||
|
||||
test("OSRM-Ausfall zeigt Luftlinie-Fallback", async ({ page }) => {
|
||||
test.skip(
|
||||
process.env.E2E_FORCE_HAVERSINE !== "1",
|
||||
"nur mit E2E_FORCE_HAVERSINE=1",
|
||||
);
|
||||
await page.goto("/fahrzeuge?ort=St.+P%C3%B6lten");
|
||||
await expect(page.getByText(/Luftlinie/i).first()).toBeVisible();
|
||||
});
|
||||
50
tests/e2e/security-headers.spec.ts
Normal file
50
tests/e2e/security-headers.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Security-Header & Cookie-Flags (Definition of Done #8, Querschnittsstandard
|
||||
* 1, 9, 11).
|
||||
*
|
||||
* NICHT in der Sandbox ausführbar (kein Server) — deferred. Wird über
|
||||
* `npm run test:e2e` gegen einen laufenden Server ausgeführt. Der statische
|
||||
* Header-Satz ist zusätzlich offline durch src/lib/security/headers.test.ts
|
||||
* abgesichert.
|
||||
*
|
||||
* Verifikation entspricht: `curl -sI https://<host>/login | grep -i x-frame-options`.
|
||||
*/
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe("Security-Header auf /login", () => {
|
||||
test("setzt X-Frame-Options, nosniff, CSP frame-ancestors none, HSTS", async ({
|
||||
request,
|
||||
}) => {
|
||||
const res = await request.get("/login");
|
||||
const h = res.headers();
|
||||
expect(h["x-frame-options"]).toBe("DENY");
|
||||
expect(h["x-content-type-options"]).toBe("nosniff");
|
||||
expect(h["content-security-policy"]).toContain("frame-ancestors 'none'");
|
||||
expect(h["strict-transport-security"]).toMatch(/max-age=\d+/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Session-Cookie-Flags", () => {
|
||||
test("nach Login: Session-Cookie ist httpOnly + sameSite", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
// Erwartet einen funktionierenden Credentials-Login (Seed). Deferred.
|
||||
await page.goto("/login");
|
||||
await page.getByLabel(/E-Mail/i).fill("wehr-admin-a@example.test");
|
||||
await page.getByLabel(/Passwort/i).fill("Test-Passwort-1234");
|
||||
await page.getByRole("button", { name: /Anmelden/i }).click();
|
||||
await page.waitForURL((url) => !url.pathname.startsWith("/login"));
|
||||
|
||||
const cookies = await context.cookies();
|
||||
const session = cookies.find((c) => /authjs|next-auth|__Secure-/.test(c.name));
|
||||
expect(session, "Session-Cookie gesetzt").toBeTruthy();
|
||||
expect(session?.httpOnly).toBe(true);
|
||||
expect(session?.sameSite).toMatch(/Lax|Strict/);
|
||||
// `secure` nur unter https (Querschnittsstandard 9). Lokal (http) false.
|
||||
const isHttps = (process.env.E2E_BASE_URL ?? "").startsWith("https://");
|
||||
expect(session?.secure).toBe(isHttps);
|
||||
});
|
||||
});
|
||||
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",
|
||||
globals: true,
|
||||
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