feat(catalog): Meldungen und Sichtbarkeitsschutz (KAT-05, 2c) #57

Merged
cschlaefke merged 6 commits from feature/2c-katalog-meldungen into main 2026-11-04 00:18:36 +00:00
5 changed files with 33 additions and 16 deletions
Showing only changes of commit de830a7148 - Show all commits

View file

@ -43,7 +43,7 @@ R1 braucht 21 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 |
| `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at`, `editions_cleared` | Meldungen fehlender Spiele (KAT-05 PR 2c, Anlegen durch Nutzer kommt mit KAT-04); siehe Katalogpflege |
| `catalog_sources` | `key`, `name`, `attribution_text`, `attribution_url`, `images_allowed`, `texts_allowed`, `terms_url`, `terms_checked_at` | Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt `bgg` ohne Erlaubnis an |
| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) |
| `game_variants` | `game_id` (FK → `games`), `name`, `year`, `language`, `bgg_id` (optional) | Editionen und Varianten eines Spiels (O4) |
@ -251,6 +251,7 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel
| `status` | text | ja | `'open'` | `open`, `approved`, `rejected`, `merged` |
| `target_game_id` | uuid | nein | | FK → games (`on delete restrict`) |
| `resolved_at` | timestamptz | nein | | gesetzt genau dann, wenn `status <> 'open'` |
| `editions_cleared` | integer | ja | `0` | Anzahl der Verweise (Sammlung, Partien), deren Edition beim Umhängen geleert wurde (`variants_cleared`); Check `>= 0`; setzt nur `admin_resolve_report` (Spec-F6) |
| `created_at`, `updated_at` | timestamptz | ja | `now()` | |
Checks: offen hat kein Ziel; `approved`/`merged` brauchen ein Ziel. Indizes: `(owner_id, created_at)`, eindeutig `(owner_id, bgg_id)` für offene Import-Meldungen (N12a), `(created_at)` für offene Meldungen, eindeutig `provisional_game_id`, `target_game_id`.
@ -365,7 +366,7 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle
- **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf).
- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). **Regel: `merged_into_id` wird nur über `public.admin_merge_games(p_source, p_target) returns jsonb` gesetzt, nie von Hand.** (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu.
- **Meldungen** (Migration `game_reports`, PR 2c): Tabelle `game_reports` (siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy auf `owner_id`; die Policy für `is_admin()` ist für R4 vorbereitet); Schreibrechte haben `anon` und `authenticated` nicht, #13 ergänzt Insert-Recht und -Policy. Bearbeitet wird in R1 nur über `public.admin_resolve_report(p_report, p_status, p_target) returns jsonb` (Ablauf, Ergebnis und Fehler: `docs/katalog-pflege.md`, Abschnitt 6): `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`), für `anon`, `authenticated` und `service_role` nicht ausführbar. `approved` und `merged` hängen den vorläufigen privaten Eintrag über `repoint_game_references` auf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert, `variants_cleared`) und blenden ihn aus (`hidden_at`, `merged_into_id`); `rejected` speichert höchstens den Verweis. `admin_merge_games` hängt zusätzlich `game_reports.target_game_id` von der Quelle auf das Ziel (`reports_repointed`). Der Trigger `games_visibility_immutable` lehnt jede Änderung von `visibility` und `owner_id` an bestehenden Spielen ab (Fehler `visibility_immutable`, für alle Rollen auch `postgres`; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501). 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`.
- **Meldungen** (Migration `game_reports`, PR 2c): Tabelle `game_reports` (siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy auf `owner_id`; die Policy für `is_admin()` ist für R4 vorbereitet); Schreibrechte haben `anon` und `authenticated` nicht, #13 ergänzt Insert-Recht und -Policy. Bearbeitet wird in R1 nur über `public.admin_resolve_report(p_report, p_status, p_target) returns jsonb` (Ablauf, Ergebnis und Fehler: `docs/katalog-pflege.md`, Abschnitt 6): `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`), für `anon`, `authenticated` und `service_role` nicht ausführbar. `approved` und `merged` hängen den vorläufigen privaten Eintrag über `repoint_game_references` auf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert, `variants_cleared`) und blenden ihn aus (`hidden_at`, `merged_into_id`); `rejected` speichert höchstens den Verweis. `admin_merge_games` hängt zusätzlich `game_reports.target_game_id` von der Quelle auf das Ziel (`reports_repointed`). Der Trigger `games_visibility_immutable` lehnt jede Änderung von `visibility` und `owner_id` an bestehenden Spielen ab (Fehler `visibility_immutable`, für alle Rollen auch `postgres`; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501). `admin_resolve_report` schreibt außerdem `editions_cleared` (Anzahl geleerter Editionen, 0 bei `rejected` und ohne Umhängen), damit die App den Nutzer zum Neuwählen auffordern kann (Spec-F6). Beim Konto-Löschen werden Meldungen und vorläufige Einträge (auch nach der Freigabe) hart gelöscht, Katalogspiele und Daten anderer Nutzer bleiben. Fremdschlüssel-Wächter: `07` und `08`.
- **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder.
- **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import).
- **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`.

View file

@ -426,7 +426,7 @@ select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'reje
**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,
select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, r.editions_cleared,
g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id
from public.game_reports r
left join public.games g on g.id = r.provisional_game_id
@ -441,7 +441,7 @@ where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
"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.
`repointed` ist `false`, wenn nichts umgehängt wurde (`rejected` oder Meldung ohne vorläufigen Eintrag); dann stehen die Zähler nicht im Ergebnis. Die Zähler haben dieselbe Bedeutung wie bei der Zusammenführung (Abschnitt 5). Wichtig hier: **`variants_cleared`** zählt die Sammlungseinträge und Partien, deren Edition geleert wurde, weil der Katalogeintrag keine gleichnamige Variante hatte. Das ist der Hinweis, dass der Nutzer die Edition neu wählen muss. Derselbe Wert steht danach in der Meldung (`game_reports.editions_cleared`, Spec-F6); die App (#13) liest ihn, um den Nutzer zum Neuwählen aufzufordern. Er ist 0 bei `rejected` und bei Meldungen ohne Umhängen. Eine Kollision (`collection_conflicts`) bedeutet: Der Nutzer hatte das Ziel schon in seiner Sammlung; der Zieleintrag bleibt, leere Felder füllt der Eintrag aus dem vorläufigen Spiel.
**Fehlermeldungen** (alle ohne Änderung, `P0001`):

View file

@ -287,6 +287,7 @@ Die Quelle wird **nicht** gelöscht: Ihre Shuffle-ID bleibt gültig, die App lei
| `status` | text | not null, default `'open'`, Check `in ('open','approved','rejected','merged')` |
| `target_game_id` | uuid | nullable, FK → `games` **`on delete restrict`** (wie `merged_into_id`) |
| `resolved_at` | timestamptz | nullable |
| `editions_cleared` | integer | not null, default 0, Check `>= 0` (Spec-F6, Option A, ergänzt bei der Umsetzung von 2c): Anzahl der Sammlungseinträge und Partien, deren Edition beim Umhängen geleert wurde |
| `created_at`, `updated_at` | timestamptz | Standard, Trigger `set_updated_at` |
Checks: `(status = 'open') = (resolved_at is null)`; `status <> 'open' or target_game_id is null`; `status not in ('approved','merged') or target_game_id is not null`.
@ -319,7 +320,7 @@ Ablauf: `status`, `target_game_id`, `resolved_at = now()` setzen. Hat die Meldun
- bei `rejected` mit Ziel: nur `target_game_id` wird gespeichert, der vorläufige Eintrag bleibt unverändert; ob der Nutzer auf den Zieleintrag wechselt, entscheidet er in der App (KAT-04 Krit. 5, #13),
- bei `rejected` ohne Ziel: keine Änderung am vorläufigen Eintrag (KAT-04 Krit. 6).
Die Rückgabe enthält die Zähler aus `repoint_game_references` (u. a. `variants_cleared`).
Die Rückgabe enthält die Zähler aus `repoint_game_references` (u. a. `variants_cleared`). Dasselbe `variants_cleared` speichert die Funktion in `game_reports.editions_cleared` (0 bei `rejected` und bei Meldungen ohne Umhängen), damit die App (#13) den Nutzer zum Neuwählen der Edition auffordern kann.
„Freigeben“ heißt in R1: Der Admin legt im Studio zuerst einen **neuen** Katalogeintrag an (bzw. wählt einen vorhandenen aus) und ruft dann die Funktion mit `approved` auf. Der vorläufige Eintrag wird nie öffentlich geschaltet (E14). Unterschied zu `merged`: `approved` = der Eintrag ist wegen dieser Meldung neu entstanden, `merged` = er war schon vorhanden.
@ -723,7 +724,7 @@ Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_cata
### 2026-11-03 – Umsetzung PR 2c (backend-dev)
Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_reports.test.sql`, 157 Tests; `07` und `00` angepasst), B10 (Runbook Abschnitt 6). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR:
Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_reports.test.sql`, 160 Tests; `07` und `00` angepasst), B10 (Runbook Abschnitt 6). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR:
- **Branch:** `feature/2c-katalog-meldungen` statt `feature/2-katalog-meldungen` (Vorgabe des Auftrags), gestapelt auf `feature/2b-katalog-zusammenfuehren`.
- **Zusätzliche Indizes** auf beide Fremdschlüssel nach `games` (Regel: Index auf jeden Fremdschlüssel). `provisional_game_id` ist **eindeutig** (`where provisional_game_id is not null`, 1:1: ein vorläufiger Eintrag gehört zu höchstens einer Meldung), `target_game_id` ein einfacher Index.
@ -736,7 +737,7 @@ Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_
- **RLS:** zwei Lese-Policies (eigene Zeilen; `is_admin()` für R4). Bewusst kein Test mit `is_admin()` als Zugang in R1, aber `08` dokumentiert, dass `app_metadata.role = 'admin'` lesen, nicht schreiben kann, und `user_metadata` nichts öffnet.
- **Tests und #13:** `08` prüft Schreibrechte je Spalte: beim Anlegen sind `status`, `target_game_id`, `resolved_at`, `origin`, `created_at`, `updated_at` nicht setzbar (nicht pauschal „kein Insert“), ein Update-Recht gibt es auf keine Spalte außer (ab #13) `result_seen_at`. Dazu „Delete verboten“ und „fremde Meldung nicht sichtbar/änderbar“. Einzelne Insert-/Update-Assertions im Abschnitt „Lesen, RLS“ (z. B. Anna legt eine Meldung für Ben an) gehen von fehlenden Insert-Rechten aus; 13a muss diese anpassen, `08` ändert sich mit #13 ohnehin.
- **Review 2c:** `admin_resolve_report` prüft zusätzlich, dass der vorläufige Eintrag existiert und dem Meldenden gehört (`report_provisional_not_private`, deckt Katalogspiel, fremdes Spiel und fehlende Zeile ab); Test mit fremdem Spiel in `08`. Hinweise: `service_role` hat Supabase-Standardrechte auf Tabellen (Krit. 9 gilt für `anon`/`authenticated`); `P0001` → HTTP 400 in PostgREST; **der Fremdschlüssel-Wächter existiert in `07` und `08`: #3 (`game_alt_titles.game_id`) und jede weitere Tabelle mit Verweis auf `games` muss beide anpassen**. Optionaler Merkpunkt (nicht gebaut): ein Check-Trigger, dass `target_game_id` auf ein Katalogspiel zeigt (analog `games_merged_into_catalog`); heute stellt das nur `admin_resolve_report` sicher.
- **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.
- **Spec-F6 (Chris, Option A) nachträglich umgesetzt:** Spalte `game_reports.editions_cleared integer not null default 0 check (>= 0)`; `admin_resolve_report` setzt sie im Abschluss-Update aus `variants_cleared` (sonst 0). Die Migration von 2c wurde dafür in place ergänzt (noch nicht gemergt). Tests in `08`: Spalte, Default, Check, Wert 1 bei geleerter Edition (Meldung …01), 0 bei `merged` ohne geleerte Edition, bei `rejected` und ohne Umhängen, keine Insert-/Update-Rechte (Mutanten erkannt). Nicht gebaut: `result_seen_at` (kommt mit 13a samt `update (result_seen_at)`, Insert-Recht und -Policy; 13a setzt `editions_cleared` in `adopt_report_target` genauso).
- **Fremdschlüssel-Wächter:** erweiterte Liste in `07` und in `08` (`game_reports.target_game_id` behandelt, `game_reports.provisional_game_id` bewusst ausgelassen). `00_schema.test.sql`: 21 Tabellen.
- **Runbook Abschnitt 6:** Alle SQL-Bausteine laufen wörtlich in `08` (einmalige Prüfung per Skript, keine CI-Prüfung). Der Trockenlauf-Baustein aus 2b gilt entsprechend (`begin; …; rollback;` in einem Lauf).
- **Mutationsprüfung:** 25 Mutanten (u. a. Trigger entfernt/ohne `owner_id`, Grants, Policies, RLS aus, Fremdschlüssel-Aktionen, N12a-Index, Funktionsrumpf-Änderungen) gegen `08`; alle werden erkannt. Bei der Prüfung fiel ein Scheintest auf (Löschen eines Ziels scheiterte an anderen Verweisen), jetzt mit eigenem Ziel. Nicht erkennbar: das Weglassen des wiederholten `revoke` allein (Postgres behält die Rechte bei `create or replace`); erkannt wird stattdessen ein ausdrückliches `grant execute`.

View file

@ -49,6 +49,10 @@ create table public.game_reports (
-- Zieleintrag: wie merged_into_id on delete restrict (Katalogspiele werden ausgeblendet, nicht gelöscht)
target_game_id uuid references public.games (id) on delete restrict,
resolved_at timestamptz,
-- Spec-F6 (Option A): Anzahl der Verweise (Sammlungseinträge, Partien), deren Edition beim Umhängen
-- geleert wurde. Setzt nur admin_resolve_report (aus variants_cleared); die App (#13) liest es,
-- um dem Nutzer zu sagen, dass er die Edition neu wählen muss.
editions_cleared integer not null default 0 check (editions_cleared >= 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint game_reports_resolved_matches_status check ((status = 'open') = (resolved_at is null)),
@ -152,7 +156,8 @@ begin
-- 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()
set status = p_status, target_game_id = p_target, resolved_at = now(),
editions_cleared = coalesce((result ->> 'variants_cleared')::integer, 0)
where r.id = rep.id;
return result || jsonb_build_object(

View file

@ -5,7 +5,7 @@
-- 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(157);
select plan(160);
insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'),
@ -22,9 +22,9 @@ select ok(has_table_privilege('authenticated', 'public.game_reports', 'select'),
-- Formuliert stabil gegenüber späteren Spaltenrechten (#13 ergänzt Insert-Recht, u. a. auf owner_id und
-- provisional_game_id, und update (result_seen_at)): geprüft wird je Spalte.
-- Insert: nur Spalten, die der Nutzer nie setzen darf (Ergebnis und Herkunft der Meldung).
select is_empty($$select c from unnest(array['status', 'target_game_id', 'resolved_at', 'origin', 'created_at', 'updated_at']) c
select is_empty($$select c from unnest(array['status', 'target_game_id', 'resolved_at', 'editions_cleared', 'origin', 'created_at', 'updated_at']) c
where has_column_privilege('authenticated', 'public.game_reports', c, 'insert')$$,
'KAT-05 Kriterium 9: authenticated kann beim Anlegen status, target_game_id, resolved_at, origin, created_at, updated_at nicht setzen');
'KAT-05 Kriterium 9: authenticated kann beim Anlegen status, target_game_id, resolved_at, editions_cleared, origin, created_at, updated_at nicht setzen');
-- Update: auf keine Spalte außer (ab #13) result_seen_at
select is_empty($$select a.attname from pg_attribute a
where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped and a.attname <> 'result_seen_at'
@ -194,8 +194,12 @@ select lives_ok($$insert into public.game_reports (id, owner_id, title, status,
('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');
select is((select origin || ':' || status || ':' || editions_cleared::text from public.game_reports where id = 'f8000000-0000-0000-0000-000000000a01'), 'user:open:0',
'Standardwerte: origin = user, status = open, editions_cleared = 0');
select throws_ok($$insert into public.game_reports (owner_id, title, editions_cleared) values ('22222222-2222-2222-2222-222222222222', 'X', -1)$$,
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_editions_cleared_check"', 'Check: editions_cleared nicht negativ');
select ok(exists (select 1 from pg_attribute where attrelid = 'public.game_reports'::regclass and attname = 'editions_cleared' and not attisdropped and attnotnull),
'Spec-F6: editions_cleared existiert und ist not null (die Update-Prüfung oben erfasst sie, da nur result_seen_at ausgenommen ist)');
-- ---------------------------------------------------------------------------------------------
-- N12a: eine offene Import-Meldung je Nutzer und BGG-Kennung
@ -511,13 +515,13 @@ select is((select r ->> 'report_id' from rb_approve), '7c1d2c3d-0000-4000-8000-0
-- 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,
select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, r.editions_cleared,
g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id
from public.game_reports r
left join public.games g on g.id = r.provisional_game_id
where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
select results_eq($$select id, status, target_game_id, erledigt, 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)$$,
select results_eq($$select id, status, target_game_id, erledigt, editions_cleared, vorlaeufig_ausgeblendet, merged_into_id from rb_check$$,
$$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid, 'approved'::text, '7c1d2c3d-0000-4000-8000-000000000002'::uuid, true, 1, true, '7c1d2c3d-0000-4000-8000-000000000002'::uuid)$$,
'KAT-05 Kriterium 8: Runbook 6, Nachprüfen: Meldung freigegeben, vorläufiger Eintrag ausgeblendet und verwiesen');
select is((select row(visibility, owner_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'),
@ -628,6 +632,12 @@ select is((select row(status, target_game_id)::text from public.game_reports whe
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'rejected')$$, 'P0001', 'report_not_open',
'KAT-05 Kriterium 5: eine freigegebene Meldung lässt sich nicht erneut bearbeiten');
-- editions_cleared nach allen Abschlüssen (erst jetzt sind …05, …06, …07 und …0b erledigt)
select is((select string_agg(id::text || '=' || editions_cleared::text, ',' order by id) from public.game_reports
where id in ('7c1d2c3d-0000-4000-8000-000000000001', '7c1d2c3d-0000-4000-8000-000000000005', '7c1d2c3d-0000-4000-8000-000000000006',
'7c1d2c3d-0000-4000-8000-000000000007', '7c1d2c3d-0000-4000-8000-00000000000b')),
'7c1d2c3d-0000-4000-8000-000000000001=1,7c1d2c3d-0000-4000-8000-000000000005=0,7c1d2c3d-0000-4000-8000-000000000006=0,7c1d2c3d-0000-4000-8000-000000000007=0,7c1d2c3d-0000-4000-8000-00000000000b=0',
'Spec-F6: editions_cleared = 1 bei approved mit geleerter Edition (…01), 0 bei merged ohne geleerte Edition, bei rejected (mit und ohne Ziel) und ohne vorläufigen Eintrag');
-- Der Nutzer sieht das Ergebnis seiner Meldung (KAT-04 Krit. 4-6), aber keine fremden
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
set local role authenticated;