Shuffle/docs/datenmodell-r1.md
cschlaefke 395816609f feat(db): enforce registration rules and add terms_version
Adds display name validation, terms_version, locale sync trigger and
pgTAP tests (KTO-01 criteria 1, 2, 10, 11). Includes GoTrue spike results.

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:36:21 +01:00

24 KiB
Raw Blame History

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 (1–30 Zeichen nach Trim), bio (max. 300), city, city_visible, avatar_path, locale, terms_accepted_at, terms_version, age_confirmed_at, deleted_at Ein Profil je Konto; wird beim Registrieren automatisch angelegt. Regeln der Registrierung (Anzeigename, Zustimmung, Sprache): siehe unten
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.

Regeln bei der Registrierung (Migration auth_registration_rules, KTO-01)

Der Trigger handle_new_user legt das Profil an und lehnt die Registrierung mit einem Fehlertext (SQLSTATE P0001) ab, wenn eine Regel verletzt ist. Es gibt keinen stillen Ersatzwert.

Regel Fehlertext
Mindestalter (age_confirmed = true) age_confirmation_required
Zustimmung (terms_accepted = true) terms_acceptance_required
terms_version vorhanden, nach Trim 1–50 Zeichen (Nachweis der zugestimmten Rechtstexte) terms_version_required
display_name vorhanden, nach Trim 1–30 Unicode-Zeichen (Codepoints); kein E-Mail-Präfix als Ersatz, kein Kürzen display_name_invalid
  • terms_accepted_at, age_confirmed_at setzt der Trigger auf now(). terms_version und beide Zeitstempel kann der Nutzer nicht ändern (Spalten-Grants).
  • locale: nur de/en, sonst und als Spalten-Standard 'en'.
  • Check profiles_display_name_not_blank (char_length(btrim(display_name)) between 1 and 30) gilt auch für spätere Änderungen (KTO-02).
  • Trigger sync_locale_to_auth: Ändert sich profiles.locale, schreibt sync_profile_locale_to_auth() (security definer, leerer search_path) den Wert in auth.users.raw_user_meta_data.locale. GoTrue-Mailvorlagen lesen die Sprache von dort. Betroffen ist nur das eigene Konto.

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 'en'
terms_accepted_at timestamptz ja
terms_version text nein (Altdaten)
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 'en'::text
terms_accepted_at timestamptz ja
terms_version text nein (Altdaten)
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()