# 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.