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>
83 KiB
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:
- zentrale
middleware.ts(erste Verteidigungslinie), - 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), - 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)
- 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 (zentralede.tsist verbindlich). Aktiviertexperimental.authInterruptsinnext.config.ts(fürforbidden()).
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
rolemit Wertenplatform_admin|wehr_admin|wehr_read; Auth-Typ-Enum heißtauth_typ(authentik|local). Status-Enum heißtasset_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_aliassemitbestaetigt-Flag — keinjsonb. - Vorlagen-Vorgabewert: drei typisierte Spalten
vorgabewert_num/_text/_bool. merkmalehat zusätzlichslug text NOT NULL UNIQUE(Idempotenz-Key für Seed).- Alle JS-Property-Namen ASCII (
wehrfuehrer, nichtwehrfü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.
- 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. - API-Routen geben
401/403zurück, keine HTML-Redirects. Jederoute.tsruftauth()selbst und antwortet bei fehlender Session mit401, bei falscher Rolle/fremder Wehr mit403. Fehlerbodies enthalten keine Fachdaten (kein Daten-Leak). - Server Actions beginnen mit einem Guard. Jede
"use server"-Funktion ruft als erste Anweisung einen Guard. Ein Lint-/Test-Check erzwingt das (Test-Workstream). - 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.
- Deutsche i18n via zentraler Tabelle. UI-Strings kommen aus
src/lib/i18n/de.tsübert(). Keine hartkodierten deutschen Strings im JSX (Ausnahme: einmalige Seitentitel sind erlaubt, müssen aber konsistent sein). Die Tabelle ist verbindlich, nicht totes Gerüst. - 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.writeAudithat eine Signatur mit optionalemtx-Parameter. - Idempotente Migrationen & Seeds. Migrationen:
CREATE TYPEinDO $$ … EXCEPTION WHEN duplicate_object THEN null; END $$;, IndizesIF NOT EXISTS. Seeds: ausschließlichonConflictDoUpdateauf Natural Keys; mehrfaches Ausführen ändert keine Counts. - 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_*. - Traefik / Forwarded-Header / sichere Cookies.
AUTH_TRUST_HOST=true,AUTH_URL=https://<host>. Cookie-secureund__Secure--Präfix nur, wennAUTH_URLhttps://ist — sonst bricht lokale HTTP-Entwicklung.sameSite=lax,httpOnly=true. - States: loading / empty / error. Jede Route-Group hat
loading.tsxunderror.tsx(deutsche Texte). Listen haben Empty-States. Geo-Calls (4 s Timeout) brauchen Suspense/Loading, damit die Suche nicht einfriert. - argon2id mit OWASP-Minima.
type=argon2id,memoryCost ≥ 19456,timeCost ≥ 2,parallelism ≥ 1. argon2 wird nie im Edge-/Middleware-Pfad importiert. - TypeScript strict, kein
any. Drizzle-Transaktionstypen korrekt verwenden.forbidden()ausnext/navigationnur mit aktiviertemexperimental.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.tsmit 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=testdürfenAUTHENTIK_*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
- Next.js-Gerüst (
package.json,tsconfig.jsonstrict + Alias,next.config.tsmitoutput:"standalone"+authInterrupts+ Security-Header,.nvmrc,.gitignore, ESLint/Prettier);npm install. - Tailwind/PostCSS + Tokens +
globals.css. - Root-Layout + Schriften (
next/font);page.tsxRedirect-Stub. - Route-Groups
(auth)und(app)inkl.loading.tsx/error.tsx/not-found.tsxje Group; Guard-Slot-Kommentar im(app)/layout.tsx. - Brand + Layout-Komponenten.
- Radix-Basiskomponenten inkl.
select,switch,slider(für Suche/Editoren). de.ts(+t()),validation/common.ts, kanonischesenv.ts.- Drizzle-Setup (
schema/index.tsleer,client.ts,drizzle.config.ts,.env.example). - Vitest-Setup +
env.test.ts. - Smoke-Run.
Verifikation
npm run typecheckExit 0;npm run lintohne Errors.grep -q "1B3A5B" tailwind.config.tsbestätigt Navy-Token.npm run dev;curl -s localhost:3000/anmelden | grep -q 'lang="de"'und Antwort enthältFlorianNetz.- Browser
/start: Topbar mit Netzknoten-SVG, Serifen-Überschrift, Nebelgrau;/anmeldenohne Topbar. - Komponenten-Sichtprüfung:
Button variant="signal"signalrot,StatusBadgekorrekte Farben,tabular-numsgleich breit. npm test→env.test.tsgrün (Fail-Fast).npm run builderfolgreich mitoutput:"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.funkrufnameist 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 (Exportdb,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(Scriptsdb: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:
roleEnumwird alspgEnum("role", …), NICHTrolle, definiert; das Drizzle-Property heißtrolle. So gibt es nur ein DB-Enumrole.
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.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
- DB-Container (
postgres:16indocker-compose.yml),DATABASE_URLin.env. - Drizzle-Deps (
drizzle-orm,pg, dev:drizzle-kit,@types/pg,tsx,vitest). - Alle Schema-Dateien +
enums.ts+relations.ts+ Barrel anlegen (Entscheidungen oben umsetzen). db/index.ts,drizzle.config.ts,scripts/migrate.ts,package.json-Scripts.tsc --noEmit,drizzle-kit check.- Einzige initiale Migration generieren (
db:generate). - Migration anwenden (
db:migrate). - Idempotenz herstellen:
CREATE TYPEin DO-Block,CREATE INDEX IF NOT EXISTS, partiellenmerkmale_active_name_uq+brigades_latlng_idxergänzen; Re-Run testen. - Schema-Tests (Enums, vier
merkmal_values-Indizes, FKs, Uniques, partieller Name-Unique,login_attempts-Index). - EAV-Smoke-Insert (Brigade→Merkmal→Vehicle→
value_num) Round-Trip.
Verifikation
docker compose up -d postgres;pg_isreadyok;psql "$DATABASE_URL" -c "select 1".npm ls drizzle-orm drizzle-kit pgohne UNMET.npx tsc --noEmit0 Fehler;npx drizzle-kit checkkeine Konflikte.db:generateerzeugt eine0000_*.sqlmitCREATE TABLE "merkmal_values", 7 Enums, ≥4 Index-Statements fürmerkmal_values.db:migrateExit 0;\dtlistet alle Tabellen +vehicle_template_aliasse+login_attempts+audit_log+__drizzle_migrations.- Zweiter
db:migrateExit 0, keinealready exists; manuellerpsql -f 0000_*.sqlfehlerfrei. - Schema-Test grün:
enum_range(NULL::asset_status)enthälteinsatzbereit,wartung,ausser_dienst; alle viermv_*-Indizes vorhanden;merkmale_active_name_uqist partiell (WHERE status='active'); zweiactive-Merkmale gleichen Namens schlagen fehl, zweiproposedgelingen;users_email_uq,vehicle_templates_code_uqexistieren. - EAV-Round-Trip liefert
value_num=2000; FK-Verletzung wirft23503.
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.tsxruft serverseitigrequireSession()(Verteidigung in der Tiefe).- API-Routen geben
401/403selbst 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.tssrc/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.tssrc/types/next-auth.d.tssrc/app/api/auth/[...nextauth]/route.tssrc/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)
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 mitNextResponse401/403(Guards mitredirect/forbiddensind 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
- Auth-Deps installieren (
next-auth@5,@node-rs/argon2). roles.ts+next-auth.d.ts.password.ts(argon2id-Parameter).auth.config.ts(Edge-sicher, Cookie umgebungsabhängig).auth.ts(Credentials + DB + Rate-Limit imauthorize+ Authentik-signIn-Gate).api/auth/[...nextauth]/route.ts.middleware.tsmitapi/health-Ausnahme.guards.ts(voller Satz).rate-limit.ts.(app)/layout.tsxGuard einbauen;(auth)/login-Seite + Action.- Seed-Skript
scripts/seed-auth.ts(1 platform_admin authentik, 1 wehr_admin local). - Tests (
password,guards,auth-gating).
Verifikation
tsc --noEmit && lint0 Fehler.grep -R "@node-rs/argon2\|@/db" src/auth.config.ts src/middleware.ts→ keine Treffer (Edge-sicher).- Migration vorhanden (
login_attemptsmit Index) — bereits von DB-WS. vitest run tests/unit/password.test.tsgrün;$argon2id$-Präfix;verify(hash,"falsch")===false.- Credentials-Login als wehr_admin → Redirect
/; Cookiefloriannetz.session(lokal) bzw.__Secure-…(https) mitHttpOnly/SameSite=Lax. - Credentials mit Authentik-E-Mail → „falsch" (authTyp-Gate).
- Default-deny (Kerngarantie): Anonyme Aufrufe von
/,/fahrzeuge,/admin/*,/verwaltung→ Redirect/login;/api/*→401. Entfernen vonrequireSession()aus(app)/layout.tsxmacht die Gating-Suite rot. - 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) —
brigadesinkl. 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+ AdaptersearchHitsToGeoCandidates.
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
searchHitsToGeoCandidateslebt hier (lädtbrigades.lat/lng).
Dateien
docker-compose.geo.yml,infra/geo/Makefile,infra/geo/README.md,scripts/prepare-osm-data.sh,docker/osrm/Dockerfilesrc/lib/geo/{config.ts,types.ts,haversine.ts,nominatim.ts,osrm.ts,eintreffzeit.ts,candidates.ts}src/app/api/geo/{geocode,health}/route.tssrc/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.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)
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
config.ts/types.ts;.env.exampleum Geo-Variablen erweitern (im kanonischenenv.tsbereits deklariert).haversine.ts.osrm.ts+nominatim.ts(Timeout/Abort).candidates.ts(Adapter + Bounding-Box).eintreffzeit.ts(OSRM-first + Fallback).api/geo/geocode+api/geo/health(auth-gated,401).standort-input.tsx+eta-badge.tsx(Fallback-Kennzeichnung „Luftlinie").- optionale
karte.tsx(next/dynamic, ssr:false). - Docker-Geo + Preprocessing + README.
- Units (Haversine,
orderByEintreffzeitmit gemocktem OSRM: Erfolg + Wurf→Fallback + null-Koordinaten).
Verifikation
tsc --noEmitok;env.tswirft bei fehlenden Geo-Variablen nicht (Defaults), bei kaputter URL doch.- Haversine-Test: St. Pölten→Wien ≈ 55 km (±3).
- OSRM-
/table-curl liefertcode:"Ok"; Unit extrahiertdurations[0][1]. - Nominatim-curl für St.-Pölten-Adresse liefert NÖ-Treffer.
eintreffzeit.test.ts: (a) OSRM-Mock → sortiert,mode:"osrm"; (b) Wurf → allehaversine,isFallback:true, sortiert; (c) null-Koordinaten am Ende.brigades-Geo-Spalten +brigades_latlng_idxvorhanden (DB-WS).searchHitsToGeoCandidatesfiltert außerhalb 60 km heraus und kappt beimaxCandidates.POST /api/geo/geocodeohne Session →401; mit Session + gültiger Adresse →200+coords.eta-badgezeigt bei OSRM-Ausfall „Luftlinie (geschätzt)".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 (keinemerkmal_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[]ansearchHitsToGeoCandidates+orderByEintreffzeit.
Fixes inline
- uuid (
string) IDs durchgängig inSearchHit. - 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 aufvehicles.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.tsxsrc/components/search/{SearchTabs,SearchBar,FilterPanel,useSearchParams}.tsx+facets/{NumberRangeFacet,EnumFacet,BooleanFacet}.tsx+results/{ResultList,VehicleResultRow,EquipmentResultRow,BrigadeResultRow}.tsxsrc/lib/search/__tests__/{query-vehicles,parse-params}.test.tstests/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.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
types.ts(uuid).indexes-trgm.sql(pg_trgm+ GIN aufvehicles.name/funkrufname).parse-params.ts.- Allrad-Logik importieren (
@/lib/admin/codes.expandNameQuery). facets.ts.query-vehicles.ts.query-equipment.ts(mitcategory_id, ohne Allrad-Spezialfall).query-brigades.ts(Name/Ort/PLZ).useSearchParams.ts.- Facet-Komponenten (Slider/Multi-Select/Tri-State Switch).
FilterPaneldispatcht + Status-Switch.SearchBar+SearchTabs.- Result-Komponenten (Empty-State, offener ETA-Slot).
- Drei Tab-Seiten + Startseite; in
fahrzeuge/page.tsxTreffer durchsearchHitsToGeoCandidates+orderByEintreffzeitsortieren, sobald Standort gesetzt. - Units (
query-vehicles,parse-params). - Playwright
search.spec.ts.
Verifikation
tsc --noEmit0 Fehler;SearchHit.entityIdiststring.\di mv_merkmal_num_idx idx_vehicles_name_trgmlistet beide;EXPLAINRange-Query zeigt Index Scan.parseSearchParams({'f.<uuid>':'2000..4000','f.<uuid2>':'ja','bereit':'1'})korrekt;f.<unbekannt>=fooverworfen.expandNameQuery('HLFA 3')⇒{nameLikes:['HLFA 3','HLF 3'], allradImpliziert:true};'HLFA 1 W'und'MTFA'korrekt (Tests im Admin-WS).getFacets('vehicle')enthält nuractive, keinproposed, keintext-Merkmal; enum sortiert; numbermin<max.searchVehicles({filter:[{typ:'number',merkmalId:<tank>,gte:2000}]})nur Tank ≥2000; kombiniert mit Allrad=true schrumpft korrekt (UND).- Geräte/Wehren liefern erwartete Teilmengen.
/fahrzeugerendert jeactive-Merkmal genau ein UI-Element des richtigen Typs.- Filter ändern → URL
f.<id>=…/?bereit=1; Trefferzahl sinkt; Reload identisch. /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.tsist 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.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)
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
- Taxonomietabellen importieren (nicht definieren).
codes.tsimplementieren + Vitest (inkl.HLFA 1 W,MTFA).audit.ts(writeAudit(actorUserId, aktion, zielTyp, zielId, details?, tx?)).(admin)/layout.tsx+AdminNav.- Merkmal-CRUD (Zod, Actions create/update/delete, Editor mit Enum-Optionen; Delete blockieren, wenn referenziert).
- Proposal-Flow (promote/merge, Typ-Kompatibilität, Audit).
- Vorlagen-CRUD (TemplateMerkmaleEditor: Vorgabewert je Typ →
vorgabewert_num/_text/_bool; AliasEditor mitbestaetigt; Allrad-Hinweis viaallradCode(code)). - Geräte-Kategorien-CRUD.
- Brigade-Bereitstellung (
provisioning.ts,wehren/neu, Reset + Audituser.reset). - Audit-Viewer (paginiert, Filter).
- Audit in allen Schreib-Actions.
- Auth-Gating-Tests
(admin).
Verifikation
tsc --noEmitgrün; alle Taxonomietabellen ohne Fehler importierbar.- Migration vorhanden (partieller
merkmale_active_name_uq— DB-WS); zweiactivegleichen Namens schlagen fehl, zweiproposedgelingen. 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.- Als platform_admin
/adminerreichbar; Audit-Insert prüfbar. - Als wehr_admin/wehr_read/anonym
/admin/*→ 403/Redirect (admin-gating.spec.tsgrün). - Merkmal Typ Auswahl ohne Optionen → Zod-Fehler; mit Optionen → Liste +
merkmal.create-Audit; Delete eines referenzierten Merkmals → klare Fehlermeldung, kein 500. - proposed-Merkmal: promote setzt
active+ Audit; merge hängtmerkmal_valuesum + löscht proposed. - Vorlagen-Detail HLF 3 zeigt „HLFA 3"; Merkmal mit
vorgabewert_text→ Zeile; Alias „RLFA 2000-4000"bestaetigt=true. - Kategorie + 2 Merkmale → 2 Zeilen, korrekte Reihenfolge.
wehren/neu: Brigade mit lat/lng (oder markiert „nicht geokodiert"),users-Eintragwehr_admin/local/$argon2id$, Temp-Passwort einmal angezeigt, Auditbrigade.create; Reset → neuer Hash +user.reset.- 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(keingeocodeBrigade). - Vorgabewerte aus
vorgabewert_num/_text/_boollesen. - Keine
any-Typen: Drizzle-Tx-Typ korrekt. - Audit für alle Schreib-Actions;
writeAuditmit optionalemtx.
Dateien
- Pages
(app)/verwaltung/:layout.tsx(GaterequireWehrAdmin),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.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
- Validierungsschemata +
merkmale/types.ts. - Lesehelfer
data/merkmale.ts(getMerkmaleForTemplate/Category, Vorgabewerte aus 3 Spalten,getMerkmalValuesForEntity). upsertValues.ts(typisierte Tx).requireWehrAdminimportieren;verwaltung/layout.tsx+ Sub-Nav.- Profil (
BrigadeProfileForm+updateBrigadeProfile+ Seite). MerkmalValueEditor+VehicleForm+TemplatePicker.- Fahrzeug-Anlage (Vorlage/frei) + Actions.
- Fahrzeug-Bearbeiten (scoped) + status/delete.
- Fahrzeug-Liste mit Status-Badge.
- Geräte (Form mit Zuordnung, Actions, Seiten).
- Benutzerverwaltung (Form, Actions, argon2id, Selbst-Deaktivierung verhindert).
- Audit in allen Schreib-Actions.
- Vitest
buildMerkmalValuesSchema+upsertMerkmalValues. - Playwright Happy-Path + Scoping.
Verifikation
tsc --noEmitohneany;vehicle.test.tsgrün (Pflicht/Typen).getMerkmaleForTemplate(<HLF-2>)liefertLöschwassertank (l),Feuerlöschpumpe (Typ)mit Optionen;Allradantriebals boolean.upsertMerkmalValueszweimal → genau eine Zeile; leerer Wert → keine Zeile.- Profil speichern →
brigades.lat/lnggesetzt; nicht geokodierbar → Warn-Toast, Speichern trotzdem. - Vorlage „HLF 2" → Felder vorbefüllt; speichern → Liste mit Badge;
merkmal_valuesbefüllt. - Gerät „im Gerätehaus" →
vehicle_id IS NULL; umstellen → gesetzt. - Scoping: fremdes Fahrzeug → not-found/403, unverändert;
wehr_read→ 403; anonym → Redirect. createBrigadeUserwehr_read→ eigene Wehr,$argon2id$;platform_adminvon Zod abgelehnt; Selbst-Deaktivierung wirft.audit_logzeigtvehicle.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ätzlichrequireSession(). - Designsystem (Phase 0) —
StatusBadgeetc. - Seed (Phase 6) — echte Eckdaten.
Dateien
src/lib/detail/{queries.ts,merkmale.ts}src/components/kontakt/{KontaktButton,WehrCard}.tsxsrc/components/detail/{EckdatenGrid,StatusBadge,BeladungListe,DetailHeader}.tsxsrc/app/(app)/{fahrzeuge,geraete,wehren}/[id]/page.tsx+fahrzeuge/[id]/not-found.tsxsrc/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
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
merkmale.ts(de-AT, NBSP, Ja/Nein, enum-Label, „–").queries.ts(uuid, read-only).requireSessionimportieren.- Kontakt-Komponenten.
- Detail-Bausteine (Beladung als
<Link href="/geraete/${id}">). - Fahrzeug-Detailseite +
not-found.tsx. - Gerät-Detailseite (Fahrzeug-Link oder „im Gerätehaus").
- Wehr-Detailseite (Fuhrpark verlinkt + Kontakt).
- Vitest
formatMerkmal(4 Typen + Null + Einheit + Tausenderpunkt). - Playwright (anonym → Redirect; eingeloggt → Eckdaten/Links/
tel:).
Verifikation
vitest run src/lib/detailgrün:{number,14000,l}→"14.000 l"(NBSP),{boolean,false}→"Nein",{enum,enumLabel:"FPN 10-2000"}→"FPN 10-2000", alle null→"–".tsc --noEmitok;getFahrzeugDetail(<uuid>)liefert nicht-leeresmerkmale+beladung.curl -sI /fahrzeuge/<uuid>ohne Cookie →location: /login.KontaktButtonmit nuremail→ genau einmailto:-Link; beide leer → Hinweistext.EckdatenGridrows=[]→ Empty-Text;BeladungListe→<a href="/geraete/…">je Item.- Browser
/fahrzeuge/<seed>: Titel, Kicker, Badge, Eckdaten, Beladung, WehrCard;/fahrzeuge/<ungültig>→ 404 (deutsch). - Gerät
vehicle_id=null→ „im Gerätehaus"; mit Fahrzeug → Link navigiert. /wehren/<seed>listet alle Fahrzeuge (count == DB).playwright test detail-auth.spec.tsgrün; Entfernen vonrequireSession()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), dreivorgabewert_*-Spalten. Migrationen müssen angewendet sein. - Admin (Phase 4) — Taxonomietabellen existieren.
Fixes inline
- Aliasse in
vehicle_template_aliassemitbestaetigt(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.slugebenfalls vorhanden). - Vorgabewerte typgerecht in
vorgabewert_num/_text/_boolschreiben.
Dateien
src/db/seed/index.ts,src/db/seed/upsert.tssrc/db/seed/data/{merkmale,vehicle-templates,equipment-categories}.tssrc/db/seed/seed.test.ts;package.jsonScriptdb: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.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
- Schema-Constraints sicherstellen (alle vom DB-WS vorhanden); ggf. Folge-Migration.
tsx+db:seed-Script.data/merkmale.ts(34 + Optionen).data/vehicle-templates.ts(11, Aliasse mitbestaetigt, HLF 4-U Alias, kein HLFA-Alias, typisierte Vorgabewerte).data/equipment-categories.ts.upsert.ts+index.ts.- Idempotenz-Test (2× ausführen, Counts stabil).
Verifikation
- Migration vorhanden:
\d merkmalezeigtslugUNIQUE;\d vehicle_template_aliassezeigtbestaetigt. npm runlistetdb:seed.select count(*) from merkmale→ 34;select count(*) from merkmal_optionen where merkmal_id=(select id from merkmale where slug='feuerloeschpumpe_typ')→ 8.select count(*) from vehicle_templates→ 11; Aliasse HLF 2 enthaltenRLF 2000/RLFA 2000mitbestaetigt=true.select count(*) from vehicle_template_aliasse where alias like 'HLFA%'→ 0 (kein HLFA-Alias); keincode='HLFA 3'.- HLF 4: Alias
HLF 4-Uvorhanden;pulverloeschanlagemitpflicht=true. select count(*) from equipment_categories→ 11.- Idempotenz:
npm run db:seed && npm run db:seedbeide grün; alle Counts unverändert;vitest run src/db/seed/seed.test.tsruftmain()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_URLals Pflicht-Env (Callback-Basis + Cookie-secure).
Dateien
Dockerfile(multi-stage, non-root, standalone),.dockerignoredocker-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,Makefiledocs/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.yml — app (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
.dockerignore+next.config.ts(output:"standalone").Dockerfilebauen.entrypoint.sh./api/health(falls nicht vorhanden)..env.example(inkl.AUTH_URL).docker-compose.yml(externes Netz, Labels, Healthchecks, kein Proxy).docker/osrm/Dockerfile+prepare-osm-data.sh+make data.docker-compose.override.yml.example(Port 3000, HTTP).Makefile.deployment-traefik.md(externes Netz, Authentik-Redirect-URI,AUTH_URL/Forwarded-Header,/api/health-Allowlist).- End-to-End hinter Traefik.
Verifikation
- Nach
pnpm build:test -f .next/standalone/server.js. docker buildExit 0;docker run ... id -u→1001.sh -n docker/entrypoint.shok; Logs zeigen Migration + Start./api/healthanonym → 200 (über Dev-Override); bei gestopptem Postgres 503. Bestätigt Middleware-Allowlist.grep -E "AUTH_URL|AUTH_TRUST_HOST|AUTHENTIK_ISSUER|DATABASE_URL" .env.examplelistet alle Pflicht-Keys.docker compose configvalide;--serviceslistet genauapp postgres osrm nominatim.- Nach
make data:austria-latest.osrmexistiert;osrmhealthy; Test-Routecode:"Ok". - Override macht App lokal auf
:3000erreichbar. make -n deployzeigtbuild→up.grep "callback/authentik" docs/reference/deployment-traefik.mdzeigthttps://${APP_HOST}/api/auth/callback/authentik.- Zielhost:
curl -I https://${APP_HOST}200/302 mit TLS; Authentik-Login setzt__Secure--Cookie; Callback-URLhttps://.
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_ALLOWLISTenthä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.tstests/e2e/global-setup.ts,tests/e2e/fixtures/auth.setup.tstests/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.tssrc/lib/security/{headers.ts,headers.test.ts}docs/reference/sicherheitshaertung-checkliste.mdpackage.jsonScripts (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)
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
- Tooling + Configs (
vitest,@vitest/coverage-v8,@playwright/test,glob); Scripts. global-setup.ts: Migration + deterministischer Seed (Wehren A/B mit Koordinaten, je Fahrzeug/Gerät mit festen uuids, vier Benutzer mit argon2id-Test-Passwort).auth.setup.ts: echter Login je Konto →storageState.routes.manifest.ts+ Driftschutz.- Kritische
auth-gating.spec.ts(Seiten Redirect, API 401, Negativbeweis). rbac-scoping.spec.ts(read→403; Wehr A auf B→403; eigene→200).search-eta.spec.ts(Filter + ETA-Sortierung;E2E_FORCE_HAVERSINE=1→ „Luftlinie").- Units (
merkmal-query,geo,argon2id,headers); Coveragesrc/lib/search+src/lib/geo≥90 %. - Security-Header verdrahten (Phase 0
next.config.ts) + Test. login-ratelimit.spec.ts.- CSRF-Verifikation (State-Changing POST ohne Token → keine Session).
- Audit-Verifikation (nach
merkmal.promote→audit_log-Zeile). sicherheitshaertung-checkliste.md(jeder Punkt mit Test/Befehl).server-actions-guard.spec.ts+ CI-Verdrahtung.
Verifikation
npx vitest --version/npx playwright --versionok;test:unitstartet ohne Configfehler.select count(*) from users→ 4; festevehicleA-uuid vorhanden.- Nach
--project=setupviertests/e2e/.auth/*.jsonmit Session-Cookie. playwright test routes.manifestgrün; Dummy-Route/(app)/leak→ Test rot (Drift), Entfernen → grün.test:e2e:gatinggrün, ein Fall je Manifest-Eintrag; Ausnehmen einer Route aus default-deny → rot. Test-Anzahl ==ROUTES.length.rbac-scopinggrün; „eigenes→200" setztstatus='wartung'; „Wehr B→403" lässt Datensatz unverändert.search-etagrün; Reihenfolge aufsteigende ETA;E2E_FORCE_HAVERSINE=1zeigt „Luftlinie".test:unitgrün; Coverage ≥90 % fürsrc/lib/search/src/lib/geo.curl -sI https://<host>/login | grep -i x-frame-options→DENY; CookiehttpOnly+sameSite.login-ratelimitgrün (Drosselung ab 6.).- CSRF: POST ohne Token erzeugt keine Session (
/api/auth/sessionleer). select aktion, ziel_typ from audit_log order by zeitpunkt desc limit 1→merkmal.promote | merkmal.server-actions-guard.spec.tsgrün; Entfernen eines Guards aus einer Action → rot.npm testExit 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:
- Auth-Gating-Garantie (oberstes Prinzip).
npm run test:e2e:gatingist grün und enthält genau einen Testfall pro Manifest-Eintrag. Für jede Seite redirectet ein anonymer Aufruf auf/login(mitcallbackUrl), für jede API-Route liefert er401ohne Daten-Leak. Der Driftschutz (routes.manifest.spec.ts) verhindert ungetestete neue Routen;server-actions-guard.spec.tsverhindert ungeschützte Server Actions. Negativ-Probe: Entfernen eines Layout-Guards oder einer Manifest-Route macht die Suite rot. - 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. - Rollen-/Wehr-Scoping.
rbac-scoping.spec.tsgrün:wehr_readkann nicht schreiben (403),wehr_adminA kann Wehr B nicht ändern (403), eigene Ressource (200);platform_admindarf wehrübergreifend. - Migrationen.
npm run db:migratezweimal hintereinander Exit 0 (idempotent, Journal + DO-Block +IF NOT EXISTS); alle Tabellen, 7 Enums, die viermerkmal_values-Indizes, partiellermerkmale_active_name_uq,brigades_latlng_idx,login_attempts-Index vorhanden;drizzle-kit checkohne Konflikte. Genau eine initiale Migration (DB-WS). - Seeds.
npm run db:seedzweimal Exit 0, Counts stabil: 34 Merkmale, 11 Vorlagen, 11 Kategorien; Aliasse invehicle_template_aliassemit korrektembestaetigt(RLF/RLFA 2000 + 2000-4000 = true); kein HLFA-Alias; HLF 4-U als Alias auf HLF 4. - Suche/ETA. Dynamische Filter (UND-verknüpft) liefern korrekte Teilmengen; HLFA→HLF + Allrad funktioniert; Treffer nach ETA sortiert; OSRM-Ausfall → „Luftlinie"-Fallback sichtbar.
- Qualität.
npm run lint && npx tsc --noEmit && npx vitest run && npx playwright testohne Fehler; keinanyin produktivem Code; Coveragesrc/lib/search/src/lib/geo≥90 %. - Härtung. Security-Header, CSRF, argon2id-Parameter, Login-Rate-Limit (über
authorize), Audit-Log, Cookie-Flags je durch einen Test/Befehl indocs/reference/sicherheitshaertung-checkliste.mdbelegt. - Deployment.
docker compose configvalide (genauapp postgres osrm nominatim, kein Proxy);/api/healthanonym 200; hinter Traefik HTTPS-Login via Authentik mit__Secure--Cookie undhttps://-Callback.
Risiken & offene Punkte
- Such-Aliasse (vorläufig). Die NÖ-Aliassammlung (RLF/RLFA, KLF, GTLF, TLFA-Varianten) ist teils „offen" (
bestaetigt=false) und muss fachlich validiert werden. Dasbestaetigt-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. - Ö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 manuellermake data-Lauf. - 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 vonradiusKm/maxCandidatessind offene Tuning-Parameter. - Künftige Kopplung
feuerwehr_dashboard. Ein separates Einsatz-/Dashboard-System wurde erwähnt, ist aber nicht Teil von v1. Schnittstellen (z. B. gemeinsamebrigades/vehicles-IDs, Auth-SSO über Authentik) sollten beim Datenmodell nicht verbaut werden; UUIDs und Audit-Log erleichtern eine spätere Integration. - 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). - 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. - Authentik-Provisionierung. Platform-Admins müssen in Authentik und in
users(authTypauthentik) vorgemerkt sein; dersignIn-Callback verweigert sonst. Der Erst-Admin muss perscripts/seed-auth.tsangelegt werden — Henne-Ei-Problem beim allerersten Deploy dokumentieren.