Files
Florian-netz/docs/superpowers/plans/2026-06-08-floriannetz-implementation-plan.md
Claude 6ebcd270ad Add FlorianNetz implementation plan (workflow-generated)
Phased, dependency-ordered plan across 11 workstreams (foundation,
schema, auth, admin taxonomy, brigade area, search, geo/ETA, detail,
deployment, seed, tests/security) with exact file paths, code/schema
snippets, ordered tasks and per-task verification. Includes cross-cutting
standards, definition-of-done, and risks. Produced by a fan-out design +
adversarial critique + synthesis workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:10:43 +02:00

1375 lines
83 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 MB1 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.