From cc72f5efd7422755867ad55b2357ae3b968a7512 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 22 Aug 2026 01:24:30 +0200 Subject: [PATCH] chore: Projektgeruest mit Next.js, TypeScript und Tailwind Grundgeruest der Anwendung: Next.js 15 (App Router), TypeScript im strict-Modus, Tailwind mit shadcn/ui-Farbtokens. Bewusste Festlegungen: - Nur Systemschriften, keine externen Fonts, kein CDN. - Grundschrift eine Stufe groesser, Fokusring immer sichtbar, Touchziele mindestens 44px. - Ampelfarben fuer Fristen als eigene Design-Tokens. - Betraege werden als Cent-Ganzzahl gefuehrt, damit die Rechnungslegung keine Rundungsfehler erbt. - .env.example dokumentiert alle Geheimnisse; npm run schluessel erzeugt sie. Etappe: 1 Phase: Fundament --- .dockerignore | 14 ++++ .env.example | 63 +++++++++++++++ .eslintrc.json | 3 + .gitignore | 41 ++++++++++ README.md | 11 +++ components.json | 17 ++++ next.config.mjs | 36 +++++++++ package.json | 66 +++++++++++++++ postcss.config.mjs | 6 ++ scripts/schluessel-erzeugen.mjs | 26 ++++++ src/app/globals.css | 139 ++++++++++++++++++++++++++++++++ src/lib/utils.ts | 81 +++++++++++++++++++ tailwind.config.ts | 97 ++++++++++++++++++++++ tsconfig.json | 24 ++++++ vitest.config.ts | 14 ++++ 15 files changed, 638 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 components.json create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 scripts/schluessel-erzeugen.mjs create mode 100644 src/app/globals.css create mode 100644 src/lib/utils.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..869526e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +.next +.git +.env +.env.local +backups +uploads +postgres-data +caddy-data +caddy-config +coverage +test-results +playwright-report +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..723870d --- /dev/null +++ b/.env.example @@ -0,0 +1,63 @@ +# ============================================================================= +# Betreuungs-Cockpit - Beispielkonfiguration +# Kopiere diese Datei nach .env und fuelle die Werte aus. +# Alle Geheimnisse erzeugst du mit: npm run schluessel +# ============================================================================= + +# --- Datenbank -------------------------------------------------------------- +# Lokal (Docker Compose): Host ist der Servicename "db". +POSTGRES_USER=cockpit +POSTGRES_PASSWORD=bitte-aendern +POSTGRES_DB=betreuungs_cockpit +DATABASE_URL="postgresql://cockpit:bitte-aendern@localhost:5432/betreuungs_cockpit?schema=public" + +# --- Auth.js ---------------------------------------------------------------- +# 32 zufaellige Bytes, base64. Erzeugen: npm run schluessel +AUTH_SECRET= +# Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash). +AUTH_URL=http://localhost:3000 +# Hinter einem Reverse Proxy (Caddy) auf true setzen. +AUTH_TRUST_HOST=true + +# --- Anwendungsseitige Feldverschluesselung --------------------------------- +# AES-256-GCM. 32 Bytes, base64. OHNE DIESEN SCHLUESSEL SIND DIE VERSCHLUESSELTEN +# FELDER UNWIEDERBRINGLICH VERLOREN - unbedingt getrennt vom Backup sichern! +# Schluesselrotation: neuen Schluessel als ENC_KEY_V2 ergaenzen und +# ENC_KEY_AKTIV=2 setzen. Alte Schluessel bleiben zum Entschluesseln stehen. +ENC_KEY_V1= +ENC_KEY_AKTIV=1 + +# Separater Schluessel fuer Blind-Indizes (HMAC-SHA256). +# Erlaubt die Suche nach z.B. einer IBAN, ohne sie im Klartext zu speichern. +INDEX_KEY= + +# --- Sicherheit ------------------------------------------------------------- +# Leerlauf bis zur automatischen Abmeldung (Sekunden). 30 Minuten. +SESSION_MAX_AGE=1800 +# Wie lange eine erneute Anmeldung fuer sensible Aktionen gilt (Sekunden). +REAUTH_MAX_AGE=300 +# Login-Sperre: max. Fehlversuche pro Zeitfenster (Sekunden). +LOGIN_MAX_VERSUCHE=5 +LOGIN_FENSTER_SEKUNDEN=900 +LOGIN_SPERRE_SEKUNDEN=900 + +# --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------ +# Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein. +# ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen. +S3_ENDPOINT=https://fsn1.your-objectstorage.com +S3_REGION=eu-central +S3_BUCKET=betreuungs-cockpit +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= + +# --- E-Mail-Erinnerungen (optional, ab Etappe 2) ---------------------------- +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM="Betreuungs-Cockpit " + +# --- Erstanmeldung ---------------------------------------------------------- +# Wird nur vom Seed benutzt, um den ersten Betreuer-Zugang anzulegen. +SEED_ADMIN_EMAIL=fw@wildeflorian.de +SEED_ADMIN_PASSWORT=bitte-beim-ersten-login-aendern diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f6ac50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Abhaengigkeiten +node_modules/ +.pnp +.pnp.js + +# Build +.next/ +out/ +build/ +dist/ +next-env.d.ts +*.tsbuildinfo + +# Umgebung - enthaelt Schluessel, gehoert NIE ins Repo +.env +.env.local +.env.*.local +!.env.example + +# Daten und Backups +backups/ +uploads/ +postgres-data/ +caddy-data/ +caddy-config/ +*.dump +*.sql.gz + +# Test-Artefakte +coverage/ +playwright-report/ +test-results/ +.playwright/ + +# Werkzeuge +.vscode/ +.idea/ +.DS_Store +Thumbs.db +*.log +npm-debug.log* diff --git a/README.md b/README.md new file mode 100644 index 0000000..e32d9ad --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Betreuungs-Cockpit + +Arbeits- und Dokumentationswerkzeug für die ehrenamtliche rechtliche Betreuung +eines Angehörigen — Fristen, Vorgänge, Belege, Vermögen, Sozialhilfe. + +> Die Anwendung erteilt **keine Rechtsberatung**. Alle Hinweise mit +> Paragraphen-Bezug ersetzen nicht die Rückfrage bei Betreuungsgericht, +> Sozialhilfeträger oder Anwalt. + +Die vollständige Installations-, Betriebs- und Backup-Anleitung folgt in +diesem Dokument, sobald Etappe 1 steht. diff --git a/components.json b/components.json new file mode 100644 index 0000000..21584ab --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui" + } +} diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..bf43217 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,36 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Standalone-Build fuer ein schlankes Docker-Image. + output: 'standalone', + reactStrictMode: true, + poweredByHeader: false, + // Argon2 ist eine native Bibliothek und darf nicht gebundelt werden. + serverExternalPackages: ['@node-rs/argon2'], + eslint: { + ignoreDuringBuilds: false, + }, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'no-referrer' }, + { key: 'X-DNS-Prefetch-Control', value: 'off' }, + { + key: 'Permissions-Policy', + // Kamera bleibt erlaubt: Belege werden am Handy fotografiert. + value: 'camera=(self), microphone=(self), geolocation=(), interest-cohort=()', + }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + ], + }, + ] + }, +} + +export default nextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..c971e5e --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "betreuungs-cockpit", + "version": "0.1.0", + "private": true, + "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", + "engines": { + "node": ">=20.11" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:deploy": "prisma migrate deploy", + "db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql", + "db:seed": "prisma db seed", + "db:studio": "prisma studio", + "setup": "prisma migrate deploy && npm run db:volltext && npm run db:seed", + "schluessel": "node scripts/schluessel-erzeugen.mjs", + "postinstall": "prisma generate" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, + "dependencies": { + "@node-rs/argon2": "2.0.2", + "@prisma/client": "6.3.1", + "@radix-ui/react-dialog": "1.1.4", + "@radix-ui/react-dropdown-menu": "2.1.4", + "@radix-ui/react-label": "2.1.1", + "@radix-ui/react-slot": "1.1.1", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "date-fns": "4.1.0", + "lucide-react": "0.474.0", + "next": "15.1.6", + "next-auth": "5.0.0-beta.25", + "otpauth": "9.3.6", + "qrcode": "1.5.4", + "react": "19.0.0", + "react-dom": "19.0.0", + "tailwind-merge": "2.6.0", + "tailwindcss-animate": "1.0.7", + "zod": "3.24.1" + }, + "devDependencies": { + "@types/node": "22.13.1", + "@types/qrcode": "1.5.5", + "@types/react": "19.0.8", + "@types/react-dom": "19.0.3", + "autoprefixer": "10.4.20", + "eslint": "9.19.0", + "eslint-config-next": "15.1.6", + "postcss": "8.5.1", + "prisma": "6.3.1", + "tailwindcss": "3.4.17", + "tsx": "4.19.2", + "typescript": "5.7.3", + "vitest": "3.0.5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/scripts/schluessel-erzeugen.mjs b/scripts/schluessel-erzeugen.mjs new file mode 100644 index 0000000..043168b --- /dev/null +++ b/scripts/schluessel-erzeugen.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +/** + * Erzeugt die Geheimnisse fuer die .env-Datei. + * + * Aufruf: npm run schluessel + * + * Die Werte werden bewusst nur ausgegeben und nirgends gespeichert. + * Kopiere sie in die .env und sichere ENC_KEY_V1 zusaetzlich getrennt + * vom Datenbank-Backup (z.B. Passwortmanager, Papier im Ordner). + */ +import { randomBytes } from 'node:crypto' + +const b64 = (bytes) => randomBytes(bytes).toString('base64') + +console.log(` +# --- Erzeugt am ${new Date().toISOString()} --- +AUTH_SECRET=${b64(32)} +ENC_KEY_V1=${b64(32)} +ENC_KEY_AKTIV=1 +INDEX_KEY=${b64(32)} +POSTGRES_PASSWORD=${b64(24).replace(/[+/=]/g, 'x')} + +# WICHTIG: ENC_KEY_V1 geht NICHT verloren gehen duerfen. +# Ohne ihn sind Diagnosen, IBANs, Versicherungsnummern und Notizen +# aus dem Backup nicht mehr lesbar. +`) diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..1321830 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,139 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* Helles Thema - bewusst hoher Kontrast, kein Grau-auf-Grau. */ + --background: 0 0% 100%; + --foreground: 222 47% 11%; + + --card: 0 0% 100%; + --card-foreground: 222 47% 11%; + + --popover: 0 0% 100%; + --popover-foreground: 222 47% 11%; + + --primary: 214 80% 32%; + --primary-foreground: 0 0% 100%; + + --secondary: 214 32% 94%; + --secondary-foreground: 222 47% 11%; + + --muted: 214 32% 95%; + --muted-foreground: 215 20% 35%; + + --accent: 214 32% 94%; + --accent-foreground: 222 47% 11%; + + --destructive: 0 72% 40%; + --destructive-foreground: 0 0% 100%; + + --border: 214 20% 84%; + --input: 214 20% 80%; + --ring: 214 80% 40%; + + --ampel-rot: 0 72% 42%; + --ampel-orange: 25 90% 38%; + --ampel-gelb: 43 90% 30%; + --ampel-gruen: 145 63% 27%; + + --radius: 0.5rem; + } + + .dark { + --background: 222 47% 9%; + --foreground: 210 40% 96%; + + --card: 222 45% 12%; + --card-foreground: 210 40% 96%; + + --popover: 222 45% 12%; + --popover-foreground: 210 40% 96%; + + --primary: 210 90% 66%; + --primary-foreground: 222 47% 11%; + + --secondary: 217 33% 18%; + --secondary-foreground: 210 40% 96%; + + --muted: 217 33% 17%; + --muted-foreground: 215 25% 72%; + + --accent: 217 33% 20%; + --accent-foreground: 210 40% 96%; + + --destructive: 0 72% 62%; + --destructive-foreground: 222 47% 11%; + + --border: 217 25% 26%; + --input: 217 25% 30%; + --ring: 210 90% 66%; + + --ampel-rot: 0 84% 68%; + --ampel-orange: 25 95% 62%; + --ampel-gelb: 45 95% 60%; + --ampel-gruen: 145 60% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + + html { + /* Verhindert das automatische Hochskalieren von Text in iOS-Safari. */ + -webkit-text-size-adjust: 100%; + } + + body { + @apply bg-background text-foreground antialiased; + } + + /* Tastaturbedienung muss sichtbar sein - niemals outline:none. */ + :focus-visible { + @apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background; + } + + /* Auf Touch-Geraeten sollen Bedienelemente mindestens 44px hoch sein. */ + @media (pointer: coarse) { + button, + [role='button'], + a.btn, + input, + select, + textarea { + min-height: 2.75rem; + } + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } + } +} + +@layer components { + /* Sprungmarke fuer Screenreader und Tastaturnutzer. */ + .skip-link { + @apply absolute left-2 top-2 z-50 -translate-y-20 rounded-md bg-primary px-4 py-2 + text-primary-foreground transition-transform focus:translate-y-0; + } +} + +@media print { + /* Berichte werden ausgedruckt und dem Gericht vorgelegt. */ + .no-print { + display: none !important; + } + + body { + background: #fff; + color: #000; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..a8fc5d1 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,81 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +/** Tailwind-Klassen zusammenfuehren, spaetere gewinnen. */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +const DATUM_FORMAT = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', +}) + +const DATUM_ZEIT_FORMAT = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}) + +const EURO_FORMAT = new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', +}) + +/** 22.08.2026 - oder ein Gedankenstrich, wenn nichts da ist. */ +export function formatDatum(datum: Date | string | null | undefined): string { + if (!datum) return '–' + const d = typeof datum === 'string' ? new Date(datum) : datum + if (Number.isNaN(d.getTime())) return '–' + return DATUM_FORMAT.format(d) +} + +/** 22.08.2026, 14:30 */ +export function formatDatumZeit(datum: Date | string | null | undefined): string { + if (!datum) return '–' + const d = typeof datum === 'string' ? new Date(datum) : datum + if (Number.isNaN(d.getTime())) return '–' + return DATUM_ZEIT_FORMAT.format(d) +} + +/** + * Betraege liegen in der Datenbank als Cent-Ganzzahl (BigInt), damit + * keine Rundungsfehler in die Rechnungslegung wandern. + */ +export function formatCent(cent: bigint | number | null | undefined): string { + if (cent === null || cent === undefined) return '–' + const zahl = typeof cent === 'bigint' ? Number(cent) : cent + return EURO_FORMAT.format(zahl / 100) +} + +/** Tage zwischen heute (Mitternacht) und einem Datum. Negativ = ueberfaellig. */ +export function tageBis(datum: Date | string): number { + const ziel = typeof datum === 'string' ? new Date(datum) : datum + const heute = new Date() + heute.setHours(0, 0, 0, 0) + const zielTag = new Date(ziel) + zielTag.setHours(0, 0, 0, 0) + return Math.round((zielTag.getTime() - heute.getTime()) / 86_400_000) +} + +/** "vor 3 Tagen", "in 2 Wochen" - fuer die Dashboard-Kacheln. */ +export function relativeTage(tage: number): string { + if (tage === 0) return 'heute' + if (tage === 1) return 'morgen' + if (tage === -1) return 'gestern' + if (tage < 0) return `vor ${Math.abs(tage)} Tagen` + return `in ${tage} Tagen` +} + +/** Aus "Erika Mustermann" wird "EM" - fuer Avatare in der Navigation. */ +export function initialen(name: string): string { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((teil) => teil[0]?.toUpperCase() ?? '') + .join('') +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..78a9a20 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,97 @@ +import type { Config } from 'tailwindcss' +import animate from 'tailwindcss-animate' + +const config: Config = { + darkMode: ['class'], + content: ['./src/**/*.{ts,tsx}'], + theme: { + container: { + center: true, + padding: '1rem', + screens: { '2xl': '1400px' }, + }, + extend: { + fontFamily: { + // Bewusst nur Systemschriften - keine externen Fonts, kein CDN. + sans: [ + 'system-ui', + '-apple-system', + 'Segoe UI', + 'Roboto', + 'Helvetica Neue', + 'Arial', + 'sans-serif', + ], + }, + fontSize: { + // Grundschrift eine Stufe groesser als der Tailwind-Standard. + base: ['1.0625rem', { lineHeight: '1.6' }], + lg: ['1.1875rem', { lineHeight: '1.6' }], + }, + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + // Ampelfarben fuer Fristen - als Token, damit sie ueberall gleich sind. + ampel: { + rot: 'hsl(var(--ampel-rot))', + orange: 'hsl(var(--ampel-orange))', + gelb: 'hsl(var(--ampel-gelb))', + gruen: 'hsl(var(--ampel-gruen))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + keyframes: { + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: '0' }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + plugins: [animate], +} + +export default config diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fce6483 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b5a520e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +})