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>
198 lines
7.3 KiB
Markdown
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` |
|