42 KiB
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 22 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu catalog_sources, publishers, game_publishers und game_reports aus KAT-05 sowie game_alt_titles aus KAT-01). 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, description_locale, year, min_players, max_players, min_minutes, max_minutes, min_age, image_path, bgg_id (optional, eindeutig), visibility (catalog oder private), hidden_at, merged_into_id (FK → games), manual_fields, catalog_source_id (FK → catalog_sources), source_ref, source_url, source_synced_at, title_search (generiert) |
Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die sieben Spalten vor title_search gehören der Katalogpflege (KAT-05), title_search der Suche (KAT-01), siehe unten |
game_reports |
owner_id (FK → profiles), origin (user/import), title, publisher_name, year, link, bgg_id, provisional_game_id (FK → games), status (open/approved/rejected/merged), target_game_id (FK → games), resolved_at, editions_cleared, result_seen_at |
Meldungen fehlender Spiele (KAT-05 PR 2c; Anlegen durch Nutzer und Ergebnishinweis KAT-04 PR 13a); siehe Katalogpflege und Abschnitt „Meldungen (KAT-04)“ |
catalog_sources |
key, name, attribution_text, attribution_url, images_allowed, texts_allowed, terms_url, terms_checked_at |
Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt bgg ohne Erlaubnis an |
publishers, game_publishers |
name (eindeutig ohne Beachtung der Schreibweise), name_search (generiert); game_id, publisher_id |
Verlage und ihre Zuordnung zu Spielen (Klärung 4) |
game_alt_titles |
game_id (FK → games), title, locale (de/en), title_search (generiert) |
Alternative Titel eines Spiels mit Sprache (Klärung 3, KAT-01); die Suche findet das Spiel auch darüber |
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 (in R1 nur über Studio/SQL, E10); private Spiele nur der Ersteller, und nur die Spalten laut Spaltenrechten |
game_reports |
nur der Meldende liest seine Zeilen (Admin-Policy is_admin() für R4) |
Anlegen: eigene Zeile mit eigenem vorläufigen Eintrag (Spaltenrechte, Policy game_reports_insert_own, Tageslimit); Ändern: nur result_seen_at an eigenen, bereits bearbeiteten Meldungen; kein Löschen. Bearbeiten (Status, Ziel) nur über admin_resolve_report (Studio) |
game_alt_titles |
für alle, auch ohne Anmeldung; folgt dem Spiel (alternative Titel eines fremden privaten Spiels sind unsichtbar) | niemand über die API (kein Recht, keine Policy); Pflege nur über Studio und das Startbestand-Werkzeug |
catalog_sources, publishers, game_publishers, View catalog_games |
für alle, auch ohne Anmeldung (game_publishers folgt dem Spiel) |
Quellen und Verlage nur Admin; game_publishers Admin oder Ersteller des privaten Spiels; die View ist nicht beschreibbar |
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. In R1 wird sie niemandem vergeben; Chris pflegt über Studio (Datenbankrolle
postgres, KAT-05 E10).
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; U+200B (Zero Width Space) ist an keiner Stelle erlaubt, U+200C/U+200D (ZWNJ/ZWJ, u. a. in Emoji-Sequenzen) schon |
display_name_invalid |
terms_accepted_at,age_confirmed_atsetzt der Trigger aufnow().terms_versionund beide Zeitstempel kann der Nutzer nicht ändern (Spalten-Grants).locale: nurde/en, sonst und als Spalten-Standard'en'.- Checks
profiles_display_name_not_blank(char_length(trim_ws(display_name)) between 1 and 30) undprofiles_display_name_no_zwsp(kein U+200B) gelten auch für spätere Änderungen (KTO-02, SQLSTATE23514). - Trigger
sync_locale_to_auth: Ändert sichprofiles.locale, schreibtsync_profile_locale_to_auth()(security definer, leerersearch_path) den Wert inauth.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_idje 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
playsundplay_playersper 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:
- Stadt: Der Nutzer kann sie im Profil ausblenden (Schalter
city_visible). - Konto löschen: Partien bleiben für andere Mitspieler erhalten, der gelöschte Nutzer erscheint als „Gelöschter Nutzer“.
- Listen: Besitz, Wunschliste und Einkaufsliste bleiben in einer Tabelle; Preis und Laden können später ergänzt werden.
- Katalogtexte: Eine Sprache je Spiel in R1.
Danach folgt diese Reihenfolge:
- Modell von dir freigeben lassen.
- Supabase-Projekt lokal aufsetzen (
devops,backend-dev). - Migrationen, RLS-Policies und pgTAP-Tests je Bereich schreiben.
- 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' |
|
description_locale |
text | nein | de oder en, nur zusammen mit description |
|
hidden_at |
timestamptz | nein | gesetzt = ausgeblendet | |
merged_into_id |
uuid | nein | FK → games (on delete restrict), nur auf Katalogspiele; nur mit hidden_at |
|
manual_fields |
text[] | ja | '{}' |
von Hand gepflegte Felder (Trigger), nur bekannte Schlüssel: title, description, description_locale, year, min_players, max_players, min_minutes, max_minutes, min_age, image_path, genres, mechanics, categories, publishers, alt_titles |
catalog_source_id |
uuid | nein | FK → catalog_sources (on delete restrict) |
|
source_ref |
text | nein | Kennung in der Quelle; genau dann gesetzt, wenn catalog_source_id gesetzt ist |
|
source_url |
text | nein | https://, nur mit Quelle |
|
source_synced_at |
timestamptz | nein | letzter Abgleich, nur mit Quelle | |
title_search |
text | nein | generiert | public.search_normalize(title), gespeichert; nicht beschreibbar (KAT-01) |
created_at |
timestamptz | ja | now() |
|
updated_at |
timestamptz | ja | now() |
catalog_sources
| Spalte | Typ | Pflicht | Standard | Schlüssel |
|---|---|---|---|---|
id |
uuid | ja | gen_random_uuid() |
PK |
key |
text | ja | eindeutig, ^[a-z0-9_]{2,30}$ |
|
name |
text | ja | ||
attribution_text |
text | nein | ||
attribution_url |
text | nein | https:// |
|
images_allowed |
boolean | ja | false |
|
texts_allowed |
boolean | ja | false |
|
terms_url |
text | nein | https:// |
|
terms_checked_at |
date | nein | ||
created_at, updated_at |
timestamptz | ja | now() |
publishers hat id, name (1 bis 200 Zeichen, eindeutig ohne Beachtung der Schreibweise), name_search (generiert, public.search_normalize(name)), created_at, updated_at. game_publishers verbindet wie die anderen Zuordnungen ein Spiel (game_id, FK → games, on delete cascade) mit einem Verlag (publisher_id, FK → publishers, on delete cascade); das Paar ist eindeutig.
game_alt_titles (KAT-01)
| Spalte | Typ | Pflicht | Standard | Schlüssel |
|---|---|---|---|---|
id |
uuid | ja | gen_random_uuid() |
PK |
game_id |
uuid | ja | FK → games (on delete cascade) |
|
title |
text | ja | 1 bis 200 Zeichen | |
locale |
text | ja | de oder en |
|
title_search |
text | ja | generiert | public.search_normalize(title), gespeichert; darf nicht leer sein (ein Titel nur aus Sonderzeichen ist unzulässig) |
created_at, updated_at |
timestamptz | ja | now() |
Eindeutig ist (game_id, locale, title_search): derselbe Titel in derselben Sprache nur einmal, unabhängig von der Schreibweise; mehrere Titel je Sprache sind erlaubt. Lesbar wie das Spiel; kein Schreibrecht für API-Rollen.
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() |
game_reports (KAT-05, Migration game_reports):
| Spalte | Typ | Pflicht | Standard | Schlüssel |
|---|---|---|---|---|
id |
uuid | ja | gen_random_uuid() |
PK |
owner_id |
uuid | ja | FK → profiles (on delete cascade) |
|
origin |
text | ja | 'user' |
user oder import |
title |
text | ja | 1 bis 200 Zeichen | |
publisher_name |
text | nein | höchstens 200 | |
year |
smallint | nein | 1800 bis 2100 | |
link |
text | nein | http(s)://, höchstens 500 |
|
bgg_id |
integer | nein | ||
provisional_game_id |
uuid | nein | FK → games (on delete set null), eindeutig (1:1) |
|
status |
text | ja | 'open' |
open, approved, rejected, merged |
target_game_id |
uuid | nein | FK → games (on delete restrict) |
|
resolved_at |
timestamptz | nein | gesetzt genau dann, wenn status <> 'open' |
|
editions_cleared |
integer | ja | 0 |
Anzahl der Verweise (Sammlung, Partien), deren Edition beim Umhängen geleert wurde (variants_cleared); Check >= 0; setzen nur admin_resolve_report und adopt_report_target (Spec-F6) |
result_seen_at |
timestamptz | nein | Der Nutzer hat den Ergebnishinweis weggeklickt (KAT-04, Spec-F5); Check: nur bei status <> 'open' |
|
created_at, updated_at |
timestamptz | ja | now() |
Checks: offen hat kein Ziel; approved/merged brauchen ein Ziel. Indizes: (owner_id, created_at), eindeutig (owner_id, bgg_id) für offene Import-Meldungen (N12a), (created_at) für offene Meldungen, eindeutig provisional_game_id, target_game_id.
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() |
Drei 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, catalog_games zeigt Suche und Entdecken die sichtbaren Katalogspiele (siehe nächster Abschnitt).
Katalogpflege (KAT-05, Migration catalog_foundation)
Die Pflege läuft in R1 nur über Supabase Studio bzw. psql als Datenbankrolle postgres (Klärungen 25/44, Ablauf: docs/katalog-pflege.md). Spec: docs/specs/2-katalog-daten.md.
- Ausblenden:
games.hidden_atgesetzt = ausgeblendet. Die Viewcatalog_gamesenthält nur Katalogspiele (visibility = 'catalog') ohnehidden_atund ohnemerged_into_id. Suche und Entdecken lesen nur diese View. Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf). - Zusammenführen (Migration
catalog_merge, PR 2b):games.merged_into_idzeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehlermerged_into_not_catalog). Regel:merged_into_idwird nur überpublic.admin_merge_games(p_source, p_target) returns jsonbgesetzt, nie von Hand. (Ablauf und Ergebnis-JSON:docs/katalog-pflege.md, Abschnitt 5). Sie istsecurity invoker, prüftcurrent_user in ('postgres', 'supabase_admin')(sonstadmin_only) und ist füranon,authenticatedundservice_rolenicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktionpublic.repoint_game_references(p_from, p_to)Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet:lower(btrim(name)), bei mehreren die älteste, dann die kleinsteid) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgtownedvorwishlistvorshopping, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat.bgg_idundcatalog_source_id/source_*gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (hidden_at,merged_into_id); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (07) listet alle Fremdschlüssel aufgamesund lässt neue Verweise erst nach einer Entscheidung zu. - Meldungen (Migration
game_reports, PR 2c): Tabellegame_reports(siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy aufowner_id; die Policy füris_admin()ist für R4 vorbereitet);anonhat keine Rechte;authenticatedbekommt ab KAT-04 (Migrationgame_reports_user) Insert- und Update-Rechte nur spaltenweise (siehe Abschnitt „Meldungen (KAT-04)“). Bearbeitet wird in R1 nur überpublic.admin_resolve_report(p_report, p_status, p_target) returns jsonb(Ablauf, Ergebnis und Fehler:docs/katalog-pflege.md, Abschnitt 6):security invoker, prüftcurrent_user in ('postgres', 'supabase_admin')(sonstadmin_only), füranon,authenticatedundservice_rolenicht ausführbar.approvedundmergedhängen den vorläufigen privaten Eintrag überrepoint_game_referencesauf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert,variants_cleared) und blenden ihn aus (hidden_at,merged_into_id);rejectedspeichert höchstens den Verweis.admin_merge_gameshängt zusätzlichgame_reports.target_game_idvon der Quelle auf das Ziel (reports_repointed). Der Triggergames_visibility_immutablelehnt jede Änderung vonvisibilityundowner_idan bestehenden Spielen ab (Fehlervisibility_immutable, für alle Rollen auchpostgres; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501).admin_resolve_reportschreibt außerdemeditions_cleared(Anzahl geleerter Editionen, 0 beirejectedund ohne Umhängen), damit die App den Nutzer zum Neuwählen auffordern kann (Spec-F6). Beim Konto-Löschen werden Meldungen und vorläufige Einträge (auch nach der Freigabe) hart gelöscht, Katalogspiele und Daten anderer Nutzer bleiben. Fremdschlüssel-Wächter:07und08. - Manuell gepflegte Felder: Der Trigger
games_mark_manual_fieldsträgt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, inmanual_fieldsein (beim Anlegen alle gesetzten Felder). Der Triggergame_links_mark_manualergänztgenres,mechanics,categoriesundpublishers, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rollecatalog_importerund Einstellungshuffle.catalog_import = 'on'(der Import setzt sie perset local; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder. - Anzeige-Erlaubnis:
catalog_games.descriptionundimage_pathsind leer, wenn die Quelle (catalog_sources) Texte bzw. Bilder nicht erlaubt und das Feld nicht inmanual_fieldssteht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import). - Spaltenrechte auf
games: Nutzer dürfen nurowner_id(nur beim Anlegen),title,description,description_locale,year,min_players,max_players,min_minutes,max_minutes,min_age,image_path,bgg_idundvisibilityschreiben, nichtid,hidden_at,merged_into_id,manual_fields,catalog_source_idundsource_*. - Startbestand:
supabase/catalog/(CSV, Werkzeug, erzeugte SQL-Dateien);supabase db resetspielt Vokabular und Startbestand ein. Der Startbestand enthält nur Fakten und einen eigenen deutschen Kurztext, kein Cover, keine Quelle, keine BGG-Kennung. - Konto löschen: Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (
game_publishersusw.) werden mit dem Spiel gelöscht. - API-Änderungen für das Frontend: neu lesbar für alle: View
catalog_games, Tabellencatalog_sources,publishers,game_publishers; neu für den Meldenden lesbar:game_reports. Das Ändern vonvisibilityan einem Spiel ist nicht mehr möglich (P0001visibility_immutable); wer eine Sichtbarkeit mitsendet, muss den alten Wert senden oder die Spalte weglassen. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keineid,created_atundupdated_atmehr mitsenden (42501; die ID kommt perreturning); eintoJson()eines Modells darf nie ungefiltert aninsert/updategehen (Merkpunkt für #3 und #13). Deris_admin()-Zweig der Zuordnungs-Policies (game_*_write) greift für Katalogspiele faktisch nicht mehr, weil der Triggergame_links_mark_manualmanual_fieldsschreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das DomänenmodellCatalogGamelegt KAT-01 (#3) auf Basis der Spalten voncatalog_gamesan.
Meldungen (KAT-04, Migration game_reports_user)
Spec: docs/specs/13-fehlendes-spiel-melden.md (Umsetzungsnachtrag: docs/specs/13-umsetzung.md). Nur additiv zu game_reports aus KAT-05; keine neue Tabelle, kein neuer Fremdschlüssel.
Ablauf (Nutzer): Die App ruft zuerst find_similar_catalog_games(titel, jahr); gibt es Treffer, bestätigt der Nutzer ausdrücklich, dass sein Spiel ein anderes ist. Danach report_missing_game(...) mit p_confirmed_distinct = true. Die RPC legt in einer Transaktion den vorläufigen privaten Eintrag (nur Titel und Jahr), die Meldung (status = 'open', origin = 'user') und optional den Listeneintrag an. Der Admin bearbeitet die Meldung manuell (admin_resolve_report, docs/katalog-pflege.md Abschnitt 6). Bei „abgelehnt mit Verweis“ entscheidet der Nutzer selbst über den Wechsel auf den Zieleintrag (adopt_report_target). Den Ergebnishinweis klickt die App mit einem Update von result_seen_at weg.
Funktionen (alle mit search_path = ''; jede zuerst für public, anon, authenticated, service_role entzogen, danach nur der genannte Grant):
| Funktion | Art | execute |
Zweck |
|---|---|---|---|
strip_leading_article(text), normalize_game_title(text) |
sql, immutable | authenticated |
Titel normalisieren; normalize_game_title = search_normalize plus ein führender Artikel (de/en), Rückfall lower(btrim) |
report_day(timestamptz) |
sql, immutable | authenticated |
Berliner Kalendertag (Tageslimit); authenticated braucht das Recht, weil der Trigger als security invoker läuft |
find_similar_catalog_games(p_title, p_year) |
RPC, security invoker, stable |
authenticated |
höchstens 5 sichtbare Katalogspiele mit gleichem normalisierten Titel (Regel A, jedes Jahr) oder Titel-Ähnlichkeit >= 0,8 und Jahr ± 1 (Regel B; fehlt ein Jahr, ohne Jahresbedingung); Kandidatentitel sind der Haupttitel und alle alternativen Titel (title_search); Rückgabe game_id, title, matched_title, year, publishers, match_kind ('same_title'/'similar_title'), similarity; Titel leer oder über 200 Zeichen ergibt eine leere Menge |
report_missing_game(p_title, p_publisher_name, p_year, p_link, p_list, p_confirmed_distinct) |
RPC, security invoker |
authenticated |
Meldung + vorläufiger Eintrag + optional Listeneintrag; Rückgabe {"report_id", "provisional_game_id", "collection_item_id"} |
adopt_report_target(p_report) |
RPC, security definer, Eigentümer postgres |
authenticated |
Wechsel auf den Zieleintrag einer mit Verweis abgelehnten Meldung; Rückgabe: Zähler aus repoint_game_references |
game_reports_daily_limit() |
Trigger-Funktion, security invoker |
niemand | Tageslimit |
Fehlercodes (raise exception '<code>' using errcode = 'P0001'; die App bildet sie auf Domänenfehler ab):
report_missing_game: not_authenticated, report_title_required, report_title_too_long, report_publisher_too_long, report_year_invalid, report_link_invalid, report_list_invalid, report_daily_limit, report_similar_exists, report_provisional_in_use (nur bei gleichzeitigem Konflikt auf den Teilindex; über die RPC praktisch nicht erreichbar, ein direkter Insert liefert 23505).
adopt_report_target: not_authenticated, report_not_found (unbekannt, fremd oder vorläufiger Eintrag gehört nicht dem Aufrufer), report_not_adoptable, report_no_provisional, report_already_adopted, report_target_not_listed, report_foreign_references.
Tageslimit im Trigger: report_daily_limit.
Rechte und Policies auf game_reports: Insert-Recht nur auf owner_id, title, publisher_name, year, link, provisional_game_id; Update-Recht nur auf result_seen_at; kein Löschrecht. Nicht schreibbar (42501): id, origin, status, bgg_id, target_game_id, resolved_at, editions_cleared, created_at, updated_at. Policy game_reports_insert_own: owner_id = auth.uid(), origin = 'user', status = 'open', kein Ziel, provisional_game_id verweist auf ein eigenes privates, nicht ausgeblendetes und nicht zusammengeführtes Spiel. Policy game_reports_update_seen: nur eigene Meldungen mit status <> 'open'. Lesen unverändert (nur eigene).
Tageslimit (Krit. 7): Trigger game_reports_daily_limit (before insert ... when (new.origin = 'user')) lässt höchstens 10 Nutzer-Meldungen je Nutzer und Berliner Kalendertag zu (Advisory-Lock je Nutzer gegen gleichzeitige Inserts, setzt READ COMMITTED voraus, den Standard bei PostgREST). Import-Meldungen (origin = 'import') werden weder gezählt noch begrenzt (N12). Der Trigger gilt für jede Rolle: Wer als postgres viele Nutzer-Meldungen „von heute“ anlegt (z. B. in Tests), stößt ebenfalls auf das Limit.
adopt_report_target (security definer, Begründung): Nutzer dürfen merged_into_id und hidden_at nicht schreiben und repoint_game_references nicht ausführen. Die Funktion bestimmt den Aufrufer nur über auth.uid(), prüft Eigentum an Meldung und vorläufigem Eintrag, dass das Ziel ein sichtbares Katalogspiel ist und dass keine Verweise mit fremdem oder fehlendem Eigentümer (collection_items, favorite_games, plays, score_sheets) am vorläufigen Eintrag hängen; sie ruft nie eine admin_*-Funktion auf. Sperrreihenfolge: erst die Meldung, dann beide Spiele nach id. result_seen_at bleibt unverändert, damit der Hinweis am Zieleintrag erscheint, bis der Nutzer ihn wegklickt. editions_cleared wird wie in admin_resolve_report gesetzt.
Normalisierung (gemeinsam mit KAT-01): search_normalize(text) (Migration catalog_search) ist die einzige Basis und entfernt nie Artikel. normalize_game_title und der Kandidatenschlüssel in find_similar_catalog_games entfernen zusätzlich einen führenden Artikel. Ein Titel, der nach search_normalize leer ist (nur nicht-lateinische Zeichen oder Sonderzeichen), fällt auf lower(btrim(titel)) zurück.
API-Änderungen für das Frontend (13b): neue RPCs find_similar_catalog_games, report_missing_game, adopt_report_target; game_reports ist für authenticated jetzt beschreibbar, aber nur wie oben (nie mit id, created_at, status, origin, target_game_id, resolved_at, bgg_id, editions_cleared senden; die App schreibt über die RPCs und das Update von result_seen_at mit {'result_seen_at': <ISO-8601 UTC>}). Es gibt keine generierten Datenbanktypen im Repo.
Katalogsuche (KAT-01, Migration catalog_search)
Spec: docs/specs/3-katalog-suche.md. Die Suche liest ausschließlich über die RPC search_catalog_games, die ihrerseits die View catalog_games nutzt.
- Erweiterungen:
pg_trgmundunaccentim Schemaextensions(Bestandteil des Supabase-Postgres-Images; lokal geprüft, auf Staging/Produktion vor dem Merge mitselect * from pg_available_extensions where name in ('pg_trgm','unaccent')bestätigen). - Gemeinsame Normalisierung:
public.search_normalize(text)ist die einzige Basisfunktion für normalisierte Titel. Sie wird von #3 (PR 3a) angelegt, ist rein rechnend und füranon,authenticatedundservice_roleausführbar, weil generierte Spalten undsecurity invoker-Funktionen sie mit den Rechten des Aufrufers berechnen. #13 legtpublic.normalize_game_title(text)darüber an und entfernt nur dort Artikel;search_normalizeentfernt nie Artikel. Ablauf: Marken- und Schutzzeichen (®©™℗℠) entfernen, Akzente/Umlaute entfernen (unaccent,ß→ss,Æ→ae), Kleinschreibung, alles außera–z/0–9zu einem Leerzeichen, Ränder kürzen, leer =null. Beispiele:'CATAN®'→'catan','Spiel™ ½'→'spiel 1 2','Æon’s End'→'aeon s end','Café Größe'→'cafe grosse','!!!'→null. Nicht-lateinische Zeichen werden entfernt. Ein Suchwort wie „kaese“ findet „Käse“ in R1 nicht (Spec-F5). - Generierte Spalten (gespeichert, nicht beschreibbar):
games.title_search,game_alt_titles.title_search,publishers.name_search, je mit Trigramm-Index (GIN; der Index aufgamesist ein Partial-Index mit den Bedingungen der Viewcatalog_games). Die Indizes sind keine Geschwindigkeitszusage, maßgeblich ist die Messung (supabase/perf/catalog_search_perf.sql, Ergebnisse und Pläne:docs/specs/3-katalog-suche-umsetzung.md). Wirdsearch_normalizeoderunaccent.rulesgeändert (Image-Upgrade), müssen die Spalten per Migration neu angelegt werden; Prüfabfrage:docs/katalog-pflege.md, Abschnitt 8. manual_fields: neuer Schlüsselalt_titles. Der Check nennt jetzt 15 Schlüssel (siehe Tabellenstruktur); jede Migration nennt die vollständige Liste.- Alternative Titel beim Zusammenführen:
admin_merge_gameshängtgame_alt_titlesnicht um (Spec-F6 A); sie bleiben am ausgeblendeten Quell-Eintrag. Vor dem Zusammenführen ergänzt der Admin relevante Titel am Ziel (docs/katalog-pflege.md, Abschnitt 5, Schritt 2b). - RPC
public.search_catalog_games(p_query, p_players, p_max_minutes, p_age, p_genre_ids, p_mechanic_ids, p_limit default 30, p_offset default 0)(stable,security invoker, leerersearch_path; ausführbar füranonundauthenticated, nicht fürpublicundservice_role). Vertrag und Semantik: Spec 4.2. Ausgabe je Treffer:id,title,year,min_players,max_players,min_minutes,max_minutes,min_age,image_path(nur auscatalog_games, maskiert nach Quelle),publisher_names,matched_alt_title,match_rank(0 bis 4, ohne Suchbegriffnull),total_count. Umsetzungsdetails, die der Vertrag offen ließ:nullbeip_limit/p_offsetgilt als Standardwert; „nur Leerraum“ (auch Tabulator, Zeilenumbruch) gilt als kein Suchbegriff; die Ähnlichkeit (zweites Sortierkriterium) ist die höchste Trigramm-Ähnlichkeit über Haupttitel und alle alternativen Titel; bei gleicher Stufe und Ähnlichkeit wird binär (collate "C") nachtitle_search, dann nachidsortiert (ohne Suchbegriff ebenso nachtitle_search, dannid). Fehler (P0001, HTTP 400):search_query_too_long,search_filter_invalid,search_paging_invalid. Einsmallint-Parameter außerhalb von −32768…32767 scheitert schon beim Umwandeln (keinP0001). - Rechte der Rolle
catalog_importer: Wergamesschreibt, berechnettitle_searchmit den eigenen Rechten. Der spätere Import (SAM-08) braucht deshalbexecuteaufpublic.search_normalize(text),usageauf das Schemaextensionsund Schreibrechte aufgame_alt_titles; die Test-Rolle in06_catalog_foundation.test.sqlbekommt die beiden ersten. - Suchbegriffe werden nicht gespeichert; der Aufruf per
POST(client.rpc) hält sie aus den URLs der Zugriffsprotokolle. Protokoll-Einstellungen der Datenbank und der Befund zu GET-Aufrufen:docs/specs/3-katalog-suche-umsetzung.md, Abschnitt Protokollprüfung. - API-Änderungen für das Frontend: neu lesbar: Tabelle
game_alt_titles, Spaltengames.title_searchundpublishers.name_search(überselect=*mitgeliefert, die App liest sie nicht), RPCsearch_catalog_games. Es gibt keine generierten Datenbanktypen im Repo; das DomänenmodellCatalogGameSummary(3b) folgt der Ausgabe oben.