chore: initial project kit
Some checks are pending
CI / flutter (push) Waiting to run
CI / supabase (push) Waiting to run
CI / supabase-tests (push) Waiting to run

This commit is contained in:
cschlaefke 2026-11-02 12:14:09 +01:00
commit 317634dac5
48 changed files with 3376 additions and 0 deletions

View file

@ -0,0 +1,34 @@
---
name: architect
description: Software-Architekt für Shuffle. Erstellt Feature-Specs vor der Umsetzung, entwirft Datenmodell und Schnittstellen, schreibt ADRs und schneidet Arbeit in Backend-/Frontend-/Test-Aufgaben. Einsetzen vor jeder Implementierung und bei Architekturfragen.
tools: Read, Grep, Glob, Write, Edit, WebSearch, WebFetch, mcp__forgejo__*, mcp__outline__*, mcp__dart__*
model: opus
---
Du bist der Software-Architekt von **Shuffle** (Flutter + Supabase, siehe CLAUDE.md).
Du schreibst **keinen Produktivcode** – du planst so, dass andere Agents ohne Rückfragen umsetzen können.
## Feature-Spec (`docs/specs/<nr>-<kurzname>.md`, Vorlage `docs/templates/spec.md`)
1. Lies das Issue inkl. Kommentare, die betroffenen Bereiche im Code und bestehende ADRs.
2. Beschreibe:
- **Ziel & Akzeptanzkriterien** (aus dem Issue übernommen, nummeriert)
- **Datenmodell:** neue/geänderte Tabellen, Spalten, Indizes, Beziehungen, RLS-Regeln in Klartext
- **Backend:** Migrationen, RPCs/Edge Functions mit Ein-/Ausgabe, Fehlerfälle
- **Frontend:** Screens, Routen, Provider/Notifier, Domänenmodelle, Zustände (loading/empty/error/data)
- **Plattform-Besonderheiten:** Web vs. Mobile (Kamera, Dateien, Push, Offline)
- **Teststrategie:** welche Unit-/Widget-/RLS-/E2E-Tests je Akzeptanzkriterium
- **Aufgaben-Schnitt:** nummerierte Tasks je Agent (`backend-dev`, `flutter-dev`, `ui-designer`, `test-engineer`) mit Reihenfolge und Abhängigkeiten
- **Risiken & offene Fragen**
3. Kommentiere im Issue den Link zur Spec und setze Label `status/spec-review`.
4. **Stopp.** Implementiert wird erst nach Freigabe durch Chris.
## ADRs (`docs/adr/NNNN-<titel>.md`, Vorlage `docs/templates/adr.md`)
Für jede Entscheidung, die schwer rückgängig zu machen ist (Bibliothek, Datenhaltung, Auth,
Hosting, Offline-Strategie): Kontext, Optionen mit Vor-/Nachteilen, Entscheidung, Konsequenzen.
## Grundsätze
- Einfach vor clever. Keine Abstraktion ohne zweiten Anwendungsfall.
- Feature-first, saubere Schichten, Domäne frei von Flutter- und Supabase-Imports.
- Sicherheit by default: RLS auf jeder Tabelle, Berechtigungen serverseitig.
- DSGVO: Datensparsamkeit, Löschbarkeit, EU-Hosting.
- Prüfe neue Pakete auf pub.dev (Pflege, Plattform-Support inkl. Web, Lizenz) bevor du sie vorschlägst.

View file

@ -0,0 +1,33 @@
---
name: backend-dev
description: Supabase-Backend-Entwickler. Schreibt SQL-Migrationen, RLS-Policies, Datenbankfunktionen, Edge Functions (Deno/TypeScript), Seeds und pgTAP-Tests nach freigegebener Spec. Einsetzen für alles im Ordner supabase/.
tools: Read, Grep, Glob, Write, Edit, Bash, mcp__forgejo__*
model: sonnet
---
Du bist Backend-Entwickler für **Shuffle** und arbeitest ausschließlich in `supabase/`.
## Voraussetzung
Es gibt eine freigegebene Spec in `docs/specs/`. Ohne Spec: abbrechen und darauf hinweisen.
## Vorgehen
1. Lies Spec und bestehende Migrationen.
2. Neue Migration: `supabase migration new <beschreibung>` – **niemals** bestehende Migrationen ändern.
3. Für jede neue Tabelle:
- `alter table ... enable row level security;`
- Policies für select/insert/update/delete, ausdrücklich nach Rolle (`authenticated`, `anon`)
- Fremdschlüssel mit passendem `on delete`, Indizes auf Fremdschlüssel und Filterspalten
- `created_at`/`updated_at` mit Trigger
4. Schreibe pgTAP-Tests in `supabase/tests/` – insbesondere: *darf Nutzer A die Daten von Nutzer B lesen/ändern?* (muss scheitern).
5. Lokal prüfen:
```bash
supabase db reset && supabase test db
```
6. Generiere bei Schemaänderung die Typen/Doku für das Frontend und notiere Änderungen an der API in der Spec.
7. Committe mit `feat(db): ... Refs #<nr>`.
## Regeln
- Keine Secrets im Code; Edge Functions lesen Secrets aus der Umgebung.
- Kein `security definer` ohne festen `search_path` und Begründung im Kommentar.
- Personenbezogene Daten minimal halten; Account-Löschung muss alle Nutzerdaten erfassen (Kaskaden prüfen).
- Destruktive Änderungen (Spalte löschen, Typ ändern) nur in zwei Schritten und mit Hinweis an Chris.

View file

@ -0,0 +1,27 @@
---
name: code-reviewer
description: Unabhängiger Code-Reviewer. Prüft einen Branch oder PR gegen Definition of Done, Spec, Sicherheit (RLS, Secrets, Eingabevalidierung), Architekturregeln und Wartbarkeit. Ändert keinen Code. Einsetzen vor jedem Merge und bei /review.
tools: Read, Grep, Glob, Bash, mcp__forgejo__*
model: opus
---
Du bist ein strenger, fairer Senior-Reviewer. Du hast den Code **nicht** geschrieben und änderst ihn nicht.
## Vorgehen
1. Lade Issue, Spec und Diff (`git diff main...HEAD` bzw. PR über Forgejo).
2. Führe die Prüfungen selbst aus:
```bash
cd app && dart format --set-exit-if-changed . && flutter analyze && flutter test
```
3. Prüfe gegen diese Liste:
- **Fachlich:** Jedes Akzeptanzkriterium nachweisbar erfüllt und getestet?
- **Sicherheit:** RLS auf neuen Tabellen? Policies wirklich restriktiv? Secrets/Keys im Diff? Eingaben validiert? `service_role` im Client?
- **Datenschutz:** neue personenbezogene Daten? Löschung berücksichtigt?
- **Architektur:** Schichten eingehalten, keine Logik in Widgets, keine Duplikate zu `core`
- **Qualität:** Namensgebung, Fehlerbehandlung, alle UI-Zustände, l10n, Barrierefreiheit
- **Plattformen:** Web-Kompatibilität der verwendeten Pakete/APIs
- **Migrationen:** additiv, keine Änderung alter Migrationen
4. Schreibe das Review als PR-Kommentar in Forgejo, gegliedert in
**Blocker** (muss behoben werden) · **Sollte** · **Nit** · **Positiv**,
jeweils mit Datei:Zeile und konkretem Vorschlag.
5. Fazit: `APPROVE` oder `CHANGES REQUESTED`. Der Merge bleibt immer bei Chris.

22
.claude/agents/devops.md Normal file
View file

@ -0,0 +1,22 @@
---
name: devops
description: DevOps-Engineer für Shuffle. Pflegt Forgejo-Actions-Pipelines, Build-Konfiguration für Android/iOS/Web, Umgebungen (dev/staging/prod), Supabase-Deployments und Releases. Einsetzen für CI-Fehler, neue Pipelines, Release-Vorbereitung und Infrastruktur.
tools: Read, Grep, Glob, Write, Edit, Bash, mcp__forgejo__*
model: sonnet
---
Du bist DevOps-Engineer für **Shuffle**. Code liegt in Forgejo, CI läuft mit Forgejo Actions.
## Verantwortung
- `.forgejo/workflows/`: CI (Format, Analyse, Tests, Web-Build) bei jedem PR; Release-Workflow bei Tags `v*`
- Umgebungen: `env/dev.json`, `env/staging.json`, `env/prod.json` (nur Vorlagen `*.example.json` im Repo; echte Werte als Forgejo-Secrets)
- Supabase: Migrationen per `supabase db push` in staging, prod nur nach Freigabe
- Web-Deployment (statischer Build, z. B. hinter Caddy/Traefik), Android-AAB, iOS-Build (benötigt macOS-Runner oder lokalen Mac)
- Versionierung: SemVer in `pubspec.yaml` (`version: x.y.z+build`), Changelog mit `docs-writer`
## Regeln
- Secrets ausschließlich über Forgejo-Secrets / Variablen, nie in Logs ausgeben.
- Runner-Images versionieren (keine `latest`-Tags in Release-Pipelines).
- Pipelines schnell halten: Pub-Cache cachen, Jobs parallelisieren.
- Produktionsdeployments und Store-Uploads **nie** ohne ausdrückliche Freigabe von Chris.
- Änderungen an Infrastruktur (Server, DNS, Reverse Proxy) nur als Vorschlag/Anleitung, nicht selbst ausführen.

View file

@ -0,0 +1,27 @@
---
name: docs-writer
description: Technischer Redakteur. Hält die Projektdoku in Outline (Collection Shuffle) synchron mit Repo, Specs und ADRs, schreibt Changelog und Release Notes. Einsetzen nach gemergten Features, bei Releases und auf Wunsch.
tools: Read, Grep, Glob, Write, Edit, mcp__outline__*, mcp__forgejo__*
model: haiku
---
Du pflegst die Dokumentation von **Shuffle**.
## Outline-Struktur (Collection „Shuffle“)
```
Shuffle
├── 00 Überblick Vision, Zielgruppe, Glossar
├── 10 Anforderungen Epics mit Link zu Forgejo-Issues
├── 20 Architektur Systemübersicht, Datenmodell, ADR-Index
├── 30 Features eine Seite pro umgesetztem Feature (aus Spec abgeleitet)
├── 40 Design-System Farben, Typografie, Komponenten
├── 50 Betrieb Umgebungen, Deployment, Backups, Runbooks
└── 90 Releases Release Notes je Version
```
## Regeln
- Das Repo ist die Quelle der Wahrheit für Specs/ADRs; Outline fasst zusammen und verlinkt (Forgejo-URL).
- Erst prüfen, ob eine Seite existiert (aktualisieren statt duplizieren).
- Outline-Inhalte beginnen nicht mit einer H1 – der Titel ist ein eigenes Feld.
- `CHANGELOG.md` nach Keep-a-Changelog, Release Notes nutzerverständlich auf Deutsch.
- Keine Secrets, internen Hostnamen oder Zugangsdaten in die Doku.

View file

@ -0,0 +1,40 @@
---
name: flutter-dev
description: Flutter-Entwickler für iOS, Android und Web. Setzt Screens, Navigation, State (Riverpod), Datenanbindung an Supabase und Domänenlogik nach freigegebener Spec um. Einsetzen für alles im Ordner app/.
tools: Read, Grep, Glob, Write, Edit, Bash, mcp__dart__*, mcp__forgejo__*
model: sonnet
---
Du bist Flutter-Entwickler für **Shuffle** und arbeitest in `app/`.
## Voraussetzung
Freigegebene Spec in `docs/specs/`. Backend-Schnittstellen sind dort beschrieben bzw. bereits umgesetzt.
## Architektur
```
lib/features/<feature>/
domain/ Entities (freezed), Repository-Interfaces, Use-Cases – keine Flutter-/Supabase-Imports
data/ DTOs, Repository-Implementierungen gegen Supabase, Mapper
presentation/ Screens, Widgets, Riverpod-Notifier/Provider
```
- State mit Riverpod (`@riverpod`-Codegen), Navigation mit go_router, Modelle mit freezed.
- Jeder Screen behandelt **loading, empty, error, data** explizit.
- UI-Texte nur über l10n (`app/lib/l10n/app_de.arb` + `app_en.arb`).
- Gemeinsame Komponenten aus `lib/core/widgets/` verwenden (Design-System von `ui-designer`), keine Einzel-Styles.
- Responsiv: Layout ab 600dp/1024dp für Tablet und Web anpassen (`LayoutBuilder`/Breakpoints in `core`).
- Plattformcode über `kIsWeb`/Conditional Imports kapseln, nie verstreut.
## Ablauf je Task
1. Code schreiben, danach:
```bash
dart run build_runner build --delete-conflicting-outputs
dart format . && flutter analyze && flutter test
```
2. Fehler selbst beheben, bis alles grün ist.
3. Für Web zusätzlich `flutter build web` prüfen, wenn Plattform-APIs berührt sind.
4. Commit: `feat(<feature>): ... Refs #<nr>`.
## Regeln
- Keine Logik in `build()`. Keine `print`s. Keine ungenutzten Pakete.
- Neue Pakete nur, wenn sie in der Spec stehen oder nach Rückfrage.
- Widget-Keys (`Key('...')`) an testrelevanten Elementen setzen, damit `test-engineer` sie findet.

View file

@ -0,0 +1,39 @@
---
name: requirements-analyst
description: Übersetzt fachliche Anforderungen aus requirements/ in testbare User Stories mit Akzeptanzkriterien und legt sie als Forgejo-Issues an. Einsetzen bei neuen oder geänderten Anforderungen, bei /intake und wenn eine Story unklar ist.
tools: Read, Grep, Glob, Write, Edit, mcp__forgejo__*, mcp__outline__*
model: opus
---
Du bist Requirements Engineer für die Brettspiel-App **Shuffle**. Product Owner ist Chris.
## Auftrag
Aus fachlichen Anforderungen (Ordner `requirements/`) entstehen umsetzbare, testbare User Stories.
## Vorgehen
1. Lies alle Dateien in `requirements/` und die bestehenden Issues in Forgejo (Duplikate vermeiden).
2. Gruppiere Anforderungen in **Epics** (z. B. Sammlung, Spielabende, Community, Profil).
3. Schneide jede Anforderung in Stories nach INVEST (unabhängig, klein genug für 1–3 Tage).
4. Formuliere jede Story:
- **Als** <Rolle> **möchte ich** <Ziel>, **damit** <Nutzen>
- **Akzeptanzkriterien** im Gherkin-Format (Gegeben / Wenn / Dann), inkl. Fehler- und Leerfälle
- **Nicht im Scope** – was explizit nicht dazugehört
- **Offene Fragen** – alles, was fachlich unklar ist
- Priorität nach MoSCoW, grobe Größe (S/M/L)
5. Lege jede Story als Forgejo-Issue an (Vorlage `.forgejo/issue_template/user-story.md`),
Labels: `type/story`, `epic/<name>`, `prio/<must|should|could>`, `size/<s|m|l>`.
Ordne Issues einem Milestone zu (MVP, v1.1 …), wenn Chris einen vorgegeben hat.
6. Stories mit offenen Fragen bekommen zusätzlich `needs-clarification`.
7. Pflege die Übersicht `requirements/backlog.md` (Epic → Stories → Issue-Nummern).
## Regeln
- Erfinde keine Anforderungen. Wenn etwas fehlt, ist es eine offene Frage.
- Technische Lösungen gehören nicht in die Story (das macht `architect`).
- Prüfe Querschnittsthemen und frage nach, falls nicht geregelt: DSGVO/Datenschutz,
Offline-Verhalten, Mehrsprachigkeit, Barrierefreiheit, Moderation bei Community-Inhalten,
Jugendschutz, Account-Löschung, Benachrichtigungen.
- Issues auf Deutsch.
- Bevor du mehr als 10 Issues auf einmal anlegst: Liste zeigen und freigeben lassen.
## Ergebnis
Kurze Zusammenfassung: Anzahl Epics/Stories, Issue-Nummern, alle offenen Fragen gesammelt.

View file

@ -0,0 +1,30 @@
---
name: test-engineer
description: Test-Engineer für Shuffle. Schreibt und pflegt Unit-, Widget-, Integrations- und Web-E2E-Tests, prüft Akzeptanzkriterien gegen die Implementierung und meldet Lücken. Einsetzen nach jeder Implementierung und vor jedem PR.
tools: Read, Grep, Glob, Write, Edit, Bash, mcp__dart__*, mcp__playwright__*, mcp__forgejo__*
model: sonnet
---
Du bist Test-Engineer für **Shuffle**. Dein Maßstab sind die Akzeptanzkriterien im Issue.
## Vorgehen
1. Lies Issue, Spec (Abschnitt Teststrategie) und den Diff des Branches (`git diff main...HEAD`).
2. Erstelle eine **Abdeckungsmatrix** Akzeptanzkriterium → Test(s) in der PR-Beschreibung.
3. Schreibe fehlende Tests:
- **Unit** (`app/test/.../domain`, `.../data`): Use-Cases, Mapper, Notifier (mit mocktail)
- **Widget** (`app/test/.../presentation`): alle vier Zustände, Interaktionen, Semantics
- **Integration** (`app/integration_test/`): kritische Flows (Login, Kernfunktion des Features)
- **RLS** (`supabase/tests/`): Zugriff fremder Nutzer muss scheitern
- **Web-E2E** mit Playwright gegen `flutter build web` für Kern-Flows
4. Ausführen:
```bash
cd app && flutter test --coverage
supabase test db
```
5. Schlägt ein Test wegen eines Fehlers im Produktivcode fehl: **nicht** den Test anpassen –
Fehler dokumentieren und an `flutter-dev`/`backend-dev` zurückgeben.
## Regeln
- Tests sind deterministisch: keine echten Netzwerkaufrufe, feste Zeit (`clock`), feste Zufallswerte.
- Testnamen beschreiben Verhalten auf Deutsch oder Englisch einheitlich: `'zeigt Leerzustand, wenn Sammlung leer ist'`.
- Ziel: neue Logik ≥ 80 % Zeilenabdeckung, Domänenschicht ≥ 90 %.

View file

@ -0,0 +1,29 @@
---
name: ui-designer
description: UI/UX-Designer für Shuffle. Pflegt das Design-System (Theme, Farben, Typografie, Abstände, Komponenten in app/lib/core), prüft Screens auf Konsistenz, Responsivität und Barrierefreiheit. Einsetzen beim Projektstart, für neue Komponenten und für UX-Reviews.
tools: Read, Grep, Glob, Write, Edit, Bash, mcp__dart__*, mcp__playwright__*
model: sonnet
---
Du bist UI/UX-Designer für die Brettspiel-App **Shuffle** – verspielt, aber aufgeräumt.
## Verantwortung
1. **Design-System** in `app/lib/core/theme/` und `app/lib/core/widgets/`:
- Material 3 Theme mit `ColorScheme` (Hell + Dunkel), Design-Tokens für Abstände, Radien, Schatten
- Typografie-Skala, Icon-Set
- Basiskomponenten: Buttons, Karten (Spiel-Kachel), Listen, Leer-/Fehlerzustände, Ladeplatzhalter, Dialoge, Formfelder
- Jede Komponente bekommt eine Widget-Vorschau bzw. einen Widget-Test
2. **Dokumentation** des Design-Systems in `docs/design-system.md`.
3. **UX-Review** von Screens: Konsistenz mit Komponenten, klare Hierarchie, Feedback bei Aktionen.
## Barrierefreiheit (Pflicht)
- Kontrast mind. WCAG AA, Touch-Ziele ≥ 48dp, `Semantics`-Labels für Bilder und Icons
- Text skaliert bis 200 % ohne Abschneiden
- Tastatur-/Fokusbedienung im Web
## Responsivität
Breakpoints: kompakt < 600dp, mittel 600–1023dp, groß ≥ 1024dp. Web darf nicht wie gestreckte Handy-App aussehen.
## Regeln
- Keine fremden Markenzeichen oder Spiele-Cover als Platzhalter im Code; neutrale Platzhalter verwenden.
- Wenn Chris Figma-Designs liefert, sind diese verbindlich – Abweichungen begründen.

10
.claude/commands/adr.md Normal file
View file

@ -0,0 +1,10 @@
---
description: Neue Architekturentscheidung (ADR) dokumentieren
argument-hint: <titel der entscheidung>
---
Beauftrage den Subagent `architect`, eine ADR zu „$ARGUMENTS“ zu erstellen:
nächste freie Nummer in `docs/adr/`, Vorlage `docs/templates/adr.md`, Status `Vorgeschlagen`.
Mindestens zwei echte Optionen mit Vor- und Nachteilen, Empfehlung mit Begründung.
Zeige mir die Empfehlung und warte auf meine Entscheidung; danach Status auf `Angenommen` setzen
und `docs-writer` den ADR-Index in Outline aktualisieren lassen.

View file

@ -0,0 +1,36 @@
---
description: Komplette Feature-Pipeline für ein Forgejo-Issue – Spec, Freigabe, Umsetzung, Tests, Review, Pull Request
argument-hint: <issue-nr>
---
Setze Issue #$ARGUMENTS um. Du bist der **Orchestrator**: du koordinierst die Subagents,
hältst den Überblick und berichtest mir an den Freigabepunkten. Schreibe selbst möglichst keinen Code.
## Phase 1 – Vorbereitung
1. Lade das Issue über Forgejo. Hat es `needs-clarification`? → Stopp, offene Fragen nennen.
2. Branch anlegen: `git switch -c feature/$ARGUMENTS-<kurzname>` von aktuellem `main`.
## Phase 2 – Spec ⏸ Freigabe
3. Subagent `architect` erstellt `docs/specs/$ARGUMENTS-<kurzname>.md` inkl. Aufgaben-Schnitt.
4. Zeige mir: Zusammenfassung, Datenmodell-Änderungen, Aufgabenliste, Risiken, offene Fragen.
5. **Warte auf meine Freigabe.** Änderungswünsche zurück an `architect`.
## Phase 3 – Umsetzung
6. Lege für die Tasks der Spec eine Task-Liste an.
7. `backend-dev` setzt die Backend-Tasks um (falls vorhanden) – zuerst, weil das Frontend davon abhängt.
8. Neue UI-Komponenten nötig? → `ui-designer` ergänzt das Design-System.
9. `flutter-dev` setzt die Frontend-Tasks um.
Unabhängige Tasks dürfen parallel laufen (eigener Worktree je Agent), abhängige nacheinander.
## Phase 4 – Qualität
10. `test-engineer` ergänzt Tests und erstellt die Abdeckungsmatrix. Fehler → zurück an den Entwickler-Agent (max. 3 Runden, dann mich fragen).
11. Alle Checks lokal grün: `dart format --set-exit-if-changed .`, `flutter analyze`, `flutter test`, ggf. `supabase test db`.
12. `code-reviewer` prüft den Branch. Blocker → zurück an den Entwickler-Agent, danach erneut Review.
## Phase 5 – Pull Request ⏸ Merge durch mich
13. Push des Branches und PR in Forgejo nach `.forgejo/pull_request_template.md`
(Akzeptanzkriterien abgehakt, Abdeckungsmatrix, Screenshots/Hinweise, `Closes #$ARGUMENTS`).
14. Review-Ergebnis als Kommentar an den PR, Label `status/ready-for-merge`.
15. Abschlussbericht an mich: was umgesetzt wurde, was offen ist, wie ich es lokal teste.
Nach dem Merge (durch mich) kann `docs-writer` die Outline-Seite unter „30 Features“ anlegen.

View file

@ -0,0 +1,14 @@
---
description: Neue/geänderte fachliche Anforderungen aus requirements/ in User Stories und Forgejo-Issues überführen
argument-hint: [optional: Datei oder Epic]
---
Führe den Intake für Shuffle durch. Fokus: $ARGUMENTS (leer = alle Dateien in `requirements/`).
1. Beauftrage den Subagent `requirements-analyst`, die Anforderungen zu analysieren und
einen Story-Entwurf zu erstellen (noch **keine** Issues anlegen).
2. Zeige mir den Entwurf kompakt: Epics, Stories (Titel, Prio, Größe), offene Fragen.
3. Warte auf meine Freigabe bzw. Korrekturen.
4. Nach Freigabe: `requirements-analyst` legt Issues und Labels in Forgejo an und aktualisiert `requirements/backlog.md`.
5. Beauftrage `docs-writer`, die Seite „10 Anforderungen“ in Outline zu aktualisieren.
6. Abschluss: Liste der angelegten Issues + alle offenen Fragen an mich.

View file

@ -0,0 +1,13 @@
---
description: Release vorbereiten – Version, Changelog, Release Notes, Tag-Vorschlag
argument-hint: <version, z. B. 0.3.0>
---
Bereite Release $ARGUMENTS vor:
1. `devops`: prüft, dass `main` grün ist, setzt `version:` in `app/pubspec.yaml` (Build-Nummer +1)
auf einem Branch `release/$ARGUMENTS` und erstellt einen PR.
2. `docs-writer`: ergänzt `CHANGELOG.md` aus den gemergten PRs seit dem letzten Tag und
schreibt nutzerverständliche Release Notes (Outline „90 Releases“).
3. Zeige mir Changelog und Release Notes.
4. **Kein Tag, kein Deployment, kein Store-Upload ohne meine ausdrückliche Freigabe.**
Nach Freigabe und Merge: Tag `v$ARGUMENTS` anlegen – der Release-Workflow übernimmt.

View file

@ -0,0 +1,9 @@
---
description: Unabhängiges Code-Review eines Pull Requests oder des aktuellen Branches
argument-hint: [pr-nr]
---
Beauftrage den Subagent `code-reviewer`, PR #$ARGUMENTS zu prüfen
(ohne Nummer: aktueller Branch gegen `main`).
Er postet das Review als Kommentar in Forgejo. Gib mir danach eine Kurzfassung:
Fazit, Anzahl Blocker/Sollte/Nit und die Blocker im Wortlaut.

12
.claude/hooks/format-dart.sh Executable file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Formatiert jede von Claude geänderte .dart-Datei automatisch.
# Erhält das Hook-JSON über stdin.
set -euo pipefail
file=$(jq -r '.tool_input.file_path // empty')
[[ -z "$file" || "$file" != *.dart ]] && exit 0
[[ "$file" == *.g.dart || "$file" == *.freezed.dart ]] && exit 0
[[ -f "$file" ]] || exit 0
dart format "$file" >/dev/null 2>&1 || true
exit 0

View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Verhindert, dass bereits committete Supabase-Migrationen verändert werden.
# Neue (noch nicht in git bekannte) Migrationen sind erlaubt.
set -euo pipefail
file=$(jq -r '.tool_input.file_path // empty')
[[ "$file" == */supabase/migrations/*.sql ]] || exit 0
cd "${CLAUDE_PROJECT_DIR:-.}"
if git ls-files --error-unmatch "$file" >/dev/null 2>&1; then
echo "Bestehende Migration $file darf nicht geändert werden. Lege eine neue Migration an (supabase migration new <name>)." >&2
exit 2 # Exit 2 blockiert den Tool-Aufruf; die Meldung geht an Claude zurück
fi
exit 0

72
.claude/settings.json Normal file
View file

@ -0,0 +1,72 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(flutter:*)",
"Bash(dart:*)",
"Bash(supabase start)",
"Bash(supabase stop)",
"Bash(supabase status)",
"Bash(supabase db reset)",
"Bash(supabase test db)",
"Bash(supabase migration new:*)",
"Bash(supabase migration list)",
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git switch:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git worktree:*)",
"Bash(git fetch:*)",
"Bash(npx playwright:*)",
"mcp__forgejo__*",
"mcp__outline__*",
"mcp__dart__*",
"mcp__playwright__*"
],
"ask": [
"Bash(git push:*)",
"Bash(supabase db push:*)",
"Bash(supabase functions deploy:*)",
"Bash(git tag:*)"
],
"deny": [
"Bash(git push --force:*)",
"Bash(git push -f:*)",
"Bash(git push origin main)",
"Bash(git reset --hard:*)",
"Bash(rm -rf:*)",
"Read(./env/*.json)",
"Read(./.env)",
"Read(./.env.*)",
"Read(./supabase/.env)",
"Read(./**/*.jks)",
"Read(./**/key.properties)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-dart.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-migrations.sh"
}
]
}
]
}
}

View file

@ -0,0 +1,20 @@
---
name: Bug
about: Etwas funktioniert nicht wie erwartet
labels: ["type/bug"]
---
## Was passiert?
## Was sollte passieren?
## Schritte zum Nachstellen
1.
2.
## Umgebung
- Plattform: iOS / Android / Web (Browser: )
- App-Version:
- Umgebung: dev / staging / prod
## Screenshots / Logs

View file

@ -0,0 +1,24 @@
---
name: User Story
about: Fachliche Anforderung als testbare User Story
labels: ["type/story"]
---
## Story
**Als** <Rolle>
**möchte ich** <Ziel>,
**damit** <Nutzen>.
## Akzeptanzkriterien
1. **Gegeben** … **wenn** … **dann** …
2. **Gegeben** … **wenn** … **dann** …
## Nicht im Scope
-
## Offene Fragen
-
## Referenzen
- Anforderung: `requirements/<datei>.md#<abschnitt>`
- Epic:

View file

@ -0,0 +1,30 @@
## Zusammenfassung
<!-- Was wurde umgesetzt und warum? -->
Closes #
Spec: `docs/specs/<nr>-<kurzname>.md`
## Akzeptanzkriterien
- [ ] AK1 – …
- [ ] AK2 – …
## Abdeckungsmatrix
| AK | Test(s) |
|---|---|
| 1 | |
## Definition of Done
- [ ] Format + Analyse ohne Befund
- [ ] Tests grün, neue Logik getestet
- [ ] Android / iOS / Web geprüft (Abweichungen begründet)
- [ ] Migration + RLS + Policy-Tests (falls DB betroffen)
- [ ] l10n DE/EN, keine hartkodierten Strings
- [ ] Barrierefreiheit geprüft
- [ ] Doku aktualisiert
## So testest du es lokal
```bash
```
## Screenshots

76
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,76 @@
# CI für Pull Requests und main – läuft auf einem Forgejo-Runner mit Docker-Label
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
flutter:
runs-on: docker
container:
image: ghcr.io/cirruslabs/flutter:stable # für Releases auf feste Version pinnen
defaults:
run:
working-directory: app
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Pub-Cache
uses: https://code.forgejo.org/actions/cache@v4
with:
path: /root/.pub-cache
key: pub-${{ hashFiles('app/pubspec.lock') }}
- name: Abhängigkeiten
run: flutter pub get
- name: Code-Generierung
run: dart run build_runner build --delete-conflicting-outputs
- name: Format
run: dart format --set-exit-if-changed lib test
- name: Analyse
run: flutter analyze --fatal-infos
- name: Tests
run: flutter test --coverage
- name: Web-Build
run: flutter build web --release --dart-define-from-file=env/ci.example.json
supabase:
runs-on: docker
container:
image: node:22-bookworm
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Migrationen prüfen (ohne laufende DB)
run: |
test -d supabase/migrations || { echo "Keine Migrationen – übersprungen"; exit 0; }
# Bereits gemergte Migrationen dürfen nicht verändert werden
if [ "${{ github.event_name }}" = "pull_request" ]; then
git fetch origin "${{ github.base_ref }}" --depth=1
changed=$(git diff --name-only --diff-filter=M "origin/${{ github.base_ref }}"...HEAD -- supabase/migrations || true)
if [ -n "$changed" ]; then echo "Geänderte bestehende Migrationen: $changed"; exit 1; fi
fi
# RLS-/pgTAP-Tests: braucht einen Runner mit Docker-Socket (Label "docker-host").
# Läuft nur, wenn Migrationen existieren. Ohne passenden Runner Job bei Bedarf deaktivieren.
supabase-tests:
runs-on: docker-host
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- uses: https://code.forgejo.org/actions/setup-node@v4
with:
node-version: 22
- name: Supabase starten und Datenbanktests ausführen
run: |
test -d supabase/migrations || { echo "Keine Migrationen – übersprungen"; exit 0; }
npx --yes supabase start -x studio,imgproxy,edge-runtime,logflare,vector
npx --yes supabase test db
- name: Aufräumen
if: always()
run: npx --yes supabase stop --no-backup || true

27
.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# Secrets & Umgebungen
.env
.env.*
env/*.json
!env/*.example.json
supabase/.env
**/key.properties
**/*.jks
**/*.keystore
# Claude Code – persönliche Einstellungen
.claude/settings.local.json
CLAUDE.local.md
# Flutter/Dart
app/.dart_tool/
app/build/
app/.flutter-plugins*
app/coverage/
# Node / Playwright
node_modules/
test-results/
playwright-report/
# OS
.DS_Store

27
.mcp.json Normal file
View file

@ -0,0 +1,27 @@
{
"mcpServers": {
"forgejo": {
"type": "stdio",
"command": "forgejo-mcp",
"args": ["--transport", "stdio"],
"env": {
"FORGEJO_URL": "${FORGEJO_URL}",
"FORGEJO_ACCESS_TOKEN": "${FORGEJO_ACCESS_TOKEN}"
}
},
"outline": {
"type": "http",
"url": "${OUTLINE_URL}/mcp"
},
"dart": {
"type": "stdio",
"command": "dart",
"args": ["mcp-server"]
},
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"]
}
}
}

115
CLAUDE.md Normal file
View file

@ -0,0 +1,115 @@
# Shuffle – Projektkontext für Claude Code
Diese Datei wird in jeder Claude-Code-Session automatisch geladen. Sie ist das "Gedächtnis" des Projekts.
Halte sie kurz, aktuell und verbindlich. Details gehören nach `docs/` oder in Outline.
## Was ist Shuffle?
Shuffle ist eine Brettspiel-App für **iOS, Android und Web** aus einer gemeinsamen Codebasis.
Die fachlichen Anforderungen liegen in `requirements/` (Quelle der Wahrheit) und werden als
User Stories in Forgejo-Issues umgesetzt.
## Tech-Stack (ADR-0001 angenommen, Hosting ADR-0002)
- **Frontend:** Flutter (stable) / Dart 3 – eine Codebasis für iOS, Android, Web
- **State-Management:** Riverpod (mit Code-Generierung)
- **Navigation:** go_router (Deep Links + Web-URLs)
- **Backend:** Supabase (PostgreSQL 16, Auth, Storage, Realtime, Edge Functions), **selbst gehostet auf Hetzner** (ADR-0002); lokal per Supabase CLI. Nur Features nutzen, die auch self-hosted verfügbar sind; Dashboard-Einstellungen im Repo dokumentieren.
- **Modelle:** freezed + json_serializable
- **Tests:** flutter_test, mocktail, integration_test, Playwright (Web-E2E)
- **CI/CD:** Forgejo Actions (`.forgejo/workflows/`)
- **Doku:** Outline (Collection „Shuffle“), Architektur-Entscheidungen als ADRs in `docs/adr/`
## Repository-Struktur
```
app/ Flutter-App
lib/
core/ Theme, Routing, DI, Fehlerbehandlung, gemeinsame Widgets
features/<feature>/ Feature-first: data/ domain/ presentation/
test/ Unit- und Widget-Tests (Spiegel von lib/)
integration_test/
supabase/
migrations/ SQL-Migrationen (nur additive Änderungen, nie bestehende editieren)
functions/ Edge Functions (TypeScript/Deno)
seed.sql
tests/database/ pgTAP-Tests für Schema und RLS (`supabase test db`)
requirements/ Fachliche Anforderungen (10-anforderungen.md, 20-r1-akzeptanzkriterien.md)
docs/
adr/ Architecture Decision Records
datenmodell-r1.md Datenmodell R1 inkl. Tabellenstruktur (Migrationen sind maßgeblich)
specs/ Technische Feature-Spezifikationen (eine pro Issue)
.claude/agents/ Spezialisierte Subagents
.claude/commands/ Slash-Commands für Workflows
```
## Arbeitsweise (verbindlich)
1. **Jede Änderung hat ein Forgejo-Issue.** Kein Code ohne Issue-Nummer.
2. **Branch pro Issue:** `feature/<nr>-<kurzname>`, `fix/<nr>-<kurzname>`.
3. **Spec vor Code:** Für jedes Feature-Issue entsteht zuerst `docs/specs/<nr>-<kurzname>.md` (Agent `architect`). Chris gibt die Spec frei, bevor implementiert wird.
4. **Tests sind Teil des Features**, nicht nachgelagert. Kein PR ohne Tests für neue Logik.
5. **Pull Request in Forgejo**, Review durch Agent `code-reviewer`, Merge **nur durch Chris**.
6. **Conventional Commits:** `feat(collection): ...`, `fix(auth): ...`, `test: ...`, `docs: ...`, mit `Refs #<nr>` bzw. `Closes #<nr>`.
## Definition of Done
- [ ] Akzeptanzkriterien des Issues erfüllt und im PR einzeln abgehakt
- [ ] `dart format --set-exit-if-changed .` und `flutter analyze` ohne Befund
- [ ] Unit-/Widget-Tests grün, neue Logik getestet
- [ ] Läuft auf Android, iOS und Web (oder Abweichung im PR begründet)
- [ ] Supabase: Migration + RLS-Policies + Policy-Tests vorhanden, wenn Tabellen betroffen
- [ ] Texte über l10n (`app/lib/l10n/*.arb`), keine hartkodierten UI-Strings (Deutsch + Englisch)
- [ ] Barrierefreiheit: Semantics-Labels, ausreichende Kontraste, Touch-Ziele ≥ 48dp
- [ ] Doku aktualisiert (Spec, ggf. ADR, ggf. Outline-Seite)
## Code-Konventionen
- Code, Bezeichner, Commits: **Englisch**. Doku, Specs, Issue-Texte: **Deutsch**.
- Feature-first-Struktur, Abhängigkeiten nur nach innen (presentation → domain ← data).
- Keine Geschäftslogik in Widgets. Widgets sind dumm, Logik in Notifiern/Use-Cases.
- Keine `print`-Aufrufe; Logging über das zentrale Logger-Utility.
- Secrets niemals ins Repo. Konfiguration über `--dart-define-from-file=env/<stage>.json` (in `.gitignore`).
- Sicherheit: Jede Tabelle hat RLS aktiviert. Der Client vertraut niemals Client-Daten für Berechtigungen.
## Agents (siehe `.claude/agents/`)
| Agent | Zuständig für |
|---|---|
| `requirements-analyst` | Anforderungen → User Stories mit Akzeptanzkriterien → Forgejo-Issues |
| `architect` | Architektur, Datenmodell, ADRs, Feature-Specs, Aufgaben-Schnitt |
| `backend-dev` | Supabase: Migrationen, RLS, Edge Functions, Seeds |
| `flutter-dev` | Flutter-UI, State, Navigation, Datenanbindung |
| `ui-designer` | Design-System, Theme, Komponenten, UX-Konsistenz, Barrierefreiheit |
| `test-engineer` | Teststrategie, Unit-/Widget-/Integration-/E2E-Tests |
| `code-reviewer` | Review gegen DoD, Sicherheit, Architektur (nur lesend) |
| `devops` | Forgejo Actions, Builds, Releases, Umgebungen |
| `docs-writer` | Outline-Doku, Changelog, Release Notes |
## Workflows (Slash-Commands)
- `/intake` – neue Anforderungen aus `requirements/` in Stories + Issues überführen
- `/feature <issue-nr>` – komplette Pipeline: Spec → Freigabe → Umsetzung → Tests → Review → PR
- `/review <pr-nr>` – Review eines PRs
- `/adr <titel>` – neue Architekturentscheidung dokumentieren
- `/release <version>` – Release vorbereiten
## Wichtige Befehle
```bash
cd app && flutter pub get
dart run build_runner build --delete-conflicting-outputs
flutter analyze && flutter test
flutter run -d chrome --dart-define-from-file=env/dev.json
supabase start # lokales Backend
supabase db reset # Migrationen + Seed neu einspielen
supabase test db # RLS-/pgTAP-Tests
```
## Werkzeuge (MCP)
- **forgejo** – Issues, PRs, Labels, Milestones im Repo `shuffle`
- **outline** – Doku-Collection „Shuffle“
- **dart** – Analyse, Tests, pub.dev-Suche, laufende App inspizieren
- **playwright** – Web-Build im Browser testen
## Daten & Sicherheit (R1)
- Datenmodell: `docs/datenmodell-r1.md`, Migrationen in `supabase/migrations/` (nie bestehende ändern).
- Alle Tabellen: `id` (uuid), `created_at`, `updated_at`; FKs nur zwischen verwandten Tabellen (`<tabelle>_id`).
- Jede neue Tabelle braucht RLS, Policies und pgTAP-Tests in `supabase/tests/database/`.
- In R1 sind alle Nutzerdaten privat; Katalog ist öffentlich lesbar, Pflege nur Admin.
- Akzeptanzkriterien werden als „KTO-01 Kriterium 3“ in Tests und PR-Checklisten referenziert.
## Grenzen
- Nichts nach `main` pushen. Kein Force-Push. Keine Releases ohne Freigabe von Chris.
- Bei unklaren fachlichen Fragen: **nicht raten** – Frage als Kommentar ins Issue schreiben und Label `needs-clarification` setzen.

28
README.md Normal file
View file

@ -0,0 +1,28 @@
# Shuffle – Starter-Kit
Grundgerüst für die Entwicklung der Brettspiel-App **Shuffle** mit spezialisierten Claude-Code-Agents.
Die vollständige Schritt-für-Schritt-Anleitung liegt als separates Dokument bei.
## Inhalt
| Pfad | Zweck |
|---|---|
| `CLAUDE.md` | Projektkontext, Regeln, Definition of Done – wird in jeder Session geladen |
| `.claude/agents/` | 9 spezialisierte Subagents |
| `.claude/commands/` | Workflows: `/intake`, `/feature`, `/review`, `/adr`, `/release` |
| `.claude/settings.json` | Berechtigungen (erlaubt/nachfragen/verboten) und Hooks |
| `.claude/hooks/` | Auto-Format für Dart, Schutz bestehender Migrationen |
| `.mcp.json` | MCP-Server: Forgejo, Outline, Dart, Playwright |
| `.forgejo/` | CI-Pipeline, Issue- und PR-Vorlagen |
| `docs/` | ADR-0001 (Tech-Stack-Vorschlag), Vorlagen für Specs und ADRs |
| `requirements/` | Ablage für deine fachlichen Anforderungen |
| `supabase/` | R1-Datenbank: Migrationen, RLS-Policies, pgTAP-Tests, Seed |
| `scripts/` | Setup-Check, Forgejo-Labels |
## Schnellstart
```bash
cp -r shuffle-starter ~/dev/shuffle && cd ~/dev/shuffle
chmod +x .claude/hooks/*.sh scripts/*.sh
./scripts/check-setup.sh
git init -b main && git add . && git commit -m "chore: project scaffold"
```
Danach weiter mit Phase 2 der Anleitung.

View file

@ -0,0 +1,30 @@
# ADR-0001: Tech-Stack für Shuffle
- **Status:** Angenommen (Chris, 2026-11-02)
- **Datum:** 2026-11-01
## Kontext
Shuffle soll auf iOS, Android und im Web laufen, von einer Person mit KI-Agents entwickelt werden
und Daten DSGVO-konform in der EU halten. Code liegt in Forgejo, Doku in Outline (beides selbst gehostet).
## Optionen
### Frontend
- **Flutter** – eine Codebasis für alle drei Plattformen, starke Typisierung, gute Testbarkeit, sehr gute Agent-Unterstützung (Dart-MCP-Server). Web-Build ist eher App-artig als SEO-optimiert.
- **React Native + Expo** – großes Ökosystem, Web über react-native-web; mehr Plattform-Sonderfälle.
- **Getrennte Web-App (z. B. Next.js) + Flutter mobil** – bestes Web/SEO, aber doppelter Aufwand.
### Backend
- **Supabase** – PostgreSQL mit Auth, Storage, Realtime, Edge Functions; selbst hostbar (Docker) oder EU-Region; Berechtigungen per Row Level Security direkt in der DB.
- **Appwrite** – ähnlich, selbst hostbar, NoSQL-artiges Modell.
- **Eigenes Backend (z. B. Dart/Go/Node + PostgreSQL)** – maximale Kontrolle, deutlich mehr Aufwand.
## Entscheidung (Vorschlag)
**Flutter + Supabase**, State mit Riverpod, Navigation mit go_router, Modelle mit freezed.
Hosting: Supabase selbst gehostet oder in EU-Region; Web-Build statisch hinter Reverse Proxy.
## Konsequenzen
- Eine Codebasis, ein Testansatz, schneller MVP.
- Relationales Modell passt gut zu Sammlungen, Spielabenden, Freundschaften.
- Sicherheit hängt an sauberen RLS-Policies → Pflicht-Tests (pgTAP) in jedem Feature.
- Öffentliche, suchmaschinenrelevante Seiten (z. B. Landingpage) ggf. separat lösen.
- iOS-Builds benötigen einen Mac (lokal oder macOS-Runner).

32
docs/adr/0002-hosting.md Normal file
View file

@ -0,0 +1,32 @@
# ADR-0002: Hosting – Supabase selbst gehostet auf Hetzner
- **Status:** Angenommen (Chris, 2026-11-02)
- **Datum:** 2026-11-02
## Kontext
Shuffle (ADR-0001: Flutter + Supabase) soll DSGVO-konform in der EU laufen. Nutzer geben u. a. Stadt, Partien
und Fotos preis; Mindestalter 16. Entwickelt wird allein mit KI-Agents, Forgejo und Outline laufen bereits selbst gehostet.
## Optionen
### A: Supabase Cloud Pro (Region Frankfurt)
- ca. $25–30 pro Monat, 100.000 MAU, 8 GB DB, 100 GB Storage, tägliche Backups (7 Tage) inklusive; PITR $100 extra.
- Kaum Betriebsaufwand. Aber: Supabase Pte. Ltd. (Singapur) bzw. Supabase, Inc. (USA) als Verarbeiter, Übermittlung per Standardvertragsklauseln, Support- und Abrechnungsdaten können die Region verlassen.
### B: Selbst gehostet auf Hetzner (Docker)
- Kosten Größenordnung €25–35 pro Monat (Server, Backups, Object Storage); keine Gebühr pro User.
- Daten bleiben bei einem deutschen Anbieter. Dafür: Updates, Härtung, Backups, Monitoring und Wiederherstellung liegen bei uns; kein Branching, keine verwalteten Backups/PITR, Community-Support.
### C: Anderer EU-Anbieter (netcup, IONOS, OVHcloud, Scaleway)
- Gleiches Betriebsmodell wie B; netcup/IONOS ähnlich günstig, siehe Vergleich. Kein klarer Vorteil gegenüber Hetzner.
## Entscheidung
**Option B: Supabase selbst gehostet auf Hetzner**, Standort Deutschland. Datenhoheit wiegt für Shuffle mehr als der geringere Betriebsaufwand der Cloud.
Medien und Backups liegen in Hetzner Object Storage (S3-kompatibel). Die Server-Größe wird vor der Bestellung anhand der aktuellen Preise und Verfügbarkeit festgelegt; Preise und Lieferbarkeit der günstigen Pläne haben sich 2026 geändert und sind vor Bestellung in der Hetzner Console zu prüfen.
## Konsequenzen
- **Pflichtbausteine vor dem ersten echten Nutzer:** automatisierte Backups (DB + Storage) an getrennten Ort, **getesteter** Restore, TLS und Reverse Proxy, Firewall, nur nötige Ports, Update-Routine, Monitoring und Alarm, Secrets nicht im Repo.
- Zwei Umgebungen: lokal (Supabase CLI) für Entwicklung, ein Hetzner-Server für Staging/Produktion; Staging und Produktion getrennt, sobald echte Nutzer da sind.
- Nur Funktionen nutzen, die es selbst gehostet gibt (kein Branching, keine Management-API). Schema ausschließlich über Migrationen.
- Dateizugriff über eine eigene Schicht, damit der Speicher austauschbar bleibt.
- Dashboard-Einstellungen (SMTP, Redirect-URLs, OAuth) schriftlich im Repo festhalten.
- Aufgabe für `devops`: Docker-Compose-Setup, Backup- und Restore-Skripte, Runbook in `docs/`.
- Rückfall: Ein Umzug in die Supabase Cloud ist möglich (Dump, Dateien kopieren, Nutzer melden sich neu an).
- Neubewertung, wenn Betriebsaufwand oder Ausfälle die Entwicklung spürbar bremsen.

512
docs/datenmodell-r1.md Normal file
View file

@ -0,0 +1,512 @@
<!-- Quelle: Claude Docs „Shuffle – R1 Datenmodell“, Export 2026-11-02. Maßgeblich bleiben die Migrationen in supabase/migrations/. -->
# Shuffle – R1 Datenmodell
2026-11-02 · Chris
Dieses Modell deckt die 26 Stories von R1 ab (Konto, Katalog, Sammlung, Partien mit Wertung, Statistik) und ist so geschnitten, dass R2 (Gruppen, Termine) und R3 (Community) ohne Umbau darauf aufsetzen. Es läuft auf selbst gehostetem Supabase (PostgreSQL) bei Hetzner, siehe ADR-0002.
## Ziel und Annahmen
Grundlage sind die Entscheidungen vom 2. November 2026 aus den Anforderungen und den R1-Akzeptanzkriterien:
- **Eigener Katalog:** Spiele haben eine eigene ID. Die BGG-ID ist ein optionales Feld, damit Shuffle auch ohne BGG-Lizenz funktioniert (F2).
- **Eigener Eintrag je Spiel:** Pro Spiel gibt es einen Eintrag, Editionen sind Varianten davon (O4, O8).
- **Nutzerkonten:** Anmeldung nur mit E-Mail und Passwort, Anzeigename frei wählbar, Mindestalter 16 (F8, O1, O2).
- **Sichtbarkeit:** In R1 ist alles privat, außer der Stadt im Profil (F5, O3). Die Stadt lässt sich im Profil ausblenden.
- **Mitspieler:** Gäste ohne Konto sind möglich; verknüpfte Nutzer müssen bestätigen (F11).
- **Offline:** Nur das Erfassen von Partien funktioniert offline und wird später abgeglichen (F3).
- **Wertungsbögen:** Privat je Nutzer, keine gemeinsamen Vorlagen in R1 (O9).
- **Grenzen:** Passwort ab 10 Zeichen, Anzeigename 30, Kurzbeschreibung 300, 5 Lieblingsspiele, 20 Mitspieler je Partie, Notiz 1000 Zeichen, Avatar 5 MB (O10).
- **Sprachen:** Deutsch und Englisch für die Oberfläche (F1). Katalogtexte haben in R1 eine Sprache je Spiel; Mehrsprachigkeit folgt später.
## Überblick
*[Diagramm: Datenmodell R1 · 8 Kerntabellen – siehe Dokument in Claude]*
Die Pfeile zeigen, welche Tabelle auf welche verweist. Jede Partie (`plays`) hängt an einem Spiel und hat Mitspieler, deren Werte aus einem Wertungsbogen stammen. Nicht gezeigt: `favorite_games`, `bgg_links` und die Zuordnungstabellen der Begriffe.
## Tabellen
R1 braucht 17 Tabellen in vier Bereichen. Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `<Zieltabelle>_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag.
### Konto und Profil
| Tabelle | Spalten | Zweck |
| --- | --- | --- |
| `profiles` | `id` (PK, zugleich FK auf `auth.users.id`), `display_name` (max. 30), `bio` (max. 300), `city`, `city_visible`, `avatar_path`, `locale`, `terms_accepted_at`, `age_confirmed_at`, `deleted_at` | Ein Profil je Konto; wird beim Registrieren automatisch angelegt |
| `favorite_games` | `profile_id` (FK → `profiles`), `game_id` (FK → `games`), `position` (1–5) | Lieblingsspiele, höchstens 5 je Nutzer |
| `bgg_links` | `profile_id` (FK → `profiles`, eindeutig), `bgg_username`, `last_import_at` | Verknüpfter BGG-Name für den Import |
### Katalog
| Tabelle | Spalten | Zweck |
| --- | --- | --- |
| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`) | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5) |
| `game_variants` | `game_id` (FK → `games`), `name`, `year`, `language`, `bgg_id` (optional) | Editionen und Varianten eines Spiels (O4) |
| `genres`, `mechanics`, `categories` | `name_de`, `name_en` | Einheitliche Begriffe für Filter und Spieldetails |
| `game_genres` | `game_id` (FK → `games`), `genre_id` (FK → `genres`) | Zuordnung Spiel zu Genre; Paar eindeutig |
| `game_mechanics` | `game_id` (FK → `games`), `mechanic_id` (FK → `mechanics`) | Zuordnung Spiel zu Mechanik; Paar eindeutig |
| `game_categories` | `game_id` (FK → `games`), `category_id` (FK → `categories`) | Zuordnung Spiel zu Kategorie; Paar eindeutig |
### Sammlung
| Tabelle | Spalten | Zweck |
| --- | --- | --- |
| `collection_items` | `owner_id` (FK → `profiles`), `game_id` (FK → `games`), `variant_id` (FK → `game_variants`, optional), `list` (`owned`, `wishlist`, `shopping`), `note` (max. 1000), `acquired_on` | Sammlung, Wunschliste und Einkaufsliste; je Nutzer und Spiel genau ein Eintrag (O8) |
### Partien und Wertung
| Tabelle | Spalten | Zweck |
| --- | --- | --- |
| `score_sheets` | `owner_id` (FK → `profiles`), `game_id` (FK → `games`, optional), `name` | Privater Wertungsbogen (O9) |
| `score_categories` | `sheet_id` (FK → `score_sheets`), `name`, `position` | Zeilen des Bogens, z. B. Siedlungen, Straßen |
| `plays` | `owner_id` (FK → `profiles`), `game_id` (FK → `games`), `variant_id` (FK → `game_variants`, optional), `sheet_id` (FK → `score_sheets`, optional), `played_at`, `duration_minutes`, `note`, `client_id` | Eine Partie; `client_id` verhindert Doppelungen beim Offline-Abgleich |
| `play_players` | `play_id` (FK → `plays`), `profile_id` (FK → `profiles`, leer bei Gästen), `guest_name` (nur bei Gästen), `link_status` (`none`, `pending`, `confirmed`, `declined`), `place`, `total_score` | Mitspieler, Platz 1 = Sieg; höchstens 20 je Partie |
| `play_scores` | `play_player_id` (FK → `play_players`), `category_id` (FK → `score_categories`), `value` | Einzelwerte aus dem Wertungsbogen; je Mitspieler und Kategorie ein Wert |
## Sicherheit
Jede Tabelle hat Row Level Security (RLS) und eine Policy-Test-Suite (pgTAP). Der Client entscheidet nie über Rechte.
| Tabelle | Lesen | Schreiben |
| --- | --- | --- |
| `profiles` | Eigenes Profil; fremde nur `display_name`, `avatar_path`, `city` | Nur eigenes |
| `favorite_games`, `bgg_links` | Nur eigene | Nur eigene |
| `games`, `game_variants`, Begriffe | Katalog für alle (O6); private Spiele nur der Ersteller | Katalog nur Admin; private Spiele nur der Ersteller |
| `collection_items`, `score_sheets`, `score_categories` | Nur eigene | Nur eigene |
| `plays` | Ersteller und eingeladene Mitspieler (offen oder bestätigt) | Nur Ersteller |
| `play_players`, `play_scores` | Wie die zugehörige Partie | Nur Ersteller der Partie; Mitspieler bestätigt oder lehnt nur den eigenen Eintrag ab |
Dazu kommen diese Regeln:
- **Mindestalter 16:** Die Registrierung verlangt eine Bestätigung (`age_confirmed_at`). Mehr prüfen wir nicht.
- **Katalog ohne Anmeldung:** Lesbar, nie änderbar.
- **Konto löschen (DSGVO):** Löscht Profil, Sammlung, Wertungsbögen und Avatar. Partien bleiben für andere Mitspieler erhalten, der gelöschte Nutzer erscheint dort als „Gelöschter Nutzer“. Entschieden am 2. November 2026.
- **Admin-Rolle:** Nur für Katalogpflege, serverseitig, nie aus dem Client heraus.
## Dateien, Import, Offline, Statistik
- **Dateien:** Avatare (max. 5 MB) und Spielbilder liegen im Storage in einem privaten Bucket je Zweck. Die App greift über eine eigene Zugriffsschicht darauf zu, damit der Speicher später auf Hetzner Object Storage wechseln kann (ADR-0002). In der Datenbank steht nur der Pfad.
- **BGG-Import:** Der Import läuft serverseitig in einer Edge Function, mit Cache und „Powered by BGG“. Er legt fehlende Spiele als private Einträge an (O5) und markiert in der Sammlung Spiele, die bei BGG fehlen (SAM-09). Er wird als letztes R1-Feature gebaut und ist von der BGG-Lizenz abhängig.
- **Offline-Erfassung:** Die App speichert Partien lokal und sendet sie beim nächsten Kontakt. Die `client_id` je Partie macht den Abgleich wiederholbar, sodass keine Doppelungen entstehen. Nur Partien funktionieren offline (F3); alles andere braucht Verbindung.
- **Statistik:** Spielzeit, Anzahl Partien und Siegquote werden aus `plays` und `play_players` per SQL-View berechnet, nicht gespeichert. Siegquote = Anteil der Partien mit Platz 1 unter Partien mit Ergebnis; ein geteilter erster Platz zählt als Sieg.
## Entscheidungen und nächste Schritte
Die vier offenen Fragen sind am 2. November 2026 entschieden, das Modell ist damit bereit für die Freigabe:
1. **Stadt:** Der Nutzer kann sie im Profil ausblenden (Schalter `city_visible`).
2. **Konto löschen:** Partien bleiben für andere Mitspieler erhalten, der gelöschte Nutzer erscheint als „Gelöschter Nutzer“.
3. **Listen:** Besitz, Wunschliste und Einkaufsliste bleiben in einer Tabelle; Preis und Laden können später ergänzt werden.
4. **Katalogtexte:** Eine Sprache je Spiel in R1.
Danach folgt diese Reihenfolge:
1. Modell von dir freigeben lassen.
2. Supabase-Projekt lokal aufsetzen (`devops`, `backend-dev`).
3. Migrationen, RLS-Policies und pgTAP-Tests je Bereich schreiben.
4. Flutter-App-Gerüst mit Anmeldung und Profil (KTO) als erste Story.
## Tabellenstruktur (aus den Migrationen)
Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgelesen und gegen eine echte PostgreSQL-16-Datenbank getestet. Spalte „Schlüssel“ zeigt Primärschlüssel (PK) und Fremdschlüssel (FK); Prüfregeln wie Längen und Wertebereiche stehen in den Migrationen.
### Konto und Profil
**`profiles`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | | PK, FK → auth.users |
| `display_name` | text | ja | | |
| `bio` | text | nein | | |
| `city` | text | nein | | |
| `city_visible` | boolean | ja | `true` | |
| `avatar_path` | text | nein | | |
| `locale` | text | ja | `'de'` | |
| `terms_accepted_at` | timestamptz | ja | | |
| `age_confirmed_at` | timestamptz | ja | | |
| `deleted_at` | timestamptz | nein | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`favorite_games`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `profile_id` | uuid | ja | | FK → profiles |
| `game_id` | uuid | ja | | FK → games |
| `position` | smallint | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`bgg_links`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `profile_id` | uuid | ja | | FK → profiles |
| `bgg_username` | text | ja | | |
| `last_import_at` | timestamptz | nein | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
### Katalog
**`games`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `owner_id` | uuid | nein | | FK → profiles |
| `title` | text | ja | | |
| `description` | text | nein | | |
| `year` | smallint | nein | | |
| `min_players` | smallint | nein | | |
| `max_players` | smallint | nein | | |
| `min_minutes` | smallint | nein | | |
| `max_minutes` | smallint | nein | | |
| `min_age` | smallint | nein | | |
| `image_path` | text | nein | | |
| `bgg_id` | integer | nein | | |
| `visibility` | text | ja | `'private'` | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`game_variants`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `game_id` | uuid | ja | | FK → games |
| `name` | text | ja | | |
| `year` | smallint | nein | | |
| `language` | text | nein | | |
| `bgg_id` | integer | nein | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`genres`**, **`mechanics`** und **`categories`** sind gleich aufgebaut:
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `name_de` | text | ja | | |
| `name_en` | text | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`game_genres`**, **`game_mechanics`** und **`game_categories`** verbinden ein Spiel mit je einem Begriff:
| Tabelle | Spalte | Typ | Pflicht | Schlüssel |
| --- | --- | --- | --- | --- |
| alle drei | `id` | uuid | ja | PK |
| alle drei | `game_id` | uuid | ja | FK → games |
| `game_genres` | `genre_id` | uuid | ja | FK → genres |
| `game_mechanics` | `mechanic_id` | uuid | ja | FK → mechanics |
| `game_categories` | `category_id` | uuid | ja | FK → categories |
| alle drei | `created_at`, `updated_at` | timestamptz | ja | |
### Sammlung
**`collection_items`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `owner_id` | uuid | ja | | FK → profiles |
| `game_id` | uuid | ja | | FK → games |
| `variant_id` | uuid | nein | | FK → game_variants (zusammen mit game_id) |
| `list` | text | ja | `'owned'` | |
| `note` | text | nein | | |
| `acquired_on` | date | nein | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
### Partien und Wertung
**`score_sheets`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `owner_id` | uuid | ja | | FK → profiles |
| `game_id` | uuid | nein | | FK → games |
| `name` | text | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`score_categories`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `sheet_id` | uuid | ja | | FK → score_sheets |
| `name` | text | ja | | |
| `position` | smallint | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`plays`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `owner_id` | uuid | nein | | FK → profiles |
| `game_id` | uuid | ja | | FK → games |
| `variant_id` | uuid | nein | | FK → game_variants (zusammen mit game_id) |
| `sheet_id` | uuid | nein | | FK → score_sheets |
| `played_at` | timestamptz | ja | | |
| `duration_minutes` | integer | nein | | |
| `note` | text | nein | | |
| `client_id` | uuid | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`play_players`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `play_id` | uuid | ja | | FK → plays |
| `profile_id` | uuid | nein | | FK → profiles |
| `guest_name` | text | nein | | |
| `link_status` | text | ja | `'none'` | |
| `place` | smallint | nein | | |
| `total_score` | numeric | nein | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`play_scores`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `play_player_id` | uuid | ja | | FK → play_players |
| `category_id` | uuid | ja | | FK → score_categories |
| `value` | numeric | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
Zwei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Avatar und freigegebene Stadt, `my_game_stats` liefert die Statistik je Spiel für den angemeldeten Nutzer.
## Tabellenstruktur (aus den Migrationen)
### Konto und Profil
**`profiles`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | | PK, FK → auth.users |
| `display_name` | text | ja | | |
| `bio` | text | nein | | |
| `city` | text | nein | | |
| `city_visible` | boolean | ja | `true` | |
| `avatar_path` | text | nein | | |
| `locale` | text | ja | `'de'::text` | |
| `terms_accepted_at` | timestamptz | ja | | |
| `age_confirmed_at` | timestamptz | ja | | |
| `deleted_at` | timestamptz | nein | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`favorite_games`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `profile_id` | uuid | ja | | FK → profiles |
| `game_id` | uuid | ja | | FK → games |
| `position` | smallint | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`bgg_links`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `profile_id` | uuid | ja | | FK → profiles |
| `bgg_username` | text | ja | | |
| `last_import_at` | timestamptz | nein | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
### Katalog
**`games`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `owner_id` | uuid | nein | | FK → profiles |
| `title` | text | ja | | |
| `description` | text | nein | | |
| `year` | smallint | nein | | |
| `min_players` | smallint | nein | | |
| `max_players` | smallint | nein | | |
| `min_minutes` | smallint | nein | | |
| `max_minutes` | smallint | nein | | |
| `min_age` | smallint | nein | | |
| `image_path` | text | nein | | |
| `bgg_id` | integer | nein | | |
| `visibility` | text | ja | `'private'::text` | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`game_variants`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `game_id` | uuid | ja | | FK → games |
| `name` | text | ja | | |
| `year` | smallint | nein | | |
| `language` | text | nein | | |
| `bgg_id` | integer | nein | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`genres`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `name_de` | text | ja | | |
| `name_en` | text | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`mechanics`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `name_de` | text | ja | | |
| `name_en` | text | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`categories`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `name_de` | text | ja | | |
| `name_en` | text | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`game_genres`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `game_id` | uuid | ja | | FK → games |
| `genre_id` | uuid | ja | | FK → genres |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`game_mechanics`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `game_id` | uuid | ja | | FK → games |
| `mechanic_id` | uuid | ja | | FK → mechanics |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`game_categories`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `game_id` | uuid | ja | | FK → games |
| `category_id` | uuid | ja | | FK → categories |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
### Sammlung
**`collection_items`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `owner_id` | uuid | ja | | FK → profiles |
| `game_id` | uuid | ja | | FK → games |
| `variant_id` | uuid | nein | | FK → game_variants (zusammen mit game_id) |
| `list` | text | ja | `'owned'::text` | |
| `note` | text | nein | | |
| `acquired_on` | date | nein | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
### Partien und Wertung
**`score_sheets`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `owner_id` | uuid | ja | | FK → profiles |
| `game_id` | uuid | nein | | FK → games |
| `name` | text | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`score_categories`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `sheet_id` | uuid | ja | | FK → score_sheets |
| `name` | text | ja | | |
| `position` | smallint | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`plays`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `owner_id` | uuid | nein | | FK → profiles |
| `game_id` | uuid | ja | | FK → games |
| `variant_id` | uuid | nein | | FK → game_variants (zusammen mit game_id) |
| `sheet_id` | uuid | nein | | FK → score_sheets |
| `played_at` | timestamptz | ja | | |
| `duration_minutes` | integer | nein | | |
| `note` | text | nein | | |
| `client_id` | uuid | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`play_players`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `play_id` | uuid | ja | | FK → plays |
| `profile_id` | uuid | nein | | FK → profiles |
| `guest_name` | text | nein | | |
| `link_status` | text | ja | `'none'::text` | |
| `place` | smallint | nein | | |
| `total_score` | numeric | nein | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`play_scores`**
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `play_player_id` | uuid | ja | | FK → play_players |
| `category_id` | uuid | ja | | FK → score_categories |
| `value` | numeric | ja | | |
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |

17
docs/templates/adr.md vendored Normal file
View file

@ -0,0 +1,17 @@
# ADR-NNNN: <Titel>
- **Status:** Vorgeschlagen | Angenommen | Abgelöst durch ADR-XXXX
- **Datum:** YYYY-MM-DD
## Kontext
Welches Problem, welche Rahmenbedingungen?
## Optionen
### Option A
Vorteile / Nachteile
### Option B
Vorteile / Nachteile
## Entscheidung
## Konsequenzen
Was wird einfacher, was schwieriger, was muss nachgezogen werden?

33
docs/templates/spec.md vendored Normal file
View file

@ -0,0 +1,33 @@
# Spec #<nr> – <Titel>
| | |
|---|---|
| Issue | #<nr> |
| Status | Entwurf / Freigegeben |
| Autor | architect |
## 1. Ziel
## 2. Akzeptanzkriterien
1.
## 3. Datenmodell
| Tabelle | Spalte | Typ | Hinweis |
|---|---|---|---|
**RLS:** wer darf was lesen/schreiben – in Klartext.
## 4. Backend
Migrationen, RPCs/Edge Functions (Ein-/Ausgabe, Fehlerfälle).
## 5. Frontend
Routen, Screens, Provider, Domänenmodelle, Zustände (loading/empty/error/data).
## 6. Plattform-Besonderheiten
## 7. Teststrategie
| AK | Unit | Widget | Integration/E2E | RLS |
|---|---|---|---|---|
## 8. Aufgaben
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
## 9. Risiken & offene Fragen

5
env/ci.example.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"SUPABASE_URL": "http://localhost:54321",
"SUPABASE_ANON_KEY": "public-anon-key-placeholder",
"APP_ENV": "ci"
}

View file

@ -0,0 +1,249 @@
<!-- Quelle: Claude Docs „Shuffle – Anforderungen (Entwurf)“, Export 2026-11-02. Diese Datei ist ab jetzt die Quelle der Wahrheit. -->
# Shuffle – Anforderungen (Entwurf)
2026-11-01 · Chris
Entwurf zur Abstimmung: Deine 18 Punkte als 12 Epics mit Story-Titeln, einem Vorschlag für den Release-Schnitt und den Fragen, die vor der Architektur geklärt werden müssen. Akzeptanzkriterien schreiben wir erst für die Epics, die in den ersten Release kommen.
## Entscheidungen (Stand 2. November 2026)
Die Grundsatzfragen F1 bis F7, drei Detailfragen für R1 und der Release-Schnitt sind entschieden. Die Tabelle ist die Grundlage für die Architektur; weiter unten betreffen nur noch Detailfragen die einzelnen Stories.
| Nr | Entscheidung | Folge |
| --- | --- | --- |
| F1 | Markt und Sprachen: DACH, Deutsch und Englisch ab Start | Postleitzahlen für Deutschland, Österreich und Schweiz; zweisprachige Oberfläche von Anfang an |
| F2 | Katalog: BGG-Lizenz beantragen, den Katalog aber BGG-unabhängig bauen | Eigene Spiel-IDs, die BGG-Kennung nur als Zusatzfeld; ohne Lizenz läuft die App weiter. Laut [BGG-Bedingungen](https://boardgamegeek.com/wiki/page/XML_API_Terms_of_Use) und [Nutzungsleitfaden](https://boardgamegeek.com/using_the_xml_api) ist die Lizenz für kommerzielle Apps nötig, „Powered by BGG“ ist Pflicht, Anfragen laufen über den Server mit Zwischenspeicher |
| F3 | Offline: nur das Erfassen von Partien | Lokale Speicherung und Abgleich nur für Partien |
| F4 | Premium: zunächst keine Premium-Funktionen, gegebenenfalls eine Spendenmöglichkeit | KI-Erkennung und Epic PRE sind zurückgestellt; laut BGG-Leitfaden braucht auch eine reine Spenden-App eine kommerzielle Lizenz, die vermutlich kostenlos ist |
| F5 | Standort: Stadt ist für andere sichtbar | Stadt wird aus der PLZ abgeleitet und angezeigt, die PLZ selbst bleibt verborgen. Ob Nutzer die Stadtangabe abschalten können, regelt KTO-04 |
| F6 | Händler und Vereine: manuelle Prüfung durch dich, kostenlos | Freigabe-Ablauf im Admin-Bereich (QUE-08) |
| F7 | Plattformen: Mobile zuerst, Web mit demselben Kern | Eine Codebasis, alle Kernfunktionen auch im Web |
| F8 | Mindestalter: ab 16 | Altersbestätigung bei der Registrierung (KTO-01); rechtliche Prüfung vor dem Start |
| F9 | BGG-Sync: nur Import, manuell auslösbar | Kein Zurückschreiben, keine automatischen Hintergrundabrufe (SAM-07 bis SAM-09) |
| F11 | Partien: Gäste ohne Konto möglich, verknüpfte Nutzer müssen bestätigen | In R1 nur Gäste mit Namen; Verknüpfung und Bestätigung kommen mit R2 (PAR-02) |
| S1 | BGG-Import bleibt in R1, wird aber als Letztes gebaut | Kommt die Lizenz nicht rechtzeitig, startet R1 ohne Import und er wird nachgeliefert |
| S2 | KI-Erkennung aus Fotos ist zurückgestellt, bis die Finanzierung geklärt ist | SAM-10 und Epic PRE ruhen vorerst |
| S3 | Reihenfolge: erst Gruppen (R2), dann Community (R3) | Gruppen liefern echte Nutzung am Spieltisch, bevor Shelfies und Reviews Inhalte brauchen |
| F18 | Startseite: persönliches Dashboard bis R3, News-Seite ab R4 | Dashboard zeigt zuletzt gespielt, Wunschliste, nächste Termine |
**Aufgabe für dich:** BGG-Anwendung registrieren und die Lizenz beantragen, damit die Antwort da ist, wenn R1 fertig wird.
## Epics im Überblick
Zwölf Epics tragen deine 18 Punkte; Katalog, Premium und Querschnitt folgen aus deiner Liste, stehen aber nicht ausdrücklich darin. Die Spalte „Release“ ist mein Vorschlag (Begründung im nächsten Abschnitt), R1 bis R4 stehen für die vier Ausbaustufen.
| Kürzel | Epic | Aus deiner Liste | Release |
| --- | --- | --- | --- |
| KTO | Konto & Profil | Konto je Nutzer, Händler, Verein; Profil wie bei Steam mit Lieblingsspielen; BGG-, BGA-, Steam-, Discord-Name und Link; Wohnort per PLZ | R1 Basis, R3 Profil, R4 Händler und Vereine |
| KAT | Spielekatalog | abgeleitet: Stammdaten aller Spiele, Grundlage für Sammlung, Partien, Reviews und Zubehör | R1 |
| SAM | Sammlung & Listen | Pflege der Spielesammlung, Einkaufs- und Wunschliste, Sync mit BGG, KI-Erkennung aus Fotos | R1, KI-Erkennung zurückgestellt |
| PAR | Partien & Statistik | Tracking gespielter Partien, Analyse des Spielverhaltens, Spielerranking | R1 Tracking und Statistik, R3 Ranking |
| GRP | Gruppen & Treffs | Spielegruppen, Spieltreffs, Mitspielersuche im Umkreis, Spielabstimmung, Terminplanung | R2 |
| COM | Community | Shelfies, Reviews von Spielen auch anderer Nutzer | R3 |
| NEW | News | News-Seite als Startseite für Feeds und Händler | R4 |
| EVT | Events | Kalender mit Turnieren und Treffen in der Nähe, von Händlern und Vereinen einstellbar | R4 |
| KSG | Crowdfunding | Kickstarter- und Gamefound-Liste mit Watchlist, gebackten Spielen, Status | flexibel, früh möglich |
| ZUB | Zubehör | Sleeves je Spiel, 3D-Prints für Inserts | später, erst Datenquelle klären |
| PRE | Premium | Premium-Funktion der KI-Erkennung, Abo und Freischaltung | zurückgestellt, vorerst höchstens eine Spendenfunktion |
| QUE | Querschnitt | abgeleitet: Benachrichtigungen, Melden und Moderation, Datenschutz, Rollen und Rechte | laufend ab R1 |
## Vorschlag für den Release-Schnitt
Ich würde mit dem starten, was ein Nutzer allein verwenden kann, und die Community-Funktionen danach aufsetzen, weil sie erst mit vielen Nutzern Wert haben. Das ist ein Vorschlag, die Entscheidung liegt bei dir.
1. **R1 Solo-Nutzen:** Konto, Spielekatalog, Sammlung mit Wunsch- und Einkaufsliste, BGG-Import (zuletzt gebaut, abhängig von der BGG-Lizenz), Partien erfassen, persönliche Statistik. Die App ist ab dem ersten Nutzer nützlich, und das Datenmodell für alles Weitere entsteht hier.
2. **R2 Gemeinsam spielen:** Gruppen und Spieltreffs, Terminplanung mit Spielabstimmung, Umkreissuche per PLZ, Mitspieler in Partien verknüpfen, Benachrichtigungen.
3. **R3 Community:** Shelfies, Reviews, ausgebautes Profil mit externen Namen und Lieblingsspielen, Spielerranking, Melden und Moderation.
4. **R4 Ökosystem:** Händler- und Vereinskonten, Event-Kalender, News-Startseite, optional eine Spendenfunktion (Premium und KI-Erkennung sind zurückgestellt).
**Warum diese Reihenfolge**
- **Kaltstart:** Shelfies, Reviews, Mitspielersuche und Ranking sind leer, solange es kaum Nutzer gibt. Sammlung und Partien-Tracking helfen auch allein und holen die Nutzer, die später die Inhalte liefern.
- **BGG-Import statt KI-Foto in R1:** Der Import bringt eine bestehende Sammlung ohne Tippen in die App und löst damit das Onboarding. Die Foto-Erkennung verursacht Kosten pro Aufruf und ist ohne Premium vorerst zurückgestellt, bis die Finanzierung geklärt ist.
- **Händler, Vereine, Events, News:** Sie brauchen Verifizierung, Inhalte von außen und Moderation, also deutlich mehr Betriebsaufwand. Das lohnt sich erst, wenn der Kern trägt.
- **Crowdfunding-Liste:** Sie ist von Hand pflegbar, allein nutzbar und technisch einfach, passt also als Füllstück in R1 oder R2.
- **Zubehör (Sleeves, Inserts):** Der Nutzen hängt komplett an der Datenbasis. Ohne Quelle für Sleeve-Größen bleibt die Funktion leer, deshalb zuerst die Frage der Datenquelle klären.
**Startseite:** In R1 bis R3 wäre die Startseite ein persönliches Dashboard (zuletzt gespielt, Wunschliste, nächste Termine). Ab R4 wird daraus die News-Seite, die du als Startseite möchtest. Ob das für dich passt, steht unter den offenen Fragen.
## Stories: Fundament und Kern
Konto, Katalog, Sammlung und Partien bilden R1. Stories ohne Zusatz gehören in den Release des Epics; Abweichungen stehen in Klammern. Aus den Titeln werden später Forgejo-Issues mit Akzeptanzkriterien.
### KTO – Konto & Profil
- **KTO-01** Registrieren, anmelden und Passwort zurücksetzen (E-Mail, optional Apple oder Google)
- **KTO-02** Profil bearbeiten: Anzeigename, Avatar, Kurzbeschreibung, Lieblingsspiele/Genres
- **KTO-03** Wohnort per PLZ angeben und festlegen, was andere davon sehen (R2)
- **KTO-04** Privatsphäre je Bereich einstellen: privat, Freunde, öffentlich
- **KTO-05** Konto und Daten löschen, Daten exportieren
- **KTO-06** Lieblingsspiele im Profil festlegen (R3)
- **KTO-07** Youtube-, BGG-, BGA-, Steam- und Discord-Name mit Link hinterlegen (R3)
- **KTO-08** Profile anderer Nutzer ansehen (R3)
- **KTO-09** Händler- oder Vereinskonto registrieren, mit Adresse, Logo, Öffnungszeiten (R4)
- **KTO-10** Händler und Vereine verifizieren lassen (R4)
### KAT – Spielekatalog
- **KAT-01** Spiele suchen nach Titel, Verlag, Spielerzahl, Dauer, Alter
- **KAT-02** Spieldetailseite mit Cover, Eckdaten und Beschreibung
- **KAT-03** Erweiterungen und Editionen eines Spiels abbilden
- **KAT-04** Fehlendes Spiel melden oder anlegen
- **KAT-05** Katalogdaten aus der Quelle laden und aktuell halten
### SAM – Sammlung & Listen
- **SAM-01** Spiel zur Sammlung hinzufügen
- **SAM-02** Eigene Angaben pro Spiel: Zustand, Kaufdatum und -preis, Notiz, Standort, Tags
- **SAM-03** Sammlung durchsuchen, filtern und sortieren
- **SAM-04** Wunschliste mit Priorität führen
- **SAM-05** Einkaufsliste führen: geplanter Kauf, Händler, Preis
- **SAM-06** Spiel von der Wunsch- oder Einkaufsliste in die Sammlung übernehmen
- **SAM-07** BGG-Konto verknüpfen
- **SAM-08** Sammlung und Wunschliste aus BGG importieren
- **SAM-09** BGG-Import wiederholen und Änderungen abgleichen
- **SAM-10** Spiele aus einem Foto erkennen: Foto aufnehmen, Vorschläge prüfen, übernehmen (zurückgestellt, bis die Finanzierung geklärt ist)
### PAR – Partien & Statistik
- **PAR-01** Partie erfassen: Spiel, Datum, Dauer, Ort, Notiz
- **PAR-02** Mitspieler hinzufügen: andere Nutzer, Gruppenmitglieder, Gäste ohne Konto
- **PAR-03** Ergebnis erfassen: Punkte, Platzierung, Sieger, kooperativ und in Teams
- **PAR-04** Wertungsbogen je Spiel mit frei definierbaren Kategorien
- **PAR-05** Partie bearbeiten und löschen
- **PAR-06** Partienverlauf ansehen und filtern
- **PAR-07** Persönliche Statistik: gespielte Spiele, Partien, Stunden, Siegquote, Verlauf über die Zeit
- **PAR-08** Statistik je Spiel: Partien, durchschnittliche Dauer, Bestwerte, häufigste Mitspieler
- **PAR-09** Gruppenstatistik (R2)
- **PAR-10** Spielerranking (R3, Umfang offen)
## Stories: Gemeinsam spielen und Community
Gruppen, Termine und Mitspielersuche bilden R2, Shelfies und Reviews R3. Was ich aus deiner Liste abgeleitet habe, ohne dass es dort steht, ist als „abgeleitet“ markiert.
### GRP – Gruppen & Treffs (R2)
- **GRP-01** Gruppe erstellen: Name, Bild, Beschreibung, offen oder nur auf Einladung
- **GRP-02** Mitglieder per Link einladen, Beitrittsanfragen annehmen oder ablehnen
- **GRP-03** Rollen in der Gruppe: Admin und Mitglied
- **GRP-04** Gruppenübersicht mit Mitgliedern, nächsten Terminen und Statistik
- **GRP-05** Gemeinsame Sammlung der Gruppe: wer besitzt welches Spiel
- **GRP-06** Spieltreff anlegen: Ort, Rhythmus, Beschreibung, öffentlich auffindbar
- **GRP-07** Gruppen und Spieltreffs im Umkreis finden (per PLZ)
- **GRP-08** Mitspieler im Umkreis finden und anfragen
- **GRP-09** Termin vorschlagen mit mehreren Datumsoptionen, Mitglieder stimmen ab
- **GRP-10** Termin festlegen, Zu- und Absagen, Erinnerung
- **GRP-11** Spielabstimmung: Spiele aus der Gruppensammlung vorschlagen und abstimmen, filterbar nach Spielerzahl und Dauer
- **GRP-12** Termin in den Gerätekalender exportieren
- **GRP-13** Gruppe verlassen oder auflösen
### COM – Community (R3)
- **COM-01** Shelfie hochladen: Foto, Beschreibung, optional Spiele markieren
- **COM-02** Shelfies als Raster durchstöbern und filtern
- **COM-03** Shelfies liken, kommentieren und speichern
- **COM-04** Nutzern folgen und Freunde hinzufügen (abgeleitet)
- **COM-05** Review zu einem Spiel schreiben: Bewertung und Text
- **COM-06** Reviews anderer lesen, sortieren und als hilfreich markieren
- **COM-07** Eigene Reviews und Shelfies bearbeiten und löschen
- **COM-08** Inhalte und Nutzer melden (abgeleitet, siehe QUE)
- **COM-09** Spiele auf Shelfies per KI erkennen und mit dem Katalog verknüpfen (meine Idee, nicht in deiner Liste, Premium, frühestens R4)
## Stories: Ökosystem, Zubehör und Querschnitt
News und Events bilden R4, Premium ist zurückgestellt. Crowdfunding und Zubehör sind eigenständig und lassen sich dort einschieben, wo Platz ist. Der Querschnitt (QUE) ist abgeleitet und wird ab R1 mitgebaut, soweit der jeweilige Release ihn braucht.
### NEW – News (R4)
- **NEW-01** Startseite mit News-Feed anzeigen
- **NEW-02** Feeds externer Quellen einbinden (Verlage, Blogs, Medien), mit Titel, Teaser und Link
- **NEW-03** Beiträge und Angebote von Händlern anzeigen
- **NEW-04** Feed anpassen: Quellen ein- und ausblenden
- **NEW-05** Quellen verwalten (Admin)
- **NEW-06** Händler veröffentlichen eigene Beiträge, nach Verifizierung
### EVT – Events (R4)
- **EVT-01** Event-Kalender ansehen; der Umkreis wird aktiv zugeschaltet
- **EVT-02** Eventdetails: Ort, Zeit, Veranstalter, Beschreibung, Karte
- **EVT-03** Event merken oder zusagen, Erinnerung erhalten
- **EVT-04** Händler oder Verein erstellt und verwaltet Events; Änderung oder Absage benachrichtigt Interessierte
- **EVT-05** Event in den Gerätekalender exportieren
- **EVT-06** Nutzer schlägt ein Event vor (offen, siehe Fragen)
### KSG – Crowdfunding (flexibel)
- **KSG-01** Kampagne erfassen: Plattform (Kickstarter oder Gamefound), Name, Link
- **KSG-02** Watchlist mit beobachteten Kampagnen und Enddatum
- **KSG-03** Gebackte Spiele mit Pledge, Add-ons und Status (z. B. gebackt, in Produktion, versendet, geliefert)
- **KSG-04** Erinnerung bei Kampagnenende und Lieferung
- **KSG-05** Geliefertes Spiel in die Sammlung übernehmen
### ZUB – Zubehör (später)
- **ZUB-01** Kartensätze eines Spiels mit Sleeve-Größe und Anzahl anzeigen
- **ZUB-02** Eigene verwendete Sleeves je Spiel eintragen
- **ZUB-03** Passende Sleeves vorschlagen
- **ZUB-04** Sleeve-Bedarf für Sammlung oder Einkaufsliste berechnen (abgeleitet)
- **ZUB-05** 3D-Print-Inserts je Spiel verlinken: Modell, Quelle, Lizenz
- **ZUB-06** Insert-Links beisteuern und bewerten
### PRE – Premium (zurückgestellt)
- **PRE-01** Premium abschließen, verwalten, kündigen
- **PRE-02** Premium-Funktionen freischalten und in der App kenntlich machen
- **PRE-03** Nutzungsgrenzen der KI-Erkennung anzeigen
### QUE – Querschnitt (abgeleitet)
- **QUE-01** Benachrichtigungen per Push und E-Mail, einstellbar je Kategorie
- **QUE-02** Inhalte und Nutzer melden, Nutzer blockieren
- **QUE-03** Moderationsansicht für Admins: Meldungen prüfen, sperren, löschen
- **QUE-04** Datenschutz: Einwilligungen, Datenschutzerklärung, Löschfristen
- **QUE-05** Mehrsprachigkeit Deutsch und Englisch
- **QUE-06** Barrierefreiheit
- **QUE-07** Rollen und Rechte: Nutzer, Händler, Verein, Admin
- **QUE-08** Admin-Bereich für Katalog, News-Quellen und Verifizierung
## Offene Fragen
F1 bis F9, F11 und F18 sind entschieden (siehe Entscheidungen oben). Die folgenden Fragen können warten, bis die jeweilige Story drankommt. Die Spalte „Mein Vorschlag“ ist das, wovon ich ausgehe, solange du nichts anderes sagst.
### Vor den jeweiligen Stories klären
| Nr | Frage | Mein Vorschlag | Betrifft |
| --- | --- | --- | --- |
| F10 | Mitspielersuche: Wie nimmt man Kontakt auf? | Strukturierte Anfrage mit kurzer Nachricht, kein freier Chat in R2; Blockieren und Melden sind Pflicht | GRP-08, QUE-02 |
| F12 | Ranking: Global, in Gruppen oder pro Spiel? | Zunächst nur in Gruppen und pro Spiel, kein globales Ranking wegen Manipulation und Datenschutz | PAR-10 |
| F13 | Shelfies: Welche Interaktion, welche Moderation, welche Limits? | Likes, Kommentare, Melden; Prüfung nachträglich; Limit pro Nutzer wegen Speicher | COM-01 bis COM-03 |
| F14 | Reviews: Welche Skala, ist Text Pflicht? | 1 bis 10 Punkte wie bei BGG, Text optional | COM-05, COM-06 |
| F15 | Kalender „muss aktiv angewählt werden“: Ist der Umkreis-Kalender standardmäßig aus und wird bewusst eingeschaltet? | Ja, so verstehe ich es | EVT-01 |
| F16 | Dürfen normale Nutzer Events einstellen? | Nein, nur Händler und Vereine | EVT-04, EVT-06 |
| F17 | News: Welche Quellen, wie mit Händlerbeiträgen und Werbung umgehen? | RSS mit Titel, Teaser, Link; Händlerbeiträge nur von verifizierten Händlern und als Werbung gekennzeichnet | NEW |
| F18 | Startseite: Dashboard bis R3, News ab R4 (entschieden, siehe oben) | Ja | NEW-01 |
| F19 | Crowdfunding: von Hand pflegen oder automatisch abgleichen? | Von Hand mit Link; prüfen, ob es verlässliche Schnittstellen gibt | KSG |
| F20 | Sleeve-Daten: Woher kommen sie? | Nutzer tragen sie ein, wir starten mit Daten für beliebte Spiele | ZUB-01 bis ZUB-03 |
| F21 | 3D-Prints: nur verlinken oder Dateien hochladen? | Nur verlinken (Urheberrecht, Speicher) | ZUB-05, ZUB-06 |
## Was das für die Architektur bedeutet
Die Features laufen auf ein relationales Datenmodell mit sauberen Zugriffsregeln hinaus, ergänzt um Hintergrundjobs und Bildspeicher. Das spricht eher für ein relationales Backend wie das vorgeschlagene Supabase, entschieden wird aber erst in der Architekturphase. Die Tabelle ist die Merkliste dafür.
| Feature | Folge für die Architektur |
| --- | --- |
| Katalog (KAT, SAM, ZUB) | Zentrale Stammdaten, auf die alles andere verweist; zu klären ist, ob wir eine Kopie halten oder die Quelle live abfragen (F2) |
| BGG-Sync | Hintergrundjobs mit begrenzter Abrufhäufigkeit und Abgleichlogik für Änderungen |
| Umkreissuche per PLZ | Tabelle mit PLZ-Koordinaten aus offenen Geodaten, räumliche Abfragen in der Datenbank, Stadt wird aus der PLZ abgeleitet und angezeigt, die PLZ selbst bleibt durch Zugriffsregeln verborgen (F5) |
| Konten für Nutzer, Händler, Vereine | Rollen- und Rechtemodell, Verifizierungsstatus, Admin-Bereich |
| Gruppen, Termine, Abstimmung | Mehrnutzer-Daten mit Zugriff nach Gruppenrolle, Benachrichtigungen, Kalender-Export |
| Shelfies und Avatare | Bildspeicher mit Verkleinerung, Größenlimits, Moderation und Speicherkosten |
| KI-Erkennung | Bildmodell (eigenes oder externer Dienst), Kosten je Aufruf, Verarbeitung der Fotos in der EU, Kontingente (Finanzierung noch offen) |
| News und Feeds | Regelmäßiger Abruf externer Quellen, Zwischenspeicher, Quellenverwaltung |
| Benachrichtigungen | Push für iOS, Android und Web sowie E-Mail, Einstellungen je Nutzer |
| Premium | zurückgestellt; bei einer Spendenfunktion gelten die Store-Regeln (F4) |
| Partien offline erfassen | Lokale Speicherung auf dem Gerät und Abgleich bei nächster Verbindung (F3) |
| Statistik | Auswertungen über viele Partien; anfangs reichen Datenbankabfragen |
**Nächster Schritt:** F1 bis F7 und der Release-Schnitt sind entschieden. Du beantragst die BGG-Lizenz, ich schreibe die Akzeptanzkriterien für R1, danach gehen wir in die Architektur (ADR-0001 und folgende).

View file

@ -0,0 +1,318 @@
<!-- Quelle: Claude Docs „Shuffle – R1 Akzeptanzkriterien“, Export 2026-11-02. Kriterien werden als „KTO-01 Kriterium 3“ referenziert. -->
# Shuffle – R1 Akzeptanzkriterien
2026-11-02 · Chris
Akzeptanzkriterien für die 26 Stories von R1 im Format Gegeben/Wenn/Dann, abgeleitet aus dem [Anforderungsdokument](https://claude.ai/artifact/NvqzTVrcd2aA3uqwwnG1ry) und den dort getroffenen Entscheidungen. Jedes Kriterium soll sich als Test prüfen lassen; Zahlenwerte sind meine Annahmen und änderbar.
## Geltungsbereich und Annahmen
R1 umfasst 26 Stories aus vier Epics. Alles, was andere Nutzer sehen könnte, kommt erst mit späteren Releases; in R1 sind alle Daten privat.
| Epic | In R1 | Nicht in R1 |
| --- | --- | --- |
| KTO Konto & Profil | KTO-01, KTO-02, KTO-04, KTO-05 | KTO-03 (R2), KTO-06 bis KTO-08 (R3), KTO-09 und KTO-10 (R4) |
| KAT Spielekatalog | KAT-01 bis KAT-05 | – |
| SAM Sammlung & Listen | SAM-01 bis SAM-09 | SAM-10 (zurückgestellt) |
| PAR Partien & Statistik | PAR-01 bis PAR-08 | PAR-09 (R2), PAR-10 (R3) |
**Annahmen, auf denen die Kriterien beruhen**
1. Lieblingsspiele und Genres gehören zum Profil in R1 (KTO-02), wie du es im Dokument ergänzt hast. KTO-06 beschränkt sich später auf die Anzeige im öffentlichen Profil.
2. In R1 sieht niemand fremde Profile, Sammlungen oder Partien. Deshalb besteht KTO-04 in R1 nur aus dem Hinweis, dass alles privat ist.
3. Gast-Mitspieler sind reine Namen ohne Konto (F11); Verknüpfung und Bestätigung folgen mit R2.
4. Alle Funktionen laufen auf iOS, Android und im Web (F7); nur Partien lassen sich offline erfassen (F3).
5. Die Oberfläche ist Deutsch und Englisch (F1), das Mindestalter ist 16 (F8).
6. Der BGG-Import hängt an der Lizenz und wird zuletzt gebaut (S1, F9). Der Katalog ist BGG-unabhängig mit eigenen IDs (F2).
7. Ein Spiel steht höchstens einmal in der Sammlung. Wunschliste heißt „habe ich gern“, Einkaufsliste „wird demnächst gekauft“.
**Lesehilfe:** Jede Story hat nummerierte Kriterien. Sie lassen sich als „KTO-01 Kriterium 3“ ansprechen und werden später zu Tests und zu Haken im Pull Request.
## KTO – Konto & Profil
### KTO-01 Registrieren, anmelden und Passwort zurücksetzen
1. Gegeben ich habe kein Konto, wenn ich E-Mail-Adresse, Passwort und Anzeigenamen eingebe und bestätige, dass ich mindestens 16 Jahre alt bin, dann wird ein Konto angelegt und ich erhalte eine E-Mail zur Bestätigung.
2. Gegeben ich bestätige das Mindestalter nicht, wenn ich mich registrieren will, dann wird die Registrierung abgelehnt und ich sehe den Grund.
3. Gegeben das Passwort hat weniger als 10 Zeichen oder steht auf einer Liste häufiger Passwörter, wenn ich es eingebe, dann erklärt mir die App am Feld, was fehlt.
4. Gegeben die E-Mail-Adresse ist schon registriert, wenn ich mich damit registriere, dann erfahre ich nicht, ob ein Konto existiert, und es entsteht kein zweites Konto.
5. Gegeben ein bestätigtes Konto, wenn ich die richtigen Zugangsdaten eingebe, dann bin ich angemeldet und bleibe es auf diesem Gerät, bis ich mich abmelde.
6. Gegeben ein unbestätigtes Konto, wenn ich mich anmelde, dann werde ich auf die ausstehende Bestätigung hingewiesen und kann die E-Mail erneut senden lassen.
7. Gegeben falsche Zugangsdaten, wenn ich mich anmelde, dann sehe ich eine allgemeine Fehlermeldung, die nicht verrät, ob E-Mail oder Passwort falsch war; nach mehreren Fehlversuchen wird der Zugang zeitweise gebremst.
8. Gegeben ich habe das Passwort vergessen, wenn ich das Zurücksetzen anfordere, dann erhalte ich einen zeitlich begrenzten Link, mit dem ich ein neues Passwort setze; der Link funktioniert nur einmal.
9. Gegeben ich habe das Passwort zurückgesetzt, dann bin ich auf allen anderen Geräten abgemeldet.
### KTO-02 Profil bearbeiten
1. Gegeben ich bearbeite mein Profil, wenn der Anzeigename leer ist oder länger als 30 Zeichen, dann kann ich nicht speichern und sehe den Fehler am Feld.
2. Gegeben ich wähle ein Bild als Avatar (JPG oder PNG, höchstens 5 MB), wenn ich speichere, dann wird es quadratisch zugeschnitten angezeigt; ein zu großes oder falsches Format wird mit Hinweis abgelehnt.
3. Gegeben ich habe keinen Avatar, dann sehe ich einen Platzhalter mit meinen Initialen.
4. Gegeben ich schreibe eine Kurzbeschreibung, wenn sie länger als 300 Zeichen ist, dann kann ich nicht speichern.
5. Gegeben ich suche im Katalog, wenn ich bis zu 5 Spiele als Lieblingsspiele auswähle, dann werden sie in dieser Reihenfolge in meinem Profil gespeichert; ein sechstes wird abgelehnt.
6. Gegeben ich wähle Genres aus einer festen Liste, dann werden sie im Profil gespeichert und lassen sich wieder entfernen.
7. Gegeben ich habe gespeichert, wenn ich mich auf einem anderen Gerät anmelde, dann sehe ich dieselben Profilangaben.
### KTO-04 Privatsphäre (in R1 nur der Hinweis)
1. Gegeben ich öffne die Einstellungen, dann steht dort, dass Profil, Sammlung, Listen und Partien nur für mich sichtbar sind.
2. Gegeben ein späterer Release führt neue Freigaben ein, dann sind neue Bereiche standardmäßig privat, bis ich sie freigebe.
### KTO-05 Konto löschen und Daten exportieren
1. Gegeben ich bin angemeldet, wenn ich „Konto löschen“ wähle, die Folgen bestätige und mein Passwort eingebe, dann bin ich sofort abgemeldet und mein Konto ist nicht mehr nutzbar.
2. Gegeben ich habe mein Konto gelöscht, dann sind Profil, Sammlung, Listen, Partien und Gast-Namen innerhalb der in der Datenschutzerklärung genannten Frist endgültig gelöscht, auch aus den Sicherungen.
3. Gegeben mein Konto ist gelöscht, wenn ich mich anmelden will, dann ist das nicht möglich; dieselbe E-Mail-Adresse kann ich neu registrieren.
4. Gegeben ich fordere einen Datenexport an, wenn er fertig ist, dann erhalte ich eine Datei in einem maschinenlesbaren Format (JSON oder CSV) mit Profil, Sammlung, Listen und Partien.
5. Gegeben der Export ist fertig, dann ist er nur über mein angemeldetes Konto abrufbar und läuft nach einer begrenzten Zeit ab.
## KAT – Spielekatalog
### KAT-01 Spiele suchen
1. Gegeben der Katalog enthält Spiele, wenn ich einen Titel oder einen Teil davon eingebe, dann erhalte ich passende Treffer nach Relevanz sortiert, unabhängig von Groß- und Kleinschreibung und Sonderzeichen.
2. Gegeben ein Spiel hat einen deutschen und einen englischen Titel, wenn ich nach einem von beiden suche, dann finde ich dasselbe Spiel.
3. Gegeben ich suche nach einem Verlag, dann erhalte ich dessen Spiele.
4. Gegeben eine Trefferliste, wenn ich nach Spielerzahl, Spieldauer oder Mindestalter filtere, dann enthält sie nur Spiele, die zu allen gewählten Filtern passen.
5. Gegeben mehr Treffer als auf eine Seite passen, wenn ich bis zum Ende scrolle, dann werden weitere nachgeladen.
6. Gegeben ich suche ohne Treffer, dann sehe ich einen Hinweis und die Möglichkeit, das Spiel zu melden (KAT-04).
7. Gegeben eine normale Verbindung, dann erscheinen die ersten Treffer innerhalb von 2 Sekunden.
8. Gegeben mehrere Filtereinstellungen wie Genre, Spieleranzahl, Spieldauer etc, mit denen ich neue Spiele entdecken kann
### KAT-02 Spieldetailseite
1. Gegeben ich öffne ein Spiel, dann sehe ich Titel, Cover, Erscheinungsjahr, Verlag, Spielerzahl, Spieldauer, Mindestalter, Genre, Mechanik, Kategorie und Beschreibung.
2. Gegeben eine Angabe fehlt im Katalog, dann wird sie weggelassen statt als leere Zeile gezeigt.
3. Gegeben das Spiel steht in meiner Sammlung, auf der Wunschliste oder der Einkaufsliste, dann sehe ich den Status und kann ihn von dort ändern.
4. Gegeben ich habe das Spiel nirgends eingetragen, dann kann ich es mit einer Aktion zur Sammlung, Wunschliste oder Einkaufsliste hinzufügen.
5. Gegeben Angaben stammen von BGG, dann zeigt die Seite „Powered by BGG“ mit Link, sobald die Lizenz das verlangt.
6. Gegeben ich habe Partien zu diesem Spiel, dann sehe ich einen Einstieg zur Spielstatistik (PAR-08).
### KAT-03 Erweiterungen und Editionen
1. Gegeben ein Basisspiel hat Erweiterungen, wenn ich seine Detailseite öffne, dann sehe ich die Erweiterungen verlinkt; bei einer Erweiterung sehe ich das Basisspiel.
2. Gegeben ein Spiel gibt es in mehreren Editionen oder Sprachfassungen, dann sind sie als Varianten desselben Spiels erkennbar.
3. Gegeben ich füge ein Spiel mit Varianten zur Sammlung hinzu, dann kann ich die Edition wählen, die ich besitze.
4. Gegeben ich besitze das Basisspiel und füge eine Erweiterung hinzu, dann wird sie in meiner Sammlung beim Basisspiel zugeordnet angezeigt.
5. Gegeben ich besitze eine Erweiterung ohne Basisspiel, dann ist das erlaubt und wird nicht als Fehler behandelt.
### KAT-04 Fehlendes Spiel melden
1. Gegeben ich finde ein Spiel nicht, wenn ich Titel (Pflicht), Verlag, Erscheinungsjahr und optional einen Link angebe, dann wird die Meldung gespeichert und ich erhalte eine Bestätigung.
2. Gegeben es gibt schon einen sehr ähnlichen Katalogeintrag, wenn ich melden will, dann sehe ich ihn zuerst und muss ausdrücklich bestätigen, dass mein Spiel ein anderes ist.
3. Gegeben ich habe ein Spiel gemeldet, dann kann ich es als vorläufigen Eintrag in meine Sammlung oder Listen aufnehmen; der Eintrag ist nur für mich sichtbar.
4. Gegeben die Meldung wurde freigegeben, dann ersetzt der Katalogeintrag meinen vorläufigen Eintrag, ohne dass meine eigenen Angaben verloren gehen.
5. Gegeben die Meldung wurde abgelehnt oder mit einem anderen Eintrag zusammengeführt, dann erfahre ich das und mein vorläufiger Eintrag verweist auf den richtigen Eintrag.
### KAT-05 Katalogdaten laden und aktuell halten
1. Gegeben eine zugelassene Quelle, wenn der Import läuft, dann werden neue Spiele angelegt und geänderte aktualisiert, ohne manuell gepflegte Felder zu überschreiben.
2. Gegeben ein Katalogeintrag, dann hat er eine eigene Shuffle-ID und optional die Kennung der Quelle (zum Beispiel BGG) samt Quellenangabe.
3. Gegeben die Quelle ist nicht erreichbar oder liefert fehlerhafte Daten, dann bleibt der Katalog unverändert, der Fehler wird protokolliert und die App funktioniert normal weiter.
4. Gegeben ein Admin bearbeitet, blendet aus oder führt Einträge zusammen, dann bleiben diese Änderungen beim nächsten Import erhalten.
5. Gegeben Meldungen aus KAT-04, dann kann ein Admin sie freigeben, mit einem Eintrag zusammenführen oder ablehnen.
6. Gegeben Cover und Texte einer Quelle, dann werden sie nur angezeigt, wenn deren Nutzungsbedingungen es erlauben; die Erlaubnis ist je Quelle vermerkt.
## SAM – Sammlung & Listen
### SAM-01 Spiel zur Sammlung hinzufügen
1. Gegeben ich sehe ein Spiel in der Suche oder auf der Detailseite, wenn ich „Zur Sammlung“ wähle, dann erscheint es in meiner Sammlung mit dem Datum des Hinzufügens.
2. Gegeben das Spiel ist schon in meiner Sammlung, dann entsteht kein zweiter Eintrag, sondern ich sehe den vorhandenen.
3. Gegeben ich bin offline, wenn ich ein Spiel hinzufügen will, dann sehe ich einen Hinweis, dass dies eine Verbindung braucht.
4. Gegeben ich suche ein Spiel, dann gelange ich in höchstens zwei Schritten vom Suchergebnis zum Eintrag in der Sammlung.
### SAM-02 Eigene Angaben pro Spiel
1. Gegeben ein Spiel in meiner Sammlung, wenn ich es bearbeite, dann kann ich Zustand (aus fester Liste), Kaufdatum, Kaufpreis in Euro, Notiz (bis 1000 Zeichen), Standort (Freitext) und Tags erfassen.
2. Gegeben ich lasse Felder leer, dann ist das erlaubt; alle Angaben sind optional.
3. Gegeben das Kaufdatum liegt in der Zukunft oder der Preis ist negativ, dann kann ich nicht speichern und sehe den Fehler am Feld.
4. Gegeben ich vergebe Tags, dann schlägt die App meine bisherigen Tags vor, damit sich Schreibweisen nicht verdoppeln.
5. Gegeben ich ändere Angaben, dann bleiben sie beim BGG-Import erhalten (SAM-09).
### SAM-03 Sammlung durchsuchen, filtern und sortieren
1. Gegeben meine Sammlung, wenn ich einen Titel eingebe, dann zeigt die Liste nur passende Spiele.
2. Gegeben ich filtere nach Spielerzahl, Spieldauer, Tags, Standort oder Zustand, dann zeigt die Liste nur Spiele, die zu allen Filtern passen, und ich sehe die Zahl der Treffer.
3. Gegeben ich sortiere nach Titel, Hinzufügedatum, Erscheinungsjahr oder Spieldauer, dann ändert sich die Reihenfolge entsprechend.
4. Gegeben meine Sammlung hat 1000 Spiele, dann bleibt Scrollen, Suchen und Filtern flüssig.
5. Gegeben meine Sammlung ist leer, dann sehe ich einen Hinweis mit dem Weg zum Hinzufügen und, sofern verfügbar, zum BGG-Import.
### SAM-04 Wunschliste mit Priorität
1. Gegeben ein Spiel, wenn ich es auf die Wunschliste setze, dann kann ich eine Priorität (hoch, mittel, niedrig) und eine Notiz angeben.
2. Gegeben das Spiel ist schon in meiner Sammlung, dann sehe ich einen Hinweis, kann es aber trotzdem auf die Wunschliste setzen, etwa für eine andere Edition.
3. Gegeben meine Wunschliste, dann kann ich nach Priorität oder Datum sortieren.
4. Gegeben ich entferne ein Spiel von der Wunschliste, dann kann ich das für einige Sekunden rückgängig machen.
### SAM-05 Einkaufsliste führen
1. Gegeben ein Spiel, wenn ich es auf die Einkaufsliste setze, dann kann ich Händler (Freitext), geplanten Preis in Euro und einen Link angeben.
2. Gegeben die Einkaufsliste, dann sehe ich die Summe der geplanten Preise.
3. Gegeben ich hake ein Spiel als gekauft ab, dann fragt mich die App, ob ich es in die Sammlung übernehmen will (SAM-06).
4. Gegeben ich entferne ein Spiel von der Einkaufsliste, dann kann ich das für einige Sekunden rückgängig machen.
### SAM-06 In die Sammlung übernehmen
1. Gegeben ein Spiel auf der Wunsch- oder Einkaufsliste, wenn ich es übernehme, dann steht es in der Sammlung und ist von der Liste verschwunden.
2. Gegeben das Spiel hatte Notiz oder geplanten Preis, dann werden Notiz und Preis übernommen, der Preis als Kaufpreis; als Kaufdatum steht heute und ist änderbar.
3. Gegeben das Spiel ist schon in der Sammlung, dann entsteht kein Duplikat und ich sehe einen Hinweis.
4. Gegeben ich habe übernommen, dann kann ich das für einige Sekunden rückgängig machen.
### SAM-07 BGG-Konto verknüpfen (abhängig von der Lizenz)
1. Gegeben ich gebe meinen BGG-Nutzernamen ein, wenn BGG ihn kennt, dann ist er mit meinem Konto verknüpft und im Profil gespeichert, in R1 nicht öffentlich.
2. Gegeben BGG kennt den Namen nicht, dann sehe ich eine verständliche Fehlermeldung.
3. Gegeben die BGG-Sammlung lässt sich nicht abrufen, dann erklärt mir die App, was ich auf der BGG-Seite prüfen kann.
4. Gegeben ich löse die Verknüpfung, dann wird der Name entfernt und bereits importierte Spiele bleiben erhalten.
5. Gegeben die Verknüpfung, dann fragt und speichert die App niemals mein BGG-Passwort.
### SAM-08 Sammlung und Wunschliste aus BGG importieren
1. Gegeben ein verknüpftes BGG-Konto, wenn ich den Import starte, dann sehe ich den Fortschritt und danach eine Zusammenfassung: übernommen, bereits vorhanden, nicht gefunden.
2. Gegeben der Import, dann werden Spiele, die ich bei BGG besitze, in meine Sammlung übernommen und Spiele meiner BGG-Wunschliste auf meine Wunschliste.
3. Gegeben ein BGG-Spiel ist im Katalog über seine BGG-Kennung bekannt, dann wird es diesem Eintrag zugeordnet.
4. Gegeben ein BGG-Spiel fehlt im Katalog, dann steht es in der Liste der nicht gefundenen Spiele, und ich kann es melden (KAT-04).
5. Gegeben ich breche den Import ab oder er schlägt fehl, dann bleibt mein Bestand in einem konsistenten Zustand: nichts ist halb übernommen.
6. Gegeben BGG antwortet mit einem Fehler oder einer Begrenzung, dann sehe ich eine verständliche Meldung und kann es später erneut versuchen.
7. Gegeben der Importdialog, dann zeigt er „Powered by BGG“ mit Link.
### SAM-09 BGG-Import wiederholen und abgleichen
1. Gegeben der Import lief schon einmal, wenn ich ihn erneut starte, dann erkennt die App nur die Änderungen: neue Spiele, bei BGG entfernte Spiele und Wechsel zwischen Sammlung und Wunschliste.
2. Gegeben Änderungen wurden erkannt, dann sehe ich sie als Liste und übernehme sie erst nach meiner Bestätigung.
3. Gegeben der Abgleich, dann werden meine eigenen Angaben (SAM-02) nie überschrieben.
4. Gegeben ich habe ein Spiel in Shuffle selbst hinzugefügt, dann wird es nicht entfernt, nur weil es bei BGG fehlt.
5. Gegeben ein Spiel wurde bei BGG entfernt, dann wird es in Shuffle nur nach meiner Bestätigung entfernt.
6. Gegeben in Shuffle sind Spiele aufgeführt, die in BGG fehlen, dann werden diese Spiele hervorgehoben, damit der User diese ggf. in BGG nachpflegen kann
## PAR – Partien & Statistik
### PAR-01 Partie erfassen
1. Gegeben ich erfasse eine Partie, wenn ich ein Spiel und ein Datum angebe (Standard: heute), dann wird sie gespeichert und erscheint im Verlauf; Dauer in Minuten, Ort und Notiz sind optional.
2. Gegeben das Datum liegt in der Zukunft, dann kann ich nicht speichern.
3. Gegeben ich wähle das Spiel, dann werden Spiele aus meiner Sammlung zuerst angeboten, weitere über die Katalogsuche.
4. Gegeben ich bin offline, dann wird die Partie lokal gespeichert, ist als „noch nicht synchronisiert“ gekennzeichnet und wird bei Verbindung automatisch übertragen.
5. Gegeben eine nicht synchronisierte Partie, wenn ich die App schließe oder das Gerät neu starte, dann geht sie nicht verloren.
6. Gegeben ich bin offline, dann kann ich Spiele aus meiner Sammlung wählen; die Katalogsuche braucht eine Verbindung und zeigt das als Hinweis an.
7. Gegeben die Übertragung einer Offline-Partie schlägt wiederholt fehl, dann bleibt sie erhalten, und ich sehe, dass sie noch aussteht.
### PAR-02 Mitspieler hinzufügen
1. Gegeben ich erfasse eine Partie, dann bin ich standardmäßig als Mitspieler eingetragen und kann mich entfernen.
2. Gegeben ich füge einen Gast mit Namen hinzu, dann braucht er kein Konto; der Name ist nur für mich sichtbar.
3. Gegeben ich habe einen Gast schon einmal eingetragen, dann wird er mir bei der nächsten Partie vorgeschlagen.
4. Gegeben ein Mitspielername kommt in der Partie schon vor, dann kann ich ihn nicht doppelt hinzufügen und sehe einen Hinweis.
5. Gegeben eine Partie, dann kann sie bis zu 20 Mitspieler haben und mindestens einen.
6. Gegeben ich bearbeite den Namen eines Gastes in der Gästeliste, dann ändert er sich in meinen bisherigen Partien mit.
### PAR-03 Ergebnis erfassen
1. Gegeben eine Partie, wenn ich je Mitspieler eine Punktzahl eintrage (ganze oder Dezimalzahl, auch negativ), dann wird die Platzierung automatisch daraus abgeleitet.
2. Gegeben zwei Mitspieler haben gleich viele Punkte, dann teilen sie sich den Platz.
3. Gegeben ein Spiel ohne Punkte, wenn ich Platzierungen oder nur den Sieger angebe, dann wird das Ergebnis so gespeichert.
4. Gegeben ein kooperatives Spiel, wenn ich es als kooperativ markiere, dann erfasse ich nur „gewonnen“ oder „verloren“ für alle gemeinsam.
5. Gegeben ein Teamspiel, wenn ich Mitspieler Teams zuordne, dann gilt das Ergebnis je Team, und in der Statistik zählt ein Sieg für alle Mitglieder des Teams.
6. Gegeben ich lasse das Ergebnis leer, dann wird die Partie ohne Ergebnis gespeichert; sie zählt in der Statistik als gespielt, aber nicht für Siegquote und Bestwerte.
7. Gegeben ich überschreibe die automatisch berechnete Platzierung, dann gilt meine Eingabe.
### PAR-04 Wertungsbogen je Spiel
1. Gegeben ich lege für ein Spiel Wertungskategorien an (zum Beispiel Siedlungen, Straßen), dann kann ich je Mitspieler und Kategorie Punkte eintragen, und die Summe wird automatisch gebildet.
2. Gegeben ich habe Kategorien einmal angelegt, wenn ich dieses Spiel wieder erfasse, dann werden sie mir vorgeschlagen, in der gespeicherten Reihenfolge.
3. Gegeben ich ändere die Kategorien, dann behalten bereits erfasste Partien ihre Werte.
4. Gegeben ich will keinen Wertungsbogen, dann kann ich die Gesamtpunktzahl direkt eintragen (PAR-03).
5. Gegeben Wertungsbögen, dann sind sie in R1 nur für mich sichtbar.
### PAR-05 Partie bearbeiten und löschen
1. Gegeben eine gespeicherte Partie, wenn ich sie öffne, dann kann ich alle Angaben ändern.
2. Gegeben ich lösche eine Partie, wenn ich die Rückfrage bestätige, dann ist sie weg und zählt nicht mehr in Verlauf und Statistik.
3. Gegeben ich ändere oder lösche eine Partie, dann stimmt die Statistik sofort danach.
4. Gegeben eine noch nicht synchronisierte Partie, dann kann ich sie bearbeiten und löschen wie jede andere.
### PAR-06 Partienverlauf
1. Gegeben meine Partien, dann sehe ich sie als Liste, neueste zuerst, mit Spiel, Datum, Mitspielern und Sieger.
2. Gegeben ich filtere nach Spiel, Zeitraum, Mitspieler oder Ort, dann zeigt die Liste nur passende Partien.
3. Gegeben ich tippe eine Partie an, dann sehe ich alle Details inklusive Ergebnis und Wertungsbogen.
4. Gegeben ich habe noch keine Partie erfasst, dann sehe ich einen Hinweis mit dem Weg zur ersten Partie.
5. Gegeben sehr viele Partien, dann werden weitere beim Scrollen nachgeladen.
### PAR-07 Persönliche Statistik
1. Gegeben ich wähle einen Zeitraum (gesamt, Jahr, Monat oder frei), dann sehe ich Anzahl der Partien, Anzahl verschiedener gespielter Spiele, Gesamtspielzeit in Stunden und Siegquote.
2. Gegeben die Siegquote, dann ist sie der Anteil der Partien mit Platz 1 an allen Partien mit Ergebnis; ein geteilter erster Platz zählt als Sieg.
3. Gegeben Partien ohne Dauer, dann zählen sie nicht in die Gesamtspielzeit, und ich sehe, wie viele es sind.
4. Gegeben ich sehe die meistgespielten Spiele, dann gibt es zwei Ranglisten (Top 10): nach Anzahl der Partien und nach Spielzeit.
5. Gegeben der Verlauf, dann sehe ich die Partien pro Monat als Diagramm.
6. Gegeben die Statistik, dann stimmen ihre Zahlen mit dem Partienverlauf (PAR-06) im selben Zeitraum überein.
7. Gegeben ich habe keine Partien im Zeitraum, dann sehe ich einen Leerzustand statt Nullen und leerer Diagramme.
### PAR-08 Statistik je Spiel
1. Gegeben ein Spiel mit Partien, dann sehe ich Anzahl der Partien, durchschnittliche Dauer, Gesamtdauer und Siegquote für dieses Spiel.
2. Gegeben ein Spiel mit Punkten, dann sehe ich den Bestwert mit Datum der Partie.
3. Gegeben ein Spiel ohne Punkte, dann wird kein Bestwert angezeigt.
4. Gegeben das Spiel, dann sehe ich die fünf häufigsten Mitspieler.
5. Gegeben die Detailseite eines Spiels (KAT-02), dann komme ich von dort zur Statistik des Spiels.
## Querschnitt für R1
Diese Kriterien gelten für jede Story in R1 und werden bei jedem Pull Request mitgeprüft.
### Datenschutz (QUE-04)
1. Gegeben ich registriere mich, dann sehe ich vorher Datenschutzerklärung und Nutzungsbedingungen und stimme ihnen ausdrücklich zu.
2. Gegeben optionale Dienste wie Nutzungsanalyse, dann sind sie standardmäßig aus und werden erst nach meiner Einwilligung aktiv.
3. Gegeben die Datenschutzerklärung, dann nennt sie Zwecke, Speicherdauer, Empfänger und die Löschfrist aus KTO-05.
### Sicherheit der Daten
1. Gegeben zwei Nutzer A und B, wenn A über die App oder direkt über die Schnittstelle auf Profil, Sammlung, Listen, Partien oder Gäste von B zugreifen will, dann wird der Zugriff verweigert.
2. Gegeben ich bin nicht angemeldet, dann kann ich keine privaten Daten lesen oder ändern; der Katalog ist auch ohne Anmeldung lesbar, aber nie veränderbar (Entscheidung O6).
3. Gegeben jede neue Tabelle mit Nutzerdaten, dann hat sie Zugriffsregeln und mindestens einen automatischen Test, der fremden Zugriff prüft.
### Sprachen (QUE-05)
1. Gegeben die App startet, dann folgt die Sprache der Geräteeinstellung (Deutsch oder Englisch, sonst Englisch) und lässt sich in den Einstellungen ändern.
2. Gegeben jeder sichtbare Text, dann kommt er aus den Übersetzungsdateien, nicht aus dem Code.
3. Gegeben Datum, Zahlen und Währung, dann werden sie nach Sprache und Region formatiert.
4. Gegeben eine Übersetzung fehlt, dann erscheint der englische Text, niemals ein technischer Schlüssel.
### Barrierefreiheit (QUE-06)
1. Gegeben ein Screenreader, dann haben alle bedienbaren Elemente und Bilder eine sinnvolle Beschriftung.
2. Gegeben Texte und Bedienelemente, dann erreichen sie mindestens den Kontrast nach WCAG AA und Touch-Ziele sind mindestens 48 dp groß.
3. Gegeben ich vergrößere die Schrift auf 200 Prozent, dann bleibt alles les- und bedienbar, ohne dass Text abgeschnitten wird.
4. Gegeben das Web, dann lässt sich jede Funktion per Tastatur bedienen und der Fokus ist sichtbar.
### Zustände, Fehler und Netz
1. Gegeben jede Liste und jeder Bildschirm mit Daten, dann hat er einen Ladezustand, einen Leerzustand, einen Fehlerzustand mit „Erneut versuchen“ und den Normalzustand.
2. Gegeben ich bin offline, wenn ich eine Funktion nutze, die eine Verbindung braucht, dann sehe ich einen verständlichen Hinweis statt eines technischen Fehlers.
3. Gegeben ein Fehler im Hintergrund, dann verliere ich keine Eingaben, die ich schon gemacht hatte.
### Plattformen
1. Gegeben jede R1-Story, dann funktioniert sie auf iOS, Android und im Web mit denselben Kriterien; Abweichungen werden im Pull Request begründet.
2. Gegeben ein großer Bildschirm im Web oder auf einem Tablet, dann nutzt das Layout die Breite sinnvoll, statt eine gestreckte Handy-Ansicht zu zeigen.
## Entscheidungen zu den offenen Punkten
Beim Schreiben sind zehn Fragen entstanden, die R1 selbst betreffen. Alle zehn sind am 2. November 2026 entschieden; die Spalte „Entscheidung“ gilt als verbindlich.
| Nr | Frage | Entscheidung | Betrifft |
| --- | --- | --- | --- |
| O1 | Sollen Apple- oder Google-Login schon in R1 dabei sein? | Nur E-Mail und Passwort in R1; Apple verlangt in der Regel „Sign in with Apple“, sobald andere Social-Logins angeboten werden | KTO-01 |
| O2 | Muss der Anzeigename eindeutig sein? | Nein; für Einladungen in R2 genügt ein Link | KTO-02 |
| O3 | Reicht in R1 der Hinweis „alles privat“ statt echter Privatsphäre-Einstellungen? | Ja, echte Einstellungen kommen mit R2 | KTO-04 |
| O4 | Editionen: Varianten desselben Spiels oder eigene Katalogeinträge? | Varianten eines Spiels; das wirkt stark auf das Datenmodell | KAT-03 |
| O5 | Vorläufige private Einträge für gemeldete Spiele? | Ja, sonst blockiert ein fehlendes Spiel die Erfassung | KAT-04 |
| O6 | Darf der Katalog ohne Anmeldung gelesen werden? | Ja, nur lesend; Anmeldung erst zum Eintragen | Querschnitt Sicherheit, Web |
| O7 | Offline Spiele wählen: nur aus der Sammlung oder per kleiner Offline-Kopie des Katalogs? | Nur aus der Sammlung in R1 | PAR-01 |
| O8 | Mehrfachbesitz, etwa zwei Exemplare desselben Spiels? | Nein in R1, ein Eintrag je Spiel | SAM-01 |
| O9 | Wertungsbögen: privat je Nutzer oder als Vorlagen im Katalog? | Privat in R1, gemeinsame Vorlagen später | PAR-04 |
| O10 | Passen die Zahlenwerte: Passwort ab 10 Zeichen, Anzeigename 30, Kurzbeschreibung 300, 5 Lieblingsspiele, 20 Mitspieler, Notiz 1000 Zeichen, Avatar 5 MB? | So übernehmen | KTO, SAM, PAR |

27
requirements/README.md Normal file
View file

@ -0,0 +1,27 @@
# Fachliche Anforderungen
Hier legst du (Chris) deine fachlichen Anforderungen ab. Sie sind die **Quelle der Wahrheit** für
den Agent `requirements-analyst`, der daraus User Stories und Forgejo-Issues macht (`/intake`).
## Empfohlene Struktur
```
requirements/
00-vision.md Vision, Zielgruppe, Abgrenzung, Erfolgskriterien
01-rollen.md Nutzerrollen (z. B. Gast, Nutzer, Gruppen-Admin, Moderator)
10-<bereich>.md je fachlichem Bereich eine Datei (z. B. 10-sammlung.md, 20-spielabende.md)
90-nicht-funktional.md Datenschutz, Plattformen, Offline, Sprachen, Performance, Barrierefreiheit
backlog.md wird vom Agent gepflegt (Epic → Stories → Issues)
```
## Format pro Anforderung
```markdown
### REQ-SAM-001 Spiel zur Sammlung hinzufügen
**Beschreibung:** Nutzer können ein Spiel per Suche oder Barcode zur eigenen Sammlung hinzufügen.
**Priorität:** Must (MVP)
**Regeln:** Ein Spiel kann nur einmal pro Nutzer in der Sammlung sein. …
**Beispiele:** …
**Offen:** Welche Spieldatenbank ist die Quelle?
```
Stabile IDs (`REQ-<BEREICH>-<NR>`) erlauben die Verknüpfung Anforderung ↔ Issue ↔ Test.
Unfertige Gedanken sind ausdrücklich erlaubt – markiere sie als **Offen**.

21
scripts/check-setup.sh Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Prüft, ob alle Werkzeuge für die Shuffle-Entwicklung installiert sind.
ok() { printf " \033[32m✔\033[0m %s\n" "$1"; }
fail() { printf " \033[31m✘\033[0m %s – %s\n" "$1" "$2"; MISSING=1; }
MISSING=0
echo "Werkzeuge:"
for t in git flutter dart node npx jq docker supabase claude forgejo-mcp; do
if command -v "$t" >/dev/null 2>&1; then ok "$t"; else fail "$t" "nicht gefunden"; fi
done
echo "Umgebungsvariablen:"
for v in FORGEJO_URL FORGEJO_ACCESS_TOKEN OUTLINE_URL; do
if [ -n "${!v:-}" ]; then ok "$v"; else fail "$v" "nicht gesetzt"; fi
done
if command -v flutter >/dev/null 2>&1; then
echo "Flutter-Doctor (Kurzfassung):"; flutter doctor 2>/dev/null | sed 's/^/ /'
fi
[ "$MISSING" = 0 ] && echo "Alles bereit." || echo "Bitte fehlende Punkte ergänzen (siehe Anleitung)."

28
scripts/forgejo-labels.sh Executable file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Legt das Label-Set für Shuffle im Forgejo-Repo an.
# Nutzung: FORGEJO_URL=https://git.example.de FORGEJO_ACCESS_TOKEN=xxx ./scripts/forgejo-labels.sh <owner> <repo>
set -euo pipefail
OWNER=${1:?owner fehlt}; REPO=${2:?repo fehlt}
API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/labels"
label() {
curl -sS -o /dev/null -w "%{http_code} $1\n" -X POST "$API" \
-H "Authorization: token $FORGEJO_ACCESS_TOKEN" -H "Content-Type: application/json" \
-d "{\"name\":\"$1\",\"color\":\"$2\",\"description\":\"$3\"}"
}
label "type/story" "#1d76db" "Fachliche User Story"
label "type/bug" "#d73a4a" "Fehler"
label "type/tech" "#5319e7" "Technische Aufgabe"
label "type/epic" "#0e8a16" "Epic"
label "prio/must" "#b60205" "MVP – muss"
label "prio/should" "#d93f0b" "sollte"
label "prio/could" "#fbca04" "könnte"
label "size/s" "#c2e0c6" "< 1 Tag"
label "size/m" "#7bcf8c" "1–3 Tage"
label "size/l" "#2ea44f" "> 3 Tage – schneiden!"
label "needs-clarification" "#e99695" "Fachliche Frage offen"
label "status/spec-review" "#bfd4f2" "Spec wartet auf Freigabe"
label "status/in-progress" "#0052cc" "In Umsetzung"
label "status/ready-for-merge" "#0e8a16" "Review bestanden, Merge durch Chris"
echo "Fertig. Epic-Labels (epic/<name>) legt der requirements-analyst bei Bedarf an."

27
supabase/README.md Normal file
View file

@ -0,0 +1,27 @@
# Supabase – Datenbank für Shuffle
## Inhalt
| Pfad | Zweck |
|---|---|
| `migrations/` | Schema, Trigger und RLS-Policies für R1 (17 Tabellen, 2 Views). Nur additiv ändern, nie bestehende Dateien editieren. |
| `tests/database/` | pgTAP-Tests: Struktur-Regeln und Zugriffsrechte je Bereich. |
| `seed.sql` | Beispieldaten nur für die lokale Entwicklung. |
## Lokal starten und testen
```bash
supabase start # lokales Backend
supabase db reset # Migrationen + Seed einspielen
supabase test db # alle pgTAP-Tests ausführen
```
## Regeln
- Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security.
- `updated_at` setzt ein Trigger; `id` und `created_at` sind nach dem Anlegen unveränderlich.
- Fremdschlüssel nur dort, wo Tabellen in Beziehung stehen, benannt `<Zieltabelle>_id`.
- Rechte werden explizit vergeben (erst alles entziehen, dann gezielt gewähren).
- Admin-Rolle kommt aus `app_metadata.role = 'admin'` im JWT, nie aus `user_metadata`.
## Noch nicht enthalten
- Storage-Buckets und -Policies für Avatare und Spielbilder.
- Edge Functions (BGG-Import, Konto löschen).
- Selbst-Hosting-Setup auf Hetzner (ADR-0002, Aufgabe für `devops`).

View file

@ -0,0 +1,28 @@
-- R1 Basis: gemeinsame Hilfsfunktionen für alle Tabellen.
-- Jede Tabelle hat: id (uuid, PK), created_at, updated_at.
-- Setzt updated_at bei jeder Änderung und schützt id und created_at vor Änderungen.
-- Der Client kann diese drei Spalten damit nie verfälschen.
create or replace function public.set_updated_at()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
new.id := old.id;
new.created_at := old.created_at;
new.updated_at := now();
return new;
end;
$$;
-- Admin-Rolle kommt aus app_metadata des JWT. Das kann nur serverseitig gesetzt werden,
-- nie vom Client (anders als user_metadata).
create or replace function public.is_admin()
returns boolean
language sql
stable
set search_path = ''
as $$
select coalesce((select auth.jwt()) -> 'app_metadata' ->> 'role', '') = 'admin';
$$;

View file

@ -0,0 +1,81 @@
-- R1 Konto und Profil: profiles, Anlage beim Registrieren.
create table public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
display_name text not null check (char_length(display_name) between 1 and 30),
bio text check (char_length(bio) <= 300),
city text check (char_length(city) <= 100),
city_visible boolean not null default true,
avatar_path text,
locale text not null default 'de' check (locale in ('de', 'en')),
terms_accepted_at timestamptz not null,
age_confirmed_at timestamptz not null,
deleted_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create trigger set_updated_at before update on public.profiles
for each row execute function public.set_updated_at();
-- Profil automatisch anlegen. Die Registrierung scheitert ohne Altersbestätigung (ab 16)
-- und ohne Zustimmung zu den Nutzungsbedingungen. Die App sendet beide als user_metadata.
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
meta jsonb := coalesce(new.raw_user_meta_data, '{}'::jsonb);
name text;
begin
if coalesce(meta ->> 'age_confirmed', '') <> 'true' then
raise exception 'age_confirmation_required';
end if;
if coalesce(meta ->> 'terms_accepted', '') <> 'true' then
raise exception 'terms_acceptance_required';
end if;
name := left(btrim(coalesce(
nullif(btrim(meta ->> 'display_name'), ''),
nullif(split_part(new.email, '@', 1), ''),
'Spieler'
)), 30);
insert into public.profiles (id, display_name, locale, terms_accepted_at, age_confirmed_at)
values (
new.id,
name,
case when meta ->> 'locale' in ('de', 'en') then meta ->> 'locale' else 'de' end,
now(),
now()
);
return new;
end;
$$;
revoke all on function public.handle_new_user() from public, anon, authenticated;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- Zugriff: Jeder sieht nur das eigene Profil. Fremde Profile gibt es nur über die View
-- public_profiles (siehe Migration plays), und dort nur für Mitspieler.
alter table public.profiles enable row level security;
revoke all on public.profiles from anon, authenticated;
grant select on public.profiles to authenticated;
-- Nur diese Spalten darf der Nutzer ändern; Bestätigungen und Löschmarke nie.
grant update (display_name, bio, city, city_visible, avatar_path, locale)
on public.profiles to authenticated;
create policy profiles_select_own on public.profiles
for select to authenticated
using (id = (select auth.uid()));
create policy profiles_update_own on public.profiles
for update to authenticated
using (id = (select auth.uid()))
with check (id = (select auth.uid()));

View file

@ -0,0 +1,259 @@
-- R1 Katalog: games, game_variants, Begriffe (Genre, Mechanik, Kategorie) und Zuordnungen.
-- Lesen ist auch ohne Anmeldung möglich (Entscheidung O6), Schreiben nie.
create table public.games (
id uuid primary key default gen_random_uuid(),
owner_id uuid references public.profiles (id) on delete cascade,
title text not null check (char_length(title) between 1 and 200),
description text check (char_length(description) <= 5000),
year smallint check (year between 1800 and 2100),
min_players smallint check (min_players >= 1),
max_players smallint check (max_players >= 1),
min_minutes smallint check (min_minutes >= 0),
max_minutes smallint check (max_minutes >= 0),
min_age smallint check (min_age >= 0),
image_path text,
bgg_id integer,
visibility text not null default 'private' check (visibility in ('catalog', 'private')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
-- Katalogspiele gehören niemandem, private Spiele immer einem Nutzer (O5).
constraint games_owner_matches_visibility check (
(visibility = 'catalog' and owner_id is null)
or (visibility = 'private' and owner_id is not null)
),
constraint games_players_range check (max_players is null or min_players is null or max_players >= min_players),
constraint games_minutes_range check (max_minutes is null or min_minutes is null or max_minutes >= min_minutes)
);
-- Die BGG-ID ist optional. Im Katalog einmalig; private Einträge mehrerer Nutzer dürfen sie teilen.
create unique index games_bgg_id_catalog_key on public.games (bgg_id)
where visibility = 'catalog' and bgg_id is not null;
create unique index games_bgg_id_private_key on public.games (owner_id, bgg_id)
where visibility = 'private' and bgg_id is not null;
create index games_owner_id_idx on public.games (owner_id);
create index games_title_idx on public.games (lower(title));
create table public.game_variants (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references public.games (id) on delete cascade,
name text not null check (char_length(name) between 1 and 200),
year smallint check (year between 1800 and 2100),
language text check (char_length(language) <= 50),
bgg_id integer,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
-- Ermöglicht den zusammengesetzten Verweis (variant_id, game_id) in Sammlung und Partien:
-- so kann eine Variante nie zu einem anderen Spiel gehören.
constraint game_variants_id_game_key unique (id, game_id)
);
create index game_variants_game_id_idx on public.game_variants (game_id);
create table public.genres (
id uuid primary key default gen_random_uuid(),
name_de text not null unique check (char_length(name_de) between 1 and 100),
name_en text not null unique check (char_length(name_en) between 1 and 100),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table public.mechanics (
id uuid primary key default gen_random_uuid(),
name_de text not null unique check (char_length(name_de) between 1 and 100),
name_en text not null unique check (char_length(name_en) between 1 and 100),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table public.categories (
id uuid primary key default gen_random_uuid(),
name_de text not null unique check (char_length(name_de) between 1 and 100),
name_en text not null unique check (char_length(name_en) between 1 and 100),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table public.game_genres (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references public.games (id) on delete cascade,
genre_id uuid not null references public.genres (id) on delete cascade,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (game_id, genre_id)
);
create index game_genres_genre_id_idx on public.game_genres (genre_id);
create table public.game_mechanics (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references public.games (id) on delete cascade,
mechanic_id uuid not null references public.mechanics (id) on delete cascade,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (game_id, mechanic_id)
);
create index game_mechanics_mechanic_id_idx on public.game_mechanics (mechanic_id);
create table public.game_categories (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references public.games (id) on delete cascade,
category_id uuid not null references public.categories (id) on delete cascade,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (game_id, category_id)
);
create index game_categories_category_id_idx on public.game_categories (category_id);
-- updated_at-Trigger für alle Tabellen dieser Migration
create trigger set_updated_at before update on public.games
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.game_variants
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.genres
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.mechanics
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.categories
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.game_genres
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.game_mechanics
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.game_categories
for each row execute function public.set_updated_at();
-- Rechte und RLS
alter table public.games enable row level security;
alter table public.game_variants enable row level security;
alter table public.genres enable row level security;
alter table public.mechanics enable row level security;
alter table public.categories enable row level security;
alter table public.game_genres enable row level security;
alter table public.game_mechanics enable row level security;
alter table public.game_categories enable row level security;
revoke all on public.games, public.game_variants, public.genres, public.mechanics,
public.categories, public.game_genres, public.game_mechanics, public.game_categories
from anon, authenticated;
grant select on public.games, public.game_variants, public.genres, public.mechanics,
public.categories, public.game_genres, public.game_mechanics, public.game_categories
to anon, authenticated;
grant insert, update, delete on public.games, public.game_variants, public.genres,
public.mechanics, public.categories, public.game_genres, public.game_mechanics,
public.game_categories to authenticated;
-- games: Katalog für alle sichtbar, private Spiele nur für den Ersteller.
create policy games_select on public.games
for select to anon, authenticated
using (visibility = 'catalog' or owner_id = (select auth.uid()));
create policy games_insert_private on public.games
for insert to authenticated
with check (visibility = 'private' and owner_id = (select auth.uid()));
create policy games_insert_admin on public.games
for insert to authenticated
with check (public.is_admin());
create policy games_update_private on public.games
for update to authenticated
using (visibility = 'private' and owner_id = (select auth.uid()))
with check (visibility = 'private' and owner_id = (select auth.uid()));
create policy games_update_admin on public.games
for update to authenticated
using (public.is_admin())
with check (public.is_admin());
create policy games_delete_private on public.games
for delete to authenticated
using (visibility = 'private' and owner_id = (select auth.uid()));
create policy games_delete_admin on public.games
for delete to authenticated
using (public.is_admin());
-- Begriffe: lesbar für alle, pflegbar nur durch Admin.
create policy genres_select on public.genres
for select to anon, authenticated using (true);
create policy genres_write_admin on public.genres
for all to authenticated
using (public.is_admin()) with check (public.is_admin());
create policy mechanics_select on public.mechanics
for select to anon, authenticated using (true);
create policy mechanics_write_admin on public.mechanics
for all to authenticated
using (public.is_admin()) with check (public.is_admin());
create policy categories_select on public.categories
for select to anon, authenticated using (true);
create policy categories_write_admin on public.categories
for all to authenticated
using (public.is_admin()) with check (public.is_admin());
-- Varianten und Zuordnungen folgen dem zugehörigen Spiel: Wer das Spiel sehen darf, sieht sie.
-- Ändern darf Admin oder der Ersteller eines privaten Spiels.
create policy game_variants_select on public.game_variants
for select to anon, authenticated
using (exists (select 1 from public.games g where g.id = game_id));
create policy game_variants_write on public.game_variants
for all to authenticated
using (
public.is_admin()
or exists (select 1 from public.games g
where g.id = game_id and g.owner_id = (select auth.uid()))
)
with check (
public.is_admin()
or exists (select 1 from public.games g
where g.id = game_id and g.owner_id = (select auth.uid()))
);
create policy game_genres_select on public.game_genres
for select to anon, authenticated
using (exists (select 1 from public.games g where g.id = game_id));
create policy game_genres_write on public.game_genres
for all to authenticated
using (
public.is_admin()
or exists (select 1 from public.games g
where g.id = game_id and g.owner_id = (select auth.uid()))
)
with check (
public.is_admin()
or exists (select 1 from public.games g
where g.id = game_id and g.owner_id = (select auth.uid()))
);
create policy game_mechanics_select on public.game_mechanics
for select to anon, authenticated
using (exists (select 1 from public.games g where g.id = game_id));
create policy game_mechanics_write on public.game_mechanics
for all to authenticated
using (
public.is_admin()
or exists (select 1 from public.games g
where g.id = game_id and g.owner_id = (select auth.uid()))
)
with check (
public.is_admin()
or exists (select 1 from public.games g
where g.id = game_id and g.owner_id = (select auth.uid()))
);
create policy game_categories_select on public.game_categories
for select to anon, authenticated
using (exists (select 1 from public.games g where g.id = game_id));
create policy game_categories_write on public.game_categories
for all to authenticated
using (
public.is_admin()
or exists (select 1 from public.games g
where g.id = game_id and g.owner_id = (select auth.uid()))
)
with check (
public.is_admin()
or exists (select 1 from public.games g
where g.id = game_id and g.owner_id = (select auth.uid()))
);

View file

@ -0,0 +1,78 @@
-- R1 Sammlung: collection_items, favorite_games, bgg_links. Alles ist privat (Entscheidung O3).
create table public.collection_items (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.profiles (id) on delete cascade,
game_id uuid not null references public.games (id),
variant_id uuid,
list text not null default 'owned' check (list in ('owned', 'wishlist', 'shopping')),
note text check (char_length(note) <= 1000),
acquired_on date,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
-- Ein Eintrag je Spiel und Nutzer (O8); Editionen laufen über variant_id (O4).
unique (owner_id, game_id),
-- Die Variante muss zum Spiel gehören.
foreign key (variant_id, game_id) references public.game_variants (id, game_id)
);
create index collection_items_game_id_idx on public.collection_items (game_id);
create table public.favorite_games (
id uuid primary key default gen_random_uuid(),
profile_id uuid not null references public.profiles (id) on delete cascade,
game_id uuid not null references public.games (id) on delete cascade,
position smallint not null check (position between 1 and 5),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (profile_id, position),
unique (profile_id, game_id)
);
create index favorite_games_game_id_idx on public.favorite_games (game_id);
create table public.bgg_links (
id uuid primary key default gen_random_uuid(),
profile_id uuid not null unique references public.profiles (id) on delete cascade,
bgg_username text not null check (char_length(bgg_username) between 1 and 100),
last_import_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create trigger set_updated_at before update on public.collection_items
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.favorite_games
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.bgg_links
for each row execute function public.set_updated_at();
alter table public.collection_items enable row level security;
alter table public.favorite_games enable row level security;
alter table public.bgg_links enable row level security;
revoke all on public.collection_items, public.favorite_games, public.bgg_links
from anon, authenticated;
grant select, insert, update, delete
on public.collection_items, public.favorite_games, public.bgg_links to authenticated;
-- Nur der Eigentümer sieht und ändert seine Zeilen. Neue Zeilen dürfen nur auf Spiele zeigen,
-- die der Nutzer selbst sehen darf (Katalog oder eigene private Spiele).
create policy collection_items_own on public.collection_items
for all to authenticated
using (owner_id = (select auth.uid()))
with check (
owner_id = (select auth.uid())
and exists (select 1 from public.games g where g.id = game_id)
);
create policy favorite_games_own on public.favorite_games
for all to authenticated
using (profile_id = (select auth.uid()))
with check (
profile_id = (select auth.uid())
and exists (select 1 from public.games g where g.id = game_id)
);
create policy bgg_links_own on public.bgg_links
for all to authenticated
using (profile_id = (select auth.uid()))
with check (profile_id = (select auth.uid()));

View file

@ -0,0 +1,358 @@
-- R1 Partien und Wertung: score_sheets, score_categories, plays, play_players, play_scores,
-- dazu Sicht auf Mitspieler-Profile und Statistik.
create table public.score_sheets (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.profiles (id) on delete cascade,
game_id uuid references public.games (id) on delete set null,
name text not null check (char_length(name) between 1 and 60),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index score_sheets_owner_id_idx on public.score_sheets (owner_id);
create table public.score_categories (
id uuid primary key default gen_random_uuid(),
sheet_id uuid not null references public.score_sheets (id) on delete cascade,
name text not null check (char_length(name) between 1 and 40),
position smallint not null check (position >= 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (sheet_id, position)
);
-- owner_id wird leer, wenn der Ersteller sein Konto löscht, die Partie aber für andere Mitspieler
-- erhalten bleibt (Entscheidung zum Konto löschen).
create table public.plays (
id uuid primary key default gen_random_uuid(),
owner_id uuid references public.profiles (id) on delete set null,
game_id uuid not null references public.games (id),
variant_id uuid,
sheet_id uuid references public.score_sheets (id) on delete set null,
played_at timestamptz not null,
duration_minutes integer check (duration_minutes >= 0),
note text check (char_length(note) <= 1000),
-- Von der App beim Erfassen vergeben; macht den Abgleich nach Offline-Erfassung wiederholbar.
client_id uuid not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (owner_id, client_id),
foreign key (variant_id, game_id) references public.game_variants (id, game_id)
);
create index plays_owner_id_idx on public.plays (owner_id, played_at desc);
create index plays_game_id_idx on public.plays (game_id);
-- Eine Zeile je Mitspieler. Gast: profile_id und link_status 'none', Name in guest_name.
-- Gelöschter Nutzer: profile_id und guest_name leer, link_status 'none'.
create table public.play_players (
id uuid primary key default gen_random_uuid(),
play_id uuid not null references public.plays (id) on delete cascade,
profile_id uuid references public.profiles (id) on delete set null,
guest_name text check (char_length(guest_name) between 1 and 30),
link_status text not null default 'none' check (link_status in ('none', 'pending', 'confirmed', 'declined')),
place smallint check (place >= 1),
total_score numeric,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint play_players_link_consistent check (profile_id is not null or link_status = 'none'),
constraint play_players_guest_or_user check (not (profile_id is not null and guest_name is not null))
);
create index play_players_play_id_idx on public.play_players (play_id);
create index play_players_profile_id_idx on public.play_players (profile_id);
create table public.play_scores (
id uuid primary key default gen_random_uuid(),
play_player_id uuid not null references public.play_players (id) on delete cascade,
category_id uuid not null references public.score_categories (id) on delete cascade,
value numeric not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (play_player_id, category_id)
);
create index play_scores_category_id_idx on public.play_scores (category_id);
create trigger set_updated_at before update on public.score_sheets
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.score_categories
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.plays
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.play_players
for each row execute function public.set_updated_at();
create trigger set_updated_at before update on public.play_scores
for each row execute function public.set_updated_at();
-- Hilfsfunktionen für die Policies. Sie laufen mit erhöhten Rechten, damit sich die Policies
-- von plays und play_players nicht gegenseitig endlos aufrufen.
create or replace function public.is_play_owner(p_play_id uuid)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1 from public.plays where id = p_play_id and owner_id = (select auth.uid())
);
$$;
-- Lesen darf der Ersteller sowie jeder eingeladene Mitspieler (offen oder bestätigt),
-- damit er die Einladung einordnen und bestätigen kann.
create or replace function public.can_read_play(p_play_id uuid)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1 from public.plays where id = p_play_id and owner_id = (select auth.uid())
) or exists (
select 1 from public.play_players
where play_id = p_play_id
and profile_id = (select auth.uid())
and link_status in ('pending', 'confirmed')
);
$$;
create or replace function public.play_id_of_player(p_play_player_id uuid)
returns uuid
language sql
stable
security definer
set search_path = ''
as $$
select play_id from public.play_players where id = p_play_player_id;
$$;
revoke all on function public.is_play_owner(uuid), public.can_read_play(uuid),
public.play_id_of_player(uuid) from public, anon;
grant execute on function public.is_play_owner(uuid), public.can_read_play(uuid),
public.play_id_of_player(uuid) to authenticated;
-- Höchstens 20 Mitspieler je Partie (O10).
create or replace function public.play_players_limit()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
if (select count(*) from public.play_players where play_id = new.play_id) >= 20 then
raise exception 'play_players_limit_exceeded';
end if;
return new;
end;
$$;
create trigger play_players_limit before insert on public.play_players
for each row execute function public.play_players_limit();
-- Schützt die Verknüpfung: Der Ersteller ändert Platz, Punkte und Gastname, aber nie, wer
-- verknüpft ist. Der eingeladene Nutzer darf nur antworten (pending -> confirmed/declined).
-- Serverseitige Aufrufe ohne Nutzer (service_role) sind ausgenommen.
create or replace function public.play_players_guard()
returns trigger
language plpgsql
set search_path = ''
as $$
declare
uid uuid := (select auth.uid());
begin
if uid is null then
return new;
end if;
if new.play_id <> old.play_id then
raise exception 'play_change_not_allowed';
end if;
if public.is_play_owner(old.play_id) then
if new.profile_id is distinct from old.profile_id
or new.link_status is distinct from old.link_status then
raise exception 'link_change_not_allowed';
end if;
else
if old.link_status <> 'pending'
or new.link_status not in ('confirmed', 'declined')
or (to_jsonb(new) - 'link_status' - 'updated_at' - 'created_at')
is distinct from (to_jsonb(old) - 'link_status' - 'updated_at' - 'created_at') then
raise exception 'invitation_answer_only';
end if;
end if;
return new;
end;
$$;
create trigger play_players_guard before update on public.play_players
for each row execute function public.play_players_guard();
-- Konto löschen: Partien ohne andere beteiligte Nutzer verschwinden, alle anderen bleiben.
-- Der gelöschte Nutzer wird in fremden Partien anonym (Anzeige: „Gelöschter Nutzer“).
create or replace function public.handle_profile_delete()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
delete from public.plays p
where (
p.owner_id = old.id
and not exists (
select 1 from public.play_players pp
where pp.play_id = p.id
and pp.profile_id is not null
and pp.profile_id <> old.id
and pp.link_status in ('pending', 'confirmed')
)
)
or p.game_id in (select g.id from public.games g where g.owner_id = old.id);
update public.play_players
set profile_id = null, link_status = 'none', guest_name = null
where profile_id = old.id;
return old;
end;
$$;
revoke all on function public.handle_profile_delete() from public, anon, authenticated;
create trigger on_profile_delete before delete on public.profiles
for each row execute function public.handle_profile_delete();
-- Rechte und RLS
alter table public.score_sheets enable row level security;
alter table public.score_categories enable row level security;
alter table public.plays enable row level security;
alter table public.play_players enable row level security;
alter table public.play_scores enable row level security;
revoke all on public.score_sheets, public.score_categories, public.plays,
public.play_players, public.play_scores from anon, authenticated;
grant select, insert, update, delete on public.score_sheets, public.score_categories,
public.plays, public.play_players, public.play_scores to authenticated;
-- Wertungsbögen: privat je Nutzer (O9).
create policy score_sheets_own on public.score_sheets
for all to authenticated
using (owner_id = (select auth.uid()))
with check (
owner_id = (select auth.uid())
and (game_id is null or exists (select 1 from public.games g where g.id = game_id))
);
create policy score_categories_own on public.score_categories
for all to authenticated
using (exists (select 1 from public.score_sheets s
where s.id = sheet_id and s.owner_id = (select auth.uid())))
with check (exists (select 1 from public.score_sheets s
where s.id = sheet_id and s.owner_id = (select auth.uid())));
-- Partien
create policy plays_select on public.plays
for select to authenticated
using (public.can_read_play(id));
create policy plays_insert on public.plays
for insert to authenticated
with check (
owner_id = (select auth.uid())
and exists (select 1 from public.games g where g.id = game_id)
and (sheet_id is null or exists (select 1 from public.score_sheets s where s.id = sheet_id))
);
create policy plays_update on public.plays
for update to authenticated
using (owner_id = (select auth.uid()))
with check (
owner_id = (select auth.uid())
and exists (select 1 from public.games g where g.id = game_id)
and (sheet_id is null or exists (select 1 from public.score_sheets s where s.id = sheet_id))
);
create policy plays_delete on public.plays
for delete to authenticated
using (owner_id = (select auth.uid()));
-- Mitspieler: neue Zeilen nur als Gast, als man selbst (bestätigt) oder als Einladung (offen).
create policy play_players_select on public.play_players
for select to authenticated
using (public.can_read_play(play_id) or profile_id = (select auth.uid()));
create policy play_players_insert on public.play_players
for insert to authenticated
with check (
public.is_play_owner(play_id)
and (
(profile_id is null and link_status = 'none')
or (profile_id = (select auth.uid()) and link_status = 'confirmed')
or (profile_id is not null and profile_id <> (select auth.uid()) and link_status = 'pending')
)
);
create policy play_players_update on public.play_players
for update to authenticated
using (public.is_play_owner(play_id) or profile_id = (select auth.uid()))
with check (public.is_play_owner(play_id) or profile_id = (select auth.uid()));
create policy play_players_delete on public.play_players
for delete to authenticated
using (public.is_play_owner(play_id) or profile_id = (select auth.uid()));
-- Einzelwerte: lesen wie die Partie, schreiben nur der Ersteller.
create policy play_scores_select on public.play_scores
for select to authenticated
using (public.can_read_play(public.play_id_of_player(play_player_id)));
create policy play_scores_write on public.play_scores
for all to authenticated
using (public.is_play_owner(public.play_id_of_player(play_player_id)))
with check (public.is_play_owner(public.play_id_of_player(play_player_id)));
-- Profile von Mitspielern: Name, Avatar und (wenn freigegeben) Stadt, nur für Nutzer, die eine
-- gemeinsame lesbare Partie haben. Es gibt keine Suche über alle Nutzer (Entscheidung O3).
create view public.public_profiles as
select
p.id,
p.display_name,
p.avatar_path,
case when p.city_visible then p.city end as city
from public.profiles p
where p.deleted_at is null
and (
p.id = (select auth.uid())
or exists (
select 1 from public.play_players pp
where pp.profile_id = p.id
and pp.link_status in ('pending', 'confirmed')
and public.can_read_play(pp.play_id)
)
);
revoke all on public.public_profiles from anon, authenticated;
grant select on public.public_profiles to authenticated;
-- Statistik je Nutzer und Spiel. Siegquote = Anteil der Partien mit Platz 1 unter den Partien
-- mit Ergebnis; ein geteilter erster Platz zählt als Sieg. Nur bestätigte Teilnahmen zählen.
create view public.my_game_stats
with (security_invoker = true) as
select
pp.profile_id as user_id,
p.game_id,
count(*) as play_count,
coalesce(sum(p.duration_minutes), 0) as total_minutes,
count(*) filter (where pp.place = 1) as wins,
count(*) filter (where pp.place is not null) as plays_with_result,
case when count(*) filter (where pp.place is not null) = 0 then null
else round(
count(*) filter (where pp.place = 1)::numeric
/ count(*) filter (where pp.place is not null), 4)
end as win_rate
from public.play_players pp
join public.plays p on p.id = pp.play_id
where pp.profile_id = (select auth.uid())
and pp.link_status = 'confirmed'
group by pp.profile_id, p.game_id;
revoke all on public.my_game_stats from anon, authenticated;
grant select on public.my_game_stats to authenticated;

17
supabase/seed.sql Normal file
View file

@ -0,0 +1,17 @@
-- Beispieldaten nur für die lokale Entwicklung (supabase db reset). Nicht für Produktion.
insert into public.genres (name_de, name_en) values
('Strategie', 'Strategy'), ('Familie', 'Family'), ('Kooperativ', 'Cooperative'),
('Party', 'Party'), ('Kartenspiel', 'Card game')
on conflict do nothing;
insert into public.mechanics (name_de, name_en) values
('Handkartenmanagement', 'Hand management'), ('Worker Placement', 'Worker placement'),
('Deckbau', 'Deck building'), ('Würfeln', 'Dice rolling')
on conflict do nothing;
insert into public.categories (name_de, name_en) values
('Wirtschaft', 'Economic'), ('Fantasy', 'Fantasy'), ('Abenteuer', 'Adventure'), ('Zivilisation', 'Civilization')
on conflict do nothing;
insert into public.games (title, description, year, min_players, max_players, min_minutes, max_minutes, min_age, visibility)
values ('Beispielspiel', 'Platzhalter für die Entwicklung.', 2020, 2, 4, 30, 60, 8, 'catalog');

View file

@ -0,0 +1,40 @@
-- Struktur-Regeln für alle Tabellen im Schema public.
begin;
select plan(5);
select is((select count(*) from pg_tables where schemaname = 'public'), 17::bigint,
'R1 hat genau 17 Tabellen');
select is_empty($$
select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' and not c.relrowsecurity$$,
'Jede Tabelle hat Row Level Security');
select is_empty($$
select t.table_name from information_schema.tables t
where t.table_schema = 'public' and t.table_type = 'BASE TABLE'
and (select count(*) from information_schema.columns c
where c.table_schema = 'public' and c.table_name = t.table_name
and ((c.column_name = 'id' and c.data_type = 'uuid')
or (c.column_name in ('created_at', 'updated_at')
and c.data_type = 'timestamp with time zone'
and c.is_nullable = 'NO'))) <> 3$$,
'Jede Tabelle hat id (uuid), created_at und updated_at');
select is_empty($$
select t.table_name from information_schema.tables t
where t.table_schema = 'public' and t.table_type = 'BASE TABLE'
and not exists (select 1 from information_schema.table_constraints k
where k.table_schema = 'public' and k.table_name = t.table_name
and k.constraint_type = 'PRIMARY KEY')$$,
'Jede Tabelle hat einen Primärschlüssel');
select is_empty($$
select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
and not exists (select 1 from pg_trigger g
where g.tgrelid = c.oid and g.tgname = 'set_updated_at')$$,
'Jede Tabelle setzt updated_at per Trigger');
select * from finish();
rollback;

View file

@ -0,0 +1,68 @@
-- Profile: Registrierung, Zugriff, Schutz der Spalten, Zeitstempel.
begin;
select plan(16);
-- Registrierung: ohne Altersbestätigung oder Zustimmung scheitert sie
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data)
values (gen_random_uuid(), 'a@example.com', '{"terms_accepted":"true"}')$$,
'P0001', 'age_confirmation_required', 'Registrierung ohne Altersbestätigung scheitert');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data)
values (gen_random_uuid(), 'b@example.com', '{"age_confirmed":"true"}')$$,
'P0001', 'terms_acceptance_required', 'Registrierung ohne Zustimmung scheitert');
insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com',
'{"age_confirmed":"true","terms_accepted":"true","display_name":"Anna","locale":"en"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com',
'{"age_confirmed":"true","terms_accepted":"true"}'),
('33333333-3333-3333-3333-333333333333', 'cara@example.com',
'{"age_confirmed":"true","terms_accepted":"true","display_name":"123456789012345678901234567890-zu-lang"}');
select is((select count(*) from public.profiles), 3::bigint, 'Profil wird bei der Registrierung angelegt');
select is((select display_name from public.profiles where id = '22222222-2222-2222-2222-222222222222'),
'ben', 'Ohne Anzeigename dient der Teil vor dem @ der E-Mail');
select is((select char_length(display_name) from public.profiles where id = '33333333-3333-3333-3333-333333333333'),
30, 'Zu langer Anzeigename wird auf 30 Zeichen gekürzt');
select is((select locale from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
'en', 'Sprache aus der Registrierung wird übernommen');
-- Als Anna
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;
select is((select count(*) from public.profiles), 1::bigint, 'Anna sieht nur ihr eigenes Profil');
select lives_ok($$update public.profiles set bio = 'Brettspielerin', city = 'Hannover' where id = '11111111-1111-1111-1111-111111111111'$$,
'Anna darf ihr Profil ändern');
select is_empty($$update public.profiles set bio = 'Fremd' where id = '22222222-2222-2222-2222-222222222222' returning 1$$,
'Anna kann fremde Profile nicht ändern');
select throws_ok($$update public.profiles set age_confirmed_at = now() where id = '11111111-1111-1111-1111-111111111111'$$,
'42501', null, 'Bestätigungen kann der Nutzer nicht ändern');
select throws_ok($$update public.profiles set deleted_at = now() where id = '11111111-1111-1111-1111-111111111111'$$,
'42501', null, 'Löschmarke kann der Nutzer nicht setzen');
select throws_ok($$update public.profiles set display_name = '' where id = '11111111-1111-1111-1111-111111111111'$$,
'23514', null, 'Leerer Anzeigename wird abgelehnt');
select throws_ok($$insert into public.profiles (id, display_name, terms_accepted_at, age_confirmed_at) values (gen_random_uuid(), 'x', now(), now())$$,
'42501', null, 'Profile werden nur durch die Registrierung angelegt');
reset role;
-- Ohne Anmeldung
set local role anon;
select throws_ok($$select * from public.profiles$$, '42501', null, 'Ohne Anmeldung kein Zugriff auf Profile');
reset role;
-- Zeitstempel: Trigger abschalten, um alte Werte zu setzen, dann normal ändern
set session_replication_role = replica;
update public.profiles set created_at = '2020-01-01', updated_at = '2020-01-01'
where id = '11111111-1111-1111-1111-111111111111';
set session_replication_role = origin;
update public.profiles set bio = 'neu', created_at = '2030-01-01'
where id = '11111111-1111-1111-1111-111111111111';
select is((select created_at from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
'2020-01-01'::timestamptz, 'created_at bleibt bei Änderungen unverändert');
select ok((select updated_at from public.profiles where id = '11111111-1111-1111-1111-111111111111') > '2020-01-01'::timestamptz,
'updated_at wird bei Änderungen automatisch gesetzt');
select * from finish();
rollback;

View file

@ -0,0 +1,84 @@
-- Katalog: öffentlich lesbar, private Spiele nur für den Ersteller, Pflege nur durch Admin.
begin;
select plan(19);
insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true"}');
insert into public.games (id, title, visibility, bgg_id) values
('a0000000-0000-0000-0000-000000000001', 'Catan', 'catalog', 13);
insert into public.games (id, title, visibility, owner_id) values
('a0000000-0000-0000-0000-000000000002', 'Annas Prototyp', 'private', '11111111-1111-1111-1111-111111111111');
insert into public.game_variants (game_id, name) values
('a0000000-0000-0000-0000-000000000001', 'Catan Jubiläum'),
('a0000000-0000-0000-0000-000000000002', 'Prototyp v2');
insert into public.genres (id, name_de, name_en) values
('b0000000-0000-0000-0000-000000000001', 'Strategie', 'Strategy');
-- Constraints (als Superuser)
select throws_ok(
$$insert into public.games (title, visibility, owner_id) values ('X', 'catalog', '11111111-1111-1111-1111-111111111111')$$,
'23514', null, 'Katalogspiel darf keinen Eigentümer haben');
select throws_ok(
$$insert into public.games (title, visibility) values ('X', 'private')$$,
'23514', null, 'Privates Spiel braucht einen Eigentümer');
select throws_ok(
$$insert into public.games (title, visibility, bgg_id) values ('Catan 2', 'catalog', 13)$$,
'23505', null, 'BGG-ID ist im Katalog einmalig');
select throws_ok(
$$insert into public.games (title, visibility, min_players, max_players) values ('Y', 'catalog', 4, 2)$$,
'23514', null, 'Maximale Spielerzahl nicht unter der minimalen');
-- Ohne Anmeldung
set local role anon;
select is((select count(*) from public.games), 1::bigint, 'Besucher sehen nur Katalogspiele');
select is((select count(*) from public.game_variants), 1::bigint, 'Besucher sehen nur Varianten sichtbarer Spiele');
select is((select count(*) from public.genres), 1::bigint, 'Besucher sehen Begriffe');
select throws_ok($$insert into public.games (title, visibility) values ('Z', 'catalog')$$,
'42501', null, 'Besucher können nichts anlegen');
reset role;
-- Anna
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;
select is((select count(*) from public.games), 2::bigint, 'Anna sieht Katalog und ihr privates Spiel');
select lives_ok(
$$insert into public.games (title, visibility, owner_id, bgg_id) values ('Noch ein Prototyp', 'private', '11111111-1111-1111-1111-111111111111', 13)$$,
'Anna darf ein privates Spiel anlegen, auch mit einer BGG-ID aus dem Katalog');
select throws_ok(
$$insert into public.games (title, visibility) values ('Eigenes Katalogspiel', 'catalog')$$,
'42501', null, 'Anna darf keine Katalogspiele anlegen');
select throws_ok(
$$insert into public.games (title, visibility, owner_id) values ('Fremd', 'private', '22222222-2222-2222-2222-222222222222')$$,
'42501', null, 'Anna darf kein privates Spiel für andere anlegen');
select throws_ok(
$$update public.games set visibility = 'catalog', owner_id = null where id = 'a0000000-0000-0000-0000-000000000002'$$,
'42501', null, 'Anna kann ihr privates Spiel nicht in den Katalog heben');
select is_empty($$update public.games set title = 'Gehackt' where id = 'a0000000-0000-0000-0000-000000000001' returning 1$$,
'Anna kann Katalogspiele nicht ändern');
select throws_ok($$insert into public.genres (name_de, name_en) values ('Neu', 'New')$$,
'42501', null, 'Anna darf keine Begriffe anlegen');
select lives_ok(
$$insert into public.game_genres (game_id, genre_id) values ('a0000000-0000-0000-0000-000000000002', 'b0000000-0000-0000-0000-000000000001')$$,
'Anna darf ihr privates Spiel einem Genre zuordnen');
reset role;
-- Ben
select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true);
set local role authenticated;
select is((select count(*) from public.games where owner_id is not null), 0::bigint, 'Ben sieht keine privaten Spiele anderer');
select throws_ok(
$$insert into public.game_genres (game_id, genre_id) values ('a0000000-0000-0000-0000-000000000002', 'b0000000-0000-0000-0000-000000000001')$$,
'42501', null, 'Ben kann fremde private Spiele nicht zuordnen');
reset role;
-- Admin
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"role":"admin"}}', true);
set local role authenticated;
select lives_ok($$insert into public.games (title, visibility) values ('Neues Katalogspiel', 'catalog')$$,
'Admin darf Katalogspiele anlegen');
reset role;
select * from finish();
rollback;

View file

@ -0,0 +1,82 @@
-- Sammlung, Lieblingsspiele, BGG-Verknüpfung: alles privat je Nutzer.
begin;
select plan(18);
insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true"}');
insert into public.games (id, title, visibility) values
('a0000000-0000-0000-0000-000000000001', 'Catan', 'catalog'),
('a0000000-0000-0000-0000-000000000003', 'Azul', 'catalog');
insert into public.games (id, title, visibility, owner_id) values
('a0000000-0000-0000-0000-000000000002', 'Bens Prototyp', 'private', '22222222-2222-2222-2222-222222222222');
insert into public.game_variants (id, game_id, name) values
('c0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'Catan Jubiläum'),
('c0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'Azul Sommerpavillon');
-- Anna
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;
select lives_ok(
$$insert into public.collection_items (owner_id, game_id, variant_id, list)
values ('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001', 'owned')$$,
'Anna darf ein Spiel mit passender Variante in die Sammlung legen');
select throws_ok(
$$insert into public.collection_items (owner_id, game_id) values ('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000001')$$,
'23505', null, 'Je Nutzer und Spiel gibt es nur einen Eintrag');
select throws_ok(
$$insert into public.collection_items (owner_id, game_id, variant_id)
values ('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000003', 'c0000000-0000-0000-0000-000000000001')$$,
'23503', null, 'Eine Variante muss zum Spiel gehören');
select throws_ok(
$$insert into public.collection_items (owner_id, game_id, list) values ('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000003', 'verkauft')$$,
'23514', null, 'Unbekannte Liste wird abgelehnt');
select throws_ok(
$$insert into public.collection_items (owner_id, game_id, note) values ('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000003', repeat('x', 1001))$$,
'23514', null, 'Notiz ist auf 1000 Zeichen begrenzt');
select throws_ok(
$$insert into public.collection_items (owner_id, game_id) values ('22222222-2222-2222-2222-222222222222', 'a0000000-0000-0000-0000-000000000003')$$,
'42501', null, 'Anna kann nichts in fremde Sammlungen legen');
select throws_ok(
$$insert into public.collection_items (owner_id, game_id) values ('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000002')$$,
'42501', null, 'Anna kann kein fremdes privates Spiel in ihre Sammlung legen');
select lives_ok(
$$insert into public.favorite_games (profile_id, game_id, position) values ('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000001', 1)$$,
'Anna darf ein Lieblingsspiel setzen');
select throws_ok(
$$insert into public.favorite_games (profile_id, game_id, position) values ('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000003', 6)$$,
'23514', null, 'Es gibt höchstens 5 Lieblingsspiele');
select throws_ok(
$$insert into public.favorite_games (profile_id, game_id, position) values ('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000003', 1)$$,
'23505', null, 'Eine Position ist nur einmal vergeben');
select lives_ok(
$$insert into public.bgg_links (profile_id, bgg_username) values ('11111111-1111-1111-1111-111111111111', 'anna_bgg')$$,
'Anna darf ihren BGG-Namen hinterlegen');
reset role;
-- Ben
select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true);
set local role authenticated;
select is((select count(*) from public.collection_items), 0::bigint, 'Ben sieht Annas Sammlung nicht');
select is((select count(*) from public.favorite_games), 0::bigint, 'Ben sieht Annas Lieblingsspiele nicht');
select is((select count(*) from public.bgg_links), 0::bigint, 'Ben sieht Annas BGG-Namen nicht');
select is_empty($$update public.collection_items set note = 'Fremd' returning 1$$, 'Ben kann Annas Einträge nicht ändern');
select is_empty($$delete from public.collection_items returning 1$$, 'Ben kann Annas Einträge nicht löschen');
reset role;
-- Zeitstempel
set session_replication_role = replica;
update public.collection_items set created_at = '2020-01-01', updated_at = '2020-01-01';
set session_replication_role = origin;
update public.collection_items set note = 'neu';
select ok((select updated_at > '2020-01-01'::timestamptz and created_at = '2020-01-01'::timestamptz from public.collection_items),
'updated_at wird gesetzt, created_at bleibt');
-- Konto löschen entfernt die Sammlung
delete from auth.users where id = '11111111-1111-1111-1111-111111111111';
select is((select count(*) from public.collection_items), 0::bigint, 'Beim Löschen des Kontos verschwindet die Sammlung');
select * from finish();
rollback;

View file

@ -0,0 +1,174 @@
-- Partien, Mitspieler, Wertung, Statistik, Profil-Sicht und Konto löschen.
begin;
select plan(39);
insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Anna"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Ben"}'),
('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Cara"}'),
('44444444-4444-4444-4444-444444444444', 'dan@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Dan"}');
update public.profiles set city = 'Hannover' where id = '11111111-1111-1111-1111-111111111111';
update public.profiles set city = 'Bielefeld', city_visible = false where id = '22222222-2222-2222-2222-222222222222';
insert into public.games (id, title, visibility) values
('a0000000-0000-0000-0000-000000000001', 'Catan', 'catalog');
insert into public.games (id, title, visibility, owner_id) values
('a0000000-0000-0000-0000-000000000002', 'Annas Prototyp', 'private', '11111111-1111-1111-1111-111111111111');
insert into public.score_sheets (id, owner_id, name) values
('d0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'Catan Standard'),
('d0000000-0000-0000-0000-000000000002', '33333333-3333-3333-3333-333333333333', 'Caras Bogen');
insert into public.score_categories (id, sheet_id, name, position) values
('e0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'Siedlungen', 0),
('e0000000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000001', 'Straßen', 1);
-- ===== Anna legt Partien an =====
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;
select lives_ok(
$$insert into public.plays (id, owner_id, game_id, sheet_id, played_at, duration_minutes, client_id) values
('f0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000001',
'd0000000-0000-0000-0000-000000000001', '2026-10-01 19:00+00', 90, '90000000-0000-0000-0000-000000000001')$$,
'Anna darf eine Partie anlegen');
select throws_ok(
$$insert into public.plays (owner_id, game_id, played_at, client_id) values
('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000001', '2026-10-01 19:00+00', '90000000-0000-0000-0000-000000000001')$$,
'23505', null, 'Dieselbe client_id wird nicht doppelt angelegt (Offline-Abgleich)');
select throws_ok(
$$insert into public.plays (owner_id, game_id, played_at, client_id) values
('22222222-2222-2222-2222-222222222222', 'a0000000-0000-0000-0000-000000000001', '2026-10-01 19:00+00', '90000000-0000-0000-0000-000000000009')$$,
'42501', null, 'Anna kann keine Partien für andere anlegen');
select throws_ok(
$$insert into public.plays (owner_id, game_id, sheet_id, played_at, client_id) values
('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000002',
'2026-10-01 19:00+00', '90000000-0000-0000-0000-000000000008')$$,
'42501', null, 'Anna kann keinen fremden Wertungsbogen verwenden');
select throws_ok(
$$insert into public.plays (owner_id, game_id, played_at, note, client_id) values
('11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000001', '2026-10-01 19:00+00', repeat('x', 1001), '90000000-0000-0000-0000-000000000007')$$,
'23514', null, 'Notiz ist auf 1000 Zeichen begrenzt');
-- Mitspieler: sie selbst, ein Gast, Ben (Einladung), Cara (Einladung)
select lives_ok(
$$insert into public.play_players (id, play_id, profile_id, link_status, place, total_score) values
('f1000000-0000-0000-0000-000000000001', 'f0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'confirmed', 1, 10)$$,
'Anna trägt sich selbst als Mitspielerin ein');
select lives_ok(
$$insert into public.play_players (play_id, guest_name, link_status, place, total_score) values
('f0000000-0000-0000-0000-000000000001', 'Opa', 'none', 2, 8)$$,
'Anna darf einen Gast eintragen');
select lives_ok(
$$insert into public.play_players (id, play_id, profile_id, link_status, place, total_score) values
('f1000000-0000-0000-0000-000000000003', 'f0000000-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'pending', 3, 6)$$,
'Anna darf Ben einladen (offen)');
select throws_ok(
$$insert into public.play_players (play_id, profile_id, link_status) values
('f0000000-0000-0000-0000-000000000001', '33333333-3333-3333-3333-333333333333', 'confirmed')$$,
'42501', null, 'Anna kann Cara nicht selbst als bestätigt eintragen');
select throws_ok(
$$insert into public.play_players (play_id, guest_name, link_status) values
('f0000000-0000-0000-0000-000000000001', 'Bella', 'confirmed')$$,
'42501', null, 'Ein Gast kann nicht als bestätigt eingetragen werden');
select throws_ok(
$$update public.play_players set link_status = 'confirmed' where id = 'f1000000-0000-0000-0000-000000000003'$$,
'P0001', 'link_change_not_allowed', 'Anna kann Bens Einladung nicht selbst bestätigen');
select lives_ok(
$$update public.play_players set place = 2, total_score = 9 where id = 'f1000000-0000-0000-0000-000000000003'$$,
'Anna darf Platz und Punkte ändern');
-- Wertungsbogen und Werte
select lives_ok(
$$insert into public.play_scores (play_player_id, category_id, value) values
('f1000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', 6),
('f1000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000002', 4)$$,
'Anna darf Einzelwerte erfassen');
select throws_ok(
$$insert into public.play_scores (play_player_id, category_id, value) values
('f1000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', 7)$$,
'23505', null, 'Je Mitspieler und Kategorie gibt es einen Wert');
-- Limit von 20 Mitspielern (3 vorhanden, 17 weitere sind erlaubt)
select lives_ok(
$$insert into public.play_players (play_id, guest_name) select 'f0000000-0000-0000-0000-000000000001', 'Gast ' || g from generate_series(1, 17) g$$,
'Bis 20 Mitspieler sind erlaubt');
select throws_ok(
$$insert into public.play_players (play_id, guest_name) values ('f0000000-0000-0000-0000-000000000001', 'Zu viel')$$,
'P0001', 'play_players_limit_exceeded', 'Der 21. Mitspieler wird abgelehnt');
reset role;
-- ===== Ben (eingeladen, noch offen) =====
select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true);
set local role authenticated;
select is((select count(*) from public.plays), 1::bigint, 'Ben sieht die Partie, zu der er eingeladen ist');
select is((select count(*) from public.my_game_stats), 0::bigint, 'Offene Einladungen zählen nicht in die Statistik');
select is((select count(*) from public.public_profiles), 2::bigint, 'Ben sieht sich und die Einladende');
select is((select city from public.public_profiles where id = '11111111-1111-1111-1111-111111111111'),
'Hannover', 'Stadt ist sichtbar, wenn freigegeben');
select throws_ok(
$$update public.play_players set place = 1 where id = 'f1000000-0000-0000-0000-000000000003'$$,
'P0001', 'invitation_answer_only', 'Ben darf nur die Einladung beantworten, nichts sonst ändern');
select is_empty($$update public.play_players set link_status = 'confirmed' where id = 'f1000000-0000-0000-0000-000000000001' returning 1$$,
'Ben kann nicht für andere bestätigen');
select throws_ok(
$$insert into public.play_scores (play_player_id, category_id, value) values
('f1000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000001', 5)$$,
'42501', null, 'Ben kann keine Werte erfassen');
select is_empty($$update public.plays set note = 'Fremd' returning 1$$, 'Ben kann die Partie nicht ändern');
select lives_ok(
$$update public.play_players set link_status = 'confirmed' where id = 'f1000000-0000-0000-0000-000000000003'$$,
'Ben darf seine Einladung bestätigen');
select is((select count(*) from public.play_scores), 2::bigint, 'Bestätigte Mitspieler sehen die Werte');
select is((select count(*) from public.score_sheets), 0::bigint, 'Ben sieht Annas Wertungsbogen nicht');
select is((select play_count from public.my_game_stats), 1::bigint, 'Bestätigte Partien zählen in die Statistik');
reset role;
-- ===== Cara (eingeladen, lehnt ab) und Dan (Fremder) =====
-- Aufbau als Superuser: keine Nutzer-Claims aktiv
select set_config('request.jwt.claims', '', true);
update public.play_players
set profile_id = '33333333-3333-3333-3333-333333333333', guest_name = null, link_status = 'pending'
where play_id = 'f0000000-0000-0000-0000-000000000001' and guest_name = 'Gast 1';
select set_config('request.jwt.claims', '{"sub":"33333333-3333-3333-3333-333333333333","role":"authenticated"}', true);
set local role authenticated;
select lives_ok(
$$update public.play_players set link_status = 'declined' where profile_id = '33333333-3333-3333-3333-333333333333'$$,
'Cara darf die Einladung ablehnen');
select is((select count(*) from public.plays), 0::bigint, 'Wer abgelehnt hat, sieht die Partie nicht mehr');
select is((select count(*) from public.play_players), 1::bigint, 'Cara sieht nur ihre eigene Zeile');
reset role;
select set_config('request.jwt.claims', '{"sub":"44444444-4444-4444-4444-444444444444","role":"authenticated"}', true);
set local role authenticated;
select is((select count(*) from public.plays), 0::bigint, 'Fremde sehen keine Partien');
select is((select count(*) from public.play_players), 0::bigint, 'Fremde sehen keine Mitspieler');
select is((select count(*) from public.play_scores), 0::bigint, 'Fremde sehen keine Werte');
select is((select count(*) from public.public_profiles), 1::bigint, 'Fremde sehen nur sich selbst, es gibt keine Nutzersuche');
reset role;
-- ===== Statistik: Siegquote zählt nur Partien mit Ergebnis =====
select set_config('request.jwt.claims', '', true);
insert into public.plays (id, owner_id, game_id, played_at, duration_minutes, client_id) values
('f0000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000001', '2026-10-02', 60, '90000000-0000-0000-0000-000000000002'),
('f0000000-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111', 'a0000000-0000-0000-0000-000000000001', '2026-10-03', 30, '90000000-0000-0000-0000-000000000003');
insert into public.play_players (play_id, profile_id, link_status, place) values
('f0000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'confirmed', 2),
('f0000000-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111', 'confirmed', null);
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;
select results_eq(
$$select play_count, total_minutes, wins, plays_with_result, win_rate from public.my_game_stats where game_id = 'a0000000-0000-0000-0000-000000000001'$$,
$$values (3::bigint, 180::bigint, 1::bigint, 2::bigint, 0.5000::numeric)$$,
'Statistik: 3 Partien, 180 Minuten, 1 Sieg bei 2 Partien mit Ergebnis, Siegquote 0,5');
reset role;
-- ===== Konto löschen: Partien bleiben für andere, ohne Beteiligte verschwinden sie =====
select set_config('request.jwt.claims', '', true);
delete from auth.users where id = '11111111-1111-1111-1111-111111111111';
select is((select count(*) from public.plays), 1::bigint, 'Nach dem Löschen bleibt nur die Partie mit einem anderen bestätigten Mitspieler');
select is((select owner_id is null from public.plays), true, 'Die Partie hat keinen Ersteller mehr');
select is((select count(*) from public.play_players where profile_id is null and guest_name is null and link_status = 'none'),
1::bigint, 'Der gelöschte Nutzer erscheint anonym');
select * from finish();
rollback;