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

198 lines
7.3 KiB
Markdown

# 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: <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.
```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` |