Workstream 1: Projekt-Fundament & Design-System (Phase 0)

Greenfield-Next.js-15-App-Router-Gerüst (TS strict) mit:
- Route-Groups (auth)/(app) inkl. loading/error/not-found je Group;
  Guard-Slot-Kommentar im (app)/layout.tsx (vom Auth-WS zu füllen).
- "Amtlich"/Netzknoten-Designsystem: Tailwind-Tokens (Navy #1B3A5B,
  Signalrot #E2231A, Anthrazit, Nebelgrau, bereit/Wartung), tabular-nums,
  Serif-Display/Inter-Sans via CSS-Variablen, Inline-SVG-Logo.
- Radix-Basiskomponenten (button/input/label/badge/tabs/dialog/select/
  switch/slider); StatusBadge entspricht asset_status.
- Kanonisches src/lib/env.ts (Zod, Fail-Fast) mit ALLEN DB-/Auth-/Geo-Slots
  inkl. AUTH_URL; isHttps-Ableitung. Zentrale i18n-Tabelle de.ts + t().
- Drizzle-Setup: client.ts (Pool-Singleton), leeres schema/index.ts-Barrel
  (KEIN Migrations-Eigentümer), drizzle.config.ts, .env.example.
- next.config.ts: output:standalone, experimental.authInterrupts,
  Security-Header. Vitest + Fail-Fast-Env-Test (TDD, 5/5 grün).

Bewusst KEINE Auth-Logik und KEINE fachlichen Tabellen.

Verifikation: typecheck/lint/test grün; npm run build erzeugt
.next/standalone/server.js; curl /anmelden -> lang="de" + FlorianNetz.
next/font/google durch CSS-Variablen ersetzt (air-gapped-Build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-06-08 16:57:01 +02:00
parent 6ebcd270ad
commit 4707844dbc
45 changed files with 10966 additions and 1 deletions

15
src/app/(app)/error.tsx Normal file
View File

@@ -0,0 +1,15 @@
"use client";
import { Button } from "@/components/ui/button";
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">
{t("aktion.erneutVersuchen")}
</Button>
</div>
);
}

21
src/app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { AppShell } from "@/components/layout/app-shell";
// Gated App-Shell.
//
// GUARD-SLOT (Default-deny, dreifach — Querschnittsstandard 1):
// Der Auth-Workstream (Workstream 3) fügt hier als ALLERERSTE Anweisung den
// serverseitigen Session-Guard ein, z. B.:
//
// import { requireSession } from "@/lib/auth/guards";
// ...
// await requireSession(); // leitet anonyme Aufrufe auf /anmelden um
//
// Lese-Seiten dürfen sich NICHT allein auf die Middleware verlassen.
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
// await requireSession(); // <- vom Auth-Workstream zu aktivieren
return <AppShell>{children}</AppShell>;
}

View File

@@ -0,0 +1,9 @@
import { t } from "@/lib/i18n/de";
export default function AppLoading() {
return (
<div className="py-16 text-center text-sm text-anthrazit/70">
{t("aktion.laden")}
</div>
);
}

View File

@@ -0,0 +1,14 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { t } from "@/lib/i18n/de";
export default function AppNotFound() {
return (
<div className="mx-auto max-w-md py-16 text-center">
<p className="text-anthrazit">{t("fehler.nichtGefunden")}</p>
<Button asChild className="mt-4">
<Link href="/start">{t("aktion.zurueckZurStartseite")}</Link>
</Button>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { StatusBadge } from "@/components/ui/badge";
import { t } from "@/lib/i18n/de";
export default function StartPage() {
return (
<div className="space-y-6">
<header>
<h1 className="font-display text-2xl text-navy">{t("app.name")}</h1>
<p className="mt-1 text-sm text-anthrazit/70">
Vernetzte Übersicht der Fahrzeuge und Geräte der Feuerwehren.
</p>
</header>
<section className="rounded-md border border-rand bg-white p-6">
<h2 className="font-display text-lg text-navy">Statusübersicht</h2>
<div className="mt-4 flex flex-wrap items-center gap-3">
<StatusBadge status="einsatzbereit" />
<StatusBadge status="wartung" />
<StatusBadge status="ausser_dienst" />
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { t } from "@/lib/i18n/de";
// Platzhalter-Anmeldeseite. Der Auth-Workstream (Workstream 3) ersetzt die
// Logik durch Authentik-OIDC bzw. lokalen Login.
export default function AnmeldenPage() {
return (
<div>
<h1 className="font-display text-xl text-navy">{t("auth.anmelden")}</h1>
<p className="mt-1 text-sm text-anthrazit/70">{t("auth.erforderlich")}</p>
<form className="mt-6 space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label>
<Input id="email" name="email" type="email" autoComplete="username" />
</div>
<div className="space-y-1.5">
<Label htmlFor="passwort">Passwort</Label>
<Input
id="passwort"
name="passwort"
type="password"
autoComplete="current-password"
/>
</div>
<Button type="submit" className="w-full" disabled>
{t("auth.anmelden")}
</Button>
</form>
</div>
);
}

15
src/app/(auth)/error.tsx Normal file
View File

@@ -0,0 +1,15 @@
"use client";
import { Button } from "@/components/ui/button";
import { t } from "@/lib/i18n/de";
export default function AuthError({ reset }: { error: Error; reset: () => void }) {
return (
<div className="py-8 text-center">
<p className="text-anthrazit">{t("fehler.allgemein")}</p>
<Button onClick={reset} className="mt-4">
{t("aktion.erneutVersuchen")}
</Button>
</div>
);
}

17
src/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { NetzknotenLogo } from "@/components/brand/netzknoten-logo";
import { t } from "@/lib/i18n/de";
// Schlankes Login-Layout ohne App-Chrome (keine Topbar/Navigation).
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-nebel px-4">
<div className="mb-6 flex items-center gap-2">
<NetzknotenLogo size={32} />
<span className="font-display text-xl text-navy">{t("app.name")}</span>
</div>
<div className="w-full max-w-sm rounded-md border border-rand bg-white p-6 shadow-sm">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { t } from "@/lib/i18n/de";
export default function AuthLoading() {
return (
<div className="py-8 text-center text-sm text-anthrazit/70">{t("aktion.laden")}</div>
);
}

25
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import "@/styles/globals.css";
export const metadata: Metadata = {
title: "FlorianNetz",
description:
"FlorianNetz — vernetzte Übersicht der Fahrzeuge und Geräte der Feuerwehren.",
};
// Schriften werden über CSS-Variablen (--font-sans / --font-display) in
// globals.css gesetzt. Bewusst KEIN next/font/google: das würde zur Build-Zeit
// Google-Fonts laden und in abgeschotteten (air-gapped) Deployments/CI brechen.
// Die Stacks fallen sauber auf Inter→system-ui bzw. Source Serif 4→Georgia
// zurück; selbst gehostete Schriften können später ergänzt werden.
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<body className="font-sans">{children}</body>
</html>
);
}

7
src/app/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
// Root-Redirect. Der Auth-Workstream verfeinert dies (Login vs. /start je
// nach Session). Im Fundament leiten wir auf die Startseite weiter.
export default function RootPage() {
redirect("/start");
}

View File

@@ -0,0 +1,47 @@
import { cn } from "@/lib/utils";
/**
* "Netzknoten"-Logo: vernetzte Wehren-Knoten (Navy) um einen roten Mittelpunkt
* (Florian + Netz). Rein dekorativ, daher aria-hidden — der Markenname steht
* als Text daneben.
*/
export function NetzknotenLogo({
className,
size = 28,
}: {
className?: string;
size?: number;
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 48 48"
fill="none"
role="img"
aria-label="FlorianNetz"
className={cn("shrink-0", className)}
>
{/* Verbindungslinien (Netz) */}
<g stroke="#1B3A5B" strokeWidth="1.5" opacity="0.85">
<line x1="24" y1="24" x2="24" y2="7" />
<line x1="24" y1="24" x2="39" y2="15" />
<line x1="24" y1="24" x2="39" y2="33" />
<line x1="24" y1="24" x2="24" y2="41" />
<line x1="24" y1="24" x2="9" y2="33" />
<line x1="24" y1="24" x2="9" y2="15" />
</g>
{/* Äußere Knoten (Wehren) */}
<g fill="#1B3A5B">
<circle cx="24" cy="7" r="3.2" />
<circle cx="39" cy="15" r="3.2" />
<circle cx="39" cy="33" r="3.2" />
<circle cx="24" cy="41" r="3.2" />
<circle cx="9" cy="33" r="3.2" />
<circle cx="9" cy="15" r="3.2" />
</g>
{/* Roter Mittelpunkt (Florian) */}
<circle cx="24" cy="24" r="5" fill="#E2231A" />
</svg>
);
}

View File

@@ -0,0 +1,10 @@
import { Topbar } from "@/components/layout/topbar";
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<Topbar />
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import Link from "next/link";
import { NetzknotenLogo } from "@/components/brand/netzknoten-logo";
import { de, t } from "@/lib/i18n/de";
const NAV_ITEMS = [
{ href: "/fahrzeuge", label: de.nav.fahrzeuge },
{ href: "/geraete", label: de.nav.geraete },
{ href: "/wehren", label: de.nav.wehren },
] as const;
export function Topbar() {
return (
<header className="border-b border-rand bg-white">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-6 px-4">
<Link href="/start" className="flex items-center gap-2">
<NetzknotenLogo />
<span className="font-display text-lg text-navy">{t("app.name")}</span>
</Link>
<nav className="flex items-center gap-1" aria-label="Hauptnavigation">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded px-3 py-1.5 text-sm font-medium text-anthrazit/80 transition-colors hover:bg-nebel hover:text-anthrazit"
>
{item.label}
</Link>
))}
</nav>
<div className="ml-auto">
{/* Guard-Slot: Benutzermenü/Abmelden wird vom Auth-Workstream gefüllt. */}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,49 @@
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>
);
}
export function Badge({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<span
className={cn(
"inline-flex items-center rounded-sm border border-rand bg-nebel px-2 py-0.5 text-xs font-medium text-anthrazit",
className,
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-navy text-white hover:bg-navy/90",
signal: "bg-signal text-white hover:bg-signal/90",
outline: "border border-rand bg-white text-anthrazit hover:bg-nebel",
ghost: "text-anthrazit hover:bg-nebel",
},
size: {
sm: "h-8 px-3",
md: "h-10 px-4",
lg: "h-11 px-6",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { buttonVariants };

View File

@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
export const DialogPortal = DialogPrimitive.Portal;
export const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-anthrazit/40 backdrop-blur-sm", className)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-md border border-rand bg-white p-6 shadow-lg",
className,
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
export function DialogTitle({
className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("font-display text-lg text-navy", className)}
{...props}
/>
);
}
export function DialogDescription({
className,
...props
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn("mt-1 text-sm text-anthrazit/70", className)}
{...props}
/>
);
}

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
ref={ref}
className={cn(
"flex h-10 w-full rounded border border-rand bg-white px-3 py-2 text-sm text-anthrazit placeholder:text-anthrazit/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
},
);
Input.displayName = "Input";

View File

@@ -0,0 +1,20 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
export const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium text-anthrazit peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;

View File

@@ -0,0 +1,64 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils";
export const Select = SelectPrimitive.Root;
export const SelectGroup = SelectPrimitive.Group;
export const SelectValue = SelectPrimitive.Value;
export const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded border border-rand bg-white px-3 py-2 text-sm text-anthrazit focus:outline-none focus:ring-2 focus:ring-navy disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon aria-hidden></SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
export const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
position={position}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded border border-rand bg-white shadow-md",
className,
)}
{...props}
>
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
export const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm text-anthrazit outline-none data-[highlighted]:bg-nebel data-[state=checked]:font-medium",
className,
)}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;

View File

@@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
export const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-rand">
<SliderPrimitive.Range className="absolute h-full bg-navy" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-navy bg-white shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;

View File

@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
export const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-navy disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-navy data-[state=unchecked]:bg-rand",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
));
Switch.displayName = SwitchPrimitive.Root.displayName;

View File

@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
export const Tabs = TabsPrimitive.Root;
export const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex items-center gap-1 border-b border-rand",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
export const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"border-b-2 border-transparent px-3 py-2 text-sm font-medium text-anthrazit/70 transition-colors hover:text-anthrazit data-[state=active]:border-navy data-[state=active]:text-navy",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
export const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content ref={ref} className={cn("pt-4", className)} {...props} />
));
TabsContent.displayName = TabsPrimitive.Content.displayName;

27
src/db/client.ts Normal file
View File

@@ -0,0 +1,27 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { env } from "@/lib/env";
import * as schema from "@/db/schema";
/**
* Drizzle-pg-Client als Pool-Singleton.
* Außerhalb der Produktion wird der Pool im globalThis gecacht, damit
* Next.js-HMR keine Verbindungen leakt.
*/
declare global {
// Pool-Cache für HMR außerhalb Produktion.
var __floriannetzPool: Pool | undefined;
}
function createPool(): Pool {
return new Pool({ connectionString: env.DATABASE_URL });
}
const pool =
env.NODE_ENV === "production"
? createPool()
: (globalThis.__floriannetzPool ??= createPool());
export const db = drizzle(pool, { schema });
export type DB = typeof db;

5
src/db/schema/index.ts Normal file
View File

@@ -0,0 +1,5 @@
// Barrel für das Datenbankschema.
// Wird vom Datenbank-Workstream (Workstream 2) mit Tabellen, Enums und Indizes
// gefüllt. Dieser Workstream (Fundament) definiert bewusst KEINE fachlichen
// Tabellen und ist NICHT Migrations-Eigentümer.
export {};

View File

@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Fail-Fast-Verhalten der kanonischen Env-Ladung.
* Wir importieren env.ts dynamisch, damit jeder Test mit frischem process.env
* und frischem Modul-Cache läuft.
*/
const VALID_ENV = {
NODE_ENV: "test",
DATABASE_URL: "postgres://user:pass@localhost:5432/floriannetz",
AUTH_SECRET: "x".repeat(32),
AUTH_URL: "http://localhost:3000",
AUTHENTIK_ISSUER: "http://localhost:9000/application/o/floriannetz/",
AUTHENTIK_CLIENT_ID: "client-id",
AUTHENTIK_CLIENT_SECRET: "client-secret",
};
let snapshot: NodeJS.ProcessEnv;
beforeEach(() => {
snapshot = { ...process.env };
vi.resetModules();
});
afterEach(() => {
process.env = snapshot;
});
function setEnv(overrides: Record<string, string | undefined>) {
for (const [k, v] of Object.entries({ ...VALID_ENV, ...overrides })) {
if (v === undefined) {
delete process.env[k];
} else {
process.env[k] = v;
}
}
}
describe("env", () => {
it("lädt gültige Umgebungsvariablen erfolgreich", async () => {
setEnv({});
const mod = await import("../env");
expect(mod.env.DATABASE_URL).toBe(VALID_ENV.DATABASE_URL);
expect(mod.env.GEO_HTTP_TIMEOUT_MS).toBe(4000);
});
it("setzt isHttps=false bei http AUTH_URL", async () => {
setEnv({ AUTH_URL: "http://localhost:3000" });
const mod = await import("../env");
expect(mod.isHttps).toBe(false);
});
it("setzt isHttps=true bei https AUTH_URL", async () => {
setEnv({ AUTH_URL: "https://florian.example.at" });
const mod = await import("../env");
expect(mod.isHttps).toBe(true);
});
it("wirft Fehler (Fail-Fast) bei fehlender DATABASE_URL", async () => {
setEnv({ DATABASE_URL: undefined });
await expect(import("../env")).rejects.toThrow(
/Umgebungsvariablen-Validierung fehlgeschlagen/,
);
});
it("wirft Fehler bei zu kurzem AUTH_SECRET", async () => {
setEnv({ AUTH_SECRET: "kurz" });
await expect(import("../env")).rejects.toThrow(
/Umgebungsvariablen-Validierung fehlgeschlagen/,
);
});
});

27
src/lib/env.ts Normal file
View File

@@ -0,0 +1,27 @@
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://");

55
src/lib/i18n/de.ts Normal file
View File

@@ -0,0 +1,55 @@
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.",
nichtGefunden: "Seite nicht gefunden.",
},
aktion: {
erneutVersuchen: "Erneut versuchen",
laden: "Wird geladen …",
zurueckZurStartseite: "Zur Startseite",
},
} 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;
}

View File

@@ -0,0 +1,12 @@
/**
* Sicherheits-Header, eingehängt in next.config.ts.
* CSP bewusst konservativ; bei Bedarf von Feature-Workstreams erweitert.
*/
export const SECURITY_HEADERS: Record<string, string> = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "geolocation=(self), camera=(), microphone=()",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
"X-DNS-Prefetch-Control": "off",
};

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,36 @@
import { z } from "zod";
/**
* Gemeinsame Zod-Schemas, die an mehreren Grenzen (FormData, JSON, searchParams)
* wiederverwendet werden. Feature-Workstreams bauen darauf auf.
*/
export const uuidSchema = z.string().uuid({ message: "Ungültige ID." });
export const nonEmptyText = z
.string()
.trim()
.min(1, { message: "Pflichtfeld." });
export const optionalText = z
.string()
.trim()
.optional()
.transform((v) => (v === "" ? undefined : v));
export const latitudeSchema = z
.number({ invalid_type_error: "Ungültiger Breitengrad." })
.min(-90)
.max(90);
export const longitudeSchema = z
.number({ invalid_type_error: "Ungültiger Längengrad." })
.min(-180)
.max(180);
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(25),
});
export type Pagination = z.infer<typeof paginationSchema>;

39
src/styles/globals.css Normal file
View File

@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--font-sans: "Inter", system-ui, sans-serif;
--font-display: "Source Serif 4", Georgia, serif;
--color-navy: #1b3a5b;
--color-signal: #e2231a;
--color-anthrazit: #1a2530;
--color-nebel: #f6f8fa;
--color-bereit: #1f8f5a;
--color-wartung: #b5460f;
--color-rand: #d9dee5;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
@apply bg-nebel text-anthrazit antialiased;
font-feature-settings: "tnum" 1;
}
h1,
h2,
h3 {
@apply font-display text-navy;
}
}
@layer utilities {
.tabular-nums {
font-variant-numeric: tabular-nums;
}
}