# Erbstücke-Reservierung — System Design **Datum:** 2026-05-04 **Status:** Freigegeben zur Implementierung **Referenz:** 2026-05-04-erbstuecke-reservierung-design.md --- ## 1. Stack | Schicht | Technologie | Begründung | |---|---|---| | Framework | SvelteKit (Node adapter) | SSR + server actions in einem Prozess, kein separates Backend nötig | | Datenbank | SQLite via `better-sqlite3` | Zero-Ops, kein DB-Container, WAL-Modus für atomare Reservierungen | | Foto-Verarbeitung | `sharp` | Resize + WebP-Konvertierung serverseitig bei Upload | | Passwort-Hashing | `bcryptjs` | Admin-Authentifizierung | | Deployment | Docker (single container) + Caddy (Host) | Root-Server, Subdomain, TLS via Caddy | --- ## 2. Deployment ```yaml # docker-compose.yml services: app: build: . volumes: - db:/app/db - uploads:/app/uploads environment: DATABASE_PATH: /app/db/erbstuecke.db UPLOAD_DIR: /app/uploads SESSION_SECRET: ADMIN_MARCEL_PASSWORD_HASH: ADMIN_RENATE_PASSWORD_HASH: ADMIN_BERIT_PASSWORD_HASH: restart: unless-stopped volumes: db: uploads: ``` Caddy auf dem Host übernimmt TLS-Terminierung und Reverse-Proxy zum Container. Die drei Admin-Konten (Marcel, Renate, Berit) sind fest in Umgebungsvariablen — kein Admin-UI zur Benutzerverwaltung. --- ## 3. Datenbankschema Schema wird beim App-Start angelegt, falls die DB-Datei noch nicht existiert. Kein Migrations-Framework. ```sql PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS codes ( id INTEGER PRIMARY KEY, code TEXT NOT NULL UNIQUE, -- 8-stellig, z. B. "AB3K7MN2" display_name TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS artikel ( id INTEGER PRIMARY KEY, titel TEXT, -- optional kategorie TEXT NOT NULL, notiz TEXT, -- optional created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS artikel_fotos ( id INTEGER PRIMARY KEY, artikel_id INTEGER NOT NULL REFERENCES artikel(id) ON DELETE CASCADE, position INTEGER NOT NULL, -- 0 = Galerie-Thumbnail filename TEXT NOT NULL, -- relativer Pfad im Uploads-Volume UNIQUE(artikel_id, position) ); -- UNIQUE auf artikel_id = atomare First-come-first-served-Garantie CREATE TABLE IF NOT EXISTS reservierungen ( id INTEGER PRIMARY KEY, artikel_id INTEGER NOT NULL UNIQUE REFERENCES artikel(id), code_id INTEGER NOT NULL REFERENCES codes(id) ON DELETE CASCADE, created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); ``` Die `UNIQUE`-Constraint auf `reservierungen.artikel_id` ist die vollständige Nebenläufigkeitsstrategie: zwei gleichzeitige INSERTs, einer gewinnt, einer bekommt einen Constraint-Fehler — kein Application-Level-Locking erforderlich. --- ## 4. Authentifizierung & Sessions ### Familienmitglieder - Code wird einmalig gegen die `codes`-Tabelle geprüft - Bei Erfolg: HTTP-only Same-Site-Cookie `family_code` gesetzt - `hooks.server.ts` liest Cookie bei jedem Request → `locals.familyCode` + `locals.displayName` - URL-Parameter `?code=AB3K7MN2` wird auf dem Gate-Screen automatisch validiert und der Cookie gesetzt - Kein Inhalt ohne gültigen Cookie sichtbar ### Admins - Login-Formular: Benutzername (`Marcel` | `Renate` | `Berit`) + Passwort - Server prüft Benutzername → lädt zugehörigen Bcrypt-Hash aus Env → `bcrypt.compare` - Bei Erfolg: HTTP-only Cookie `admin_session` mit Admin-Name - `hooks.server.ts` → `locals.admin` - Unbekannter Benutzername gibt dieselbe Fehlermeldung wie falsches Passwort (kein User-Enumeration) ### Trennung - Gültiger `family_code`-Cookie gewährt null Zugang zu `/admin/*` - Gültiger `admin_session`-Cookie gewährt null Zugang zur Familiengalerie - `+layout.server.ts` unter `/admin/` prüft `locals.admin` und leitet bei Fehlen zu `/admin/login` um --- ## 5. Foto-Handling ### Upload 1. Admin sendet Multipart-Formular 2. SvelteKit Server-Action empfängt Dateien via `request.formData()` 3. `sharp` verkleinert auf max. 1200 px lange Seite, konvertiert zu WebP (~800 KB Ziel) 4. Datei wird geschrieben nach `/app/uploads/{artikel_id}/{uuid}.webp` 5. `artikel_fotos`-Zeile mit relativem Pfad und Position wird eingefügt ### Auslieferung Route `GET /uploads/[...path]/+server.ts` streamt die Datei vom Disk. URLs sind UUID-basiert und nicht erratbar — keine Auth-Prüfung erforderlich. ### Reihenfolge ändern Admin verschiebt Thumbnails → Client sendet neues `position`-Array → Server aktualisiert alle `artikel_fotos`-Zeilen in einer Transaktion. ### Löschen - Einzelnes Foto: `artikel_fotos`-Zeile löschen + Datei von Disk entfernen - Artikel löschen: CASCADE löscht `artikel_fotos`-Zeilen, Server entfernt Dateien im `uploads/{artikel_id}/`-Verzeichnis --- ## 6. Routenstruktur ``` src/ hooks.server.ts # Cookie → locals.familyCode / locals.admin routes/ +page.svelte # Gate Screen +page.server.ts # ?code= validieren, Cookie setzen uploads/[...path]/ +server.ts # Foto von Disk streamen galerie/ +page.svelte # Responsives Grid + Kategorie-Filter +page.server.ts # Artikel + Reservierungsstatus laden artikel/[id]/ +page.server.ts # Reservieren / Aufheben (Form Actions) admin/ +layout.server.ts # Auth-Guard +layout.svelte # Sidebar-Navigation login/ +page.svelte +page.server.ts # Bcrypt-Verify, Session-Cookie setzen inventar/ +page.svelte +page.server.ts # CRUD Artikel + Foto-Upload codes/ +page.svelte +page.server.ts # Codes verwalten, Link kopieren reservierungen/ +page.svelte +page.server.ts # Übersicht nach Person gruppiert uebersicht/ +page.svelte +page.server.ts # Dashboard-Kennzahlen lib/ db.ts # better-sqlite3-Singleton, WAL on init photos.ts # sharp resize + write/delete auth.ts # bcrypt verify, Cookie-Helpers ``` Alle Daten werden in `+page.server.ts`-Dateien geladen — kein separater API-Layer, keine Client-seitigen Fetch-Calls für Datenzugriff. Mutationen laufen über SvelteKit Form Actions (progressive enhancement, funktioniert ohne JS). --- ## 7. Nicht-funktionale Entscheidungen | Anforderung | Umsetzung | |---|---| | NFR-SEC-001 Codes kryptographisch zufällig | `crypto.randomBytes` → zufällige Auswahl aus A-Z + 0-9, 8 Zeichen | | NFR-SEC-002 Passwörter gehasht | bcrypt, work factor 12 | | NFR-SEC-003 Nur eigene Reservierungen aufheben | Server prüft `reservierungen.code_id = locals.familyCode.id` | | NFR-SEC-004 Admin-Trennung | Separate Cookie-Namen, separate Auth-Guards in hooks | | NFR-PERF-001 500 Artikel flüssig scrollen | SSR-gerenderte HTML-Liste, kein Client-Side-Fetch beim Laden | | NFR-PERF-002 Fotos max. ~800 KB | sharp WebP-Konvertierung bei Upload | | NFR-DATA-002 Atomare Reservierungen | UNIQUE-Constraint auf `reservierungen.artikel_id` |