Files
wannsee-kram/docs/superpowers/specs/2026-05-04-erbstuecke-system-design.md
Marcel Raddatz 92c3d686c5 Add design specs and personas
Feature spec, system design, design system (colors/typography/components),
and per-view HTML specs for Erbstücke Wannsee. Also includes Claude personas
used during design sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 10:45:07 +02:00

7.3 KiB

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

# 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: <random-string>
      ADMIN_MARCEL_PASSWORD_HASH: <bcrypt-hash>
      ADMIN_RENATE_PASSWORD_HASH: <bcrypt-hash>
      ADMIN_BERIT_PASSWORD_HASH: <bcrypt-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.

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