Shuffle/docs/datenmodell-r1.md
cschlaefke ec42421312
All checks were successful
CI / flutter (pull_request) Successful in 2m9s
CI / web-build (pull_request) Successful in 1m33s
CI / supabase (pull_request) Successful in 10s
CI / supabase-tests (pull_request) Successful in 37s
CI / e2e (pull_request) Successful in 4m14s
docs(db): Review-Nachbesserungen PR #67 (Spec, Nachtrag, Release-Checkliste, Testkorrekturen) Refs #13
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 20:03:50 +01:00

41 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 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_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'.
  • Checks profiles_display_name_not_blank (char_length(trim_ws(display_name)) between 1 and 30) und profiles_display_name_no_zwsp (kein U+200B) gelten auch für spätere Änderungen (KTO-02, SQLSTATE 23514).
  • 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'
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; setzt nur admin_resolve_report (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; rejected hat kein Ziel (game_reports_rejected_no_target, ab KAT-04). 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_at gesetzt = ausgeblendet. Die View catalog_games enthält nur Katalogspiele (visibility = 'catalog') ohne hidden_at und ohne merged_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_id zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler merged_into_not_catalog). Regel: merged_into_id wird nur über public.admin_merge_games(p_source, p_target) returns jsonb gesetzt, nie von Hand. (Ablauf und Ergebnis-JSON: docs/katalog-pflege.md, Abschnitt 5). Sie ist security invoker, prüft current_user in ('postgres', 'supabase_admin') (sonst admin_only) und ist für anon, authenticated und service_role nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion public.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 kleinste id) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt owned vor wishlist vor shopping, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. bgg_id und catalog_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 auf games und lässt neue Verweise erst nach einer Entscheidung zu.
  • Meldungen (Migration game_reports, PR 2c): Tabelle game_reports (siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy auf owner_id; die Policy für is_admin() ist für R4 vorbereitet); anon hat keine Rechte; authenticated bekommt ab KAT-04 (Migration game_reports_user) Insert- und Update-Rechte nur spaltenweise (siehe Abschnitt „Meldungen (KAT-04)“). Bearbeitet wird in R1 nur über public.admin_resolve_report(p_report, p_status, p_target) returns jsonb (Ablauf, Ergebnis und Fehler: docs/katalog-pflege.md, Abschnitt 6): security invoker, prüft current_user in ('postgres', 'supabase_admin') (sonst admin_only), für anon, authenticated und service_role nicht ausführbar. approved und merged hängen den vorläufigen privaten Eintrag über repoint_game_references auf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert, variants_cleared) und blenden ihn aus (hidden_at, merged_into_id); rejected ändert nur Status und Zeitpunkt und ist nur ohne Ziel zulässig (ab KAT-04, Entscheidung Chris 2026-11-04). admin_merge_games hängt zusätzlich game_reports.target_game_id von der Quelle auf das Ziel (reports_repointed). Der Trigger games_visibility_immutable lehnt jede Änderung von visibility und owner_id an bestehenden Spielen ab (Fehler visibility_immutable, für alle Rollen auch postgres; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501). admin_resolve_report schreibt außerdem editions_cleared (Anzahl geleerter Editionen, 0 bei rejected und 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: 07 und 08.
  • Manuell gepflegte Felder: Der Trigger games_mark_manual_fields trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in manual_fields ein (beim Anlegen alle gesetzten Felder). Der Trigger game_links_mark_manual ergänzt genres, mechanics, categories und publishers, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle catalog_importer und Einstellung shuffle.catalog_import = 'on' (der Import setzt sie per set 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.description und image_path sind leer, wenn die Quelle (catalog_sources) Texte bzw. Bilder nicht erlaubt und das Feld nicht in manual_fields steht. 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 nur owner_id (nur beim Anlegen), title, description, description_locale, year, min_players, max_players, min_minutes, max_minutes, min_age, image_path, bgg_id und visibility schreiben, nicht id, hidden_at, merged_into_id, manual_fields, catalog_source_id und source_*.
  • Startbestand: supabase/catalog/ (CSV, Werkzeug, erzeugte SQL-Dateien); supabase db reset spielt 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_publishers usw.) werden mit dem Spiel gelöscht.
  • API-Änderungen für das Frontend: neu lesbar für alle: View catalog_games, Tabellen catalog_sources, publishers, game_publishers; neu für den Meldenden lesbar: game_reports. Das Ändern von visibility an einem Spiel ist nicht mehr möglich (P0001 visibility_immutable); wer eine Sichtbarkeit mitsendet, muss den alten Wert senden oder die Spalte weglassen. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine id, created_at und updated_at mehr mitsenden (42501; die ID kommt per returning); ein toJson() eines Modells darf nie ungefiltert an insert/update gehen (Merkpunkt für #3 und #13). Der is_admin()-Zweig der Zuordnungs-Policies (game_*_write) greift für Katalogspiele faktisch nicht mehr, weil der Trigger game_links_mark_manual manual_fields schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell CatalogGame legt KAT-01 (#3) auf Basis der Spalten von catalog_games an.

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): approved und merged hängen den vorläufigen Eintrag direkt um (ohne Aktion des Nutzers; Verweis auf einen vorhandenen Eintrag = merged), rejected gibt es nur ohne Ziel (Entscheidung Chris, 2026-11-04: kein „abgelehnt mit Verweis“). Den Ergebnishinweis klickt die App mit einem Update von result_seen_at weg. Zustände für die App: pending / approved / merged / rejected.

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; Ein Jahr außerhalb 1800–2100 gilt wie null; 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"}
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). 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.

admin_resolve_report seit KAT-04: weist rejected mit Ziel ab (report_reject_has_target, P0001); der Check game_reports_rejected_no_target (status <> 'rejected' or target_game_id is null) sichert die Tabelle. Die Funktion bleibt security invoker und nur für postgres/supabase_admin ausführbar. Es gibt in 13a keine security definer-Funktion.

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 und report_missing_game; 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.

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_trgm und unaccent im Schema extensions (Bestandteil des Supabase-Postgres-Images; lokal geprüft, auf Staging/Produktion vor dem Merge mit select * 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ür anon, authenticated und service_role ausführbar, weil generierte Spalten und security invoker-Funktionen sie mit den Rechten des Aufrufers berechnen. #13 legt public.normalize_game_title(text) darüber an und entfernt nur dort Artikel; search_normalize entfernt nie Artikel. Ablauf: Marken- und Schutzzeichen (®©™℗℠) entfernen, Akzente/Umlaute entfernen (unaccent, ß → ss, Æ → ae), Kleinschreibung, alles außer a–z/0–9 zu 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 auf games ist ein Partial-Index mit den Bedingungen der View catalog_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). Wird search_normalize oder unaccent.rules geändert (Image-Upgrade), müssen die Spalten per Migration neu angelegt werden; Prüfabfrage: docs/katalog-pflege.md, Abschnitt 8.
  • manual_fields: neuer Schlüssel alt_titles. Der Check nennt jetzt 15 Schlüssel (siehe Tabellenstruktur); jede Migration nennt die vollständige Liste.
  • Alternative Titel beim Zusammenführen: admin_merge_games hängt game_alt_titles nicht 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, leerer search_path; ausführbar für anon und authenticated, nicht für public und service_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 aus catalog_games, maskiert nach Quelle), publisher_names, matched_alt_title, match_rank (0 bis 4, ohne Suchbegriff null), total_count. Umsetzungsdetails, die der Vertrag offen ließ: null bei p_limit/p_offset gilt 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") nach title_search, dann nach id sortiert (ohne Suchbegriff ebenso nach title_search, dann id). Fehler (P0001, HTTP 400): search_query_too_long, search_filter_invalid, search_paging_invalid. Ein smallint-Parameter außerhalb von −32768…32767 scheitert schon beim Umwandeln (kein P0001).
  • Rechte der Rolle catalog_importer: Wer games schreibt, berechnet title_search mit den eigenen Rechten. Der spätere Import (SAM-08) braucht deshalb execute auf public.search_normalize(text), usage auf das Schema extensions und Schreibrechte auf game_alt_titles; die Test-Rolle in 06_catalog_foundation.test.sql bekommt 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, Spalten games.title_search und publishers.name_search (über select=* mitgeliefert, die App liest sie nicht), RPC search_catalog_games. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell CatalogGameSummary (3b) folgt der Ausgabe oben.