# 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://<host>`. 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 `<html lang="de">`, 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> = T extends Leaf ? "" :
  { [K in keyof T & string]: T[K] extends Leaf ? K : `${K}.${Paths<T[K]>}` }[keyof T & string];
export function t(path: Paths<typeof de>): string {
  return path.split(".").reduce<unknown>((o, k) => (o as Record<string, unknown>)[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 (
    <span className={cn("inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 text-xs font-medium tabular-nums", s.cls)}>
      <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden />
      {s.label}
    </span>
  );
}
```

**`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 (
    <div className="mx-auto max-w-md py-16 text-center">
      <p className="text-anthrazit">{t("fehler.allgemein")}</p>
      <button onClick={reset} className="mt-4 rounded bg-navy px-4 py-2 text-white">Erneut versuchen</button>
    </div>
  );
}
```

**`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<Awaited<ReturnType<typeof auth>>> & {
  user: { id: string; role: Role; brigadeId: string | null; email: string; name: string };
};
export async function requireSession(): Promise<AppSession> {
  const s = await auth();
  if (!s?.user) redirect("/login");
  return s as AppSession;
}
export async function requireRole(...allowed: Role[]): Promise<AppSession> {
  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<AppSession & { user: { brigadeId: string } }> {
  const s = await requireRole("wehr_admin");
  if (!s.user.brigadeId) forbidden();
  return s as any;
}
export async function requireOwnBrigade(brigadeId: string): Promise<AppSession> {
  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 <AppShell>{children}</AppShell>;
}
```

### 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> = T & { eta: EtaResult };
export type GeoCandidate = { brigadeCoords: Coordinates | null };
```

**`nominatim.ts`** — `geocodeAddress(address): Promise<GeocodeResult>` (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<T extends { brigadeId: string }>(
  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.<merkmalId>=...` (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.<uuid>':'2000..4000','f.<uuid2>':'ja','bereit':'1'})` korrekt; `f.<unbekannt>=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<max`.
6. `searchVehicles({filter:[{typ:'number',merkmalId:<tank>,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.<id>=…`/`?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<string>([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 <div className="min-h-screen bg-nebelgrau"><AdminNav /><main className="mx-auto max-w-6xl px-6 py-8">{children}</main></div>;
}
```

**`_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<NodePgQueryResultHKT, typeof schema, ExtractTablesWithRelations<typeof schema>>;

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(<HLF-2>)` 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 `<Link href="/geraete/${id}">`).
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(<uuid>)` liefert nicht-leeres `merkmale` + `beladung`.
3. `curl -sI /fahrzeuge/<uuid>` ohne Cookie → `location: /login`.
4. `KontaktButton` mit nur `email` → genau ein `mailto:`-Link; beide leer → Hinweistext.
5. `EckdatenGrid` `rows=[]` → Empty-Text; `BeladungListe` → `<a href="/geraete/…">` je Item.
6. Browser `/fahrzeuge/<seed>`: Titel, Kicker, Badge, Eckdaten, Beladung, WehrCard; `/fahrzeuge/<ungültig>` → 404 (deutsch).
7. Gerät `vehicle_id=null` → „im Gerätehaus"; mit Fahrzeug → Link navigiert.
8. `/wehren/<seed>` 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://<host>/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.
