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>
1375 lines
83 KiB
Markdown
1375 lines
83 KiB
Markdown
# 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.
|