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

83 KiB
Raw Permalink Blame History

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 & Migrationenalleiniger 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 vehiclesnicht 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)

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)

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)

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)

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)

"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 testenv.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)

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

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

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

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.tsvehicles (mit templateId set null, funkrufname, status asset_status), equipment (mit categoryId, vehicleId nullbar = im Gerätehaus). auth-rate-limit.tslogin_attempts(key, erfolg, zeitpunkt) + Index login_attempts_key_zeit_idx. audit.tsaudit_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.tsROLES/Role/ALL_ROLES als Single Source of Truth.

src/lib/auth/password.ts (argon2id, nur Node)

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)

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)

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)

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)

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:

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

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.tsgeocodeAddress(address): Promise<GeocodeResult> (rein, countrycodes=at, Timeout/Abort, status: "ok"|"not_found"|"error").

osrm.tsetaTable(origin, destinations) via /table/v1/driving/...?sources=0&annotations=duration,distance (Koordinaten lng,lat), wirft bei Fehler.

candidates.ts (Adapter + Bounding-Box-Vorfilter)

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.tsorderByEintreffzeit(origin, candidates): OSRM zuerst, kompletter Haversine-Fallback bei Wurf, Kandidaten ohne Koordinaten ans Ende (durationSec=null), stabile aufsteigende Sortierung.

api/geo/geocode/route.tsPOST, 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.ymlosrm (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 nominatimhealthy.

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)

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.tsf.<merkmalId>=... (number lo..hi, enum CSV, boolean ja/nein), verwirft Ungültiges still.

useSearchParams.tsrouter.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)

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

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.tspromoteMerkmal (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)

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)

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)

"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.tscreateBrigadeUser (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.tsformatMerkmal (number+Einheit via Intl.NumberFormat("de-AT"), NBSP vor Einheit; boolean→Ja/Nein; enum→enumLabel; leer→„").

queries.tsloadMerkmalRows(entityTyp, entityId) joint merkmal_valuesmerkmalemerkmal_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

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.tsxtel:/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)

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.tsonConflictDoUpdate 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 merkmale34; select count(*) from merkmal_optionen where merkmal_id=(select id from merkmale where slug='feuerloeschpumpe_typ') → 8.
  4. select count(*) from vehicle_templates11; 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 0next.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)

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.ymlapp (Traefik-Labels: Host(\${APP_HOST}`), websecure, tls.certresolver, Security-Header-Middleware), postgres(healthcheckpg_isready), osrm, nominatim; Netze traefik(external) +internal; **kein** Proxy-Service. app.environmentsetztAUTH_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 -u1001.
  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 buildup.
  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.tsROUTES (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.tstest.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)

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.tsbuildMerkmalPredicates (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.tsARGON2_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.tsx-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.tsSECURITY_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.promoteaudit_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-optionsDENY; 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 1merkmal.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.