diff --git a/docs/superpowers/plans/2026-06-08-floriannetz-implementation-plan.md b/docs/superpowers/plans/2026-06-08-floriannetz-implementation-plan.md new file mode 100644 index 0000000..9df6dbc --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-floriannetz-implementation-plan.md @@ -0,0 +1,1374 @@ +# FlorianNetz — Implementierungsplan + +## Überblick + +FlorianNetz ist ein internes Verzeichnis- und Suchsystem für die niederösterreichischen Feuerwehren (NÖ FF): Wehren erfassen ihren Fuhrpark (Fahrzeuge) und ihre Geräte mit typisierten technischen Merkmalen; alle authentifizierten Nutzer können wehrübergreifend nach Fahrzeugen/Geräten/Wehren suchen, dynamisch nach Merkmalen filtern und Treffer nach Eintreffzeit (Fahrzeit ab eigenem Standort) sortieren, um im Bedarfsfall die nächstgelegene passende Ressource out-of-band (Telefon/E-Mail) zu kontaktieren. + +**Stack:** Next.js 15 (App Router, React 19, TypeScript strict), Tailwind + Radix („Amtlich"/Netzknoten-Designsystem), Drizzle ORM auf PostgreSQL 16, Auth.js v5 (Authentik-OIDC für Platform-Admins, Credentials/argon2id für Wehr-Konten), selbstgehostetes OSRM + Nominatim auf Österreich-OSM-Extrakt, Deployment via Docker Compose hinter einem **bereits vorhandenen, externen** Traefik. Tests mit Vitest (Units) und Playwright (E2E). + +**Die harte Regel — „kein anonymer Zugriff":** Jede Seite, jede API-Route und jede Server Action ist standardmäßig gesperrt (default-deny). Das wird auf **drei** Ebenen erzwungen und ist nicht verhandelbar: +1. zentrale `middleware.ts` (erste Verteidigungslinie), +2. **serverseitiger Guard im Layout jeder Route-Group** (`(app)`, `(admin)`, Wehr-`verwaltung`) sowie in **jeder** API-Route und **jeder** Server Action (Verteidigung in der Tiefe — die Middleware allein genügt nicht), +3. eine kritische Playwright-Suite, die für **jede** im Manifest geführte Route anonym Redirect (Seiten) bzw. `401` (API) beweist, inkl. Driftschutz gegen neue, ungetestete Routen und Server Actions. + +Dieses Dokument ist für einen Engineer ohne Vorwissen geschrieben: exakte Dateipfade, lauffähige Code-/Schema-Snippets, geordnete Aufgaben und konkrete Verifikationsschritte je Workstream. + +--- + +## Build-Reihenfolge & Meilensteine + +Die Reihenfolge ist das Rückgrat des Plans. Jede Phase setzt voraus, dass die vorherige gemergt ist. **Kritische Vorbedingung:** Alle Schema-Entscheidungen (siehe Phase 1) müssen vor der **ersten** Migration getroffen sein — sonst entstehen inkompatible Parallel-Migrationen. + +**Phase 0 — Fundament (keine Abhängigkeiten, muss zuerst gemergt sein)** +1. **Projekt-Fundament & Design-System** — Next.js-Scaffolding, Tailwind/Radix-Theme, **EIN kanonisches `src/lib/env.ts`** (inkl. ALLER DB-/Auth-/Geo-Variablen + `AUTH_URL`), `loading.tsx`/`error.tsx`/`not-found.tsx`-Templates je Route-Group, i18n-Entscheidung (zentrale `de.ts` ist verbindlich). Aktiviert `experimental.authInterrupts` in `next.config.ts` (für `forbidden()`). + +**Phase 1 — Datenfundament (hängt von Phase 0)** +2. **Datenbankschema & Migrationen** — **alleiniger Eigentümer** aller Tabellen, Enums und Indizes. Generiert die einzige initiale Migration. Trifft vorab die Querschnitt-Entscheidungen: + - Rollen-Enum heißt **`role`** mit Werten `platform_admin|wehr_admin|wehr_read`; Auth-Typ-Enum heißt **`auth_typ`** (`authentik|local`). Status-Enum heißt **`asset_status`** (`einsatzbereit|wartung|ausser_dienst`). + - **Funkrufname ist eine Spalte** auf `vehicles` — **nicht** ein Merkmal. Merkmal-Katalog hat damit **34** Merkmale. + - **HLF 4-U** ist eine **Alias-Variante** von HLF 4 (Pulver-Pflichtmerkmale), **keine** eigene Vorlage → **11** Vorlagen. + - Aliasse leben in eigener Tabelle **`vehicle_template_aliasse`** mit `bestaetigt`-Flag — **kein** `jsonb`. + - Vorlagen-Vorgabewert: **drei typisierte Spalten** `vorgabewert_num/_text/_bool`. + - `merkmale` hat zusätzlich `slug text NOT NULL UNIQUE` (Idempotenz-Key für Seed). + - Alle JS-Property-Namen **ASCII** (`wehrfuehrer`, nicht `wehrführer`). + +**Phase 2 — Auth & Sicherheits-Rückgrat (hängt von Phase 1)** +3. **Authentifizierung & Zugriffskontrolle** — importiert das Schema (definiert es nicht neu). Liefert **alle** Guards (`requireSession/requireRole/requireOwnBrigade/requirePlatformAdmin/requireWehrAdmin`) als einzigen Satz. `(app)`-Layout-Gate serverseitig; API gibt `401` (nicht Redirect); Rate-Limit im `authorize`-Callback; Cookie-`secure` umgebungsabhängig; Middleware-Allowlist inkl. `api/health`. + +**Phase 3 — Geo & Suche-Kern (parallel; beide hängen von Phase 1)** +4. **Geo & Eintreffzeit-Sortierung** — kanonische, reine `geocodeAddress(adresszeile)`-API + `orderByEintreffzeit` mit Bounding-Box-Vorfilter; definiert den `searchHitsToGeoCandidates`-Adapter. +5. **Dynamische Suche & Filter** — uuid-IDs durchgängig; importiert `codes.ts` aus Admin; ergänzt nur `pg_trgm`-Indizes. + +**Phase 4 — Schreib-Features (hängen von Phase 1+2; Admin vor Wehr-CRUD)** +6. **Admin-Panel: Taxonomie & Bereitstellung** — Eigentümer von `codes.ts`/Allrad-Regel; alle Schreib-Actions mit Audit. +7. **Wehr-Bereich: Fuhrpark & Benutzer** — nutzt Admin-Taxonomie + Geo-Geocoding; keine `any`-Typen; Audit vollständig. + +**Phase 5 — Lese-Oberflächen (hängen von Phase 3+4)** +8. **Detailseiten & Kontakt** — Lese-Queries über alle Wehren; Gruppen-Gate aus Phase 2 schützt. + +**Phase 6 — Seed (hängt von Phase-1-Schema + Phase-4-Taxonomietabellen)** +9. **Seed-Daten aus NÖ-Katalog** — befüllt `vehicle_template_aliasse` mit `bestaetigt`; HLFA **nicht** als Alias; 34 Merkmale; HLF 4-U als Alias. + +**Phase 7 — Auslieferung & Härtung (hängen von allem)** +10. **Deployment (Docker + externes Traefik)** — `/api/health`-Allowlist, `AUTH_URL`/Forwarded-Header. +11. **Tests & Sicherheitshärtung** — Gating-Suite inkl. Server-Action-Pfade und API-`401`; Rate-Limit-Test auf korrektem Pfad; Drift-Check über Routen + Actions. + +--- + +## Querschnittsstandards + +Diese Regeln gelten für **jede** Aufgabe in **jedem** Workstream. Abweichungen sind Bugs. + +1. **Default-deny serverseitig, dreifach.** Middleware + Route-Group-Layout-Guard (`await requireSession()` / `requireRole()` / `requireWehrAdmin()` / `requirePlatformAdmin()` als erste Zeile) + Guard in jeder API-Route und jeder Server Action. Lese-Seiten dürfen sich **nicht** allein auf die Middleware verlassen. +2. **API-Routen geben `401`/`403` zurück, keine HTML-Redirects.** Jede `route.ts` ruft `auth()` selbst und antwortet bei fehlender Session mit `401`, bei falscher Rolle/fremder Wehr mit `403`. Fehlerbodies enthalten **keine** Fachdaten (kein Daten-Leak). +3. **Server Actions beginnen mit einem Guard.** Jede `"use server"`-Funktion ruft als erste Anweisung einen Guard. Ein Lint-/Test-Check erzwingt das (Test-Workstream). +4. **Zod überall an der Grenze.** Jede Eingabe (FormData, JSON-Body, searchParams) wird mit Zod validiert, bevor sie die DB erreicht. Ungültige Eingaben → klare deutsche Fehlermeldung, kein 500. +5. **Deutsche i18n via zentraler Tabelle.** UI-Strings kommen aus `src/lib/i18n/de.ts` über `t()`. Keine hartkodierten deutschen Strings im JSX (Ausnahme: einmalige Seitentitel sind erlaubt, müssen aber konsistent sein). Die Tabelle ist verbindlich, nicht totes Gerüst. +6. **Audit-Logging-Pflicht.** Folgende Aktionen schreiben `writeAudit`: `brigade.create`, `user.create`, `user.deactivate`, `user.reset`, `merkmal.create`, `merkmal.promote`, `merkmal.merge`, `vehicle.create/update/delete`, `vehicle.status`, `equipment.create/update/delete`, `brigade.profile_update`. `writeAudit` hat **eine** Signatur mit optionalem `tx`-Parameter. +7. **Idempotente Migrationen & Seeds.** Migrationen: `CREATE TYPE` in `DO $$ … EXCEPTION WHEN duplicate_object THEN null; END $$;`, Indizes `IF NOT EXISTS`. Seeds: ausschließlich `onConflictDoUpdate` auf Natural Keys; mehrfaches Ausführen ändert keine Counts. +8. **Genau ein Migrations-Eigentümer.** Nur der DB-Workstream generiert die initiale Migration. Feature-Workstreams generieren Folge-Migrationen erst **nach** Merge des DB-Schemas. Kein paralleles `0000_*`. +9. **Traefik / Forwarded-Header / sichere Cookies.** `AUTH_TRUST_HOST=true`, `AUTH_URL=https://`. Cookie-`secure` und `__Secure-`-Präfix nur, wenn `AUTH_URL` `https://` ist — sonst bricht lokale HTTP-Entwicklung. `sameSite=lax`, `httpOnly=true`. +10. **States: loading / empty / error.** Jede Route-Group hat `loading.tsx` und `error.tsx` (deutsche Texte). Listen haben Empty-States. Geo-Calls (4 s Timeout) brauchen Suspense/Loading, damit die Suche nicht einfriert. +11. **argon2id mit OWASP-Minima.** `type=argon2id`, `memoryCost ≥ 19456`, `timeCost ≥ 2`, `parallelism ≥ 1`. argon2 wird **nie** im Edge-/Middleware-Pfad importiert. +12. **TypeScript strict, kein `any`.** Drizzle-Transaktionstypen korrekt verwenden. `forbidden()` aus `next/navigation` nur mit aktiviertem `experimental.authInterrupts`. + +--- + +## Workstream 1 — Projekt-Fundament & Design-System (Phase 0) + +### Ziel +Greenfield-Scaffolding einer Next.js-15-App-Router-Anwendung (TS strict) mit Route-Groups `(auth)`/`(app)`, dem „Amtlich"/Netzknoten-Designsystem, deutschem i18n, Zod- und Drizzle-Setup inkl. validierter Env-Ladung, plus den verbindlichen `loading.tsx`/`error.tsx`-Templates. Enthält bewusst **keine** Auth-Logik und **keine** fachlichen Tabellen. + +### Abhängigkeiten +- **Keine** — Basis-Workstream, zuerst gemergt. +- Stellt für Auth bereit: Route-Group-Layouts, **das kanonische `env.ts` mit allen Auth-/Geo-/DB-Slots inkl. `AUTH_URL`**, den Guard-Slot im `(app)`-Layout (vom Auth-Workstream gefüllt). +- Stellt für DB-Schema bereit: Drizzle-Client-Konvention, `drizzle.config.ts`, Migrations-Ordner. +- Stellt für Suche/Admin das Designsystem bereit. + +### Dateien + +| Pfad | Zweck | +|---|---| +| `package.json` | Dependencies + Scripts | +| `tsconfig.json` | TS strict + Alias `@/*` → `src/*` | +| `next.config.ts` | `output:"standalone"`, `experimental.authInterrupts:true`, Security-Header-Einhängung | +| `.nvmrc` | Node `22` | +| `.env.example` | dokumentierte Env (committed) | +| `.gitignore` | `node_modules`, `.next`, `.env*`, `tests/e2e/.auth/` | +| `eslint.config.mjs` | Flat-Config `next/core-web-vitals` + TS | +| `prettier.config.mjs` | + `prettier-plugin-tailwindcss` | +| `postcss.config.mjs` | Tailwind/PostCSS | +| `tailwind.config.ts` | Design-Tokens | +| `src/styles/globals.css` | Tailwind-Layers, CSS-Variablen | +| `src/app/layout.tsx` | Root-Layout ``, Schriften | +| `src/app/page.tsx` | Root-Redirect → `/start` bzw. Login | +| `src/app/(auth)/layout.tsx` | Schlankes Login-Layout (kein Chrome) | +| `src/app/(auth)/loading.tsx`, `.../error.tsx` | Auth-Group-States | +| `src/app/(app)/layout.tsx` | Gated App-Shell (Guard-Slot, vom Auth-WS gefüllt) | +| `src/app/(app)/loading.tsx`, `.../error.tsx`, `.../not-found.tsx` | App-Group-States | +| `src/app/(app)/start/page.tsx` | Platzhalter-Startseite | +| `src/components/ui/{button,input,tabs,dialog,badge,label,select,switch,slider}.tsx` | Radix-Basiskomponenten | +| `src/components/layout/{app-shell,topbar}.tsx` | Shell + Topbar | +| `src/components/brand/netzknoten-logo.tsx` | Inline-SVG-Logo | +| `src/lib/utils.ts` | `cn()` | +| `src/lib/env.ts` | **kanonisches** Zod-Env (server + client) | +| `src/lib/i18n/de.ts` | zentrale String-Tabelle + `t()` | +| `src/lib/validation/common.ts` | gemeinsame Zod-Schemas | +| `src/db/client.ts` | Drizzle-`pg`-Client (Singleton) | +| `src/db/schema/index.ts` | leeres Barrel (vom DB-WS gefüllt) | +| `drizzle.config.ts` | Drizzle-Kit-Config | +| `vitest.config.ts` | Vitest | +| `src/lib/__tests__/env.test.ts` | Env-Fail-Fast-Test | + +### Schlüssel-Code + +**`next.config.ts`** (standalone + authInterrupts + Security-Header) +```ts +import type { NextConfig } from "next"; +import { SECURITY_HEADERS } from "./src/lib/security/headers"; + +const nextConfig: NextConfig = { + output: "standalone", + poweredByHeader: false, + experimental: { authInterrupts: true }, // erlaubt forbidden() aus next/navigation + async headers() { + return [{ source: "/(.*)", headers: Object.entries(SECURITY_HEADERS).map(([key, value]) => ({ key, value })) }]; + }, +}; +export default nextConfig; +``` + +**`src/lib/env.ts`** (kanonisch — ALLE Variablen aller Workstreams; Auth-Slots scharf, wenn vorhanden) +```ts +import { z } from "zod"; + +const serverSchema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + DATABASE_URL: z.string().url(), + // Auth (vom Auth-WS konsumiert; AUTH_URL bestimmt Cookie-secure): + AUTH_SECRET: z.string().min(32), + AUTH_URL: z.string().url(), + AUTH_TRUST_HOST: z.coerce.boolean().default(true), + AUTHENTIK_ISSUER: z.string().url(), + AUTHENTIK_CLIENT_ID: z.string().min(1), + AUTHENTIK_CLIENT_SECRET: z.string().min(1), + // Geo: + OSRM_URL: z.string().url().default("http://osrm:5000"), + NOMINATIM_URL: z.string().url().default("http://nominatim:8080"), + GEO_HTTP_TIMEOUT_MS: z.coerce.number().int().positive().default(4000), + HAVERSINE_KMH: z.coerce.number().positive().default(50), +}); + +const parsed = serverSchema.safeParse(process.env); +if (!parsed.success) { + console.error("Ungültige Umgebungsvariablen:", parsed.error.flatten().fieldErrors); + throw new Error("Umgebungsvariablen-Validierung fehlgeschlagen"); +} +export const env = parsed.data; +export const isHttps = env.AUTH_URL.startsWith("https://"); +``` +> Hinweis: In CI/Build ohne echte Authentik-Werte werden Platzhalter gesetzt; in `NODE_ENV=test` dürfen `AUTHENTIK_*` durch leere Test-Defaults ersetzt werden (separater Test-Env-Pfad). + +**`tailwind.config.ts`** (Tokens — Navy `#1B3A5B`, Signalrot `#E2231A`, Anthrazit `#1A2530`, Nebelgrau `#F6F8FA`, Bereit `#1F8F5A`, Wartung `#B5460F`, Rand `#D9DEE5`; `fontFeatureSettings tnum`; `borderRadius` dezent 2/4/6px; `font-display` Source Serif 4, `font-sans` Inter). + +**`src/lib/i18n/de.ts`** (verbindliche String-Tabelle, typsicher) +```ts +export const de = { + app: { name: "FlorianNetz" }, + nav: { fahrzeuge: "Fahrzeuge", geraete: "Geräte", wehren: "Wehren", verwaltung: "Verwaltung", admin: "Administration" }, + auth: { anmelden: "Anmelden", abmelden: "Abmelden", erforderlich: "Anmeldung erforderlich." }, + status: { einsatzbereit: "einsatzbereit", wartung: "Wartung", ausser_dienst: "außer Dienst" }, + search: { meinStandort: "Meinen Standort verwenden", suchen: "Suchen", keineTreffer: "Keine Treffer.", luftlinie: "Luftlinie (geschätzt)" }, + detail: { eckdaten: "Eckdaten", beladung: "Beladung", keineEckdaten: "Keine Eckdaten erfasst.", imGeraetehaus: "im Gerätehaus" }, + fehler: { allgemein: "Es ist ein Fehler aufgetreten.", keineBerechtigung: "Keine Berechtigung." }, +} as const; + +type Leaf = string; +type Paths = T extends Leaf ? "" : + { [K in keyof T & string]: T[K] extends Leaf ? K : `${K}.${Paths}` }[keyof T & string]; +export function t(path: Paths): string { + return path.split(".").reduce((o, k) => (o as Record)[k], de) as string; +} +``` + +**`src/components/ui/badge.tsx`** (Status — entspricht `asset_status`) +```tsx +import { cn } from "@/lib/utils"; +const STATUS = { + einsatzbereit: { label: "einsatzbereit", cls: "bg-bereit/10 text-bereit border-bereit/30" }, + wartung: { label: "Wartung", cls: "bg-wartung/10 text-wartung border-wartung/30" }, + ausser_dienst: { label: "außer Dienst", cls: "bg-anthrazit/10 text-anthrazit border-anthrazit/30" }, +} as const; +export type StatusKey = keyof typeof STATUS; +export function StatusBadge({ status }: { status: StatusKey }) { + const s = STATUS[status]; + return ( + + + {s.label} + + ); +} +``` + +**`src/app/(app)/error.tsx`** (Beispiel-State, je Group analog) +```tsx +"use client"; +import { t } from "@/lib/i18n/de"; +export default function AppError({ reset }: { error: Error; reset: () => void }) { + return ( +
+

{t("fehler.allgemein")}

+ +
+ ); +} +``` + +**`src/db/client.ts`**, **`drizzle.config.ts`**, **`src/db/schema/index.ts`** (leeres Barrel `export {};`) wie Standard-Drizzle-Setup; `client.ts` als Pool-Singleton (`globalThis`-Cache außerhalb Produktion). + +### Aufgaben +1. Next.js-Gerüst (`package.json`, `tsconfig.json` strict + Alias, `next.config.ts` mit `output:"standalone"` + `authInterrupts` + Security-Header, `.nvmrc`, `.gitignore`, ESLint/Prettier); `npm install`. +2. Tailwind/PostCSS + Tokens + `globals.css`. +3. Root-Layout + Schriften (`next/font`); `page.tsx` Redirect-Stub. +4. Route-Groups `(auth)` und `(app)` inkl. **`loading.tsx`/`error.tsx`/`not-found.tsx`** je Group; Guard-Slot-Kommentar im `(app)/layout.tsx`. +5. Brand + Layout-Komponenten. +6. Radix-Basiskomponenten inkl. `select`, `switch`, `slider` (für Suche/Editoren). +7. `de.ts` (+ `t()`), `validation/common.ts`, kanonisches `env.ts`. +8. Drizzle-Setup (`schema/index.ts` leer, `client.ts`, `drizzle.config.ts`, `.env.example`). +9. Vitest-Setup + `env.test.ts`. +10. Smoke-Run. + +### Verifikation +1. `npm run typecheck` Exit 0; `npm run lint` ohne Errors. +2. `grep -q "1B3A5B" tailwind.config.ts` bestätigt Navy-Token. +3. `npm run dev`; `curl -s localhost:3000/anmelden | grep -q 'lang="de"'` und Antwort enthält `FlorianNetz`. +4. Browser `/start`: Topbar mit Netzknoten-SVG, Serifen-Überschrift, Nebelgrau; `/anmelden` ohne Topbar. +5. Komponenten-Sichtprüfung: `Button variant="signal"` signalrot, `StatusBadge` korrekte Farben, `tabular-nums` gleich breit. +6. `npm test` → `env.test.ts` grün (Fail-Fast). +7. `npm run build` erfolgreich mit `output:"standalone"` (`test -f .next/standalone/server.js`). + +--- + +## Workstream 2 — Datenbankschema & Migrationen (Phase 1, alleiniger Schema-Eigentümer) + +### Ziel +Vollständiges, typsicheres Drizzle-Schema für alle Tabellen aus Spec §6 (EAV-Merkmalwerte mit typisierten Spalten + den vier geforderten Indizes, alle Enums, Templates, Aliasse als eigene Tabelle mit `bestaetigt`, `login_attempts`, `audit_log`) und daraus eine einzige, idempotente initiale Migration. **Dieser Workstream besitzt exklusiv alle Tabellen, Enums und Indizes.** + +### Abhängigkeiten +- **Phase 0** (Tooling, Docker-Compose-`postgres`, `env.ts`). +- Voraussetzung für Auth, Suche, Admin, Wehr-CRUD, Geo, Seed. Diese **importieren** nur. + +### Verbindliche Entscheidungen (Querschnitt) +- Enums: `role(platform_admin,wehr_admin,wehr_read)`, `auth_typ(authentik,local)`, `merkmal_typ(number,enum,boolean,text)`, `merkmal_status(active,proposed)`, `geltungsbereich(vehicle,equipment,both)`, `asset_status(einsatzbereit,wartung,ausser_dienst)`, `entity_typ(vehicle,equipment)`. +- `vehicles.funkrufname` ist **Spalte**. Funkrufname ist **kein** Merkmal. +- Aliasse: Tabelle `vehicle_template_aliasse(template_id, alias, bestaetigt)` — **kein jsonb**. +- Vorgabewerte typisiert: `vorgabewert_num/_text/_bool`. +- `merkmale.slug text NOT NULL UNIQUE`. +- HLF 4-U = Alias, nicht eigene Vorlage. +- ASCII-Properties (`wehrfuehrer`). + +### Dateien +- `src/db/index.ts` — Pool + `drizzle()`-Singleton (Export `db`, `pool`). +- `src/db/schema/enums.ts`, `brigades.ts`, `users.ts`, `merkmale.ts`, `templates.ts`, `equipment-categories.ts`, `assets.ts`, `merkmal-values.ts`, `auth-rate-limit.ts`, `audit.ts`, `relations.ts`, `index.ts` (Barrel). +- `drizzle.config.ts`, `scripts/migrate.ts`, `package.json` (Scripts `db:generate/migrate/push/studio`). +- `src/db/schema/__tests__/schema.test.ts`. + +### Schlüssel-Schema (Auszüge) + +**`enums.ts`** — alle pgEnums wie oben. + +**`users.ts`** (Platform-Admin `brigadeId=NULL`; `passwortHash=NULL` bei Authentik) +```ts +import { pgTable, uuid, text, boolean, timestamp, unique } from "drizzle-orm/pg-core"; +import { brigades } from "./brigades"; +import { roleEnum, authTypEnum } from "./enums"; + +export const users = pgTable("users", { + id: uuid("id").primaryKey().defaultRandom(), + brigadeId: uuid("brigade_id").references(() => brigades.id, { onDelete: "restrict" }), + rolle: roleEnum("rolle").notNull(), + authTyp: authTypEnum("auth_typ").notNull(), + email: text("email").notNull(), + name: text("name").notNull(), + passwortHash: text("passwort_hash"), + aktiv: boolean("aktiv").notNull().default(true), + erstelltVon: uuid("erstellt_von"), + erstelltAm: timestamp("erstellt_am", { withTimezone: true }).notNull().defaultNow(), +}, (t) => ({ emailUq: unique("users_email_uq").on(t.email) })); +``` +> Spalten-Enum-Name in der DB: `roleEnum` wird als `pgEnum("role", …)`, NICHT `rolle`, definiert; das Drizzle-Property heißt `rolle`. So gibt es nur **ein** DB-Enum `role`. + +**`brigades.ts`** — inkl. `lat/lng doublePrecision`, `geocodeQuery text`, `geocodedAt timestamptz`, `geocodeStatus text`, `wehrfuehrer text`, `funkrufnameSchema text`, `aktiv`, `bundesland default 'Niederösterreich'`. + +**`merkmale.ts`** +```ts +export const merkmale = pgTable("merkmale", { + id: uuid("id").primaryKey().defaultRandom(), + slug: text("slug").notNull(), // Idempotenz-Key für Seed + name: text("name").notNull(), + typ: merkmalTypEnum("typ").notNull(), + einheit: text("einheit"), + geltungsbereich: geltungsbereichEnum("geltungsbereich").notNull(), + status: merkmalStatusEnum("status").notNull().default("proposed"), + vorgeschlagenVonBrigadeId: uuid("vorgeschlagen_von_brigade_id").references(() => brigades.id, { onDelete: "set null" }), + erstelltAm: timestamp("erstellt_am", { withTimezone: true }).notNull().defaultNow(), +}, (t) => ({ + slugUq: unique("merkmale_slug_uq").on(t.slug), + // partieller Unique-Index auf name nur für status='active' (per sql in Migration): + byStatus: index("merkmale_status_idx").on(t.status), +})); +// merkmalOptionen(merkmalId, wert, label, reihenfolge), unique(merkmalId, wert) +``` +> Partieller Unique-Index `CREATE UNIQUE INDEX merkmale_active_name_uq ON merkmale (name) WHERE status='active'` in der Migration (handnachbearbeitet). + +**`templates.ts`** +```ts +export const vehicleTemplates = pgTable("vehicle_templates", { + id: uuid("id").primaryKey().defaultRandom(), + code: text("code").notNull(), + name: text("name").notNull(), + beschreibung: text("beschreibung"), + reihenfolge: integer("reihenfolge").notNull().default(0), +}, (t) => ({ codeUq: unique("vehicle_templates_code_uq").on(t.code) })); + +export const vehicleTemplateMerkmale = pgTable("vehicle_template_merkmale", { + templateId: uuid("template_id").notNull().references(() => vehicleTemplates.id, { onDelete: "cascade" }), + merkmalId: uuid("merkmal_id").notNull().references(() => merkmale.id, { onDelete: "cascade" }), + vorgabewertNum: doublePrecision("vorgabewert_num"), + vorgabewertText: text("vorgabewert_text"), + vorgabewertBool: boolean("vorgabewert_bool"), + pflicht: boolean("pflicht").notNull().default(false), + reihenfolge: integer("reihenfolge").notNull().default(0), +}, (t) => ({ pk: primaryKey({ columns: [t.templateId, t.merkmalId] }) })); + +export const vehicleTemplateAliasse = pgTable("vehicle_template_aliasse", { + id: uuid("id").primaryKey().defaultRandom(), + templateId: uuid("template_id").notNull().references(() => vehicleTemplates.id, { onDelete: "cascade" }), + alias: text("alias").notNull(), + bestaetigt: boolean("bestaetigt").notNull().default(false), +}, (t) => ({ uq: unique("vehicle_template_aliasse_uq").on(t.templateId, t.alias) })); +``` + +**`merkmal-values.ts`** — EAV mit den vier geforderten Indizes +```ts +export const merkmalValues = pgTable("merkmal_values", { + id: uuid("id").primaryKey().defaultRandom(), + merkmalId: uuid("merkmal_id").notNull().references(() => merkmale.id, { onDelete: "cascade" }), + entityTyp: entityTypEnum("entity_typ").notNull(), + entityId: uuid("entity_id").notNull(), // polymorph -> vehicles.id ODER equipment.id + valueNum: doublePrecision("value_num"), + valueText: text("value_text"), + valueBool: boolean("value_bool"), +}, (t) => ({ + idxNum: index("mv_merkmal_num_idx").on(t.merkmalId, t.valueNum), + idxBool: index("mv_merkmal_bool_idx").on(t.merkmalId, t.valueBool), + idxText: index("mv_merkmal_text_idx").on(t.merkmalId, t.valueText), + idxEntity: index("mv_entity_idx").on(t.entityTyp, t.entityId), +})); +``` + +**`assets.ts`** — `vehicles` (mit `templateId` `set null`, `funkrufname`, `status asset_status`), `equipment` (mit `categoryId`, `vehicleId` nullbar = im Gerätehaus). **`auth-rate-limit.ts`** — `login_attempts(key, erfolg, zeitpunkt)` + Index `login_attempts_key_zeit_idx`. **`audit.ts`** — `audit_log(actorUserId set null, aktion, zielTyp, zielId, details jsonb, zeitpunkt)` + Indizes auf `zeitpunkt`/`aktion`. Zusätzlich `brigades_latlng_idx` auf `(lat, lng)`. + +**`scripts/migrate.ts`** — programmatischer Runner (`drizzle-orm/node-postgres/migrator`). + +### Aufgaben +1. DB-Container (`postgres:16` in `docker-compose.yml`), `DATABASE_URL` in `.env`. +2. Drizzle-Deps (`drizzle-orm`, `pg`, dev: `drizzle-kit`, `@types/pg`, `tsx`, `vitest`). +3. Alle Schema-Dateien + `enums.ts` + `relations.ts` + Barrel anlegen (Entscheidungen oben umsetzen). +4. `db/index.ts`, `drizzle.config.ts`, `scripts/migrate.ts`, `package.json`-Scripts. +5. `tsc --noEmit`, `drizzle-kit check`. +6. **Einzige** initiale Migration generieren (`db:generate`). +7. Migration anwenden (`db:migrate`). +8. Idempotenz herstellen: `CREATE TYPE` in DO-Block, `CREATE INDEX IF NOT EXISTS`, partiellen `merkmale_active_name_uq` + `brigades_latlng_idx` ergänzen; Re-Run testen. +9. Schema-Tests (Enums, vier `merkmal_values`-Indizes, FKs, Uniques, partieller Name-Unique, `login_attempts`-Index). +10. EAV-Smoke-Insert (Brigade→Merkmal→Vehicle→`value_num`) Round-Trip. + +### Verifikation +1. `docker compose up -d postgres`; `pg_isready` ok; `psql "$DATABASE_URL" -c "select 1"`. +2. `npm ls drizzle-orm drizzle-kit pg` ohne UNMET. +3. `npx tsc --noEmit` 0 Fehler; `npx drizzle-kit check` keine Konflikte. +4. `db:generate` erzeugt eine `0000_*.sql` mit `CREATE TABLE "merkmal_values"`, 7 Enums, ≥4 Index-Statements für `merkmal_values`. +5. `db:migrate` Exit 0; `\dt` listet alle Tabellen + `vehicle_template_aliasse` + `login_attempts` + `audit_log` + `__drizzle_migrations`. +6. Zweiter `db:migrate` Exit 0, keine `already exists`; manueller `psql -f 0000_*.sql` fehlerfrei. +7. Schema-Test grün: `enum_range(NULL::asset_status)` enthält `einsatzbereit,wartung,ausser_dienst`; alle vier `mv_*`-Indizes vorhanden; `merkmale_active_name_uq` ist partiell (`WHERE status='active'`); zwei `active`-Merkmale gleichen Namens schlagen fehl, zwei `proposed` gelingen; `users_email_uq`, `vehicle_templates_code_uq` existieren. +8. EAV-Round-Trip liefert `value_num=2000`; FK-Verletzung wirft `23503`. + +--- + +## Workstream 3 — Authentifizierung & Zugriffskontrolle (Phase 2, kritisch) + +### Ziel +Einheitliche Auth.js-v5-Sitzung (`role` + `brigadeId`) über Authentik-OIDC (Platform-Admins) und Credentials/argon2id (Wehr-Konten). Default-deny dreifach. Liefert **alle** Guards als einzigen Satz, den jeder Feature-Workstream importiert. + +### Abhängigkeiten +- **DB (Phase 1)** — importiert `users`/`brigades`/`loginAttempts` (definiert sie **nicht** neu). +- **Phase 0** — kanonisches `env.ts` (keine eigene Env-Datei). +- Liefert für alle Feature-WS die Guards + `(auth)`-Segment. + +### Fixes inline +- `(app)/layout.tsx` ruft serverseitig `requireSession()` (Verteidigung in der Tiefe). +- API-Routen geben `401`/`403` selbst zurück. +- Rate-Limit im `authorize`-Callback (greift für beide Login-Pfade), nicht nur in der Action. +- Cookie-`secure`/`__Secure-`-Präfix umgebungsabhängig (`isHttps`). +- Middleware-Allowlist enthält `api/auth`, `login`, `api/health`, Statics. +- `forbidden()` nutzbar (Flag in Phase 0 aktiviert). + +### Dateien +- `src/auth.ts`, `src/auth.config.ts`, `src/middleware.ts` +- `src/lib/auth/guards.ts` (`requireSession/requireRole/requireOwnBrigade/requirePlatformAdmin/requireWehrAdmin`) +- `src/lib/auth/password.ts` (argon2id), `src/lib/auth/rate-limit.ts`, `src/lib/auth/roles.ts` +- `src/types/next-auth.d.ts` +- `src/app/api/auth/[...nextauth]/route.ts` +- `src/app/(auth)/login/{page.tsx,login-form.tsx,actions.ts}` +- `tests/e2e/auth-gating.spec.ts`, `tests/unit/{guards,password}.test.ts` + +### Schlüssel-Code + +**`src/lib/auth/roles.ts`** — `ROLES`/`Role`/`ALL_ROLES` als Single Source of Truth. + +**`src/lib/auth/password.ts`** (argon2id, nur Node) +```ts +import { hash, verify } from "@node-rs/argon2"; // NIE im Edge/Middleware importieren +export const ARGON2_PARAMS = { type: 2 as const, memoryCost: 19456, timeCost: 2, parallelism: 1 }; +export const hashPassword = (pw: string) => hash(pw, ARGON2_PARAMS); +export const verifyPassword = (h: string, pw: string) => verify(h, pw, ARGON2_PARAMS); +``` + +**`src/auth.config.ts`** (Edge-sicher; Cookie-secure umgebungsabhängig) +```ts +import type { NextAuthConfig } from "next-auth"; +import Authentik from "next-auth/providers/authentik"; +import { isHttps } from "@/lib/env"; + +export const authConfig = { + trustHost: true, + pages: { signIn: "/login", error: "/login" }, + session: { strategy: "jwt", maxAge: 60 * 60 * 8 }, + cookies: { + sessionToken: { + name: isHttps ? "__Secure-floriannetz.session" : "floriannetz.session", + options: { httpOnly: true, sameSite: "lax", path: "/", secure: isHttps }, + }, + }, + providers: [ + Authentik({ issuer: process.env.AUTHENTIK_ISSUER!, clientId: process.env.AUTHENTIK_CLIENT_ID!, clientSecret: process.env.AUTHENTIK_CLIENT_SECRET! }), + ], + callbacks: { + authorized: ({ auth }) => !!auth?.user, + async jwt({ token, user }) { + if (user) { token.role = (user as any).role; token.brigadeId = (user as any).brigadeId ?? null; } + return token; + }, + async session({ session, token }) { + session.user.role = token.role as any; + session.user.brigadeId = (token.brigadeId as string | null) ?? null; + return session; + }, + }, +} satisfies NextAuthConfig; +``` + +**`src/auth.ts`** (Node; Credentials + DB-Lookup + Rate-Limit im `authorize`) +```ts +import NextAuth from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { authConfig } from "./auth.config"; +import { verifyPassword } from "@/lib/auth/password"; +import { checkRateLimit, recordAttempt } from "@/lib/auth/rate-limit"; + +const credSchema = z.object({ email: z.string().email(), password: z.string().min(1) }); + +export const { handlers, auth, signIn, signOut } = NextAuth({ + ...authConfig, + providers: [ + ...authConfig.providers, + Credentials({ + credentials: { email: {}, password: {} }, + authorize: async (raw) => { + const parsed = credSchema.safeParse(raw); + if (!parsed.success) return null; + const { email, password } = parsed.data; + const key = `email:${email.toLowerCase()}`; + if (!(await checkRateLimit(key))) return null; // Rate-Limit für ALLE Credentials-Logins + const u = await db.query.users.findFirst({ where: eq(users.email, email) }); + if (!u || !u.aktiv || u.authTyp !== "local" || !u.passwortHash) { await recordAttempt(key, "fail"); return null; } + if (!(await verifyPassword(u.passwortHash, password))) { await recordAttempt(key, "fail"); return null; } + await recordAttempt(key, "ok"); + return { id: u.id, email: u.email, name: u.name, role: u.rolle, brigadeId: u.brigadeId }; + }, + }), + ], + callbacks: { + ...authConfig.callbacks, + async signIn({ user, account }) { + if (account?.provider === "authentik") { + const u = await db.query.users.findFirst({ where: eq(users.email, user.email!) }); + if (!u || !u.aktiv || u.authTyp !== "authentik") return false; + (user as any).role = u.rolle; (user as any).brigadeId = u.brigadeId ?? null; + } + return true; + }, + }, +}); +``` + +**`src/middleware.ts`** (Matcher inkl. `api/health`-Ausnahme) +```ts +import NextAuth from "next-auth"; +import { authConfig } from "./auth.config"; +export const { auth: middleware } = NextAuth(authConfig); +export default middleware(() => {}); +export const config = { + matcher: ["/((?!api/auth|api/health|login|_next/static|_next/image|favicon.ico|.*\\.(?:png|svg|ico|jpg|webp|woff2?)$).*)"], +}; +``` + +**`src/lib/auth/guards.ts`** (vollständiger Guard-Satz) +```ts +import { redirect, forbidden } from "next/navigation"; +import { auth } from "@/auth"; +import { type Role } from "./roles"; + +export type AppSession = NonNullable>> & { + user: { id: string; role: Role; brigadeId: string | null; email: string; name: string }; +}; +export async function requireSession(): Promise { + const s = await auth(); + if (!s?.user) redirect("/login"); + return s as AppSession; +} +export async function requireRole(...allowed: Role[]): Promise { + const s = await requireSession(); + if (!allowed.includes(s.user.role)) forbidden(); // 403 + return s; +} +export async function requirePlatformAdmin() { return requireRole("platform_admin"); } +export async function requireWehrAdmin(): Promise { + const s = await requireRole("wehr_admin"); + if (!s.user.brigadeId) forbidden(); + return s as any; +} +export async function requireOwnBrigade(brigadeId: string): Promise { + const s = await requireRole("wehr_admin", "platform_admin"); + if (s.user.role === "platform_admin") return s; + if (s.user.brigadeId !== brigadeId) forbidden(); + return s; +} +``` +> API-Routen rufen `auth()` direkt und antworten mit `NextResponse` `401`/`403` (Guards mit `redirect`/`forbidden` sind für Server Components/Actions). + +**`src/lib/auth/rate-limit.ts`** — Sliding Window (5 Fehlversuche / 15 min pro `key`), liest/schreibt `login_attempts`. + +**`src/app/(app)/layout.tsx`** — wird vom Auth-WS gefüllt: +```tsx +import { requireSession } from "@/lib/auth/guards"; +import { AppShell } from "@/components/layout/app-shell"; +export default async function AppLayout({ children }: { children: React.ReactNode }) { + await requireSession(); // serverseitiger Gate für die GANZE Gruppe + return {children}; +} +``` + +### Aufgaben +1. Auth-Deps installieren (`next-auth@5`, `@node-rs/argon2`). +2. `roles.ts` + `next-auth.d.ts`. +3. `password.ts` (argon2id-Parameter). +4. `auth.config.ts` (Edge-sicher, Cookie umgebungsabhängig). +5. `auth.ts` (Credentials + DB + Rate-Limit im `authorize` + Authentik-signIn-Gate). +6. `api/auth/[...nextauth]/route.ts`. +7. `middleware.ts` mit `api/health`-Ausnahme. +8. `guards.ts` (voller Satz). +9. `rate-limit.ts`. +10. `(app)/layout.tsx` Guard einbauen; `(auth)/login`-Seite + Action. +11. Seed-Skript `scripts/seed-auth.ts` (1 platform_admin authentik, 1 wehr_admin local). +12. Tests (`password`, `guards`, `auth-gating`). + +### Verifikation +1. `tsc --noEmit && lint` 0 Fehler. +2. `grep -R "@node-rs/argon2\|@/db" src/auth.config.ts src/middleware.ts` → **keine** Treffer (Edge-sicher). +3. Migration vorhanden (`login_attempts` mit Index) — bereits von DB-WS. +4. `vitest run tests/unit/password.test.ts` grün; `$argon2id$`-Präfix; `verify(hash,"falsch")===false`. +5. Credentials-Login als wehr_admin → Redirect `/`; Cookie `floriannetz.session` (lokal) bzw. `__Secure-…` (https) mit `HttpOnly`/`SameSite=Lax`. +6. Credentials mit Authentik-E-Mail → „falsch" (authTyp-Gate). +7. **Default-deny (Kerngarantie):** Anonyme Aufrufe von `/`, `/fahrzeuge`, `/admin/*`, `/verwaltung` → Redirect `/login`; `/api/*` → `401`. Entfernen von `requireSession()` aus `(app)/layout.tsx` macht die Gating-Suite rot. +8. Rate-Limit: 6× falsches Passwort → ab 6. `null`/Drosselung; `select count(*) from login_attempts where erfolg='fail'` ≥ 5. + +--- + +## Workstream 4 — Geo & Eintreffzeit-Sortierung (Phase 3) + +### Ziel +Selbstgehostetes OSRM + Nominatim auf Österreich-Extrakt; Geokodierung beim Speichern; ETA-Sortierung der Suchtreffer mit Haversine-Fallback; optional MapLibre-Karte. **Mit Bounding-Box-Vorfilter** vor dem OSRM-Call. + +### Abhängigkeiten +- **DB (Phase 1)** — `brigades` inkl. Geo-Spalten + `brigades_latlng_idx` (bereits dort definiert; dieser WS legt **keine** Migration an). +- **Auth (Phase 2)** — API-Routen hinter `auth()`. +- Liefert kanonische, **reine** `geocodeAddress(adresszeile)` + `orderByEintreffzeit` + Adapter `searchHitsToGeoCandidates`. + +### Fixes inline +- **Eine** Geocoding-API: `geocodeAddress(address: string)` rein, kein DB-Zugriff. Admin/Wehr-CRUD rufen sie und schreiben lat/lng selbst. **`geocodeBrigade()` entfällt** — kein zweiter Pfad. +- IDs sind **uuid** (`string`). +- Bounding-Box-Vorfilter limitiert Kandidaten und URL-Länge. +- Adapter `searchHitsToGeoCandidates` lebt hier (lädt `brigades.lat/lng`). + +### Dateien +- `docker-compose.geo.yml`, `infra/geo/Makefile`, `infra/geo/README.md`, `scripts/prepare-osm-data.sh`, `docker/osrm/Dockerfile` +- `src/lib/geo/{config.ts,types.ts,haversine.ts,nominatim.ts,osrm.ts,eintreffzeit.ts,candidates.ts}` +- `src/app/api/geo/{geocode,health}/route.ts` +- `src/components/geo/{standort-input.tsx,eta-badge.tsx,karte.tsx}` +- `src/lib/geo/__tests__/{haversine,eintreffzeit}.test.ts` + +### Schlüssel-Code + +**`types.ts`** +```ts +export type Coordinates = { lat: number; lng: number }; +export type RoutingMode = "osrm" | "haversine"; +export type EtaResult = { durationSec: number | null; distanceMeters: number | null; mode: RoutingMode; isFallback: boolean }; +export type OrderedResult = T & { eta: EtaResult }; +export type GeoCandidate = { brigadeCoords: Coordinates | null }; +``` + +**`nominatim.ts`** — `geocodeAddress(address): Promise` (rein, `countrycodes=at`, Timeout/Abort, `status: "ok"|"not_found"|"error"`). + +**`osrm.ts`** — `etaTable(origin, destinations)` via `/table/v1/driving/...?sources=0&annotations=duration,distance` (Koordinaten `lng,lat`), wirft bei Fehler. + +**`candidates.ts`** (Adapter + Bounding-Box-Vorfilter) +```ts +import { inArray, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { brigades } from "@/db/schema"; +import { haversineMeters } from "./haversine"; +import type { Coordinates, GeoCandidate } from "./types"; + +/** Lädt brigadeCoords für Treffer und filtert grob per Radius (km) vor dem OSRM-Call. */ +export async function searchHitsToGeoCandidates( + origin: Coordinates, hits: T[], radiusKm = 60, maxCandidates = 100, +): Promise<(T & GeoCandidate)[]> { + const ids = [...new Set(hits.map((h) => h.brigadeId))]; + if (!ids.length) return []; + const rows = await db.select({ id: brigades.id, lat: brigades.lat, lng: brigades.lng }) + .from(brigades).where(inArray(brigades.id, ids)); + const coordsById = new Map(rows.map((r) => [r.id, r.lat != null && r.lng != null ? { lat: r.lat, lng: r.lng } : null])); + const enriched = hits.map((h) => ({ ...h, brigadeCoords: coordsById.get(h.brigadeId) ?? null })); + // Vorfilter: innerhalb Radius zuerst; ohne Koordinaten ans Ende; harte Obergrenze für OSRM-/table. + const within = enriched.filter((e) => e.brigadeCoords && haversineMeters(origin, e.brigadeCoords) <= radiusKm * 1000); + const rest = enriched.filter((e) => !within.includes(e)); + return [...within, ...rest].slice(0, maxCandidates); +} +``` + +**`eintreffzeit.ts`** — `orderByEintreffzeit(origin, candidates)`: OSRM zuerst, kompletter Haversine-Fallback bei Wurf, Kandidaten ohne Koordinaten ans Ende (`durationSec=null`), stabile aufsteigende Sortierung. + +**`api/geo/geocode/route.ts`** — `POST`, `auth()`-Gate (`401` ohne Session), Zod-Body `{ address }`, ruft `geocodeAddress`, `404` bei `not_found`. + +**`api/geo/health/route.ts`** — hinter `auth()`; meldet OSRM/Nominatim `up`/`down`. (Achtung: das ist **nicht** der Docker-Healthcheck — der ist der öffentliche `/api/health` aus dem Deployment-WS.) + +**`docker-compose.geo.yml`** — `osrm` (Image `ghcr.io/project-osrm/osrm-backend`, `--algorithm mld`) + `nominatim` (`mediagis/nominatim`, Austria PBF), internes Netz, Healthchecks. + +### Aufgaben +1. `config.ts`/`types.ts`; `.env.example` um Geo-Variablen erweitern (im kanonischen `env.ts` bereits deklariert). +2. `haversine.ts`. +3. `osrm.ts` + `nominatim.ts` (Timeout/Abort). +4. `candidates.ts` (Adapter + Bounding-Box). +5. `eintreffzeit.ts` (OSRM-first + Fallback). +6. `api/geo/geocode` + `api/geo/health` (auth-gated, `401`). +7. `standort-input.tsx` + `eta-badge.tsx` (Fallback-Kennzeichnung „Luftlinie"). +8. optionale `karte.tsx` (`next/dynamic`, ssr:false). +9. Docker-Geo + Preprocessing + README. +10. Units (Haversine, `orderByEintreffzeit` mit gemocktem OSRM: Erfolg + Wurf→Fallback + null-Koordinaten). + +### Verifikation +1. `tsc --noEmit` ok; `env.ts` wirft bei fehlenden Geo-Variablen nicht (Defaults), bei kaputter URL doch. +2. Haversine-Test: St. Pölten→Wien ≈ 55 km (±3). +3. OSRM-`/table`-curl liefert `code:"Ok"`; Unit extrahiert `durations[0][1]`. +4. Nominatim-curl für St.-Pölten-Adresse liefert NÖ-Treffer. +5. `eintreffzeit.test.ts`: (a) OSRM-Mock → sortiert, `mode:"osrm"`; (b) Wurf → alle `haversine`, `isFallback:true`, sortiert; (c) null-Koordinaten am Ende. +6. `brigades`-Geo-Spalten + `brigades_latlng_idx` vorhanden (DB-WS). +7. `searchHitsToGeoCandidates` filtert außerhalb 60 km heraus und kappt bei `maxCandidates`. +8. `POST /api/geo/geocode` ohne Session → `401`; mit Session + gültiger Adresse → `200` + `coords`. +9. `eta-badge` zeigt bei OSRM-Ausfall „Luftlinie (geschätzt)". +10. `docker compose -f docker-compose.yml -f docker-compose.geo.yml up -d osrm nominatim` → `healthy`. + +--- + +## Workstream 5 — Dynamische Suche & Filter (Phase 3) + +### Ziel +Startseite mit Tabs Fahrzeuge·Geräte·Wehren; Namens-/Funkrufnamen-Suche; dynamisch aus dem `active`-Merkmal-Katalog erzeugtes Filter-UI (Slider/Dropdown/Switch) + Status-Filter; serverseitige typisierte Query über `merkmal_values` liefert **ungeordnete** `SearchHit[]` (ETA-Sortierung im Geo-WS). + +### Abhängigkeiten +- **DB (Phase 1)** — konsumiert Schema; legt **nur** `pg_trgm`-Indizes an (keine `merkmal_values`-Indizes — die gehören dem DB-WS). +- **Auth (Phase 2)** — Seiten unter `(app)`, durch Gruppen-Gate geschützt. +- **Admin (Phase 4)** — importiert `codes.ts` (Allrad-Regel) statt eigener Implementierung. +- **Geo (Phase 3)** — übergibt `SearchHit[]` an `searchHitsToGeoCandidates` + `orderByEintreffzeit`. + +### Fixes inline +- **uuid (`string`) IDs** durchgängig in `SearchHit`. +- Allrad-Regel über gemeinsame `codes.ts` (Admin-Eigentum), nicht doppelt implementiert. +- Nur `pg_trgm`-Indizes ergänzen; `merkmal_values`-Indexnamen aus DB-WS nutzen. + +### Dateien +- `src/lib/db/indexes-trgm.sql` (nur Trigram-Indizes auf `vehicles.name`/`funkrufname`) +- `src/lib/search/{types.ts,parse-params.ts,facets.ts,query-vehicles.ts,query-equipment.ts,query-brigades.ts}` +- (Allrad-Logik: Import aus `@/lib/admin/codes`) +- `src/app/(app)/page.tsx`, `(app)/{fahrzeuge,geraete,wehren}/page.tsx` +- `src/components/search/{SearchTabs,SearchBar,FilterPanel,useSearchParams}.tsx` + `facets/{NumberRangeFacet,EnumFacet,BooleanFacet}.tsx` + `results/{ResultList,VehicleResultRow,EquipmentResultRow,BrigadeResultRow}.tsx` +- `src/lib/search/__tests__/{query-vehicles,parse-params}.test.ts` +- `tests/e2e/search.spec.ts` + +### Schlüssel-Code + +**`types.ts`** (uuid-IDs) +```ts +export type MerkmalTyp = "number" | "enum" | "boolean" | "text"; +export type EntityTyp = "vehicle" | "equipment"; +export interface FacetDef { merkmalId: string; name: string; typ: MerkmalTyp; einheit: string | null; min?: number; max?: number; optionen?: { wert: string; label: string }[] } +export type FilterValue = + | { typ: "number"; merkmalId: string; gte?: number; lte?: number } + | { typ: "enum"; merkmalId: string; in: string[] } + | { typ: "boolean"; merkmalId: string; eq: boolean }; +export interface SearchParams { q?: string; nurEinsatzbereit: boolean; filter: FilterValue[] } +export interface SearchHit { entityTyp: EntityTyp; entityId: string; brigadeId: string; name: string; funkrufname: string | null; status: "einsatzbereit" | "wartung" | "ausser_dienst" } +``` + +**`facets.ts`** — lädt nur `status='active'`, `geltungsbereich in (typ,'both')`, `typ<>'text'`; min/max je number aus `merkmal_values`; Optionen sortiert je enum. + +**`query-vehicles.ts`** — Name+Alias+Status+`EXISTS`-Filter (UND-verknüpft je `merkmal_id`); nutzt `expandNameQuery` aus `@/lib/admin/codes` (HLFA→HLF + `Allradantrieb=Ja`); **keine** Sortierung. IDs `string`. + +**`parse-params.ts`** — `f.=...` (number `lo..hi`, enum CSV, boolean `ja/nein`), verwirft Ungültiges still. + +**`useSearchParams.ts`** — `router.replace` (kein Reload), debounced bei Text. + +### Aufgaben +1. `types.ts` (uuid). +2. `indexes-trgm.sql` (`pg_trgm` + GIN auf `vehicles.name`/`funkrufname`). +3. `parse-params.ts`. +4. Allrad-Logik **importieren** (`@/lib/admin/codes.expandNameQuery`). +5. `facets.ts`. +6. `query-vehicles.ts`. +7. `query-equipment.ts` (mit `category_id`, ohne Allrad-Spezialfall). +8. `query-brigades.ts` (Name/Ort/PLZ). +9. `useSearchParams.ts`. +10. Facet-Komponenten (Slider/Multi-Select/Tri-State Switch). +11. `FilterPanel` dispatcht + Status-Switch. +12. `SearchBar` + `SearchTabs`. +13. Result-Komponenten (Empty-State, offener ETA-Slot). +14. Drei Tab-Seiten + Startseite; in `fahrzeuge/page.tsx` Treffer durch `searchHitsToGeoCandidates`+`orderByEintreffzeit` sortieren, sobald Standort gesetzt. +15. Units (`query-vehicles`, `parse-params`). +16. Playwright `search.spec.ts`. + +### Verifikation +1. `tsc --noEmit` 0 Fehler; `SearchHit.entityId` ist `string`. +2. `\di mv_merkmal_num_idx idx_vehicles_name_trgm` listet beide; `EXPLAIN` Range-Query zeigt Index Scan. +3. `parseSearchParams({'f.':'2000..4000','f.':'ja','bereit':'1'})` korrekt; `f.=foo` verworfen. +4. `expandNameQuery('HLFA 3')` ⇒ `{nameLikes:['HLFA 3','HLF 3'], allradImpliziert:true}`; `'HLFA 1 W'` und `'MTFA'` korrekt (Tests im Admin-WS). +5. `getFacets('vehicle')` enthält nur `active`, kein `proposed`, kein `text`-Merkmal; enum sortiert; number `min,gte:2000}]})` nur Tank ≥2000; kombiniert mit Allrad=true schrumpft korrekt (UND). +7. Geräte/Wehren liefern erwartete Teilmengen. +8. `/fahrzeuge` rendert je `active`-Merkmal genau ein UI-Element des richtigen Typs. +9. Filter ändern → URL `f.=…`/`?bereit=1`; Trefferzahl sinkt; Reload identisch. +10. `/fahrzeuge?...` ohne Session → Redirect Login. + +--- + +## Workstream 6 — Admin-Panel: Taxonomie & Bereitstellung (Phase 4) + +### Ziel +Platform-Admin-only Oberflächen: Merkmal-Katalog, Fahrzeug-Vorlagen (Merkmale/Vorgabewerte/Aliasse), Allrad-Namensregel, Geräte-Kategorien, Governance-Flow (proposed→promote/merge), Wehr-/Erst-Login-Bereitstellung (anlegen, geokodieren, Passwort-Reset), Audit-Viewer. **Eigentümer von `codes.ts`.** + +### Abhängigkeiten +- **DB (Phase 1)** — alle Taxonomietabellen existieren bereits; Admin **importiert** sie. +- **Auth (Phase 2)** — `requirePlatformAdmin()`. +- **Geo (Phase 3)** — `geocodeAddress(adresszeile)` (rein). +- **Phase 0** — Designsystem. + +### Fixes inline +- Schema **nicht** neu definieren (nur importieren). +- `codes.ts` ist hier definiert; Suche importiert es. +- Vorgabewerte als drei typisierte Spalten (DB-WS-Entscheidung). +- Geocoding inline (Adresse → lat/lng selbst schreiben). +- Audit für **alle** Schreib-Actions inkl. `brigade.create`, `user.reset`, `merkmal.promote/merge`. +- `forbidden()` nutzbar (Flag aktiv). + +### Dateien +- Domänen-Logik: `src/lib/admin/codes.ts` (`allradCode`, `normalizeCode`, `codesMatch`, `expandNameQuery`), `src/lib/audit.ts`, `src/lib/validation/{merkmal,template,equipment-category,brigade}.ts`, `src/lib/admin/provisioning.ts`. +- Server Actions: `src/app/(admin)/_actions/{merkmale,proposals,templates,equipment-categories,brigades}.ts`. +- Routen `(admin)`: `layout.tsx` (Guard), `admin/page.tsx`, `admin/merkmale/{page,MerkmalEditor}.tsx`, `admin/merkmale/proposals/{page,MergeDialog}.tsx`, `admin/vorlagen/{page,[id]/page,[id]/TemplateMerkmaleEditor,[id]/AliasEditor}.tsx`, `admin/geraete-kategorien/{page,[id]/page}.tsx`, `admin/wehren/{page,neu/page,[id]/page}.tsx`, `admin/audit/page.tsx`, `components/admin/{AdminNav,DataTable}.tsx`. +- Tests: `src/lib/admin/codes.test.ts`, `tests/e2e/{admin-gating,admin-merkmal-proposal,admin-brigade-provision}.spec.ts`. + +### Schlüssel-Code + +**`src/lib/admin/codes.ts`** (kanonische Allrad-Regel; von Suche importiert) +```ts +const ALLRAD_RE = /^([A-ZÄÖÜ]{2,4})A(\b|\s|$)/i; + +export function allradCode(code: string): string { + return code.replace(/^([A-ZÄÖÜ]+)/, (m) => (m.endsWith("A") ? m : `${m}A`)); +} +export function normalizeCode(s: string): string { + return s.toUpperCase().replace(/\s+/g, " ").trim().replace(/^([A-ZÄÖÜ]+?)A(\b| |$)/, "$1$2").trim(); +} +export function codesMatch(a: string, b: string): boolean { return normalizeCode(a) === normalizeCode(b); } + +export interface ExpandedQuery { nameLikes: string[]; allradImpliziert: boolean } +export function expandNameQuery(q: string): ExpandedQuery { + const trimmed = q.trim(); + const tokens = new Set([trimmed]); + let allrad = false; + if (ALLRAD_RE.test(trimmed)) { + allrad = true; + tokens.add(trimmed.replace(/^([A-ZÄÖÜ]{2,4})A(\b|\s|$)/i, "$1$2").replace(/\s+/g, " ").trim()); + } + return { nameLikes: [...tokens], allradImpliziert: allrad }; +} +``` +> Testfälle: `allradCode("HLF 3")==="HLFA 3"`, `allradCode("MTF")==="MTFA"`, `expandNameQuery("HLFA 1 W")` ⇒ enthält `"HLF 1 W"`, `expandNameQuery("MTFA")` ⇒ enthält `"MTF"`. + +**`src/lib/auth/guards.ts`** wird **nicht** hier definiert — `requirePlatformAdmin` kommt aus dem Auth-WS. + +**`(admin)/layout.tsx`** +```tsx +import { requirePlatformAdmin } from "@/lib/auth/guards"; +import { AdminNav } from "@/components/admin/AdminNav"; +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + await requirePlatformAdmin(); + return
{children}
; +} +``` + +**`_actions/proposals.ts`** — `promoteMerkmal` (proposed→active + Audit `merkmal.promote`), `mergeMerkmal` (Werte in `merkmal_values` umhängen, proposed löschen, Typ-Kompatibilität in UI prüfen + Audit `merkmal.merge`). + +**`src/lib/admin/provisioning.ts`** (Geocoding inline, lat/lng selbst schreiben, Audit `brigade.create`) +```ts +import { randomBytes } from "node:crypto"; +import { db } from "@/db"; +import { brigades, users } from "@/db/schema"; +import { hashPassword } from "@/lib/auth/password"; +import { geocodeAddress } from "@/lib/geo/nominatim"; + +export function generateTempPassword(): string { return randomBytes(9).toString("base64url"); } + +export async function createBrigadeWithFirstAdmin(input: { + brigade: { name: string; strasse: string; plz: string; ort: string; telefon: string; email?: string; wehrfuehrer?: string }; + admin: { email: string; name: string }; actorUserId: string; +}) { + const adr = `${input.brigade.strasse}, ${input.brigade.plz} ${input.brigade.ort}, Österreich`; + const geo = await geocodeAddress(adr); + const temp = generateTempPassword(); + const hash = await hashPassword(temp); + return db.transaction(async (tx) => { + const [b] = await tx.insert(brigades).values({ + name: input.brigade.name, art: "FF", strasse: input.brigade.strasse, plz: input.brigade.plz, ort: input.brigade.ort, + bundesland: "Niederösterreich", + lat: geo.status === "ok" ? geo.coords.lat : null, lng: geo.status === "ok" ? geo.coords.lng : null, + geocodeQuery: adr, geocodeStatus: geo.status, geocodedAt: new Date(), + telefon: input.brigade.telefon, email: input.brigade.email ?? null, wehrfuehrer: input.brigade.wehrfuehrer ?? null, aktiv: true, + }).returning(); + const [u] = await tx.insert(users).values({ + brigadeId: b.id, rolle: "wehr_admin", authTyp: "local", email: input.admin.email.toLowerCase(), + name: input.admin.name, passwortHash: hash, aktiv: true, erstelltVon: input.actorUserId, + }).returning(); + return { brigadeId: b.id, userId: u.id, tempPassword: temp, geocoded: geo.status === "ok" }; + }); +} +``` + +### Aufgaben +1. Taxonomietabellen **importieren** (nicht definieren). +2. `codes.ts` implementieren + Vitest (inkl. `HLFA 1 W`, `MTFA`). +3. `audit.ts` (`writeAudit(actorUserId, aktion, zielTyp, zielId, details?, tx?)`). +4. `(admin)/layout.tsx` + `AdminNav`. +5. Merkmal-CRUD (Zod, Actions create/update/delete, Editor mit Enum-Optionen; Delete blockieren, wenn referenziert). +6. Proposal-Flow (promote/merge, Typ-Kompatibilität, Audit). +7. Vorlagen-CRUD (TemplateMerkmaleEditor: Vorgabewert je Typ → `vorgabewert_num/_text/_bool`; AliasEditor mit `bestaetigt`; Allrad-Hinweis via `allradCode(code)`). +8. Geräte-Kategorien-CRUD. +9. Brigade-Bereitstellung (`provisioning.ts`, `wehren/neu`, Reset + Audit `user.reset`). +10. Audit-Viewer (paginiert, Filter). +11. Audit in **allen** Schreib-Actions. +12. Auth-Gating-Tests `(admin)`. + +### Verifikation +1. `tsc --noEmit` grün; alle Taxonomietabellen ohne Fehler importierbar. +2. Migration vorhanden (partieller `merkmale_active_name_uq` — DB-WS); zwei `active` gleichen Namens schlagen fehl, zwei `proposed` gelingen. +3. `vitest run src/lib/admin/codes.test.ts`: `allradCode("HLF 3")==="HLFA 3"`, `allradCode("MTF")==="MTFA"`, `expandNameQuery("HLFA 1 W")` enthält `"HLF 1 W"`, `codesMatch("HLFA 3","HLF 3")===true`. +4. Als platform_admin `/admin` erreichbar; Audit-Insert prüfbar. +5. Als wehr_admin/wehr_read/anonym `/admin/*` → 403/Redirect (`admin-gating.spec.ts` grün). +6. Merkmal Typ Auswahl ohne Optionen → Zod-Fehler; mit Optionen → Liste + `merkmal.create`-Audit; Delete eines referenzierten Merkmals → klare Fehlermeldung, kein 500. +7. proposed-Merkmal: promote setzt `active` + Audit; merge hängt `merkmal_values` um + löscht proposed. +8. Vorlagen-Detail HLF 3 zeigt „HLFA 3"; Merkmal mit `vorgabewert_text` → Zeile; Alias „RLFA 2000-4000" `bestaetigt=true`. +9. Kategorie + 2 Merkmale → 2 Zeilen, korrekte Reihenfolge. +10. `wehren/neu`: Brigade mit lat/lng (oder markiert „nicht geokodiert"), `users`-Eintrag `wehr_admin/local/$argon2id$`, Temp-Passwort einmal angezeigt, Audit `brigade.create`; Reset → neuer Hash + `user.reset`. +11. Audit-Viewer + Filter funktionieren. + +--- + +## Workstream 7 — Wehr-Bereich: Fuhrpark & Benutzer (Phase 4) + +### Ziel +Wehr-Admins verwalten serverseitig auf die eigene `brigadeId` beschränkt: Profil (inkl. Geokodierung), Fuhrpark (Fahrzeug per Vorlage/frei, typisierter Merkmal-Editor), Geräte (Kategorie, Werte, Zuordnung Fahrzeug/„im Gerätehaus"), Benutzerkonten der eigenen Wehr (`wehr_admin`/`wehr_read`). `wehr_read` hat keinen Zugang. + +### Abhängigkeiten +- **DB (Phase 1)** — Schema importiert (nicht definiert). +- **Auth (Phase 2)** — `requireWehrAdmin()`. +- **Admin (Phase 4)** — Taxonomie befüllt; Lesehelfer nutzen Vorlagen/Merkmale/Kategorien. +- **Geo (Phase 3)** — `geocodeAddress(adresszeile)` rein; lat/lng selbst schreiben. + +### Fixes inline +- Schema **importieren**; ASCII-Property `wehrfuehrer`. +- Geocoding inline via `geocodeAddress` (kein `geocodeBrigade`). +- Vorgabewerte aus `vorgabewert_num/_text/_bool` lesen. +- Keine `any`-Typen: Drizzle-Tx-Typ korrekt. +- Audit für alle Schreib-Actions; `writeAudit` mit optionalem `tx`. + +### Dateien +- Pages `(app)/verwaltung/`: `layout.tsx` (Gate `requireWehrAdmin`), `profil/page.tsx`, `fahrzeuge/{page,neu/page,[id]/page}.tsx`, `geraete/{page,neu/page,[id]/page}.tsx`, `benutzer/page.tsx`. +- Actions: `src/server/actions/{brigade,vehicles,equipment,brigade-users}.ts`. +- Daten/Helfer: `src/server/data/{merkmale,vehicles,equipment}.ts`, `src/server/merkmale/upsertValues.ts`. +- Validierung: `src/lib/validation/{brigade,vehicle,equipment,brigade-user}.ts`, `src/lib/merkmale/types.ts`. +- Client: `src/components/verwaltung/{MerkmalValueEditor,VehicleForm,EquipmentForm,BrigadeProfileForm,BrigadeUserForm,TemplatePicker,VerwaltungNav}.tsx`. +- Tests: `tests/e2e/{verwaltung-fuhrpark,verwaltung-scoping}.spec.ts`, `src/lib/validation/__tests__/vehicle.test.ts`. + +### Schlüssel-Code + +**`upsertValues.ts`** (typisierte Tx, delete-then-insert) +```ts +import type { PgTransaction } from "drizzle-orm/pg-core"; +import type { ExtractTablesWithRelations } from "drizzle-orm"; +import type { NodePgQueryResultHKT } from "drizzle-orm/node-postgres"; +import * as schema from "@/db/schema"; +import { merkmalValues } from "@/db/schema"; +import { and, eq } from "drizzle-orm"; +import type { MerkmalValueInput } from "@/lib/merkmale/types"; + +type Tx = PgTransaction>; + +export async function upsertMerkmalValues(tx: Tx, entityTyp: "vehicle" | "equipment", entityId: string, werte: MerkmalValueInput[]) { + 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 }); + } +} +``` + +**`actions/brigade.ts`** (Profil + Geocoding inline + Audit `brigade.profile_update`) +```ts +"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 async function updateBrigadeProfile(input: unknown) { + const s = await requireWehrAdmin(); + const d = brigadeProfileSchema.parse(input); + const geo = await geocodeAddress(`${d.strasse}, ${d.plz} ${d.ort}, Österreich`); + const geocodeWarnung = geo.status !== "ok"; + await db.update(brigades).set({ + strasse: d.strasse, plz: d.plz, ort: d.ort, telefon: d.telefon, email: d.email, wehrfuehrer: d.wehrfuehrer, funkrufnameSchema: d.funkrufnameSchema, + ...(geo.status === "ok" ? { lat: geo.coords.lat, lng: geo.coords.lng, geocodeStatus: "ok", geocodedAt: new Date() } : { 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 { geocodeWarnung }; +} +``` + +**`actions/brigade-users.ts`** — `createBrigadeUser` (Zod-Enum nur `wehr_admin|wehr_read`, argon2id, eigene Wehr, Audit `user.create`), `deactivateBrigadeUser` (Scope, Selbst-Deaktivierung verhindert, Audit `user.deactivate` über denselben `writeAudit`). + +Fahrzeug-/Geräte-Actions: Vorlage-Vorbefüllung aus `vorgabewert_num/_text/_bool`; Werte typgerecht validieren (`buildMerkmalValuesSchema`); Zuordnung nur zu eigenem Fahrzeug; `brigadeId` immer serverseitig aus Session; Audit `vehicle.create/update/delete`, `vehicle.status`, `equipment.*`. + +### Aufgaben +1. Validierungsschemata + `merkmale/types.ts`. +2. Lesehelfer `data/merkmale.ts` (`getMerkmaleForTemplate/Category`, Vorgabewerte aus 3 Spalten, `getMerkmalValuesForEntity`). +3. `upsertValues.ts` (typisierte Tx). +4. `requireWehrAdmin` importieren; `verwaltung/layout.tsx` + Sub-Nav. +5. Profil (`BrigadeProfileForm` + `updateBrigadeProfile` + Seite). +6. `MerkmalValueEditor` + `VehicleForm` + `TemplatePicker`. +7. Fahrzeug-Anlage (Vorlage/frei) + Actions. +8. Fahrzeug-Bearbeiten (scoped) + status/delete. +9. Fahrzeug-Liste mit Status-Badge. +10. Geräte (Form mit Zuordnung, Actions, Seiten). +11. Benutzerverwaltung (Form, Actions, argon2id, Selbst-Deaktivierung verhindert). +12. Audit in allen Schreib-Actions. +13. Vitest `buildMerkmalValuesSchema` + `upsertMerkmalValues`. +14. Playwright Happy-Path + Scoping. + +### Verifikation +1. `tsc --noEmit` ohne `any`; `vehicle.test.ts` grün (Pflicht/Typen). +2. `getMerkmaleForTemplate()` liefert `Löschwassertank (l)`, `Feuerlöschpumpe (Typ)` mit Optionen; `Allradantrieb` als boolean. +3. `upsertMerkmalValues` zweimal → genau eine Zeile; leerer Wert → keine Zeile. +4. Profil speichern → `brigades.lat/lng` gesetzt; nicht geokodierbar → Warn-Toast, Speichern trotzdem. +5. Vorlage „HLF 2" → Felder vorbefüllt; speichern → Liste mit Badge; `merkmal_values` befüllt. +6. Gerät „im Gerätehaus" → `vehicle_id IS NULL`; umstellen → gesetzt. +7. Scoping: fremdes Fahrzeug → not-found/403, unverändert; `wehr_read` → 403; anonym → Redirect. +8. `createBrigadeUser` `wehr_read` → eigene Wehr, `$argon2id$`; `platform_admin` von Zod abgelehnt; Selbst-Deaktivierung wirft. +9. `audit_log` zeigt `vehicle.create`, `user.create`, `user.deactivate`, `brigade.profile_update`. + +--- + +## Workstream 8 — Detailseiten & Kontakt (Phase 5) + +### Ziel +Drei Lese-Detailseiten (Fahrzeug, Gerät, Wehr) serverseitig, default-deny: Eckdaten aus typisierten Merkmalwerten, verlinkte Beladung/Zuordnung, Wehr-Karte, out-of-band Kontakt (`tel:`/`mailto:`). Borrow-Workflow v1 ausgeschlossen, Datenmodell offen. + +### Abhängigkeiten +- **DB (Phase 1)** — Schema read-only. +- **Auth (Phase 2)** — Gruppen-Gate `(app)` schützt; Seiten rufen zusätzlich `requireSession()`. +- **Designsystem (Phase 0)** — `StatusBadge` etc. +- **Seed (Phase 6)** — echte Eckdaten. + +### Dateien +- `src/lib/detail/{queries.ts,merkmale.ts}` +- `src/components/kontakt/{KontaktButton,WehrCard}.tsx` +- `src/components/detail/{EckdatenGrid,StatusBadge,BeladungListe,DetailHeader}.tsx` +- `src/app/(app)/{fahrzeuge,geraete,wehren}/[id]/page.tsx` + `fahrzeuge/[id]/not-found.tsx` +- `src/lib/detail/queries.test.ts`, `tests/e2e/detail-auth.spec.ts` + +### Schlüssel-Code + +**`merkmale.ts`** — `formatMerkmal` (number+Einheit via `Intl.NumberFormat("de-AT")`, NBSP vor Einheit; boolean→Ja/Nein; enum→`enumLabel`; leer→„–"). + +**`queries.ts`** — `loadMerkmalRows(entityTyp, entityId)` joint `merkmal_values`↔`merkmale`↔`merkmal_optionen` (`merkmal_optionen.wert = merkmal_values.value_text`); `getFahrzeugDetail` (+Beladung, +`getBrigadeCard`), `getGeraetDetail` (Zuordnung Fahrzeug/„im Gerätehaus"), `getWehrDetail` (Fuhrpark + Geräte im Haus), `getBrigadeCard`. IDs **uuid** (`string`). + +**`fahrzeuge/[id]/page.tsx`** +```tsx +import { notFound } from "next/navigation"; +import { requireSession } from "@/lib/auth/guards"; +import { getFahrzeugDetail } from "@/lib/detail/queries"; +import { toEckdaten } from "@/lib/detail/merkmale"; +// ... DetailHeader/EckdatenGrid/BeladungListe/WehrCard +export default async function FahrzeugDetailPage({ params }: { params: Promise<{ id: string }> }) { + await requireSession(); + const { id } = await params; + const v = await getFahrzeugDetail(id); + if (!v) notFound(); + // ... render +} +``` + +**`KontaktButton.tsx`** — `tel:`/`mailto:` (Telefon ohne Leerzeichen, optional `subject`); leerer Zustand „Keine Kontaktdaten hinterlegt." + +### Aufgaben +1. `merkmale.ts` (de-AT, NBSP, Ja/Nein, enum-Label, „–"). +2. `queries.ts` (uuid, read-only). +3. `requireSession` importieren. +4. Kontakt-Komponenten. +5. Detail-Bausteine (Beladung als ``). +6. Fahrzeug-Detailseite + `not-found.tsx`. +7. Gerät-Detailseite (Fahrzeug-Link oder „im Gerätehaus"). +8. Wehr-Detailseite (Fuhrpark verlinkt + Kontakt). +9. Vitest `formatMerkmal` (4 Typen + Null + Einheit + Tausenderpunkt). +10. Playwright (anonym → Redirect; eingeloggt → Eckdaten/Links/`tel:`). + +### Verifikation +1. `vitest run src/lib/detail` grün: `{number,14000,l}`→`"14.000 l"` (NBSP), `{boolean,false}`→`"Nein"`, `{enum,enumLabel:"FPN 10-2000"}`→`"FPN 10-2000"`, alle null→`"–"`. +2. `tsc --noEmit` ok; `getFahrzeugDetail()` liefert nicht-leeres `merkmale` + `beladung`. +3. `curl -sI /fahrzeuge/` ohne Cookie → `location: /login`. +4. `KontaktButton` mit nur `email` → genau ein `mailto:`-Link; beide leer → Hinweistext. +5. `EckdatenGrid` `rows=[]` → Empty-Text; `BeladungListe` → `` je Item. +6. Browser `/fahrzeuge/`: Titel, Kicker, Badge, Eckdaten, Beladung, WehrCard; `/fahrzeuge/` → 404 (deutsch). +7. Gerät `vehicle_id=null` → „im Gerätehaus"; mit Fahrzeug → Link navigiert. +8. `/wehren/` listet alle Fahrzeuge (count == DB). +9. `playwright test detail-auth.spec.ts` grün; Entfernen von `requireSession()` macht Default-deny-Spec rot. + +--- + +## Workstream 9 — Seed-Daten aus NÖ-Katalog (Phase 6) + +### Ziel +Idempotente Seeds, die `docs/reference/fahrzeug-katalog-noelfv.md` als Code abbilden: **34** Merkmale (+ Enum-Optionen), **11** Vorlagen mit Merkmalen/Vorgabewerten/Aliassen (RLF/RLFA bestätigt, HLF 4-U als Alias), Geräte-Kategorien. Alle Inserts als Upserts. + +### Abhängigkeiten +- **DB (Phase 1)** — Schema inkl. `merkmale.slug`, `vehicle_template_aliasse(bestaetigt)`, drei `vorgabewert_*`-Spalten. **Migrationen müssen angewendet sein.** +- **Admin (Phase 4)** — Taxonomietabellen existieren. + +### Fixes inline +- Aliasse in `vehicle_template_aliasse` mit `bestaetigt` (RLF/RLFA 2000 + 2000-4000 = true, Rest false) — **kein jsonb**. +- **HLFA NICHT als Alias** (Laufzeitregel im Such-WS ist kanonisch). +- **34 Merkmale** (Funkrufname ist Spalte, nicht Merkmal). +- HLF 4-U = Alias auf HLF 4 + Pulver-Pflichtmerkmale. +- Idempotenz-Key `slug` (merkmale), `code` (templates), `name` (categories — `equipment_categories.slug` ebenfalls vorhanden). +- Vorgabewerte typgerecht in `vorgabewert_num/_text/_bool` schreiben. + +### Dateien +- `src/db/seed/index.ts`, `src/db/seed/upsert.ts` +- `src/db/seed/data/{merkmale,vehicle-templates,equipment-categories}.ts` +- `src/db/seed/seed.test.ts`; `package.json` Script `db:seed`. + +### Schlüssel-Code + +**`data/vehicle-templates.ts`** (Aliasse mit `bestaetigt`; **kein** Allrad-Alias) +```ts +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 2", name: "Hilfeleistungsfahrzeug 2", + aliasse: [ + { alias: "RLF 2000", bestaetigt: true }, { alias: "RLFA 2000", bestaetigt: true }, + { alias: "LF", bestaetigt: false }, { alias: "TLFA 2000", bestaetigt: false }, + ], // KEIN "HLFA 2" — Allrad ist Laufzeitregel + Merkmal + merkmale: [ + { slug: "feuerloeschpumpe_typ", vorgabewertText: "fpn_10_1000", pflicht: true }, + { slug: "loeschwassertank", vorgabewertNum: 2000, pflicht: true }, + { slug: "allradantrieb", vorgabewertBool: false }, + // ... + ], + }, + { + code: "HLF 3", name: "Hilfeleistungsfahrzeug 3", + 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 }, + ], + }, + { + code: "HLF 4", name: "Hilfeleistungsfahrzeug 4", + aliasse: [{ alias: "HLF 4-U", bestaetigt: false }], // U-Variante als Alias + merkmale: [ + { slug: "pulverloeschanlage", vorgabewertBool: true, pflicht: true }, + { slug: "pulvermenge", vorgabewertNum: 250 }, + ], + }, + // ... HLF 1, HLF 1 W, VRF, VF, ALF, SSTF, WLF, MTF (insgesamt 11) +]; +``` + +**`data/merkmale.ts`** — 34 Einträge (Funkrufname **nicht** dabei) mit `slug`/`name`/`typ`/`einheit`/`geltung`; Enum-Optionen für `feuerloeschpumpe_typ` (8), `anzahl_achsen` (3), `stromerzeuger_bauart` (3). + +**`upsert.ts`** — `onConflictDoUpdate` auf `merkmale.slug`, `merkmal_optionen(merkmalId,wert)`, `vehicle_templates.code`, `vehicle_template_merkmale(templateId,merkmalId)`, `vehicle_template_aliasse(templateId,alias)`, `equipment_categories.slug`. + +**`index.ts`** — Transaktion: Merkmale→Optionen→Vorlagen→Vorlagen-Merkmale→Aliasse→Kategorien; Slug→ID-Map; sequenzielle Awaits. + +### Aufgaben +1. Schema-Constraints sicherstellen (alle vom DB-WS vorhanden); ggf. Folge-Migration. +2. `tsx` + `db:seed`-Script. +3. `data/merkmale.ts` (34 + Optionen). +4. `data/vehicle-templates.ts` (11, Aliasse mit `bestaetigt`, HLF 4-U Alias, kein HLFA-Alias, typisierte Vorgabewerte). +5. `data/equipment-categories.ts`. +6. `upsert.ts` + `index.ts`. +7. Idempotenz-Test (2× ausführen, Counts stabil). + +### Verifikation +1. Migration vorhanden: `\d merkmale` zeigt `slug` UNIQUE; `\d vehicle_template_aliasse` zeigt `bestaetigt`. +2. `npm run` listet `db:seed`. +3. `select count(*) from merkmale` → **34**; `select count(*) from merkmal_optionen where merkmal_id=(select id from merkmale where slug='feuerloeschpumpe_typ')` → 8. +4. `select count(*) from vehicle_templates` → **11**; Aliasse HLF 2 enthalten `RLF 2000`/`RLFA 2000` mit `bestaetigt=true`. +5. `select count(*) from vehicle_template_aliasse where alias like 'HLFA%'` → **0** (kein HLFA-Alias); kein `code='HLFA 3'`. +6. HLF 4: Alias `HLF 4-U` vorhanden; `pulverloeschanlage` mit `pflicht=true`. +7. `select count(*) from equipment_categories` → 11. +8. **Idempotenz:** `npm run db:seed && npm run db:seed` beide grün; alle Counts unverändert; `vitest run src/db/seed/seed.test.ts` ruft `main()` 2× und assert't gleiche Counts. + +--- + +## Workstream 10 — Deployment (Docker + externes Traefik) (Phase 7) + +### Ziel +App + Postgres + OSRM + Nominatim als reproduzierbares Compose-Setup hinter **externem** Traefik; Forwarded-Header/sichere Cookies/Authentik-Callback korrekt; Migration + Seed beim Deploy automatisiert. + +### Abhängigkeiten +- **Auth (Phase 2)** — Env-Verträge (`AUTH_SECRET`, `AUTH_URL`, `AUTHENTIK_*`, `AUTH_TRUST_HOST`), Forwarded-Header. +- **DB (Phase 1)** — `drizzle/`, `drizzle.config.ts`. +- **Seed (Phase 6)** — `src/db/seed/index.ts`. +- **Phase 0** — `next.config.ts` (`output:"standalone"`), Security-Header. + +### Fixes inline +- `/api/health` öffentlich (Liveness, keine Fachdaten) und in Middleware-Matcher **ausgenommen** (bereits in Auth-WS) sowie in Test-`PUBLIC_ALLOWLIST`. +- `AUTH_URL` als Pflicht-Env (Callback-Basis + Cookie-secure). + +### Dateien +- `Dockerfile` (multi-stage, non-root, standalone), `.dockerignore` +- `docker-compose.yml` (kein Proxy; Traefik-Labels am externen Netz), `docker-compose.override.yml.example` +- `.env.example` (voller Vertrag) +- `src/app/api/health/route.ts` (öffentlich) +- `docker/entrypoint.sh` (warten→migrate→seed→start), `docker/osrm/Dockerfile`, `scripts/prepare-osm-data.sh`, `Makefile` +- `docs/reference/deployment-traefik.md` + +### Schlüssel-Code + +**`src/app/api/health/route.ts`** (öffentlich, nur Liveness) +```ts +import { NextResponse } from "next/server"; +import { sql } from "drizzle-orm"; +import { db } from "@/db"; +export const dynamic = "force-dynamic"; +export async function GET() { + try { await db.execute(sql`select 1`); return NextResponse.json({ status: "ok" }, { status: 200 }); } + catch { return NextResponse.json({ status: "degraded" }, { status: 503 }); } +} +``` + +**`docker/entrypoint.sh`** — wartet auf Postgres, `drizzle-kit migrate`, optional `tsx src/db/seed/index.ts` (RUN_SEED), `exec node server.js`. + +**`docker-compose.yml`** — `app` (Traefik-Labels: `Host(\`${APP_HOST}\`)`, `websecure`, `tls.certresolver`, Security-Header-Middleware), `postgres` (healthcheck `pg_isready`), `osrm`, `nominatim`; Netze `traefik` (external) + `internal`; **kein** Proxy-Service. `app.environment` setzt `AUTH_URL`, `AUTH_TRUST_HOST=true`, `DATABASE_URL`, `OSRM_URL`, `NOMINATIM_URL`. App-Healthcheck pingt `http://127.0.0.1:3000/api/health`. + +### Aufgaben +1. `.dockerignore` + `next.config.ts` (`output:"standalone"`). +2. `Dockerfile` bauen. +3. `entrypoint.sh`. +4. `/api/health` (falls nicht vorhanden). +5. `.env.example` (inkl. `AUTH_URL`). +6. `docker-compose.yml` (externes Netz, Labels, Healthchecks, kein Proxy). +7. `docker/osrm/Dockerfile` + `prepare-osm-data.sh` + `make data`. +8. `docker-compose.override.yml.example` (Port 3000, HTTP). +9. `Makefile`. +10. `deployment-traefik.md` (externes Netz, Authentik-Redirect-URI, `AUTH_URL`/Forwarded-Header, `/api/health`-Allowlist). +11. End-to-End hinter Traefik. + +### Verifikation +1. Nach `pnpm build`: `test -f .next/standalone/server.js`. +2. `docker build` Exit 0; `docker run ... id -u` → `1001`. +3. `sh -n docker/entrypoint.sh` ok; Logs zeigen Migration + Start. +4. **`/api/health` anonym → 200** (über Dev-Override); bei gestopptem Postgres 503. Bestätigt Middleware-Allowlist. +5. `grep -E "AUTH_URL|AUTH_TRUST_HOST|AUTHENTIK_ISSUER|DATABASE_URL" .env.example` listet alle Pflicht-Keys. +6. `docker compose config` valide; `--services` listet genau `app postgres osrm nominatim`. +7. Nach `make data`: `austria-latest.osrm` existiert; `osrm` `healthy`; Test-Route `code:"Ok"`. +8. Override macht App lokal auf `:3000` erreichbar. +9. `make -n deploy` zeigt `build`→`up`. +10. `grep "callback/authentik" docs/reference/deployment-traefik.md` zeigt `https://${APP_HOST}/api/auth/callback/authentik`. +11. Zielhost: `curl -I https://${APP_HOST}` 200/302 mit TLS; Authentik-Login setzt `__Secure-`-Cookie; Callback-URL `https://`. + +--- + +## Workstream 11 — Tests & Sicherheitshärtung (Phase 7) + +### Ziel +Beweise „kein anonymer Zugriff" durch automatisierte Tests: kritische Auth-Gating-Suite über **alle** Seiten/APIs **und Server Actions**, Rollen-/Wehr-Scoping, Such-/ETA-Happy-Paths, Vitest-Units (Merkmal-Query, Geo/Haversine, argon2id, Security-Header) und eine Härtungs-Checkliste mit je einem Verifikationsschritt. + +### Abhängigkeiten +- Testet/härtet alle anderen WS; setzt deren Verträge voraus. +- Benötigt maschinenlesbares Routen-Manifest (erzeugt es notfalls aus `src/app`). + +### Fixes inline +- API-Erwartung `401` (passt zu Auth-WS-Konvention). +- `PUBLIC_ALLOWLIST` enthält `/api/health`. +- Server-Action-Guard-Check (jede `"use server"`-Funktion beginnt mit Guard). +- Rate-Limit-Test auf den Pfad, der den `authorize`-Callback durchläuft. + +### Dateien +- `playwright.config.ts`, `vitest.config.ts` +- `tests/e2e/global-setup.ts`, `tests/e2e/fixtures/auth.setup.ts` +- `tests/e2e/{routes.manifest.ts,routes.manifest.spec.ts,auth-gating.spec.ts,rbac-scoping.spec.ts,search-eta.spec.ts,security-headers.spec.ts,login-ratelimit.spec.ts,server-actions-guard.spec.ts}` +- `tests/unit/{merkmal-query,geo,argon2id,setup}.test.ts` +- `src/lib/security/{headers.ts,headers.test.ts}` +- `docs/reference/sicherheitshaertung-checkliste.md` +- `package.json` Scripts (`test`, `test:unit`, `test:e2e`, `test:e2e:gating`) + +### Schlüssel-Code + +**`routes.manifest.ts`** — `ROUTES` (Seiten → `expectWhenAnon:"redirect"`, API → `"401"`, alle mit uuid-Beispiel-IDs aus deterministischem Seed), `PUBLIC_ALLOWLIST = ["/login","/api/auth","/api/health","/_next","/favicon.ico","/robots.txt"]`. + +**`auth-gating.spec.ts`** — `test.use({ storageState: { cookies: [], origins: [] } })`; iteriert `ROUTES`: Seiten → `toHaveURL(/\/login/)` + `callbackUrl` + kein Daten-Leak; API → frischer Kontext, `expect(status).toBe(401)`, Body enthält keine Domain-Begriffe. + +**`server-actions-guard.spec.ts`** (statischer Check) +```ts +import { test, expect } from "@playwright/test"; +import { globSync } from "glob"; +import { readFileSync } from "node:fs"; + +const GUARDS = /require(Session|Role|OwnBrigade|PlatformAdmin|WehrAdmin)\s*\(/; +test("jede \"use server\"-Funktion ruft einen Guard", () => { + const files = globSync("src/**/*.ts").filter((f) => readFileSync(f, "utf8").includes('"use server"')); + const offenders: string[] = []; + for (const f of files) { + const src = readFileSync(f, "utf8"); + const fns = src.split(/export async function /).slice(1); + for (const body of fns) if (!GUARDS.test(body.slice(0, 600))) offenders.push(`${f}: ${body.slice(0, body.indexOf("("))}`); + } + expect(offenders, `Server Actions ohne Guard:\n${offenders.join("\n")}`).toEqual([]); +}); +``` + +**`routes.manifest.spec.ts`** — Driftschutz gegen `src/app/**/{page,route}.tsx`; jede nicht in `PUBLIC_ALLOWLIST` befindliche Route muss im Manifest stehen. + +**`merkmal-query.test.ts`** — `buildMerkmalPredicates` (Range→BETWEEN, enum→ANY, boolean→`value_bool`, mehrere AND, leer→TRUE). + +**`geo.test.ts`** — Haversine (St. Pölten→Wien ≈55 km), `etaMinutes` OSRM vs. Haversine-Fallback (`source` markiert), Nominatim-Parsing. + +**`argon2id.test.ts`** — `ARGON2_PARAMS` (type 2, m≥19456, t≥2, p≥1), `$argon2id$`, Roundtrip. + +**`login-ratelimit.spec.ts`** — 7× falsches Passwort über den Credentials-Login (Pfad, der `authorize` durchläuft, z. B. Server-Action `loginWithCredentials`); ab Versuch 6 Drosselung/Fehler; `login_attempts.fail ≥ 5`. + +**`security-headers.spec.ts`** — `x-frame-options:DENY`, `x-content-type-options:nosniff`, CSP `frame-ancestors 'none'`, HSTS; Session-Cookie `httpOnly`+`sameSite`; `secure` nur unter https. + +**`src/lib/security/headers.ts`** — `SECURITY_HEADERS` (HSTS, nosniff, X-Frame-Options DENY, Referrer-Policy, Permissions-Policy `geolocation=(self)`, CSP mit `default-src 'self'`, `img-src 'self' data: blob:`, `worker-src 'self' blob:`, `frame-ancestors 'none'`, `form-action 'self'`). + +### Aufgaben +1. Tooling + Configs (`vitest`, `@vitest/coverage-v8`, `@playwright/test`, `glob`); Scripts. +2. `global-setup.ts`: Migration + deterministischer Seed (Wehren A/B mit Koordinaten, je Fahrzeug/Gerät mit festen uuids, vier Benutzer mit argon2id-Test-Passwort). +3. `auth.setup.ts`: echter Login je Konto → `storageState`. +4. `routes.manifest.ts` + Driftschutz. +5. **Kritische** `auth-gating.spec.ts` (Seiten Redirect, API 401, Negativbeweis). +6. `rbac-scoping.spec.ts` (read→403; Wehr A auf B→403; eigene→200). +7. `search-eta.spec.ts` (Filter + ETA-Sortierung; `E2E_FORCE_HAVERSINE=1` → „Luftlinie"). +8. Units (`merkmal-query`, `geo`, `argon2id`, `headers`); Coverage `src/lib/search`+`src/lib/geo` ≥90 %. +9. Security-Header verdrahten (Phase 0 `next.config.ts`) + Test. +10. `login-ratelimit.spec.ts`. +11. CSRF-Verifikation (State-Changing POST ohne Token → keine Session). +12. Audit-Verifikation (nach `merkmal.promote` → `audit_log`-Zeile). +13. `sicherheitshaertung-checkliste.md` (jeder Punkt mit Test/Befehl). +14. `server-actions-guard.spec.ts` + CI-Verdrahtung. + +### Verifikation +1. `npx vitest --version` / `npx playwright --version` ok; `test:unit` startet ohne Configfehler. +2. `select count(*) from users` → 4; feste `vehicleA`-uuid vorhanden. +3. Nach `--project=setup` vier `tests/e2e/.auth/*.json` mit Session-Cookie. +4. `playwright test routes.manifest` grün; Dummy-Route `/(app)/leak` → Test rot (Drift), Entfernen → grün. +5. **`test:e2e:gating` grün**, ein Fall je Manifest-Eintrag; Ausnehmen einer Route aus default-deny → rot. Test-Anzahl == `ROUTES.length`. +6. `rbac-scoping` grün; „eigenes→200" setzt `status='wartung'`; „Wehr B→403" lässt Datensatz unverändert. +7. `search-eta` grün; Reihenfolge aufsteigende ETA; `E2E_FORCE_HAVERSINE=1` zeigt „Luftlinie". +8. `test:unit` grün; Coverage ≥90 % für `src/lib/search`/`src/lib/geo`. +9. `curl -sI https:///login | grep -i x-frame-options` → `DENY`; Cookie `httpOnly`+`sameSite`. +10. `login-ratelimit` grün (Drosselung ab 6.). +11. CSRF: POST ohne Token erzeugt keine Session (`/api/auth/session` leer). +12. `select aktion, ziel_typ from audit_log order by zeitpunkt desc limit 1` → `merkmal.promote | merkmal`. +13. `server-actions-guard.spec.ts` grün; Entfernen eines Guards aus einer Action → rot. +14. `npm test` Exit 0; `test:e2e:gating` <60 s, als Required-Check konfigurierbar. + +--- + +## Definition of Done & Verifikation + +Das System ist fertig, wenn **alle** folgenden Punkte erfüllt sind: + +1. **Auth-Gating-Garantie (oberstes Prinzip).** `npm run test:e2e:gating` ist grün und enthält genau einen Testfall pro Manifest-Eintrag. Für **jede** Seite redirectet ein anonymer Aufruf auf `/login` (mit `callbackUrl`), für **jede** API-Route liefert er `401` ohne Daten-Leak. Der Driftschutz (`routes.manifest.spec.ts`) verhindert ungetestete neue Routen; `server-actions-guard.spec.ts` verhindert ungeschützte Server Actions. Negativ-Probe: Entfernen eines Layout-Guards oder einer Manifest-Route macht die Suite rot. +2. **Default-deny in der Tiefe.** Jedes Route-Group-Layout (`(app)`, `(admin)`, `verwaltung`) ruft als erste Zeile einen Guard; jede API-Route und Server Action beginnt mit einem Guard. +3. **Rollen-/Wehr-Scoping.** `rbac-scoping.spec.ts` grün: `wehr_read` kann nicht schreiben (403), `wehr_admin` A kann Wehr B nicht ändern (403), eigene Ressource (200); `platform_admin` darf wehrübergreifend. +4. **Migrationen.** `npm run db:migrate` zweimal hintereinander Exit 0 (idempotent, Journal + DO-Block + `IF NOT EXISTS`); alle Tabellen, 7 Enums, die vier `merkmal_values`-Indizes, partieller `merkmale_active_name_uq`, `brigades_latlng_idx`, `login_attempts`-Index vorhanden; `drizzle-kit check` ohne Konflikte. Genau **eine** initiale Migration (DB-WS). +5. **Seeds.** `npm run db:seed` zweimal Exit 0, Counts stabil: 34 Merkmale, 11 Vorlagen, 11 Kategorien; Aliasse in `vehicle_template_aliasse` mit korrektem `bestaetigt` (RLF/RLFA 2000 + 2000-4000 = true); kein HLFA-Alias; HLF 4-U als Alias auf HLF 4. +6. **Suche/ETA.** Dynamische Filter (UND-verknüpft) liefern korrekte Teilmengen; HLFA→HLF + Allrad funktioniert; Treffer nach ETA sortiert; OSRM-Ausfall → „Luftlinie"-Fallback sichtbar. +7. **Qualität.** `npm run lint && npx tsc --noEmit && npx vitest run && npx playwright test` ohne Fehler; kein `any` in produktivem Code; Coverage `src/lib/search`/`src/lib/geo` ≥90 %. +8. **Härtung.** Security-Header, CSRF, argon2id-Parameter, Login-Rate-Limit (über `authorize`), Audit-Log, Cookie-Flags je durch einen Test/Befehl in `docs/reference/sicherheitshaertung-checkliste.md` belegt. +9. **Deployment.** `docker compose config` valide (genau `app postgres osrm nominatim`, kein Proxy); `/api/health` anonym 200; hinter Traefik HTTPS-Login via Authentik mit `__Secure-`-Cookie und `https://`-Callback. + +--- + +## Risiken & offene Punkte + +1. **Such-Aliasse (vorläufig).** Die NÖ-Aliassammlung (RLF/RLFA, KLF, GTLF, TLFA-Varianten) ist teils „offen" (`bestaetigt=false`) und muss fachlich validiert werden. Das `bestaetigt`-Flag erlaubt schrittweises Freigeben ohne Schema-Änderung. HLF 4-U als Alias statt eigener Vorlage ist eine bewusste Vereinfachung — falls die U-Variante eigene Pflichtmerkmale/Anzeige braucht, kann sie später zur eigenständigen Vorlage promoviert werden. +2. **Österreich-OSM-Extrakt-Dimensionierung.** Nominatim-Import (Geofabrik Austria PBF, ~700 MB–1 GB) braucht erheblichen RAM/Disk (mehrere GB, `shm_size: 1g`) und Importzeit (Stunden). OSRM-Preprocessing (extract/partition/customize) ebenso. Erstinbetriebnahme einplanen; Daten-Volumes persistieren. Updates des Extrakts sind manueller `make data`-Lauf. +3. **OSRM `/table`-Skalierung.** Der Bounding-Box-Vorfilter (60 km, max 100 Kandidaten) begrenzt URL-Länge/Latenz. Bei landesweiter Suche ohne Standortnähe kann die Trefferliste groß sein — Pagination der Suche und Anpassung von `radiusKm`/`maxCandidates` sind offene Tuning-Parameter. +4. **Künftige Kopplung `feuerwehr_dashboard`.** Ein separates Einsatz-/Dashboard-System wurde erwähnt, ist aber nicht Teil von v1. Schnittstellen (z. B. gemeinsame `brigades`/`vehicles`-IDs, Auth-SSO über Authentik) sollten beim Datenmodell nicht verbaut werden; UUIDs und Audit-Log erleichtern eine spätere Integration. +5. **Bewusst zurückgestellt (v1 ausgeschlossen):** Borrow-/Ausleih-Workflow (`borrow_requests`) — Datenmodell offen gehalten, Kontakt erfolgt out-of-band; Zwei-Faktor-Authentifizierung (2FA) für lokale Konten — Authentik kann MFA für Platform-Admins bereits abdecken; Volltextsuche über Beladungsdetails; Kartenansicht ist optional (`karte.tsx`). +6. **Funkrufname als Spalte (statt Merkmal).** Entscheidung reduziert auf 34 Merkmale und vermeidet Doppelmodellierung. Falls künftig nach Funkrufname-Schema gefiltert werden soll, ist das über die `brigades.funkrufnameSchema`-Spalte und eine dedizierte Filterlogik zu lösen, nicht über den Merkmal-Katalog. +7. **Authentik-Provisionierung.** Platform-Admins müssen in Authentik **und** in `users` (authTyp `authentik`) vorgemerkt sein; der `signIn`-Callback verweigert sonst. Der Erst-Admin muss per `scripts/seed-auth.ts` angelegt werden — Henne-Ei-Problem beim allerersten Deploy dokumentieren.