feat(catalog): Fehlendes Spiel melden – Backend (KAT-04, 13a) #67
6 changed files with 21 additions and 13 deletions
|
|
@ -403,7 +403,7 @@ Spec: `docs/specs/13-fehlendes-spiel-melden.md` (Umsetzungsnachtrag: `docs/specs
|
|||
| --- | --- | --- | --- |
|
||||
| `strip_leading_article(text)`, `normalize_game_title(text)` | sql, immutable | `authenticated` | Titel normalisieren; `normalize_game_title` = `search_normalize` plus ein führender Artikel (de/en), Rückfall `lower(btrim)` |
|
||||
| `report_day(timestamptz)` | sql, immutable | `authenticated` | Berliner Kalendertag (Tageslimit); `authenticated` braucht das Recht, weil der Trigger als `security invoker` läuft |
|
||||
| `find_similar_catalog_games(p_title, p_year)` | RPC, `security invoker`, stable | `authenticated` | höchstens 5 sichtbare Katalogspiele mit gleichem normalisierten Titel (Regel A, jedes Jahr) oder Titel-Ähnlichkeit >= 0,8 und Jahr ± 1 (Regel B; fehlt ein Jahr, ohne Jahresbedingung); Kandidatentitel sind der Haupttitel und alle alternativen Titel (`title_search`); Rückgabe `game_id, title, matched_title, year, publishers, match_kind ('same_title'/'similar_title'), similarity`; Titel leer oder über 200 Zeichen ergibt eine leere Menge |
|
||||
| `find_similar_catalog_games(p_title, p_year)` | RPC, `security invoker`, stable | `authenticated` | höchstens 5 sichtbare Katalogspiele mit gleichem normalisierten Titel (Regel A, jedes Jahr) oder Titel-Ähnlichkeit >= 0,8 und Jahr ± 1 (Regel B; fehlt ein Jahr, ohne Jahresbedingung); Kandidatentitel sind der Haupttitel und alle alternativen Titel (`title_search`); Rückgabe `game_id, title, matched_title, year, publishers, match_kind ('same_title'/'similar_title'), similarity`; Ein Jahr außerhalb 1800–2100 gilt wie `null`; Titel leer oder über 200 Zeichen ergibt eine leere Menge |
|
||||
| `report_missing_game(p_title, p_publisher_name, p_year, p_link, p_list, p_confirmed_distinct)` | RPC, `security invoker` | `authenticated` | Meldung + vorläufiger Eintrag + optional Listeneintrag; Rückgabe `{"report_id", "provisional_game_id", "collection_item_id"}` |
|
||||
| `game_reports_daily_limit()` | Trigger-Funktion, `security invoker` | niemand | Tageslimit |
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ Reihenfolge: Migrationen, `vocabulary.sql`, `startbestand.sql` (Runbook `docs/ka
|
|||
| ☐ | Das lokale Beispielspiel aus `seed.sql` ist auf Produktion nicht vorhanden (reiner Sicherheitsnetz-Check; `checks.sql` zählt es nicht mit) | devops | `select count(*) from public.games where title = 'Beispielspiel';` erwartet 0 |
|
||||
| ☐ | **Go-live:** `startbestand.sql` ist ab jetzt eingefroren und wird nicht mehr eingespielt (Korrekturen nur im Studio, Spec-F6); dieser Schritt kommt **nach** dem Einspielen und der `checks.sql`-Abnahme | Chris | Hinweis im Release-PR |
|
||||
|
||||
| ☐ | **Vor dem Einspielen von 13a (KAT-04) auf Staging/Prod:** `select count(*) from public.game_reports where status = 'rejected' and target_game_id is not null;` muss 0 sein, denn der neue Check `game_reports_rejected_no_target` (ohne `NOT VALID`) scheitert sonst. Bei Treffern vorher bereinigen: Ziel auf null setzen oder Status auf `merged` umstellen | devops | Ergebnis 0 |
|
||||
|
||||
Issue #2 wird erst nach der Abnahme von `checks.sql` auf Produktion (C1) und nach Fertigstellung von #4 (KAT-02) geschlossen, und nur von Chris.
|
||||
|
||||
## 2a. Katalogsuche (Spec #3, KAT-01; Belege: `docs/specs/3-katalog-suche-umsetzung.md`)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
| | |
|
||||
|---|---|
|
||||
| Issue | #13 (KAT-04) |
|
||||
| Status | Entwurf, Review-Befunde eingearbeitet; wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2). **Spec-F6 muss vor dem Merge von PR #57 (2c) entschieden werden.** **Änderung vom 2026-11-04 (Entscheidung Chris, Option B, Spec-F10):** Es gibt kein „abgelehnt mit Verweis“ und kein `adopt_report_target` mehr; `rejected` nur ohne Ziel, Verweis auf einen vorhandenen Eintrag über `merged`. |
|
||||
| Status | Entwurf, Review-Befunde eingearbeitet; von Chris zur Umsetzung von 13a freigegeben (Spec-F1 bis Spec-F9 gelten mit den Empfehlungen aus § 9.2, Spec-F6 ist mit 2c umgesetzt, Spec-F10 am 2026-11-04 von Chris entschieden). **Änderung vom 2026-11-04 (Entscheidung Chris, Option B, Spec-F10):** Es gibt kein „abgelehnt mit Verweis“ und kein `adopt_report_target` mehr; `rejected` nur ohne Ziel, Verweis auf einen vorhandenen Eintrag über `merged`. |
|
||||
| Autor | architect |
|
||||
| Stand | 2026-11-03 |
|
||||
| Etappe | R1.2 Sammlung |
|
||||
|
|
@ -223,7 +223,7 @@ returns table (
|
|||
)
|
||||
```
|
||||
|
||||
`language sql`, `stable`, `security invoker`, `search_path = ''`. Liest `catalog_games` und verbindet per Primärschlüssel mit `games` (für `title_search`), außerdem `game_alt_titles`, `game_publishers`, `publishers`. Regel und Kandidatenschlüssel nach 3.3. Sortierung: `same_title` zuerst, dann `similarity` absteigend, dann `title`; höchstens **5** Treffer. Leerer Titel oder Titel > 200 Zeichen → leere Menge (kein Fehler; `report_missing_game` meldet die Länge selbst). Nur `authenticated`.
|
||||
`language sql`, `stable`, `security invoker`, `search_path = ''`. Liest `catalog_games` und verbindet per Primärschlüssel mit `games` (für `title_search`), außerdem `game_alt_titles`, `game_publishers`, `publishers`. Regel und Kandidatenschlüssel nach 3.3. Sortierung: `same_title` zuerst, dann `similarity` absteigend, dann `title`; höchstens **5** Treffer. Ein Jahr außerhalb 1800–2100 gilt wie `null` (kein Überlauf, kein 22003). Leerer Titel oder Titel > 200 Zeichen → leere Menge (kein Fehler; `report_missing_game` meldet die Länge selbst). Nur `authenticated`.
|
||||
|
||||
### 4.4 entfällt (Entscheidung Chris, 2026-11-04, Option B, Spec-F10)
|
||||
|
||||
|
|
@ -260,8 +260,7 @@ features/game_reports/
|
|||
report_outcome.dart sealed ReportOutcome (5.4) + reine Funktion outcomeOf(...)
|
||||
game_report_draft.dart freezed: Eingaben + Validierung
|
||||
similar_game.dart freezed: SimilarGame (gameId, title, matchedTitle, year, publishers, matchKind)
|
||||
game_report_failure.dart sealed: dailyLimit, similarExists, validation(field, code), notAdoptable,
|
||||
noProvisional, alreadyAdopted, targetNotListed, offline, unknown
|
||||
game_report_failure.dart sealed: dailyLimit, similarExists, validation(field, code), offline, unknown
|
||||
game_report_repository.dart abstrakte Schnittstelle
|
||||
data/
|
||||
supabase_game_report_repository.dart RPC-Aufrufe und Abfragen (4.2–4.6), explizite Parameter-Maps
|
||||
|
|
|
|||
|
|
@ -20,12 +20,15 @@ Ergänzt `docs/specs/13-fehlendes-spiel-melden.md` (die Spec selbst wird hier ni
|
|||
- **Trimmen:** `report_missing_game` trimmt Leerzeichen, Tab, CR und LF (nicht nur Leerzeichen). Titel, Verlag und Link werden vor allen Prüfungen getrimmt, Längen gelten für den getrimmten Wert.
|
||||
- **`find_similar_catalog_games`:** Der Titel wird vor der Normalisierung getrimmt und die Länge (1 bis 200) am getrimmten Wert geprüft. Ist der normalisierte Titel leer (kann nach `normalize_game_title` nur bei leerem Rest vorkommen), gibt es keine Treffer. Je Spiel wird der beste Kandidatentitel gewählt: gleicher Schlüssel vor Ähnlichkeit, dann Haupttitel vor alternativen Titeln, dann Schreibweise. Verlage sind nach `publishers.name_search`, dann `name` sortiert (wie in `search_catalog_games`). Bei gleicher Ähnlichkeit sortiert das Ergebnis nach `title`, dann `game_id`. Die Ähnlichkeit bei `same_title` ist immer `1.0`.
|
||||
- **`report_provisional_in_use`:** Der Handler um den Insert in `game_reports` wertet den Namen des verletzten Index (`game_reports_provisional_game_id_key`) aus; andere Unique-Verletzungen laufen unverändert weiter. Über die RPC ist der Fall nicht erreichbar (jeder Aufruf legt ein frisches Spiel an); der Test lenkt ihn mit einem temporären Trigger herbei.
|
||||
- **Testplan:** `00_schema` unverändert (22 Tabellen), `07`/`08`-Fremdschlüssel-Wächter unberührt (kein neuer Fremdschlüssel), `08` nur um das Abschalten des Limit-Triggers ergänzt (Erwartungen und Beschreibungen unverändert gültig: die Spaltenrechte aus 08 waren schon auf `result_seen_at` und die Insert-Spalten ausgelegt).
|
||||
- **Testplan:** `00_schema` unverändert (22 Tabellen), `07`/`08`-Fremdschlüssel-Wächter unberührt (kein neuer Fremdschlüssel), `08` um das Abschalten des Limit-Triggers ergänzt und nach Spec-F10 angepasst (Fixtures „rejected mit Ziel“ → `merged`, Tests zu `report_reject_has_target` und zum Check `game_reports_rejected_no_target`, Runbook-Baustein 3c ohne Ziel); die Spaltenrechte-Tests aus 08 waren schon auf `result_seen_at` und die Insert-Spalten ausgelegt.
|
||||
|
||||
- **Review-Nachbesserungen:** `find_similar_catalog_games` behandelt ein Jahr außerhalb 1800-2100 wie `null` (kein Überlauf, kein 22003). Zusätzliche Tests: Tagesgrenze Berlin (10 Meldungen kurz nach Mitternacht Berlin, Trigger und Vorprüfung), Trimmen von Tab/Zeilenumbruch.
|
||||
- **08-Fixtures (N3) bleiben bei `disable trigger`:** Ben und weitere Nutzer haben dort über 20 Fixture-Inserts mit Standard-`created_at`; sie alle auf feste alte Zeitstempel umzustellen wäre ein großer, fehleranfälliger Eingriff in 2c-Tests. Das Limit prüft `10` ausführlich.
|
||||
- **Zielsperre (N1) unverändert `for update`:** nicht umgestellt, weil `admin_resolve_report` und `admin_merge_games` ebenfalls `for update` sperren und eine gemischte Sperrstufe die Verklemmungsanalyse (Spec R2) ändern würde.
|
||||
|
||||
- **Check ohne `NOT VALID` (Einspielen auf Staging/Prod):** `game_reports_rejected_no_target` prüft beim Anlegen alle vorhandenen Zeilen. Vor dem Einspielen von 13a prüfen: `select count(*) from public.game_reports where status = 'rejected' and target_game_id is not null;` Bei Treffern vorher bereinigen (Ziel auf null setzen oder Status auf `merged` umstellen). Der Punkt steht in `docs/release-checkliste.md`.
|
||||
- **Uhrzeitabhängigkeit:** Die Mitternachtstests (Tagesgrenze) hängen an der Uhrzeit: Läuft der Test zwischen Mitternacht Berlin und Mitternacht UTC, hat `created_at` dasselbe UTC-Datum wie `now()` und die Mutante `created_at::date = now()::date` überlebt. Das Verhalten der Funktion ist davon nicht betroffen.
|
||||
|
||||
## 2. Mutationsprüfung
|
||||
|
||||
Die Mutanten sind veränderte Kopien der Migration, jeweils in einer Transaktion zusammen mit dem Testlauf angewendet und mit dem Rollback des Tests zurückgenommen (am echten Schema bleibt nichts). Die Skripte liegen nicht im Repo. Ergebnis siehe Bericht an Chris; die überlebenden Mutanten sind entweder Doppelabsicherungen (zum Beispiel `origin = 'user'` in der Insert-Policy, die schon durch die Spaltenrechte gesichert ist) oder nur mit Parallelität testbar (Advisory-Lock).
|
||||
|
|
|
|||
|
|
@ -739,7 +739,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.
|
||||
- **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).
|
||||
- **[Teilweise überholt 2026-11-04: `adopt_report_target` entfällt (Spec-F10, siehe Nachtrag am Ende).]** **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`.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
-- KAT-04 (PR 13a): Fehlendes Spiel melden. Normalisierung (strip_leading_article, normalize_game_title),
|
||||
-- report_day, Rechte/Policies/Trigger auf game_reports, find_similar_catalog_games, report_missing_game,
|
||||
-- adopt_report_target (security definer), Ergebnis nach admin_resolve_report, Konto-Löschen.
|
||||
-- Ergebnis nach admin_resolve_report (merged/rejected, rejected nur ohne Ziel), Check game_reports_rejected_no_target, Konto-Löschen.
|
||||
-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix a130/f13) und
|
||||
-- unwahrscheinlichen Titeln (zyxq...), damit der Katalog nicht hineinspielt.
|
||||
-- Fixtures legt postgres an; "als Nutzer" läuft wirklich als Rolle authenticated mit gesetztem
|
||||
|
|
@ -418,10 +418,12 @@ reset role;
|
|||
select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000e4' and public.report_day(created_at) = '2026-01-15'), 1::bigint,
|
||||
'KAT-04 Kriterium 7: der Berliner Kalendertag trennt 22:59 und 23:30 UTC im Winter');
|
||||
|
||||
-- Tagesgrenze: Meldungen kurz nach Mitternacht Berlin zählen als heute (auch wenn das UTC-Datum ein anderes ist)
|
||||
-- Tagesgrenze: Meldungen kurz nach Mitternacht Berlin zählen als heute (auch wenn das UTC-Datum ein anderes ist).
|
||||
-- Uhrzeitabhängig: Läuft der Test zwischen Mitternacht Berlin und Mitternacht UTC, hat created_at dasselbe UTC-Datum wie now();
|
||||
-- die Tests bestehen dann trotzdem, erkennen aber die Mutante created_at::date = now()::date nicht.
|
||||
insert into public.games (id, title, visibility, owner_id)
|
||||
select ('f13b0000-0000-4000-8000-' || lpad(to_hex(200 + i), 12, '0'))::uuid, 'Mitternacht ' || i, 'private',
|
||||
case when i <= 11 then 'a1300000-0000-4000-8000-0000000000e5'::uuid else 'a1300000-0000-4000-8000-0000000000e6'::uuid end
|
||||
case when i <= 10 then 'a1300000-0000-4000-8000-0000000000e5'::uuid else 'a1300000-0000-4000-8000-0000000000e6'::uuid end
|
||||
from generate_series(1, 22) i;
|
||||
insert into public.game_reports (owner_id, title, provisional_game_id, created_at)
|
||||
select case when i <= 10 then 'a1300000-0000-4000-8000-0000000000e5'::uuid else 'a1300000-0000-4000-8000-0000000000e6'::uuid end,
|
||||
|
|
@ -438,7 +440,7 @@ reset role;
|
|||
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e6","role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values
|
||||
('a1300000-0000-4000-8000-0000000000e6', 'Mitternacht 21', 'f13b0000-0000-4000-8000-0000000000d9')$$, 'P0001', 'report_daily_limit',
|
||||
('a1300000-0000-4000-8000-0000000000e6', 'Mitternacht 21', 'f13b0000-0000-4000-8000-0000000000dd')$$, 'P0001', 'report_daily_limit',
|
||||
'KAT-04 Kriterium 7: 10 Meldungen kurz nach Mitternacht Berlin zählen für heute (Trigger)');
|
||||
reset role;
|
||||
|
||||
|
|
@ -625,7 +627,8 @@ select throws_ok($$select public.admin_resolve_report((select (j->>'report_id'):
|
|||
select is((select status from public.game_reports where id = (select (j->>'report_id')::uuid from res_mg)), 'open',
|
||||
'KAT-04 Kriterium 5: die abgewiesene Meldung bleibt offen');
|
||||
select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now(), target_game_id = 'f13c0000-0000-4000-8000-000000000013'
|
||||
where id = (select (j->>'report_id')::uuid from res_mg)$$, '23514', null,
|
||||
where id = (select (j->>'report_id')::uuid from res_mg)$$, '23514',
|
||||
'new row for relation "game_reports" violates check constraint "game_reports_rejected_no_target"',
|
||||
'KAT-04 Kriterium 5: auch die Tabelle erlaubt rejected mit Ziel nicht (Check game_reports_rejected_no_target)');
|
||||
select lives_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_mg), 'merged', 'f13c0000-0000-4000-8000-000000000013')$$,
|
||||
'KAT-04 Kriterium 5: mit Verweis auf einen vorhandenen Eintrag wird die Meldung mit merged bearbeitet');
|
||||
|
|
@ -641,7 +644,8 @@ select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_mg), now())
|
|||
'KAT-04 Kriterium 5: Anna kann den Hinweis zur zusammengeführten Meldung wegklicken');
|
||||
reset role;
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at, target_game_id)
|
||||
values ('a1300000-0000-4000-8000-0000000000a1', 'X', 'rejected', now(), 'f13c0000-0000-4000-8000-000000000013')$$, '23514', null,
|
||||
values ('a1300000-0000-4000-8000-0000000000a1', 'X', 'rejected', now(), 'f13c0000-0000-4000-8000-000000000013')$$, '23514',
|
||||
'new row for relation "game_reports" violates check constraint "game_reports_rejected_no_target"',
|
||||
'KAT-04 Kriterium 5: Check game_reports_rejected_no_target greift auch beim Anlegen als postgres');
|
||||
select lives_ok($$insert into public.game_reports (owner_id, title, status, resolved_at, created_at)
|
||||
values ('a1300000-0000-4000-8000-0000000000a1', 'Abgelehnt ohne Ziel', 'rejected', now(), '2026-02-01')$$,
|
||||
|
|
|
|||
Loading…
Reference in a new issue