All checks were successful
Refs #2 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
722 lines
94 KiB
Markdown
722 lines
94 KiB
Markdown
# Spec #2 – KAT-05 Katalogdaten laden und aktuell halten
|
||
|
||
| | |
|
||
|---|---|
|
||
| Issue | #2 (KAT-05) |
|
||
| Status | freigegeben (Chris, 2026-11-03); Spec-F1–Spec-F9 entschieden wie empfohlen (§ 9.2); Review und Re-Review PR #51 eingearbeitet (§ 10); Designwahlen in § 9.3 einzeln bestätigt (Chris, 2026-11-03, Issue #2) |
|
||
| Autor | architect |
|
||
| Stand | 2026-11-03 |
|
||
| 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, N15), `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). „RB1“, „W3“ usw. sind Punkte aus dem Review zu PR #51; „B1“ bis „B10“ sind Aufgaben (§ 8).
|
||
|
||
## 1. Ziel
|
||
|
||
Der Katalog ist das Fundament aller anderen Stories: Suche (KAT-01), Detailseite (KAT-02), Sammlung, Partien und Meldungen verweisen auf `games`. KAT-05 sorgt in R1 dafür, dass
|
||
|
||
- 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, 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.
|
||
|
||
### Kernentscheidungen (Kurzfassung)
|
||
|
||
| # | Entscheidung | Begründung |
|
||
|---|---|---|
|
||
| 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. 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 (W7, bestätigt). 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 `<Zieltabelle>_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). Bestätigt (Chris, 2026-11-03, W1). | 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.5); 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 (H1/H2, bestätigt). | 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)`. **Ob Varianten kopiert werden, ergibt sich aus der Sichtbarkeit:** nur wenn Quelle und Ziel Katalogspiele sind. Beim Umhängen eines privaten Eintrags wird nie kopiert (Entscheidung Chris, RB1); die Edition wird dort auf eine gleichnamige Variante des Ziels abgebildet, sonst geleert (Klärung N15). | 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. |
|
||
| E14 | **Sichtbarkeit und Eigentümer eines Spiels sind unveränderlich** (Entscheidung Chris, W-a): Ein Trigger lehnt jede Änderung von `visibility` oder `owner_id` an einer bestehenden `games`-Zeile ab, für alle Rollen einschließlich `postgres`. Ein privater Eintrag wird nie „öffentlich geschaltet“; freigegebene Meldungen bekommen immer einen neuen Katalogeintrag. | Sonst könnten private Angaben (Beschreibung, Bild, Varianten) eines Nutzers durch einen Handgriff im Studio öffentlich werden. Die Policies verhindern das für Nutzer bereits; der Trigger schützt auch vor Admin-Versehen. |
|
||
|
||
## 2. Akzeptanzkriterien
|
||
|
||
Ü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).
|
||
3. *Zurückgestellt bis zur Lizenz (Klärung 24):* Gegeben die Quelle ist nicht erreichbar oder liefert fehlerhafte Daten, dann bleibt der Katalog unverändert, der Fehler wird protokolliert und die App funktioniert normal weiter.
|
||
4. Gegeben der Admin bearbeitet, blendet aus oder führt Einträge zusammen (in R1 über Supabase Studio/SQL), dann sind ausgeblendete Einträge in der App nicht mehr auffindbar, und manuell gepflegte Felder sind als solche gekennzeichnet, damit ein späterer Import sie nicht überschreibt (Klärungen 7, 44).
|
||
5. Gegeben Meldungen aus KAT-04, dann kann der Admin sie über Supabase Studio/SQL freigeben, mit einem Eintrag zusammenführen oder ablehnen; Status (offen, freigegeben, abgelehnt, zusammengeführt) und gegebenenfalls der Zieleintrag werden gespeichert (Klärungen 6, 44).
|
||
6. *Zurückgestellt bis zur Lizenz (Klärung 24):* Gegeben Cover und Texte einer Quelle, dann werden sie nur angezeigt, wenn deren Nutzungsbedingungen es erlauben; die Erlaubnis ist je Quelle vermerkt. Das Feld für die Erlaubnis je Quelle ist ab R1 im Datenmodell vorhanden (Klärung 7).
|
||
7. Gegeben R1 geht live, dann enthält der Katalog einen manuell gepflegten Startbestand von etwa 200 Spielen, den der Admin (Chris) per Supabase Studio bzw. Seed einpflegt (Klärung 24, N11).
|
||
8. Gegeben die Admin-Pflege über Supabase Studio/SQL, dann ist der Ablauf für Bearbeiten, Ausblenden, Zusammenführen und das Bearbeiten von Meldungen im Repo dokumentiert (Klärung 44).
|
||
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 Spec-F5, Spec-F7, Spec-F8)**
|
||
|
||
- **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).
|
||
|
||
**Abweichung bei KAT-04 Krit. 4 (Klärung N15):** Beim Ersetzen eines vorläufigen Eintrags bleibt die Edition nur erhalten, wenn der Katalogeintrag eine gleichnamige Variante hat (3.3, R10).
|
||
|
||
**Zuordnung zu den PRs** (alle PRs `Refs #2`, siehe E1)
|
||
|
||
| PR | Etappe | Kriterien | Bei zurückgestellten Kriterien: nur Datenmodell |
|
||
|---|---|---|---|
|
||
| 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, unveränderliche Sichtbarkeit) | – |
|
||
| 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.
|
||
|
||
**Relevante Querschnittskriterien**
|
||
|
||
- **Sicherheit der Daten Kriterium 2:** Katalog ohne Anmeldung lesbar (`catalog_games`, `catalog_sources`, `publishers` für `anon`), nie durch Nutzer veränderbar (O6).
|
||
- **Sicherheit der Daten Kriterium 1 und 3:** `game_reports` enthält Nutzerdaten; nur der Meldende liest seine Meldungen, pgTAP prüft fremden Zugriff. Private Einträge können nicht öffentlich werden (E14).
|
||
- **Datenschutz (QUE-04):** Meldungen werden beim Konto-Löschen hart gelöscht (`on delete cascade`, Klärung 10). Der Startbestand enthält keine personenbezogenen Daten. Freigegebene Katalogeinträge übernehmen nur Fakten aus den Meldungsfeldern, nie Beschreibung oder Bild des vorläufigen Eintrags (4.4).
|
||
- **Sprachen (QUE-05) Kriterium 2:** Die Semantics-Beschriftung des Platzhalter-Covers kommt aus ARB (de/en). Begriffe (Genre, Mechanik) haben `name_de`/`name_en`.
|
||
- **Barrierefreiheit (QUE-06) Kriterien 1, 2:** Platzhalter-Cover mit sinnvoller Beschriftung und Kontrast AA (Kontrast der Grafik zum Hintergrund mindestens 3:1).
|
||
- **Plattformen Kriterium 1:** Das Platzhalter-Cover ist ein reines Flutter-Widget ohne Plattformcode.
|
||
|
||
## 3. Datenmodell
|
||
|
||
### 3.1 Bestand gegen die Kriterien
|
||
|
||
| 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.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 (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) |
|
||
| Schreibrechte (Krit. 9) | Katalog nur `is_admin()`; Nutzer dürfen eigene private Spiele vollständig ändern (Tabellenrecht `update` für `authenticated`) | Nutzer könnten an eigenen privaten Spielen `hidden_at`, `manual_fields`, `source_*`, `merged_into_id` setzen | Spaltenrechte statt Tabellenrechte für `insert`/`update` auf `games` (3.2) |
|
||
| Sichtbarkeit unveränderlich (E14) | Policy verhindert für Nutzer das Heben in den Katalog; `postgres` könnte `visibility` ändern | Lücke für Admin-Versehen | Trigger `games_visibility_immutable` (2c, 3.4) |
|
||
|
||
### 3.2 Migration 2a `supabase/migrations/<ts>_catalog_foundation.sql` (R1.1)
|
||
|
||
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 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`. Nach jedem `create or replace function` wird der `revoke` in derselben Migration ausdrücklich wiederholt.
|
||
|
||
**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`**
|
||
|
||
| Spalte | Typ | Hinweis |
|
||
|---|---|---|
|
||
| `id` | uuid | PK, `gen_random_uuid()` |
|
||
| `key` | text | not null, unique, `^[a-z0-9_]{2,30}$` (z. B. `bgg`) |
|
||
| `name` | text | not null, 1–100 Zeichen (z. B. „BoardGameGeek“) |
|
||
| `attribution_text` | text | nullable, ≤ 200 (z. B. „Powered by BGG“, KAT-02 Krit. 5) |
|
||
| `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, 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` |
|
||
|
||
Die Migration fügt eine Zeile ein: `key = 'bgg'`, `name = 'BoardGameGeek'`, `attribution_text = 'Powered by BGG'`, `attribution_url = 'https://boardgamegeek.com'`, beide Erlaubnisse `false`. Das ist Referenzdaten-Pflege, keine Katalogdaten.
|
||
|
||
**Neue Spalten in `games`**
|
||
|
||
| Spalte | Typ | Hinweis |
|
||
|---|---|---|
|
||
| `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)` **`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`; Ziel muss ein Katalogspiel sein (Trigger `games_merged_into_catalog`, unten) |
|
||
| `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). |
|
||
| `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: `(catalog_source_id is null) = (source_ref is null)`; `source_url` und `source_synced_at` nur mit `catalog_source_id`.
|
||
|
||
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).
|
||
|
||
**Trigger `games_merged_into_catalog`** (H7): `before insert or update of merged_into_id on public.games`; ist `merged_into_id` gesetzt, muss das Ziel `visibility = 'catalog'` haben, sonst Fehler `merged_into_not_catalog` (P0001). Entschieden als Trigger statt nur als Runbook-Regel, weil Studio-Handgriffe sonst einen Verweis auf ein privates Spiel erzeugen könnten; dessen Löschen beim Konto-Löschen würde dann an `on delete restrict` scheitern (KTO-05). Ein Check-Constraint geht nicht, weil er keine andere Zeile lesen darf.
|
||
|
||
**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 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` |
|
||
|
||
Beide mit `id`, `created_at`, `updated_at` und Trigger `set_updated_at`. Ein Spiel kann mehrere Verlage haben (KAT-01 Krit. 3).
|
||
|
||
**Trigger `games_mark_manual_fields`** (E4)
|
||
|
||
- `before insert or update on public.games`, nur wenn `new.visibility = 'catalog'`.
|
||
- 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 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 = ''`.
|
||
|
||
**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.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).
|
||
- 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.5).
|
||
- 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)
|
||
|
||
```
|
||
revoke insert, update on public.games from authenticated;
|
||
grant insert (owner_id, title, description, description_locale, year, min_players, max_players,
|
||
min_minutes, max_minutes, min_age, image_path, bgg_id, visibility)
|
||
on public.games to authenticated;
|
||
grant update (title, description, description_locale, year, min_players, max_players,
|
||
min_minutes, max_minutes, min_age, image_path, bgg_id, visibility)
|
||
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`, `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. `visibility` bleibt im Update-Recht, ab 2c lehnt aber der Trigger `games_visibility_immutable` jede Änderung ab (E14).
|
||
|
||
### 3.3 Migration 2b `supabase/migrations/<ts>_catalog_merge.sql` (R1.2)
|
||
|
||
**Interne Funktion `public.repoint_game_references(p_from uuid, p_to uuid) returns jsonb`**
|
||
|
||
`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.
|
||
|
||
Prüfungen zu Beginn:
|
||
|
||
| Prüfung | Fehler (P0001) |
|
||
|---|---|
|
||
| `p_from = p_to` | `repoint_same_game` |
|
||
| `p_to` ist kein sichtbares Katalogspiel (`visibility = 'catalog' and hidden_at is null and merged_into_id is null`) | `repoint_target_not_listed` |
|
||
|
||
Dann entsteht eine temporäre Zuordnung „alte Variante → neue Variante“ (`variant_map`). **Ob kopiert wird, folgt aus der Sichtbarkeit** (kein Parameter, W-c):
|
||
|
||
| Fall | Erkennung | Zuordnung der Varianten |
|
||
|---|---|---|
|
||
| **Zwei Katalogspiele zusammenführen** (`admin_merge_games`) | `p_from` und `p_to` haben `visibility = 'catalog'` | 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; dann wird auf diese abgebildet. Danach werden die alten Varianten gelöscht. |
|
||
| **Privaten Eintrag auf ein Katalogspiel umhängen** (`admin_resolve_report`) | `p_from` hat `visibility = 'private'` | **Es wird nichts kopiert** (Entscheidung Chris, RB1; Klärung N15): Hat `p_to` eine Variante mit gleichem Namen, 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). |
|
||
|
||
**Namensabgleich:** gleich heißt `lower(trim(name))` gleich. Gibt es am Ziel mehrere gleichnamige Varianten, wird immer dieselbe gewählt: die älteste nach `created_at`, bei Gleichstand die kleinste `id`.
|
||
|
||
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.5).
|
||
5. Nur wenn beide Katalogspiele sind: 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“, Klärung N15): 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.5): Der Ergebnishinweis in der App sollte in diesem Fall sagen, dass die Edition neu gewählt werden muss; `variants_cleared` in der Rückgabe macht das für den Admin erkennbar.
|
||
|
||
**Funktion `public.admin_merge_games(p_source uuid, p_target uuid) returns jsonb`**
|
||
|
||
`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 (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:
|
||
|
||
1. `repoint_game_references(p_source, p_target)` (beide Katalog → Varianten werden kopiert).
|
||
2. **BGG-Kennung und Quelle übernehmen** (W6, bestätigt von Chris am 2026-11-03): 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 den Konflikt **mit den alten Werten**, z. B. `"bgg_id_conflict": {"source": 13, "target": 42}` bzw. `"source_conflict": {"source": {"catalog_source_id": …, "source_ref": …, "source_url": …}, "target": {…}}`, damit der Admin den geleerten Wert nicht verliert. Begründung: Sonst würde der BGG-Abgleich (SAM-08) später den ausgeblendeten Quell-Eintrag treffen. `bgg_id` und `source_*` stehen nicht in der `manual_fields`-Schlüsselliste.
|
||
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/<ts>_game_reports.sql` (R1.2)
|
||
|
||
**Trigger `games_visibility_immutable`** (E14, Entscheidung Chris W-a): `before update on public.games`; ist `new.visibility is distinct from old.visibility` oder `new.owner_id is distinct from old.owner_id`, Fehler `visibility_immutable` (P0001). Gilt für alle Rollen einschließlich `postgres`. Das Konto-Löschen ist nicht betroffen (private Spiele werden per `on delete cascade` gelöscht, nicht geändert).
|
||
|
||
**Neue Tabelle `game_reports`** (Klärungen 6, 10, 26, 27, N12, N12a; E12)
|
||
|
||
| Spalte | Typ | Hinweis |
|
||
|---|---|---|
|
||
| `id` | uuid | PK |
|
||
| `owner_id` | uuid | not null, FK → `profiles` `on delete cascade` (meldender Nutzer; Klärung 10: hart löschen) |
|
||
| `origin` | text | not null, default `'user'`, Check `in ('user','import')` (N12) |
|
||
| `title` | text | not null, 1–200 (KAT-04 Krit. 1) |
|
||
| `publisher_name` | text | nullable, ≤ 200 (Freitext des Nutzers) |
|
||
| `year` | smallint | nullable, 1800–2100 |
|
||
| `link` | text | nullable, ≤ 500, Check `^https?://` |
|
||
| `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` **`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); `(created_at) where status = 'open'` für die Admin-Liste.
|
||
|
||
**RLS in Klartext**
|
||
|
||
- `enable row level security`; `revoke all … from anon, authenticated`; `grant select on game_reports to authenticated`.
|
||
- Lesen: Der Meldende sieht seine eigenen Meldungen (für den Ergebnishinweis, KAT-04 Krit. 4–6); Admin-Policy `is_admin()` für R4. `anon` sieht nichts.
|
||
- Anlegen: **in 2c nicht möglich** (kein Insert-Recht). #13 ergänzt Insert-Recht, Insert-Policy (`owner_id = auth.uid()`, `origin = 'user'`, `status = 'open'`, `target_game_id is null`, `provisional_game_id` nur ein eigenes privates Spiel) und den Limit-Trigger (10 je Nutzer und Tag, nur `origin = 'user'`).
|
||
- Ändern, Löschen: kein Recht für `anon` und `authenticated` (Krit. 9). Der Admin ändert in R1 nur über `admin_resolve_report` im Studio.
|
||
|
||
**Funktion `public.admin_resolve_report(p_report uuid, p_status text, p_target uuid default null) returns jsonb`**
|
||
|
||
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 (Spec-F3)
|
||
|
||
- bei `approved` und bei `merged`: `repoint_game_references(provisional, target)` (privat → Katalog, keine Varianten kopiert, RB1); 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 einen **neuen** Katalogeintrag an (bzw. wählt einen vorhandenen aus) und ruft dann die Funktion mit `approved` auf. Der vorläufige Eintrag wird nie öffentlich geschaltet (E14). 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, und **wiederholt danach ausdrücklich** `revoke execute on function public.admin_merge_games(uuid, uuid) from public, anon, authenticated, service_role`; `08` prüft die Rechte erneut.
|
||
|
||
### 3.5 Doku zum Datenmodell
|
||
|
||
`docs/datenmodell-r1.md` wird in jedem der drei PRs nachgezogen (neue Tabellen, Spalten, View, Funktionen, Trigger, 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
|
||
|
||
### 4.1 Überblick
|
||
|
||
| Objekt | Art | PR | Aufrufbar von |
|
||
|---|---|---|---|
|
||
| `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`, `games_merged_into_catalog` | Trigger | 2a | automatisch |
|
||
| `repoint_game_references` | interne Funktion | 2b | nur `postgres` |
|
||
| `admin_merge_games` | Funktion | 2b | nur `postgres`/`supabase_admin` (Studio/`psql`) |
|
||
| `games_visibility_immutable` | Trigger | 2c | automatisch |
|
||
| `game_reports` | Tabelle | 2c | Lesen: eigene; Schreiben: Studio |
|
||
| `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 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 Regeln SB1–SB8 (Spec-F4, Spec-F5, Spec-F7, Spec-F8)**
|
||
|
||
| # | Regel | Prüfung |
|
||
|---|---|---|
|
||
| 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). 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:<key>`, Genre `genre:<vocab_key>`, Mechanik `mechanic:<vocab_key>` (`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)`.
|
||
|
||
**Vokabular**
|
||
|
||
- Maschinenlesbare Quelle ist `supabase/catalog/vocabulary.csv` (Spalten `kind` = `genre`/`mechanic`, `vocab_key`, `name_de`, `name_en`); das Skript erzeugt daraus `vocabulary.sql`.
|
||
- **Kollision mit im Studio angelegten Begriffen** (gleicher `name_de` oder `name_en` unter anderer ID): `vocabulary.sql` fügt mit `on conflict do nothing` (ohne Konfliktziel, fängt ID- und Namenskonflikte) ein; es entsteht kein Fehler 23505. Der vorhandene Begriff gewinnt. `startbestand.sql` löst Genres und Mechaniken deshalb **über `name_de`** auf, nicht über die abgeleitete UUID. `checks.sql` meldet solche Begriffe als Information („Begriff mit abweichender ID“), damit Chris sie prüfen kann.
|
||
|
||
**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 … where (games.title, games.year, …, games.description, games.description_locale) is distinct from (excluded.title, excluded.year, …)`; unveränderte Zeilen werden damit nicht angefasst (`updated_at` und `manual_fields` bleiben stabil).
|
||
3. Zuordnungen je Startbestandsspiel als **Abgleich**: Paare, die nicht mehr in der CSV stehen, werden gelöscht (`delete … where game_id = … and genre_id not in (…)`); fehlende Paare werden mit `on conflict (game_id, genre_id) do nothing` (bzw. `mechanic_id`, `publisher_id`) eingefügt. Unveränderte Paare bleiben unberührt. 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“). Zusätzlich prüft ein CI-Schritt gegen `main`, dass jede Zeile von `startbestand-keys.csv` aus `main` unverändert erhalten ist (die Liste wächst nur; D1). 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).
|
||
6. **`hidden` leer:** Die Datei fasst `hidden_at` nicht an. Ein im Studio ausgeblendetes Startbestandsspiel bleibt also ausgeblendet; wieder einblenden geht nur im Studio (`hidden_at = null`).
|
||
|
||
**Dateien**
|
||
|
||
- `supabase/catalog/vocabulary.csv` (Quelle) → `vocabulary.sql` (erzeugt): Genres und Mechaniken (de/en) mit IDs nach UUID v5. Inhalt: Vorschlag durch Agents (B3a), gültig erst nach Freigabe durch Chris (C0). `seed.sql` legt danach keine kollidierenden Begriffe mehr an.
|
||
- `supabase/catalog/startbestand.csv`: von Chris gepflegt (z. B. aus einer Tabellenkalkulation exportiert).
|
||
- `supabase/catalog/build_startbestand.ts`: Deno, nur Standardbibliothek; prüft beide CSV-Dateien und erzeugt `vocabulary.sql`, `startbestand.sql`, `startbestand-keys.csv` und `checks.sql`. Die erzeugten Dateien werden nie von Hand bearbeitet.
|
||
|
||
**CSV-Format `startbestand.csv`**
|
||
|
||
| Spalte | Pflicht | Inhalt |
|
||
|---|---|---|
|
||
| `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 |
|
||
| `min_players`, `max_players` | ja | Spielerzahl |
|
||
| `min_minutes`, `max_minutes` | ja | Spieldauer |
|
||
| `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 (SB4) | eigener Kurztext |
|
||
| `description_locale` | ja (SB4) | immer `de` |
|
||
| `hidden` | nein | `ja` = ausgeblendet (Regel 5); leer = `hidden_at` bleibt unverändert (Regel 6) |
|
||
| `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 oder entferntem `key`, `max < min`, Werten außerhalb der DB-Checks, fehlendem Kurztext, `description_locale` ≠ `de`, `base_game_key` ohne passenden `key`, doppeltem `vocab_key` oder Namen im Vokabular. Es gibt **keine** Spalte für Cover, Quelle oder BGG-Kennung (Krit. 10).
|
||
|
||
**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,
|
||
- Information: Vokabularbegriffe mit abweichender ID (siehe Vokabular).
|
||
- 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).
|
||
|
||
### 4.4 Runbook `docs/katalog-pflege.md` (Krit. 8)
|
||
|
||
Ein Dokument, wachsend über die PRs:
|
||
|
||
| Abschnitt | PR | Inhalt |
|
||
|---|---|---|
|
||
| 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; Vokabular- und Startbestands-CSV bearbeiten → Skript → Commit; Schlüssel nie umbenennen, Entfernen nur über `hidden`, Einblenden nur im Studio; 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; `merged_into_id` nie von Hand setzen, nur über `admin_merge_games` |
|
||
| 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('<quelle>', '<ziel>');`, Ergebnis-JSON lesen (Kollisionen nach Spec-F1, Varianten, `bgg_id_conflict`/`source_conflict` mit den alten Werten), Fehlertexte |
|
||
| Meldungen bearbeiten | 2c | offene Meldungen abfragen (älteste zuerst), Ähnliche im Katalog suchen, Entscheidung: freigeben, zusammenführen (`'merged'`), ablehnen mit oder ohne Verweis (`'rejected'`); Fehlertexte. **Verboten: einen privaten Eintrag öffentlich schalten** (der Trigger lehnt es ab, E14). **Freigeben:** neuen Katalogeintrag anlegen und dabei **nur Fakten aus den Meldungsfeldern** übernehmen (`title`, `publisher_name`, `year`; `link` nur als Recherchehilfe), **nie** `description` oder `image_path` des vorläufigen Eintrags; Kurztext selbst schreiben (SB4); dann `admin_resolve_report(…, 'approved', '<ziel>')`. **Editionen:** werden nicht kopiert; soll die Edition des Nutzers erhalten bleiben, vorher am Katalogeintrag eine Variante anlegen, und zwar ebenfalls nur aus belegbaren Fakten (Name und Jahr der Edition aus Verlags- oder Schachtelangaben), nie durch Übernahme der privaten Variante |
|
||
| 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 (`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. 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“ (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 (Spec-F3); bei geleerter Edition Hinweis, sie neu zu wählen (3.3, N15) | 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 (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}“).
|
||
- Ergänzung bei der Umsetzung: zusätzlicher ARB-Schlüssel `gameCoverLabel` („Cover von {title}“ / „Cover of {title}“) für ein tatsächlich geladenes Bild; die Beschriftung folgt dem, was sichtbar ist (Platzhalter beim Laden und nach einem Ladefehler, sonst `gameCoverLabel`). Der Parameter für die `excludeSemantics`-Variante heißt `titleVisibleNearby`. `imageUrl` muss aus der Dateizugriffsschicht (eigener Storage) stammen, nie aus `source_url` oder Fremd-URLs (Datenschutz); Hinweise für #3/#4 (animierte Ladeanzeige und `pumpAndSettle`, signierte URLs bei privatem Bucket, CORS beim Self-Hosting) stehen im Doc-Kommentar des Widgets.
|
||
- 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`.
|
||
|
||
## 6. Plattform-Besonderheiten
|
||
|
||
- **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).
|
||
- **Self-Hosting:** Zugang nur über Tunnel/VPN (4.2); Startbestand per SQL-Datei (4.3).
|
||
|
||
## 7. Teststrategie
|
||
|
||
Werkzeuge: pgTAP (`supabase test db`), Deno-Tests für das Erzeugungsskript, `flutter_test` für das Widget. Testnamen enthalten die Referenz („KAT-05 Kriterium n“).
|
||
|
||
**Neue pgTAP-Dateien**
|
||
|
||
- `06_catalog_foundation.test.sql` (2a): Spalten, Checks, Grants, Spaltenrechte, Trigger `manual_fields` (inkl. Import-Ausnahme mit einer im Test angelegten Rolle `catalog_importer`), Trigger `games_merged_into_catalog`, View `catalog_games`, `catalog_sources`, Verlage, Ausblenden, Runbook-Bausteine aus 2a.
|
||
- `07_catalog_merge.test.sql` (2b): `repoint_game_references` (Prüfungen, beide Variantenfälle, Namensabgleich), `admin_merge_games`; Fremdschlüssel-Wächter.
|
||
- `08_game_reports.test.sql` (2c): Tabelle, RLS, `admin_resolve_report`, Trigger `games_visibility_immutable`, Rechte von `admin_merge_games` nach dem `create or replace`, Konto-Löschen nach Freigabe.
|
||
- `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. `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, 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`; `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`: 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; `merged_into_id` auf ein privates Spiel → `merged_into_not_catalog`. `07`: `repoint_game_references` mit `p_from = p_to` → `repoint_same_game`, mit ausgeblendetem/zusammengeführtem/privatem Ziel → `repoint_target_not_listed`; Zusammenführen zweier Katalogspiele kopiert Varianten (gleichnamige werden abgebildet; bei mehreren gleichnamigen Zielvarianten immer die älteste, bei Gleichstand kleinste `id`) 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. Konflikt mit alten Werten im JSON, 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 des Meldenden **nach Freigabe** löschen gelingt (vorläufiger Eintrag und Meldung weg), Katalogspiel und die umgehängten Daten anderer Nutzer bleiben unverändert; 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 Fixture-CSVs die erwartete SQL, Schlüsselliste, `vocabulary.sql` und `checks.sql` (Snapshot); gleicher `key` → gleiche UUID (Namespace aus 4.3); entfernter `key` → Fehler; `hidden = ja` → `hidden_at`, `hidden` leer → `hidden_at` unverändert; `facts_source` landet nicht in der SQL | – | CI: `supabase db reset` mit `vocabulary.sql` + `startbestand.sql` läuft durch; **Idempotenz:** zweites Einspielen ändert nichts – Vergleich der Spiele ohne `updated_at`, Zuordnungen als Schlüsselpaare (`game_id`, `genre_id` usw.), dazu `updated_at` unverändert dank `is distinct from`; Verlag mit anderer Schreibweise und im Studio angelegter Begriff mit gleichem `name_de` führen zu keinem Fehler; CI-Schritte „erzeugte Dateien aktuell“ (`git diff --exit-code`) und „`startbestand-keys.csv` wächst nur“ (gegen `main`); `checks.sql` läuft ohne Fehler | – | B3, T1, D1 |
|
||
| 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), in `08` erneut für `admin_merge_games` nach dem `create or replace`; `game_reports`: Nutzer B liest A's Meldungen nicht, niemand außer `postgres` ändert oder löscht Meldungen, `anon` liest nichts; `08`: Änderung von `visibility` (privat → Katalog, Katalog → privat) und `owner_id` an bestehenden Spielen → `visibility_immutable`, **auch als `postgres`** | 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:** 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 `<ts>_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`), `game_links_mark_manual` (insert/update/delete) und `games_merged_into_catalog`, 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.csv`; 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, Vokabular-Erzeugung und -Kollision, Schlüsselliste, Idempotenz-Regeln 1–6 mit `is distinct from`, UUID-v5-Namespace, Erzeugung von `vocabulary.sql`, `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 wie in 7.1 präzisiert, entfernter Schlüssel, Vokabular-Kollision); CI-Schritte mit devops abstimmen | B2, B3 |
|
||
| T2 | test-engineer | Widget-Tests `GameCover` (Platzhalter, Ladefehler, de/en, a11y-Guidelines, `textScaler` 2.0) | U1 |
|
||
| 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, Schritte „erzeugte Dateien aktuell“ und „`startbestand-keys.csv` wächst nur“ (Vergleich mit `main`); 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 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 – Zusammenführen (R1.2), Branch `feature/2-katalog-pflege`
|
||
|
||
| # | Agent | Aufgabe | abhängig von |
|
||
|---|---|---|---|
|
||
| B5 | backend-dev | Migration `<ts>_catalog_merge.sql` nach 3.3: `repoint_game_references(p_from, p_to)` (Prüfungen, Variantenfall aus der Sichtbarkeit, Namensabgleich mit fester Auswahlregel, Kollisionsregel Spec-F1 über `variant_map`), `admin_merge_games` (Rollenprüfung, Übertragung `bgg_id`/`source_*`, Konflikte mit alten Werten), Rechte (`revoke execute` inkl. `service_role`) | 2a gemergt |
|
||
| B6 | backend-dev | pgTAP `07_catalog_merge.test.sql` inkl. Prüfungen von `repoint_game_references`, Kollisionsfällen, Variantenfällen (auch mehrere gleichnamige Zielvarianten), `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 `<ts>_game_reports.sql` nach 3.4: Trigger `games_visibility_immutable`, 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` per `create or replace` erweitert und **`revoke execute` danach ausdrücklich wiederholt** | 2b gemergt |
|
||
| B9 | backend-dev | pgTAP `08_game_reports.test.sql` (inkl. `visibility_immutable` auch als `postgres`, Rechte von `admin_merge_games` erneut, Konto-Löschen nach Freigabe); 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 (Verbot „öffentlich schalten“, nur Fakten aus Meldungsfeldern, Editionen nur aus belegbaren Fakten) | B8, B9 |
|
||
|
||
### 8.4 Reihenfolge und Parallelität
|
||
|
||
```
|
||
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; `vocabulary.csv` wird erst nach C0 endgültig. 2c muss vor der Umsetzung von #13 gemergt sein (E12). Merge nur durch Chris.
|
||
|
||
## 9. Risiken & offene Fragen
|
||
|
||
### 9.1 Risiken
|
||
|
||
| # | Risiko | Auswirkung | Gegenmaßnahme |
|
||
|---|---|---|---|
|
||
| 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; Trigger gegen Öffentlich-Schalten (E14) |
|
||
| 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 an Fakten und Zuordnungen | Entschieden (Spec-F6): Datei ab Go-live eingefroren, wird nicht mehr eingespielt; `hidden_at` fasst die Datei ohnehin nur bei `hidden = ja` an |
|
||
| 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.5 |
|
||
| 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 (RB1) | KAT-04 Krit. 4 für die Edition nur eingeschränkt erfüllt | Bewusst entschieden (Chris), festgehalten als Klärung N15; Runbook: passende Variante am Katalogeintrag vorher aus belegbaren Fakten 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 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 |
|
||
|---|---|---|---|---|
|
||
| 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 |
|
||
|
||
Weitere Entscheidungen von Chris aus den Reviews zu PR #51 (2026-11-03):
|
||
|
||
| # | Thema | Entscheidung | Wirkung |
|
||
|---|---|---|---|
|
||
| RB1 | 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. Festgehalten als Klärung N15 (`requirements/r1-klaerungen.md`). | E11, 3.3, 3.4, R10, 7.1, 9.5 (#13) |
|
||
| W3 | Issue-Abschluss | Alle drei PRs nur `Refs #2`; Chris schließt #2 nach C1 und KAT-02 (#4). | E1, § 2, § 5, 8 |
|
||
| W-a | Privaten Eintrag öffentlich schalten | **Trigger plus Runbook-Regel:** Der Trigger `games_visibility_immutable` lehnt jede Änderung von `visibility` und `owner_id` ab (auch als `postgres`), das Runbook verbietet es ausdrücklich. | E14, 3.4, 4.4, 7.1 Krit. 9, B8, B9, B10 |
|
||
| N15 | KAT-04 Krit. 4 (Edition) | Abweichung als neue Klärung N15 in `requirements/r1-klaerungen.md` festgehalten. | § 2, 3.3, R10, 9.5 |
|
||
|
||
### 9.3 Designwahlen aus dem Review – bestätigt (Chris, 2026-11-03)
|
||
|
||
Chris hat jede Wahl einzeln und ohne Änderung bestätigt (Kommentar im Issue #2 vom 2026-11-03).
|
||
|
||
| # | Status | Wahl | Begründung | Verworfene Alternative |
|
||
|---|---|---|---|---|
|
||
| W1 | bestätigt (Chris, 2026-11-03) | 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 | bestätigt (Chris, 2026-11-03) | `admin_merge_games` überträgt `bgg_id` und `source_*` auf das Ziel, wenn es keine hat, leert sie an der Quelle und meldet Konflikte mit den alten Werten (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 | bestätigt (Chris, 2026-11-03) | 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 | bestätigt (Chris, 2026-11-03) | 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 | bestätigt (Chris, 2026-11-03) | 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 | bestätigt (Chris, 2026-11-03) | `merged_into_id`, `target_game_id`, `catalog_source_id`: `on delete restrict`; `merged_into_id` zeigt per Trigger nur auf Katalogspiele (3.2). | Katalogspiele und Quellen werden ausgeblendet, nicht gelöscht; versehentliches Löschen fällt sofort auf; das Konto-Löschen bleibt möglich, weil private Spiele nie Ziel eines Verweises sind. | `on delete set null` |
|
||
|
||
### 9.4 Ausstehende Prozessschritte
|
||
|
||
- Spec freigegeben am 2026-11-03; Spec-F1–Spec-F9, Review und Re-Review PR #51 eingearbeitet; Designwahlen aus 9.3 einzeln bestätigt. Freigabe und Bestätigungen stehen als Kommentare im Issue #2.
|
||
- 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 (siehe 9.5).
|
||
- Kein ADR nötig: Die Entscheidungen bleiben innerhalb von ADR-0001/0002 und sind ohne großen Aufwand änderbar.
|
||
|
||
### 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“ (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** (RB1, Klärung N15): 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); sie können nie öffentlich geschaltet werden (E14). **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 Spec-F1 aufnehmen.
|
||
- **SAM-08 (#25) und Import-Spec:** Import läuft als Rolle `catalog_importer` mit `set local shuffle.catalog_import = 'on'` (E4). **Achtung:** Läuft der Import über eine `security definer`-Funktion, ist `current_user` deren Eigentümer, nicht `catalog_importer`; die Ausnahme greift dann nicht. Der Import muss sich deshalb direkt als `catalog_importer` anmelden oder Funktionen nutzen, die `catalog_importer` gehören. Weiter: 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; Test in `08`).
|
||
|
||
## 10. Änderungsprotokoll
|
||
|
||
### 2026-11-03 – Entwurf
|
||
|
||
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 Spec-F1–Spec-F9
|
||
|
||
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.
|
||
|
||
### 2026-11-03 – Review PR #51
|
||
|
||
- **RB1 (Chris):** Beim Umhängen eines privaten Eintrags werden keine Varianten kopiert; Abbildung auf gleichnamige Zielvariante, sonst `null`; 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.
|
||
- **W2:** Grant-Tabelle je Objekt in 3.2.
|
||
- **W4:** Idempotenz von `startbestand.sql`, 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.
|
||
- **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; Fremdschlüssel-Wächter mit `game_reports`; `on delete restrict`; Umbenennung in SB1–SB8 und Spec-F1–Spec-F9; Abhängigkeit #4; Pooler und `/pg/`; CLI-Annahmen als unverifiziert markiert (R11); KI-Entwürfe nur aus Fakten (SB4); Kleinigkeiten (H14).
|
||
|
||
### 2026-11-03 – Re-Review PR #51
|
||
|
||
- **W-a (Chris):** Neue Kernentscheidung E14 und Trigger `games_visibility_immutable` (2c, auch für `postgres`), Test in `08`; Runbook verbietet ausdrücklich, private Einträge öffentlich zu schalten.
|
||
- **N15 (Chris):** Abweichung bei KAT-04 Krit. 4 (Edition) als Klärung N15 in `requirements/r1-klaerungen.md` festgehalten; Verweise in § 2, 3.3, R10, 9.5.
|
||
- **W-b:** Runbook: beim Freigeben nur Fakten aus den Meldungsfeldern übernehmen, nie `description`/`image_path` des vorläufigen Eintrags; Varianten am Katalogeintrag nur aus belegbaren Fakten anlegen.
|
||
- **W-c:** Parameter `p_copy_variants` gestrichen; Kopieren folgt aus der Sichtbarkeit; `repoint_game_references` prüft `p_from <> p_to` und sichtbares Katalogziel; Tests in `07`.
|
||
- **W4 a–e:** Upsert mit `is distinct from` und Abgleich der Zuordnungen, präzisierter Idempotenz-Test; Vokabular-Kollision ohne 23505, Auflösung über `name_de`; `vocabulary.csv` als Quelle; CI-Schritt „`startbestand-keys.csv` wächst nur“; `hidden` leer lässt `hidden_at` unverändert.
|
||
- Feste Auswahlregel bei mehreren gleichnamigen Zielvarianten (älteste, dann kleinste `id`), Test in `07`.
|
||
- 2c: `revoke` für `admin_merge_games` nach `create or replace` ausdrücklich wiederholt, Prüfung in `08`; Konflikt-JSON mit alten Werten.
|
||
- **H7:** Trigger `games_merged_into_catalog` (2a) begründet; Test in `08`: Konto des Meldenden nach Freigabe löschen gelingt, Katalogspiel und Daten anderer Nutzer bleiben.
|
||
- Verweise auf die Hinweisliste auf 9.5 korrigiert; Review-Blocker B1 in RB1 umbenannt; Hinweis zu `security definer` und `current_user` für die Import-Spec (9.5).
|
||
|
||
### 2026-11-03 – Bestätigung der Designwahlen
|
||
|
||
Chris hat W1, W4, W6, W7, H1/H2 und H7 (§ 9.3) einzeln und ohne Änderung bestätigt (Kommentar im Issue #2). § 9.3 trägt jetzt je Zeile den Status „bestätigt (Chris, 2026-11-03)“; die Hinweise „bitte bestätigen“ in Status, E6 und 3.3 sind entfernt; 9.4 entsprechend angepasst.
|
||
|
||
### 2026-11-03 – Umsetzung PR 2a, Flutter-Anteil
|
||
|
||
- `GameCover` umgesetzt; Review-Nacharbeit: Semantics-Label folgt dem sichtbaren Zustand, zusätzlicher ARB-Schlüssel `gameCoverLabel`, Parameter `titleVisibleNearby`, `cacheWidth` nach Gerätepixeln, Hinweise für #3/#4 (5).
|
||
|
||
### 2026-11-03 – Umsetzung PR 2a (backend-dev)
|
||
|
||
Umgesetzt: B1, B2, B3a, B3, B4 und die beiden CI-Schritte aus D1 (Deno-Tests und „erzeugte Dateien aktuell“, „`startbestand-keys.csv` wächst nur“). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR:
|
||
|
||
- **Branch:** `feature/2a-katalog-datenmodell` statt `feature/2-katalog-fundament` (Vorgabe des Auftrags).
|
||
- **CLI-Annahmen (R11) geprüft** mit Supabase CLI 3.0.0, Ergebnis in `docs/katalog-pflege.md` 2.7: Reihenfolge und `begin`/`commit` funktionieren; ein Fehler rollt die ganze Datei zurück (Exit 1); ein Pfad ohne Treffer ist nur eine Warnung (Exit 0). Die erzeugten Dateien enthalten kein `begin`/`commit`.
|
||
- **Nicht in 2a:** `games_visibility_immutable` (2c, wie in 3.4).
|
||
- **API-Hinweis für das Frontend:** `catalog_games` ist über PostgREST nicht schreibbar (Fehler 55000 „cannot insert/update view“, zusätzlich fehlen die Rechte). Clients dürfen beim Anlegen oder Ändern von `games` keine `id`, `created_at` und `updated_at` mehr senden (42501; bisher setzte der Trigger `set_updated_at` diese Werte nur bei UPDATE stillschweigend zurück, bei INSERT wurden `id` und `created_at` vom Client übernommen). Ein `toJson()` eines Modells darf deshalb nicht unverändert an `insert`/`update` gehen, sondern nur die erlaubten Spalten (3.2); Merkpunkt für KAT-01 (#3) und KAT-04 (#13). Außerdem funktioniert der `is_admin()`-Zweig der `game_*_write`-Policies für Katalogspiele faktisch nicht mehr (der Trigger `game_links_mark_manual` braucht Schreibrecht auf `manual_fields`; R8), relevant für QUE-08. Es gibt keine generierten Datenbanktypen im Repo.
|
||
- **Ergänzung `startbestand.sql`:** Ein Wächter am Anfang bricht ab, wenn ein Genre oder eine Mechanik aus der CSV in der Datenbank fehlt; sonst würde der Abgleich der Zuordnungen still Verknüpfungen löschen.
|
||
- **Ergänzung Testdaten:** `supabase/catalog/testdata/` (fiktives Vokabular und drei Testspiele) erzeugt `supabase/tests/support/*.inc`, die `06_catalog_foundation.test.sql` per `\ir` einbindet; so laufen Idempotenz und `checks.sql` im pgTAP-Test mit den echten erzeugten Skripten.
|
||
- **`game_links_mark_manual`:** prüft zuerst per `select`, ob das Spiel ein Katalogspiel ist, und schreibt nur dann `manual_fields`. Ohne diese Prüfung scheiterte jede Zuordnung zu einem privaten Spiel mit 42501, weil Nutzer kein Schreibrecht auf `manual_fields` haben.
|
||
- **Werkzeug:** Deno wird für das Skript gebraucht (lokal 2.9.8, in CI gepinnt über `DENO_VERSION`). Keine externen Importe.
|
||
|
||
### 2026-11-03 – Umsetzung PR 2b (backend-dev)
|
||
|
||
Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_catalog_merge.test.sql`, 89 Tests), B7 (Runbook Abschnitt 5). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR:
|
||
|
||
- **Branch:** `feature/2b-katalog-zusammenfuehren` statt `feature/2-katalog-pflege` (Vorgabe des Auftrags).
|
||
- **Keine neue Tabelle:** `00_schema.test.sql` bleibt unverändert (20 Tabellen).
|
||
- **Ergebnis-JSON (festgelegt):** `collection_items` (ohne Kollision umgehängt), `collection_conflicts`, `favorite_games`, `favorite_conflicts`, `plays`, `score_sheets`, `variants_copied`, `variants_mapped`, `variants_cleared`; `admin_merge_games` ergänzt `source_id`, `target_id`, `bgg_id_transferred`, `source_transferred`, `chain_flattened` und bei Konflikt `bgg_id_conflict` / `source_conflict`. **`variants_cleared` zählt Verweise** (Sammlungseinträge und Partien, deren Edition geleert wurde), nicht Varianten; so erkennt der Admin und später #13, ob bei einem Nutzer eine Edition verloren ging. Beim Zusammenführen zweier Katalogspiele ist er immer 0. `source_conflict` enthält zusätzlich `source_synced_at` (alle vier Werte der Gruppe bleiben erhalten).
|
||
- **Zusätzliche Prüfung:** `admin_merge_games(null, …)` / `(…, null)` meldet `merge_game_not_found`. `admin_merge_games` sperrt beide Zeilen in fester Reihenfolge (`for update`), damit parallele Aufrufe nicht verklemmen.
|
||
- **Variantenzuordnung** ist ein jsonb-Wert in der Funktion, keine temporäre Tabelle (mehrere Aufrufe in einer Transaktion bleiben möglich). Der Namensabgleich läuft nur gegen die Zielvarianten, die vor dem Aufruf existierten; jede Quellvariante wird einzeln kopiert, auch wenn zwei Quellvarianten gleich heißen (wörtliche Auslegung von 3.3).
|
||
- **Kollisionsregel Spec-F1** füllt nur `note`, `acquired_on` und `variant_id`; die Felder aus SAM-02/SAM-04 gibt es noch nicht (Hinweis 9.5 bleibt). Eine leere Notiz (`null` oder leerer Text) am Ziel gilt als leer.
|
||
- **`repoint_game_references` prüft nicht, ob `p_from` existiert** (nur die beiden Fehler aus 3.3); `admin_merge_games` prüft es. Der Fall „privater Eintrag auf Katalogspiel“ ist in 2b nur direkt gegen `repoint_game_references` getestet; `admin_resolve_report` kommt mit 2c.
|
||
- **Schritt 4 aus 3.3** (`game_reports.target_game_id` umhängen) entfällt bis 2c, wie vorgesehen.
|
||
- **Hinweis für 2c (B9):** Sobald `game_reports` zwei Fremdschlüssel auf `games` bekommt, wird der Wächter in `07` rot. Die erwartete Liste in `07` muss dort um `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (bewusst ausgelassen) erweitert werden, nicht nur der Wächter in `08`.
|
||
- **Runbook:** Alle SQL-Bausteine aus Abschnitt 5 laufen wörtlich in `07` (einmalige Prüfung per Skript; keine CI-Prüfung). Abschnitt 5 enthält die Duplikatsuche, eine Vorschau (Verweise, Doppelbesitz), den Aufruf, die Nachprüfung, die Regeln, die Schlüssel des Ergebnis-JSON und die Fehlertexte.
|
||
|
||
- **Nachtrag Review 2b:** Tests ergänzt (Spec-F1-Edition am Ziel, `variants_cleared` im Kollisionszweig, gleichnamige Quellvarianten, zweiter Fremdschlüssel-Wächter auf `game_variants`, unbeteiligte Spiele, Ziel `null`); Runbook um Trockenlauf (`begin; …; rollback;` in einem Lauf, nur dieser Baustein läuft nicht wörtlich in pgTAP), Ketten-Nachprüfung und Nachschlagen von `merged_into_id` ergänzt. Der Null-Check in `admin_merge_games` ist ein äquivalenter Mutant (das spätere `not found` liefert denselben Fehler) und bleibt als ausdrückliche Absicherung. Offen für Chris: Verweise, die nach dem Merge noch auf die Quelle entstehen (W3).
|