Merge pull request 'feat(catalog): Meldungen und Sichtbarkeitsschutz (KAT-05, 2c)' (#57) from feature/2c-katalog-meldungen into main
All checks were successful
CI / flutter (push) Successful in 1m38s
CI / web-build (push) Successful in 1m29s
CI / supabase (push) Successful in 11s
CI / supabase-tests (push) Successful in 38s
CI / e2e (push) Successful in 4m17s

Reviewed-on: #57
This commit is contained in:
cschlaefke 2026-11-04 00:18:36 +00:00
commit 76d54adbd1
8 changed files with 1275 additions and 19 deletions

View file

@ -28,7 +28,7 @@ Die Pfeile zeigen, welche Tabelle auf welche verweist. Jede Partie (`plays`) hä
## Tabellen
R1 braucht 20 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers` und `game_publishers` aus KAT-05; mit `game_reports` in KAT-05 PR 2c werden es 21). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `<Zieltabelle>_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag.
R1 braucht 21 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers`, `game_publishers` und `game_reports` aus KAT-05). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `<Zieltabelle>_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag.
### Konto und Profil
@ -43,6 +43,7 @@ R1 braucht 20 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog
| Tabelle | Spalten | Zweck |
| --- | --- | --- |
| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at` | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die letzten sieben Spalten gehören der Katalogpflege (KAT-05), siehe unten |
| `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at`, `editions_cleared` | Meldungen fehlender Spiele (KAT-05 PR 2c, Anlegen durch Nutzer kommt mit KAT-04); siehe Katalogpflege |
| `catalog_sources` | `key`, `name`, `attribution_text`, `attribution_url`, `images_allowed`, `texts_allowed`, `terms_url`, `terms_checked_at` | Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt `bgg` ohne Erlaubnis an |
| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) |
| `game_variants` | `game_id` (FK → `games`), `name`, `year`, `language`, `bgg_id` (optional) | Editionen und Varianten eines Spiels (O4) |
@ -76,6 +77,7 @@ Jede Tabelle hat Row Level Security (RLS) und eine Policy-Test-Suite (pgTAP). De
| `profiles` | Eigenes Profil; fremde nur `display_name`, `avatar_path`, `city` | Nur eigenes |
| `favorite_games`, `bgg_links` | Nur eigene | Nur eigene |
| `games`, `game_variants`, Begriffe | Katalog für alle (O6); private Spiele nur der Ersteller | Katalog nur Admin (in R1 nur über Studio/SQL, E10); private Spiele nur der Ersteller, und nur die Spalten laut Spaltenrechten |
| `game_reports` | nur der Meldende liest seine Zeilen (Admin-Policy `is_admin()` für R4); Schreiben kein Nutzerrecht | Anlegen ergänzt KAT-04 (#13); Bearbeiten nur über `admin_resolve_report` (Studio) |
| `catalog_sources`, `publishers`, `game_publishers`, View `catalog_games` | für alle, auch ohne Anmeldung (`game_publishers` folgt dem Spiel) | Quellen und Verlage nur Admin; `game_publishers` Admin oder Ersteller des privaten Spiels; die View ist nicht beschreibbar |
| `collection_items`, `score_sheets`, `score_categories` | Nur eigene | Nur eigene |
| `plays` | Ersteller und eingeladene Mitspieler (offen oder bestätigt) | Nur Ersteller |
@ -233,6 +235,27 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel
| `created_at` | timestamptz | ja | `now()` | |
| `updated_at` | timestamptz | ja | `now()` | |
**`game_reports`** (KAT-05, Migration `game_reports`):
| Spalte | Typ | Pflicht | Standard | Schlüssel |
| --- | --- | --- | --- | --- |
| `id` | uuid | ja | `gen_random_uuid()` | PK |
| `owner_id` | uuid | ja | | FK → profiles (`on delete cascade`) |
| `origin` | text | ja | `'user'` | `user` oder `import` |
| `title` | text | ja | | 1 bis 200 Zeichen |
| `publisher_name` | text | nein | | höchstens 200 |
| `year` | smallint | nein | | 1800 bis 2100 |
| `link` | text | nein | | `http(s)://`, höchstens 500 |
| `bgg_id` | integer | nein | | |
| `provisional_game_id` | uuid | nein | | FK → games (`on delete set null`), eindeutig (1:1) |
| `status` | text | ja | `'open'` | `open`, `approved`, `rejected`, `merged` |
| `target_game_id` | uuid | nein | | FK → games (`on delete restrict`) |
| `resolved_at` | timestamptz | nein | | gesetzt genau dann, wenn `status <> 'open'` |
| `editions_cleared` | integer | ja | `0` | Anzahl der Verweise (Sammlung, Partien), deren Edition beim Umhängen geleert wurde (`variants_cleared`); Check `>= 0`; setzt nur `admin_resolve_report` (Spec-F6) |
| `created_at`, `updated_at` | timestamptz | ja | `now()` | |
Checks: offen hat kein Ziel; `approved`/`merged` brauchen ein Ziel. Indizes: `(owner_id, created_at)`, eindeutig `(owner_id, bgg_id)` für offene Import-Meldungen (N12a), `(created_at)` für offene Meldungen, eindeutig `provisional_game_id`, `target_game_id`.
**`genres`**, **`mechanics`** und **`categories`** sind gleich aufgebaut:
| Spalte | Typ | Pflicht | Standard | Schlüssel |
@ -343,9 +366,10 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle
- **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf).
- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). **Regel: `merged_into_id` wird nur über `public.admin_merge_games(p_source, p_target) returns jsonb` gesetzt, nie von Hand.** (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu.
- **Meldungen** (Migration `game_reports`, PR 2c): Tabelle `game_reports` (siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy auf `owner_id`; die Policy für `is_admin()` ist für R4 vorbereitet); Schreibrechte haben `anon` und `authenticated` nicht, #13 ergänzt Insert-Recht und -Policy. Bearbeitet wird in R1 nur über `public.admin_resolve_report(p_report, p_status, p_target) returns jsonb` (Ablauf, Ergebnis und Fehler: `docs/katalog-pflege.md`, Abschnitt 6): `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`), für `anon`, `authenticated` und `service_role` nicht ausführbar. `approved` und `merged` hängen den vorläufigen privaten Eintrag über `repoint_game_references` auf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert, `variants_cleared`) und blenden ihn aus (`hidden_at`, `merged_into_id`); `rejected` speichert höchstens den Verweis. `admin_merge_games` hängt zusätzlich `game_reports.target_game_id` von der Quelle auf das Ziel (`reports_repointed`). Der Trigger `games_visibility_immutable` lehnt jede Änderung von `visibility` und `owner_id` an bestehenden Spielen ab (Fehler `visibility_immutable`, für alle Rollen auch `postgres`; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501). `admin_resolve_report` schreibt außerdem `editions_cleared` (Anzahl geleerter Editionen, 0 bei `rejected` und ohne Umhängen), damit die App den Nutzer zum Neuwählen auffordern kann (Spec-F6). Beim Konto-Löschen werden Meldungen und vorläufige Einträge (auch nach der Freigabe) hart gelöscht, Katalogspiele und Daten anderer Nutzer bleiben. Fremdschlüssel-Wächter: `07` und `08`.
- **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder.
- **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import).
- **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`.
- **Startbestand:** `supabase/catalog/` (CSV, Werkzeug, erzeugte SQL-Dateien); `supabase db reset` spielt Vokabular und Startbestand ein. Der Startbestand enthält nur Fakten und einen eigenen deutschen Kurztext, kein Cover, keine Quelle, keine BGG-Kennung.
- **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht.
- **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an.
- **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`; neu für den Meldenden lesbar: `game_reports`. Das Ändern von `visibility` an einem Spiel ist nicht mehr möglich (P0001 `visibility_immutable`); wer eine Sichtbarkeit mitsendet, muss den alten Wert senden oder die Spalte weglassen. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an.

View file

@ -2,9 +2,9 @@
Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`.
Stand: PR 2b (Datenmodell, Startbestand, Ausblenden, Zusammenführen). Der Abschnitt **Meldungen bearbeiten** (PR 2c) kommt mit dem jeweiligen PR dazu.
Stand: PR 2c (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten).
**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1).
**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`, Abschnitt 6 in `08_game_reports.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1).
## 1. Zugang
@ -190,7 +190,7 @@ Zwei Katalogspiele, die dasselbe Spiel beschreiben (Duplikat), führt man mit de
- **Quelle** ist das Duplikat, das verschwindet. Sie wird **nicht gelöscht**, sondern ausgeblendet und auf das Ziel verwiesen (`hidden_at`, `merged_into_id`); ihre ID bleibt gültig, die App leitet alte Links auf das Ziel um (KAT-02).
- **Ziel** ist der Eintrag, der bleibt. Er muss sichtbar sein (nicht ausgeblendet, nicht selbst zusammengeführt). Das Ziel behält seine Fakten, Kurztexte und Zuordnungen (Verlag, Genre, Mechanik, Kategorie); die Zuordnungen der Quelle bleiben am ausgeblendeten Quell-Eintrag. Was am Ziel fehlt, ergänzt man vorher oder nachher mit den Bausteinen aus 3.
- Beide müssen **Katalogspiele** sein. Einen vorläufigen privaten Eintrag eines Nutzers führt man nicht hiermit zusammen (dafür gibt es ab PR 2c die Meldungs-Funktion).
- Beide müssen **Katalogspiele** sein. Einen vorläufigen privaten Eintrag eines Nutzers führt man nicht hiermit zusammen (dafür gibt es die Meldungs-Funktion, siehe 6).
- **Es gibt kein Rückgängigmachen.** Vor dem Zusammenführen den Stand des Backups prüfen; im Zweifel zuerst die Vorschau (unten) lesen.
**Schritt 1: Duplikate finden** (normalisierter Titel + Jahr, nur sichtbare Katalogspiele; die älteste ID steht zuerst):
@ -308,7 +308,7 @@ Die Funktion arbeitet wie beim Merge (Kollisionsregeln, Favoriten, Editionen). D
{"plays": 7, "target_id": "…", "source_id": "…", "score_sheets": 1, "favorite_games": 1, "collection_items": 3,
"variants_copied": 2, "variants_mapped": 1, "variants_cleared": 0, "chain_flattened": 0,
"favorite_conflicts": 0, "bgg_id_transferred": true, "source_transferred": false,
"collection_conflicts": 1}
"collection_conflicts": 1, "reports_repointed": 0}
```
| Schlüssel | Bedeutung |
@ -318,11 +318,12 @@ Die Funktion arbeitet wie beim Merge (Kollisionsregeln, Favoriten, Editionen). D
| `favorite_games`, `favorite_conflicts` | Favoriten umgehängt bzw. wegen vorhandenem Favoriten am Ziel entfallen |
| `plays`, `score_sheets` | umgehängte Partien und Wertungsbögen |
| `variants_copied`, `variants_mapped` | Varianten, die am Ziel neu angelegt bzw. auf eine gleichnamige abgebildet wurden |
| `variants_cleared` | Sammlungseinträge und Partien, deren Edition geleert wurde. Beim Zusammenführen zweier Katalogspiele bleibt das `0`; es kommt nur beim Umhängen privater Einträge vor (PR 2c) |
| `variants_cleared` | Sammlungseinträge und Partien, deren Edition geleert wurde. Beim Zusammenführen zweier Katalogspiele bleibt das `0`; es kommt nur beim Umhängen privater Einträge vor (Abschnitt 6) |
| `bgg_id_transferred`, `source_transferred` | `true`, wenn das Ziel die BGG-Kennung bzw. die Quelle der Quelle übernommen hat |
| `bgg_id_conflict` | nur vorhanden, wenn beide eine **unterschiedliche** BGG-Kennung hatten: `{"source": 13, "target": 42}`. Das Ziel behält seinen Wert, der Wert der Quelle ist geleert und steht **nur noch hier**. Bei Bedarf notieren und prüfen, ob das Ziel das richtige Spiel ist |
| `source_conflict` | wie `bgg_id_conflict` für die Quelle (`source` und `target` mit `catalog_source_id`, `source_ref`, `source_url`, `source_synced_at`) |
| `chain_flattened` | Anzahl früher zusammengeführter Spiele, die jetzt direkt auf das Ziel zeigen |
| `reports_repointed` | Meldungen (`game_reports`), deren Zieleintrag die Quelle war und die jetzt auf das Ziel zeigen (Abschnitt 6) |
**Fehlermeldungen** (alle ohne Änderung, `P0001`):
@ -345,7 +346,147 @@ Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mi
## 6. Meldungen bearbeiten
Kommt mit PR 2c.
Nutzer melden fehlende Spiele (KAT-04, #13); Meldungen, die ein Import anlegt, gibt es ab SAM-08 (`origin = 'import'`). In der Tabelle `game_reports` steht, was der Nutzer angegeben hat (Titel, Verlag, Jahr, Link, BGG-Kennung) und, falls vorhanden, sein **vorläufiger privater Eintrag** (`provisional_game_id`, nur für ihn sichtbar). Der Admin entscheidet in drei Wegen, immer mit der Funktion `public.admin_resolve_report(meldung, status, ziel)`:
| Entscheidung | Aufruf | Wirkung |
|---|---|---|
| **freigeben** | `'approved'` + Ziel | Der Eintrag ist wegen dieser Meldung **neu** entstanden. Der vorläufige Eintrag des Nutzers wird auf das Ziel umgehängt (Sammlung, Favoriten, Partien, Wertungsbögen) und ausgeblendet. |
| **zusammenführen** | `'merged'` + Ziel | Der Eintrag war **schon vorhanden**. Wirkung wie bei `'approved'`. |
| **ablehnen** | `'rejected'`, optional mit Ziel | Mit Ziel wird nur der Verweis gespeichert (die App kann dem Nutzer den Zieleintrag anbieten); der vorläufige Eintrag bleibt unverändert. Ohne Ziel ändert sich nichts außer Status und Zeitpunkt. |
**Regeln**
- **Nie einen privaten Eintrag öffentlich schalten.** `visibility` und `owner_id` eines Spiels lassen sich nicht ändern (der Trigger `games_visibility_immutable` lehnt es für jede Rolle ab, auch für `postgres`, Fehler `visibility_immutable`). Freigeben heißt immer: **neuen** Katalogeintrag anlegen.
- **Beim Anlegen nur Fakten aus den Meldungsfeldern übernehmen:** `title`, `publisher_name`, `year`; `link` dient nur als Recherchehilfe. **Nie** `description` oder `image_path` des vorläufigen Eintrags übernehmen (das sind private Angaben des Nutzers). Den Kurztext schreibt der Admin selbst (SB4, deutsch).
- **Datenschutz:** vom vorläufigen Eintrag nur ansehen, was die Entscheidung braucht (Anzahl Verweise, Namen der Editionen). Die Meldung selbst gehört zum Konto des Nutzers und wird mit ihm gelöscht.
- **Editionen werden nicht kopiert** (RB1, Klärung N15). Hat der Katalogeintrag eine gleichnamige Edition (Vergleich ohne Groß-/Kleinschreibung und ohne Leerzeichen am Rand), bleibt die Edition des Nutzers erhalten, sonst wird sie an Sammlungseintrag und Partien geleert (`variants_cleared` im Ergebnis). Soll die Edition erhalten bleiben, **vorher** am Katalogeintrag eine Variante anlegen, und zwar nur aus belegbaren Fakten (Name und Jahr der Edition aus Verlags- oder Schachtelangaben), nie durch Übernahme der privaten Variante. Die private Variante bleibt am ausgeblendeten vorläufigen Eintrag und wird mit dem Konto gelöscht.
- Das Ziel muss ein sichtbares Katalogspiel sein (nicht ausgeblendet, nicht zusammengeführt). Es gibt kein Rückgängigmachen; in einer Transaktion arbeiten (`begin;` davor, nach dem Lesen des Ergebnisses `commit;`, bei Zweifel `rollback;`; die Bausteine stehen wie überall ohne `begin`/`commit`).
- Wird ein Zieleintrag später mit einem anderen Katalogspiel zusammengeführt (Abschnitt 5), zeigen die Meldungen automatisch auf das neue Ziel (`reports_repointed` im Ergebnis).
In den Bausteinen stehen Beispiel-IDs: `7c1d2c3d-0000-4000-8000-000000000001` ist die Meldung, `…0002` das neu angelegte Katalogspiel, `…0004` ein vorhandenes Katalogspiel. Immer durch die echten IDs ersetzen (neue ID erzeugen: `select gen_random_uuid();`).
**Schritt 1: Offene Meldungen** (älteste zuerst):
```sql
select r.id, r.created_at, r.origin, r.title, r.publisher_name, r.year, r.link, r.bgg_id, r.provisional_game_id
from public.game_reports r
where r.status = 'open'
order by r.created_at, r.id;
```
**Schritt 2: Ähnliche Spiele im Katalog suchen** (gleicher Titel ohne Groß-/Kleinschreibung oder gleiche BGG-Kennung wie die Meldung). Gibt es schon einen Eintrag, entscheidet man sich für `'merged'`:
```sql
select g.id, g.title, g.year, g.bgg_id
from public.game_reports r
join public.catalog_games g
on lower(btrim(g.title)) = lower(btrim(r.title))
or (r.bgg_id is not null and g.bgg_id = r.bgg_id)
where r.id = '7c1d2c3d-0000-4000-8000-000000000001'
order by g.title, g.id;
```
Was hängt am vorläufigen Eintrag des Nutzers? Nur Anzahlen:
```sql
select g.id, g.title,
(select count(*) from public.collection_items c where c.game_id = g.id) as sammlung,
(select count(*) from public.favorite_games f where f.game_id = g.id) as favoriten,
(select count(*) from public.plays p where p.game_id = g.id) as partien,
(select count(*) from public.score_sheets s where s.game_id = g.id) as wertungsboegen
from public.game_reports r
join public.games g on g.id = r.provisional_game_id
where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
```
Und die Namen seiner Editionen (für die Entscheidung, ob am Katalogeintrag eine gleichnamige Variante angelegt wird):
```sql
select v.id, v.name, v.year, v.language
from public.game_reports r
join public.game_variants v on v.game_id = r.provisional_game_id
where r.id = '7c1d2c3d-0000-4000-8000-000000000001'
order by v.name, v.id;
```
**Schritt 3a: Freigeben** (neuer Eintrag). Zuerst den Katalogeintrag mit den Fakten aus der Meldung und einem eigenen Kurztext anlegen (jedes gesetzte Feld wird automatisch in `manual_fields` vermerkt):
```sql
insert into public.games (id, title, year, description, description_locale, visibility)
values ('7c1d2c3d-0000-4000-8000-000000000002', 'Meldungsspiel', 2024, 'Eigener Kurztext des Admins.', 'de', 'catalog');
```
Verlag zuordnen (ein vorhandener Verlag wird wiederverwendet, die Schreibweise entscheidet nicht):
```sql
insert into public.publishers (name) values ('Beispielverlag') on conflict ((lower(name))) do nothing;
insert into public.game_publishers (game_id, publisher_id)
select '7c1d2c3d-0000-4000-8000-000000000002', p.id from public.publishers p where lower(p.name) = lower('Beispielverlag');
```
Optional, wenn die Edition des Nutzers erhalten bleiben soll (nur belegbare Fakten, siehe Regeln):
```sql
insert into public.game_variants (game_id, name, year) values ('7c1d2c3d-0000-4000-8000-000000000002', 'Deluxe', 2024);
```
Dann die Meldung abschließen und das Ergebnis lesen:
```sql
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', '7c1d2c3d-0000-4000-8000-000000000002');
```
**Schritt 3b: Zusammenführen** (der Eintrag war schon da, Ziel ist das vorhandene Katalogspiel):
```sql
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004');
```
**Schritt 3c: Ablehnen**, ohne Verweis oder mit Verweis auf ein vorhandenes Katalogspiel:
```sql
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected');
```
```sql
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004');
```
**Schritt 4: Nachprüfen.** Status der Meldung und Zustand des vorläufigen Eintrags (bei `approved`/`merged`: ausgeblendet und verwiesen; bei `rejected`: unverändert):
```sql
select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, r.editions_cleared,
g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id
from public.game_reports r
left join public.games g on g.id = r.provisional_game_id
where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
```
**Ergebnis lesen** (Beispiel, `approved` mit vorläufigem Eintrag):
```
{"plays": 2, "status": "approved", "repointed": true, "report_id": "…", "target_id": "…", "score_sheets": 1,
"favorite_games": 1, "collection_items": 1, "variants_copied": 0, "variants_mapped": 1, "variants_cleared": 1,
"favorite_conflicts": 0, "collection_conflicts": 0, "provisional_game_id": "…"}
```
`repointed` ist `false`, wenn nichts umgehängt wurde (`rejected` oder Meldung ohne vorläufigen Eintrag); dann stehen die Zähler nicht im Ergebnis. Die Zähler haben dieselbe Bedeutung wie bei der Zusammenführung (Abschnitt 5). Wichtig hier: **`variants_cleared`** zählt die Sammlungseinträge und Partien, deren Edition geleert wurde, weil der Katalogeintrag keine gleichnamige Variante hatte. Das ist der Hinweis, dass der Nutzer die Edition neu wählen muss. Derselbe Wert steht danach in der Meldung (`game_reports.editions_cleared`, Spec-F6); die App (#13) liest ihn, um den Nutzer zum Neuwählen aufzufordern. Er ist 0 bei `rejected` und bei Meldungen ohne Umhängen. Eine Kollision (`collection_conflicts`) bedeutet: Der Nutzer hatte das Ziel schon in seiner Sammlung; der Zieleintrag bleibt, leere Felder füllt der Eintrag aus dem vorläufigen Spiel.
**Fehlermeldungen** (alle ohne Änderung, `P0001`):
| Meldung | Bedeutung |
|---|---|
| `admin_only` | Die Sitzung läuft nicht als `postgres` bzw. `supabase_admin` (`select current_user;` prüfen) |
| `report_not_found` | Die Meldung gibt es nicht (ID vertippt?) |
| `report_not_open` | Die Meldung ist schon bearbeitet (`approved`, `merged` oder `rejected`) |
| `report_status_invalid` | Der Status ist nicht `approved`, `merged` oder `rejected` |
| `report_target_required` | `approved` und `merged` brauchen ein Ziel |
| `report_target_not_listed` | Das Ziel ist kein sichtbares Katalogspiel (privat, ausgeblendet, zusammengeführt oder unbekannt); das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) |
| `report_provisional_not_private` | Der vorläufige Eintrag der Meldung ist kein privates Spiel **des Meldenden** (Datenfehler, z. B. ein Katalogspiel oder das Spiel eines anderen Nutzers); nicht umhängen, sondern die Meldung prüfen |
**Falsch gesetzte `visibility` oder `owner_id`** lassen sich nicht mit `update` korrigieren (Trigger `games_visibility_immutable`, Fehler `visibility_immutable`). Korrigieren geht nur durch Löschen und Neuanlegen des Spiels oder, bewusst und nur als Tabelleneigentümer in einer Transaktion, mit `alter table public.games disable trigger games_visibility_immutable;` … `enable trigger …`. Für Clients: Der Fehler `P0001` kommt über PostgREST als HTTP 400 an (Hinweis für die Fehlerabbildung in #3 und #13).
Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Hinweis: Die Rolle `service_role` hat (Supabase-Standard) volle Tabellenrechte und umgeht RLS; die Zugriffsgrenzen in Krit. 9 gelten für die API-Rollen `anon` und `authenticated`, den Funktionen ist `service_role` ausdrücklich entzogen. Meldungen anlegen, ändern oder löschen können Nutzer in dieser Etappe nicht (das Anlegen kommt mit KAT-04); sie lesen nur ihre eigenen. Das Löschen eines Zielspiels, auf das eine Meldung verweist, schlägt mit `23503` fehl (gewollt: ausblenden statt löschen).
## 7. Admin-Rolle in der App

View file

@ -287,6 +287,7 @@ Die Quelle wird **nicht** gelöscht: Ihre Shuffle-ID bleibt gültig, die App lei
| `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 |
| `editions_cleared` | integer | not null, default 0, Check `>= 0` (Spec-F6, Option A, ergänzt bei der Umsetzung von 2c): Anzahl der Sammlungseinträge und Partien, deren Edition beim Umhängen geleert wurde |
| `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`.
@ -319,7 +320,7 @@ Ablauf: `status`, `target_game_id`, `resolved_at = now()` setzen. Hat die Meldun
- 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`).
Die Rückgabe enthält die Zähler aus `repoint_game_references` (u. a. `variants_cleared`). Dasselbe `variants_cleared` speichert die Funktion in `game_reports.editions_cleared` (0 bei `rejected` und bei Meldungen ohne Umhängen), damit die App (#13) den Nutzer zum Neuwählen der Edition auffordern kann.
„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.
@ -722,3 +723,23 @@ Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_cata
- **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).
- **Nachtrag W3 (Chris):** Nachträgliche Verweise auf eine bereits zusammengeführte Quelle sind eine bekannte Einschränkung, kein neuer Code. Runbook 5 beschreibt Erkennung, Behebung (`repoint_game_references` erneut als `postgres`) und Nachprüfung; `07` testet das (Quelle bleibt ausgeblendet, Kollisionen, nachträglich entstandene Variante wird am Ziel kopiert).
### 2026-11-03 – Umsetzung PR 2c (backend-dev)
Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_reports.test.sql`, 160 Tests; `07` und `00` angepasst), B10 (Runbook Abschnitt 6). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR:
- **Branch:** `feature/2c-katalog-meldungen` statt `feature/2-katalog-meldungen` (Vorgabe des Auftrags), gestapelt auf `feature/2b-katalog-zusammenfuehren`.
- **Zusätzliche Indizes** auf beide Fremdschlüssel nach `games` (Regel: Index auf jeden Fremdschlüssel). `provisional_game_id` ist **eindeutig** (`where provisional_game_id is not null`, 1:1: ein vorläufiger Eintrag gehört zu höchstens einer Meldung), `target_game_id` ein einfacher Index.
- **Zusatzfehler `report_provisional_not_private`** in `admin_resolve_report`: Ist der vorläufige Eintrag kein privates Spiel (Datenfehler per Studio), wird nichts umgehängt; sonst würde `repoint_game_references` ein Katalogspiel wie beim Zusammenführen behandeln (Varianten kopieren und löschen).
- **Ergebnis-JSON `admin_resolve_report`:** `report_id`, `status`, `target_id`, `provisional_game_id`, `repointed` (bool). Nur bei `repointed = true` zusätzlich die Zähler aus `repoint_game_references`. `rejected` und Meldungen ohne vorläufigen Eintrag liefern `repointed = false` und keine Zähler.
- **Ergebnis-JSON `admin_merge_games`:** neuer Schlüssel `reports_repointed` (Anzahl umgehängter Meldungen, immer vorhanden). Dadurch mussten zwei Assertions in `07` (exaktes JSON) und das Beispiel im Runbook Abschnitt 5 angepasst werden. Rumpf von `admin_merge_games` sonst unverändert (per `diff` gegen 2b geprüft); `revoke execute … from public, anon, authenticated, service_role` nach dem `create or replace` ausdrücklich wiederholt.
- **Sperren:** `admin_resolve_report` sperrt die Meldung und danach Ziel und vorläufigen Eintrag in `id`-Reihenfolge (`for update`), wie `admin_merge_games`. Dass der vorläufige Eintrag existiert, sichert der Fremdschlüssel (`on delete set null`); `repoint_game_references` selbst prüft die Existenz von `p_from` weiterhin nicht.
- **Ziel wird auch bei `rejected` geprüft** (`report_target_not_listed`), wenn eines angegeben ist. Reihenfolge der Prüfungen wie in 3.4; `p_report = null` ist `report_not_found`, `p_status = null` ist `report_status_invalid`.
- **Trigger `games_visibility_immutable`** läuft vor der RLS-Prüfung: Ein Nutzer, der `visibility` ändert, bekommt `P0001 visibility_immutable` statt `42501` (API-Änderung für Clients; bestehende Tests in `02` bleiben grün, weil dort `owner_id` ohne Spaltenrecht vorher mit 42501 scheitert). Es gibt weiterhin keine generierten Datenbanktypen im Repo.
- **RLS:** zwei Lese-Policies (eigene Zeilen; `is_admin()` für R4). Bewusst kein Test mit `is_admin()` als Zugang in R1, aber `08` dokumentiert, dass `app_metadata.role = 'admin'` lesen, nicht schreiben kann, und `user_metadata` nichts öffnet.
- **Tests und #13:** `08` prüft Schreibrechte je Spalte: beim Anlegen sind `status`, `target_game_id`, `resolved_at`, `origin`, `created_at`, `updated_at` nicht setzbar (nicht pauschal „kein Insert“), ein Update-Recht gibt es auf keine Spalte außer (ab #13) `result_seen_at`. Dazu „Delete verboten“ und „fremde Meldung nicht sichtbar/änderbar“. Einzelne Insert-/Update-Assertions im Abschnitt „Lesen, RLS“ (z. B. Anna legt eine Meldung für Ben an) gehen von fehlenden Insert-Rechten aus; 13a muss diese anpassen, `08` ändert sich mit #13 ohnehin.
- **Review 2c:** `admin_resolve_report` prüft zusätzlich, dass der vorläufige Eintrag existiert und dem Meldenden gehört (`report_provisional_not_private`, deckt Katalogspiel, fremdes Spiel und fehlende Zeile ab); Test mit fremdem Spiel in `08`. Hinweise: `service_role` hat Supabase-Standardrechte auf Tabellen (Krit. 9 gilt für `anon`/`authenticated`); `P0001` → HTTP 400 in PostgREST; **der Fremdschlüssel-Wächter existiert in `07` und `08`: #3 (`game_alt_titles.game_id`) und jede weitere Tabelle mit Verweis auf `games` muss beide anpassen**. Optionaler Merkpunkt (nicht gebaut): ein Check-Trigger, dass `target_game_id` auf ein Katalogspiel zeigt (analog `games_merged_into_catalog`); heute stellt das nur `admin_resolve_report` sicher.
- **Spec-F6 (Chris, Option A) nachträglich umgesetzt:** Spalte `game_reports.editions_cleared integer not null default 0 check (>= 0)`; `admin_resolve_report` setzt sie im Abschluss-Update aus `variants_cleared` (sonst 0). Die Migration von 2c wurde dafür in place ergänzt (noch nicht gemergt). Tests in `08`: Spalte, Default, Check, Wert 1 bei geleerter Edition (Meldung …01), 0 bei `merged` ohne geleerte Edition, bei `rejected` und ohne Umhängen, keine Insert-/Update-Rechte (Mutanten erkannt). Nicht gebaut: `result_seen_at` (kommt mit 13a samt `update (result_seen_at)`, Insert-Recht und -Policy; 13a setzt `editions_cleared` in `adopt_report_target` genauso).
- **Fremdschlüssel-Wächter:** erweiterte Liste in `07` und in `08` (`game_reports.target_game_id` behandelt, `game_reports.provisional_game_id` bewusst ausgelassen). `00_schema.test.sql`: 21 Tabellen.
- **Runbook Abschnitt 6:** Alle SQL-Bausteine laufen wörtlich in `08` (einmalige Prüfung per Skript, keine CI-Prüfung). Der Trockenlauf-Baustein aus 2b gilt entsprechend (`begin; …; rollback;` in einem Lauf).
- **Mutationsprüfung:** 25 Mutanten (u. a. Trigger entfernt/ohne `owner_id`, Grants, Policies, RLS aus, Fremdschlüssel-Aktionen, N12a-Index, Funktionsrumpf-Änderungen) gegen `08`; alle werden erkannt. Bei der Prüfung fiel ein Scheintest auf (Löschen eines Ziels scheiterte an anderen Verweisen), jetzt mit eigenem Ziel. Nicht erkennbar: das Weglassen des wiederholten `revoke` allein (Postgres behält die Rechte bei `create or replace`); erkannt wird stattdessen ein ausdrückliches `grant execute`.

View file

@ -3,7 +3,7 @@
## Inhalt
| Pfad | Zweck |
|---|---|
| `migrations/` | Schema, Trigger und RLS-Policies für R1 (20 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. |
| `migrations/` | Schema, Trigger und RLS-Policies für R1 (21 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. |
| `tests/database/` | pgTAP-Tests: Struktur-Regeln und Zugriffsrechte je Bereich. |
| `templates/` | Auth-Mailvorlagen (HTML, Go-Template) für Bestätigung und Passwort-Reset. |
| `seed.sql` | Beispieldaten nur für die lokale Entwicklung. |
@ -32,7 +32,8 @@ supabase test db # alle pgTAP-Tests ausführen
## Katalog (KAT-05)
- Pflege in R1 nur über Studio bzw. `psql` (Rolle `postgres`), Ablauf und Regeln: [`docs/katalog-pflege.md`](../docs/katalog-pflege.md). Spec: `docs/specs/2-katalog-daten.md`.
- Suche und Entdecken lesen nur die View `catalog_games` (ohne ausgeblendete und zusammengeführte Spiele). Geänderte Fakten trägt ein Trigger in `games.manual_fields` ein.
- Zusammenführen von Duplikaten nur über die Funktion `public.admin_merge_games(quelle, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`, nicht über die API aufrufbar); Ablauf in `docs/katalog-pflege.md`, Abschnitt 5. Jede neue Tabelle mit Verweis auf `games` muss dort behandelt werden (Wächter in `tests/database/07_catalog_merge.test.sql`).
- Zusammenführen von Duplikaten nur über die Funktion `public.admin_merge_games(quelle, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`, nicht über die API aufrufbar); Ablauf in `docs/katalog-pflege.md`, Abschnitt 5. Jede neue Tabelle mit Verweis auf `games` muss dort behandelt werden (Wächter in `tests/database/07_catalog_merge.test.sql` und `08_game_reports.test.sql`).
- Meldungen fehlender Spiele (`game_reports`) bearbeitet der Admin nur über `public.admin_resolve_report(meldung, status, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`); Ablauf in `docs/katalog-pflege.md`, Abschnitt 6. `visibility` und `owner_id` eines Spiels sind unveränderlich (Trigger `games_visibility_immutable`).
- `db reset` spielt `seed.sql`, `catalog/vocabulary.sql` und `catalog/startbestand.sql` ein (`[db.seed] sql_paths` in `config.toml`). Die SQL-Dateien in `catalog/` sind **erzeugt**; Quellen sind die CSV-Dateien. Neu erzeugen und testen (Deno, `brew install deno`):
```bash
deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts

View file

@ -0,0 +1,287 @@
-- KAT-05 / PR 2c: Meldungen (Spec docs/specs/2-katalog-daten.md, Abschnitt 3.4).
-- Nur additiv. Enthält:
-- games_visibility_immutable Trigger: Sichtbarkeit und Eigentümer eines Spiels sind unveränderlich (E14)
-- game_reports Tabelle mit RLS (Nutzer lesen nur ihre eigenen Meldungen)
-- admin_resolve_report Admin-Funktion: Meldung freigeben, zusammenführen oder ablehnen
-- admin_merge_games per create or replace erweitert: hängt auch game_reports.target_game_id um
-- Anlegen von Meldungen durch Nutzer (Insert-Recht, Insert-Policy, Tageslimit) liefert #13 (KAT-04).
-- ---------------------------------------------------------------------------------------------
-- E14: visibility und owner_id einer bestehenden games-Zeile ändern sich nie, für keine Rolle
-- (auch nicht für postgres). Ein privater Eintrag wird nie öffentlich geschaltet; freigegebene
-- Meldungen bekommen immer einen neuen Katalogeintrag. Das Konto-Löschen ist nicht betroffen
-- (private Spiele werden per on delete cascade gelöscht, nicht geändert).
-- Hinweis: Ein BEFORE-Trigger läuft vor der RLS-Prüfung (with check). Ein Nutzer, der visibility
-- ändert, bekommt deshalb P0001 visibility_immutable statt 42501.
-- ---------------------------------------------------------------------------------------------
create function public.games_visibility_immutable()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
if new.visibility is distinct from old.visibility or new.owner_id is distinct from old.owner_id then
raise exception 'visibility_immutable' using errcode = 'P0001';
end if;
return new;
end;
$$;
create trigger games_visibility_immutable before update on public.games
for each row execute function public.games_visibility_immutable();
-- ---------------------------------------------------------------------------------------------
-- Tabelle game_reports (Klärungen 6, 10, 26, 27, N12, N12a; E12)
-- ---------------------------------------------------------------------------------------------
create table public.game_reports (
id uuid primary key default gen_random_uuid(),
-- meldender Nutzer; Klärung 10: beim Konto-Löschen hart gelöscht
owner_id uuid not null references public.profiles (id) on delete cascade,
origin text not null default 'user' check (origin in ('user', 'import')),
title text not null check (char_length(title) between 1 and 200),
publisher_name text check (char_length(publisher_name) <= 200),
year smallint check (year between 1800 and 2100),
link text check (char_length(link) <= 500 and link ~ '^https?://'),
bgg_id integer,
-- vorläufiger privater Eintrag des Meldenden (KAT-04 Krit. 3); verschwindet er, bleibt die Meldung
provisional_game_id uuid references public.games (id) on delete set null,
status text not null default 'open' check (status in ('open', 'approved', 'rejected', 'merged')),
-- Zieleintrag: wie merged_into_id on delete restrict (Katalogspiele werden ausgeblendet, nicht gelöscht)
target_game_id uuid references public.games (id) on delete restrict,
resolved_at timestamptz,
-- Spec-F6 (Option A): Anzahl der Verweise (Sammlungseinträge, Partien), deren Edition beim Umhängen
-- geleert wurde. Setzt nur admin_resolve_report (aus variants_cleared); die App (#13) liest es,
-- um dem Nutzer zu sagen, dass er die Edition neu wählen muss.
editions_cleared integer not null default 0 check (editions_cleared >= 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint game_reports_resolved_matches_status check ((status = 'open') = (resolved_at is null)),
constraint game_reports_open_has_no_target check (status <> 'open' or target_game_id is null),
constraint game_reports_target_required check (status not in ('approved', 'merged') or target_game_id is not null)
);
create trigger set_updated_at before update on public.game_reports
for each row execute function public.set_updated_at();
-- Tageslimit (#13): (owner_id, created_at)
create index game_reports_owner_created_idx on public.game_reports (owner_id, created_at);
-- N12a: je Nutzer höchstens eine offene Import-Meldung je BGG-Kennung
create unique index game_reports_import_open_key on public.game_reports (owner_id, bgg_id)
where origin = 'import' and status = 'open' and bgg_id is not null;
-- Admin-Liste: offene Meldungen, älteste zuerst
create index game_reports_open_idx on public.game_reports (created_at) where status = 'open';
-- Fremdschlüssel (Ergänzung zur Spec: Index auf jeden Fremdschlüssel). Ein vorläufiger Eintrag
-- gehört zu höchstens einer Meldung (1:1), deshalb eindeutig; mehrere Meldungen können dasselbe
-- Ziel haben, daher dort ein einfacher Index.
create unique index game_reports_provisional_game_id_key on public.game_reports (provisional_game_id)
where provisional_game_id is not null;
create index game_reports_target_game_id_idx on public.game_reports (target_game_id);
-- RLS: Der Meldende liest seine eigenen Meldungen (Ergebnishinweis, KAT-04 Krit. 4-6). Die
-- Admin-Policy (is_admin) ist für R4 vorbereitet, in R1 wird die Rolle niemandem vergeben (E10).
-- Schreibrechte gibt es für anon und authenticated nicht; #13 ergänzt Insert-Recht und -Policy.
alter table public.game_reports enable row level security;
revoke all on public.game_reports from anon, authenticated;
grant select on public.game_reports to authenticated;
create policy game_reports_select_own on public.game_reports
for select to authenticated
using (owner_id = (select auth.uid()));
create policy game_reports_select_admin on public.game_reports
for select to authenticated
using (public.is_admin());
-- ---------------------------------------------------------------------------------------------
-- Admin-Funktion: Meldung bearbeiten (Krit. 5). security invoker, nur postgres/supabase_admin.
-- ---------------------------------------------------------------------------------------------
create function public.admin_resolve_report(p_report uuid, p_status text, p_target uuid default null)
returns jsonb
language plpgsql
security invoker
set search_path = ''
as $$
declare
rep public.game_reports;
prov public.games;
result jsonb := '{}'::jsonb;
repointed boolean := false;
begin
if current_user not in ('postgres', 'supabase_admin') then
raise exception 'admin_only' using errcode = 'P0001';
end if;
select * into rep from public.game_reports r where r.id = p_report for update;
if not found then
raise exception 'report_not_found' using errcode = 'P0001';
end if;
if rep.status <> 'open' then
raise exception 'report_not_open' using errcode = 'P0001';
end if;
if p_status is null or p_status not in ('approved', 'merged', 'rejected') then
raise exception 'report_status_invalid' using errcode = 'P0001';
end if;
if p_status in ('approved', 'merged') and p_target is null then
raise exception 'report_target_required' using errcode = 'P0001';
end if;
-- Beteiligte Spiele in fester Reihenfolge sperren (wie admin_merge_games).
perform 1 from public.games g
where g.id in (p_target, rep.provisional_game_id) order by g.id for update;
if p_target is not null and not exists (
select 1 from public.games g
where g.id = p_target and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null
) then
raise exception 'report_target_not_listed' using errcode = 'P0001';
end if;
if p_status in ('approved', 'merged') and rep.provisional_game_id is not null then
select * into prov from public.games g where g.id = rep.provisional_game_id;
-- Zusatzprüfung (nicht in der Spec): Nur ein privater Eintrag des Meldenden darf umgehängt
-- werden. Ein Katalogspiel würde sonst über den Variantenfall "Katalog" kopiert, und ein
-- fremdes privates Spiel (Datenfehler im Studio) würde Daten eines anderen Nutzers umhängen.
-- "not found" deckt eine fehlende Zeile ab (null <> 'private' wäre sonst nicht wahr).
if not found or prov.visibility <> 'private' or prov.owner_id is distinct from rep.owner_id then
raise exception 'report_provisional_not_private' using errcode = 'P0001';
end if;
-- privat -> Katalog: nichts wird kopiert (RB1, Klärung N15)
result := public.repoint_game_references(prov.id, p_target);
repointed := true;
-- merged_into_id verlangt hidden_at (Check), deshalb in einem Statement.
update public.games g
set merged_into_id = p_target, hidden_at = coalesce(g.hidden_at, now())
where g.id = prov.id;
end if;
-- Bei "rejected" mit Ziel wird nur der Verweis gespeichert (Spec-F3); der vorläufige Eintrag
-- bleibt unverändert, ob der Nutzer wechselt, entscheidet er in der App.
update public.game_reports r
set status = p_status, target_game_id = p_target, resolved_at = now(),
editions_cleared = coalesce((result ->> 'variants_cleared')::integer, 0)
where r.id = rep.id;
return result || jsonb_build_object(
'report_id', rep.id,
'status', p_status,
'target_id', p_target,
'provisional_game_id', rep.provisional_game_id,
'repointed', repointed
);
end;
$$;
-- ---------------------------------------------------------------------------------------------
-- admin_merge_games: Rumpf aus PR 2b unverändert, nur Schritt 4 neu (game_reports.target_game_id
-- von der Quelle auf das Ziel umhängen) und der Zähler reports_repointed im Ergebnis.
-- ---------------------------------------------------------------------------------------------
create or replace function public.admin_merge_games(p_source uuid, p_target uuid)
returns jsonb
language plpgsql
security invoker
set search_path = ''
as $$
declare
s public.games;
t public.games;
result jsonb;
n_chain integer;
n_reports integer;
bgg_transferred boolean := false;
source_transferred boolean := false;
begin
if current_user not in ('postgres', 'supabase_admin') then
raise exception 'admin_only' using errcode = 'P0001';
end if;
if p_source = p_target then
raise exception 'merge_same_game' using errcode = 'P0001';
end if;
if p_source is null or p_target is null then
raise exception 'merge_game_not_found' using errcode = 'P0001';
end if;
-- Beide Zeilen in fester Reihenfolge sperren, damit zwei gleichzeitige Aufrufe nicht verklemmen.
perform 1 from public.games g where g.id in (p_source, p_target) order by g.id for update;
select * into s from public.games g where g.id = p_source;
if not found then
raise exception 'merge_game_not_found' using errcode = 'P0001';
end if;
select * into t from public.games g where g.id = p_target;
if not found then
raise exception 'merge_game_not_found' using errcode = 'P0001';
end if;
if s.visibility <> 'catalog' or t.visibility <> 'catalog' then
raise exception 'merge_not_catalog' using errcode = 'P0001';
end if;
if t.hidden_at is not null or t.merged_into_id is not null then
raise exception 'merge_target_not_listed' using errcode = 'P0001';
end if;
if s.merged_into_id is not null then
raise exception 'merge_source_already_merged' using errcode = 'P0001';
end if;
-- 1. Verweise umhängen (beide Katalog -> Varianten werden kopiert)
result := public.repoint_game_references(p_source, p_target);
-- 2. BGG-Kennung und Quelle übernehmen (W6). Wegen der eindeutigen Indizes (bgg_id im Katalog,
-- (Quelle, source_ref) im Katalog) zuerst an der Quelle leeren, dann am Ziel setzen.
-- Gleichzeitig wird die Quelle als zusammengeführt und ausgeblendet markiert (ein Statement:
-- merged_into_id verlangt hidden_at). Bereits ausgeblendete Quellen behalten ihr hidden_at.
update public.games g
set bgg_id = null,
catalog_source_id = null, source_ref = null, source_url = null, source_synced_at = null,
merged_into_id = p_target,
hidden_at = coalesce(g.hidden_at, now())
where g.id = p_source;
if s.bgg_id is not null then
if t.bgg_id is null then
update public.games g set bgg_id = s.bgg_id where g.id = p_target;
bgg_transferred := true;
elsif t.bgg_id <> s.bgg_id then
-- Das Ziel behält seinen Wert; der geleerte Wert der Quelle bleibt hier erhalten.
result := result || jsonb_build_object('bgg_id_conflict',
jsonb_build_object('source', s.bgg_id, 'target', t.bgg_id));
end if;
end if;
if s.catalog_source_id is not null then
if t.catalog_source_id is null then
update public.games g
set catalog_source_id = s.catalog_source_id, source_ref = s.source_ref,
source_url = s.source_url, source_synced_at = s.source_synced_at
where g.id = p_target;
source_transferred := true;
elsif (t.catalog_source_id, t.source_ref) is distinct from (s.catalog_source_id, s.source_ref) then
result := result || jsonb_build_object('source_conflict', jsonb_build_object(
'source', jsonb_build_object('catalog_source_id', s.catalog_source_id, 'source_ref', s.source_ref,
'source_url', s.source_url, 'source_synced_at', s.source_synced_at),
'target', jsonb_build_object('catalog_source_id', t.catalog_source_id, 'source_ref', t.source_ref,
'source_url', t.source_url, 'source_synced_at', t.source_synced_at)));
end if;
end if;
-- 3. Ketten glätten: Spiele, die bisher auf die Quelle zeigten, zeigen jetzt auf das Ziel.
update public.games g set merged_into_id = p_target where g.merged_into_id = p_source;
get diagnostics n_chain = row_count;
-- 4. (neu in 2c) Meldungen, die auf die Quelle als Zieleintrag zeigen, zeigen jetzt auf das Ziel.
update public.game_reports r set target_game_id = p_target where r.target_game_id = p_source;
get diagnostics n_reports = row_count;
return result || jsonb_build_object(
'source_id', p_source,
'target_id', p_target,
'bgg_id_transferred', bgg_transferred,
'source_transferred', source_transferred,
'chain_flattened', n_chain,
'reports_repointed', n_reports
);
end;
$$;
-- Rechte-Regel: nur der Eigentümer darf ausführen. Nach dem create or replace wird der revoke
-- für admin_merge_games ausdrücklich wiederholt (create or replace behält zwar die bisherigen
-- Rechte, aber das soll nicht still vorausgesetzt werden).
revoke execute on function public.games_visibility_immutable() from public, anon, authenticated, service_role;
revoke execute on function public.admin_resolve_report(uuid, text, uuid) from public, anon, authenticated, service_role;
revoke execute on function public.admin_merge_games(uuid, uuid) from public, anon, authenticated, service_role;

View file

@ -2,8 +2,8 @@
begin;
select plan(5);
select is((select count(*) from pg_tables where schemaname = 'public'), 20::bigint,
'R1 hat genau 20 Tabellen (17 + catalog_sources, publishers, game_publishers)');
select is((select count(*) from pg_tables where schemaname = 'public'), 21::bigint,
'R1 hat genau 21 Tabellen (17 + catalog_sources, publishers, game_publishers, game_reports)');
select is_empty($$
select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace

View file

@ -2,8 +2,8 @@
-- Fremdschlüssel-Wächter und die SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 5
-- (unverändert, ohne begin/commit; Auswahl immer per id).
-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix f7/7a1b), gezählt wird nur darüber.
-- Hinweis für 2c: Der Fremdschlüssel-Wächter am Ende muss um game_reports.target_game_id (behandelt) und
-- game_reports.provisional_game_id (bewusst ausgelassen) erweitert werden, sonst wird er rot.
-- Ab 2c kennt der Fremdschlüssel-Wächter am Ende auch game_reports.target_game_id (behandelt) und
-- game_reports.provisional_game_id (bewusst ausgelassen); admin_merge_games meldet reports_repointed.
begin;
select plan(118);
@ -219,7 +219,7 @@ select is((select admin_merge_games from rb_merge),
'source_id', '7a1b2c3d-0000-4000-8000-000000000002', 'target_id', '7a1b2c3d-0000-4000-8000-000000000003',
'collection_items', 1, 'collection_conflicts', 3, 'favorite_games', 1, 'favorite_conflicts', 1,
'plays', 3, 'score_sheets', 1, 'variants_copied', 1, 'variants_mapped', 1, 'variants_cleared', 0,
'bgg_id_transferred', true, 'source_transferred', true, 'chain_flattened', 0),
'bgg_id_transferred', true, 'source_transferred', true, 'chain_flattened', 0, 'reports_repointed', 0),
'KAT-05 Kriterium 8: Runbook 5, das Ergebnis-JSON zählt alles richtig');
-- Runbook 5, Schritt 4: Nachprüfen (der Vorschau-Baustein aus Schritt 2 liefert überall 0)
@ -431,7 +431,7 @@ select is((select count(*) from public.game_variants where game_id = 'f7000000-0
insert into public.games (id, title, visibility) values
('f7000000-0000-0000-0000-000000000161', 'Leer Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000162', 'Leer Ziel', 'catalog');
select is(public.admin_merge_games('f7000000-0000-0000-0000-000000000161', 'f7000000-0000-0000-0000-000000000162') - 'source_id' - 'target_id',
'{"collection_items":0,"collection_conflicts":0,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":0,"bgg_id_transferred":false,"source_transferred":false,"chain_flattened":0}'::jsonb,
'{"collection_items":0,"collection_conflicts":0,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":0,"bgg_id_transferred":false,"source_transferred":false,"chain_flattened":0,"reports_repointed":0}'::jsonb,
'Zusammenführen ohne Verweise liefert lauter Nullen');
-- ---------------------------------------------------------------------------------------------
@ -630,9 +630,11 @@ select set_eq($$select fk from fk_found$$,
$$values
-- 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'),
('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'),
-- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag
('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id')$$,
('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'),
-- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht
('game_reports.provisional_game_id')$$,
'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen');
-- Zweiter Wächter: Verweise auf game_variants. Sie werden über die Variantenzuordnung umgesetzt;

View file

@ -0,0 +1,780 @@
-- KAT-05 (PR 2c): Meldungen. Tabelle game_reports (RLS, Checks, Indizes, Fremdschlüssel),
-- Trigger games_visibility_immutable, admin_resolve_report, admin_merge_games nach dem
-- create or replace (Rechte, Schritt 4), Konto-Löschen nach Freigabe, Fremdschlüssel-Wächter und die
-- SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 6 (unverändert, ohne begin/commit; Auswahl per id).
-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix 7c1d/f8), gezählt wird nur darüber.
-- Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall, sonst würde "alles verweigern" bestehen.
begin;
select plan(160);
insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'),
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'),
('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Cara"}');
-- ---------------------------------------------------------------------------------------------
-- Tabellenrechte (KAT-05 Kriterium 9)
-- ---------------------------------------------------------------------------------------------
select ok((select relrowsecurity from pg_class where oid = 'public.game_reports'::regclass),
'Sicherheit Kriterium 1: game_reports hat Row Level Security');
select ok(has_table_privilege('authenticated', 'public.game_reports', 'select'),
'KAT-05 Kriterium 5: authenticated darf game_reports lesen (eigene Zeilen, Policy)');
-- Formuliert stabil gegenüber späteren Spaltenrechten (#13 ergänzt Insert-Recht, u. a. auf owner_id und
-- provisional_game_id, und update (result_seen_at)): geprüft wird je Spalte.
-- Insert: nur Spalten, die der Nutzer nie setzen darf (Ergebnis und Herkunft der Meldung).
select is_empty($$select c from unnest(array['status', 'target_game_id', 'resolved_at', 'editions_cleared', 'origin', 'created_at', 'updated_at']) c
where has_column_privilege('authenticated', 'public.game_reports', c, 'insert')$$,
'KAT-05 Kriterium 9: authenticated kann beim Anlegen status, target_game_id, resolved_at, editions_cleared, origin, created_at, updated_at nicht setzen');
-- Update: auf keine Spalte außer (ab #13) result_seen_at
select is_empty($$select a.attname from pg_attribute a
where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped and a.attname <> 'result_seen_at'
and has_column_privilege('authenticated', 'public.game_reports', a.attname, 'update')$$,
'KAT-05 Kriterium 9: authenticated hat auf keine Spalte von game_reports ein Update-Recht (ausgenommen ab #13 result_seen_at)');
select is_empty($$select c, p from (select attname as c from pg_attribute where attrelid = 'public.game_reports'::regclass and attnum > 0 and not attisdropped) a,
unnest(array['insert', 'update']) p
where has_column_privilege('anon', 'public.game_reports', c, p)$$,
'KAT-05 Kriterium 9: anon hat auf keine Spalte von game_reports ein Schreibrecht');
select is_empty($$select r, p from unnest(array['anon', 'authenticated']) r, unnest(array['delete', 'truncate', 'references', 'trigger']) p
where has_table_privilege(r, 'public.game_reports', p)$$,
'KAT-05 Kriterium 9: kein Löschen, Leeren oder Trigger-Recht für anon und authenticated');
select is_empty($$select 1 from pg_policies where tablename = 'game_reports' and cmd in ('DELETE', 'ALL')$$,
'KAT-05 Kriterium 9: game_reports hat keine Delete- oder All-Policy');
select ok(not has_table_privilege('anon', 'public.game_reports', 'select'),
'Sicherheit Kriterium 3: anon darf game_reports nicht einmal lesen');
-- ---------------------------------------------------------------------------------------------
-- Fixtures (als postgres)
-- ---------------------------------------------------------------------------------------------
insert into public.games (id, title, visibility) values
('7c1d2c3d-0000-4000-8000-000000000004', 'Bestehendes Spiel', 'catalog'),
('f8000000-0000-0000-0000-00000000000c', 'MELDUNGSSPIEL', 'catalog'),
('f8000000-0000-0000-0000-00000000000e', 'Unbeteiligt', 'catalog');
update public.games set year = 2023 where id = 'f8000000-0000-0000-0000-00000000000c';
insert into public.games (id, title, year, bgg_id, visibility) values
('f8000000-0000-0000-0000-00000000000d', 'Anderer Titel', 2020, 4711, 'catalog');
insert into public.game_variants (id, game_id, name) values
('f8000000-0000-0000-0000-0000000000a4', '7c1d2c3d-0000-4000-8000-000000000004', 'Standard');
-- vorläufige private Einträge: P1 (Anna, freigeben), P2 (Anna, zusammenführen), P3 und P4 (Cara, ablehnen)
insert into public.games (id, title, year, description, image_path, visibility, owner_id) values
('7c1d2c3d-0000-4000-8000-000000000003', 'Meldungsspiel', 2024, 'Annas privater Text', 'private/anna.png', 'private', '11111111-1111-1111-1111-111111111111'),
('7c1d2c3d-0000-4000-8000-000000000008', 'Bestehendes Spiel', 2019, null, null, 'private', '11111111-1111-1111-1111-111111111111'),
('7c1d2c3d-0000-4000-8000-000000000009', 'Caras Prototyp', 2025, null, null, 'private', '33333333-3333-3333-3333-333333333333'),
('7c1d2c3d-0000-4000-8000-00000000000a', 'Caras Wunsch', 2025, null, null, 'private', '33333333-3333-3333-3333-333333333333');
insert into public.game_reports (id, owner_id, title, publisher_name, year, link, bgg_id, provisional_game_id, created_at) values
('7c1d2c3d-0000-4000-8000-000000000001', '11111111-1111-1111-1111-111111111111', 'Meldungsspiel', 'Beispielverlag', 2024, 'https://example.org/spiel', 4711, '7c1d2c3d-0000-4000-8000-000000000003', '2026-01-01'),
('7c1d2c3d-0000-4000-8000-000000000005', '11111111-1111-1111-1111-111111111111', 'Bestehendes Spiel', null, null, null, null, '7c1d2c3d-0000-4000-8000-000000000008', '2026-01-03'),
('7c1d2c3d-0000-4000-8000-000000000006', '33333333-3333-3333-3333-333333333333', 'Caras Prototyp', null, null, null, null, '7c1d2c3d-0000-4000-8000-000000000009', '2026-01-02'),
('7c1d2c3d-0000-4000-8000-000000000007', '33333333-3333-3333-3333-333333333333', 'Caras Wunsch', null, null, null, null, '7c1d2c3d-0000-4000-8000-00000000000a', '2026-01-04'),
('7c1d2c3d-0000-4000-8000-00000000000b', '22222222-2222-2222-2222-222222222222', 'Bens Meldung ohne Eintrag', null, null, null, null, null, '2026-01-05');
-- Verweise an den vorläufigen Einträgen
insert into public.game_variants (id, game_id, name) values
('f8000000-0000-0000-0000-0000000000e1', '7c1d2c3d-0000-4000-8000-000000000003', 'Deluxe'),
('f8000000-0000-0000-0000-0000000000e2', '7c1d2c3d-0000-4000-8000-000000000003', 'Prototyp'),
('f8000000-0000-0000-0000-0000000000e3', '7c1d2c3d-0000-4000-8000-000000000008', ' standard');
insert into public.collection_items (id, owner_id, game_id, variant_id, list, note) values
('f8000000-0000-0000-0000-0000000000c1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'f8000000-0000-0000-0000-0000000000e2', 'owned', 'Annas Notiz'),
('f8000000-0000-0000-0000-0000000000c2', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000008', 'f8000000-0000-0000-0000-0000000000e3', 'wishlist', 'Annas Wunsch'),
('f8000000-0000-0000-0000-0000000000c3', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000004', null, 'owned', null),
('f8000000-0000-0000-0000-0000000000c4', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000004', 'f8000000-0000-0000-0000-0000000000a4', 'owned', 'Bens Notiz'),
('f8000000-0000-0000-0000-0000000000c5', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-000000000009', null, 'owned', 'Caras Notiz'),
('f8000000-0000-0000-0000-0000000000c6', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-00000000000a', null, 'wishlist', 'Caras Wunschnotiz');
insert into public.favorite_games (id, profile_id, game_id, position) values
('f8000000-0000-0000-0000-0000000000d1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 1);
insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values
('f8000000-0000-0000-0000-0000000000b1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'f8000000-0000-0000-0000-0000000000e1', '2026-01-01', 'f8000000-0000-0000-0000-0000000000b1'),
('f8000000-0000-0000-0000-0000000000b2', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', null, '2026-01-02', 'f8000000-0000-0000-0000-0000000000b2'),
('f8000000-0000-0000-0000-0000000000b3', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000004', 'f8000000-0000-0000-0000-0000000000a4', '2026-01-03', 'f8000000-0000-0000-0000-0000000000b3'),
('f8000000-0000-0000-0000-0000000000b4', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-000000000009', null, '2026-01-04', 'f8000000-0000-0000-0000-0000000000b4');
insert into public.score_sheets (id, owner_id, game_id, name) values
('f8000000-0000-0000-0000-0000000000b5', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'Bogen');
-- ---------------------------------------------------------------------------------------------
-- Lesen, RLS (Sicherheit Kriterium 1 und 3, KAT-05 Kriterium 5 und 9)
-- ---------------------------------------------------------------------------------------------
select cmp_ok((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), '>', 0::bigint,
'Vorbedingung: es gibt Meldungen anderer Nutzer');
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;
select is((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), 2::bigint,
'Sicherheit Kriterium 3: Anna liest ihre beiden eigenen Meldungen');
select is((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), 0::bigint,
'Sicherheit Kriterium 3: Anna liest keine fremden Meldungen');
select is_empty($$select 1 from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000006'$$,
'Sicherheit Kriterium 3: Anna findet Caras Meldung auch per ID nicht');
select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', 'Im Namen von Ben')$$, '42501', null,
'KAT-05 Kriterium 9: Anna kann keine Meldung für einen anderen Nutzer anlegen');
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at, target_game_id)
values ('11111111-1111-1111-1111-111111111111', 'Selbst freigegeben', 'approved', now(), '7c1d2c3d-0000-4000-8000-000000000004')$$, '42501', null,
'KAT-05 Kriterium 9: Anna kann keine bereits freigegebene Meldung anlegen');
select throws_ok($$update public.game_reports set target_game_id = '7c1d2c3d-0000-4000-8000-000000000004' where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
'KAT-05 Kriterium 9: Anna kann das Ziel ihrer Meldung nicht setzen');
select throws_ok($$update public.game_reports set status = 'approved' where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
'KAT-05 Kriterium 9: Anna kann ihre Meldung nicht selbst freigeben');
select throws_ok($$delete from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
'KAT-05 Kriterium 9: Anna kann ihre Meldung nicht löschen');
reset role;
select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true);
set local role authenticated;
select is((select count(*) from public.game_reports where owner_id = '22222222-2222-2222-2222-222222222222'), 1::bigint,
'Sicherheit Kriterium 3: Ben liest seine eigene Meldung');
select is((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), 0::bigint,
'Sicherheit Kriterium 3: Ben liest Annas und Caras Meldungen nicht');
reset role;
-- Gefälschte Rolle im user_metadata (vom Client änderbar) öffnet nichts
select set_config('request.jwt.claims',
'{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated","user_metadata":{"role":"admin"}}', true);
set local role authenticated;
select is((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), 0::bigint,
'Sicherheit Kriterium 3: user_metadata.role = admin öffnet keine fremden Meldungen');
reset role;
-- Die Admin-Policy (R4) ist vorbereitet: nur app_metadata (serverseitig gesetzt) öffnet das Lesen, schreiben kann auch der Admin nicht
select set_config('request.jwt.claims',
'{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated","app_metadata":{"role":"admin"}}', true);
set local role authenticated;
select cmp_ok((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), '>=', 4::bigint,
'Admin-Policy für R4: app_metadata.role = admin liest alle Meldungen');
select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now() where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
'KAT-05 Kriterium 9: auch mit app_metadata.role = admin kein Ändern des Status über die Schnittstelle');
select throws_ok($$delete from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
'KAT-05 Kriterium 9: auch mit app_metadata.role = admin kein Löschen über die Schnittstelle');
reset role;
set local role anon;
select throws_ok($$select count(*) from public.game_reports$$, '42501', null,
'Sicherheit Kriterium 3: anon liest keine Meldungen');
select throws_ok($$insert into public.game_reports (owner_id, title) values ('11111111-1111-1111-1111-111111111111', 'Neu')$$, '42501', null,
'KAT-05 Kriterium 9: anon kann keine Meldung anlegen');
select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now() where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
'KAT-05 Kriterium 9: anon kann keine Meldung ändern');
reset role;
-- ---------------------------------------------------------------------------------------------
-- Checks (jeweils mit dem Namen der verletzten Regel, damit nicht ein anderer Fehler besteht)
-- ---------------------------------------------------------------------------------------------
select lives_ok($$insert into public.game_reports (id, owner_id, title, link, year, publisher_name) values
('f8000000-0000-0000-0000-000000000a01', '22222222-2222-2222-2222-222222222222', 'Gültig https', 'https://example.org/x', 1800, repeat('v', 200)),
('f8000000-0000-0000-0000-000000000a02', '22222222-2222-2222-2222-222222222222', repeat('t', 200), 'http://example.org/x', 2100, null)$$,
'Gültige Meldungen (https/http, Jahresgrenzen, Längengrenzen) lassen sich anlegen');
select throws_ok($$insert into public.game_reports (owner_id, title, origin) values ('22222222-2222-2222-2222-222222222222', 'X', 'bot')$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_origin_check"', 'Check: origin nur user/import');
select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', '')$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_title_check"', 'Check: Titel nicht leer');
select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', repeat('t', 201))$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_title_check"', 'Check: Titel höchstens 200 Zeichen');
select throws_ok($$insert into public.game_reports (owner_id, title, publisher_name) values ('22222222-2222-2222-2222-222222222222', 'X', repeat('v', 201))$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_publisher_name_check"', 'Check: Verlag höchstens 200 Zeichen');
select throws_ok($$insert into public.game_reports (owner_id, title, year) values ('22222222-2222-2222-2222-222222222222', 'X', 1799)$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_year_check"', 'Check: Jahr mindestens 1800');
select throws_ok($$insert into public.game_reports (owner_id, title, year) values ('22222222-2222-2222-2222-222222222222', 'X', 2101)$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_year_check"', 'Check: Jahr höchstens 2100');
select throws_ok($$insert into public.game_reports (owner_id, title, link) values ('22222222-2222-2222-2222-222222222222', 'X', 'ftp://example.org')$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_link_check"', 'Check: Link nur http(s)');
select throws_ok($$insert into public.game_reports (owner_id, title, link) values ('22222222-2222-2222-2222-222222222222', 'X', 'https://' || repeat('a', 493))$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_link_check"', 'Check: Link höchstens 500 Zeichen');
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'done', now())$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_status_check"', 'Check: Status nur open/approved/rejected/merged');
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'open', now())$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_resolved_matches_status"', 'Check: offene Meldung hat kein resolved_at');
select throws_ok($$insert into public.game_reports (owner_id, title, status) values ('22222222-2222-2222-2222-222222222222', 'X', 'rejected')$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_resolved_matches_status"', 'Check: erledigte Meldung braucht resolved_at');
select throws_ok($$insert into public.game_reports (owner_id, title, target_game_id) values ('22222222-2222-2222-2222-222222222222', 'X', '7c1d2c3d-0000-4000-8000-000000000004')$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_open_has_no_target"', 'Check: offene Meldung hat kein Ziel');
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'approved', now())$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: approved braucht ein Ziel');
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'merged', now())$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: merged braucht ein Ziel');
select lives_ok($$insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values
('f8000000-0000-0000-0000-000000000a03', '22222222-2222-2222-2222-222222222222', 'rejected mit Ziel', 'rejected', now(), '7c1d2c3d-0000-4000-8000-000000000004'),
('f8000000-0000-0000-0000-000000000a04', '22222222-2222-2222-2222-222222222222', 'rejected ohne Ziel', 'rejected', now(), null)$$,
'Positiv: rejected ist mit und ohne Ziel zulässig');
select is((select origin || ':' || status || ':' || editions_cleared::text from public.game_reports where id = 'f8000000-0000-0000-0000-000000000a01'), 'user:open:0',
'Standardwerte: origin = user, status = open, editions_cleared = 0');
select throws_ok($$insert into public.game_reports (owner_id, title, editions_cleared) values ('22222222-2222-2222-2222-222222222222', 'X', -1)$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_editions_cleared_check"', 'Check: editions_cleared nicht negativ');
select ok(exists (select 1 from pg_attribute where attrelid = 'public.game_reports'::regclass and attname = 'editions_cleared' and not attisdropped and attnotnull),
'Spec-F6: editions_cleared existiert und ist not null (die Update-Prüfung oben erfasst sie, da nur result_seen_at ausgenommen ist)');
-- ---------------------------------------------------------------------------------------------
-- N12a: eine offene Import-Meldung je Nutzer und BGG-Kennung
-- ---------------------------------------------------------------------------------------------
insert into public.game_reports (id, owner_id, title, origin, bgg_id) values
('f8000000-0000-0000-0000-000000000b01', '22222222-2222-2222-2222-222222222222', 'Import 1', 'import', 9001);
select throws_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id)
values ('22222222-2222-2222-2222-222222222222', 'Import 1 doppelt', 'import', 9001)$$, '23505', null,
'N12a: zweite offene Import-Meldung desselben Nutzers mit derselben BGG-Kennung wird abgelehnt');
select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) values
('33333333-3333-3333-3333-333333333333', 'Import anderer Nutzer', 'import', 9001),
('22222222-2222-2222-2222-222222222222', 'Import andere Kennung', 'import', 9002),
('22222222-2222-2222-2222-222222222222', 'Von Hand dieselbe Kennung', 'user', 9001),
('22222222-2222-2222-2222-222222222222', 'Von Hand dieselbe Kennung 2', 'user', 9001),
('22222222-2222-2222-2222-222222222222', 'Import ohne Kennung', 'import', null),
('22222222-2222-2222-2222-222222222222', 'Import ohne Kennung 2', 'import', null)$$,
'N12a: anderer Nutzer, andere Kennung, origin = user und fehlende Kennung bleiben zulässig');
insert into public.game_reports (id, owner_id, title, origin, bgg_id, status, resolved_at) values
('f8000000-0000-0000-0000-000000000b02', '22222222-2222-2222-2222-222222222222', 'Import 1 erledigt', 'import', 9001, 'rejected', now());
select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id)
values ('22222222-2222-2222-2222-222222222222', 'Import 1 neu', 'import', 9003)$$,
'N12a: Vorbedingung für die nächste Prüfung');
update public.game_reports set status = 'rejected', resolved_at = now() where id = 'f8000000-0000-0000-0000-000000000b01';
select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id)
values ('22222222-2222-2222-2222-222222222222', 'Import 1 nach Erledigung', 'import', 9001)$$,
'N12a: ist die frühere Meldung erledigt, darf eine neue offene für dieselbe Kennung angelegt werden');
select is((select indexdef from pg_indexes where indexname = 'game_reports_open_idx'),
'CREATE INDEX game_reports_open_idx ON public.game_reports USING btree (created_at) WHERE (status = ''open''::text)',
'Index für die Admin-Liste (offene Meldungen nach Alter)');
select is((select count(*) from pg_indexes where tablename = 'game_reports' and indexname in
('game_reports_owner_created_idx', 'game_reports_provisional_game_id_key', 'game_reports_target_game_id_idx')), 3::bigint,
'Indizes für Tageslimit und beide Fremdschlüssel auf games vorhanden');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id)
values ('11111111-1111-1111-1111-111111111111', 'Zweite Meldung zum selben Eintrag', '7c1d2c3d-0000-4000-8000-000000000003')$$, '23505', null,
'Ein vorläufiger Eintrag gehört zu höchstens einer Meldung (1:1)');
select lives_ok($$insert into public.game_reports (owner_id, title, target_game_id, status, resolved_at) values
('22222222-2222-2222-2222-222222222222', 'Gleiches Ziel 1', '7c1d2c3d-0000-4000-8000-000000000004', 'merged', now()),
('22222222-2222-2222-2222-222222222222', 'Gleiches Ziel 2', '7c1d2c3d-0000-4000-8000-000000000004', 'merged', now())$$,
'Mehrere Meldungen dürfen dasselbe Ziel haben, und mehrere dürfen ohne vorläufigen Eintrag sein');
-- ---------------------------------------------------------------------------------------------
-- Fremdschlüssel-Regeln
-- ---------------------------------------------------------------------------------------------
insert into public.games (id, title, visibility, owner_id) values
('f8000000-0000-0000-0000-000000000c01', 'Wird gelöscht', 'private', '22222222-2222-2222-2222-222222222222');
insert into public.game_reports (id, owner_id, title, provisional_game_id) values
('f8000000-0000-0000-0000-000000000c02', '22222222-2222-2222-2222-222222222222', 'Vorläufiger Eintrag verschwindet', 'f8000000-0000-0000-0000-000000000c01');
delete from public.games where id = 'f8000000-0000-0000-0000-000000000c01';
select is((select count(*) || ':' || count(provisional_game_id) from public.game_reports where id = 'f8000000-0000-0000-0000-000000000c02'), '1:0',
'Fremdschlüssel: wird der vorläufige Eintrag gelöscht, bleibt die Meldung und der Verweis wird leer (on delete set null)');
-- eigenes Ziel, auf das nur eine Meldung verweist (sonst würde ein anderer Verweis die Prüfung vortäuschen)
insert into public.games (id, title, visibility) values ('f8000000-0000-0000-0000-000000000c03', 'Ziel nur für eine Meldung', 'catalog');
insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values
('f8000000-0000-0000-0000-000000000c04', '22222222-2222-2222-2222-222222222222', 'Verweist auf Ziel', 'rejected', now(), 'f8000000-0000-0000-0000-000000000c03');
select throws_ok($$delete from public.games where id = 'f8000000-0000-0000-0000-000000000c03'$$, '23503', null,
'Fremdschlüssel: ein Ziel mit Verweis aus einer Meldung lässt sich nicht löschen (on delete restrict)');
select is((select target_game_id from public.game_reports where id = 'f8000000-0000-0000-0000-000000000c04'), 'f8000000-0000-0000-0000-000000000c03'::uuid,
'Der Verweis der Meldung auf das Ziel bleibt bestehen');
select throws_ok($$insert into public.game_reports (owner_id, title) values (gen_random_uuid(), 'Ohne Konto')$$, '23503', null,
'Fremdschlüssel: Meldung braucht ein vorhandenes Profil');
-- ---------------------------------------------------------------------------------------------
-- Trigger games_visibility_immutable (E14), KAT-05 Kriterium 9
-- ---------------------------------------------------------------------------------------------
select is_empty($$select p.proname, r from pg_proc p join pg_namespace n on n.oid = p.pronamespace,
unnest(array['anon', 'authenticated', 'service_role']) as r
where n.nspname = 'public' and p.proname in ('games_visibility_immutable', 'admin_resolve_report')
and has_function_privilege(r, p.oid, 'execute')$$,
'KAT-05 Kriterium 9: games_visibility_immutable und admin_resolve_report sind für anon, authenticated und service_role nicht ausführbar');
select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname in ('games_visibility_immutable', 'admin_resolve_report', 'admin_merge_games')
and not p.prosecdef and p.proconfig = array['search_path=""']), 3::bigint,
'Alle drei Funktionen sind security invoker mit leerem search_path');
-- als postgres: private -> Katalog (mit allem, was der Check games_owner_matches_visibility verlangt)
select throws_ok($$update public.games set visibility = 'catalog', owner_id = null where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch postgres kann einen privaten Eintrag nicht öffentlich schalten');
select throws_ok($$update public.games set visibility = 'catalog' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch nur visibility allein wird abgelehnt');
select throws_ok($$update public.games set visibility = 'private', owner_id = '11111111-1111-1111-1111-111111111111' where id = '7c1d2c3d-0000-4000-8000-000000000004'$$,
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch postgres kann ein Katalogspiel nicht privat schalten');
select throws_ok($$update public.games set owner_id = '22222222-2222-2222-2222-222222222222' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: der Eigentümer eines privaten Spiels ist unveränderlich');
select throws_ok($$update public.games set owner_id = null where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch ein leerer Eigentümer wird abgelehnt');
select is((select visibility || ':' || owner_id::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'),
'private:11111111-1111-1111-1111-111111111111', 'Nach den Fehlern ist das Spiel unverändert');
select lives_ok($$update public.games set title = 'Meldungsspiel', visibility = 'private' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
'Positiv: dieselbe Sichtbarkeit nochmals zu setzen ist keine Änderung');
select lives_ok($$update public.games set year = 2022 where id = '7c1d2c3d-0000-4000-8000-000000000004'$$,
'Positiv: andere Felder eines Katalogspiels bleiben als postgres änderbar');
update public.games set year = null where id = '7c1d2c3d-0000-4000-8000-000000000004';
-- als Nutzer: der Trigger läuft vor der RLS-Prüfung, deshalb P0001 statt 42501
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;
select throws_ok($$update public.games set visibility = 'catalog' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: Anna kann ihren privaten Eintrag nicht öffentlich schalten (Trigger vor RLS)');
select lives_ok($$update public.games set title = 'Meldungsspiel (neu)' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
'Positiv: Anna ändert den Titel ihres privaten Eintrags weiterhin');
update public.games set title = 'Meldungsspiel' where id = '7c1d2c3d-0000-4000-8000-000000000003';
reset role;
select is((select visibility from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 'private',
'Annas Eintrag ist weiterhin privat');
-- ---------------------------------------------------------------------------------------------
-- admin_resolve_report: Rechte (KAT-05 Kriterium 9)
-- ---------------------------------------------------------------------------------------------
select set_config('request.jwt.claims',
'{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"role":"admin"}}', true);
set local role authenticated;
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null,
'KAT-05 Kriterium 9: authenticated (auch mit app_metadata.role = admin) kann admin_resolve_report nicht aufrufen');
reset role;
set local role anon;
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null,
'KAT-05 Kriterium 9: anon kann admin_resolve_report nicht aufrufen');
reset role;
set local role service_role;
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null,
'KAT-05 Kriterium 9: service_role kann admin_resolve_report nicht aufrufen');
reset role;
select is((select status from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), 'open',
'Die verweigerten Aufrufe haben nichts verändert');
-- Die Rollenprüfung im Funktionskörper: eine Rolle MIT execute-Recht, aber ohne postgres/supabase_admin
create role report_tester nologin;
grant report_tester to postgres with set true;
grant usage on schema public to report_tester;
grant execute on function public.admin_resolve_report(uuid, text, uuid) to report_tester;
set local role report_tester;
do $$
begin
perform public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected');
perform set_config('t.resolve_err', 'kein Fehler', true);
exception when others then
perform set_config('t.resolve_err', sqlstate || ':' || sqlerrm, true);
end
$$;
reset role;
select is(current_setting('t.resolve_err'), 'P0001:admin_only',
'KAT-05 Kriterium 9: admin_resolve_report prüft die Datenbankrolle (admin_only)');
select is((select status from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), 'open',
'admin_only ändert nichts');
-- ---------------------------------------------------------------------------------------------
-- admin_resolve_report: Fehlerfälle (alle ohne Änderung)
-- ---------------------------------------------------------------------------------------------
insert into public.games (id, title, visibility, hidden_at) values
('f8000000-0000-0000-0000-000000000d01', 'Ziel versteckt', 'catalog', '2020-01-01');
insert into public.games (id, title, visibility, hidden_at, merged_into_id) values
('f8000000-0000-0000-0000-000000000d02', 'Ziel zusammengeführt', 'catalog', '2020-01-01', '7c1d2c3d-0000-4000-8000-000000000004');
insert into public.games (id, title, visibility, owner_id) values
('f8000000-0000-0000-0000-000000000d03', 'Ziel privat', 'private', '22222222-2222-2222-2222-222222222222');
create temp table rb_before as
select (select count(*) from public.game_reports where status <> 'open') as erledigt,
(select count(*) from public.games where hidden_at is not null) as ausgeblendet,
(select string_agg(id::text || coalesce(target_game_id::text, '-') || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%') as meldungen;
select throws_ok($$select public.admin_resolve_report(gen_random_uuid(), 'rejected')$$, 'P0001', 'report_not_found',
'KAT-05 Kriterium 5: unbekannte Meldung (report_not_found)');
select throws_ok($$select public.admin_resolve_report(null, 'rejected')$$, 'P0001', 'report_not_found',
'KAT-05 Kriterium 5: fehlende Meldung (null) ist report_not_found');
select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000a04', 'rejected')$$, 'P0001', 'report_not_open',
'KAT-05 Kriterium 5: bereits abgelehnte Meldung (report_not_open)');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'open')$$, 'P0001', 'report_status_invalid',
'KAT-05 Kriterium 5: Status open ist kein Ergebnis (report_status_invalid)');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'fertig')$$, 'P0001', 'report_status_invalid',
'KAT-05 Kriterium 5: unbekannter Status (report_status_invalid)');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', null)$$, 'P0001', 'report_status_invalid',
'KAT-05 Kriterium 5: fehlender Status (null) ist report_status_invalid');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'fertig', null)$$, 'P0001', 'report_status_invalid',
'KAT-05 Kriterium 5: ungültiger Status geht vor fehlendem Ziel');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved')$$, 'P0001', 'report_target_required',
'KAT-05 Kriterium 5: approved ohne Ziel (report_target_required)');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'merged', null)$$, 'P0001', 'report_target_required',
'KAT-05 Kriterium 5: merged ohne Ziel (report_target_required)');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-000000000d01')$$,
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: ausgeblendetes Ziel (report_target_not_listed)');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'merged', 'f8000000-0000-0000-0000-000000000d02')$$,
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: zusammengeführtes Ziel (report_target_not_listed)');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-000000000d03')$$,
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: privates Ziel (report_target_not_listed)');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-0000000000ff')$$,
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: unbekanntes Ziel (report_target_not_listed)');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected', 'f8000000-0000-0000-0000-000000000d01')$$,
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: auch bei rejected wird das Ziel geprüft');
-- Meldung MIT vorläufigem Eintrag und ungültigem Ziel: die Zielprüfung der Funktion greift zuerst
-- (sonst käme repoint_target_not_listed aus der internen Funktion)
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', 'f8000000-0000-0000-0000-000000000d01')$$,
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: Meldung mit vorläufigem Eintrag und ausgeblendetem Ziel (report_target_not_listed)');
-- Datenfehler: der vorläufige Eintrag gehört einem anderen Nutzer (Zusatzprüfung gegenüber der Spec)
insert into public.games (id, title, visibility, owner_id) values
('f8000000-0000-0000-0000-000000000e11', 'Annas fremdes Spiel', 'private', '11111111-1111-1111-1111-111111111111');
insert into public.collection_items (id, owner_id, game_id, list) values
('f8000000-0000-0000-0000-000000000e12', '11111111-1111-1111-1111-111111111111', 'f8000000-0000-0000-0000-000000000e11', 'owned');
insert into public.game_reports (id, owner_id, title, provisional_game_id) values
('f8000000-0000-0000-0000-000000000e02', '22222222-2222-2222-2222-222222222222', 'Bens Meldung auf Annas Spiel', 'f8000000-0000-0000-0000-000000000e11');
select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000e02', 'merged', '7c1d2c3d-0000-4000-8000-000000000004')$$,
'P0001', 'report_provisional_not_private', 'Zusatzprüfung: ein privates Spiel eines anderen Nutzers wird nicht umgehängt');
select is((select game_id::text || ':' || (select hidden_at is null and merged_into_id is null from public.games where id = 'f8000000-0000-0000-0000-000000000e11')::text
from public.collection_items where id = 'f8000000-0000-0000-0000-000000000e12'),
'f8000000-0000-0000-0000-000000000e11:true', 'Annas Daten und ihr Spiel bleiben unverändert');
-- Datenfehler: vorläufiger Eintrag ist ein Katalogspiel (Zusatzprüfung gegenüber der Spec)
insert into public.game_reports (id, owner_id, title, provisional_game_id) values
('f8000000-0000-0000-0000-000000000e01', '22222222-2222-2222-2222-222222222222', 'Vorläufig ist Katalog', 'f8000000-0000-0000-0000-00000000000e');
select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000e01', 'merged', '7c1d2c3d-0000-4000-8000-000000000004')$$,
'P0001', 'report_provisional_not_private', 'Zusatzprüfung: ein Katalogspiel als vorläufiger Eintrag wird nicht umgehängt');
select is((select status from public.game_reports where id = 'f8000000-0000-0000-0000-000000000e01'), 'open',
'Nach report_provisional_not_private ist die Meldung weiterhin offen');
select is((select hidden_at is null and merged_into_id is null from public.games where id = 'f8000000-0000-0000-0000-00000000000e'), true,
'Das Katalogspiel wurde nicht verändert');
select is((select row(erledigt, ausgeblendet, meldungen)::text from rb_before),
(select row((select count(*) from public.game_reports where status <> 'open'),
(select count(*) from public.games where hidden_at is not null),
(select string_agg(id::text || coalesce(target_game_id::text, '-') || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%'))::text),
'Nach allen Fehlerfällen ist nichts verändert (Meldungen, Ausblendungen)');
-- ---------------------------------------------------------------------------------------------
-- Runbook Abschnitt 6, Schritt 1 und 2 (SQL-Bausteine unverändert)
-- ---------------------------------------------------------------------------------------------
-- Vorbereitung für Schritt 2 (ähnliche Spiele): Titel gleich bzw. BGG-Kennung 4711 am Katalogspiel
-- (Fixtures oben: MELDUNGSSPIEL, Anderer Titel)
-- Schritt 1: Offene Meldungen
create temp table rb_open as
select r.id, r.created_at, r.origin, r.title, r.publisher_name, r.year, r.link, r.bgg_id, r.provisional_game_id
from public.game_reports r
where r.status = 'open'
order by r.created_at, r.id;
select results_eq($$select id from rb_open where id::text like '7c1d2c3d%' order by created_at, id$$,
$$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid), ('7c1d2c3d-0000-4000-8000-000000000006'), ('7c1d2c3d-0000-4000-8000-000000000005'),
('7c1d2c3d-0000-4000-8000-000000000007'), ('7c1d2c3d-0000-4000-8000-00000000000b')$$,
'KAT-05 Kriterium 8: Runbook 6, offene Meldungen (älteste zuerst)');
select is((select row(origin, title, publisher_name, year, link, bgg_id, provisional_game_id)::text from rb_open where id = '7c1d2c3d-0000-4000-8000-000000000001'),
'(user,Meldungsspiel,Beispielverlag,2024,https://example.org/spiel,4711,7c1d2c3d-0000-4000-8000-000000000003)',
'KAT-05 Kriterium 8: Runbook 6, die Spalten der Meldung stehen in der Liste');
-- Schritt 2: Ähnliche Spiele im Katalog
create temp table rb_similar as
select g.id, g.title, g.year, g.bgg_id
from public.game_reports r
join public.catalog_games g
on lower(btrim(g.title)) = lower(btrim(r.title))
or (r.bgg_id is not null and g.bgg_id = r.bgg_id)
where r.id = '7c1d2c3d-0000-4000-8000-000000000001'
order by g.title, g.id;
select results_eq($$select id, title, year, bgg_id from rb_similar$$,
$$values ('f8000000-0000-0000-0000-00000000000d'::uuid, 'Anderer Titel'::text, 2020::smallint, 4711::integer),
('f8000000-0000-0000-0000-00000000000c', 'MELDUNGSSPIEL', 2023::smallint, null::integer)$$,
'KAT-05 Kriterium 8: Runbook 6, ähnliche Spiele (gleicher Titel ohne Groß-/Kleinschreibung oder gleiche BGG-Kennung)');
-- Schritt 2: Was hängt am vorläufigen Eintrag?
create temp table rb_counts as
select g.id, g.title,
(select count(*) from public.collection_items c where c.game_id = g.id) as sammlung,
(select count(*) from public.favorite_games f where f.game_id = g.id) as favoriten,
(select count(*) from public.plays p where p.game_id = g.id) as partien,
(select count(*) from public.score_sheets s where s.game_id = g.id) as wertungsboegen
from public.game_reports r
join public.games g on g.id = r.provisional_game_id
where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
select results_eq($$select id, title, sammlung, favoriten, partien, wertungsboegen from rb_counts$$,
$$values ('7c1d2c3d-0000-4000-8000-000000000003'::uuid, 'Meldungsspiel'::text, 1::bigint, 1::bigint, 2::bigint, 1::bigint)$$,
'KAT-05 Kriterium 8: Runbook 6, Anzahlen am vorläufigen Eintrag');
-- Schritt 2: Editionen des vorläufigen Eintrags
create temp table rb_editions as
select v.id, v.name, v.year, v.language
from public.game_reports r
join public.game_variants v on v.game_id = r.provisional_game_id
where r.id = '7c1d2c3d-0000-4000-8000-000000000001'
order by v.name, v.id;
select results_eq($$select id, name from rb_editions$$,
$$values ('f8000000-0000-0000-0000-0000000000e1'::uuid, 'Deluxe'::text), ('f8000000-0000-0000-0000-0000000000e2', 'Prototyp')$$,
'KAT-05 Kriterium 8: Runbook 6, Namen der Editionen des vorläufigen Eintrags');
-- ---------------------------------------------------------------------------------------------
-- Runbook Abschnitt 6, Schritt 3a: Freigeben (neuer Katalogeintrag, nur Fakten aus der Meldung)
-- ---------------------------------------------------------------------------------------------
insert into public.games (id, title, year, description, description_locale, visibility)
values ('7c1d2c3d-0000-4000-8000-000000000002', 'Meldungsspiel', 2024, 'Eigener Kurztext des Admins.', 'de', 'catalog');
insert into public.publishers (name) values ('Beispielverlag') on conflict ((lower(name))) do nothing;
insert into public.game_publishers (game_id, publisher_id)
select '7c1d2c3d-0000-4000-8000-000000000002', p.id from public.publishers p where lower(p.name) = lower('Beispielverlag');
insert into public.game_variants (game_id, name, year) values ('7c1d2c3d-0000-4000-8000-000000000002', 'Deluxe', 2024);
select is((select manual_fields from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'),
array['description', 'description_locale', 'publishers', 'title', 'year'],
'KAT-05 Kriterium 8: Runbook 6, der neue Katalogeintrag vermerkt seine Felder in manual_fields');
-- Ben hat das neue Spiel nicht; Bens Daten am vorhandenen Ziel (…04) merken, sie dürfen sich nicht ändern
create temp table ben_before as
select c.id, c.game_id, c.variant_id, c.list, c.note from public.collection_items c where c.owner_id = '22222222-2222-2222-2222-222222222222'
union all
select p.id, p.game_id, p.variant_id, 'play', null from public.plays p where p.owner_id = '22222222-2222-2222-2222-222222222222';
create temp table rb_approve as
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', '7c1d2c3d-0000-4000-8000-000000000002') as r;
select is((select r - 'report_id' from rb_approve),
jsonb_build_object(
'status', 'approved', 'target_id', '7c1d2c3d-0000-4000-8000-000000000002',
'provisional_game_id', '7c1d2c3d-0000-4000-8000-000000000003', 'repointed', true,
'collection_items', 1, 'collection_conflicts', 0, 'favorite_games', 1, 'favorite_conflicts', 0,
'plays', 2, 'score_sheets', 1, 'variants_copied', 0, 'variants_mapped', 1, 'variants_cleared', 1),
'KAT-05 Kriterium 5: Runbook 6, approved: Ergebnis-JSON zählt alles richtig (nichts kopiert, eine Edition abgebildet, eine geleert)');
select is((select r ->> 'report_id' from rb_approve), '7c1d2c3d-0000-4000-8000-000000000001', 'Das Ergebnis nennt die Meldung');
-- Schritt 4: Nachprüfen
create temp table rb_check as
select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, r.editions_cleared,
g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id
from public.game_reports r
left join public.games g on g.id = r.provisional_game_id
where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
select results_eq($$select id, status, target_game_id, erledigt, editions_cleared, vorlaeufig_ausgeblendet, merged_into_id from rb_check$$,
$$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid, 'approved'::text, '7c1d2c3d-0000-4000-8000-000000000002'::uuid, true, 1, true, '7c1d2c3d-0000-4000-8000-000000000002'::uuid)$$,
'KAT-05 Kriterium 8: Runbook 6, Nachprüfen: Meldung freigegeben, vorläufiger Eintrag ausgeblendet und verwiesen');
select is((select row(visibility, owner_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'),
'(private,11111111-1111-1111-1111-111111111111)', 'KAT-05 Kriterium 5: der vorläufige Eintrag bleibt privat und gehört weiter Anna (E14)');
select is((select row(description, image_path)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'),
'("Eigener Kurztext des Admins.",)', 'Beschreibung und Bild des vorläufigen Eintrags werden nie übernommen');
select is((select row(visibility, owner_id, hidden_at is null, merged_into_id is null)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'),
'(catalog,,t,t)', 'Das neue Katalogspiel ist sichtbar und gehört niemandem');
select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4000-8000-000000000002'), 1::bigint,
'Das neue Katalogspiel erscheint in catalog_games');
select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 0::bigint,
'Der vorläufige Eintrag erscheint nie in catalog_games');
select is((select resolved_at is not null and updated_at > '2026-01-01' from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'), true,
'resolved_at ist gesetzt');
-- Verweise
select is((select row(game_id, variant_id, list, note)::text from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c1'),
'(7c1d2c3d-0000-4000-8000-000000000002,,owned,"Annas Notiz")',
'RB1: Sammlungseintrag am neuen Spiel, Edition "Prototyp" ohne Gegenstück ist geleert');
select is((select row(p.game_id, v.name, v.game_id)::text from public.plays p join public.game_variants v on v.id = p.variant_id
where p.id = 'f8000000-0000-0000-0000-0000000000b1'),
'(7c1d2c3d-0000-4000-8000-000000000002,Deluxe,7c1d2c3d-0000-4000-8000-000000000002)',
'RB1: die Edition "Deluxe" der Partie zeigt auf die vom Admin angelegte gleichnamige Variante am Katalogeintrag');
select is((select row(game_id, variant_id)::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b2'),
'(7c1d2c3d-0000-4000-8000-000000000002,)', 'Partie ohne Edition wechselt auf das neue Spiel');
select is((select game_id from public.favorite_games where id = 'f8000000-0000-0000-0000-0000000000d1'), '7c1d2c3d-0000-4000-8000-000000000002'::uuid,
'Favorit wechselt auf das neue Spiel');
select is((select game_id from public.score_sheets where id = 'f8000000-0000-0000-0000-0000000000b5'), '7c1d2c3d-0000-4000-8000-000000000002'::uuid,
'Wertungsbogen wechselt auf das neue Spiel');
select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000002'), 1::bigint,
'RB1: am Katalogeintrag gibt es nur die vom Admin angelegte Variante, die private wurde nicht kopiert');
select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000003'), 2::bigint,
'RB1: die privaten Varianten bleiben am vorläufigen Eintrag');
select is((select count(*) from public.collection_items where game_id = '7c1d2c3d-0000-4000-8000-000000000003'), 0::bigint,
'Nichts hängt mehr am vorläufigen Eintrag');
-- ---------------------------------------------------------------------------------------------
-- Runbook Abschnitt 6, Schritt 3b: Zusammenführen mit einem vorhandenen Eintrag (Kollision)
-- ---------------------------------------------------------------------------------------------
create temp table rb_merged as
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004') as r;
select is((select r - 'report_id' from rb_merged),
jsonb_build_object(
'status', 'merged', 'target_id', '7c1d2c3d-0000-4000-8000-000000000004',
'provisional_game_id', '7c1d2c3d-0000-4000-8000-000000000008', 'repointed', true,
'collection_items', 0, 'collection_conflicts', 1, 'favorite_games', 0, 'favorite_conflicts', 0,
'plays', 0, 'score_sheets', 0, 'variants_copied', 0, 'variants_mapped', 1, 'variants_cleared', 0),
'KAT-05 Kriterium 5: Runbook 6, merged: Kollision (Anna hatte das Ziel schon), gleichnamige Variante abgebildet');
select is((select row(status, target_game_id)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000005'),
'(merged,7c1d2c3d-0000-4000-8000-000000000004)', 'merged: Status und Ziel gespeichert');
select is((select row(hidden_at is not null, merged_into_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000008'),
'(t,7c1d2c3d-0000-4000-8000-000000000004)', 'merged: der vorläufige Eintrag ist ausgeblendet und verwiesen');
select is((select row(list, note, variant_id)::text from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c3'),
'(owned,"Annas Wunsch",f8000000-0000-0000-0000-0000000000a4)',
'Spec-F1: Anna: der Zieleintrag gewinnt (owned vor wishlist), die leere Notiz und Edition füllt der vorläufige Eintrag (Edition über die gleichnamige Variante)');
select is((select count(*) from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c2'), 0::bigint,
'Spec-F1: der Quell-Eintrag der Kollision ist gelöscht');
select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000004'), 1::bigint,
'RB1: das vorhandene Katalogspiel bekommt keine Variante');
select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000008'), 1::bigint,
'RB1: die private Variante bleibt am vorläufigen Eintrag');
select results_eq($$select c.id, c.game_id, c.variant_id, c.list, c.note from public.collection_items c
where c.owner_id = '22222222-2222-2222-2222-222222222222'
union all select p.id, p.game_id, p.variant_id, 'play', null from public.plays p where p.owner_id = '22222222-2222-2222-2222-222222222222'$$,
$$select id, game_id, variant_id, list, note from ben_before$$,
'Daten anderer Nutzer am Ziel (Bens Sammlung und Partie) sind unverändert');
-- ---------------------------------------------------------------------------------------------
-- Runbook Abschnitt 6, Schritt 3c: Ablehnen
-- ---------------------------------------------------------------------------------------------
create temp table rb_rej_snapshot as
select 'c5' as k, game_id::text || coalesce(variant_id::text, '') || list as v from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c5'
union all select 'c6', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c6'
union all select 'b4', game_id::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b4'
union all select 'g9', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-000000000009'
union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-00000000000a';
create temp table rb_rej1 as
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected') as r;
create temp table rb_rej2 as
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004') as r;
select is((select r - 'report_id' from rb_rej1),
'{"status":"rejected","target_id":null,"provisional_game_id":"7c1d2c3d-0000-4000-8000-000000000009","repointed":false}'::jsonb,
'KAT-05 Kriterium 5: Runbook 6, rejected ohne Ziel: nichts umgehängt, keine Zähler');
select is((select r - 'report_id' from rb_rej2),
'{"status":"rejected","target_id":"7c1d2c3d-0000-4000-8000-000000000004","provisional_game_id":"7c1d2c3d-0000-4000-8000-00000000000a","repointed":false}'::jsonb,
'KAT-05 Kriterium 5: Runbook 6, rejected mit Ziel: nur der Verweis, nichts umgehängt');
select is((select row(status, target_game_id, resolved_at is not null)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000006'),
'(rejected,,t)', 'rejected ohne Ziel: Status und Zeitpunkt gespeichert, kein Ziel');
select is((select row(status, target_game_id, resolved_at is not null)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000007'),
'(rejected,7c1d2c3d-0000-4000-8000-000000000004,t)', 'rejected mit Ziel: der Verweis auf den Zieleintrag ist gespeichert (Spec-F3)');
select results_eq($$select 'c5', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c5'
union all select 'c6', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c6'
union all select 'b4', game_id::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b4'
union all select 'g9', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-000000000009'
union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-00000000000a'$$,
$$select k, v from rb_rej_snapshot$$,
'KAT-05 Kriterium 5: bei rejected (mit und ohne Ziel) bleiben vorläufige Einträge, Sammlung und Partien unverändert');
-- approved ohne vorläufigen Eintrag: nur Status und Ziel
create temp table rb_noprov as
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', '7c1d2c3d-0000-4000-8000-000000000004') as r;
select is((select r - 'report_id' from rb_noprov),
'{"status":"approved","target_id":"7c1d2c3d-0000-4000-8000-000000000004","provisional_game_id":null,"repointed":false}'::jsonb,
'approved ohne vorläufigen Eintrag: nichts umzuhängen (repointed = false)');
select is((select row(status, target_game_id)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'),
'(approved,7c1d2c3d-0000-4000-8000-000000000004)', 'approved ohne vorläufigen Eintrag: Status und Ziel gespeichert');
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'rejected')$$, 'P0001', 'report_not_open',
'KAT-05 Kriterium 5: eine freigegebene Meldung lässt sich nicht erneut bearbeiten');
-- editions_cleared nach allen Abschlüssen (erst jetzt sind …05, …06, …07 und …0b erledigt)
select is((select string_agg(id::text || '=' || editions_cleared::text, ',' order by id) from public.game_reports
where id in ('7c1d2c3d-0000-4000-8000-000000000001', '7c1d2c3d-0000-4000-8000-000000000005', '7c1d2c3d-0000-4000-8000-000000000006',
'7c1d2c3d-0000-4000-8000-000000000007', '7c1d2c3d-0000-4000-8000-00000000000b')),
'7c1d2c3d-0000-4000-8000-000000000001=1,7c1d2c3d-0000-4000-8000-000000000005=0,7c1d2c3d-0000-4000-8000-000000000006=0,7c1d2c3d-0000-4000-8000-000000000007=0,7c1d2c3d-0000-4000-8000-00000000000b=0',
'Spec-F6: editions_cleared = 1 bei approved mit geleerter Edition (…01), 0 bei merged ohne geleerte Edition, bei rejected (mit und ohne Ziel) und ohne vorläufigen Eintrag');
-- Der Nutzer sieht das Ergebnis seiner Meldung (KAT-04 Krit. 4-6), aber keine fremden
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;
select is((select status || ':' || target_game_id::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'),
'approved:7c1d2c3d-0000-4000-8000-000000000002', 'Anna sieht das Ergebnis ihrer Meldung');
select is((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), 0::bigint,
'Anna sieht weiterhin keine fremden Meldungen');
select cmp_ok((select count(*) from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), '=', 1::bigint,
'Anna sieht ihren ausgeblendeten vorläufigen Eintrag weiterhin');
reset role;
-- ---------------------------------------------------------------------------------------------
-- admin_merge_games nach dem create or replace (Schritt 4: Meldungen umhängen) und Rechte
-- ---------------------------------------------------------------------------------------------
select is_empty($$select p.proname, r from pg_proc p join pg_namespace n on n.oid = p.pronamespace,
unnest(array['anon', 'authenticated', 'service_role']) as r
where n.nspname = 'public' and p.proname in ('admin_merge_games', 'repoint_game_references')
and has_function_privilege(r, p.oid, 'execute')$$,
'KAT-05 Kriterium 9: nach dem create or replace sind admin_merge_games und repoint_game_references für anon, authenticated und service_role nicht ausführbar');
select set_config('request.jwt.claims',
'{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"role":"admin"}}', true);
set local role authenticated;
select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null,
'KAT-05 Kriterium 9: authenticated (auch mit app_metadata.role = admin) kann admin_merge_games weiterhin nicht aufrufen');
reset role;
set local role anon;
select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null,
'KAT-05 Kriterium 9: anon kann admin_merge_games weiterhin nicht aufrufen');
reset role;
set local role service_role;
select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null,
'KAT-05 Kriterium 9: service_role kann admin_merge_games weiterhin nicht aufrufen');
reset role;
create role merge_tester nologin;
grant merge_tester to postgres with set true;
grant usage on schema public to merge_tester;
grant execute on function public.admin_merge_games(uuid, uuid) to merge_tester;
set local role merge_tester;
do $$
begin
perform public.admin_merge_games('f8000000-0000-0000-0000-000000000f01', 'f8000000-0000-0000-0000-000000000f02');
perform set_config('t.merge_err', 'kein Fehler', true);
exception when others then
perform set_config('t.merge_err', sqlstate || ':' || sqlerrm, true);
end
$$;
reset role;
select is(current_setting('t.merge_err'), 'P0001:admin_only',
'KAT-05 Kriterium 9: admin_merge_games prüft nach dem create or replace weiterhin die Datenbankrolle');
-- Schritt 4: Quelle (…f01) -> Ziel (…f02); Meldungen mit Ziel = Quelle ziehen mit
insert into public.games (id, title, visibility) values
('f8000000-0000-0000-0000-000000000f01', 'Merge Quelle', 'catalog'),
('f8000000-0000-0000-0000-000000000f02', 'Merge Ziel', 'catalog'),
('f8000000-0000-0000-0000-000000000f03', 'Anderes Ziel', 'catalog');
insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values
('f8000000-0000-0000-0000-000000000f11', '22222222-2222-2222-2222-222222222222', 'Freigegeben auf Quelle', 'approved', now(), 'f8000000-0000-0000-0000-000000000f01'),
('f8000000-0000-0000-0000-000000000f12', '22222222-2222-2222-2222-222222222222', 'Abgelehnt mit Verweis auf Quelle', 'rejected', now(), 'f8000000-0000-0000-0000-000000000f01'),
('f8000000-0000-0000-0000-000000000f13', '22222222-2222-2222-2222-222222222222', 'Zusammengeführt mit anderem', 'merged', now(), 'f8000000-0000-0000-0000-000000000f03');
insert into public.game_reports (id, owner_id, title) values
('f8000000-0000-0000-0000-000000000f14', '22222222-2222-2222-2222-222222222222', 'Offen');
create temp table rb_mg as select public.admin_merge_games('f8000000-0000-0000-0000-000000000f01', 'f8000000-0000-0000-0000-000000000f02') as r;
select is((select r -> 'reports_repointed' from rb_mg), '2'::jsonb, 'KAT-05 Kriterium 4: admin_merge_games meldet 2 umgehängte Meldungen');
select results_eq($$select id, target_game_id from public.game_reports where id::text like 'f8000000-0000-0000-0000-000000000f1%' order by id$$,
$$values ('f8000000-0000-0000-0000-000000000f11'::uuid, 'f8000000-0000-0000-0000-000000000f02'::uuid),
('f8000000-0000-0000-0000-000000000f12', 'f8000000-0000-0000-0000-000000000f02'),
('f8000000-0000-0000-0000-000000000f13', 'f8000000-0000-0000-0000-000000000f03'),
('f8000000-0000-0000-0000-000000000f14', null)$$,
'KAT-05 Kriterium 4: Meldungen mit Ziel = Quelle zeigen auf das Ziel, andere Meldungen bleiben unverändert');
select is((select row(status, resolved_at is not null)::text from public.game_reports where id = 'f8000000-0000-0000-0000-000000000f11'), '(approved,t)',
'Das Umhängen ändert Status und Zeitpunkt der Meldung nicht');
select is(public.admin_merge_games('f8000000-0000-0000-0000-000000000f03', 'f8000000-0000-0000-0000-000000000f02') -> 'reports_repointed', '1'::jsonb,
'Ein weiteres Zusammenführen hängt die nächste Meldung um');
select lives_ok($$delete from public.games where id = 'f8000000-0000-0000-0000-000000000f01'$$,
'Positiv: Die Quelle ist nach dem Umhängen nicht mehr Ziel einer Meldung und lässt sich löschen (keine verwaiste Restrict-Sperre)');
-- ---------------------------------------------------------------------------------------------
-- Fremdschlüssel-Wächter (wie in 07): jeder Verweis auf games ist behandelt oder bewusst ausgelassen
-- ---------------------------------------------------------------------------------------------
create temp table fk_found as
select (select relname from pg_class where oid = c.conrelid) || '.' ||
(select attname from pg_attribute where attrelid = c.conrelid and attnum = c.conkey[1]) as fk
from pg_constraint c
where c.contype = 'f' and c.confrelid = 'public.games'::regclass;
select is((select count(*) from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.games'::regclass and cardinality(c.conkey) > 1), 0::bigint,
'Fremdschlüssel-Wächter: alle Verweise auf games sind einspaltig');
select set_eq($$select fk from fk_found$$,
$$values
-- 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'), ('game_reports.target_game_id'),
-- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag
('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'),
-- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht
('game_reports.provisional_game_id')$$,
'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen');
-- ---------------------------------------------------------------------------------------------
-- Konto löschen nach Freigabe (QUE-04, Klärung 10; Spec 9.5 KTO-05)
-- Anna: freigegebene Meldung (neu angelegter Katalogeintrag), zusammengeführte Meldung, ausgeblendete
-- vorläufige Einträge mit merged_into_id auf Katalogspiele.
-- ---------------------------------------------------------------------------------------------
insert into public.collection_items (id, owner_id, game_id, list, note) values
('f8000000-0000-0000-0000-0000000000c7', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000002', 'owned', 'Bens Notiz am neuen Spiel');
create temp table other_before as
select 'game2' as k, row(g.title, g.year, g.description, g.hidden_at, g.merged_into_id, g.visibility, g.manual_fields)::text as v
from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000002'
union all select 'game4', row(g.title, g.year, g.hidden_at, g.merged_into_id, g.visibility)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000004'
union all select 'ben_c7', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c7'
union all select 'ben_c4', row(c.game_id, c.variant_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c4'
union all select 'cara_report', row(r.status, r.target_game_id)::text from public.game_reports r where r.id = '7c1d2c3d-0000-4000-8000-000000000007'
union all select 'cara_c5', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c5'
union all select 'variants', (select count(*) from public.game_variants where game_id in ('7c1d2c3d-0000-4000-8000-000000000002', '7c1d2c3d-0000-4000-8000-000000000004'))::text;
select cmp_ok((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), '=', 2::bigint,
'Vorbedingung: Anna hat zwei bearbeitete Meldungen');
select cmp_ok((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111' and merged_into_id is not null), '=', 2::bigint,
'Vorbedingung: Anna hat zwei ausgeblendete vorläufige Einträge mit merged_into_id');
select lives_ok($$delete from auth.users where id = '11111111-1111-1111-1111-111111111111'$$,
'KAT-05 Kriterium 5 / QUE-04: das Konto der Meldenden lässt sich nach der Freigabe löschen');
select is((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint,
'QUE-04: die Meldungen sind mit dem Konto gelöscht');
select is((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint,
'QUE-04: die vorläufigen Einträge sind mit dem Konto gelöscht');
select is((select count(*) from public.collection_items where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint,
'QUE-04: Annas Sammlung (auch die umgehängten Einträge) ist gelöscht');
select results_eq($$select 'game2', row(g.title, g.year, g.description, g.hidden_at, g.merged_into_id, g.visibility, g.manual_fields)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000002'
union all select 'game4', row(g.title, g.year, g.hidden_at, g.merged_into_id, g.visibility)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000004'
union all select 'ben_c7', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c7'
union all select 'ben_c4', row(c.game_id, c.variant_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c4'
union all select 'cara_report', row(r.status, r.target_game_id)::text from public.game_reports r where r.id = '7c1d2c3d-0000-4000-8000-000000000007'
union all select 'cara_c5', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c5'
union all select 'variants', (select count(*) from public.game_variants where game_id in ('7c1d2c3d-0000-4000-8000-000000000002', '7c1d2c3d-0000-4000-8000-000000000004'))::text$$,
$$select k, v from other_before$$,
'QUE-04: Katalogspiele (neu angelegt und vorhanden), ihre Varianten und die Daten und Meldungen anderer Nutzer bleiben unverändert');
select is((select count(*) from public.game_reports where owner_id = '33333333-3333-3333-3333-333333333333'), 3::bigint,
'Caras Meldungen (zwei aus dem Runbook, eine Import-Meldung) sind nach Annas Löschung vorhanden');
select * from finish();
rollback;