diff --git a/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index 6d59204..f115555 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -3,12 +3,14 @@ | | | |---|---| | Issue | #2 (KAT-05) | -| Status | freigegeben (Chris, 2026-11-03); Fragen F1–F9 entschieden wie empfohlen (§ 9.2) | +| Status | freigegeben (Chris, 2026-11-03); Spec-F1–Spec-F9 entschieden wie empfohlen (§ 9.2); Review PR #51 eingearbeitet (§ 10), Designwahlen mit „bitte bestätigen“ markiert (§ 9.3) | | Autor | architect | | Stand | 2026-11-03 | -| Etappe | R1.1 (Teil 2a: Datenmodell, Rechte, Startbestand), R1.2 (Teil 2b: Admin-Pflege per Studio; Teil 2c: Meldungen bearbeiten); Import (Krit. 1, 3, 6, Quellenteil von 2) erst mit Lizenz | -| Abhängigkeiten | keine (Fundament). Abhängig von #2: #3 KAT-01, #13 KAT-04, #25 SAM-08 | -| Quellen | `requirements/20-r1-akzeptanzkriterien.md` § KAT-05 und § Querschnitt, `requirements/r1-klaerungen.md` (3, 4, 5, 6, 7, 10, 24, 25/44, 26, 27, 39, 48, N11, N12, N12a), `requirements/backlog.md` (Abschnitt „Datenmodell-Änderungen für architect“), `docs/datenmodell-r1.md`, `supabase/migrations/20261102100000_base.sql`, `…100200_catalog.sql`, `…100300_collection.sql`, `…100400_plays.sql`, `supabase/tests/database/02_catalog.test.sql`, `supabase/seed.sql`, `supabase/config.toml`, ADR-0001, ADR-0002 | +| Etappe | R1.1 (Teil 2a: Datenmodell, Rechte, Ausblenden, Startbestand), R1.2 (Teil 2b: Zusammenführen; Teil 2c: Meldungen bearbeiten); Import (Krit. 1, 3, 6, Quellenteil von 2) erst mit Lizenz | +| Abhängigkeiten | keine (Fundament). Von #2 hängen ab: #3 KAT-01, #13 KAT-04, #25 SAM-08; #4 KAT-02 über #3. **#2 wird erst geschlossen**, wenn der Startbestand auf Produktion abgenommen ist (C1) und KAT-02 (#4) das Platzhalter-Cover und den Kurztext zeigt (Krit. 10). | +| Quellen | `requirements/20-r1-akzeptanzkriterien.md` § KAT-05 und § Querschnitt, `requirements/r1-klaerungen.md` (3, 4, 5, 6, 7, 10, 24, 25/44, 26, 27, 39, 48, N11, N12, N12a), `requirements/backlog.md` (Abschnitt „Datenmodell-Änderungen für architect“), `docs/datenmodell-r1.md`, `supabase/migrations/20261102100000_base.sql`, `…100200_catalog.sql`, `…100300_collection.sql`, `…100400_plays.sql`, `supabase/tests/database/00_schema.test.sql`, `…/02_catalog.test.sql`, `supabase/seed.sql`, `supabase/config.toml`, ADR-0001, ADR-0002 | + +**Namenskonvention in dieser Spec:** „Spec-F1“ bis „Spec-F9“ sind die Fragen an Chris aus § 9.2. „F2“, „O6“ usw. ohne Präfix sind Entscheidungen aus den Anforderungen (`20-r1-akzeptanzkriterien.md`). „SB1“ bis „SB8“ sind die Regeln für den Startbestand (4.3). ## 1. Ziel @@ -16,7 +18,7 @@ Der Katalog ist das Fundament aller anderen Stories: Suche (KAT-01), Detailseite - das Datenmodell alles enthält, was Admin-Pflege und ein späterer Import brauchen (ausgeblendet, zusammengeführt, manuell gepflegte Felder, Quelle mit Anzeige-Erlaubnis; Klärung 7), ohne dass der Import später eine Migration braucht, - nur der Admin den Katalog und Meldungen ändern kann, und zwar in R1 ausschließlich über Supabase Studio/SQL (Klärungen 25/44), -- zum Start von R1 ein manuell gepflegter Startbestand von etwa 200 Spielen (mindestens 180, F8) vorliegt, der nur Fakten enthält, ohne fremde Cover und fremde Beschreibungstexte (Klärung 24, N11), +- zum Start von R1 ein manuell gepflegter Startbestand von etwa 200 Spielen (mindestens 180, Spec-F8) vorliegt, der nur Fakten enthält, ohne fremde Cover und fremde Beschreibungstexte (Klärung 24, N11), - der Ablauf der Admin-Pflege im Repo dokumentiert ist (Krit. 8). Ein Import-Job (BGG oder eine andere Quelle) ist **nicht** Teil dieser Spec. Es gibt keine Edge Function und keine Admin-Oberfläche in der App. @@ -25,23 +27,23 @@ Ein Import-Job (BGG oder eine andere Quelle) ist **nicht** Teil dieser Spec. Es | # | Entscheidung | Begründung | |---|---|---| -| E1 | **Ein Issue #2, drei PRs:** 2a (R1.1) Datenmodell, Rechte, Startbestand-Lieferweg, Platzhalter-Cover; 2b (R1.2) Zusammenführen und Runbook; 2c (R1.2) Meldungstabelle und Bearbeiten von Meldungen. 2a und 2b referenzieren `Refs #2`, 2c `Closes #2`. | Entspricht dem Etappenschnitt im Issue. 2a blockiert KAT-01 (#3), 2c muss vor der Umsetzung von KAT-04 (#13) gemergt sein. Eigene Issues würden nur Abhängigkeiten verschieben. | +| E1 | **Ein Issue #2, drei PRs:** 2a (R1.1) Datenmodell, Rechte, Ausblenden, Startbestand-Werkzeug, Platzhalter-Cover; 2b (R1.2) Zusammenführen; 2c (R1.2) Meldungstabelle und Bearbeiten von Meldungen. **Alle drei PRs referenzieren nur `Refs #2`**, keiner schließt das Issue. Chris schließt #2 nach der Go-live-Abnahme des Startbestands (C1) und nach KAT-02 (#4). | Entspricht dem Etappenschnitt im Issue. Krit. 7 und 10 lassen sich erst mit den echten Daten bzw. der Detailseite abnehmen. 2a blockiert KAT-01 (#3), 2c muss vor der Umsetzung von KAT-04 (#13) gemergt sein. | | E2 | **Größe neu bewertet: L statt XL.** 2a ≈ 2–3 Tage (davon der Großteil Migration und pgTAP), 2b ≈ 1–2 Tage, 2c ≈ 1 Tag. Das Einpflegen der ~200 Spiele durch Chris ist nicht eingerechnet. | Import, Edge Function und Admin-UI entfallen (Klärungen 24, 44). Das Label `size/xl` ändert nur Chris. | -| E3 | **Ausblenden über `games.hidden_at`, Zusammenführen über `games.merged_into_id`.** Die Lese-Policy von `games` bleibt unverändert. „Nicht mehr auffindbar“ (Krit. 4) wird über die neue View `catalog_games` umgesetzt, die ausgeblendete und zusammengeführte Einträge nicht enthält. Suche und Entdecken (KAT-01) lesen nur diese View. | Würde RLS ausgeblendete Spiele verbergen, liefen bestehende Sammlungs-, Favoriten- und Partie-Zeilen ins Leere (Joins liefern `null`, die `with check`-Klausel von `collection_items` schlägt beim Bearbeiten fehl). Entschieden (F2): Ausgeblendete Einträge bleiben per ID lesbar; ein eigener Status „gesperrt“ für rechtliche Fälle kommt erst bei Bedarf. | -| E4 | **Manuell gepflegte Felder als Liste `games.manual_fields text[]`**, gepflegt von einem Trigger: Jedes Feld eines Katalogspiels, das ein Mensch (Studio/SQL) setzt oder ändert, landet automatisch in der Liste. Nur ein späterer Import setzt in seiner Transaktion `set local shuffle.catalog_import = 'on'` und wird dann nicht markiert. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) zählen als Pseudo-Felder (`genres`, `mechanics`, `categories`, `publishers`). | Der Admin muss in Studio nichts zusätzlich pflegen, die Kennzeichnung kann nicht vergessen werden (Krit. 4). Der Import (Krit. 1) überschreibt später genau die Felder nicht, die in der Liste stehen. Eine Liste statt einer Boolean-Spalte je Feld hält das Schema klein; ein Check begrenzt die erlaubten Schlüssel. Entschieden (F4): Im Startbestand gelten alle gesetzten Felder als manuell gepflegt. | -| E5 | **Quellen als eigene Tabelle `catalog_sources`** (Schlüssel, Name, Quellenangabe, Anzeige-Erlaubnis für Bilder und Texte je Quelle) und je Spiel `source_id`, `source_ref` (Kennung in der Quelle), `source_url`, `source_synced_at`. Die Migration legt die Quelle `bgg` mit `images_allowed = false`, `texts_allowed = false` an. | Klärung 7 und Krit. 2, 6: Felder und Erlaubnis je Quelle ab R1 vorhanden. `games.bgg_id` bleibt unverändert der Abgleichsschlüssel für SAM-08 (auch bei privaten Spielen); `source_*` beschreibt, woher die Katalogdaten stammen (Quellenangabe). Die Doppelung bei BGG ist bewusst; die Konsistenz (`source_ref = bgg_id::text` bei Quelle `bgg`) stellt der Import sicher (Spec des Imports). Eine Quelle je Spiel reicht, bis es eine zweite Quelle gibt. | -| E6 | **Die View `catalog_games` setzt die Anzeige-Erlaubnis schon jetzt serverseitig um:** `description` und `image_path` erscheinen nur, wenn das Feld manuell gepflegt ist, das Spiel keine Quelle hat oder die Quelle die Anzeige erlaubt. | Vorbereitung von Krit. 6 ohne Mehraufwand im Client. Im Startbestand hat kein Spiel eine Quelle, die Regel wirkt also erst mit dem Import. | -| E7 | **Verlage (Klärung 4) entstehen in dieser Spec** (`publishers`, `game_publishers`), nicht erst in KAT-01. Alternative Titel (Klärung 3, #3) und Basisspiel-Verweis (Klärung 5, #12) bleiben in ihren Stories; das Pflegeformat des Startbestands hat dafür aber schon Spalten (siehe 4.3), damit Chris die ~200 Spiele nicht zweimal anfassen muss. | Krit. 10 verlangt den Verlag im Startbestand. Alternative Titel und Erweiterungen sind für den Startbestand nicht Pflicht und werden in #3 bzw. #12 modelliert und getestet. | -| E8 | **Sprache des Kurztexts als Spalte `games.description_locale`** (`de`/`en`, optional), schon in 2a. | Der Startbestand füllt sie (immer `de`, F5); KAT-02 Krit. 7 (Klärung 48) zeigt damit den Sprachhinweis. Die Sprache des Titels gehört zu den alternativen Titeln (#3). | -| E9 | **Startbestand ist keine Migration und nicht Teil von `seed.sql`**, sondern eine versionierte, idempotente SQL-Datei `supabase/catalog/startbestand.sql` mit festen IDs. Lokal und in CI lädt sie `supabase db reset` über `[db.seed] sql_paths`; auf Staging und Produktion spielt Chris sie nach den Migrationen einmalig per Studio-SQL-Editor oder `psql` ein (Runbook). | Migrationen sind unveränderlich und nur additiv, Katalogdaten werden bis zum Start aber laufend korrigiert. `seed.sql` ist ausdrücklich nur für die lokale Entwicklung. Entschieden (F6): Chris pflegt eine CSV, ein Skript erzeugt daraus die SQL-Datei; nach dem Go-live ist die Datei eingefroren. | -| E10 | **In R1 gibt es kein App-Konto mit Admin-Rolle.** Chris pflegt ausschließlich über Studio bzw. `psql` als Datenbankrolle `postgres` (umgeht RLS). Die bestehenden `is_admin()`-Policies (JWT `app_metadata.role = 'admin'`) bleiben für QUE-08 (R4) stehen. Die neuen Admin-Funktionen sind für `anon` und `authenticated` **nicht** ausführbar, auch nicht mit Admin-Claim. | Klärung 44: Admin-Rolle nur als Datenbankrolle. Kein Admin-JWT heißt weniger Angriffsfläche. Krit. 9 wird dadurch gegen die Rollen `anon` und `authenticated` getestet (mit und ohne gefälschten Claim). | -| E11 | **Zusammenführen und Meldungsergebnis laufen über SQL-Funktionen**, nicht über Hand-SQL im Studio: `admin_merge_games(source, target)` und `admin_resolve_report(report, status, target)`, beide nutzen die interne Funktion `repoint_game_references(from, to)`. | Zusammenführen ist kein einzelnes UPDATE: Varianten mit zusammengesetzten Fremdschlüsseln, `unique (owner_id, game_id)` in `collection_items`, `unique (profile_id, game_id)` in `favorite_games` und künftig ein Wertungsbogen je Spiel (Klärung 39). Hand-SQL wäre fehleranfällig. Freigabe einer Meldung (KAT-04 Krit. 4) braucht dasselbe Umhängen, das ist der zweite Anwendungsfall. | -| E12 | **Die Meldungstabelle `game_reports` entsteht in 2c (diese Spec), vollständig mit den Spalten, die KAT-04 und SAM-08 brauchen** (Herkunft Nutzer/Import, BGG-Kennung, vorläufiger Eintrag). Anlegen durch Nutzer (Insert-Policy, Tageslimit 10, Ähnlichkeitsprüfung) liefert #13. | #13 hängt in Forgejo von #2 ab. Läge die Tabelle in #13, wäre 2c von #13 blockiert und #2 nicht abschließbar. Die Spalten ergeben sich vollständig aus den Klärungen 6, 26, 27, N12 und N12a. | +| E3 | **Ausblenden über `games.hidden_at`, Zusammenführen über `games.merged_into_id`.** Die Lese-Policy von `games` bleibt unverändert. „Nicht mehr auffindbar“ (Krit. 4) wird über die neue View `catalog_games` umgesetzt, die ausgeblendete und zusammengeführte Einträge nicht enthält. Suche und Entdecken (KAT-01) lesen nur diese View. | Würde RLS ausgeblendete Spiele verbergen, liefen bestehende Sammlungs-, Favoriten- und Partie-Zeilen ins Leere. Entschieden (Spec-F2): Ausgeblendete Einträge bleiben per ID lesbar; ein Status „gesperrt“ kommt erst bei Bedarf. | +| E4 | **Manuell gepflegte Felder als Liste `games.manual_fields text[]`**, gepflegt von einem Trigger: Jedes Feld eines Katalogspiels, das ein Mensch (Studio/SQL) setzt oder ändert, landet automatisch in der Liste. Ausgenommen ist nur ein späterer Import, und nur wenn **beides** gilt: Er läuft als eigene Datenbankrolle `catalog_importer` **und** setzt in seiner Transaktion `set local shuffle.catalog_import = 'on'`. Zuordnungen zählen als Pseudo-Felder (`genres`, `mechanics`, `categories`, `publishers`). | Der Admin muss in Studio nichts zusätzlich pflegen, die Kennzeichnung kann nicht vergessen werden (Krit. 4). Die Bindung an eine eigene Rolle verhindert, dass ein versehentliches `set` im Studio die Kennzeichnung abschaltet. Entschieden (Spec-F4): Im Startbestand gelten alle gesetzten Felder als manuell gepflegt. | +| E5 | **Quellen als eigene Tabelle `catalog_sources`** (Schlüssel, Name, Quellenangabe, Anzeige-Erlaubnis für Bilder und Texte je Quelle) und je Spiel `catalog_source_id`, `source_ref` (Kennung in der Quelle), `source_url`, `source_synced_at`. Die Migration legt die Quelle `bgg` mit `images_allowed = false`, `texts_allowed = false` an. | Klärung 7 und Krit. 2, 6: Felder und Erlaubnis je Quelle ab R1 vorhanden. `games.bgg_id` bleibt unverändert der Abgleichsschlüssel für SAM-08 (auch bei privaten Spielen); `source_*` beschreibt, woher die Katalogdaten stammen. Die Doppelung bei BGG ist bewusst; die Konsistenz stellt der Import sicher. Der Fremdschlüssel heißt nach Konvention `_id`. | +| E6 | **Anzeige-Erlaubnis je Quelle (Krit. 6): Daten ohne Erlaubnis werden gar nicht gespeichert.** Die View `catalog_games` blendet `description` und `image_path` zusätzlich aus, wenn die Quelle die Anzeige nicht erlaubt (zweite Sicherung). **Bitte bestätigen.** | Die View allein schützt nicht: `games` ist für `anon` tabellenweise lesbar, die Rohspalten wären über die API abrufbar. Spaltenweises `select` für `anon` würde auch eigene private Spiele und bestehende Abfragen treffen und wäre invasiv. Deshalb wird die Pflicht „nicht speichern“ der Import-Spec mitgegeben (9.4); im Startbestand gibt es ohnehin keine Quelle. | +| E7 | **Verlage (Klärung 4) entstehen in dieser Spec** (`publishers`, `game_publishers`), nicht erst in KAT-01. Alternative Titel (Klärung 3, #3) und Basisspiel-Verweis (Klärung 5, #12) bleiben in ihren Stories; das Pflegeformat des Startbestands hat dafür aber schon Spalten (4.3), damit Chris die ~200 Spiele nicht zweimal anfassen muss. | Krit. 10 verlangt den Verlag im Startbestand. | +| E8 | **Sprache des Kurztexts als Spalte `games.description_locale`** (`de`/`en`, nur zusammen mit `description`), schon in 2a. | Der Startbestand füllt sie (immer `de`, Spec-F5); KAT-02 Krit. 7 (Klärung 48) zeigt damit den Sprachhinweis. | +| E9 | **Startbestand ist keine Migration und nicht Teil von `seed.sql`**, sondern eine erzeugte, idempotente SQL-Datei `supabase/catalog/startbestand.sql` mit festen IDs. Lokal und in CI lädt sie `supabase db reset` über `[db.seed] sql_paths`; auf Staging und Produktion spielt Chris sie nach den Migrationen per Studio-SQL-Editor oder `psql` ein. Entschieden (Spec-F6): Chris pflegt eine CSV, ein Skript prüft sie und erzeugt die SQL-Datei; nach dem Go-live ist die Datei eingefroren. | Migrationen sind unveränderlich und nur additiv, Katalogdaten werden bis zum Start aber laufend korrigiert. `seed.sql` ist ausdrücklich nur für die lokale Entwicklung. | +| E10 | **In R1 gibt es kein App-Konto mit Admin-Rolle.** Chris pflegt ausschließlich über Studio bzw. `psql` als Datenbankrolle `postgres` bzw. `supabase_admin` (umgeht RLS). Die bestehenden `is_admin()`-Policies (JWT `app_metadata.role = 'admin'`) bleiben für QUE-08 (R4) stehen. Die neuen Admin-Funktionen prüfen **nur** die Datenbankrolle (kein `is_admin()`-Pfad) und sind für `anon`, `authenticated` und `service_role` nicht ausführbar. | Klärung 44: Admin-Rolle nur als Datenbankrolle. Kein Admin-JWT und kein Funktionsaufruf über die API heißt weniger Angriffsfläche. QUE-08 (R4) ergänzt bei Bedarf einen eigenen Pfad. | +| E11 | **Umhängen, Zusammenführen und Meldungsergebnis laufen über SQL-Funktionen**, nicht über Hand-SQL: `admin_merge_games(source, target)` (zwei Katalogspiele) und `admin_resolve_report(report, status, target)` (vorläufiges privates Spiel → Katalogspiel), beide nutzen die interne Funktion `repoint_game_references(from, to, copy_variants)`. **Varianten werden nur beim Zusammenführen zweier Katalogspiele kopiert; beim Umhängen eines privaten Eintrags nie** (Entscheidung Chris, B1); dort wird die Edition auf eine gleichnamige Variante des Ziels abgebildet, sonst geleert. | Zusammenführen ist kein einzelnes UPDATE: Varianten mit zusammengesetzten Fremdschlüsseln, `unique (owner_id, game_id)` in `collection_items`, `unique (profile_id, game_id)` in `favorite_games` und künftig ein Wertungsbogen je Spiel (Klärung 39). Private Varianten eines Nutzers dürfen nicht in den öffentlichen Katalog wandern. | +| E12 | **Die Meldungstabelle `game_reports` entsteht in 2c (diese Spec), vollständig mit den Spalten, die KAT-04 und SAM-08 brauchen** (Herkunft Nutzer/Import, BGG-Kennung, vorläufiger Eintrag). Anlegen durch Nutzer (Insert-Policy, Tageslimit 10, Ähnlichkeitsprüfung) liefert #13. | #13 hängt in Forgejo von #2 ab. Läge die Tabelle in #13, wäre 2c von #13 blockiert. Die Spalten ergeben sich aus den Klärungen 6, 26, 27, N12 und N12a. | | E13 | **Keine neuen Pakete, keine Edge Function, keine Supabase-Cloud-Funktionen.** Alles ist reines PostgreSQL 17 (Trigger, Views mit `security_invoker`, Spaltenrechte, Custom-GUC), PostgREST und Studio, die es im Self-Hosting gibt (ADR-0002). `[db.seed] sql_paths` ist eine Funktion der lokalen CLI. | Vorgabe aus CLAUDE.md und ADR-0002. | ## 2. Akzeptanzkriterien -Übernommen aus Issue #2 / `20-r1-akzeptanzkriterien.md` § KAT-05. Referenz in Tests und PRs: „KAT-05 Kriterium n“. +Übernommen aus Issue #2 / `20-r1-akzeptanzkriterien.md` § KAT-05. Referenz in Tests und PRs: „KAT-05 Kriterium n“. „F2“ in Krit. 2 ist die Anforderungs-Entscheidung „eigener Katalog mit eigener ID“, nicht Spec-F2. 1. *Zurückgestellt bis zur Lizenz (Klärung 24):* Gegeben eine zugelassene Quelle, wenn der Import läuft, dann werden neue Spiele angelegt und geänderte aktualisiert, ohne manuell gepflegte Felder zu überschreiben. 2. Gegeben ein Katalogeintrag, dann hat er eine eigene Shuffle-ID (F2). *Zurückgestellt bis zur Lizenz (Klärung 24):* optional die Kennung der Quelle (zum Beispiel BGG) samt Quellenangabe; die Felder dafür sind ab R1 im Datenmodell vorhanden (Klärung 7). @@ -54,18 +56,19 @@ Ein Import-Job (BGG oder eine andere Quelle) ist **nicht** Teil dieser Spec. Es 9. Gegeben ein Nutzer ohne Admin-Rolle, wenn er über die App oder direkt über die Schnittstelle Katalogeinträge oder fremde Meldungen ändern will, dann wird der Zugriff verweigert; die Admin-Rolle existiert in R1 nur als Datenbankrolle (Klärung 44). 10. Gegeben ein Spiel aus dem Startbestand, dann enthält es nur Fakten (Titel, Verlag, Erscheinungsjahr, Spielerzahl, Spieldauer, Mindestalter, Genre, Mechanik); fremde Cover und fremde Beschreibungstexte werden nicht übernommen, stattdessen zeigt die App ein Platzhalter-Cover und einen eigenen Kurztext (N11). -**Abnahme von Krit. 7 und 10 (konkretisiert durch F5, F7, F8, entschieden 2026-11-03)** +**Abnahme von Krit. 7 und 10 (konkretisiert durch Spec-F5, Spec-F7, Spec-F8)** -- **Krit. 7 gilt als erfüllt**, wenn die Prüfabfrage `supabase/catalog/checks.sql` auf Produktion **mindestens 180 sichtbare Katalogspiele** (`catalog_games`) meldet und für die Startbestandsspiele 0 Verstöße gegen die Randbedingungen aus 4.3 zeigt. Chris nimmt das Ergebnis vor dem Go-live ab. -- **Krit. 10 gilt als erfüllt**, wenn jedes Startbestandsspiel alle Pflicht-Fakten hat (mindestens ein Verlag, ein Genre, eine Mechanik), einen eigenen Kurztext auf Deutsch (`description_locale = 'de'`) hat, kein `image_path` und keine Quelle (`source_id`) gesetzt ist, die Fakten nach F7 erhoben wurden (Bestätigung durch Chris) und die App für alle diese Spiele das Platzhalter-Cover zeigt (KAT-01/KAT-02). +- **Krit. 7 gilt als erfüllt**, wenn die Prüfabfrage `supabase/catalog/checks.sql` auf Produktion für die Startbestandsspiele **mindestens 180 sichtbare Katalogspiele** meldet und 0 Verstöße gegen SB2, SB4 und SB6 zeigt. Chris nimmt das Ergebnis vor dem Go-live ab (C1). +- **Krit. 10 gilt als erfüllt**, wenn jedes Startbestandsspiel alle Pflicht-Fakten hat (SB2), einen eigenen Kurztext auf Deutsch (SB4), kein Cover, keine Quelle und keine BGG-Kennung (SB6), die Fakten nach SB3 erhoben wurden (Bestätigung durch Chris) und die App für diese Spiele das Platzhalter-Cover und den Kurztext zeigt (KAT-01/KAT-02). -**Zuordnung zu den PRs** +**Zuordnung zu den PRs** (alle PRs `Refs #2`, siehe E1) | PR | Etappe | Kriterien | Bei zurückgestellten Kriterien: nur Datenmodell | |---|---|---|---| -| 2a | R1.1 | 2, 7 (Lieferweg, Werkzeug, Prüfabfrage; der Datenbestand selbst kommt von Chris), 9 (Katalog), 10 | 1 (Kennzeichnung `manual_fields`), 2 (Quellenteil), 6 (Erlaubnis je Quelle) | -| 2b | R1.2 | 4, 8 (Bearbeiten, Ausblenden, Zusammenführen) | – | -| 2c | R1.2 | 5, 8 (Meldungen), 9 (fremde Meldungen) | – | +| 2a | R1.1 | 2; 4 (Bearbeiten mit Kennzeichnung, Ausblenden, `catalog_games`); 7 (Lieferweg, Werkzeug, Prüfabfrage; der Datenbestand selbst kommt von Chris); 8 (Runbook: Zugang, Startbestand, Bearbeiten, Ausblenden); 9 (Katalog); 10 (Daten- und Widget-Anteil) | 1 (Kennzeichnung `manual_fields`), 2 (Quellenteil), 6 (Erlaubnis je Quelle) | +| 2b | R1.2 | 4 (Zusammenführen); 8 (Runbook: Zusammenführen); 9 (Admin-Funktionen) | – | +| 2c | R1.2 | 5; 8 (Runbook: Meldungen); 9 (fremde Meldungen) | – | +| außerhalb | – | 7 (Abnahme auf Produktion, C1); 10 (Anzeige in KAT-01/KAT-02, #3/#4) | – | Krit. 3 hat keinen Anteil am Datenmodell und wird vollständig mit dem Import spezifiziert. @@ -85,13 +88,13 @@ Krit. 3 hat keinen Anteil am Datenmodell und wird vollständig mit dem Import sp | Bedarf | Bestand | Abgleich | Handlung | |---|---|---|---| | Eigene Shuffle-ID (Krit. 2) | `games.id uuid primary key default gen_random_uuid()`, `set_updated_at` schützt `id` | passt | keine; pgTAP-Test als Nachweis | -| Kennung + Quellenangabe (Krit. 2, Quellenteil) | nur `games.bgg_id` | Quellenangabe fehlt | `catalog_sources`, `games.source_*` (3.2) | -| Ausblenden (Krit. 4) | – | fehlt | `games.hidden_at`, View `catalog_games` | -| Zusammenführen (Krit. 4) | – | fehlt | `games.merged_into_id`, Funktion `admin_merge_games` (2b) | +| Kennung + Quellenangabe (Krit. 2, Quellenteil) | nur `games.bgg_id` | Quellenangabe fehlt | `catalog_sources`, `games.catalog_source_id` + `source_*` (3.2) | +| Ausblenden (Krit. 4) | – | fehlt | `games.hidden_at`, View `catalog_games` (2a) | +| Zusammenführen (Krit. 4) | – | fehlt | `games.merged_into_id` (2a), Funktion `admin_merge_games` (2b) | | Manuell gepflegte Felder (Krit. 1, 4) | – | fehlt | `games.manual_fields`, Trigger | | Erlaubnis je Quelle (Krit. 6) | – | fehlt | `catalog_sources.images_allowed`, `texts_allowed` | | Verlag (Krit. 10) | – | fehlt (Klärung 4) | `publishers`, `game_publishers` | -| Genre, Mechanik (Krit. 10) | `genres`, `mechanics`, Zuordnungstabellen | passt | Vokabular für den Startbestand: Vorschlag durch Agents, Freigabe durch Chris (F8) | +| Genre, Mechanik (Krit. 10) | `genres`, `mechanics`, Zuordnungstabellen | passt | Vokabular für den Startbestand: Vorschlag durch Agents, Freigabe durch Chris (Spec-F8) | | Kurztext + Sprache (Krit. 10, KAT-02 Krit. 7) | `games.description` (≤ 5000), keine Sprache | Sprache fehlt | `games.description_locale` | | Kein fremdes Cover (Krit. 10) | `games.image_path` nullable | passt | Startbestand setzt kein `image_path`; Platzhalter in der App | | Meldungen (Krit. 5) | – | fehlt (Klärung 6) | `game_reports` (2c) | @@ -101,7 +104,19 @@ Krit. 3 hat keinen Anteil am Datenmodell und wird vollständig mit dem Import sp Nur additiv. Bestehende Dateien bleiben unverändert; die Änderung der Rechte auf `games` erfolgt über `revoke`/`grant` in der neuen Datei. -**Rechte-Regel für alle neuen Objekte (2a–2c):** Supabase vergibt für neue Tabellen, Views und Funktionen standardmäßig alle Rechte an `anon`, `authenticated` und `service_role`. Jede neue Tabelle und View bekommt deshalb zuerst `revoke all … from anon, authenticated` und danach nur die genannten Grants (wie in den bestehenden Migrationen und `supabase/README.md`); jede neue Funktion `revoke execute … from public, anon, authenticated`. +**Rechte-Regel für alle neuen Objekte (2a–2c):** Supabase vergibt für neue Tabellen, Views und Funktionen standardmäßig alle Rechte an `anon`, `authenticated` und `service_role`. Jede neue Tabelle und View bekommt deshalb zuerst `revoke all … from anon, authenticated` und danach nur die Grants aus der Tabelle unten (wie `catalog.sql` und `supabase/README.md`). Jede neue Funktion bekommt `revoke execute … from public, anon, authenticated, service_role`; `service_role` braucht in R1 keine dieser Funktionen (keine Edge Function), der Import ergänzt später gezielt Rechte für `catalog_importer`. + +**Grants je Objekt (verbindlich)** + +| Objekt | `anon` | `authenticated` | Policies (Kurzform) | +|---|---|---|---| +| `catalog_sources` | `select` | `select`, `insert`, `update`, `delete` | `select` für alle `using (true)`; Schreiben nur `is_admin()` (für R4, in R1 ungenutzt) | +| `publishers` | `select` | `select`, `insert`, `update`, `delete` | wie `catalog_sources` (Muster `genres` in `catalog.sql`) | +| `game_publishers` | `select` | `select`, `insert`, `update`, `delete` | Lesen: wer das Spiel sehen darf (`exists (select 1 from games g where g.id = game_id)`); Schreiben: `is_admin()` oder Ersteller des privaten Spiels (Muster `game_genres_write`, `catalog.sql` Z. 141–143 und 216–227) | +| `catalog_games` (View) | `select` | `select` | über `security_invoker` die Policies von `games` und `catalog_sources` | +| `games` | unverändert `select` | `select`, `delete` unverändert; `insert`/`update` nur spaltenweise (unten) | unverändert | +| `game_reports` (2c) | – | `select` | eigene Zeilen; Admin-Policy für R4 | +| neue Funktionen | – | – | `execute` nur für den Eigentümer (`postgres`) | **Neue Tabelle `catalog_sources`** @@ -114,7 +129,7 @@ Nur additiv. Bestehende Dateien bleiben unverändert; die Änderung der Rechte a | `attribution_url` | text | nullable, ≤ 500, Check `^https://` | | `images_allowed` | boolean | not null, default `false` (Krit. 6) | | `texts_allowed` | boolean | not null, default `false` (Krit. 6) | -| `terms_url` | text | nullable, ≤ 500, Nutzungsbedingungen der Quelle | +| `terms_url` | text | nullable, ≤ 500, Check `^https://`; Nutzungsbedingungen der Quelle | | `terms_checked_at` | date | nullable, wann die Erlaubnis zuletzt geprüft wurde | | `created_at`, `updated_at` | timestamptz | Standard, Trigger `set_updated_at` | @@ -124,24 +139,24 @@ Die Migration fügt eine Zeile ein: `key = 'bgg'`, `name = 'BoardGameGeek'`, `at | Spalte | Typ | Hinweis | |---|---|---| -| `description_locale` | text | nullable, Check `in ('de','en')` (E8) | +| `description_locale` | text | nullable, Check `in ('de','en')`; Check `description_locale is null or description is not null` (E8) | | `hidden_at` | timestamptz | nullable; gesetzt = ausgeblendet (Krit. 4) | -| `merged_into_id` | uuid | nullable, FK → `games (id)`; gesetzt = in den Zieleintrag zusammengeführt. Check `merged_into_id <> id`; Check `merged_into_id is null or hidden_at is not null` | +| `merged_into_id` | uuid | nullable, FK → `games (id)` **`on delete restrict`** (ein Zieleintrag kann nicht gelöscht werden, solange etwas auf ihn verweist; Katalogspiele werden ausgeblendet, nicht gelöscht). Check `merged_into_id <> id`; Check `merged_into_id is null or hidden_at is not null` | | `manual_fields` | text[] | not null, default `'{}'`. Check `manual_fields <@ array['title','description','description_locale','year','min_players','max_players','min_minutes','max_minutes','min_age','image_path','genres','mechanics','categories','publishers']`. Neue Schlüssel (z. B. alternative Titel in #3, Basisspiel in #12) ergänzt die jeweilige Story per neuer Migration (Check ersetzen). | -| `source_id` | uuid | nullable, FK → `catalog_sources (id)` | +| `catalog_source_id` | uuid | nullable, FK → `catalog_sources (id)` `on delete restrict` | | `source_ref` | text | nullable, 1–100, Kennung des Spiels in der Quelle | | `source_url` | text | nullable, ≤ 500, Check `^https://` | | `source_synced_at` | timestamptz | nullable, letzter erfolgreicher Abgleich (Import) | -Weitere Checks: `(source_id is null) = (source_ref is null)`; `source_url` und `source_synced_at` nur mit `source_id`. +Weitere Checks: `(catalog_source_id is null) = (source_ref is null)`; `source_url` und `source_synced_at` nur mit `catalog_source_id`. -Indizes: `unique (source_id, source_ref) where visibility = 'catalog' and source_id is not null`; `games_merged_into_id_idx` auf `merged_into_id`; `games_catalog_listed_idx` auf `(lower(title)) where visibility = 'catalog' and hidden_at is null and merged_into_id is null` (Suche baut KAT-01 darauf aus). +Indizes: `unique (catalog_source_id, source_ref) where visibility = 'catalog' and catalog_source_id is not null`; `games_merged_into_id_idx` auf `merged_into_id`; `games_catalog_listed_idx` auf `(lower(title)) where visibility = 'catalog' and hidden_at is null and merged_into_id is null` (Suche baut KAT-01 darauf aus). **Neue Tabellen `publishers` und `game_publishers`** (Klärung 4, E7) | Tabelle | Spalte | Typ | Hinweis | |---|---|---|---| -| `publishers` | `name` | text | not null, 1–200; eindeutig über `unique index on lower(name)` | +| `publishers` | `name` | text | not null, 1–200; eindeutig über `unique index publishers_name_key on (lower(name))` | | `game_publishers` | `game_id` | uuid | not null, FK → `games` `on delete cascade` | | `game_publishers` | `publisher_id` | uuid | not null, FK → `publishers` `on delete cascade`; `unique (game_id, publisher_id)`; Index auf `publisher_id` | @@ -150,24 +165,25 @@ Beide mit `id`, `created_at`, `updated_at` und Trigger `set_updated_at`. Ein Spi **Trigger `games_mark_manual_fields`** (E4) - `before insert or update on public.games`, nur wenn `new.visibility = 'catalog'`. -- Wenn `coalesce(current_setting('shuffle.catalog_import', true), '') <> 'on'`: - - bei INSERT: jedes Feld aus der Schlüsselliste, das nicht `null` ist, wird ergänzt (F4, entschieden: alle gesetzten Felder), - - bei UPDATE: jedes Feld aus der Schlüsselliste, dessen Wert sich ändert (`is distinct from`), wird ergänzt. +- Ausnahme Import: `current_user = 'catalog_importer' and coalesce(current_setting('shuffle.catalog_import', true), '') = 'on'`. Die Rolle gibt es in R1 nicht; der Vergleich ist ein reiner Namensvergleich und funktioniert auch ohne die Rolle. Sonst wird markiert: + - bei INSERT: jedes Feld aus der Schlüsselliste, das nicht `null` ist (Spec-F4: alle gesetzten Felder), + - bei UPDATE: jedes Feld aus der Schlüsselliste, dessen Wert sich ändert (`is distinct from`). - Eine Änderung nur an `manual_fields` selbst wird nicht verändert, damit der Admin eine Markierung bewusst entfernen kann. - Sortiert und ohne Duplikate speichern (`array(select distinct unnest(...) order by 1)`), damit Tests stabil vergleichen können. -**Trigger `game_links_mark_manual`** auf `game_genres`, `game_mechanics`, `game_categories`, `game_publishers`: `after insert or delete`, wenn das Spiel ein Katalogspiel ist und kein Import läuft, ergänzt er `genres` / `mechanics` / `categories` / `publishers` in `manual_fields` des Spiels. Funktion `security invoker`, `search_path = ''`. +**Trigger `game_links_mark_manual`** auf `game_genres`, `game_mechanics`, `game_categories`, `game_publishers`: `after insert or update or delete` (auch Update, z. B. wenn `genre_id` geändert wird). Ist das betroffene Spiel (bei Update altes und neues) ein Katalogspiel und greift die Import-Ausnahme nicht, ergänzt er `genres` / `mechanics` / `categories` / `publishers` in `manual_fields`. Funktion `security invoker`, `search_path = ''`. -Ein Nutzer kann `shuffle.catalog_import` nicht setzen: PostgREST stellt `set_config` nicht bereit, und Katalogspiele kann er ohnehin nicht schreiben. +**Warum ein Nutzer die Kennzeichnung nicht umgehen kann:** Er darf Katalogspiele und deren Zuordnungen nicht schreiben (Policies, Krit. 9); bei privaten Spielen markiert der Trigger nichts. Zusätzlich greift die Import-Ausnahme nur für die Rolle `catalog_importer`. **Regel fürs Runbook:** im Studio niemals `set shuffle.…` ohne `local` (sonst gilt die Einstellung für die ganze Sitzung). **View `catalog_games`** (`with (security_invoker = true)`, E3, E6) - Zeilen: `games` mit `visibility = 'catalog' and hidden_at is null and merged_into_id is null`. - Spalten: `id`, `title`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `bgg_id`, `description_locale`, `created_at`, `updated_at`, dazu - - `description` = `case when g.source_id is null or 'description' = any(g.manual_fields) or s.texts_allowed then g.description end`, + - `description` = `case when g.catalog_source_id is null or 'description' = any(g.manual_fields) or s.texts_allowed then g.description end`, - `image_path` = gleiche Regel mit `'image_path'` und `s.images_allowed`, - `source_key`, `source_name`, `source_attribution_text`, `source_attribution_url`, `source_url` aus `catalog_sources` (Left Join). -- `grant select on public.catalog_games to anon, authenticated`. Weil `security_invoker` gesetzt ist, gelten die RLS-Regeln von `games` und `catalog_sources` für den Aufrufer. +- Grants laut Tabelle oben. Weil `security_invoker` gesetzt ist, gelten die RLS-Regeln von `games` und `catalog_sources` für den Aufrufer. +- **Grenze (E6):** Die View ist für Krit. 6 nur die zweite Sicherung. `games` bleibt für `anon` tabellenweise lesbar; Daten ohne Anzeige-Erlaubnis dürfen deshalb gar nicht erst gespeichert werden (Pflicht für die Import-Spec, 9.4). - KAT-01 und KAT-02 lesen Katalogdaten nur über diese View (bzw. über eine RPC, die darauf aufbaut). Der Direktzugriff auf `games` bleibt für eigene private Spiele und für bestehende Verweise (Sammlung, Partien) nötig. **Rechte auf `games` neu schneiden** (Krit. 9) @@ -182,49 +198,57 @@ grant update (title, description, description_locale, year, min_players, max_pla on public.games to authenticated; ``` -`delete` und `select` bleiben wie bisher. Die Policies regeln weiterhin, welche Zeilen (eigene private Spiele bzw. Admin). Damit kann kein Nutzer `hidden_at`, `merged_into_id`, `manual_fields` oder `source_*` setzen, auch nicht an eigenen privaten Spielen (Fehler 42501). Hinweis für QUE-08 (R4): Ein Admin über die API wäre davon ebenfalls betroffen; die Admin-UI nutzt dann RPCs. - -**RLS in Klartext (2a)** - -| Tabelle/Objekt | Lesen | Schreiben | -|---|---|---| -| `catalog_sources` | alle, auch ohne Anmeldung | nur Admin (`is_admin()`-Policy, für R4); in R1 nur Studio/`postgres` | -| `publishers` | alle, auch ohne Anmeldung | nur Admin | -| `game_publishers` | wie das Spiel (wer das Spiel sehen darf) | Admin oder Ersteller des privaten Spiels (gleiches Muster wie `game_genres`) | -| `games` | unverändert: Katalog für alle, private Spiele nur der Ersteller; ausgeblendete und zusammengeführte Katalogspiele bleiben per ID lesbar (E3, F2 entschieden) | unverändert per Policy; zusätzlich Spaltenrechte | -| `catalog_games` | alle, auch ohne Anmeldung, nur sichtbare Katalogspiele | nicht schreibbar (View ohne Insert/Update-Rechte) | +`delete` und `select` bleiben wie bisher. Die Policies regeln weiterhin, welche Zeilen (eigene private Spiele bzw. Admin). Damit kann kein Nutzer `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` oder `source_*` setzen, auch nicht an eigenen privaten Spielen (Fehler 42501). **`id` ist bewusst nicht enthalten:** Clients können die ID eines Spiels nicht selbst wählen, sie bekommen sie per `returning`. Bisher gibt es keinen Client-Code, der eine ID mitschickt; KAT-04 (#13) legt vorläufige Spiele ohne eigene ID an. ### 3.3 Migration 2b `supabase/migrations/_catalog_merge.sql` (R1.2) -**Interne Funktion `public.repoint_game_references(p_from uuid, p_to uuid)`** +**Interne Funktion `public.repoint_game_references(p_from uuid, p_to uuid, p_copy_variants boolean) returns jsonb`** -`language plpgsql`, `security invoker`, `search_path = ''`; `revoke execute ... from public, anon, authenticated`. Hängt alle Verweise von `p_from` auf `p_to` um, in einer Transaktion: +`language plpgsql`, `security invoker`, `search_path = ''`; `execute` nur für den Eigentümer. Hängt alle Verweise von `p_from` auf `p_to` um, in einer Transaktion. Zuerst entsteht eine temporäre Zuordnung „alte Variante → neue Variante“ (`variant_map`): -1. **Varianten:** Für jede Variante von `p_from` wird eine Kopie (`name`, `year`, `language`, `bgg_id`) an `p_to` angelegt; eine Zuordnungstabelle alt → neu (temporär) entsteht. Grund: Die zusammengesetzten Fremdschlüssel `(variant_id, game_id)` in `collection_items` und `plays` erlauben kein direktes Umhängen von `game_variants.game_id`. Ein UPDATE, das `game_id` und `variant_id` in derselben Zeile gemeinsam setzt, ist zulässig. -2. **`collection_items`:** Hat derselbe Nutzer schon einen Eintrag zu `p_to`, gilt die Kollisionsregel (F1, entschieden): Der Zieleintrag bleibt; seine leeren Felder (`note`, `acquired_on`, `variant_id` sowie die Felder, die SAM-02/SAM-04 ergänzen) werden aus dem Quell-Eintrag gefüllt; bei unterschiedlicher Liste gilt `owned` vor `wishlist` vor `shopping` (wie Klärung 34 „besessen und gewünscht → Sammlung“); danach wird der Quell-Eintrag gelöscht. Ohne Kollision: `game_id := p_to`, `variant_id := neu`. -3. **`favorite_games`:** Hat der Nutzer `p_to` schon als Favorit, wird die Zeile zu `p_from` gelöscht (die Lücke in `position` ist zulässig). Sonst `game_id := p_to`. -4. **`plays`:** `game_id := p_to`, `variant_id := neu`. -5. **`score_sheets`:** `game_id := p_to`. Sobald PAR-04 (#18) „ein Bogen je Spiel und Nutzer“ (Klärung 39) erzwingt, muss #18 hier eine Kollisionsregel ergänzen (Hinweis in 9.4). -6. Alte Varianten von `p_from` löschen (sie sind jetzt unreferenziert). -7. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) von `p_from` bleiben unverändert am ausgeblendeten Quell-Eintrag; der Zieleintrag behält seine eigenen. +| Fall | `p_copy_variants` | Zuordnung der Varianten | +|---|---|---| +| **Zwei Katalogspiele zusammenführen** (`admin_merge_games`, beide öffentlich) | `true` | Für jede Variante von `p_from` wird eine Kopie (`name`, `year`, `language`, `bgg_id`) an `p_to` angelegt, **außer** `p_to` hat schon eine Variante mit gleichem Namen (`lower(trim(name))`); dann wird auf diese abgebildet. Danach werden die alten Varianten gelöscht. | +| **Privaten Eintrag auf ein Katalogspiel umhängen** (`admin_resolve_report`) | `false` | **Es wird nichts kopiert** (Entscheidung Chris, B1): Hat `p_to` eine Variante mit gleichem Namen (`lower(trim(name))`), wird darauf abgebildet, sonst auf `null`. Die privaten Varianten bleiben am ausgeblendeten vorläufigen Eintrag (sie gehören dem Nutzer und werden mit seinem Konto gelöscht). | -Rückgabe: `jsonb` mit Zählern je Tabelle (z. B. `{"collection_items": 3, "collection_conflicts": 1, "plays": 7, …}`), damit der Admin im Studio sieht, was passiert ist. +Grund für die Zuordnung: Die zusammengesetzten Fremdschlüssel `(variant_id, game_id)` in `collection_items` und `plays` verlangen, dass die Variante zum neuen Spiel gehört. Ein UPDATE, das `game_id` und `variant_id` in derselben Zeile gemeinsam setzt (`variant_id := variant_map(variant_id)`), ist zulässig. + +Danach, in dieser Reihenfolge: + +1. **`collection_items`:** Hat derselbe Nutzer schon einen Eintrag zu `p_to`, gilt die Kollisionsregel (Spec-F1): Der Zieleintrag bleibt; seine leeren Felder werden aus dem Quell-Eintrag gefüllt (`note`, `acquired_on`, die Felder aus SAM-02/SAM-04 und `variant_id`, wobei `variant_id` **immer über `variant_map`** übersetzt wird, nie direkt übernommen); bei unterschiedlicher Liste gilt `owned` vor `wishlist` vor `shopping` (Klärung 34); danach wird der Quell-Eintrag gelöscht. Ohne Kollision: `game_id := p_to`, `variant_id := variant_map(variant_id)`. +2. **`favorite_games`:** Hat der Nutzer `p_to` schon als Favorit, wird die Zeile zu `p_from` gelöscht (die Lücke in `position` ist zulässig). Sonst `game_id := p_to`. +3. **`plays`:** `game_id := p_to`, `variant_id := variant_map(variant_id)`. +4. **`score_sheets`:** `game_id := p_to`. Sobald PAR-04 (#18) „ein Bogen je Spiel und Nutzer“ (Klärung 39) erzwingt, muss #18 hier eine Kollisionsregel ergänzen (9.4). +5. Nur bei `p_copy_variants = true`: alte Varianten von `p_from` löschen (sie sind jetzt unreferenziert). +6. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) von `p_from` bleiben unverändert am ausgeblendeten Quell-Eintrag; der Zieleintrag behält seine eigenen. + +Rückgabe: `jsonb` mit Zählern je Tabelle, z. B. `{"collection_items": 3, "collection_conflicts": 1, "plays": 7, "variants_copied": 2, "variants_mapped": 1, "variants_cleared": 0, …}`, damit der Admin im Studio sieht, was passiert ist. + +**Auswirkung auf KAT-04 Krit. 4** („eigene Angaben gehen nicht verloren“): Für alle Angaben gilt das, für die **Edition nur eingeschränkt**. Hat der Katalogeintrag keine gleichnamige Variante, wird die Edition am Sammlungseintrag und an Partien geleert; die private Variante selbst bleibt am ausgeblendeten vorläufigen Eintrag erhalten. Hinweis für #13 (9.4): Der Ergebnishinweis in der App sollte in diesem Fall sagen, dass die Edition neu gewählt werden muss; die Rückgabe von `repoint_game_references` (`variants_cleared`) bzw. ein vergleichbarer Hinweis an der Meldung macht das erkennbar. **Funktion `public.admin_merge_games(p_source uuid, p_target uuid) returns jsonb`** -`security invoker`, `search_path = ''`; `revoke execute ... from public, anon, authenticated` (E10). Prüft zusätzlich zu Beginn `current_user in ('postgres', 'supabase_admin') or public.is_admin()` (Schutz in der Tiefe). +`security invoker`, `search_path = ''`; `execute` nur für den Eigentümer (E10). Prüft zu Beginn `current_user in ('postgres', 'supabase_admin')`, sonst Fehler `admin_only`. (Kein `is_admin()`-Pfad; QUE-08 in R4 ergänzt bei Bedarf einen eigenen.) | Prüfung | Fehler (`raise exception … using errcode = 'P0001'`) | |---|---| +| Aufrufer ist nicht `postgres`/`supabase_admin` | `admin_only` | | `p_source = p_target` | `merge_same_game` | | Quelle oder Ziel existiert nicht | `merge_game_not_found` | -| Quelle oder Ziel ist kein Katalogspiel | `merge_not_catalog` | +| Quelle oder Ziel ist kein Katalogspiel (private Einträge nur über `admin_resolve_report`) | `merge_not_catalog` | | Ziel ist ausgeblendet oder selbst zusammengeführt | `merge_target_not_listed` | | Quelle ist bereits zusammengeführt | `merge_source_already_merged` | -Ablauf: `repoint_game_references(p_source, p_target)`; `p_source.merged_into_id := p_target`, `hidden_at := coalesce(hidden_at, now())`; Ketten glätten (`update games set merged_into_id = p_target where merged_into_id = p_source`). Ab 2c zusätzlich: `game_reports.target_game_id` von `p_source` auf `p_target`. Die Quelle wird **nicht** gelöscht: Ihre Shuffle-ID bleibt gültig, die App kann alte Links (Web-URLs, Deep Links) auf den Zieleintrag weiterleiten (KAT-02). +Ablauf: -**Ausblenden** braucht keine Funktion: `update public.games set hidden_at = now() where id = …` (Wieder einblenden: `hidden_at = null`). Das Runbook (4.4) enthält die Anweisungen. +1. `repoint_game_references(p_source, p_target, true)`. +2. **BGG-Kennung und Quelle übernehmen** (W6, **bitte bestätigen**): Hat das Ziel kein `bgg_id`, aber die Quelle, wird der Wert übertragen; ebenso `catalog_source_id`/`source_ref`/`source_url`/`source_synced_at` als Gruppe. Reihenfolge wegen der eindeutigen Indizes: Werte merken → an der Quelle leeren → am Ziel setzen. Haben beide unterschiedliche Werte, bleibt das Ziel unverändert, an der Quelle werden die Werte trotzdem geleert, und das Ergebnis-JSON meldet `bgg_id_conflict` bzw. `source_conflict` zur manuellen Klärung. Begründung: Sonst würde der BGG-Abgleich (SAM-08) später den ausgeblendeten Quell-Eintrag treffen. Die übertragenen Felder werden am Ziel wie jede Admin-Änderung in `manual_fields` vermerkt, soweit sie in der Schlüsselliste stehen (`bgg_id` und `source_*` stehen nicht darin). +3. `p_source.merged_into_id := p_target`, `hidden_at := coalesce(hidden_at, now())`; Ketten glätten (`update games set merged_into_id = p_target where merged_into_id = p_source`). +4. Ab 2c zusätzlich: `game_reports.target_game_id` von `p_source` auf `p_target`. + +Die Quelle wird **nicht** gelöscht: Ihre Shuffle-ID bleibt gültig, die App leitet alte Links (Web-URLs, Deep Links) auf den Zieleintrag weiter (KAT-02). + +**Ausblenden** (schon 2a) braucht keine Funktion: `update public.games set hidden_at = now() where id = …` (Wieder einblenden: `hidden_at = null`). Das Runbook (4.4) enthält die Anweisungen. ### 3.4 Migration 2c `supabase/migrations/_game_reports.sql` (R1.2) @@ -242,13 +266,13 @@ Ablauf: `repoint_game_references(p_source, p_target)`; `p_source.merged_into_id | `bgg_id` | integer | nullable (N12, Deduplizierung) | | `provisional_game_id` | uuid | nullable, FK → `games` `on delete set null` (vorläufiger privater Eintrag, KAT-04 Krit. 3) | | `status` | text | not null, default `'open'`, Check `in ('open','approved','rejected','merged')` | -| `target_game_id` | uuid | nullable, FK → `games` | +| `target_game_id` | uuid | nullable, FK → `games` **`on delete restrict`** (wie `merged_into_id`) | | `resolved_at` | timestamptz | nullable | | `created_at`, `updated_at` | timestamptz | Standard, Trigger `set_updated_at` | Checks: `(status = 'open') = (resolved_at is null)`; `status <> 'open' or target_game_id is null`; `status not in ('approved','merged') or target_game_id is not null`. -Indizes: `(owner_id, created_at)` für das Tageslimit (#13); `unique (owner_id, bgg_id) where origin = 'import' and status = 'open' and bgg_id is not null` (N12a: höchstens eine offene Import-Meldung je Nutzer und BGG-Spiel); `(created_at) where status = 'open'` für die Admin-Liste. +Indizes: `(owner_id, created_at)` für das Tageslimit (#13); `unique (owner_id, bgg_id) where origin = 'import' and status = 'open' and bgg_id is not null` (N12a); `(created_at) where status = 'open'` für die Admin-Liste. **RLS in Klartext** @@ -259,29 +283,32 @@ Indizes: `(owner_id, created_at)` für das Tageslimit (#13); `unique (owner_id, **Funktion `public.admin_resolve_report(p_report uuid, p_status text, p_target uuid default null) returns jsonb`** -Rechte wie `admin_merge_games`. +Rechte und Rollenprüfung wie `admin_merge_games`. | Prüfung | Fehler | |---|---| +| Aufrufer ist nicht `postgres`/`supabase_admin` | `admin_only` | | Meldung existiert nicht | `report_not_found` | | Meldung ist nicht `open` | `report_not_open` | | `p_status` nicht in `approved`, `merged`, `rejected` | `report_status_invalid` | | `approved`/`merged` ohne `p_target` | `report_target_required` | | `p_target` ist kein sichtbares Katalogspiel | `report_target_not_listed` | -Ablauf: `status`, `target_game_id`, `resolved_at = now()` setzen. Hat die Meldung einen vorläufigen Eintrag, dann (F3, entschieden) +Ablauf: `status`, `target_game_id`, `resolved_at = now()` setzen. Hat die Meldung einen vorläufigen Eintrag, dann (Spec-F3) -- bei `approved` und bei `merged`: `repoint_game_references(provisional, target)`; der vorläufige Eintrag bekommt `merged_into_id = target`, `hidden_at = now()` (KAT-04 Krit. 4: der Katalogeintrag ersetzt ihn, eigene Angaben bleiben erhalten), +- bei `approved` und bei `merged`: `repoint_game_references(provisional, target, false)` (keine Varianten kopieren, B1); der vorläufige Eintrag bekommt `merged_into_id = target`, `hidden_at = now()` (KAT-04 Krit. 4, mit der Einschränkung zur Edition aus 3.3), - bei `rejected` mit Ziel: nur `target_game_id` wird gespeichert, der vorläufige Eintrag bleibt unverändert; ob der Nutzer auf den Zieleintrag wechselt, entscheidet er in der App (KAT-04 Krit. 5, #13), - bei `rejected` ohne Ziel: keine Änderung am vorläufigen Eintrag (KAT-04 Krit. 6). +Die Rückgabe enthält die Zähler aus `repoint_game_references` (u. a. `variants_cleared`). + „Freigeben“ heißt in R1: Der Admin legt im Studio zuerst den Katalogeintrag an (bzw. wählt ihn aus) und ruft dann die Funktion mit `approved` auf. Unterschied zu `merged`: `approved` = der Eintrag ist wegen dieser Meldung neu entstanden, `merged` = er war schon vorhanden. Die Migration ersetzt `admin_merge_games` per `create or replace`, sodass beim Zusammenführen auch `game_reports.target_game_id` umgehängt wird. ### 3.5 Doku zum Datenmodell -`docs/datenmodell-r1.md` wird in jedem der drei PRs nachgezogen (neue Tabellen, Spalten, View, Funktionen, Regeln). Dabei wird der doppelte Abschnitt „Tabellenstruktur (aus den Migrationen)“ (steht zweimal im Dokument) zusammengeführt. `supabase/README.md` bekommt einen Abschnitt „Katalog (KAT-05)“. +`docs/datenmodell-r1.md` wird in jedem der drei PRs nachgezogen (neue Tabellen, Spalten, View, Funktionen, Regeln). Dabei wird der doppelte Abschnitt „Tabellenstruktur (aus den Migrationen)“ zusammengeführt. `supabase/README.md` bekommt einen Abschnitt „Katalog (KAT-05)“; die Regel „Admin-Rolle kommt aus `app_metadata.role = 'admin'`“ erhält den Zusatz „in R1 niemandem vergeben, Pflege über Studio (KAT-05 E10)“; die Zahlen „17 Tabellen, 2 Views“ werden aktualisiert. ## 4. Backend @@ -289,55 +316,74 @@ Die Migration ersetzt `admin_merge_games` per `create or replace`, sodass beim Z | Objekt | Art | PR | Aufrufbar von | |---|---|---|---| -| `catalog_sources`, `publishers`, `game_publishers`, neue Spalten `games` | Tabellen | 2a | Lesen: alle; Schreiben: Studio (`postgres`) | +| `catalog_sources`, `publishers`, `game_publishers`, neue Spalten `games` | Tabellen | 2a | Lesen: alle; Schreiben: Studio (`postgres`) bzw. Ersteller privater Spiele (`game_publishers`) | | `catalog_games` | View | 2a | `anon`, `authenticated` (lesen) | | `games_mark_manual_fields`, `game_links_mark_manual` | Trigger | 2a | automatisch | | `repoint_game_references` | interne Funktion | 2b | nur `postgres` | -| `admin_merge_games` | Funktion | 2b | nur `postgres` (Studio/`psql`) | +| `admin_merge_games` | Funktion | 2b | nur `postgres`/`supabase_admin` (Studio/`psql`) | | `game_reports` | Tabelle | 2c | Lesen: eigene; Schreiben: Studio | -| `admin_resolve_report` | Funktion | 2c | nur `postgres` | +| `admin_resolve_report` | Funktion | 2c | nur `postgres`/`supabase_admin` | Keine Edge Functions, keine RPCs für die App in dieser Spec. ### 4.2 Admin-Zugang (self-hosted) -- **Studio läuft als Datenbankrolle `postgres` bzw. `supabase_admin`** (je nach Self-Hosting-Setup; D1 bestätigt das mit `select current_user` im Studio-SQL-Editor) und umgeht RLS. Die Admin-Funktionen akzeptieren beide Rollen. Die `is_admin()`-Policies sind deshalb nicht der Studio-Pfad; Schutz bietet allein der Zugang zu Studio. -- **Studio darf nicht öffentlich erreichbar sein.** Im Self-Hosting gibt es in Studio keine Benutzerverwaltung, nur die Basic-Auth des Gateways (`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD`). Vorgabe für devops (D1): Studio und der Postgres-Port sind nur über SSH-Tunnel oder VPN erreichbar; Basic-Auth bleibt als zweite Schicht mit starkem Passwort. Gilt ebenso für Staging. +- **Studio läuft als Datenbankrolle `postgres` bzw. `supabase_admin`** (je nach Self-Hosting-Setup; D1 bestätigt das mit `select current_user` im Studio-SQL-Editor) und umgeht RLS. Die Admin-Funktionen akzeptieren genau diese beiden Rollen. Die `is_admin()`-Policies sind nicht der Studio-Pfad; Schutz bietet allein der Zugang. +- **Nicht öffentlich erreichbar** (Vorgabe für devops, D1): Studio, der Postgres-Port (5432), der Pooler (Supavisor, Standard 6543) und die Kong-Route `/pg/` (pg-meta, darüber spricht Studio mit der Datenbank) sind nur über SSH-Tunnel oder VPN erreichbar. Im Self-Hosting gibt es in Studio keine Benutzerverwaltung, nur die Basic-Auth des Gateways (`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD`); sie bleibt als zweite Schicht mit starkem Passwort. Gilt ebenso für Staging. - **`app_metadata.role = 'admin'`** wird in R1 niemandem gesetzt (E10). Falls doch nötig (R4), geht das nur serverseitig: `update auth.users set raw_app_meta_data = raw_app_meta_data || '{"role":"admin"}' where id = …` als `postgres`; der Nutzer muss sich danach neu anmelden. Das Runbook dokumentiert das als „nicht in R1“. ### 4.3 Startbestand (Krit. 7, 10; E9) -**Verbindliche Randbedingungen (F5, F7, F8, entschieden 2026-11-03)** +**Verbindliche Regeln SB1–SB8 (Spec-F4, Spec-F5, Spec-F7, Spec-F8)** | # | Regel | Prüfung | |---|---|---| -| S1 | Zum Go-live **mindestens 180 sichtbare Katalogspiele** (Ziel etwa 200). | `checks.sql` (Release-Checkliste) | -| S2 | Jedes Spiel hat alle Pflicht-Fakten: Titel, mindestens ein Verlag, Jahr, Spielerzahl, Spieldauer, Mindestalter, mindestens ein Genre, mindestens eine Mechanik. | Skript (CSV-Prüfung) und `checks.sql` | -| S3 | **Herkunft der Fakten (F7):** Verlagsseiten, Spielschachtel und eigene Kenntnis, ergänzend Wikidata (CC0). Fakten werden **nicht** systematisch aus BGG oder einer anderen fremden Datenbank übernommen. Die endgültige Bewertung erfolgt mit der rechtlichen Prüfung vor dem Release. | Bestätigung durch Chris; optional Spalte `facts_source` in der CSV als Nachweis | -| S4 | **Kurztext (F5):** jedes Spiel hat einen eigenen Kurztext auf **Deutsch** (`description_locale = 'de'`); geschrieben von Chris oder als KI-Entwurf, den Chris geprüft und freigegeben hat. Keine fremden Beschreibungstexte. | Skript (Pflicht, Sprache `de`) und `checks.sql` | -| S5 | **Vokabular (F8):** Genres und Mechaniken nur aus der von Chris freigegebenen Liste (Vorschlag durch Agents: ca. 12–15 Genres, 20–30 Mechaniken, de/en). | Skript (unbekannter Begriff → Fehler) | -| S6 | Kein Cover (`image_path` leer), keine Quelle (`source_id` leer), keine BGG-Kennung aus einem Abgleich (N11, Krit. 10). | CSV hat keine solchen Spalten; `checks.sql` | -| S7 | **Erweiterungen (F8)** sind erlaubt; das Basisspiel wird in `base_game_key` vorgemerkt, die Verknüpfung entsteht mit KAT-03 (#12). Bis dahin erscheinen Erweiterungen als eigenständige Katalogeinträge. | Skript prüft, dass `base_game_key` auf einen vorhandenen `key` zeigt | -| S8 | Alle gesetzten Felder gelten als manuell gepflegt (F4); das erledigt der Trigger beim Einspielen. | pgTAP `06` | +| SB1 | Zum Go-live **mindestens 180 sichtbare Katalogspiele aus dem Startbestand** (Ziel etwa 200). | `checks.sql` | +| SB2 | Jedes Spiel hat alle Pflicht-Fakten: Titel, mindestens ein Verlag, Jahr, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, mindestens ein Genre, mindestens eine Mechanik. | Skript und `checks.sql` | +| SB3 | **Herkunft der Fakten (Spec-F7):** Verlagsseiten, Spielschachtel und eigene Kenntnis, ergänzend Wikidata (CC0). Fakten werden **nicht** systematisch aus BGG oder einer anderen fremden Datenbank übernommen. Endgültige Bewertung mit der rechtlichen Prüfung vor dem Release. | Bestätigung durch Chris; optionale Spalte `facts_source` als Nachweis | +| SB4 | **Kurztext (Spec-F5):** jedes Spiel hat einen eigenen Kurztext auf **Deutsch** (`description_locale = 'de'`), geschrieben von Chris oder als KI-Entwurf, den Chris geprüft und freigegeben hat. KI-Entwürfe entstehen **nur aus den Fakten**; sie dürfen keine umformulierten Texte von BGG, Verlagen oder anderen Quellen sein (keine fremden Texte als Eingabe). | Skript und `checks.sql` (Pflicht, Sprache); Herkunft: Chris | +| SB5 | **Vokabular (Spec-F8):** Genres und Mechaniken nur aus der von Chris freigegebenen Liste. | Skript | +| SB6 | Kein Cover (`image_path` leer), keine Quelle (`catalog_source_id` leer), keine BGG-Kennung (`bgg_id` leer) (N11, Krit. 10). | CSV hat keine solchen Spalten; `checks.sql` | +| SB7 | **Erweiterungen (Spec-F8)** sind erlaubt; das Basisspiel wird in `base_game_key` vorgemerkt, die Verknüpfung entsteht mit KAT-03 (#12). Bis dahin erscheinen Erweiterungen als eigenständige Katalogeinträge. | Skript (`base_game_key` zeigt auf vorhandenen `key`) | +| SB8 | Alle gesetzten Felder gelten als manuell gepflegt (Spec-F4); das erledigt der Trigger beim Einspielen. | pgTAP `06` | **Lieferweg** | Umgebung | Weg | |---|---| | lokal, CI | `supabase/config.toml`: `[db.seed] sql_paths = ["./seed.sql", "./catalog/vocabulary.sql", "./catalog/startbestand.sql"]`; `supabase db reset` spielt alles ein | -| Staging, Produktion | Nach den Migrationen spielt Chris `vocabulary.sql` und `startbestand.sql` per Studio-SQL-Editor oder `psql` ein (Runbook § „Startbestand einspielen“). Die Dateien sind idempotent und dürfen wiederholt laufen, **bis zum Go-live**; danach ist die Datei eingefroren und Korrekturen erfolgen nur noch im Studio (F6). | +| Staging, Produktion | Nach den Migrationen spielt Chris `vocabulary.sql` und `startbestand.sql` per Studio-SQL-Editor oder `psql` ein (Runbook). Die Dateien sind idempotent und dürfen wiederholt laufen, **bis zum Go-live**; danach ist `startbestand.sql` eingefroren, Korrekturen nur noch im Studio (Spec-F6). | + +**Unverifizierte Annahmen (B3 prüft gegen CLI 3.x, Ergebnis ins Runbook):** + +- `supabase db reset` lädt alle Dateien aus `sql_paths` in der angegebenen Reihenfolge; `supabase start` lädt Seeds nur beim ersten Start einer leeren Datenbank. +- `begin;`/`commit;` innerhalb einer Seed-Datei sind zulässig. Falls die CLI Seeds selbst in eine Transaktion packt oder die Anweisungen stören: Die Datei enthält kein `begin`/`commit`, und das Runbook lässt sie auf Staging/Prod in `begin; \i …; commit;` bzw. im Studio in einer Transaktion ausführen. + +**Feste IDs: UUID v5** + +- Namespace (fest, nie ändern; steht im Skript und in `docs/katalog-pflege.md`): `6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4`. +- Name je Objekt: Spiel `game:`, Genre `genre:`, Mechanik `mechanic:` (`vocab_key` ist ein stabiler englischer Kurzschlüssel im Vokabular, z. B. `strategy`, `worker-placement`). +- **Verlage bekommen keine abgeleitete ID**, weil Chris im Studio auch selbst Verlage anlegen kann: Einfügen mit `on conflict ((lower(name))) do nothing`, danach Auflösen der ID über `lower(name)`. + +**Idempotenz von `startbestand.sql`** + +1. Verlage: `insert into publishers (name) … on conflict ((lower(name))) do nothing`. +2. Spiele: `insert … on conflict (id) do update set` alle Fakten-Spalten, `description`, `description_locale`, `hidden_at` (nur nach Regel 5 unten). +3. Zuordnungen je Startbestandsspiel neu aufbauen: `delete from game_genres / game_mechanics / game_publishers where game_id in ()`, danach `insert … on conflict (game_id, genre_id) do nothing` (bzw. `mechanic_id`, `publisher_id`). Kategorien (`game_categories`) fasst die Datei nicht an. +4. **Schlüssel sind unveränderlich.** Das Skript führt eine committete Liste `supabase/catalog/startbestand-keys.csv` (`key`, `uuid`, erzeugt und fortgeschrieben). Fehlt ein früher vorhandener `key` in der CSV, bricht das Skript ab („Schlüssel entfernt oder umbenannt“). Umbenennen ist nicht erlaubt; ein falscher Schlüssel bleibt stehen, nur der Titel wird korrigiert. +5. **Spiel entfernen:** Zeile bleibt in der CSV, Spalte `hidden` = `ja` → das Skript erzeugt `hidden_at = coalesce(hidden_at, now())`. Es wird nichts gelöscht (Verweise aus Sammlungen bleiben gültig). **Dateien** -- `supabase/catalog/vocabulary.sql`: Genres und Mechaniken (de/en) mit festen UUIDs, `insert … on conflict (id) do update set name_de = excluded.name_de, name_en = excluded.name_en`. Inhalt: Vorschlag durch Agents (Task B3a), gültig erst nach Freigabe durch Chris (S5). Die Begriffe aus `seed.sql` sind nur Beispieldaten für die Entwicklung; `seed.sql` wird so angepasst, dass es keine Begriffe mehr anlegt, die mit dem Vokabular kollidieren (Unique auf `name_de`/`name_en`). -- `supabase/catalog/startbestand.sql`: je Spiel ein Eintrag in `games` (`visibility = 'catalog'`, `image_path = null`, `source_id = null`) mit fester UUID sowie Zuordnungen zu Verlagen, Genres, Mechaniken. Upsert auf `id` (`on conflict (id) do update`). Am Anfang `begin;`, am Ende `commit;`. Die Datei wird erzeugt, nie von Hand bearbeitet. -- Entschieden (F6): Chris pflegt `supabase/catalog/startbestand.csv` (z. B. aus einer Tabellenkalkulation exportiert). Das Skript `supabase/catalog/build_startbestand.ts` (Deno, ohne Fremdpakete außer der Standardbibliothek) prüft die CSV und erzeugt daraus `startbestand.sql`. Die UUID eines Spiels ergibt sich deterministisch (UUID v5) aus der Spalte `key` (z. B. `azul`), damit Korrekturen dieselbe Zeile treffen. +- `supabase/catalog/vocabulary.sql`: Genres und Mechaniken (de/en) mit IDs nach UUID v5, `insert … on conflict (id) do update set name_de = excluded.name_de, name_en = excluded.name_en`. Inhalt: Vorschlag durch Agents (B3a), gültig erst nach Freigabe durch Chris (C0). `seed.sql` legt danach keine kollidierenden Begriffe mehr an (Unique auf `name_de`/`name_en`). +- `supabase/catalog/startbestand.csv`: von Chris gepflegt (z. B. aus einer Tabellenkalkulation exportiert). +- `supabase/catalog/build_startbestand.ts`: Deno, nur Standardbibliothek; prüft die CSV und erzeugt `startbestand.sql`, `startbestand-keys.csv` und `checks.sql`. Die erzeugten Dateien werden nie von Hand bearbeitet. -**CSV-Format (F6)** +**CSV-Format** | Spalte | Pflicht | Inhalt | |---|---|---| -| `key` | ja | stabiler Schlüssel, `^[a-z0-9-]{2,60}$`, eindeutig | +| `key` | ja | stabiler Schlüssel, `^[a-z0-9-]{2,60}$`, eindeutig, unveränderlich | | `title` | ja | Haupttitel | | `publishers` | ja | ein oder mehrere Verlage, getrennt durch `;` | | `year` | ja | Erscheinungsjahr | @@ -346,15 +392,24 @@ Keine Edge Functions, keine RPCs für die App in dieser Spec. | `min_age` | ja | Mindestalter | | `genres` | ja | ein oder mehrere Genres aus dem freigegebenen Vokabular (`name_de`), getrennt durch `;` | | `mechanics` | ja | eine oder mehrere Mechaniken aus dem freigegebenen Vokabular, getrennt durch `;` | -| `description` | ja (S4) | eigener Kurztext | -| `description_locale` | ja (S4) | immer `de` | -| `facts_source` | nein | Herkunft der Fakten als Nachweis für S3 (z. B. „Verlag“, „Schachtel“, „Wikidata Q…“); wird **nicht** in die Datenbank übernommen | -| `alt_titles` | nein | reserviert für KAT-01 (#3), Format `de:Titel;en:Title`; das Skript ignoriert die Spalte, bis #3 die Tabelle hat | -| `base_game_key` | nein | reserviert für KAT-03 (#12), `key` des Basisspiels (S7); wird nur auf Existenz geprüft, bis #12 die Spalte hat | +| `description` | ja (SB4) | eigener Kurztext | +| `description_locale` | ja (SB4) | immer `de` | +| `hidden` | nein | `ja` = ausgeblendet (Regel 5) | +| `facts_source` | nein | Herkunft der Fakten als Nachweis für SB3 (z. B. „Verlag“, „Schachtel“, „Wikidata Q…“); wird **nicht** in die Datenbank übernommen | +| `alt_titles` | nein | reserviert für KAT-01 (#3), Format `de:Titel;en:Title`; wird ignoriert, bis #3 die Tabelle hat | +| `base_game_key` | nein | reserviert für KAT-03 (#12), `key` des Basisspiels (SB7); wird nur auf Existenz geprüft, bis #12 die Spalte hat | -Das Skript bricht mit Zeilennummer und Grund ab bei: fehlender Pflichtangabe, unbekanntem Genre/Mechanik, doppeltem `key`, `max < min`, Werten außerhalb der DB-Checks, fehlendem Kurztext, `description_locale` ≠ `de`, `base_game_key` ohne passenden `key`. Es gibt **keine** Spalte für ein Cover und keine Quellen-Spalten (Krit. 10). So kann ein unvollständiges Spiel gar nicht erst in die SQL-Datei gelangen; CI bleibt grün, auch während Chris noch einpflegt. +Das Skript bricht mit Zeilennummer und Grund ab bei: fehlender Pflichtangabe, unbekanntem Genre/Mechanik, doppeltem oder entferntem `key`, `max < min`, Werten außerhalb der DB-Checks, fehlendem Kurztext, `description_locale` ≠ `de`, `base_game_key` ohne passenden `key`. Es gibt **keine** Spalte für Cover, Quelle oder BGG-Kennung (Krit. 10). -**Prüfabfrage vor dem Go-live** (`supabase/catalog/checks.sql`, Teil der Release-Checkliste): Anzahl sichtbarer Katalogspiele (S1, mindestens 180), Spiele ohne Verlag, Genre, Mechanik oder Kurztext (S2, S4), Kurztexte mit `description_locale` ≠ `de`, Katalogspiele mit `image_path` oder `source_id` (S6, müssen 0 sein). Die Abfrage gibt je Regel eine Zeile mit Sollwert, Istwert und „ok“/„Verstoß“ aus. +**Prüfabfrage `supabase/catalog/checks.sql`** (erzeugt, Teil der Release-Checkliste) + +- Kennzeichnung der Startbestandsspiele: Das Skript bettet die IDs aus `startbestand-keys.csv` ein (`with sb(id) as (values …)`). Alle Prüfungen beziehen sich nur auf diese IDs; Spiele, die Chris später im Studio anlegt, und das Beispielspiel aus `seed.sql` (nur lokal) sind damit ausgenommen. +- Je Regel eine Zeile mit Sollwert, Istwert und „ok“/„Verstoß“: + - SB1: Anzahl `sb`-IDs in `catalog_games` ≥ 180, + - SB2: `sb`-Spiele mit `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes` oder `min_age` leer, oder ohne Verlag, Genre oder Mechanik = 0, + - SB4: `sb`-Spiele ohne `description` oder mit `description_locale` ≠ `de` = 0, + - SB6: `sb`-Spiele mit `image_path`, `catalog_source_id` oder `bgg_id` = 0. +- Lokal und in CI ist SB1 nur Information (kein Abbruch), solange Chris noch einpflegt. **Daten werden nicht von Agents erfunden.** Agents liefern Werkzeug, Format, Prüfungen, den Vokabular-Vorschlag und höchstens offensichtlich fiktive Testzeilen in Fixtures (z. B. „Testspiel A“). Den Inhalt liefert Chris (Krit. 7). @@ -364,33 +419,33 @@ Ein Dokument, wachsend über die PRs: | Abschnitt | PR | Inhalt | |---|---|---| -| Zugang | 2a | Studio über SSH-Tunnel/VPN, Rolle `postgres`, „immer in einer Transaktion arbeiten“ (`begin; … commit;`), vor größeren Änderungen Backup-Stand prüfen | -| Startbestand pflegen, einspielen und abnehmen | 2a | Randbedingungen S1–S8; CSV bearbeiten → Skript ausführen → Commit; Reihenfolge Migrationen → `vocabulary.sql` → `startbestand.sql`; Prüfabfrage `checks.sql`; ab dem Go-live ist die Datei eingefroren, Korrekturen nur noch im Studio (F6) | -| Spiel anlegen und bearbeiten | 2a | Tabelleneditor oder SQL; Zuordnungen zu Verlag, Genre, Mechanik; Hinweis: jede Änderung wird automatisch in `manual_fields` vermerkt; Markierung bewusst entfernen nur per SQL | -| Ausblenden und wieder einblenden | 2b | `hidden_at` setzen bzw. leeren; Wirkung: nicht mehr in Suche/Entdecken, Detailseite und bestehende Sammlungen und Partien bleiben (F2) | -| Zusammenführen | 2b | Duplikat finden (Abfrage nach normalisiertem Titel + Jahr), `select public.admin_merge_games('', '');`, Ergebnis-JSON lesen (inkl. Kollisionen nach F1), Fehlertexte und ihre Bedeutung | -| Meldungen bearbeiten | 2c | offene Meldungen abfragen (älteste zuerst), Ähnliche im Katalog suchen, Entscheidung: freigeben (erst Katalogeintrag anlegen, dann `admin_resolve_report(…, 'approved', '')`), zusammenführen (`'merged'`, hängt den vorläufigen Eintrag um), ablehnen mit Verweis (`'rejected'` mit Ziel, hängt nicht um) oder ohne Verweis (F3); Fehlertexte | +| Zugang | 2a | SSH-Tunnel/VPN (Studio, 5432, Pooler, `/pg/`), Rolle `postgres` bzw. `supabase_admin`; immer in einer Transaktion arbeiten (`begin; … commit;`); **niemals `set` ohne `local`**; vor größeren Änderungen Backup-Stand prüfen | +| Startbestand pflegen, einspielen und abnehmen | 2a | Regeln SB1–SB8; CSV bearbeiten → Skript → Commit; Schlüssel nie umbenennen, Entfernen nur über `hidden`; UUID-v5-Namespace; Reihenfolge Migrationen → `vocabulary.sql` → `startbestand.sql`; `checks.sql`; ab Go-live ist die Datei eingefroren (Spec-F6); Ergebnis der Prüfung der unverifizierten Annahmen (4.3) | +| Spiel anlegen und bearbeiten | 2a | Tabelleneditor oder SQL; Zuordnungen zu Verlag, Genre, Mechanik; jede Änderung wird automatisch in `manual_fields` vermerkt; Markierung bewusst entfernen nur per SQL; Katalogspiele nie löschen, sondern ausblenden | +| Ausblenden und wieder einblenden | 2a | `hidden_at` setzen bzw. leeren; Wirkung: nicht mehr in Suche/Entdecken, Detailseite und bestehende Sammlungen und Partien bleiben (Spec-F2) | +| Zusammenführen | 2b | Duplikat finden (normalisierter Titel + Jahr), `select public.admin_merge_games('', '');`, Ergebnis-JSON lesen (Kollisionen nach Spec-F1, Varianten, `bgg_id_conflict`), Fehlertexte | +| Meldungen bearbeiten | 2c | offene Meldungen abfragen (älteste zuerst), Ähnliche im Katalog suchen, Entscheidung: freigeben (erst Katalogeintrag anlegen, dann `admin_resolve_report(…, 'approved', '')`), zusammenführen (`'merged'`), ablehnen mit oder ohne Verweis (`'rejected'`); Hinweis: Editionen werden nicht kopiert, ggf. passende Variante am Katalogeintrag vorher anlegen; Fehlertexte | | Admin-Rolle in der App | 2a | in R1 nicht vergeben (E10), Vorgehen für R4 | -Jeder SQL-Baustein im Runbook wird in pgTAP mindestens einmal genau so ausgeführt (siehe 7.1), damit das Runbook nicht veraltet. +Jeder SQL-Baustein im Runbook wird in pgTAP mindestens einmal genau so ausgeführt (`06` für die Abschnitte aus 2a, `07` für 2b, `08` für 2c; siehe 7.1), damit das Runbook nicht veraltet. ## 5. Frontend -KAT-05 hat in R1 keine eigenen Screens (keine Admin-UI, Klärung 44). Die App-Anteile der Kriterien entstehen in den Stories, die Katalogdaten anzeigen: +KAT-05 hat in R1 keine eigenen Screens (keine Admin-UI, Klärung 44). Die App-Anteile der Kriterien entstehen in den Stories, die Katalogdaten anzeigen. Krit. 10 wird deshalb erst mit KAT-02 (#4) vollständig abgenommen; erst danach schließt Chris #2 (E1). | Kriterium | App-Anteil | Wo umgesetzt | |---|---|---| | Krit. 4 („nicht auffindbar“) | Suche und Entdecken lesen nur `catalog_games` | KAT-01 (#3), Spec #3 | -| Krit. 4 (zusammengeführt, ausgeblendet) | Detailseite eines zusammengeführten Eintrags leitet auf den Zieleintrag weiter; ein ausgeblendeter Eintrag bleibt per Direktaufruf und aus Sammlung/Partien erreichbar und zeigt den Hinweis „nicht mehr im Katalog“ (F2) | KAT-02 (#4) | +| Krit. 4 (zusammengeführt, ausgeblendet) | Detailseite eines zusammengeführten Eintrags leitet auf den Zieleintrag weiter; ein ausgeblendeter Eintrag bleibt per Direktaufruf und aus Sammlung/Partien erreichbar und zeigt den Hinweis „nicht mehr im Katalog“ (Spec-F2) | KAT-02 (#4) | | Krit. 10 (Platzhalter-Cover) | Platzhalter, wenn `image_path` leer | **Widget in 2a**, Einsatz in #3/#4 | | Krit. 10 (eigener Kurztext) | Anzeige von `description`, Sprachhinweis über `description_locale` (englische Oberfläche: „Beschreibung auf Deutsch“) | KAT-02 (#4) | -| Krit. 5 (Ergebnis einer Meldung) | Hinweis am vorläufigen Eintrag; bei „abgelehnt mit Verweis“ Angebot, auf den Zieleintrag zu wechseln (F3) | KAT-04 (#13) | +| Krit. 5 (Ergebnis einer Meldung) | Hinweis am vorläufigen Eintrag; bei „abgelehnt mit Verweis“ Angebot, auf den Zieleintrag zu wechseln (Spec-F3); bei geleerter Edition Hinweis, sie neu zu wählen (3.3) | KAT-04 (#13) | **In 2a:** Widget `GameCover` in `app/lib/core/widgets/game_cover.dart` (wird von Katalog, Sammlung, Dashboard und Partien genutzt, deshalb `core/`). - Eingaben: `imageUrl` (nullable; die Auflösung des Storage-Pfads liefert später die Dateizugriffsschicht), `title` (für die Beschriftung), `size` (klein für Listen, groß für die Detailseite). - Zustände: kein Bild → Platzhalter; Bild lädt → Platzhalter mit dezenter Ladeanzeige; Fehler beim Laden → Platzhalter (kein Fehlerzustand, ein fehlendes Cover ist kein Fehler). -- Platzhalter (F9, entschieden): **ein einheitlicher, neutraler Platzhalter für alle Spiele**, mit Flutter gezeichnet (Fläche in Theme-Farbe, neutrales Symbol), keine Bilddatei von Dritten, kein neues Paket, keine Variante je Genre. +- Platzhalter (Spec-F9): **ein einheitlicher, neutraler Platzhalter für alle Spiele**, mit Flutter gezeichnet (Fläche in Theme-Farbe, neutrales Symbol), keine Bilddatei von Dritten, kein neues Paket. - Semantics: Bild ausgeblendet aus dem Semantics-Baum, wenn der Titel daneben steht (`excludeSemantics`-Variante); sonst Beschriftung aus ARB `gameCoverPlaceholderLabel` („Kein Cover für {title}“ / „No cover for {title}“). - Keine Geschäftslogik, keine Domänenmodelle in 2a. Das Domänenmodell `CatalogGame` (freezed) und das Repository legt KAT-01 (#3) an, auf Basis der Spalten von `catalog_games`. @@ -398,8 +453,8 @@ KAT-05 hat in R1 keine eigenen Screens (keine Admin-UI, Klärung 44). Die App-An - **iOS, Android, Web:** keine Unterschiede. Das Platzhalter-Cover ist Flutter-Code, keine Plattform-API. - **Offline:** nicht betroffen. Katalogsuche braucht eine Verbindung (PAR-01 Krit. 6). -- **Dateien/Kamera/Push:** nicht betroffen. Cover-Uploads gibt es in R1 nicht (N11); Storage-Buckets für Spielbilder entstehen erst, wenn ein Cover erlaubt ist. -- **Self-Hosting:** Studio-Zugang nur über Tunnel/VPN (4.2); Startbestand per SQL-Datei (4.3). +- **Dateien/Kamera/Push:** nicht betroffen. Cover-Uploads gibt es in R1 nicht (N11). +- **Self-Hosting:** Zugang nur über Tunnel/VPN (4.2); Startbestand per SQL-Datei (4.3). ## 7. Teststrategie @@ -407,66 +462,74 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests für das Erzeugungsskript, `fl **Neue pgTAP-Dateien** -- `06_catalog_foundation.test.sql` (2a): Spalten, Checks, Spaltenrechte, Trigger `manual_fields`, View `catalog_games`, `catalog_sources`, Verlage. -- `07_catalog_merge.test.sql` (2b): `repoint_game_references`, `admin_merge_games`, Ausblenden; Fremdschlüssel-Wächter. +- `06_catalog_foundation.test.sql` (2a): Spalten, Checks, Grants, Spaltenrechte, Trigger `manual_fields` (inkl. Import-Ausnahme mit einer im Test angelegten Rolle `catalog_importer`), View `catalog_games`, `catalog_sources`, Verlage, Ausblenden, Runbook-Bausteine aus 2a. +- `07_catalog_merge.test.sql` (2b): `repoint_game_references` (beide Variantenfälle), `admin_merge_games`; Fremdschlüssel-Wächter. - `08_game_reports.test.sql` (2c): Tabelle, RLS, `admin_resolve_report`. +- `00_schema.test.sql` wird angepasst (fest „17 Tabellen“ → 20 in 2a, 21 in 2c). -Die Tests müssen auch mit eingespieltem Startbestand grün sein: Sie legen eigene Fixtures mit festen Test-UUIDs an und zählen nur diese (Filter auf die Fixture-IDs), statt den gesamten Katalog zu löschen. `02_catalog.test.sql` löscht weiterhin alles (`delete from public.games` innerhalb der zurückgerollten Transaktion); das funktioniert auch mit dem Startbestand, weil der Startbestand keine Verweise aus Nutzertabellen hat. +Die Tests müssen auch mit eingespieltem Startbestand grün sein: Sie legen eigene Fixtures mit festen Test-UUIDs an und zählen nur diese. `02_catalog.test.sql` löscht weiterhin alles (`delete from public.games` innerhalb der zurückgerollten Transaktion); das funktioniert auch mit dem Startbestand, weil er keine Verweise aus Nutzertabellen hat. -**Fremdschlüssel-Wächter (2b):** Ein Test liest aus `pg_constraint` alle Fremdschlüssel, die auf `public.games` zeigen, und vergleicht sie mit der Liste, die `repoint_game_references` behandelt (bzw. bewusst auslässt: Zuordnungstabellen, `games.merged_into_id`, `game_reports`). Legt eine spätere Story eine neue Tabelle mit Verweis auf `games` an, wird der Test rot und erzwingt die Erweiterung der Funktion. +**Fremdschlüssel-Wächter (2b, erweitert in 2c):** Ein Test liest aus `pg_constraint` alle Fremdschlüssel, die auf `public.games` zeigen, und vergleicht sie mit einer festen Liste in zwei Gruppen: + +- **behandelt** von `repoint_game_references`/`admin_merge_games`: `collection_items.game_id`, `favorite_games.game_id`, `plays.game_id`, `score_sheets.game_id`, `game_variants.game_id`, `games.merged_into_id` (Ketten glätten), ab 2c `game_reports.target_game_id`, +- **bewusst ausgelassen**: `game_genres`, `game_mechanics`, `game_categories`, `game_publishers` (bleiben am Quell-Eintrag), ab 2c `game_reports.provisional_game_id` (zeigt auf ein privates Spiel; das Zusammenführen zweier Katalogspiele betrifft ihn nicht). + +Legt eine spätere Story eine neue Tabelle mit Verweis auf `games` an, wird der Test rot und erzwingt eine Entscheidung. ### 7.1 Abdeckungsmatrix | AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task | |---|---|---|---|---|---| -| KAT-05 Krit. 1 (nur Modell) | – | – | – | `06`: Update eines Katalogspiels als `postgres` → Feld in `manual_fields`; mit `set local shuffle.catalog_import = 'on'` → nicht markiert; Insert markiert alle gesetzten Felder (F4); Zuordnung Genre/Verlag → `genres`/`publishers` markiert; unbekannter Schlüssel in `manual_fields` → 23514 | B1, B2 | -| Krit. 2 | – | – | – | `06`: `games.id` ist uuid-PK mit Standard; ID bleibt bei Update unverändert (bestehender Trigger); `source_id` ohne `source_ref` → 23514; `(source_id, source_ref)` im Katalog eindeutig → 23505; Quelle `bgg` vorhanden | B1, B2 | +| KAT-05 Krit. 1 (nur Modell) | – | – | – | `06`: Update eines Katalogspiels als `postgres` → Feld in `manual_fields`; `set local shuffle.catalog_import = 'on'` als `postgres` → **trotzdem markiert**; als Rolle `catalog_importer` mit dieser Einstellung → nicht markiert; Insert markiert alle gesetzten Felder (Spec-F4); Zuordnung Genre/Verlag anlegen, ändern, löschen → `genres`/`publishers` markiert; unbekannter Schlüssel → 23514 | B1, B2 | +| Krit. 2 | – | – | – | `06`: `games.id` ist uuid-PK mit Standard; ID bleibt bei Update unverändert; `catalog_source_id` ohne `source_ref` → 23514; `(catalog_source_id, source_ref)` im Katalog eindeutig → 23505; Quelle `bgg` vorhanden; `terms_url` ohne `https://` → 23514 | B1, B2 | | Krit. 3 | – | – | – | – (zurückgestellt, kein Modellanteil) | – | -| Krit. 4 | – | – | (KAT-01: Suche findet ausgeblendete Spiele nicht, E2E in #3) | `06`: `catalog_games` ohne ausgeblendete und zusammengeführte Spiele, für `anon` und `authenticated`; ausgeblendetes Spiel bleibt per ID in `games` lesbar (F2); Sammlungseintrag eines Nutzers zu einem ausgeblendeten Spiel bleibt lesbar. `07`: Ausblenden/Einblenden per Runbook-SQL; `admin_merge_games` hängt Sammlung, Favoriten, Partien (mit Variante) und Wertungsbögen um; Kollision (F1): Ziel bleibt, leere Felder aus der Quelle gefüllt, `owned` schlägt `wishlist`/`shopping`, Quell-Eintrag gelöscht; Quelle danach `merged_into_id`/`hidden_at` gesetzt, Ketten geglättet; alle Fehlerfälle (`merge_same_game` … `merge_source_already_merged`); Fremdschlüssel-Wächter | B1, B2, B5, B6 | -| Krit. 5 | – | – | – | `08`: Checks der Status-Kombinationen; `admin_resolve_report` für `approved` und `merged` (vorläufiger Eintrag umgehängt und zusammengeführt), `rejected` mit Ziel (nur Verweis gespeichert, vorläufiger Eintrag unverändert; F3), `rejected` ohne Ziel (vorläufiger Eintrag unverändert); `report_not_open` bei zweitem Aufruf; `report_target_required`, `report_target_not_listed`; N12a-Index: zweite offene Import-Meldung desselben Nutzers zur selben BGG-ID → 23505, bei anderem Nutzer erlaubt; Konto löschen → Meldungen weg | B8, B9 | -| Krit. 6 (nur Modell) | – | – | – | `06`: `catalog_games.description`/`image_path` sind `null` bei Quelle ohne Erlaubnis, sichtbar mit Erlaubnis, sichtbar bei manuell gepflegtem Feld, sichtbar ohne Quelle | B1, B2 | -| Krit. 7 | Deno: Skript erzeugt aus einer Fixture-CSV die erwartete SQL (Snapshot), gleiche `key` → gleiche UUID, Fehler mit Zeilennummer je Prüfregel, `facts_source` landet nicht in der SQL | – | CI: `supabase db reset` mit `vocabulary.sql` + `startbestand.sql` läuft durch; CI-Schritt „erzeugte SQL ist aktuell“ (Skript erneut ausführen, `git diff --exit-code`); `checks.sql` läuft gegen die lokale DB ohne Fehler (die Mindestzahl S1 ist dort nur Information, kein CI-Abbruch) | – | B3, T1 | -| Krit. 8 | – | – | – | `07`/`08`: jeder SQL-Baustein aus `docs/katalog-pflege.md` wird im Test genau so ausgeführt (Kommentar mit Abschnittsverweis); Review prüft Vollständigkeit | B4, B7, B10 | -| Krit. 9 | – | – | API-Test optional (PostgREST `PATCH /rest/v1/games` mit Nutzer-JWT → 0 Zeilen bzw. 401/403), nur falls test-engineer ihn für nötig hält; pgTAP deckt dieselben Rollen ab | `06`: `anon` und Nutzer (auch mit gefälschtem `user_metadata.role = 'admin'`) können Katalogspiele, `catalog_sources`, `publishers`, `game_publishers` (Katalog) nicht anlegen, ändern, löschen; Nutzer kann an eigenem privaten Spiel `hidden_at`, `merged_into_id`, `manual_fields`, `source_id` nicht setzen (42501), wohl aber `title`; View nicht schreibbar. `07`/`08`: `admin_merge_games`, `admin_resolve_report`, `repoint_game_references` für `anon`/`authenticated` nicht ausführbar (42501), auch mit `app_metadata.role = 'admin'`; `game_reports`: Nutzer B liest A's Meldungen nicht, niemand außer `postgres` ändert oder löscht Meldungen, `anon` liest nichts | B2, B6, B9 | -| Krit. 10 | Deno: CSV ohne Pflicht-Fakten, ohne Kurztext oder mit `description_locale` ≠ `de` → Fehler; unbekannter Begriff → Fehler; CSV hat keine Cover-/Quellen-Spalte | `GameCover`: ohne `imageUrl` → einheitlicher Platzhalter; Ladefehler → Platzhalter; Semantics-Label de/en; Kontrast und `textScaler` 2.0 ohne Überlauf | (KAT-02: Detailseite zeigt Platzhalter, Kurztext und Sprachhinweis, in #4) | `06`: Fixture-Spiele aus dem Startbestands-Format haben `image_path is null`, `source_id is null`, `description_locale = 'de'`; Prüfabfrage `checks.sql` meldet für die Fixtures 0 Verstöße und für ein absichtlich fehlerhaftes Fixture den erwarteten Verstoß | B3, U1, T1, T2 | +| Krit. 4 | – | – | (KAT-01: Suche findet ausgeblendete Spiele nicht, E2E in #3) | `06`: Ausblenden/Einblenden per Runbook-SQL; `catalog_games` ohne ausgeblendete und zusammengeführte Spiele für `anon` und `authenticated`; ausgeblendetes Spiel bleibt per ID lesbar (Spec-F2); Sammlungseintrag zu einem ausgeblendeten Spiel bleibt lesbar. `07`: Zusammenführen zweier Katalogspiele kopiert Varianten (gleichnamige werden abgebildet) und hängt Sammlung, Favoriten, Partien (mit Variante) und Wertungsbögen um; Kollision (Spec-F1) inkl. Variante über die Zuordnung; `bgg_id`/`source_*` übertragen bzw. `bgg_id_conflict` gemeldet, Unique-Indizes verletzt nicht; Quelle danach `merged_into_id`/`hidden_at`, Ketten geglättet; Löschen eines Ziels mit Verweis → 23503; alle Fehlerfälle (`admin_only` … `merge_source_already_merged`, `merge_not_catalog` für private Quelle); Fremdschlüssel-Wächter | B1, B2, B5, B6 | +| Krit. 5 | – | – | – | `08`: Checks der Status-Kombinationen; `admin_resolve_report` für `approved` und `merged` (vorläufiger Eintrag umgehängt und zusammengeführt; **keine Variante kopiert**, gleichnamige abgebildet, sonst `variant_id` leer, private Varianten bleiben am vorläufigen Eintrag), `rejected` mit Ziel (nur Verweis), `rejected` ohne Ziel (unverändert); `report_not_open`, `report_target_required`, `report_target_not_listed`; N12a-Index; Konto löschen → Meldungen weg; Fremdschlüssel-Wächter erweitert | B8, B9 | +| Krit. 6 (nur Modell) | – | – | – | `06`: `catalog_games.description`/`image_path` sind `null` bei Quelle ohne Erlaubnis, sichtbar mit Erlaubnis, bei manuell gepflegtem Feld und ohne Quelle (zweite Sicherung, E6) | B1, B2 | +| Krit. 7 | Deno: Skript erzeugt aus einer Fixture-CSV die erwartete SQL, Schlüsselliste und `checks.sql` (Snapshot); gleicher `key` → gleiche UUID (Namespace aus 4.3); entfernter `key` → Fehler; `hidden = ja` → `hidden_at`; `facts_source` landet nicht in der SQL | – | CI: `supabase db reset` mit `vocabulary.sql` + `startbestand.sql` läuft durch, zweimaliges Einspielen ergibt denselben Stand (Idempotenz, inkl. Verlag, den es schon mit anderer Schreibweise gibt); CI-Schritt „erzeugte Dateien aktuell“ (`git diff --exit-code`); `checks.sql` läuft ohne Fehler | – | B3, T1 | +| Krit. 8 | – | – | – | `06`/`07`/`08`: jeder SQL-Baustein aus `docs/katalog-pflege.md` wird im Test genau so ausgeführt (Kommentar mit Abschnittsverweis) | B4, B7, B10 | +| Krit. 9 | – | – | API-Test optional (PostgREST `PATCH /rest/v1/games` mit Nutzer-JWT → 0 Zeilen), nur falls test-engineer ihn für nötig hält | `06`: Grants laut 3.2 (`has_table_privilege` je Rolle); `anon` und Nutzer (auch mit gefälschtem `user_metadata.role = 'admin'`) können Katalogspiele, `catalog_sources`, `publishers`, `game_publishers` (Katalog) nicht ändern; Nutzer darf Verlage seinem privaten Spiel zuordnen, fremden nicht; Nutzer kann an eigenem privaten Spiel `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` nicht setzen (42501), wohl aber `title`; View nicht schreibbar. `07`/`08`: Admin-Funktionen für `anon`, `authenticated` (auch mit `app_metadata.role = 'admin'`) und `service_role` nicht ausführbar (42501); `game_reports`: Nutzer B liest A's Meldungen nicht, niemand außer `postgres` ändert oder löscht Meldungen, `anon` liest nichts | B2, B6, B9 | +| Krit. 10 | Deno: CSV ohne Pflicht-Fakten, ohne Kurztext oder mit `description_locale` ≠ `de` → Fehler; unbekannter Begriff → Fehler; keine Cover-/Quellen-/BGG-Spalte | `GameCover`: ohne `imageUrl` → einheitlicher Platzhalter; Ladefehler → Platzhalter; Semantics-Label de/en; Kontrast und `textScaler` 2.0 ohne Überlauf | (KAT-02: Detailseite zeigt Platzhalter, Kurztext und Sprachhinweis, in #4) | `06`: Fixture-Spiele im Startbestands-Format erfüllen SB6 und SB4; `checks.sql` meldet für die Fixtures 0 Verstöße und für ein absichtlich fehlerhaftes Fixture (fehlende Spieldauer, gesetzte `bgg_id`) den erwarteten Verstoß; ein Nicht-Startbestandsspiel wird ignoriert | B3, U1, T1, T2 | | Sicherheit Krit. 2 | – | – | – | `06`: `anon` liest `catalog_games`, `catalog_sources`, `publishers`, `game_publishers` (Katalog), schreibt nichts | B2 | | Sicherheit Krit. 3 | – | – | – | `08`: fremder Zugriff auf `game_reports` | B9 | | QUE-05 Krit. 2, QUE-06 Krit. 1–3 | – | `GameCover` in de/en, a11y-Guidelines | – | – | U1, T2 | -**Nicht automatisiert testbar, manuelle Prüfung:** Studio nicht öffentlich erreichbar (Staging/Prod, D1); Einspielen des Startbestands auf Staging/Prod und Prüfabfrage mit mindestens 180 Spielen (S1, Release-Checkliste, Abnahme durch Chris); Herkunft der Fakten nach S3 (Bestätigung durch Chris, rechtliche Prüfung vor dem Release); inhaltliche Richtigkeit der Fakten und Kurztexte (Chris). +**Nicht automatisiert testbar, manuelle Prüfung:** Zugang nur über Tunnel/VPN (Studio, 5432, Pooler, `/pg/`; D1); Einspielen des Startbestands auf Staging/Prod und `checks.sql` mit mindestens 180 Spielen (SB1, Abnahme durch Chris, C1); Herkunft der Fakten und Kurztexte nach SB3/SB4 (Chris, rechtliche Prüfung vor dem Release); inhaltliche Richtigkeit (Chris). ## 8. Aufgaben +Alle PRs: `Refs #2` (E1). + ### 8.1 PR 2a – Fundament (R1.1), Branch `feature/2-katalog-fundament` | # | Agent | Aufgabe | abhängig von | |---|---|---|---| -| B1 | backend-dev | Migration `_catalog_foundation.sql` nach 3.2: `catalog_sources` (+ Zeile `bgg`), neue Spalten in `games` mit Checks und Indizes, `publishers`, `game_publishers`, Trigger `games_mark_manual_fields` (Insert markiert alle gesetzten Felder, F4) und `game_links_mark_manual`, View `catalog_games`, Spaltenrechte auf `games`, RLS und Grants für alle neuen Tabellen | – | -| B2 | backend-dev | pgTAP `06_catalog_foundation.test.sql` nach 7.1 (Krit. 1, 2, 4, 6, 9, 10, Sicherheit Krit. 2); bestehende Tests mit Startbestand lauffähig halten; `00_schema.test.sql` prüft fest „genau 17 Tabellen“ → auf 20 anpassen (`catalog_sources`, `publishers`, `game_publishers`), Zahlen in `supabase/README.md` („17 Tabellen, 2 Views“) nachziehen (3 Views mit `catalog_games`) | B1 | -| B3a | backend-dev | Vokabular-Vorschlag (ca. 12–15 Genres, 20–30 Mechaniken, je `name_de`/`name_en`) als `supabase/catalog/vocabulary.sql` mit festen UUIDs; Vorlage zur Freigabe an Chris (im PR als eigener Abschnitt). Gilt erst nach Freigabe (C0). | B1 | -| B3 | backend-dev | Startbestand-Werkzeug nach 4.3: CSV-Vorlage `startbestand.csv` (nur Kopfzeile + Hinweise), Skript `build_startbestand.ts` mit allen Prüfregeln (S2, S4–S7) und Deno-Tests, erzeugtes `startbestand.sql` (zunächst leer bzw. nur Kopf), `checks.sql` mit Regeln S1, S2, S4, S6 (Soll/Ist/Ergebnis), `config.toml` `[db.seed] sql_paths`, `seed.sql` bereinigen (keine kollidierenden Begriffe) | B1, B3a | -| B4 | backend-dev | Doku: `docs/katalog-pflege.md` Abschnitte Zugang, Startbestand (Randbedingungen S1–S8, Ablauf CSV → Skript → Einspielen → Abnahme, Einfrieren nach Go-live), Spiel anlegen/bearbeiten, Admin-Rolle (4.4); `docs/datenmodell-r1.md` (neue Objekte, doppelten Abschnitt zusammenführen); `supabase/README.md` | B1, B3 | -| U1 | ui-designer | `GameCover` in `core/widgets/` nach 5: ein einheitlicher neutraler Platzhalter (F9), Größen klein/groß, Ladezustand, Semantics, ARB-Schlüssel de/en | – | -| T1 | test-engineer | Review der pgTAP-Abdeckung gegen 7.1; Deno-Tests des Skripts ergänzen (jede Prüfregel S2, S4–S7 mit Positiv- und Negativfall); CI-Schritt „erzeugte SQL ist aktuell“ mit devops abstimmen | B2, B3 | +| B1 | backend-dev | Migration `_catalog_foundation.sql` nach 3.2: `catalog_sources` (+ Zeile `bgg`), neue Spalten in `games` mit Checks, `on delete`-Regeln und Indizes, `publishers`, `game_publishers`, Trigger `games_mark_manual_fields` (Import-Ausnahme nur für Rolle `catalog_importer` + `set local`) und `game_links_mark_manual` (insert/update/delete), View `catalog_games`, Spaltenrechte auf `games` (ohne `id`), RLS, Policies und Grants laut Grant-Tabelle | – | +| B2 | backend-dev | pgTAP `06_catalog_foundation.test.sql` nach 7.1 (Krit. 1, 2, 4, 6, 8, 9, 10, Sicherheit Krit. 2) inkl. Runbook-Bausteine aus 2a; bestehende Tests mit Startbestand lauffähig halten; `00_schema.test.sql` auf 20 Tabellen; README-Zahlen (3 Views mit `catalog_games`) | B1 | +| B3a | backend-dev | Vokabular-Vorschlag (ca. 12–15 Genres, 20–30 Mechaniken, je `vocab_key`, `name_de`, `name_en`) als `supabase/catalog/vocabulary.sql` mit IDs nach UUID v5; im PR als eigener Abschnitt zur Freigabe. Gilt erst nach C0. | B1 | +| B3 | backend-dev | Startbestand-Werkzeug nach 4.3: CSV-Vorlage, Skript `build_startbestand.ts` (Prüfregeln SB2, SB4–SB7, Schlüsselliste, Idempotenz-Regeln 1–5, UUID-v5-Namespace, Erzeugung von `startbestand.sql`, `startbestand-keys.csv`, `checks.sql`) mit Deno-Tests; `config.toml` `[db.seed] sql_paths`; `seed.sql` bereinigen; **unverifizierte Annahmen aus 4.3 prüfen** und Ergebnis dokumentieren | B1, B3a | +| B4 | backend-dev | Doku: `docs/katalog-pflege.md` Abschnitte Zugang, Startbestand, Spiel anlegen/bearbeiten, Ausblenden, Admin-Rolle (4.4); `docs/datenmodell-r1.md` (neue Objekte, doppelten Abschnitt zusammenführen); `supabase/README.md` (3.5) | B1, B3 | +| U1 | ui-designer | `GameCover` in `core/widgets/` nach 5: ein einheitlicher neutraler Platzhalter (Spec-F9), Größen klein/groß, Ladezustand, Semantics, ARB-Schlüssel de/en | – | +| T1 | test-engineer | Review der pgTAP-Abdeckung gegen 7.1; Deno-Tests des Skripts ergänzen (jede Prüfregel mit Positiv- und Negativfall, Idempotenz, entfernter Schlüssel); CI-Schritt „erzeugte Dateien aktuell“ mit devops abstimmen | B2, B3 | | T2 | test-engineer | Widget-Tests `GameCover` (Platzhalter, Ladefehler, de/en, a11y-Guidelines, `textScaler` 2.0) | U1 | -| D1 | devops | Studio-/Postgres-Zugang auf Staging/Prod nur über SSH-Tunnel/VPN, Basic-Auth gesetzt, Studio-Rolle per `select current_user` bestätigen (Doku in `docs/`, kein Eingriff ohne Freigabe); CI: Deno für das Skript bereitstellen, Schritt „erzeugte SQL aktuell“; Release-Checkliste um `checks.sql` (S1: mindestens 180) ergänzen | B3 | +| D1 | devops | Zugang auf Staging/Prod nur über SSH-Tunnel/VPN für Studio, 5432, Pooler und Kong-Route `/pg/`; Basic-Auth gesetzt; Studio-Rolle per `select current_user` bestätigen (Doku in `docs/`, kein Eingriff ohne Freigabe); CI: Deno bereitstellen, Schritt „erzeugte Dateien aktuell“; Release-Checkliste um `checks.sql` (SB1: mindestens 180) ergänzen | B3 | | C0 | Chris | Vokabular-Vorschlag aus B3a freigeben oder korrigieren | B3a | -| C1 | Chris | Startbestand in der CSV pflegen (mindestens 180, Ziel ~200; Fakten nach S3, deutsche Kurztexte nach S4), Skript ausführen, committen; vor dem Go-live auf Produktion einspielen und `checks.sql` abnehmen | B3, C0 | +| C1 | Chris | Startbestand in der CSV pflegen (mindestens 180, Ziel ~200; Fakten nach SB3, deutsche Kurztexte nach SB4), Skript ausführen, committen; vor dem Go-live auf Produktion einspielen und `checks.sql` abnehmen; danach #2 schließen, sobald #4 fertig ist | B3, C0 | -### 8.2 PR 2b – Admin-Pflege (R1.2), Branch `feature/2-katalog-pflege` +### 8.2 PR 2b – Zusammenführen (R1.2), Branch `feature/2-katalog-pflege` | # | Agent | Aufgabe | abhängig von | |---|---|---|---| -| B5 | backend-dev | Migration `_catalog_merge.sql` nach 3.3: `repoint_game_references` mit Kollisionsregel F1, `admin_merge_games`, Rechte (`revoke execute`) | 2a gemergt | -| B6 | backend-dev | pgTAP `07_catalog_merge.test.sql` inkl. Kollisionsfällen (F1: gleiche Liste, unterschiedliche Liste, leere/gefüllte Felder) und Fremdschlüssel-Wächter | B5 | -| B7 | backend-dev | Runbook: Abschnitte Ausblenden und Zusammenführen (SQL-Bausteine identisch zu den Tests) | B5, B6 | +| B5 | backend-dev | Migration `_catalog_merge.sql` nach 3.3: `repoint_game_references` (beide Variantenfälle, Kollisionsregel Spec-F1 über `variant_map`), `admin_merge_games` (Rollenprüfung, Übertragung `bgg_id`/`source_*`), Rechte (`revoke execute` inkl. `service_role`) | 2a gemergt | +| B6 | backend-dev | pgTAP `07_catalog_merge.test.sql` inkl. Kollisionsfällen, Variantenfällen, `bgg_id`-Übertragung und -Konflikt, Fremdschlüssel-Wächter | B5 | +| B7 | backend-dev | Runbook: Abschnitt Zusammenführen (SQL-Bausteine identisch zu den Tests) | B5, B6 | ### 8.3 PR 2c – Meldungen (R1.2), Branch `feature/2-katalog-meldungen` | # | Agent | Aufgabe | abhängig von | |---|---|---|---| -| B8 | backend-dev | Migration `_game_reports.sql` nach 3.4: Tabelle, Checks, Indizes, RLS (nur Lesen der eigenen), `admin_resolve_report` (Umhängen bei `approved` und `merged`, nur Verweis bei `rejected` mit Ziel; F3), `admin_merge_games` erweitert | 2b gemergt | -| B9 | backend-dev | pgTAP `08_game_reports.test.sql`; `00_schema.test.sql` auf 21 Tabellen, README-Zahlen nachziehen | B8 | +| B8 | backend-dev | Migration `_game_reports.sql` nach 3.4: Tabelle, Checks, `on delete`-Regeln, Indizes, RLS (nur Lesen der eigenen), `admin_resolve_report` (Umhängen bei `approved`/`merged` ohne Varianten-Kopie; nur Verweis bei `rejected` mit Ziel), `admin_merge_games` erweitert | 2b gemergt | +| B9 | backend-dev | pgTAP `08_game_reports.test.sql`; Fremdschlüssel-Wächter um `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (bewusst ausgelassen) erweitern; `00_schema.test.sql` auf 21 Tabellen; README-Zahlen | B8 | | B10 | backend-dev | Runbook: Abschnitt Meldungen bearbeiten | B8, B9 | ### 8.4 Reihenfolge und Parallelität @@ -475,9 +538,10 @@ Die Tests müssen auch mit eingespieltem Startbestand grün sein: Sie legen eige R1.1 B1 → B2 | B3a → C0 → B3 → B4, T1, D1 parallel: U1 → T2 danach: C1 (Chris, laufend bis Go-live) 2a gemergt → #3 KAT-01 kann starten R1.2 B5 → B6 → B7 (2b) → B8 → B9 → B10 (2c) → #13 KAT-04 Umsetzung +Abschluss #2: C1 abgenommen und #4 KAT-02 gemergt → Chris schließt #2 ``` -B3 kann mit einem vorläufigen Fixture-Vokabular für die Tests beginnen; die Datei `vocabulary.sql` wird erst nach C0 endgültig. 2c muss vor der Umsetzung von #13 gemergt sein (E12); die Spec von #13 kann parallel entstehen und baut auf 3.4 auf. Merge nur durch Chris. +B3 kann mit einem vorläufigen Fixture-Vokabular für die Tests beginnen; `vocabulary.sql` wird erst nach C0 endgültig. 2c muss vor der Umsetzung von #13 gemergt sein (E12). Merge nur durch Chris. ## 9. Risiken & offene Fragen @@ -485,66 +549,92 @@ B3 kann mit einem vorläufigen Fixture-Vokabular für die Tests beginnen; die Da | # | Risiko | Auswirkung | Gegenmaßnahme | |---|---|---|---| -| R1 | **Studio umgeht RLS** und hat im Self-Hosting keine eigene Benutzerverwaltung | Wer Studio erreicht, kann alles lesen und ändern, auch private Nutzerdaten | Studio und Postgres nur über SSH-Tunnel/VPN (D1), Basic-Auth als zweite Schicht; Runbook: immer in Transaktionen, Funktionen statt Hand-SQL für Zusammenführen und Meldungen | -| R2 | **EU-Datenbankrecht und Nutzungsbedingungen:** Werden die Fakten für ~200 Spiele in großem Umfang aus BGG oder einer anderen Datenbank übernommen, kann das das Schutzrecht des Datenbankherstellers bzw. deren Nutzungsbedingungen verletzen, auch wenn Einzelfakten nicht geschützt sind | Rechtliches Risiko für den Start | Entschieden (F7): Fakten aus Verlagsseiten, Spielschachtel, eigener Kenntnis, ergänzend Wikidata (CC0); keine systematische Übernahme aus BGG (S3); optionaler Herkunftsnachweis `facts_source`; endgültige Bewertung mit der rechtlichen Prüfung vor dem Release (R11 in Spec #1) | -| R3 | Spätere Tabellen mit Verweis auf `games` werden beim Zusammenführen vergessen | Verwaiste Verweise auf ausgeblendete Quell-Einträge | Fremdschlüssel-Wächter in `07` (7) | -| R4 | Ausgeblendete Spiele bleiben über die rohe API per ID lesbar (E3) | Ein aus rechtlichen Gründen ausgeblendeter Eintrag wäre weiter abrufbar | Akzeptiert (F2); im Ernstfall Eintrag zusammenführen oder inhaltlich leeren; Status „gesperrt“ mit RLS-Filter erst bei Bedarf | -| R5 | Startbestand weicht zwischen CSV/SQL-Datei und Produktion ab, sobald Chris nach dem Go-live im Studio korrigiert | Erneutes Einspielen der Datei überschreibt Studio-Korrekturen (Upsert) | Entschieden (F6): Datei ist ab dem Go-live eingefroren und wird nicht mehr eingespielt; Regel im Runbook | -| R6 | `manual_fields` markiert den ganzen Startbestand als manuell (F4) | Ein späterer Import ergänzt nur leere Felder und korrigiert keine Fakten | Bewusst entschieden (F4); Admin kann Markierungen per SQL gezielt entfernen (Runbook) | -| R7 | `game_reports` wird in #2 angelegt, die Regeln zum Anlegen kommen erst mit #13 | Zwischen 2c und #13 gibt es Meldungen nur per Studio (für Tests) | Kein Insert-Recht für Nutzer in 2c, deshalb kein offener Zwischenzustand | -| R8 | Spaltenrechte auf `games` schränken auch einen künftigen Admin über die API ein | QUE-08 (R4) braucht eigene RPCs | Bewusst; Hinweis in 3.2 | -| R9 | ~180–200 Spiele mit deutschen Kurztexten einpflegen ist viel Handarbeit für Chris | Startbestand zum R1-Start unvollständig | CSV mit Prüfungen (F6), KI-Entwürfe für Kurztexte mit Prüfung durch Chris zulässig (F5), Mindestzahl 180 (F8); `checks.sql` zeigt den Fortschritt | +| R1 | **Studio umgeht RLS** und hat im Self-Hosting keine eigene Benutzerverwaltung | Wer Studio, Postgres, Pooler oder `/pg/` erreicht, kann alles lesen und ändern, auch private Nutzerdaten | Zugang nur über SSH-Tunnel/VPN (D1), Basic-Auth als zweite Schicht; Runbook: Transaktionen, nie `set` ohne `local`, Funktionen statt Hand-SQL | +| R2 | **EU-Datenbankrecht und Nutzungsbedingungen** bei massenhafter Übernahme von Fakten aus BGG oder einer anderen Datenbank; ebenso umformulierte fremde Texte | Rechtliches Risiko für den Start | Entschieden (Spec-F7): SB3, SB4 (KI-Entwürfe nur aus Fakten); optionaler Herkunftsnachweis `facts_source`; rechtliche Prüfung vor dem Release (R11 in Spec #1) | +| R3 | Spätere Tabellen mit Verweis auf `games` werden beim Zusammenführen vergessen | Verwaiste Verweise auf ausgeblendete Quell-Einträge | Fremdschlüssel-Wächter (7) | +| R4 | Ausgeblendete Spiele bleiben über die rohe API per ID lesbar (E3) | Ein aus rechtlichen Gründen ausgeblendeter Eintrag wäre weiter abrufbar | Akzeptiert (Spec-F2); im Ernstfall inhaltlich leeren; Status „gesperrt“ mit RLS-Filter erst bei Bedarf | +| R5 | Startbestand weicht zwischen Datei und Produktion ab, sobald Chris nach dem Go-live im Studio korrigiert | Erneutes Einspielen überschreibt Studio-Korrekturen (Upsert, Zuordnungen neu aufgebaut) | Entschieden (Spec-F6): Datei ab Go-live eingefroren, wird nicht mehr eingespielt | +| R6 | `manual_fields` markiert den ganzen Startbestand als manuell (Spec-F4) | Ein späterer Import ergänzt nur leere Felder | Bewusst entschieden; Markierungen lassen sich per SQL gezielt entfernen | +| R7 | `game_reports` wird in #2 angelegt, die Regeln zum Anlegen kommen erst mit #13 | Zwischen 2c und #13 gibt es Meldungen nur per Studio | Kein Insert-Recht für Nutzer in 2c | +| R8 | Spaltenrechte auf `games` und die Rollenprüfung der Funktionen schränken auch einen künftigen Admin über die API ein; der Trigger `game_links_mark_manual` (security invoker) müsste dann `manual_fields` schreiben dürfen | QUE-08 (R4) braucht eigene RPCs (security definer) bzw. angepasste Rechte | Bewusst; Hinweis für QUE-08 in 9.4 | +| R9 | ~180–200 Spiele mit deutschen Kurztexten einpflegen ist viel Handarbeit für Chris | Startbestand zum R1-Start unvollständig | CSV mit Prüfungen, KI-Entwürfe aus Fakten zulässig (Spec-F5), Mindestzahl 180 (Spec-F8); `checks.sql` zeigt den Fortschritt | +| R10 | Beim Umhängen eines vorläufigen Eintrags geht die Edition verloren, wenn der Katalogeintrag keine gleichnamige Variante hat (B1) | KAT-04 Krit. 4 nur eingeschränkt erfüllt (Edition) | Bewusst entschieden (Chris); Runbook: passende Variante am Katalogeintrag vorher anlegen; Hinweis in der App (#13) | +| R11 | Unverifizierte CLI-Annahmen zu `sql_paths` und Transaktionen (4.3) | Seed bricht lokal/CI ab oder lädt in falscher Reihenfolge | B3 prüft vorab; dokumentierter Rückfall ohne `begin`/`commit` | -### 9.2 Entscheidungen zu den offenen Fragen (Chris, 2026-11-03) +### 9.2 Entscheidungen zu den Spec-Fragen (Chris, 2026-11-03) Alle neun Fragen hat Chris am 2026-11-03 entschieden („Alle wie empfohlen“). Keine Frage blockiert mehr einen PR. | # | Frage | Status | Entscheidung | Wirkung in der Spec | |---|---|---|---|---| -| F1 | Zusammenführen bei Doppelbesitz (Nutzer hat Quell- und Zielspiel) | entschieden 2026-11-03 | **Option A:** Zieleintrag bleibt; leere Felder des Ziels werden aus dem Quell-Eintrag gefüllt; bei unterschiedlicher Liste gilt Sammlung vor Wunsch- vor Einkaufsliste (wie Klärung 34); Quell-Eintrag wird gelöscht. | 3.3 Schritt 2, 7.1 Krit. 4, B5, B6 | -| F2 | Sichtbarkeit ausgeblendeter Einträge | entschieden 2026-11-03 | **Option A:** nur aus Suche und Entdecken entfernt; Detailseite, Sammlungen und Partien zeigen sie weiter mit Hinweis „nicht mehr im Katalog“. Ein Status „gesperrt“ (Option C) kommt erst bei Bedarf. | E3, 3.2 RLS, 5, R4, KAT-02 (#4) | -| F3 | Umhängen bei Meldung „zusammengeführt“ / „abgelehnt mit Verweis“ | entschieden 2026-11-03 | **Option A:** „zusammengeführt“ hängt den vorläufigen Eintrag automatisch um (wie „freigegeben“); „abgelehnt mit Verweis“ speichert nur den Verweis, der Nutzer entscheidet in der App. | 3.4, 4.4, 5, 7.1 Krit. 5, B8, Spec #13 | -| F4 | Kennzeichnung des Startbestands als manuell gepflegt | entschieden 2026-11-03 | **Option A:** alle beim Einpflegen gesetzten Felder gelten als manuell gepflegt; ein späterer Import ergänzt nur leere Felder. | E4, 3.2 Trigger, S8, R6, B1 | -| F5 | Eigene Kurztexte: Sprache, Autor, Pflicht | entschieden 2026-11-03 | **Deutsch**; Chris schreibt selbst oder prüft und gibt KI-Entwürfe frei; **Pflicht** für alle Startbestandsspiele. Englische Oberflächen zeigen den Sprachhinweis (Klärung 48). | E8, § 2 Abnahme, S4, CSV, `checks.sql`, B3 | -| F6 | Pflegeformat des Startbestands | entschieden 2026-11-03 | **Option A:** CSV im Repo, Deno-Skript prüft und erzeugt die SQL-Datei; nach dem Go-live ist die Datei eingefroren, Korrekturen nur noch im Studio. | E9, 4.3, 4.4, R5, B3 | -| F7 | Herkunft der Fakten | entschieden 2026-11-03 | **Option A, ergänzend C:** Verlagsseiten, Spielschachtel, eigene Kenntnis, ergänzend Wikidata (CC0); keine systematische Übernahme aus BGG; endgültige Bewertung mit der rechtlichen Prüfung vor dem Release. **Verbindliche Randbedingung S3.** | § 2 Abnahme, 4.3 S3, `facts_source`, R2, C1 | -| F8 | Umfang, Abnahme, Vokabular, Erweiterungen | entschieden 2026-11-03 | **Mindestens 180** sichtbare Katalogspiele mit allen Pflicht-Fakten zum Go-live (Ziel ~200); Vokabular schlagen Agents vor, **Chris gibt frei**; Erweiterungen erlaubt und über `base_game_key` vorgemerkt. **Verbindliche Randbedingungen S1, S5, S7.** | § 1, § 2 Abnahme, 4.3, B3a, C0, C1, D1 | -| F9 | Platzhalter-Cover | entschieden 2026-11-03 | **Option A:** ein einheitlicher, neutraler Platzhalter für alle Spiele. | 5, U1, T2 | +| Spec-F1 | Zusammenführen bei Doppelbesitz | entschieden 2026-11-03 | **Option A:** Zieleintrag bleibt; leere Felder werden aus dem Quell-Eintrag gefüllt (Edition über die Variantenzuordnung); Sammlung vor Wunsch- vor Einkaufsliste (Klärung 34); Quell-Eintrag wird gelöscht. | 3.3 Schritt 1, 7.1 Krit. 4, B5, B6 | +| Spec-F2 | Sichtbarkeit ausgeblendeter Einträge | entschieden 2026-11-03 | **Option A:** nur aus Suche und Entdecken; Detailseite, Sammlungen und Partien zeigen sie weiter mit Hinweis „nicht mehr im Katalog“. Status „gesperrt“ erst bei Bedarf. | E3, 5, R4, KAT-02 (#4) | +| Spec-F3 | Umhängen bei „zusammengeführt“ / „abgelehnt mit Verweis“ | entschieden 2026-11-03 | **Option A:** „zusammengeführt“ hängt um (wie „freigegeben“); „abgelehnt mit Verweis“ speichert nur den Verweis. | 3.4, 4.4, 5, 7.1 Krit. 5, B8 | +| Spec-F4 | Kennzeichnung des Startbestands | entschieden 2026-11-03 | **Option A:** alle gesetzten Felder gelten als manuell gepflegt. | E4, 3.2, SB8, R6, B1 | +| Spec-F5 | Kurztexte | entschieden 2026-11-03 | **Deutsch**; Chris schreibt oder prüft KI-Entwürfe (nur aus Fakten, SB4); **Pflicht** für alle Startbestandsspiele. | E8, § 2, SB4, B3 | +| Spec-F6 | Pflegeformat | entschieden 2026-11-03 | **Option A:** CSV + Deno-Skript; Datei nach Go-live eingefroren. | E9, 4.3, 4.4, R5, B3 | +| Spec-F7 | Herkunft der Fakten | entschieden 2026-11-03 | **Option A, ergänzend C:** Verlagsseiten, Schachtel, eigene Kenntnis, Wikidata (CC0); keine systematische Übernahme aus BGG. | § 2, SB3, R2, C1 | +| Spec-F8 | Umfang, Abnahme, Vokabular, Erweiterungen | entschieden 2026-11-03 | **Mindestens 180** Spiele (Ziel ~200); Vokabular schlagen Agents vor, **Chris gibt frei**; Erweiterungen erlaubt und vorgemerkt. | § 1, § 2, SB1, SB5, SB7, B3a, C0, C1, D1 | +| Spec-F9 | Platzhalter-Cover | entschieden 2026-11-03 | **Option A:** ein einheitlicher, neutraler Platzhalter. | 5, U1, T2 | -### 9.3 Ausstehende Prozessschritte +Weitere Entscheidungen von Chris aus dem Review zu PR #51 (2026-11-03): -- Spec freigegeben am 2026-11-03, F1–F9 eingearbeitet. Nächste Schritte: B1 (backend-dev), parallel U1 (ui-designer); B3a liefert den Vokabular-Vorschlag zur Freigabe durch Chris (C0). +| # | Thema | Entscheidung | Wirkung | +|---|---|---|---| +| B1 | Varianten beim Umhängen eines privaten Eintrags | **Nicht kopieren, auf `null`:** Abbildung auf gleichnamige Variante des Ziels, sonst `null`. Beim Zusammenführen zweier Katalogspiele werden Varianten weiter kopiert. | E11, 3.3, 3.4, R10, 7.1, 9.4 (#13) | +| W3 | Issue-Abschluss | Alle drei PRs nur `Refs #2`; Chris schließt #2 nach C1 und KAT-02 (#4). | E1, § 2, § 5, 8 | + +### 9.3 Designwahlen aus dem Review – bitte bestätigen + +| # | Wahl | Begründung | Alternative | +|---|---|---|---| +| W1 | Krit. 6: Daten ohne Anzeige-Erlaubnis werden gar nicht gespeichert (Pflicht für die Import-Spec); die View ist nur zweite Sicherung (E6). | Am wenigsten invasiv: keine Änderung an den Leserechten von `games`, keine Auswirkung auf private Spiele und bestehende Abfragen. | Spaltenweises `select` für `anon` auf `games` | +| W6 | `admin_merge_games` überträgt `bgg_id` und `source_*` auf das Ziel, wenn es keine hat, leert sie an der Quelle und meldet Konflikte (3.3). | Sonst träfe der BGG-Abgleich (SAM-08) den ausgeblendeten Quell-Eintrag; die Unique-Indizes bleiben gewahrt. | Nichts übertragen, SAM-08 muss ausgeblendete Treffer selbst auflösen | +| W7 | Import-Ausnahme beim Kennzeichnen nur für die Rolle `catalog_importer` **und** `set local` (E4). | Ein versehentliches `set` im Studio schaltet die Kennzeichnung nicht ab. | nur die Sitzungseinstellung | +| W4 | Verlage ohne abgeleitete ID, Konfliktziel `lower(name)`; Schlüssel unveränderlich, Entfernen über `hidden`; UUID-v5-Namespace `6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4` (4.3). | Studio-Verlage und Startbestand vertragen sich; keine verwaisten Spiele. | feste Verlags-IDs aus dem Skript | +| H1/H2 | Admin-Funktionen ohne `is_admin()`-Pfad, `execute` auch `service_role` entzogen (E10). | Klärung 44; in R1 gibt es keinen API-Aufrufer. | `is_admin()` als R4-Vorbereitung belassen | +| H7 | `merged_into_id`, `target_game_id`, `catalog_source_id`: `on delete restrict`. | Katalogspiele und Quellen werden ausgeblendet, nicht gelöscht; versehentliches Löschen fällt sofort auf. | `on delete set null` | + +### 9.4 Ausstehende Prozessschritte + +- Spec freigegeben am 2026-11-03, Spec-F1–Spec-F9 und Review PR #51 eingearbeitet. Bitte die Freigabe und die Bestätigung der Designwahlen aus 9.3 als Kommentar im Issue #2 festhalten (Chris). +- Nächste Schritte nach Merge der Spec: B1 (backend-dev), parallel U1 (ui-designer); B3a liefert den Vokabular-Vorschlag zur Freigabe durch Chris (C0). - Label `size/xl` → `size/l` (E2) ändert nur Chris. -- Spec #13 (KAT-04) baut auf 3.4 auf: Insert-Policy, Tageslimit, Ähnlichkeitsprüfung, Hinweis am vorläufigen Eintrag inkl. Wechsel auf den Zieleintrag bei „abgelehnt mit Verweis“ (F3), Herkunft „aus Import“ (N12). -- Kein ADR nötig: Die Entscheidungen bleiben innerhalb von ADR-0001/0002 und sind ohne großen Aufwand änderbar (Startbestand ist eine Datei, `manual_fields` eine Spalte). +- Spec #13 (KAT-04) baut auf 3.4 auf (siehe 9.5). +- Kein ADR nötig: Die Entscheidungen bleiben innerhalb von ADR-0001/0002 und sind ohne großen Aufwand änderbar. -### 9.4 Hinweise für andere Stories, nicht Teil von #2 +### 9.5 Hinweise für andere Stories, nicht Teil von #2 - **KAT-01 (#3):** Suche über `catalog_games` (bzw. eine RPC darauf); Tabelle für alternative Titel (Klärung 3) mit neuem `manual_fields`-Schlüssel; Spalte `alt_titles` der Startbestands-CSV auswerten. -- **KAT-02 (#4):** Weiterleitung bei `merged_into_id`; bei `hidden_at` Detailseite weiter anzeigen mit Hinweis „nicht mehr im Katalog“ (F2); Quellenangabe aus `source_*` (KAT-02 Krit. 5); Sprachhinweis über `description_locale` (Startbestand: Deutsch). -- **KAT-03 (#12):** Basisspiel-Verweis (Klärung 5) mit `manual_fields`-Schlüssel; `base_game_key` der CSV auswerten und Erweiterungen des Startbestands verknüpfen (S7); `repoint_game_references` für die neue Spalte prüfen (Fremdschlüssel-Wächter). +- **KAT-02 (#4):** Weiterleitung bei `merged_into_id`; bei `hidden_at` Detailseite weiter anzeigen mit Hinweis „nicht mehr im Katalog“ (Spec-F2); Quellenangabe aus `source_*` (KAT-02 Krit. 5); Sprachhinweis über `description_locale` (Startbestand: Deutsch). Mit #4 wird Krit. 10 abgenommen; danach schließt Chris #2. +- **KAT-03 (#12):** Basisspiel-Verweis (Klärung 5) mit `manual_fields`-Schlüssel; `base_game_key` der CSV auswerten (SB7); Fremdschlüssel-Wächter für die neue Spalte entscheiden. +- **KAT-04 (#13):** Insert-Policy, Tageslimit, Ähnlichkeitsprüfung, Hinweis am vorläufigen Eintrag inkl. Wechsel auf den Zieleintrag bei „abgelehnt mit Verweis“ (Spec-F3), Herkunft „aus Import“ (N12). **Krit. 4 („eigene Angaben bleiben erhalten“) gilt für die Edition nur eingeschränkt** (B1): Ohne gleichnamige Variante am Katalogeintrag wird die Edition geleert; der Hinweis in der App sollte dann zum Neuwählen auffordern. Vorläufige Spiele ohne eigene ID anlegen (Spaltenrechte, 3.2). **Bitte als Hinweis ins Issue #13 übernehmen.** - **PAR-04 (#18):** Sobald ein Wertungsbogen je Spiel und Nutzer erzwungen wird, Kollisionsregel in `repoint_game_references` ergänzen. -- **SAM-02/SAM-04 (#7, #8):** Neue Felder an `collection_items` (Zustand, Standort, Tags, Priorität) in die Kollisionsregel F1 („leere Felder des Ziels aus der Quelle füllen“) aufnehmen. -- **SAM-08 (#25):** Import setzt `set local shuffle.catalog_import = 'on'`, pflegt `source_*` und `source_synced_at`, legt Import-Meldungen mit `origin = 'import'` an (N12, N12a). -- **KTO-05 (#23):** `game_reports` wird per `on delete cascade` mit dem Profil gelöscht; im Export enthalten. +- **SAM-02/SAM-04 (#7, #8):** Neue Felder an `collection_items` (Zustand, Standort, Tags, Priorität) in die Kollisionsregel Spec-F1 aufnehmen. +- **SAM-08 (#25) und Import-Spec:** Import läuft als Rolle `catalog_importer` mit `set local shuffle.catalog_import = 'on'` (E4); pflegt `catalog_source_id`, `source_*`, `source_synced_at`; **speichert Cover und Texte einer Quelle ohne Anzeige-Erlaubnis gar nicht** (E6, Pflicht); ignoriert beim Abgleich über `bgg_id` ausgeblendete und zusammengeführte Einträge; legt Import-Meldungen mit `origin = 'import'` an (N12, N12a); bekommt gezielt Rechte, die in R1 allen API-Rollen entzogen sind. +- **QUE-08 (R4):** Admin-Pfad über die API braucht eigene RPCs (security definer) statt der Rollenprüfung `postgres`/`supabase_admin`; Spaltenrechte auf `games` und Trigger-Rechte berücksichtigen (R8). +- **KTO-05 (#23):** `game_reports` wird per `on delete cascade` mit dem Profil gelöscht; im Export enthalten. Vorläufige Spiele mit `merged_into_id` auf ein Katalogspiel werden mit dem Konto gelöscht (sie sind die verweisende Seite, `restrict` greift nicht). ## 10. Änderungsprotokoll ### 2026-11-03 – Entwurf -Erste Fassung durch architect, wartet auf Freigabe und Antworten zu F1–F9. +Erste Fassung durch architect, wartet auf Freigabe und Antworten zu Spec-F1–Spec-F9. Nachtrag am selben Tag: Rechte-Regel für neue Objekte (Standardrechte von Supabase erst entziehen) in 3.2 ausdrücklich ergänzt; Studio-Rolle `postgres` bzw. `supabase_admin` (4.2); B2/B9 um die Anpassung von `00_schema.test.sql` (fest „17 Tabellen“) und der README-Zahlen ergänzt. -### 2026-11-03 – Freigabe und Entscheidungen F1–F9 +### 2026-11-03 – Freigabe und Entscheidungen Spec-F1–Spec-F9 -Chris hat die Spec freigegeben und alle Fragen wie empfohlen entschieden. Eingearbeitet: +Chris hat die Spec freigegeben und alle Fragen wie empfohlen entschieden. Eingearbeitet: Status, Entscheidungstabelle, E3/E4/E8/E9, Abnahme von Krit. 7 und 10 in § 2, verbindliche Startbestandsregeln, CSV-Format, Kollisions- und Umhäng-Regeln, einheitlicher Platzhalter, Tests und Aufgaben (B3a, C0), Hinweis für SAM-02/SAM-04. -- Status auf „freigegeben“; § 9.2 als Entscheidungstabelle. -- E3, E4, E8, E9 mit den Entscheidungen F2, F4, F5, F6. -- § 2: Abnahme von Krit. 7 (mindestens 180 sichtbare Katalogspiele, `checks.sql`) und Krit. 10 (Pflicht-Fakten, deutscher Kurztext, kein Cover, Herkunft nach F7) konkretisiert. -- 4.3: verbindliche Randbedingungen S1–S8 für den Startbestand; CSV-Format mit Pflicht-Kurztext (`de`), optionaler Spalte `facts_source` und Prüfung von `base_game_key`; `checks.sql` mit Soll/Ist je Regel. -- 3.3/3.4: Kollisionsregel F1 und Umhäng-Regel F3 ausformuliert. -- 5: einheitlicher Platzhalter (F9), Hinweis „nicht mehr im Katalog“ (F2). -- 7.1 und 8: Tests und Aufgaben nachgezogen; neue Tasks B3a (Vokabular-Vorschlag) und C0 (Freigabe Vokabular durch Chris); Abhängigkeiten von offenen Fragen entfernt; D1 um die Release-Prüfung S1 ergänzt. -- 9.4: Hinweis für SAM-02/SAM-04 zur Kollisionsregel. +### 2026-11-03 – Review PR #51 + +- **B1 (Chris):** Beim Umhängen eines privaten Eintrags werden keine Varianten kopiert; Abbildung auf gleichnamige Zielvariante, sonst `null`; `repoint_game_references` mit Parameter `p_copy_variants`; Spec-F1-Füllregel über `variant_map`; Auswirkung auf KAT-04 Krit. 4 (R10, 9.5). +- **W3 (Chris):** Alle PRs `Refs #2`; Abschluss von #2 nach C1 und #4 (E1, § 2, § 5, 8). +- **W1:** E6 eingeschränkt, Pflicht „nicht speichern“ für die Import-Spec (bitte bestätigen). +- **W2:** Grant-Tabelle je Objekt in 3.2. +- **W4:** Idempotenz von `startbestand.sql` (Verlage über `lower(name)`, Zuordnungen neu aufbauen, Konfliktziele), unveränderliche Schlüssel mit Schlüsselliste, Entfernen über `hidden`, UUID-v5-Namespace. +- **W5:** `checks.sql` erzeugt, mit eingebetteten Startbestands-IDs, alle SB2-Felder, SB6 inkl. `bgg_id`, Beispielspiel ausgenommen. +- **W6:** Übertragung von `bgg_id`/`source_*` beim Zusammenführen (bitte bestätigen). +- **W7:** Begründung zum Import-Schalter korrigiert; Ausnahme an Rolle `catalog_importer` gebunden; Runbook „nie `set` ohne `local`“. +- **H1–H14:** `is_admin()`-Pfad aus den Funktionen gestrichen, `service_role` entzogen; Spaltenrechte ohne `id`; Trigger auch für Update, R8 ergänzt; Zuordnung der Kriterien zu PRs (Ausblenden und Runbook-Abschnitt nach 2a, pgTAP `06` für Runbook-Bausteine); Fremdschlüssel-Wächter mit `game_reports`; `on delete restrict` für `merged_into_id`, `target_game_id`, `catalog_source_id`; Umbenennung in SB1–SB8 und Spec-F1–Spec-F9, Klarstellung „F2“ in Krit. 2; Abhängigkeit #4 im Kopf; Hinweis zur Freigabe im Issue; Pooler und `/pg/` in der Zugangsvorgabe; CLI-Annahmen als unverifiziert markiert (R11); KI-Entwürfe nur aus Fakten (SB4); `terms_url`-Check, `description_locale` nur mit `description`, Fremdschlüssel `catalog_source_id`, README-Hinweis.