All checks were successful
Replace tests that could not fail (manual_fields stability, no-op update), select runbook games by id, add grant matrix and forged-admin tests, assert vocabulary seeding and Deno checksum in CI, clarify the set local convention and API notes. Refs #2 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
351 lines
23 KiB
Markdown
351 lines
23 KiB
Markdown
<!-- Quelle: Claude Docs „Shuffle – R1 Datenmodell“, Export 2026-11-02. Maßgeblich bleiben die Migrationen in supabase/migrations/. -->
|
||
|
||
# Shuffle – R1 Datenmodell
|
||
|
||
2026-11-02 · Chris
|
||
|
||
Dieses Modell deckt die 26 Stories von R1 ab (Konto, Katalog, Sammlung, Partien mit Wertung, Statistik) und ist so geschnitten, dass R2 (Gruppen, Termine) und R3 (Community) ohne Umbau darauf aufsetzen. Es läuft auf selbst gehostetem Supabase (PostgreSQL) bei Hetzner, siehe ADR-0002.
|
||
|
||
## Ziel und Annahmen
|
||
|
||
Grundlage sind die Entscheidungen vom 2. November 2026 aus den Anforderungen und den R1-Akzeptanzkriterien:
|
||
|
||
- **Eigener Katalog:** Spiele haben eine eigene ID. Die BGG-ID ist ein optionales Feld, damit Shuffle auch ohne BGG-Lizenz funktioniert (F2).
|
||
- **Eigener Eintrag je Spiel:** Pro Spiel gibt es einen Eintrag, Editionen sind Varianten davon (O4, O8).
|
||
- **Nutzerkonten:** Anmeldung nur mit E-Mail und Passwort, Anzeigename frei wählbar, Mindestalter 16 (F8, O1, O2).
|
||
- **Sichtbarkeit:** In R1 ist alles privat, außer der Stadt im Profil (F5, O3). Die Stadt lässt sich im Profil ausblenden.
|
||
- **Mitspieler:** Gäste ohne Konto sind möglich; verknüpfte Nutzer müssen bestätigen (F11).
|
||
- **Offline:** Nur das Erfassen von Partien funktioniert offline und wird später abgeglichen (F3).
|
||
- **Wertungsbögen:** Privat je Nutzer, keine gemeinsamen Vorlagen in R1 (O9).
|
||
- **Grenzen:** Passwort ab 10 Zeichen, Anzeigename 30, Kurzbeschreibung 300, 5 Lieblingsspiele, 20 Mitspieler je Partie, Notiz 1000 Zeichen, Avatar 5 MB (O10).
|
||
- **Sprachen:** Deutsch und Englisch für die Oberfläche (F1). Katalogtexte haben in R1 eine Sprache je Spiel; Mehrsprachigkeit folgt später.
|
||
|
||
## Überblick
|
||
|
||
*[Diagramm: Datenmodell R1 · 8 Kerntabellen – siehe Dokument in Claude]*
|
||
|
||
Die Pfeile zeigen, welche Tabelle auf welche verweist. Jede Partie (`plays`) hängt an einem Spiel und hat Mitspieler, deren Werte aus einem Wertungsbogen stammen. Nicht gezeigt: `favorite_games`, `bgg_links` und die Zuordnungstabellen der Begriffe.
|
||
|
||
## Tabellen
|
||
|
||
R1 braucht 20 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers` und `game_publishers` aus KAT-05; mit `game_reports` in KAT-05 PR 2c werden es 21). 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` | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die letzten sieben Spalten gehören der Katalogpflege (KAT-05), siehe unten |
|
||
| `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); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) |
|
||
| `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 |
|
||
| `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 |
|
||
| `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 |
|
||
| `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), `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_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()` | |
|
||
|
||
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:** `games.merged_into_id` zeigt auf das Zielspiel (Spalte seit 2a; die Funktion `admin_merge_games` kommt mit PR 2b). Ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`).
|
||
- **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`. 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.
|