Compare commits

...

4 commits

Author SHA1 Message Date
cschlaefke
e665a71f13 feat(db): reports tests, runbook section 6 and docs for KAT-05 2c Refs #2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 21:07:19 +01:00
cschlaefke
ac5b5f2be2 Merge remote-tracking branch 'origin/feature/2b-katalog-zusammenfuehren' into feature/2c-katalog-meldungen 2026-11-03 21:05:02 +01:00
cschlaefke
147dd1870e feat(db): game reports table, admin_resolve_report and visibility trigger (KAT-05 2c) Refs #2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 21:05:02 +01:00
cschlaefke
127f765d85 test(db): close surviving mutants in merge tests, extend runbook (KAT-05, 2b)
All checks were successful
CI / flutter (pull_request) Successful in 1m20s
CI / web-build (pull_request) Successful in 1m2s
CI / supabase (pull_request) Successful in 8s
CI / supabase-tests (pull_request) Successful in 31s
CI / e2e (pull_request) Successful in 3m1s
Refs #2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 21:04:12 +01:00
8 changed files with 1349 additions and 30 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` | 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,26 @@ 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'` |
| `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 |
@ -342,10 +364,11 @@ Drei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Av
Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle `postgres` (Klärungen 25/44, Ablauf: `docs/katalog-pflege.md`). Spec: `docs/specs/2-katalog-daten.md`.
- **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf).
- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). Gesetzt wird die Spalte nur von der Funktion `public.admin_merge_games(p_source, p_target) returns jsonb` (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.
- **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). 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):
@ -233,12 +233,22 @@ join public.collection_items t on t.owner_id = s.owner_id and t.game_id = '7a1b2
where s.game_id = '7a1b2c3d-0000-4000-8000-000000000002';
```
**Schritt 3: Zusammenführen.** In einer Transaktion ausführen (`begin;` davor, nach dem Lesen des Ergebnisses `commit;`, bei Zweifel `rollback;`; die Bausteine stehen wie überall ohne `begin`/`commit`). Bei einem Fehler wird nichts geändert:
**Schritt 3: Zusammenführen.** Der Baustein steht wie überall ohne `begin`/`commit`; er wird in einer Transaktion ausgeführt. Bei einem Fehler wird nichts geändert:
```sql
select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003');
```
**Trockenlauf (empfohlen vor dem ersten echten Merge).** Das Ergebnis lesen und alles zurückrollen, **in einem einzigen Lauf**:
```sql
begin;
select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003');
rollback;
```
Danach ist nichts verändert; mit `commit;` statt `rollback;` wird der Lauf endgültig. Achtung: `begin;` und `commit;`/`rollback;` in getrennten Läufen funktionieren nur in `psql` (eine Sitzung). Der SQL-Editor in Studio führt jeden Lauf womöglich in einer eigenen Verbindung bzw. Transaktion aus; dort immer alles in einem Lauf ausführen. (Dieser Trockenlauf enthält Transaktionssteuerung und kann deshalb nicht wörtlich in pgTAP laufen; der enthaltene Aufruf ist identisch zu Schritt 3 und dort getestet.)
**Schritt 4: Nachprüfen.** Der Vorschau-Baustein aus Schritt 2 (mit der Quelle) muss jetzt überall `0` liefern. Zustand beider Einträge:
```sql
@ -246,7 +256,11 @@ select id, title, hidden_at is not null as ausgeblendet, merged_into_id, bgg_id,
from public.games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003');
```
Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert sichtbar.
Die Quelle ist ausgeblendet und verweist auf das Ziel. Das Ziel bleibt sichtbar; Fakten, Kurztext und Zuordnungen sind unverändert. **Ändern können sich `bgg_id` und die Quelle (`catalog_source_id`, `source_*`)**, wenn das Ziel sie von der Quelle übernommen hat (siehe `bgg_id_transferred` und `source_transferred` im Ergebnis). Außerdem darf kein Spiel mehr auf die Quelle verweisen; diese Abfrage muss leer sein (Ketten sind geglättet):
```sql
select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002';
```
**Was die Funktion macht**
@ -255,7 +269,7 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert
| Sammlung (`collection_items`) | Hat der Nutzer nur die Quelle, wechselt der Eintrag auf das Ziel. Hat er beide (Doppelbesitz, Spec-F1): Der Zieleintrag bleibt; leere Felder (Notiz, Erwerbsdatum, Edition) füllt der Quell-Eintrag; bei unterschiedlicher Liste gilt Sammlung vor Wunschliste vor Einkaufsliste; der Quell-Eintrag wird gelöscht. Eine nicht leere Notiz am Ziel wird nie überschrieben. |
| Favoriten | Hat der Nutzer das Ziel auch als Favorit, entfällt der Favorit der Quelle (die Lücke in der Reihenfolge ist zulässig); sonst wechselt er auf das Ziel. |
| Partien, Wertungsbögen | wechseln auf das Ziel; die Edition einer Partie wird über die Variantenzuordnung übertragen. |
| Varianten (Editionen) | Für jede Variante der Quelle: Hat das Ziel eine gleichnamige Variante (Vergleich ohne Groß-/Kleinschreibung und ohne Leerzeichen am Rand), wird darauf abgebildet; gibt es mehrere gleichnamige, gilt die älteste (bei Gleichstand die kleinste ID). Sonst wird die Variante am Ziel neu angelegt (Name, Jahr, Sprache, BGG-Kennung). Danach werden die Varianten der Quelle gelöscht. |
| Varianten (Editionen) | Für jede Variante der Quelle: Hat das Ziel eine gleichnamige Variante (Vergleich ohne Groß-/Kleinschreibung und ohne **Leerzeichen** am Rand; Tabulatoren und Zeilenumbrüche am Rand zählen als Teil des Namens), wird darauf abgebildet; gibt es mehrere gleichnamige, gilt die älteste (bei Gleichstand die kleinste ID). Sonst wird die Variante am Ziel neu angelegt (Name, Jahr, Sprache, BGG-Kennung). Danach werden die Varianten der Quelle gelöscht. |
| BGG-Kennung und Quelle | Hat das Ziel keine, aber die Quelle, übernimmt das Ziel sie (`bgg_id`; ebenso Quelle, `source_ref`, `source_url`, `source_synced_at` als Gruppe). An der Quelle werden sie in jedem Fall geleert, damit der BGG-Abgleich später den ausgeblendeten Eintrag nicht trifft. |
| Ketten | Spiele, die bisher auf die Quelle verwiesen, verweisen jetzt auf das Ziel. |
@ -265,7 +279,7 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert
{"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 |
@ -275,11 +289,12 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert
| `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`):
@ -289,14 +304,158 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert
| `merge_same_game` | Quelle und Ziel sind dieselbe ID |
| `merge_game_not_found` | Quelle oder Ziel gibt es nicht (ID vertippt?) |
| `merge_not_catalog` | Quelle oder Ziel ist ein privater Eintrag; Zusammenführen gilt nur für Katalogspiele |
| `merge_target_not_listed` | Das Ziel ist ausgeblendet oder selbst schon zusammengeführt; das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) |
| `merge_target_not_listed` | Das Ziel ist ausgeblendet oder selbst schon zusammengeführt. Ist es zusammengeführt, das endgültige Ziel über `merged_into_id` nachschlagen (Baustein unten, hier mit der Quelle aus dem Beispiel; Ketten sind geglättet, ein Schritt genügt) oder ein ausgeblendetes Ziel zuerst wieder einblenden (siehe 4) |
| `merge_source_already_merged` | Die Quelle ist bereits zusammengeführt |
Nachschlagen, wohin ein zusammengeführtes Spiel verweist (`merged_into_id` ist das endgültige Ziel):
```sql
select id, title, hidden_at, merged_into_id from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002';
```
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). Bei Zeitüberschreitung oder anderem Fehler `rollback;` und erneut prüfen. Löscht man ein Zielspiel, auf das etwas verweist, schlägt das mit `23503` fehl (gewollt: ausblenden statt löschen).
## 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,
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. 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 (Datenfehler); nicht umhängen, sondern die Meldung prüfen |
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). 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

@ -718,3 +718,24 @@ Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_cata
- **Schritt 4 aus 3.3** (`game_reports.target_game_id` umhängen) entfällt bis 2c, wie vorgesehen.
- **Hinweis für 2c (B9):** Sobald `game_reports` zwei Fremdschlüssel auf `games` bekommt, wird der Wächter in `07` rot. Die erwartete Liste in `07` muss dort um `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (bewusst ausgelassen) erweitert werden, nicht nur der Wächter in `08`.
- **Runbook:** Alle SQL-Bausteine aus Abschnitt 5 laufen wörtlich in `07` (einmalige Prüfung per Skript; keine CI-Prüfung). Abschnitt 5 enthält die Duplikatsuche, eine Vorschau (Verweise, Doppelbesitz), den Aufruf, die Nachprüfung, die Regeln, die Schlüssel des Ergebnis-JSON und die Fehlertexte.
- **Nachtrag Review 2b:** Tests ergänzt (Spec-F1-Edition am Ziel, `variants_cleared` im Kollisionszweig, gleichnamige Quellvarianten, zweiter Fremdschlüssel-Wächter auf `game_variants`, unbeteiligte Spiele, Ziel `null`); Runbook um Trockenlauf (`begin; …; rollback;` in einem Lauf, nur dieser Baustein läuft nicht wörtlich in pgTAP), Ketten-Nachprüfung und Nachschlagen von `merged_into_id` ergänzt. Der Null-Check in `admin_merge_games` ist ein äquivalenter Mutant (das spätere `not found` liefert denselben Fehler) und bleibt als ausdrückliche Absicherung. Offen für Chris: Verweise, die nach dem Merge noch auf die Quelle entstehen (W3).
### 2026-11-03 – Umsetzung PR 2c (backend-dev)
Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_reports.test.sql`, 153 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 stabil gegenüber #13:** `08` prüft Schreibrechte je Spalte (`has_column_privilege` auf `status`, `target_game_id`, `resolved_at`, `origin`, `owner_id`, `bgg_id`, `created_at`), „Delete verboten“ und „fremde Meldung nicht sichtbar/änderbar“, nicht „kein Insert/Update auf der Tabelle“.
- **Bewusst nicht gebaut (offene Entscheidung Chris, Spec #13):** `game_reports.result_seen_at` und `editions_cleared`. #13 ergänzt bei Freigabe das Spaltenrecht `update (result_seen_at)` und ggf. `editions_cleared` sowie Insert-Recht und -Policy; die Tests in `08` sind darauf vorbereitet.
- **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,280 @@
-- 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,
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 darf als vorläufiger Eintrag
-- umgehängt werden; ein Katalogspiel würde sonst über den Variantenfall "Katalog" kopiert.
if prov.visibility <> 'private' 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()
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,10 +2,10 @@
-- 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(89);
select plan(107);
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"}'),
@ -156,6 +156,16 @@ insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_i
insert into public.score_sheets (id, owner_id, game_id, name) values
('f7000000-0000-0000-0000-0000000000b5', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 'Bogen');
-- Unbeteiligtes Spiel mit Verweisen: darf vom Zusammenführen nicht berührt werden
insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000501', 'Unbeteiligt', 'catalog');
insert into public.game_variants (id, game_id, name) values ('f7000000-0000-0000-0000-0000000005e1', 'f7000000-0000-0000-0000-000000000501', 'Deluxe');
insert into public.collection_items (id, owner_id, game_id, variant_id, list, note) values
('f7000000-0000-0000-0000-0000000005c1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 'f7000000-0000-0000-0000-0000000005e1', 'wishlist', 'bleibt');
insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values
('f7000000-0000-0000-0000-0000000005b1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 'f7000000-0000-0000-0000-0000000005e1', '2026-04-01', 'f7000000-0000-0000-0000-0000000005b1');
insert into public.favorite_games (id, profile_id, game_id, position) values
('f7000000-0000-0000-0000-0000000005d1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 1);
-- Runbook 5, Schritt 1: Duplikate finden (global; geprüft wird nur die Fixture-Zeile)
create temp table rb_dup as
select lower(btrim(title)) as titel_normalisiert, year,
@ -209,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)
@ -233,6 +243,17 @@ select set_eq($$select id, ausgeblendet, merged_into_id, bgg_id, catalog_source_
('7a1b2c3d-0000-4000-8000-000000000003', false, null, 13, true)$$,
'KAT-05 Kriterium 4: Quelle ausgeblendet und verwiesen, Ziel sichtbar und mit BGG-Kennung und Quelle der Quelle');
-- Runbook 5, Schritt 4: Nachprüfung auf Ketten (muss leer sein)
create temp table rb_ketten as
select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002';
select is_empty($$select * from rb_ketten$$, 'KAT-05 Kriterium 8: Runbook 5, kein Spiel verweist mehr auf die Quelle');
-- Runbook 5, Fehlertext merge_target_not_listed: wohin verweist ein zusammengeführtes Spiel?
create temp table rb_verweis as
select id, title, hidden_at, merged_into_id from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002';
select results_eq($$select merged_into_id from rb_verweis$$, $$values ('7a1b2c3d-0000-4000-8000-000000000003'::uuid)$$,
'KAT-05 Kriterium 8: Runbook 5, Nachschlagen des endgültigen Ziels über merged_into_id');
-- Weitere Prüfungen am Ergebnis des Hauptfalls
select is((select count(*) from public.catalog_games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003')), 1::bigint,
'KAT-05 Kriterium 4: catalog_games enthält nur noch das Ziel');
@ -268,8 +289,16 @@ select is((select row(list, note, acquired_on)::text from public.collection_item
select is((select row(list, note, acquired_on)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c7'),
'(owned,"Doras Quellnotiz",)',
'Spec-F1: Dora: owned (Quelle) vor shopping (Ziel); eine leere Notiz am Ziel wird aus der Quelle gefüllt');
select is((select count(*) from public.collection_items where owner_id = '44444444-4444-4444-4444-444444444444'), 1::bigint,
select is((select count(*) from public.collection_items where owner_id = '44444444-4444-4444-4444-444444444444'
and game_id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003')), 1::bigint,
'Spec-F1: Doras Quell-Eintrag ist gelöscht');
select is((select row(c.game_id, c.variant_id, c.list, c.note)::text || '|' || p.game_id::text || p.variant_id::text || '|' || f.game_id::text || f.position::text
from public.collection_items c, public.plays p, public.favorite_games f
where c.id = 'f7000000-0000-0000-0000-0000000005c1' and p.id = 'f7000000-0000-0000-0000-0000000005b1' and f.id = 'f7000000-0000-0000-0000-0000000005d1'),
'(f7000000-0000-0000-0000-000000000501,f7000000-0000-0000-0000-0000000005e1,wishlist,bleibt)|f7000000-0000-0000-0000-000000000501f7000000-0000-0000-0000-0000000005e1|f7000000-0000-0000-0000-0000000005011',
'Daten unbeteiligter Spiele bleiben unverändert (Sammlung, Partie, Favorit, Variante)');
select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000501'), 1::bigint,
'Varianten unbeteiligter Spiele bleiben erhalten');
select is((select count(*) from public.collection_items where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 4::bigint,
'Jeder der vier Nutzer hat genau einen Eintrag am Ziel');
@ -308,7 +337,7 @@ select is((select count(distinct variant_id) from (
-- Löschen eines Ziels mit Verweis (nur der Verweis merged_into_id, ohne andere Verweise)
insert into public.games (id, title, visibility) values
('f7000000-0000-0000-0000-0000000000b8', 'Löschziel', 'catalog'), ('f7000000-0000-0000-0000-0000000000b9', 'Löschquelle', 'catalog');
select public.admin_merge_games('f7000000-0000-0000-0000-0000000000b9', 'f7000000-0000-0000-0000-0000000000b8');
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000000b9', 'f7000000-0000-0000-0000-0000000000b8')$$, 'Zusammenführen läuft ohne Fehler (0000b8)');
select throws_ok($$delete from public.games where id = 'f7000000-0000-0000-0000-0000000000b8'$$, '23503',
null, 'KAT-05 Kriterium 4: Ein Ziel mit Verweis lässt sich nicht löschen (on delete restrict)');
@ -356,7 +385,7 @@ select is((select r ? 'bgg_id_conflict' from rb_tgt), false, 'Kein Konflikt, wen
insert into public.games (id, title, visibility, hidden_at) values
('f7000000-0000-0000-0000-000000000131', 'Versteckte Quelle', 'catalog', '2020-05-05 00:00:00+00'),
('f7000000-0000-0000-0000-000000000132', 'Ziel zu Versteckt', 'catalog', null);
select public.admin_merge_games('f7000000-0000-0000-0000-000000000131', 'f7000000-0000-0000-0000-000000000132');
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000131', 'f7000000-0000-0000-0000-000000000132')$$, 'Zusammenführen läuft ohne Fehler (000132)');
select is((select hidden_at from public.games where id = 'f7000000-0000-0000-0000-000000000131'), '2020-05-05 00:00:00+00'::timestamptz,
'Eine bereits ausgeblendete Quelle behält ihr hidden_at');
select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000131'), 'f7000000-0000-0000-0000-000000000132'::uuid,
@ -369,7 +398,7 @@ insert into public.games (id, title, visibility) values
('f7000000-0000-0000-0000-000000000141', 'Kette A', 'catalog'),
('f7000000-0000-0000-0000-000000000142', 'Kette B', 'catalog'),
('f7000000-0000-0000-0000-000000000143', 'Kette C', 'catalog');
select public.admin_merge_games('f7000000-0000-0000-0000-000000000143', 'f7000000-0000-0000-0000-000000000141');
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000143', 'f7000000-0000-0000-0000-000000000141')$$, 'Zusammenführen läuft ohne Fehler (000141)');
select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000143'), 'f7000000-0000-0000-0000-000000000141'::uuid, 'Kette: C zeigt zuerst auf A');
create temp table rb_chain as select public.admin_merge_games('f7000000-0000-0000-0000-000000000141', 'f7000000-0000-0000-0000-000000000142') as r;
select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000143'), 'f7000000-0000-0000-0000-000000000142'::uuid,
@ -392,7 +421,7 @@ insert into public.game_variants (id, game_id, name, created_at) values
insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values
('f7000000-0000-0000-0000-0000000001c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000151',
'f7000000-0000-0000-0000-0000000001b1', '2026-02-01', 'f7000000-0000-0000-0000-0000000001c1');
select public.admin_merge_games('f7000000-0000-0000-0000-000000000151', 'f7000000-0000-0000-0000-000000000152');
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000151', 'f7000000-0000-0000-0000-000000000152')$$, 'Zusammenführen läuft ohne Fehler (000152)');
select is((select variant_id from public.plays where id = 'f7000000-0000-0000-0000-0000000001c1'), 'f7000000-0000-0000-0000-0000000001a1'::uuid,
'KAT-05 Kriterium 4: bei Gleichstand von created_at gilt die kleinste id');
select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000152'), 3::bigint,
@ -402,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');
-- ---------------------------------------------------------------------------------------------
@ -456,11 +485,59 @@ insert into public.game_variants (id, game_id, name) values
insert into public.collection_items (id, owner_id, game_id, variant_id, list) values
('f7000000-0000-0000-0000-0000000003c1', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003e1', 'owned'),
('f7000000-0000-0000-0000-0000000003c2', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-0000000003b1', null, 'owned');
select public.admin_merge_games('f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003b1');
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003b1')$$, 'Zusammenführen läuft ohne Fehler (0003b1)');
select is((select v.name || '/' || v.game_id::text from public.collection_items c join public.game_variants v on v.id = c.variant_id
where c.id = 'f7000000-0000-0000-0000-0000000003c2'),
'Jubiläum/f7000000-0000-0000-0000-0000000003b1', 'Spec-F1: Kollision: die Edition der Quelle kommt über die Zuordnung (Kopie am Ziel) in den Zieleintrag');
-- Spec-F1 Kernregel (W1): Doppelbesitz, beide Einträge haben eine eigene Edition -> der Zieleintrag behält seine
insert into public.games (id, title, visibility) values
('f7000000-0000-0000-0000-000000000401', 'Edition Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000402', 'Edition Ziel', 'catalog');
insert into public.game_variants (id, game_id, name) values
('f7000000-0000-0000-0000-0000000004e1', 'f7000000-0000-0000-0000-000000000401', 'Y-Edition'),
('f7000000-0000-0000-0000-0000000004e2', 'f7000000-0000-0000-0000-000000000402', 'X-Edition');
insert into public.collection_items (id, owner_id, game_id, variant_id, list) values
('f7000000-0000-0000-0000-0000000004c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000401', 'f7000000-0000-0000-0000-0000000004e1', 'owned'),
('f7000000-0000-0000-0000-0000000004c2', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000402', 'f7000000-0000-0000-0000-0000000004e2', 'owned');
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000401', 'f7000000-0000-0000-0000-000000000402')$$,
'Zusammenführen mit Doppelbesitz und zwei Editionen läuft');
select is((select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000004c2'),
'f7000000-0000-0000-0000-0000000004e2'::uuid, 'Spec-F1: Doppelbesitz: der Zieleintrag behält seine eigene Edition (X), die der Quelle (Y) verdrängt sie nicht');
-- N15 (W2): privater Eintrag -> Katalog, Doppelbesitz, Ziel ohne Edition, Quelle mit Edition ohne Gegenstück -> geleert und gezählt
insert into public.games (id, title, visibility, owner_id) values
('f7000000-0000-0000-0000-000000000411', 'Vorläufig Cara', 'private', '33333333-3333-3333-3333-333333333333');
insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000412', 'Ziel Cara', 'catalog');
insert into public.game_variants (id, game_id, name) values
('f7000000-0000-0000-0000-0000000004e3', 'f7000000-0000-0000-0000-000000000411', 'Nur bei Cara');
insert into public.collection_items (id, owner_id, game_id, variant_id, list) values
('f7000000-0000-0000-0000-0000000004c3', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-000000000411', 'f7000000-0000-0000-0000-0000000004e3', 'owned'),
('f7000000-0000-0000-0000-0000000004c4', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-000000000412', null, 'owned');
create temp table rb_n15 as select public.repoint_game_references('f7000000-0000-0000-0000-000000000411', 'f7000000-0000-0000-0000-000000000412') as r;
select is((select r from rb_n15),
'{"collection_items":0,"collection_conflicts":1,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":1}'::jsonb,
'KAT-05 Kriterium 4 (N15): Doppelbesitz mit Edition ohne Gegenstück: variants_cleared = 1');
select is((select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000004c4'), null::uuid,
'KAT-05 Kriterium 4 (N15): die Edition am Zieleintrag bleibt leer (nichts kopiert)');
select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000412'), 0::bigint,
'KAT-05 Kriterium 4 (N15): das Katalogspiel bekommt keine Variante');
-- Zwei gleichnamige Quellvarianten werden einzeln kopiert (nur gegen vorher vorhandene Zielvarianten abgeglichen)
insert into public.games (id, title, visibility) values
('f7000000-0000-0000-0000-000000000421', 'Zwilling Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000422', 'Zwilling Ziel', 'catalog');
insert into public.game_variants (id, game_id, name, created_at) values
('f7000000-0000-0000-0000-0000000004f1', 'f7000000-0000-0000-0000-000000000421', 'Twin', '2020-01-01'),
('f7000000-0000-0000-0000-0000000004f2', 'f7000000-0000-0000-0000-000000000421', 'twin ', '2021-01-01');
create temp table rb_twin as select public.admin_merge_games('f7000000-0000-0000-0000-000000000421', 'f7000000-0000-0000-0000-000000000422') as r;
select is((select (r ->> 'variants_copied') || '/' || (r ->> 'variants_mapped') from rb_twin), '2/0',
'Zwei gleichnamige Quellvarianten werden beide kopiert, keine auf die eben kopierte abgebildet');
select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000422'), 2::bigint,
'Das Ziel hat danach beide Kopien');
-- Ziel null
select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', null)$$,
'P0001', 'merge_game_not_found', 'KAT-05 Kriterium 4: fehlendes Ziel (null) ist merge_game_not_found');
-- ---------------------------------------------------------------------------------------------
-- Fremdschlüssel-Wächter: jeder Verweis auf games ist entweder behandelt oder bewusst ausgelassen
-- ---------------------------------------------------------------------------------------------
@ -475,10 +552,21 @@ 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;
-- ein neuer Verweis (z. B. aus SAM-02/SAM-04) erzwingt eine Entscheidung in repoint_game_references.
select set_eq($$select (select relname from pg_class where oid = c.conrelid) || '(' ||
(select string_agg(a.attname, ', ' order by k.ord) from unnest(c.conkey) with ordinality as k(attnum, ord)
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k.attnum) || ')'
from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.game_variants'::regclass$$,
$$values ('collection_items(variant_id, game_id)'), ('plays(variant_id, game_id)')$$,
'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: Verweise auf game_variants sind genau Sammlung und Partien (beide über die Variantenzuordnung)');
select * from finish();
rollback;

View file

@ -0,0 +1,747 @@
-- 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(153);
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 und update (result_seen_at)):
-- geprüft wird je Spalte, nicht "kein Insert/Update auf der Tabelle".
select is_empty($$select c, p from unnest(array['status', 'target_game_id', 'resolved_at', 'origin', 'owner_id', 'bgg_id', 'created_at']) c,
unnest(array['insert', 'update']) p
where has_column_privilege('authenticated', 'public.game_reports', c, p)$$,
'KAT-05 Kriterium 9: authenticated hat kein Schreibrecht auf status, target_game_id, resolved_at, origin, owner_id, bgg_id, created_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 from public.game_reports where id = 'f8000000-0000-0000-0000-000000000a01'), 'user:open',
'Standardwerte: origin = user, status = open');
-- ---------------------------------------------------------------------------------------------
-- 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 || 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');
-- 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 || 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,
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, vorlaeufig_ausgeblendet, merged_into_id from rb_check$$,
$$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid, 'approved'::text, '7c1d2c3d-0000-4000-8000-000000000002'::uuid, true, 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 >= created_at 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');
-- 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;