Compare commits
6 commits
main
...
feature/13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec42421312 | ||
|
|
3af18073a6 | ||
|
|
154e5e4bb5 | ||
|
|
3135ed6381 | ||
|
|
0826c97471 | ||
|
|
7186cddac5 |
10 changed files with 1222 additions and 73 deletions
|
|
@ -43,7 +43,7 @@ R1 braucht 22 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog
|
||||||
| Tabelle | Spalten | Zweck |
|
| 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`, `title_search` (generiert) | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die sieben Spalten vor `title_search` gehören der Katalogpflege (KAT-05), `title_search` der Suche (KAT-01), siehe unten |
|
| `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`, `title_search` (generiert) | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die sieben Spalten vor `title_search` gehören der Katalogpflege (KAT-05), `title_search` der Suche (KAT-01), siehe unten |
|
||||||
| `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at`, `editions_cleared` | Meldungen fehlender Spiele (KAT-05 PR 2c, Anlegen durch Nutzer kommt mit KAT-04); siehe Katalogpflege |
|
| `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`, `result_seen_at` | Meldungen fehlender Spiele (KAT-05 PR 2c; Anlegen durch Nutzer und Ergebnishinweis KAT-04 PR 13a); siehe Katalogpflege und Abschnitt „Meldungen (KAT-04)“ |
|
||||||
| `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 |
|
| `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), `name_search` (generiert); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) |
|
| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise), `name_search` (generiert); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) |
|
||||||
| `game_alt_titles` | `game_id` (FK → `games`), `title`, `locale` (`de`/`en`), `title_search` (generiert) | Alternative Titel eines Spiels mit Sprache (Klärung 3, KAT-01); die Suche findet das Spiel auch darüber |
|
| `game_alt_titles` | `game_id` (FK → `games`), `title`, `locale` (`de`/`en`), `title_search` (generiert) | Alternative Titel eines Spiels mit Sprache (Klärung 3, KAT-01); die Suche findet das Spiel auch darüber |
|
||||||
|
|
@ -78,7 +78,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 |
|
| `profiles` | Eigenes Profil; fremde nur `display_name`, `avatar_path`, `city` | Nur eigenes |
|
||||||
| `favorite_games`, `bgg_links` | Nur eigene | Nur eigene |
|
| `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 |
|
| `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) |
|
| `game_reports` | nur der Meldende liest seine Zeilen (Admin-Policy `is_admin()` für R4) | Anlegen: eigene Zeile mit eigenem vorläufigen Eintrag (Spaltenrechte, Policy `game_reports_insert_own`, Tageslimit); Ändern: nur `result_seen_at` an eigenen, bereits bearbeiteten Meldungen; kein Löschen. Bearbeiten (Status, Ziel) nur über `admin_resolve_report` (Studio) |
|
||||||
| `game_alt_titles` | für alle, auch ohne Anmeldung; folgt dem Spiel (alternative Titel eines fremden privaten Spiels sind unsichtbar) | **niemand über die API** (kein Recht, keine Policy); Pflege nur über Studio und das Startbestand-Werkzeug |
|
| `game_alt_titles` | für alle, auch ohne Anmeldung; folgt dem Spiel (alternative Titel eines fremden privaten Spiels sind unsichtbar) | **niemand über die API** (kein Recht, keine Policy); Pflege nur über Studio und das Startbestand-Werkzeug |
|
||||||
| `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 |
|
| `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 |
|
| `collection_items`, `score_sheets`, `score_categories` | Nur eigene | Nur eigene |
|
||||||
|
|
@ -268,9 +268,10 @@ Eindeutig ist `(game_id, locale, title_search)`: derselbe Titel in derselben Spr
|
||||||
| `target_game_id` | uuid | nein | | FK → games (`on delete restrict`) |
|
| `target_game_id` | uuid | nein | | FK → games (`on delete restrict`) |
|
||||||
| `resolved_at` | timestamptz | nein | | gesetzt genau dann, wenn `status <> 'open'` |
|
| `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) |
|
| `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) |
|
||||||
|
| `result_seen_at` | timestamptz | nein | | Der Nutzer hat den Ergebnishinweis weggeklickt (KAT-04, Spec-F5); Check: nur bei `status <> 'open'` |
|
||||||
| `created_at`, `updated_at` | timestamptz | ja | `now()` | |
|
| `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`.
|
Checks: offen hat kein Ziel; `approved`/`merged` brauchen ein Ziel; `rejected` hat kein Ziel (`game_reports_rejected_no_target`, ab KAT-04). 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:
|
**`genres`**, **`mechanics`** und **`categories`** sind gleich aufgebaut:
|
||||||
|
|
||||||
|
|
@ -382,7 +383,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).
|
- **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.
|
- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). **Regel: `merged_into_id` wird nur über `public.admin_merge_games(p_source, p_target) returns jsonb` gesetzt, nie von Hand.** (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu.
|
||||||
- **Meldungen** (Migration `game_reports`, PR 2c): Tabelle `game_reports` (siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy auf `owner_id`; die Policy für `is_admin()` ist für R4 vorbereitet); Schreibrechte haben `anon` und `authenticated` nicht, #13 ergänzt Insert-Recht und -Policy. Bearbeitet wird in R1 nur über `public.admin_resolve_report(p_report, p_status, p_target) returns jsonb` (Ablauf, Ergebnis und Fehler: `docs/katalog-pflege.md`, Abschnitt 6): `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`), für `anon`, `authenticated` und `service_role` nicht ausführbar. `approved` und `merged` hängen den vorläufigen privaten Eintrag über `repoint_game_references` auf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert, `variants_cleared`) und blenden ihn aus (`hidden_at`, `merged_into_id`); `rejected` speichert höchstens den Verweis. `admin_merge_games` hängt zusätzlich `game_reports.target_game_id` von der Quelle auf das Ziel (`reports_repointed`). Der Trigger `games_visibility_immutable` lehnt jede Änderung von `visibility` und `owner_id` an bestehenden Spielen ab (Fehler `visibility_immutable`, für alle Rollen auch `postgres`; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501). `admin_resolve_report` schreibt außerdem `editions_cleared` (Anzahl geleerter Editionen, 0 bei `rejected` und ohne Umhängen), damit die App den Nutzer zum Neuwählen auffordern kann (Spec-F6). Beim Konto-Löschen werden Meldungen und vorläufige Einträge (auch nach der Freigabe) hart gelöscht, Katalogspiele und Daten anderer Nutzer bleiben. Fremdschlüssel-Wächter: `07` und `08`.
|
- **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); `anon` hat keine Rechte; `authenticated` bekommt ab KAT-04 (Migration `game_reports_user`) Insert- und Update-Rechte nur spaltenweise (siehe Abschnitt „Meldungen (KAT-04)“). 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` ändert nur Status und Zeitpunkt und ist nur ohne Ziel zulässig (ab KAT-04, Entscheidung Chris 2026-11-04). `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.
|
- **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).
|
- **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_*`.
|
- **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_*`.
|
||||||
|
|
@ -390,6 +391,36 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle
|
||||||
- **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht.
|
- **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`; 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.
|
- **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.
|
||||||
|
|
||||||
|
## Meldungen (KAT-04, Migration `game_reports_user`)
|
||||||
|
|
||||||
|
Spec: `docs/specs/13-fehlendes-spiel-melden.md` (Umsetzungsnachtrag: `docs/specs/13-umsetzung.md`). Nur additiv zu `game_reports` aus KAT-05; keine neue Tabelle, kein neuer Fremdschlüssel.
|
||||||
|
|
||||||
|
**Ablauf (Nutzer):** Die App ruft zuerst `find_similar_catalog_games(titel, jahr)`; gibt es Treffer, bestätigt der Nutzer ausdrücklich, dass sein Spiel ein anderes ist. Danach `report_missing_game(...)` mit `p_confirmed_distinct = true`. Die RPC legt in einer Transaktion den vorläufigen privaten Eintrag (nur Titel und Jahr), die Meldung (`status = 'open'`, `origin = 'user'`) und optional den Listeneintrag an. Der Admin bearbeitet die Meldung manuell (`admin_resolve_report`, `docs/katalog-pflege.md` Abschnitt 6): `approved` und `merged` hängen den vorläufigen Eintrag direkt um (ohne Aktion des Nutzers; Verweis auf einen vorhandenen Eintrag = `merged`), `rejected` gibt es nur ohne Ziel (Entscheidung Chris, 2026-11-04: kein „abgelehnt mit Verweis“). Den Ergebnishinweis klickt die App mit einem Update von `result_seen_at` weg. Zustände für die App: pending / approved / merged / rejected.
|
||||||
|
|
||||||
|
**Funktionen** (alle mit `search_path = ''`; jede zuerst für `public`, `anon`, `authenticated`, `service_role` entzogen, danach nur der genannte Grant):
|
||||||
|
|
||||||
|
| Funktion | Art | `execute` | Zweck |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `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`; 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 |
|
||||||
|
|
||||||
|
**Fehlercodes** (`raise exception '<code>' using errcode = 'P0001'`; die App bildet sie auf Domänenfehler ab):
|
||||||
|
`report_missing_game`: `not_authenticated`, `report_title_required`, `report_title_too_long`, `report_publisher_too_long`, `report_year_invalid`, `report_link_invalid`, `report_list_invalid`, `report_daily_limit`, `report_similar_exists`, `report_provisional_in_use` (nur bei gleichzeitigem Konflikt auf den Teilindex; über die RPC praktisch nicht erreichbar, ein direkter Insert liefert 23505).
|
||||||
|
Tageslimit im Trigger: `report_daily_limit`.
|
||||||
|
|
||||||
|
**Rechte und Policies auf `game_reports`:** Insert-Recht nur auf `owner_id, title, publisher_name, year, link, provisional_game_id`; Update-Recht nur auf `result_seen_at`; kein Löschrecht. Nicht schreibbar (42501): `id`, `origin`, `status`, `bgg_id`, `target_game_id`, `resolved_at`, `editions_cleared`, `created_at`, `updated_at`. Policy `game_reports_insert_own`: `owner_id = auth.uid()`, `origin = 'user'`, `status = 'open'`, kein Ziel, `provisional_game_id` verweist auf ein eigenes privates, nicht ausgeblendetes und nicht zusammengeführtes Spiel. Policy `game_reports_update_seen`: nur eigene Meldungen mit `status <> 'open'`. Lesen unverändert (nur eigene).
|
||||||
|
|
||||||
|
**Tageslimit (Krit. 7):** Trigger `game_reports_daily_limit` (`before insert ... when (new.origin = 'user')`) lässt höchstens 10 Nutzer-Meldungen je Nutzer und Berliner Kalendertag zu (Advisory-Lock je Nutzer gegen gleichzeitige Inserts, setzt READ COMMITTED voraus, den Standard bei PostgREST). Import-Meldungen (`origin = 'import'`) werden weder gezählt noch begrenzt (N12). Der Trigger gilt für jede Rolle: Wer als `postgres` viele Nutzer-Meldungen „von heute“ anlegt (z. B. in Tests), stößt ebenfalls auf das Limit.
|
||||||
|
|
||||||
|
**`admin_resolve_report` seit KAT-04:** weist `rejected` mit Ziel ab (`report_reject_has_target`, P0001); der Check `game_reports_rejected_no_target` (`status <> 'rejected' or target_game_id is null`) sichert die Tabelle. Die Funktion bleibt `security invoker` und nur für `postgres`/`supabase_admin` ausführbar. Es gibt in 13a keine `security definer`-Funktion.
|
||||||
|
|
||||||
|
**Normalisierung (gemeinsam mit KAT-01):** `search_normalize(text)` (Migration `catalog_search`) ist die einzige Basis und entfernt nie Artikel. `normalize_game_title` und der Kandidatenschlüssel in `find_similar_catalog_games` entfernen zusätzlich einen führenden Artikel. Ein Titel, der nach `search_normalize` leer ist (nur nicht-lateinische Zeichen oder Sonderzeichen), fällt auf `lower(btrim(titel))` zurück.
|
||||||
|
|
||||||
|
**API-Änderungen für das Frontend (13b):** neue RPCs `find_similar_catalog_games` und `report_missing_game`; `game_reports` ist für `authenticated` jetzt beschreibbar, aber nur wie oben (nie mit `id`, `created_at`, `status`, `origin`, `target_game_id`, `resolved_at`, `bgg_id`, `editions_cleared` senden; die App schreibt über die RPCs und das Update von `result_seen_at` mit `{'result_seen_at': <ISO-8601 UTC>}`). Es gibt keine generierten Datenbanktypen im Repo.
|
||||||
|
|
||||||
## Katalogsuche (KAT-01, Migration `catalog_search`)
|
## Katalogsuche (KAT-01, Migration `catalog_search`)
|
||||||
|
|
||||||
Spec: `docs/specs/3-katalog-suche.md`. Die Suche liest ausschließlich über die RPC `search_catalog_games`, die ihrerseits die View `catalog_games` nutzt.
|
Spec: `docs/specs/3-katalog-suche.md`. Die Suche liest ausschließlich über die RPC `search_catalog_games`, die ihrerseits die View `catalog_games` nutzt.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
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`.
|
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 3a (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten, alternative Titel und Suche).
|
Stand: PR 13a (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten inkl. Nutzer-Meldungen, alternative Titel und Suche).
|
||||||
|
|
||||||
**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`, Abschnitte 3.1, 5 (Schritt 2b) und 8 in `09_catalog_search.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`, Abschnitte 3.1, 5 (Schritt 2b) und 8 in `09_catalog_search.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).
|
||||||
|
|
||||||
|
|
@ -398,8 +398,14 @@ Nutzer melden fehlende Spiele (KAT-04, #13); Meldungen, die ein Import anlegt, g
|
||||||
| Entscheidung | Aufruf | Wirkung |
|
| 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. |
|
| **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'`. |
|
| **zusammenführen / auf vorhandenen Eintrag verweisen** | `'merged'` + Ziel | Der Eintrag war **schon vorhanden**. Wirkung wie bei `'approved'`: der vorläufige Eintrag wird direkt umgehängt, ohne Aktion des Nutzers. **Das ist der einzige Weg, den Nutzer auf einen vorhandenen Eintrag zu verweisen.** |
|
||||||
| **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. |
|
| **ablehnen** | `'rejected'`, **nur ohne Ziel** | Ändert nichts außer Status und Zeitpunkt; der vorläufige Eintrag bleibt privat und unverändert. Mit Ziel scheitert der Aufruf (`report_reject_has_target`); auch die Tabelle erlaubt kein Ziel bei `rejected` (Check `game_reports_rejected_no_target`). *Entscheidung Chris, 2026-11-04: Es gibt kein „abgelehnt mit Verweis“ mehr.* |
|
||||||
|
|
||||||
|
**Nutzer-Meldungen (KAT-04, ab PR 13a):** Jede Meldung, die ein Nutzer in der App abschickt, hat einen **vorläufigen Eintrag** (`provisional_game_id`), sofern der Nutzer ihn nicht inzwischen gelöscht hat (dann ist die Spalte leer; `approved`/`merged` ändern dann nur Status und Ziel). Die Meldung entsteht über die RPC `report_missing_game` (höchstens 10 Meldungen je Nutzer und Berliner Kalendertag; Import-Meldungen zählen nicht). Daraus folgt für die Bearbeitung:
|
||||||
|
|
||||||
|
- **Mit Verweis auf einen vorhandenen Eintrag heißt `'merged'`, nie `'rejected'`.** Status nur über `admin_resolve_report` setzen, nie von Hand: Die App leitet den Hinweis an den Nutzer aus Status, `merged_into_id` am vorläufigen Eintrag und `result_seen_at` ab.
|
||||||
|
- **Vor `approved` und `merged` die Edition berücksichtigen** (Regel „Editionen werden nicht kopiert“ unten): Die App zeigt dem Nutzer nach einer Freigabe den Hinweis „Edition neu wählen“, wenn `editions_cleared > 0`.
|
||||||
|
- Der Nutzer kann nur `result_seen_at` ändern (Hinweis „Verstanden“); das Ergebnis der Bearbeitung kann er nicht beeinflussen.
|
||||||
|
|
||||||
**Regeln**
|
**Regeln**
|
||||||
|
|
||||||
|
|
@ -489,16 +495,12 @@ select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'appr
|
||||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004');
|
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:
|
**Schritt 3c: Ablehnen**, immer ohne Ziel (soll auf ein vorhandenes Katalogspiel verwiesen werden, ist es Schritt 3b mit `'merged'`):
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected');
|
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):
|
**Schritt 4: Nachprüfen.** Status der Meldung und Zustand des vorläufigen Eintrags (bei `approved`/`merged`: ausgeblendet und verwiesen; bei `rejected`: unverändert):
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
|
@ -528,12 +530,13 @@ where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
|
||||||
| `report_not_open` | Die Meldung ist schon bearbeitet (`approved`, `merged` oder `rejected`) |
|
| `report_not_open` | Die Meldung ist schon bearbeitet (`approved`, `merged` oder `rejected`) |
|
||||||
| `report_status_invalid` | Der Status ist nicht `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_required` | `approved` und `merged` brauchen ein Ziel |
|
||||||
|
| `report_reject_has_target` | `rejected` darf kein Ziel haben; soll auf ein vorhandenes Spiel verwiesen werden, `'merged'` verwenden |
|
||||||
| `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_target_not_listed` | Das Ziel ist kein sichtbares Katalogspiel (privat, ausgeblendet, zusammengeführt oder unbekannt); das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) |
|
||||||
| `report_provisional_not_private` | Der vorläufige Eintrag der Meldung ist kein privates Spiel **des Meldenden** (Datenfehler, z. B. ein Katalogspiel oder das Spiel eines anderen Nutzers); nicht umhängen, sondern die Meldung prüfen |
|
| `report_provisional_not_private` | Der vorläufige Eintrag der Meldung ist kein privates Spiel **des Meldenden** (Datenfehler, z. B. ein Katalogspiel oder das Spiel eines anderen Nutzers); nicht umhängen, sondern die Meldung prüfen |
|
||||||
|
|
||||||
**Falsch gesetzte `visibility` oder `owner_id`** lassen sich nicht mit `update` korrigieren (Trigger `games_visibility_immutable`, Fehler `visibility_immutable`). Korrigieren geht nur durch Löschen und Neuanlegen des Spiels oder, bewusst und nur als Tabelleneigentümer in einer Transaktion, mit `alter table public.games disable trigger games_visibility_immutable;` … `enable trigger …`. Für Clients: Der Fehler `P0001` kommt über PostgREST als HTTP 400 an (Hinweis für die Fehlerabbildung in #3 und #13).
|
**Falsch gesetzte `visibility` oder `owner_id`** lassen sich nicht mit `update` korrigieren (Trigger `games_visibility_immutable`, Fehler `visibility_immutable`). Korrigieren geht nur durch Löschen und Neuanlegen des Spiels oder, bewusst und nur als Tabelleneigentümer in einer Transaktion, mit `alter table public.games disable trigger games_visibility_immutable;` … `enable trigger …`. Für Clients: Der Fehler `P0001` kommt über PostgREST als HTTP 400 an (Hinweis für die Fehlerabbildung in #3 und #13).
|
||||||
|
|
||||||
Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Hinweis: Die Rolle `service_role` hat (Supabase-Standard) volle Tabellenrechte und umgeht RLS; die Zugriffsgrenzen in Krit. 9 gelten für die API-Rollen `anon` und `authenticated`, den Funktionen ist `service_role` ausdrücklich entzogen. Meldungen anlegen, ändern oder löschen können Nutzer in dieser Etappe nicht (das Anlegen kommt mit KAT-04); sie lesen nur ihre eigenen. Das Löschen eines Zielspiels, auf das eine Meldung verweist, schlägt mit `23503` fehl (gewollt: ausblenden statt löschen).
|
Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Hinweis: Die Rolle `service_role` hat (Supabase-Standard) volle Tabellenrechte und umgeht RLS; die Zugriffsgrenzen in Krit. 9 gelten für die API-Rollen `anon` und `authenticated`, den Funktionen ist `service_role` ausdrücklich entzogen. Nutzer können ab KAT-04 (PR 13a) eigene Meldungen nur über die RPC `report_missing_game` bzw. einen Insert mit eng begrenzten Spaltenrechten anlegen und an eigenen, bearbeiteten Meldungen nur `result_seen_at` ändern; Status, Ziel und Löschen bleiben ihnen verwehrt, 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
|
## 7. Admin-Rolle in der App
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| ☐ | 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 |
|
| ☐ | **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.
|
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`)
|
## 2a. Katalogsuche (Spec #3, KAT-01; Belege: `docs/specs/3-katalog-suche-umsetzung.md`)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Issue | #13 (KAT-04) |
|
| 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.** |
|
| 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 |
|
| Autor | architect |
|
||||||
| Stand | 2026-11-03 |
|
| Stand | 2026-11-03 |
|
||||||
| Etappe | R1.2 Sammlung |
|
| Etappe | R1.2 Sammlung |
|
||||||
|
|
@ -28,9 +28,9 @@ Diese Spec baut auf `game_reports` und `admin_resolve_report` aus Spec #2 § 3.4
|
||||||
| E2 | **Anlegen über die RPC `report_missing_game` (security invoker)**: Limit-Vorprüfung, Ähnlichkeitsprüfung, vorläufiger Eintrag, Meldung, optional Listeneintrag in einer Transaktion. | Getrennte Client-Aufrufe ließen verwaiste vorläufige Spiele zurück. `security invoker`: RLS, Spaltenrechte und Trigger gelten weiter. |
|
| E2 | **Anlegen über die RPC `report_missing_game` (security invoker)**: Limit-Vorprüfung, Ähnlichkeitsprüfung, vorläufiger Eintrag, Meldung, optional Listeneintrag in einer Transaktion. | Getrennte Client-Aufrufe ließen verwaiste vorläufige Spiele zurück. `security invoker`: RLS, Spaltenrechte und Trigger gelten weiter. |
|
||||||
| E3 | **Schutz liegt in der Datenbank:** Spaltenrechte auf `game_reports`, Insert-Policy, Limit-Trigger mit Sperre je Nutzer. | Ein direkter REST-Insert umgeht nur den UX-Schritt der Ähnlichkeitsprüfung, nicht Limit oder Rechte. `created_at` ist nicht schreibbar, das Limit lässt sich nicht durch ein falsches Datum umgehen. |
|
| E3 | **Schutz liegt in der Datenbank:** Spaltenrechte auf `game_reports`, Insert-Policy, Limit-Trigger mit Sperre je Nutzer. | Ein direkter REST-Insert umgeht nur den UX-Schritt der Ähnlichkeitsprüfung, nicht Limit oder Rechte. `created_at` ist nicht schreibbar, das Limit lässt sich nicht durch ein falsches Datum umgehen. |
|
||||||
| E4 | **Ähnlichkeitsprüfung serverseitig** über `find_similar_catalog_games` mit `extensions.similarity`. Grundlage sind die von #3 gepflegten Spalten `title_search` (Haupttitel in `games`, alternative Titel in `game_alt_titles`); 13a entfernt darüber nur führende Artikel (`strip_leading_article`, `normalize_game_title`). Nur sichtbare Katalogspiele. | Klärung 15 ist eine Datenbankregel. Eine einzige Normalisierung für Suche und Meldung (gemeinsamer Absatz in 3.3). Die RPC prüft beim Absenden noch einmal; ohne Bestätigung wird nicht gespeichert (Krit. 2). |
|
| E4 | **Ähnlichkeitsprüfung serverseitig** über `find_similar_catalog_games` mit `extensions.similarity`. Grundlage sind die von #3 gepflegten Spalten `title_search` (Haupttitel in `games`, alternative Titel in `game_alt_titles`); 13a entfernt darüber nur führende Artikel (`strip_leading_article`, `normalize_game_title`). Nur sichtbare Katalogspiele. | Klärung 15 ist eine Datenbankregel. Eine einzige Normalisierung für Suche und Meldung (gemeinsamer Absatz in 3.3). Die RPC prüft beim Absenden noch einmal; ohne Bestätigung wird nicht gespeichert (Krit. 2). |
|
||||||
| E5 | **„Abgelehnt mit Verweis“: Der Nutzer entscheidet selbst** über den Wechsel (Spec #2 Spec-F3) per `adopt_report_target` (security definer, Eigentümer `postgres`), streng geprüft; nutzt `repoint_game_references` aus 2b. | Krit. 5. Erster Weg, auf dem ein Nutzer `repoint_game_references` indirekt auslöst; deshalb ausführliche Prüfungen, feste Sperrreihenfolge und Rechte-Tests (4.4). |
|
| E5 | **Entscheidung Chris, 2026-11-04 (Option B, Spec-F10), ändert Spec #2 Spec-F3: Kein „abgelehnt mit Verweis“.** Wer auf einen vorhandenen Eintrag verweisen will, setzt `merged` (`admin_resolve_report` hängt direkt um, ohne Nutzeraktion). `rejected` gibt es nur ohne Ziel; `admin_resolve_report` weist `rejected` mit Ziel ab (`report_reject_has_target`), der Check `game_reports_rejected_no_target` sichert die Tabelle. Die frühere Funktion `adopt_report_target` (security definer) entfällt. | Der Nutzer muss nichts entscheiden; weniger Zustände in der App (nur pending / approved / merged / rejected) und keine Rechteausweitung durch eine Nutzer-RPC auf `repoint_game_references`. |
|
||||||
| E6 | **Ergebnishinweise werden aus vorhandenen Daten abgeleitet** und lassen sich wegklicken (`result_seen_at`, Spec-F5). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft. Fehlt der vorläufige Eintrag (vom Nutzer gelöscht), folgt das Ergebnis nur aus `status`. | Kein Benachrichtigungssystem in R1 (QUE-01 erst R2). |
|
| E6 | **Ergebnishinweise werden aus vorhandenen Daten abgeleitet** und lassen sich wegklicken (`result_seen_at`, Spec-F5). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft. Fehlt der vorläufige Eintrag (vom Nutzer gelöscht), folgt das Ergebnis nur aus `status`. | Kein Benachrichtigungssystem in R1 (QUE-01 erst R2). |
|
||||||
| E7 | **Edition (N15):** Empfehlung (Spec-F6) ist ein Zähler `game_reports.editions_cleared`, angelegt und gesetzt in **2c (PR #57, vor dem Merge)** aus dem schon vorhandenen `variants_cleared` von `repoint_game_references`; 2b bleibt unverändert; 13a setzt ihn in `adopt_report_target` genauso. Rückfall ist die Ableitung aus den Daten (nur Sammlung, `list = 'owned'`). | Der Zähler ist genau und erfasst Sammlung und Partien; der Nachtrag ist eine Spalte und eine Zuweisung im bestehenden Update. Nach dem Merge von 2c wäre ein `create or replace` von `admin_resolve_report` in 13a nötig. |
|
| E7 | **Edition (N15):** Empfehlung (Spec-F6) ist ein Zähler `game_reports.editions_cleared`, angelegt und gesetzt in **2c (PR #57, vor dem Merge)** aus dem schon vorhandenen `variants_cleared` von `repoint_game_references`; 2b bleibt unverändert; `adopt_report_target` entfällt (Spec-F10), ihn setzt nur `admin_resolve_report`. Rückfall ist die Ableitung aus den Daten (nur Sammlung, `list = 'owned'`). | Der Zähler ist genau und erfasst Sammlung und Partien; der Nachtrag ist eine Spalte und eine Zuweisung im bestehenden Update. Nach dem Merge von 2c wäre ein `create or replace` von `admin_resolve_report` in 13a nötig. |
|
||||||
| E8 | **Verlag bleibt Freitext** in `game_reports.publisher_name`. | `publishers` ist nur für den Admin schreibbar (Spec #2 3.2). |
|
| E8 | **Verlag bleibt Freitext** in `game_reports.publisher_name`. | `publishers` ist nur für den Admin schreibbar (Spec #2 3.2). |
|
||||||
| E9 | **Keine neuen Pakete, keine Edge Function.** `pg_trgm` kommt aus 3a (Spec #3 E14). | CLAUDE.md, ADR-0002. |
|
| E9 | **Keine neuen Pakete, keine Edge Function.** `pg_trgm` kommt aus 3a (Spec #3 E14). | CLAUDE.md, ADR-0002. |
|
||||||
| E10 | **Zwei PRs:** 13a Backend (`Refs #13`), 13b Flutter (`Closes #13`). 13b beginnt gegen ein gemocktes Repository, sobald der RPC-Vertrag (4.2–4.5) freigegeben ist. | Der Vertrag steht vollständig in dieser Spec. |
|
| E10 | **Zwei PRs:** 13a Backend (`Refs #13`), 13b Flutter (`Closes #13`). 13b beginnt gegen ein gemocktes Repository, sobald der RPC-Vertrag (4.2–4.5) freigegeben ist. | Der Vertrag steht vollständig in dieser Spec. |
|
||||||
|
|
@ -73,7 +73,7 @@ Aus Spec #2 § 3.4 (Spaltennamen werden so vorausgesetzt; weicht 2c ab, passt B1
|
||||||
- `game_reports` mit `id`, `owner_id` (FK → `profiles`, `on delete cascade`), `origin` (`user`/`import`, Standard `user`), `title` (1–200), `publisher_name` (≤ 200), `year` (1800–2100), `link` (≤ 500, `^https?://`), `bgg_id`, `provisional_game_id` (FK → `games`, `on delete set null`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`, `on delete restrict`), `resolved_at`, `created_at`, `updated_at`; Checks zu Status, Ziel und `resolved_at`.
|
- `game_reports` mit `id`, `owner_id` (FK → `profiles`, `on delete cascade`), `origin` (`user`/`import`, Standard `user`), `title` (1–200), `publisher_name` (≤ 200), `year` (1800–2100), `link` (≤ 500, `^https?://`), `bgg_id`, `provisional_game_id` (FK → `games`, `on delete set null`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`, `on delete restrict`), `resolved_at`, `created_at`, `updated_at`; Checks zu Status, Ziel und `resolved_at`.
|
||||||
- **Indizes aus 2c:** `(owner_id, created_at)` (Tageslimit), `game_reports_target_game_id_idx` (Hinweis am Zieleintrag), eindeutiger Teilindex auf `provisional_game_id` (E1). **13a legt keine eigenen Indizes an.**
|
- **Indizes aus 2c:** `(owner_id, created_at)` (Tageslimit), `game_reports_target_game_id_idx` (Hinweis am Zieleintrag), eindeutiger Teilindex auf `provisional_game_id` (E1). **13a legt keine eigenen Indizes an.**
|
||||||
- RLS: Lesen nur eigene, kein Insert/Update/Delete für `authenticated`.
|
- RLS: Lesen nur eigene, kein Insert/Update/Delete für `authenticated`.
|
||||||
- `admin_resolve_report(p_report, p_status, p_target)`: bei `approved`/`merged` Umhängen per `repoint_game_references`, am vorläufigen Eintrag `merged_into_id` und `hidden_at`; bei `rejected` nur der Verweis (oder nichts). Gibt die Zähler aus `repoint_game_references` zurück, darunter `variants_cleared` (geleerte Verweise in `collection_items` inkl. Kollisionsfall und in `plays`), speichert sie aber nicht (Stand PR #57).
|
- `admin_resolve_report(p_report, p_status, p_target)`: bei `approved`/`merged` Umhängen per `repoint_game_references`, am vorläufigen Eintrag `merged_into_id` und `hidden_at`; bei `rejected` nichts (ab 13a, Spec-F10: `rejected` nur ohne Ziel). Gibt die Zähler aus `repoint_game_references` zurück, darunter `variants_cleared` (geleerte Verweise in `collection_items` inkl. Kollisionsfall und in `plays`), speichert sie aber nicht (Stand PR #57).
|
||||||
- Trigger `games_visibility_immutable`: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14).
|
- Trigger `games_visibility_immutable`: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14).
|
||||||
|
|
||||||
Aus Spec #3 PR 3a: `search_normalize(text)`, die generierten Spalten `games.title_search` und `game_alt_titles.title_search`, die Tabelle `game_alt_titles` sowie die Erweiterungen `pg_trgm` und `unaccent`. Der Fremdschlüssel `game_alt_titles.game_id` und seine Einordnung im Fremdschlüssel-Wächter gehören zu #3.
|
Aus Spec #3 PR 3a: `search_normalize(text)`, die generierten Spalten `games.title_search` und `game_alt_titles.title_search`, die Tabelle `game_alt_titles` sowie die Erweiterungen `pg_trgm` und `unaccent`. Der Fremdschlüssel `game_alt_titles.game_id` und seine Einordnung im Fremdschlüssel-Wächter gehören zu #3.
|
||||||
|
|
@ -161,7 +161,6 @@ Beispiele (pgTAP; das genaue Ergebnis von `search_normalize` legt #3 fest): „D
|
||||||
| `report_day(timestamptz)` | Funktion, sql, immutable | `authenticated` | Berliner Kalendertag (3.2) |
|
| `report_day(timestamptz)` | Funktion, sql, immutable | `authenticated` | Berliner Kalendertag (3.2) |
|
||||||
| `find_similar_catalog_games(p_title text, p_year integer)` | RPC, security invoker | `authenticated` | Ähnliche Katalogspiele (Krit. 2) |
|
| `find_similar_catalog_games(p_title text, p_year integer)` | RPC, security invoker | `authenticated` | Ähnliche Katalogspiele (Krit. 2) |
|
||||||
| `report_missing_game(...)` | RPC, security invoker | `authenticated` | Meldung + vorläufiger Eintrag + optional Listeneintrag (Krit. 1, 2, 3, 7, 8) |
|
| `report_missing_game(...)` | RPC, security invoker | `authenticated` | Meldung + vorläufiger Eintrag + optional Listeneintrag (Krit. 1, 2, 3, 7, 8) |
|
||||||
| `adopt_report_target(p_report uuid)` | RPC, **security definer**, Eigentümer `postgres` | `authenticated` | Wechsel auf den Zieleintrag (Krit. 5) |
|
|
||||||
| `game_reports_daily_limit()` | Trigger-Funktion, security invoker | – | Tageslimit (Krit. 7) |
|
| `game_reports_daily_limit()` | Trigger-Funktion, security invoker | – | Tageslimit (Krit. 7) |
|
||||||
| Policies `game_reports_insert_own`, `game_reports_update_seen`, Spaltenrechte | RLS | – | 3.2 |
|
| Policies `game_reports_insert_own`, `game_reports_update_seen`, Spaltenrechte | RLS | – | 3.2 |
|
||||||
|
|
||||||
|
|
@ -224,32 +223,11 @@ 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 RPC `public.adopt_report_target`
|
### 4.4 entfällt (Entscheidung Chris, 2026-11-04, Option B, Spec-F10)
|
||||||
|
|
||||||
```
|
Die frühere RPC `adopt_report_target` (Wechsel auf den Zieleintrag nach „abgelehnt mit Verweis“) wird nicht gebaut. Stattdessen ändert 13a `admin_resolve_report` per `create or replace` (Rumpf aus 2c unverändert) um genau eine Prüfung: `p_status = 'rejected'` mit `p_target is not null` löst `report_reject_has_target` (P0001) aus. Zusätzlich verbietet der Check `game_reports_rejected_no_target` (`status <> 'rejected' or target_game_id is null`) ein Ziel bei abgelehnten Meldungen in der Tabelle. Der Verweis auf einen vorhandenen Eintrag läuft über `merged` (Umhängen durch den Admin, `repoint_game_references`, wie bei `approved`).
|
||||||
adopt_report_target(p_report uuid) returns jsonb -- Zähler aus repoint_game_references
|
|
||||||
```
|
|
||||||
|
|
||||||
`language plpgsql`, `security definer`, Eigentümer `postgres`, `set search_path = ''` (als Funktionseinstellung). Nötig, weil Nutzer `merged_into_id`/`hidden_at` nicht schreiben und `repoint_game_references` nicht ausführen dürfen. **Innerhalb der Funktion ist `current_user = postgres`;** deshalb darf sie keine `admin_*`-Funktion aufrufen (deren Rollenprüfung würde sonst bestehen), weder jetzt noch in späteren Änderungen. pgTAP prüft `prosecdef = true`, `proowner = postgres` und `proconfig` mit `search_path=""`.
|
|
||||||
|
|
||||||
**Sperrreihenfolge** (wie im 2c-Entwurf von `admin_resolve_report`, gegen Verklemmungen): zuerst die Meldung `select … for update`, danach beide Spiele `select … from games where id in (provisional, target) order by id for update`.
|
|
||||||
|
|
||||||
| Prüfung | Fehler |
|
|
||||||
|---|---|
|
|
||||||
| `auth.uid()` ist null | `not_authenticated` |
|
|
||||||
| Meldung existiert nicht oder gehört nicht `auth.uid()` (gleicher Fehler) | `report_not_found` |
|
|
||||||
| `status <> 'rejected'` oder `target_game_id is null` | `report_not_adoptable` |
|
|
||||||
| `provisional_game_id is null` (z. B. vom Nutzer gelöscht) | `report_no_provisional` |
|
|
||||||
| vorläufiger Eintrag ist nicht `private` mit `owner_id = auth.uid()` | `report_not_found` |
|
|
||||||
| vorläufiger Eintrag ist schon zusammengeführt | `report_already_adopted` |
|
|
||||||
| Ziel ist kein sichtbares Katalogspiel (`visibility = 'catalog' and hidden_at is null and merged_into_id is null`) | `report_target_not_listed` |
|
|
||||||
| Verweise auf den vorläufigen Eintrag mit fremdem oder fehlendem Eigentümer: `collection_items.owner_id`, `favorite_games.profile_id`, `plays.owner_id`, `score_sheets.owner_id` jeweils `is distinct from auth.uid()` (null zählt als fremd) | `report_foreign_references` |
|
|
||||||
|
|
||||||
Ablauf: `result := repoint_game_references(provisional, target)` (privat → Katalog, keine Varianten kopiert, gleichnamige abgebildet, sonst `null`; Spec #2 RB1/N15), danach am vorläufigen Eintrag `merged_into_id = target`, `hidden_at = coalesce(hidden_at, now())`. Die Meldung bleibt `rejected`. **`result_seen_at` bleibt unverändert (null)**, damit der Hinweis „Dein Eintrag zeigt jetzt auf …“ (inkl. Edition-Hinweis) am Zieleintrag einmal erscheint, bis der Nutzer „Verstanden“ wählt (5.4). Bei Spec-F6 = Zähler setzt die Funktion zusätzlich `editions_cleared = coalesce((result->>'variants_cleared')::int, 0)`, genau wie `admin_resolve_report`. Rückgabe: `result`.
|
|
||||||
|
|
||||||
**Wirkung auf Partien:** `plays.game_id` zeigt danach auf das Katalogspiel; Mitspieler (`play_players` mit `profile_id`) sehen in der Partie dann das Katalogspiel statt eines für sie unsichtbaren privaten Spiels. Das ist gewollt und gilt ebenso für `admin_resolve_report`.
|
|
||||||
|
|
||||||
### 4.5 Ergebnishinweis wegklicken
|
### 4.5 Ergebnishinweis wegklicken
|
||||||
|
|
||||||
|
|
@ -264,7 +242,7 @@ Kein RPC. Die App sendet `PATCH /rest/v1/game_reports?id=eq.<id>` mit genau `{'r
|
||||||
### 4.7 Doku
|
### 4.7 Doku
|
||||||
|
|
||||||
- `docs/datenmodell-r1.md`: `result_seen_at`, Spaltenrechte, Policies, Trigger, Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes; Verweis auf den gemeinsamen Absatz zur Normalisierung (3.3).
|
- `docs/datenmodell-r1.md`: `result_seen_at`, Spaltenrechte, Policies, Trigger, Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes; Verweis auf den gemeinsamen Absatz zur Normalisierung (3.3).
|
||||||
- `docs/katalog-pflege.md`, Abschnitt „Meldungen bearbeiten“ (2c) ergänzen: Nutzer-Meldungen haben einen vorläufigen Eintrag (sofern nicht gelöscht); bei „abgelehnt mit Verweis“ hängt der Admin nichts um, der Nutzer entscheidet selbst (`adopt_report_target`); vor `approved` die Edition berücksichtigen (Runbook 2c); Status nie von Hand setzen, nur über `admin_resolve_report`, weil die App den Hinweis aus Status und `merged_into_id` ableitet.
|
- `docs/katalog-pflege.md`, Abschnitt „Meldungen bearbeiten“ (2c) ergänzen: Nutzer-Meldungen haben einen vorläufigen Eintrag (sofern nicht gelöscht); Verweis auf einen vorhandenen Eintrag = `merged` (Spec-F10), `rejected` nur ohne Ziel; vor `approved` und `merged` die Edition berücksichtigen (Runbook 2c); Status nie von Hand setzen, nur über `admin_resolve_report`, weil die App den Hinweis aus Status und `merged_into_id` ableitet.
|
||||||
- `supabase/README.md`: neue RPCs und Rechte.
|
- `supabase/README.md`: neue RPCs und Rechte.
|
||||||
|
|
||||||
## 5. Frontend
|
## 5. Frontend
|
||||||
|
|
@ -282,8 +260,7 @@ features/game_reports/
|
||||||
report_outcome.dart sealed ReportOutcome (5.4) + reine Funktion outcomeOf(...)
|
report_outcome.dart sealed ReportOutcome (5.4) + reine Funktion outcomeOf(...)
|
||||||
game_report_draft.dart freezed: Eingaben + Validierung
|
game_report_draft.dart freezed: Eingaben + Validierung
|
||||||
similar_game.dart freezed: SimilarGame (gameId, title, matchedTitle, year, publishers, matchKind)
|
similar_game.dart freezed: SimilarGame (gameId, title, matchedTitle, year, publishers, matchKind)
|
||||||
game_report_failure.dart sealed: dailyLimit, similarExists, validation(field, code), notAdoptable,
|
game_report_failure.dart sealed: dailyLimit, similarExists, validation(field, code), offline, unknown
|
||||||
noProvisional, alreadyAdopted, targetNotListed, offline, unknown
|
|
||||||
game_report_repository.dart abstrakte Schnittstelle
|
game_report_repository.dart abstrakte Schnittstelle
|
||||||
data/
|
data/
|
||||||
supabase_game_report_repository.dart RPC-Aufrufe und Abfragen (4.2–4.6), explizite Parameter-Maps
|
supabase_game_report_repository.dart RPC-Aufrufe und Abfragen (4.2–4.6), explizite Parameter-Maps
|
||||||
|
|
@ -334,12 +311,9 @@ Ohne Treffer geht `checking` direkt in `submitting` (`p_confirmed_distinct = fal
|
||||||
| `pending` (Krit. 8) | `status = open` | „Gemeldet am …, wird geprüft“ | – | Meldung; vorläufiger Eintrag (Badge „vorläufig“) |
|
| `pending` (Krit. 8) | `status = open` | „Gemeldet am …, wird geprüft“ | – | Meldung; vorläufiger Eintrag (Badge „vorläufig“) |
|
||||||
| `approved` (Krit. 4) | `status = approved` | „Dein gemeldetes Spiel ist jetzt im Katalog. Deine Angaben wurden übernommen.“ | „Zum Spiel“, „Verstanden“ | Meldung; Eintrag am Zielspiel, **nur wenn** `provisional.merged_into_id = target` |
|
| `approved` (Krit. 4) | `status = approved` | „Dein gemeldetes Spiel ist jetzt im Katalog. Deine Angaben wurden übernommen.“ | „Zum Spiel“, „Verstanden“ | Meldung; Eintrag am Zielspiel, **nur wenn** `provisional.merged_into_id = target` |
|
||||||
| `merged` (Krit. 5) | `status = merged` | „Dein Spiel gab es schon im Katalog als „{title}“. Dein Eintrag zeigt jetzt dorthin.“ | „Zum Spiel“, „Verstanden“ | wie `approved` |
|
| `merged` (Krit. 5) | `status = merged` | „Dein Spiel gab es schon im Katalog als „{title}“. Dein Eintrag zeigt jetzt dorthin.“ | „Zum Spiel“, „Verstanden“ | wie `approved` |
|
||||||
| `rejectedWithTarget` (Krit. 5) | `status = rejected`, Ziel gesetzt, vorläufiger Eintrag vorhanden und nicht zusammengeführt | „Dein Spiel gibt es bereits als „{title}“.“ | „Zu „{title}“ wechseln“ (`adopt_report_target`, Bestätigungsdialog „Deine Angaben werden auf den Katalogeintrag übertragen.“), „Vorläufig behalten“ | Meldung; **nur** am vorläufigen Eintrag, nicht am Ziel |
|
|
||||||
| `rejectedReference` | `status = rejected`, Ziel gesetzt, vorläufiger Eintrag fehlt | „Dein Spiel gibt es bereits als „{title}“.“ | „Zum Spiel“ | nur Meldung |
|
|
||||||
| `rejectedAdopted` | `status = rejected`, Ziel gesetzt, `provisional.merged_into_id = target` | wie `merged` | „Zum Spiel“, „Verstanden“ | Meldung; Eintrag am Zielspiel bis „Verstanden“ (`result_seen_at` bleibt nach dem Wechsel null, 4.4) |
|
|
||||||
| `rejected` (Krit. 6) | `status = rejected`, kein Ziel | dauerhafte Markierung „Abgelehnt“ + „Dieses Spiel wird nicht in den Katalog aufgenommen. Dein Eintrag bleibt privat für dich.“ | „Verstanden“ (blendet nur den Text aus) | Meldung; vorläufiger Eintrag |
|
| `rejected` (Krit. 6) | `status = rejected`, kein Ziel | dauerhafte Markierung „Abgelehnt“ + „Dieses Spiel wird nicht in den Katalog aufgenommen. Dein Eintrag bleibt privat für dich.“ | „Verstanden“ (blendet nur den Text aus) | Meldung; vorläufiger Eintrag |
|
||||||
|
|
||||||
**Zusatz Edition (E7, N15)** bei `approved`, `merged`, `rejectedAdopted`: „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“
|
**Zusatz Edition (E7, N15)** bei `approved` und `merged`: „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“
|
||||||
|
|
||||||
- Bei Spec-F6 = Zähler (Empfehlung): wenn `editions_cleared > 0`. Der Text nennt Sammlung **und** Partien („in deiner Sammlung oder deinen Partien“).
|
- Bei Spec-F6 = Zähler (Empfehlung): wenn `editions_cleared > 0`. Der Text nennt Sammlung **und** Partien („in deiner Sammlung oder deinen Partien“).
|
||||||
- Rückfall Ableitung: wenn der vorläufige Eintrag mindestens eine eigene Variante hat **und** der Sammlungseintrag am Ziel mit `list = 'owned'` `variant_id = null` hat. Wunsch- und Einkaufsliste haben nach Klärung 28 nie eine Edition und werden nicht geprüft; Partien werden nicht einzeln geprüft (Grenze der Ableitung).
|
- Rückfall Ableitung: wenn der vorläufige Eintrag mindestens eine eigene Variante hat **und** der Sammlungseintrag am Ziel mit `list = 'owned'` `variant_id = null` hat. Wunsch- und Einkaufsliste haben nach Klärung 28 nie eine Edition und werden nicht geprüft; Partien werden nicht einzeln geprüft (Grenze der Ableitung).
|
||||||
|
|
@ -347,7 +321,7 @@ Ohne Treffer geht `checking` direkt in `submitting` (`p_confirmed_distinct = fal
|
||||||
|
|
||||||
**„Verstanden“** setzt `result_seen_at` (4.5); danach kein Hinweis mehr am Eintrag, in „Meine Meldungen“ bleibt der Status sichtbar.
|
**„Verstanden“** setzt `result_seen_at` (4.5); danach kein Hinweis mehr am Eintrag, in „Meine Meldungen“ bleibt der Status sichtbar.
|
||||||
|
|
||||||
**Hinweis „am Eintrag“:** `reportNoticeForGameProvider(gameId)` liefert den passenden Fall: am vorläufigen Eintrag (`provisional_game_id = gameId`) die Fälle `pending`, `rejectedWithTarget`, `rejected`; am Zielspiel (`target_game_id = gameId`, `result_seen_at is null`) nur `approved`, `merged`, `rejectedAdopted`, und nur wenn `provisional.merged_into_id = gameId`. `ReportOutcomeNotice` (kompakt/ausführlich) wird eingebaut in 13b in „Meine Meldungen“ und die Meldungsdetailseite, in #4/#6/#11 soweit gemergt, sonst übernehmen diese Stories den Einbau (Spec-F8, 9.4). Vorläufige Einträge zeigen ein Badge „vorläufig“ (Text) und den `GameCover`-Platzhalter.
|
**Hinweis „am Eintrag“:** `reportNoticeForGameProvider(gameId)` liefert den passenden Fall: am vorläufigen Eintrag (`provisional_game_id = gameId`) die Fälle `pending` und `rejected`; am Zielspiel (`target_game_id = gameId`, `result_seen_at is null`) nur `approved` und `merged`, und nur wenn `provisional.merged_into_id = gameId`. `ReportOutcomeNotice` (kompakt/ausführlich) wird eingebaut in 13b in „Meine Meldungen“ und die Meldungsdetailseite, in #4/#6/#11 soweit gemergt, sonst übernehmen diese Stories den Einbau (Spec-F8, 9.4). Vorläufige Einträge zeigen ein Badge „vorläufig“ (Text) und den `GameCover`-Platzhalter. Zustände nach der Entscheidung vom 2026-11-04: nur `pending`, `approved`, `merged`, `rejected` (ohne Ziel).
|
||||||
|
|
||||||
**Aktualität:** kein Realtime; Neuladen beim Öffnen und per Pull-to-Refresh.
|
**Aktualität:** kein Realtime; Neuladen beim Öffnen und per Pull-to-Refresh.
|
||||||
|
|
||||||
|
|
@ -380,7 +354,7 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We
|
||||||
| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` → zurück | Kandidatenliste, Checkbox aktiviert Button, Tastatur, Semantics | Playwright: Meldung „Catan“ zeigt „CATAN“ | `strip_leading_article`/`normalize_game_title`: Artikel de/en nur führend, Großbuchstaben, Sonderzeichen, Umlaute über `search_normalize`, nur Artikel, nicht-lateinischer Titel (Rückfall); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr; Trigramm ≥ 0,8 bei ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; fehlendes Jahr nach Spec-F2; **Treffer über `title_search` des alternativen Titels** (`matched_title`); Haupttitel über `games.title_search`; ausgeblendete, zusammengeführte, private Spiele nicht; Titel > 200 → leere Menge; höchstens 5, Sortierung; `report_similar_exists` ohne, Speichern mit Bestätigung | B1, B2, F2, U1, T1, T2 |
|
| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` → zurück | Kandidatenliste, Checkbox aktiviert Button, Tastatur, Semantics | Playwright: Meldung „Catan“ zeigt „CATAN“ | `strip_leading_article`/`normalize_game_title`: Artikel de/en nur führend, Großbuchstaben, Sonderzeichen, Umlaute über `search_normalize`, nur Artikel, nicht-lateinischer Titel (Rückfall); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr; Trigramm ≥ 0,8 bei ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; fehlendes Jahr nach Spec-F2; **Treffer über `title_search` des alternativen Titels** (`matched_title`); Haupttitel über `games.title_search`; ausgeblendete, zusammengeführte, private Spiele nicht; Titel > 200 → leere Menge; höchstens 5, Sortierung; `report_similar_exists` ohne, Speichern mit Bestätigung | B1, B2, F2, U1, T1, T2 |
|
||||||
| Krit. 3 | – | Bestätigung zeigt Liste; Badge „vorläufig“ | Playwright: gemeldetes Spiel in der Wunschliste (sofern #6/#8 da) | `p_list` legt `collection_items` an; B sieht nichts von A; `anon` nichts; Rollback bei Fehler im Listeneintrag | B1, B2, F2, F3 |
|
| Krit. 3 | – | Bestätigung zeigt Liste; Badge „vorläufig“ | Playwright: gemeldetes Spiel in der Wunschliste (sofern #6/#8 da) | `p_list` legt `collection_items` an; B sieht nichts von A; `anon` nichts; Rollback bei Fehler im Listeneintrag | B1, B2, F2, F3 |
|
||||||
| Krit. 4 | `outcomeOf`: `approved` mit/ohne Edition; vorläufiger Eintrag fehlt | Hinweis de/en, „Verstanden“ | Playwright: Testsetup bearbeitet per `admin_resolve_report(…, 'approved', …)` → Hinweis am Eintrag | nach `admin_resolve_report`: Sammlungseintrag von A am Ziel mit Liste/Notiz/Kaufdatum; Edition ohne gleichnamige Variante → null; private Variante für A lesbar; bei Zähler `editions_cleared` korrekt; `result_seen_at` setzbar | B2, F3, T1, T2 |
|
| Krit. 4 | `outcomeOf`: `approved` mit/ohne Edition; vorläufiger Eintrag fehlt | Hinweis de/en, „Verstanden“ | Playwright: Testsetup bearbeitet per `admin_resolve_report(…, 'approved', …)` → Hinweis am Eintrag | nach `admin_resolve_report`: Sammlungseintrag von A am Ziel mit Liste/Notiz/Kaufdatum; Edition ohne gleichnamige Variante → null; private Variante für A lesbar; bei Zähler `editions_cleared` korrekt; `result_seen_at` setzbar | B2, F3, T1, T2 |
|
||||||
| Krit. 5 | `outcomeOf`: `merged`, `rejectedWithTarget`, `rejectedReference`, `rejectedAdopted` | Hinweise, „wechseln“ mit Dialog, Hinweis nur am richtigen Eintrag | Playwright: abgelehnt mit Verweis → wechseln → Eintrag zeigt Zielspiel | `adopt_report_target`: hängt Sammlung und Partien um, setzt `merged_into_id`/`hidden_at`, Status bleibt `rejected`, `result_seen_at` bleibt null, bei Zähler `editions_cleared` gesetzt; Fehler `report_not_found` (B, unbekannt), `report_not_adoptable`, `report_no_provisional` (gelöschter Eintrag), `report_already_adopted`, `report_target_not_listed`, `report_foreign_references` (fremder Eigentümer **und** `plays.owner_id = null`, Fixture als `postgres`); `prosecdef`, `proowner = postgres`, `proconfig` `search_path=""`; `has_function_privilege` für `public`, `anon`, `authenticated`, `service_role` | B1, B2, F3, T1, T2 |
|
| Krit. 5 | `outcomeOf`: `merged` (mit/ohne Edition-Hinweis, vorläufiger Eintrag fehlt) | Hinweise am Eintrag und am Zielspiel, „Verstanden“ | Playwright: Testsetup bearbeitet per `admin_resolve_report(…, 'merged', …)` → Eintrag zeigt das Zielspiel | `admin_resolve_report(…, 'merged', ziel)`: Listeneintrag und Partien zeigen aufs Ziel, `merged_into_id`/`hidden_at` gesetzt, `result_seen_at` setzbar; `rejected` mit Ziel → `report_reject_has_target`; Check `game_reports_rejected_no_target` (Insert und Update) | B1, B2, F3, T1, T2 |
|
||||||
| Krit. 6 | `outcomeOf`: `rejected` ohne Ziel | Markierung bleibt nach „Verstanden“ | – | nach `admin_resolve_report(…, 'rejected')` bleibt das vorläufige Spiel privat und lesbar | B2, F3, T2 |
|
| Krit. 6 | `outcomeOf`: `rejected` ohne Ziel | Markierung bleibt nach „Verstanden“ | – | nach `admin_resolve_report(…, 'rejected')` bleibt das vorläufige Spiel privat und lesbar | B2, F3, T2 |
|
||||||
| Krit. 7 | Mapping `report_daily_limit` | Limit-Hinweis, Eingaben bleiben | – | **als `authenticated`** 10 Meldungen über `report_missing_game` ok, 11. → `report_daily_limit`, kein vorläufiges Spiel übrig; ebenso direkter Insert als `authenticated` (prüft, dass der Trigger `report_day` ausführen darf); Meldungen von gestern (`created_at` als `postgres` gesetzt) zählen nicht; `report_day` mit festen Zeitstempeln (`2026-01-15 23:30 UTC` → 16.01., `2026-07-15 21:59 UTC` → 15.07., `22:00 UTC` → 16.07.); `origin = 'import'` als `postgres` zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; Insert mit `created_at`, `origin` oder `status` → 42501 | B1, B2, F2, T1 |
|
| Krit. 7 | Mapping `report_daily_limit` | Limit-Hinweis, Eingaben bleiben | – | **als `authenticated`** 10 Meldungen über `report_missing_game` ok, 11. → `report_daily_limit`, kein vorläufiges Spiel übrig; ebenso direkter Insert als `authenticated` (prüft, dass der Trigger `report_day` ausführen darf); Meldungen von gestern (`created_at` als `postgres` gesetzt) zählen nicht; `report_day` mit festen Zeitstempeln (`2026-01-15 23:30 UTC` → 16.01., `2026-07-15 21:59 UTC` → 15.07., `22:00 UTC` → 16.07.); `origin = 'import'` als `postgres` zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; Insert mit `created_at`, `origin` oder `status` → 42501 | B1, B2, F2, T1 |
|
||||||
| Krit. 8 | – | Status „offen“ | – | Nutzer setzt `status`, `target_game_id`, `resolved_at` weder per Insert noch Update (42501); Update von `result_seen_at` bei `open` → 0 Zeilen | B2, T1 |
|
| Krit. 8 | – | Status „offen“ | – | Nutzer setzt `status`, `target_game_id`, `resolved_at` weder per Insert noch Update (42501); Update von `result_seen_at` bei `open` → 0 Zeilen | B2, T1 |
|
||||||
|
|
@ -399,7 +373,7 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We
|
||||||
|
|
||||||
| # | Agent | Aufgabe | abhängig von |
|
| # | Agent | Aufgabe | abhängig von |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| B1 | backend-dev | Migration nach 3.2–4.5: `strip_leading_article`, `normalize_game_title`, `report_day` (mit `execute` für `authenticated`), Spalte `result_seen_at`, Spaltenrechte, Policies, Trigger `game_reports_daily_limit` (`when (new.origin = 'user')`, Sperre), RPCs `find_similar_catalog_games` (Kandidatenschlüssel über `title_search`), `report_missing_game`, `adopt_report_target` (Sperrreihenfolge, `is distinct from`); Rechte nach 4.1 | #2 PR 2b und 2c gemergt; #3 PR 3a gemergt |
|
| B1 | backend-dev | Migration nach 3.2–4.3 und 4.5: `strip_leading_article`, `normalize_game_title`, `report_day` (mit `execute` für `authenticated`), Spalte `result_seen_at`, Spaltenrechte, Policies, Trigger `game_reports_daily_limit` (`when (new.origin='user')`, Sperre), RPCs `find_similar_catalog_games` (Kandidatenschlüssel über `title_search`) und `report_missing_game`; `admin_resolve_report` per `create or replace` mit `report_reject_has_target` und Check `game_reports_rejected_no_target` (4.4, Spec-F10); Rechte nach 4.1. **`adopt_report_target` entfällt.** | #2 PR 2b und 2c gemergt; #3 PR 3a gemergt |
|
||||||
| B2 | backend-dev | pgTAP `10_game_reports_user.test.sql` nach 7.1; Testbeschreibungen in `08` anpassen | B1 |
|
| B2 | backend-dev | pgTAP `10_game_reports_user.test.sql` nach 7.1; Testbeschreibungen in `08` anpassen | B1 |
|
||||||
| B3 | backend-dev | Doku nach 4.7 | B1 |
|
| B3 | backend-dev | Doku nach 4.7 | B1 |
|
||||||
| T1 | test-engineer | Review der pgTAP-Abdeckung; manueller Paralleltest des Limits dokumentieren | B2 |
|
| T1 | test-engineer | Review der pgTAP-Abdeckung; manueller Paralleltest des Limits dokumentieren | B2 |
|
||||||
|
|
@ -434,12 +408,12 @@ F1 und U1 starten nach Freigabe der Spec, parallel zu B1. Merge nur durch Chris.
|
||||||
| # | Risiko | Auswirkung | Gegenmaßnahme |
|
| # | Risiko | Auswirkung | Gegenmaßnahme |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| R1 | 2b/2c/#3 weichen von den beschriebenen Namen oder Rückgaben ab | 13a passt nicht | B1 liest die gemergten Migrationen zuerst; Abweichungen im PR nennen |
|
| R1 | 2b/2c/#3 weichen von den beschriebenen Namen oder Rückgaben ab | 13a passt nicht | B1 liest die gemergten Migrationen zuerst; Abweichungen im PR nennen |
|
||||||
| R2 | `adopt_report_target` ist `security definer` und erreicht `repoint_game_references` | Fehler würden fremde Daten berühren | Prüfungen 4.4 inkl. fremder Verweise mit `is distinct from`, feste Sperrreihenfolge, kein Aufruf von `admin_*`, Rechte- und Attribut-Tests. Zur Sperrreihenfolge: `adopt_report_target` und `admin_resolve_report` sperren erst die Meldung, dann die Spiele; `admin_merge_games` sperrt erst Spiele, dann Meldungen. Eine Verklemmung ist deshalb möglich, PostgreSQL erkennt sie und bricht eine Transaktion ab; Daten sind nicht gefährdet. |
|
| R2 | ~~`adopt_report_target` ist `security definer`~~ **entfällt (Spec-F10):** 13a enthält keine `security definer`-Funktion mehr; das Umhängen macht ausschließlich der Admin in `admin_resolve_report` (security invoker, nur `postgres`/`supabase_admin`). | – | – |
|
||||||
| R3 | Schwelle 0,8 ist bei kurzen Titeln streng, bei Reihen großzügig | zu wenige/zu viele Kandidaten | Klärung 15 verbindlich; Kandidatenschritt verlangt nur eine Bestätigung |
|
| R3 | Schwelle 0,8 ist bei kurzen Titeln streng, bei Reihen großzügig | zu wenige/zu viele Kandidaten | Klärung 15 verbindlich; Kandidatenschritt verlangt nur eine Bestätigung |
|
||||||
| R4 | Limit-Sperre wirkt nur unter READ COMMITTED | unter höherer Isolation 11+ Meldungen möglich | PostgREST-Standard; in 3.2 festgehalten; manueller Paralleltest |
|
| R4 | Limit-Sperre wirkt nur unter READ COMMITTED | unter höherer Isolation 11+ Meldungen möglich | PostgREST-Standard; in 3.2 festgehalten; manueller Paralleltest |
|
||||||
| R5 | Ableitung der Edition (Rückfall) ist ungenau | unnötige oder fehlende Hinweise, Partien nicht erfasst | Empfehlung Zähler `editions_cleared` (Spec-F6) |
|
| R5 | Ableitung der Edition (Rückfall) ist ungenau | unnötige oder fehlende Hinweise, Partien nicht erfasst | Empfehlung Zähler `editions_cleared` (Spec-F6) |
|
||||||
| R6 | Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11 | Krit. 4–6 sonst nur in „Meine Meldungen“ | Spec-F8 |
|
| R6 | Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11 | Krit. 4–6 sonst nur in „Meine Meldungen“ | Spec-F8 |
|
||||||
| R7 | Mehrfachmeldungen desselben Spiels | mehr Arbeit für Chris | Tageslimit; Ablehnen mit Verweis |
|
| R7 | Mehrfachmeldungen desselben Spiels | mehr Arbeit für Chris | Tageslimit; Mit Verweis auf den vorhandenen Eintrag `merged` setzen |
|
||||||
| R8 | Kein Realtime | Ergebnis erst beim nächsten Laden | akzeptiert |
|
| R8 | Kein Realtime | Ergebnis erst beim nächsten Laden | akzeptiert |
|
||||||
| R9 | `pg_trgm` bildet Trigramme abhängig von der Datenbank-Locale | Ähnlichkeitswerte könnten zwischen Umgebungen abweichen | Die Normalisierung liefert `a–z0–9`; nur der Rückfall `lower(btrim(t))` ist locale-abhängig. Akzeptiert (Spec #3 Spec-F7: nur de/en). |
|
| R9 | `pg_trgm` bildet Trigramme abhängig von der Datenbank-Locale | Ähnlichkeitswerte könnten zwischen Umgebungen abweichen | Die Normalisierung liefert `a–z0–9`; nur der Rückfall `lower(btrim(t))` ist locale-abhängig. Akzeptiert (Spec #3 Spec-F7: nur de/en). |
|
||||||
|
|
||||||
|
|
@ -458,10 +432,11 @@ Ohne andere Entscheidung gilt mit der Freigabe die Empfehlung. **Ausnahme Spec-F
|
||||||
| Spec-F7 | **Krit. 2:** auch eigene frühere Meldungen als ähnlich zeigen? | **Nein in R1.** | Kriterium nennt nur Katalogeinträge. |
|
| Spec-F7 | **Krit. 2:** auch eigene frühere Meldungen als ähnlich zeigen? | **Nein in R1.** | Kriterium nennt nur Katalogeinträge. |
|
||||||
| Spec-F8 | **Abhängigkeiten:** Soll #13 zusätzlich von #4 und #6 abhängen? | **Ja** (beide liegen ohnehin vorher); #11 übernimmt den Einbau selbst. | Sonst Zwischenlösungen, die später ersetzt werden. |
|
| Spec-F8 | **Abhängigkeiten:** Soll #13 zusätzlich von #4 und #6 abhängen? | **Ja** (beide liegen ohnehin vorher); #11 übernimmt den Einbau selbst. | Sonst Zwischenlösungen, die später ersetzt werden. |
|
||||||
| Spec-F9 | **Krit. 3:** vorausgewählte Liste? | **Sammlung.** | Häufigster Fall laut Story. |
|
| Spec-F9 | **Krit. 3:** vorausgewählte Liste? | **Sammlung.** | Häufigster Fall laut Story. |
|
||||||
|
| Spec-F10 | **Verweis auf vorhandenen Eintrag:** Soll es „abgelehnt mit Verweis“ geben, bei dem der Nutzer selbst wechselt? | **Entschieden 2026-11-04 durch Chris (Option B):** Nein. Verweis = `merged` (Admin hängt direkt um); `rejected` nur ohne Ziel. Ändert Spec #2 Spec-F3. | Weniger Zustände in der App, keine Nutzer-RPC mit Rechteausweitung. (Nummer F10, weil Spec-F9 oben schon die vorausgewählte Liste betrifft.) |
|
||||||
|
|
||||||
### 9.3 Entscheidungen innerhalb der Architektur (kein ADR nötig)
|
### 9.3 Entscheidungen innerhalb der Architektur (kein ADR nötig)
|
||||||
|
|
||||||
- RPC als Bündelung mit `security invoker`, Rechte in der Datenbank; nur `adopt_report_target` ist `security definer` (E2, E3, E5).
|
- RPC als Bündelung mit `security invoker`, Rechte in der Datenbank; keine `security definer`-Funktion (E2, E3, E5).
|
||||||
- Normalisierung gehört #3; 13a ergänzt nur den Artikel-Wrapper und den Kandidatenschlüssel über `title_search` (E4).
|
- Normalisierung gehört #3; 13a ergänzt nur den Artikel-Wrapper und den Kandidatenschlüssel über `title_search` (E4).
|
||||||
- Kein Realtime, keine Edge Function, keine neuen Pakete.
|
- Kein Realtime, keine Edge Function, keine neuen Pakete.
|
||||||
|
|
||||||
|
|
@ -494,7 +469,7 @@ Nachtrag am selben Tag: Normalisierung faltet Akzente per `translate()` (Spec-F3
|
||||||
|
|
||||||
- **B1:** `report_day` mit `execute` für `authenticated` (sonst 42501 im Trigger); Trigger mit `when (new.origin = 'user')`; Voraussetzung READ COMMITTED; pgTAP fügt als `authenticated` ein.
|
- **B1:** `report_day` mit `execute` für `authenticated` (sonst 42501 im Trigger); Trigger mit `when (new.origin = 'user')`; Voraussetzung READ COMMITTED; pgTAP fügt als `authenticated` ein.
|
||||||
- **B2/B3:** Normalisierung gehört Spec #3 (`search_normalize`); 13a nur `strip_leading_article` und Wrapper `normalize_game_title`; `translate()`-Faltung gestrichen; Regeln A und B auf `normalize_game_title`; alternative Titel verpflichtend; alle „falls #3“-Bedingungen entfernt (#3 vor 13a); Satz zur Zuständigkeit/Rechten; `unaccent`-Immutability als Konvention.
|
- **B2/B3:** Normalisierung gehört Spec #3 (`search_normalize`); 13a nur `strip_leading_article` und Wrapper `normalize_game_title`; `translate()`-Faltung gestrichen; Regeln A und B auf `normalize_game_title`; alternative Titel verpflichtend; alle „falls #3“-Bedingungen entfernt (#3 vor 13a); Satz zur Zuständigkeit/Rechten; `unaccent`-Immutability als Konvention.
|
||||||
- `adopt_report_target`: Sperrreihenfolge, `is distinct from`, Attribut- und Rechte-Tests, kein `admin_*`-Aufruf; `result_seen_at` bleibt null (Hinweis am Ziel nach Wechsel).
|
- (überholt durch Spec-F10) `adopt_report_target`: Sperrreihenfolge, `is distinct from`, Attribut- und Rechte-Tests, kein `admin_*`-Aufruf; `result_seen_at` bleibt null.
|
||||||
- Insert-Policy verlangt `provisional_game_id`; 23505 → `report_provisional_in_use`.
|
- Insert-Policy verlangt `provisional_game_id`; 23505 → `report_provisional_in_use`.
|
||||||
- Gelöschter vorläufiger Eintrag in `outcomeOf` (`rejectedReference`, nur Status); Hinweis am Ziel nur bei `merged_into_id = target`.
|
- Gelöschter vorläufiger Eintrag in `outcomeOf` (`rejectedReference`, nur Status); Hinweis am Ziel nur bei `merged_into_id = target`.
|
||||||
- Indizes aus 2c übernommen; Testdatei `10_…`; Tabellenzahl unverändert gegenüber `main`.
|
- Indizes aus 2c übernommen; Testdatei `10_…`; Tabellenzahl unverändert gegenüber `main`.
|
||||||
|
|
@ -509,3 +484,8 @@ Nachtrag am selben Tag: Normalisierung faltet Akzente per `translate()` (Spec-F3
|
||||||
- **W-2:** gemeinsamer Absatz zur Normalisierung wörtlich aus Spec #3 übernommen; veraltete Hinweise in 9.4/9.5 gestrichen.
|
- **W-2:** gemeinsamer Absatz zur Normalisierung wörtlich aus Spec #3 übernommen; veraltete Hinweise in 9.4/9.5 gestrichen.
|
||||||
- **W-3:** Vorbedingungen 13a (2b, 2c, 3a) und 13b (3b) präzisiert.
|
- **W-3:** Vorbedingungen 13a (2b, 2c, 3a) und 13b (3b) präzisiert.
|
||||||
- Hinweise: R9 (Locale) aufgenommen; Runbook-Satz in 4.7; `hidden_at = coalesce(hidden_at, now())`; Sperrreihenfolge gegenüber `admin_merge_games` in R2; optionale Bereichsform des Limits; Testbeschreibungen in `08` statt Erwartungen; Wrapper-Funktionen nur für `authenticated`.
|
- Hinweise: R9 (Locale) aufgenommen; Runbook-Satz in 4.7; `hidden_at = coalesce(hidden_at, now())`; Sperrreihenfolge gegenüber `admin_merge_games` in R2; optionale Bereichsform des Limits; Testbeschreibungen in `08` statt Erwartungen; Wrapper-Funktionen nur für `authenticated`.
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-11-04 – Entscheidung Chris (Option B, Spec-F10)
|
||||||
|
|
||||||
|
Kein „abgelehnt mit Verweis“ mehr. Geändert: E5, 4.1 (Zeile `adopt_report_target` entfällt), 4.4 (entfällt, ersetzt durch Änderung an `admin_resolve_report` und Check), 5.4 (Zustände `rejectedWithTarget`, `rejectedReference`, `rejectedAdopted` entfallen; Hinweis „am Eintrag“, Zusatz Edition), 7.1 Krit. 5, 8.1 B1, 9.1 R2 und R7, 9.2 neue Zeile Spec-F10, 9.3. Die Hinweise zu `adopt_report_target` in früheren Änderungsprotokoll-Einträgen sind überholt. Spec #2 trägt einen Nachtrag (Spec-F3).
|
||||||
38
docs/specs/13-umsetzung.md
Normal file
38
docs/specs/13-umsetzung.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Spec #13 – KAT-04 Fehlendes Spiel melden: Umsetzungs-Nachtrag PR 13a (Backend)
|
||||||
|
|
||||||
|
Ergänzt `docs/specs/13-fehlendes-spiel-melden.md` (die Spec selbst wird hier nicht geändert). Stand: 2026-11-04, Branch `feature/13a-fehlendes-spiel-backend` (die Spec nennt `feature/13a-meldungen-backend`), Migration `20261104100000_game_reports_user.sql`, Tests `supabase/tests/database/10_game_reports_user.test.sql`.
|
||||||
|
|
||||||
|
## 0. Entscheidung von Chris (2026-11-04, Option B, Spec-F10): kein „abgelehnt mit Verweis“
|
||||||
|
|
||||||
|
**Dies ist eine Entscheidung von Chris**, sie ändert Spec #2 Spec-F3. `adopt_report_target` (Funktion, Grants, Tests) wurde vollständig entfernt; ebenso entfallen die App-Zustände `rejectedWithTarget`, `rejectedReference` und `rejectedAdopted`. Wer auf einen vorhandenen Eintrag verweisen will, setzt `merged` (direktes Umhängen, ohne Nutzeraktion). `rejected` gibt es nur ohne Ziel.
|
||||||
|
|
||||||
|
- `admin_resolve_report` ist per `create or replace` ersetzt (Rumpf aus 2c unverändert, einzige Ergänzung: `rejected` mit `p_target` → `report_reject_has_target`, P0001; Kommentar zu `rejected` angepasst). Rechte-Revoke ausdrücklich wiederholt.
|
||||||
|
- Neuer Check `game_reports_rejected_no_target` (`status <> 'rejected' or target_game_id is null`). Verträglich mit `admin_merge_games` (hängt nur Ziele von `approved`/`merged` um) und `repoint_game_references`; keine Altdaten betroffen (2c ist nicht ausgerollt).
|
||||||
|
- Tests: `08` (Fixtures „rejected mit Ziel“ → `merged`, neuer Test für den Check, `rejected` mit Ziel → `report_reject_has_target`, Runbook-Baustein 3c ohne Ziel), `10` (Abschnitt „adopt“ ersetzt durch merged/rejected-Tests). `result_seen_at` (Spalte, Recht, Policy „Verstanden“) bleibt.
|
||||||
|
- Für 13b: nur noch Zustände pending / approved / merged / rejected (ohne Ziel); der Edition-Hinweis gilt bei approved und merged.
|
||||||
|
- Spec-F10 statt F9: Die Nummer F9 ist in der Spec schon durch die vorausgewählte Liste belegt.
|
||||||
|
|
||||||
|
## 1. Abweichungen und Präzisierungen zum Vertrag
|
||||||
|
|
||||||
|
- **Spec-F6 ist entschieden und umgesetzt (2c):** `game_reports.editions_cleared` und die Zuweisung in `admin_resolve_report` stehen schon in der Migration `game_reports` (PR 2c); 13a legt die Spalte nicht an.
|
||||||
|
- **Tageslimit gilt auch für `postgres`:** Der Trigger `game_reports_daily_limit` unterscheidet nicht nach Rolle (Spec 3.2). Fixtures, die als `postgres` mehr als 10 Nutzer-Meldungen „von heute“ für einen Nutzer anlegen, scheitern deshalb. Folge für die Tests: `08_game_reports.test.sql` schaltet den Trigger am Anfang ab (`alter table … disable trigger`, mit dem Rollback am Ende zurückgenommen); in `10` bekommen Fixtures für gestern/älter feste Zeitstempel. Außerdem ist `created_at` nach dem Anlegen unveränderlich (Trigger `set_updated_at`), alte Zeitstempel müssen also beim Einfügen gesetzt werden.
|
||||||
|
- **Limit-Zählung über `report_day(created_at) = report_day(now())`** (nicht die optionale Bereichsform aus Spec 3.2); bei höchstens 10 Zeilen je Nutzer und Tag spielt der Index `(owner_id, created_at)` keine Rolle.
|
||||||
|
- **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` 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).
|
||||||
|
|
||||||
|
## 3. Offene Punkte
|
||||||
|
|
||||||
|
- Paralleltest des Limits (zwei `psql`-Sitzungen unter READ COMMITTED) ist nicht automatisiert (Spec 7.1) und gehört zu T1.
|
||||||
|
|
@ -314,7 +314,7 @@ Rechte und Rollenprüfung wie `admin_merge_games`.
|
||||||
| `approved`/`merged` ohne `p_target` | `report_target_required` |
|
| `approved`/`merged` ohne `p_target` | `report_target_required` |
|
||||||
| `p_target` ist kein sichtbares Katalogspiel | `report_target_not_listed` |
|
| `p_target` ist kein sichtbares Katalogspiel | `report_target_not_listed` |
|
||||||
|
|
||||||
Ablauf: `status`, `target_game_id`, `resolved_at = now()` setzen. Hat die Meldung einen vorläufigen Eintrag, dann (Spec-F3)
|
Ablauf: `status`, `target_game_id`, `resolved_at = now()` setzen. Hat die Meldung einen vorläufigen Eintrag, dann (Spec-F3) **[Geändert 2026-11-04, Entscheidung Chris: „abgelehnt mit Verweis“ entfällt; `rejected` nur ohne Ziel, Verweis = `merged`. Siehe Nachtrag am Ende und `docs/specs/13-umsetzung.md`.]**
|
||||||
|
|
||||||
- bei `approved` und bei `merged`: `repoint_game_references(provisional, target)` (privat → Katalog, keine Varianten kopiert, RB1); der vorläufige Eintrag bekommt `merged_into_id = target`, `hidden_at = now()` (KAT-04 Krit. 4, mit der Einschränkung zur Edition aus 3.3),
|
- bei `approved` und bei `merged`: `repoint_game_references(provisional, target)` (privat → Katalog, keine Varianten kopiert, RB1); der vorläufige Eintrag bekommt `merged_into_id = target`, `hidden_at = now()` (KAT-04 Krit. 4, mit der Einschränkung zur Edition aus 3.3),
|
||||||
- bei `rejected` mit Ziel: nur `target_game_id` wird gespeichert, der vorläufige Eintrag bleibt unverändert; ob der Nutzer auf den Zieleintrag wechselt, entscheidet er in der App (KAT-04 Krit. 5, #13),
|
- bei `rejected` 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),
|
||||||
|
|
@ -467,7 +467,7 @@ KAT-05 hat in R1 keine eigenen Screens (keine Admin-UI, Klärung 44). Die App-An
|
||||||
| Krit. 4 (zusammengeführt, ausgeblendet) | Detailseite eines zusammengeführten Eintrags leitet auf den Zieleintrag weiter; ein ausgeblendeter Eintrag bleibt per Direktaufruf und aus Sammlung/Partien erreichbar und zeigt den Hinweis „nicht mehr im Katalog“ (Spec-F2) | KAT-02 (#4) |
|
| Krit. 4 (zusammengeführt, ausgeblendet) | Detailseite eines zusammengeführten Eintrags leitet auf den Zieleintrag weiter; ein ausgeblendeter Eintrag bleibt per Direktaufruf und aus Sammlung/Partien erreichbar und zeigt den Hinweis „nicht mehr im Katalog“ (Spec-F2) | KAT-02 (#4) |
|
||||||
| Krit. 10 (Platzhalter-Cover) | Platzhalter, wenn `image_path` leer | **Widget in 2a**, Einsatz in #3/#4 |
|
| Krit. 10 (Platzhalter-Cover) | Platzhalter, wenn `image_path` leer | **Widget in 2a**, Einsatz in #3/#4 |
|
||||||
| Krit. 10 (eigener Kurztext) | Anzeige von `description`, Sprachhinweis über `description_locale` (englische Oberfläche: „Beschreibung auf Deutsch“) | KAT-02 (#4) |
|
| Krit. 10 (eigener Kurztext) | Anzeige von `description`, Sprachhinweis über `description_locale` (englische Oberfläche: „Beschreibung auf Deutsch“) | KAT-02 (#4) |
|
||||||
| Krit. 5 (Ergebnis einer Meldung) | Hinweis am vorläufigen Eintrag; bei „abgelehnt mit Verweis“ Angebot, auf den Zieleintrag zu wechseln (Spec-F3); bei geleerter Edition Hinweis, sie neu zu wählen (3.3, N15) | KAT-04 (#13) |
|
| Krit. 5 (Ergebnis einer Meldung) | Hinweis am vorläufigen Eintrag; bei „abgelehnt mit Verweis“ Angebot, auf den Zieleintrag zu wechseln (Spec-F3); bei geleerter Edition Hinweis, sie neu zu wählen (3.3, N15) | KAT-04 (#13) | **[Geändert 2026-11-04: kein „abgelehnt mit Verweis“ und kein Wechsel-Angebot mehr; siehe Nachtrag.]**
|
||||||
|
|
||||||
**In 2a:** Widget `GameCover` in `app/lib/core/widgets/game_cover.dart` (wird von Katalog, Sammlung, Dashboard und Partien genutzt, deshalb `core/`).
|
**In 2a:** Widget `GameCover` in `app/lib/core/widgets/game_cover.dart` (wird von Katalog, Sammlung, Dashboard und Partien genutzt, deshalb `core/`).
|
||||||
|
|
||||||
|
|
@ -598,7 +598,7 @@ Alle neun Fragen hat Chris am 2026-11-03 entschieden („Alle wie empfohlen“).
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Spec-F1 | Zusammenführen bei Doppelbesitz | entschieden 2026-11-03 | **Option A:** Zieleintrag bleibt; leere Felder werden aus dem Quell-Eintrag gefüllt (Edition über die Variantenzuordnung); Sammlung vor Wunsch- vor Einkaufsliste (Klärung 34); Quell-Eintrag wird gelöscht. | 3.3 Schritt 1, 7.1 Krit. 4, B5, B6 |
|
| Spec-F1 | Zusammenführen bei Doppelbesitz | entschieden 2026-11-03 | **Option A:** Zieleintrag bleibt; leere Felder werden aus dem Quell-Eintrag gefüllt (Edition über die Variantenzuordnung); Sammlung vor Wunsch- vor Einkaufsliste (Klärung 34); Quell-Eintrag wird gelöscht. | 3.3 Schritt 1, 7.1 Krit. 4, B5, B6 |
|
||||||
| Spec-F2 | Sichtbarkeit ausgeblendeter Einträge | entschieden 2026-11-03 | **Option A:** nur aus Suche und Entdecken; Detailseite, Sammlungen und Partien zeigen sie weiter mit Hinweis „nicht mehr im Katalog“. Status „gesperrt“ erst bei Bedarf. | E3, 5, R4, KAT-02 (#4) |
|
| Spec-F2 | Sichtbarkeit ausgeblendeter Einträge | entschieden 2026-11-03 | **Option A:** nur aus Suche und Entdecken; Detailseite, Sammlungen und Partien zeigen sie weiter mit Hinweis „nicht mehr im Katalog“. Status „gesperrt“ erst bei Bedarf. | E3, 5, R4, KAT-02 (#4) |
|
||||||
| Spec-F3 | Umhängen bei „zusammengeführt“ / „abgelehnt mit Verweis“ | entschieden 2026-11-03 | **Option A:** „zusammengeführt“ hängt um (wie „freigegeben“); „abgelehnt mit Verweis“ speichert nur den Verweis. | 3.4, 4.4, 5, 7.1 Krit. 5, B8 |
|
| Spec-F3 | Umhängen bei „zusammengeführt“ / „abgelehnt mit Verweis“ | entschieden 2026-11-03 | **Option A:** „zusammengeführt“ hängt um (wie „freigegeben“); „abgelehnt mit Verweis“ speichert nur den Verweis. | 3.4, 4.4, 5, 7.1 Krit. 5, B8 | **[Geändert 2026-11-04 durch Chris (Option B): Der zweite Teil („abgelehnt mit Verweis speichert nur den Verweis“) gilt nicht mehr; siehe Nachtrag.]**
|
||||||
| Spec-F4 | Kennzeichnung des Startbestands | entschieden 2026-11-03 | **Option A:** alle gesetzten Felder gelten als manuell gepflegt. | E4, 3.2, SB8, R6, B1 |
|
| Spec-F4 | Kennzeichnung des Startbestands | entschieden 2026-11-03 | **Option A:** alle gesetzten Felder gelten als manuell gepflegt. | E4, 3.2, SB8, R6, B1 |
|
||||||
| Spec-F5 | Kurztexte | entschieden 2026-11-03 | **Deutsch**; Chris schreibt oder prüft KI-Entwürfe (nur aus Fakten, SB4); **Pflicht** für alle Startbestandsspiele. | E8, § 2, SB4, B3 |
|
| Spec-F5 | Kurztexte | entschieden 2026-11-03 | **Deutsch**; Chris schreibt oder prüft KI-Entwürfe (nur aus Fakten, SB4); **Pflicht** für alle Startbestandsspiele. | E8, § 2, SB4, B3 |
|
||||||
| Spec-F6 | Pflegeformat | entschieden 2026-11-03 | **Option A:** CSV + Deno-Skript; Datei nach Go-live eingefroren. | E9, 4.3, 4.4, R5, B3 |
|
| Spec-F6 | Pflegeformat | entschieden 2026-11-03 | **Option A:** CSV + Deno-Skript; Datei nach Go-live eingefroren. | E9, 4.3, 4.4, R5, B3 |
|
||||||
|
|
@ -641,7 +641,7 @@ Chris hat jede Wahl einzeln und ohne Änderung bestätigt (Kommentar im Issue #2
|
||||||
- **KAT-01 (#3):** Suche über `catalog_games` (bzw. eine RPC darauf); Tabelle für alternative Titel (Klärung 3) mit neuem `manual_fields`-Schlüssel; Spalte `alt_titles` der Startbestands-CSV auswerten.
|
- **KAT-01 (#3):** Suche über `catalog_games` (bzw. eine RPC darauf); Tabelle für alternative Titel (Klärung 3) mit neuem `manual_fields`-Schlüssel; Spalte `alt_titles` der Startbestands-CSV auswerten.
|
||||||
- **KAT-02 (#4):** Weiterleitung bei `merged_into_id`; bei `hidden_at` Detailseite weiter anzeigen mit Hinweis „nicht mehr im Katalog“ (Spec-F2); Quellenangabe aus `source_*` (KAT-02 Krit. 5); Sprachhinweis über `description_locale` (Startbestand: Deutsch). Mit #4 wird Krit. 10 abgenommen; danach schließt Chris #2.
|
- **KAT-02 (#4):** Weiterleitung bei `merged_into_id`; bei `hidden_at` Detailseite weiter anzeigen mit Hinweis „nicht mehr im Katalog“ (Spec-F2); Quellenangabe aus `source_*` (KAT-02 Krit. 5); Sprachhinweis über `description_locale` (Startbestand: Deutsch). Mit #4 wird Krit. 10 abgenommen; danach schließt Chris #2.
|
||||||
- **KAT-03 (#12):** Basisspiel-Verweis (Klärung 5) mit `manual_fields`-Schlüssel; `base_game_key` der CSV auswerten (SB7); Fremdschlüssel-Wächter für die neue Spalte entscheiden.
|
- **KAT-03 (#12):** Basisspiel-Verweis (Klärung 5) mit `manual_fields`-Schlüssel; `base_game_key` der CSV auswerten (SB7); Fremdschlüssel-Wächter für die neue Spalte entscheiden.
|
||||||
- **KAT-04 (#13):** Insert-Policy, Tageslimit, Ähnlichkeitsprüfung, Hinweis am vorläufigen Eintrag inkl. Wechsel auf den Zieleintrag bei „abgelehnt mit Verweis“ (Spec-F3), Herkunft „aus Import“ (N12). **Krit. 4 („eigene Angaben bleiben erhalten“) gilt für die Edition nur eingeschränkt** (RB1, Klärung N15): Ohne gleichnamige Variante am Katalogeintrag wird die Edition geleert; der Hinweis in der App sollte dann zum Neuwählen auffordern. Vorläufige Spiele ohne eigene ID anlegen (Spaltenrechte, 3.2); sie können nie öffentlich geschaltet werden (E14). **Bitte als Hinweis ins Issue #13 übernehmen.**
|
- **KAT-04 (#13):** Insert-Policy, Tageslimit, Ähnlichkeitsprüfung, Hinweis am vorläufigen Eintrag inkl. Wechsel auf den Zieleintrag bei „abgelehnt mit Verweis“ (Spec-F3), Herkunft „aus Import“ (N12). **Krit. 4 („eigene Angaben bleiben erhalten“) gilt für die Edition nur eingeschränkt** (RB1, Klärung N15): Ohne gleichnamige Variante am Katalogeintrag wird die Edition geleert; der Hinweis in der App sollte dann zum Neuwählen auffordern. Vorläufige Spiele ohne eigene ID anlegen (Spaltenrechte, 3.2); sie können nie öffentlich geschaltet werden (E14). **Bitte als Hinweis ins Issue #13 übernehmen.** **[Überholt 2026-11-04: kein Wechsel auf den Zieleintrag mehr, siehe Nachtrag.]**
|
||||||
- **PAR-04 (#18):** Sobald ein Wertungsbogen je Spiel und Nutzer erzwungen wird, Kollisionsregel in `repoint_game_references` ergänzen.
|
- **PAR-04 (#18):** Sobald ein Wertungsbogen je Spiel und Nutzer erzwungen wird, Kollisionsregel in `repoint_game_references` ergänzen.
|
||||||
- **SAM-02/SAM-04 (#7, #8):** Neue Felder an `collection_items` (Zustand, Standort, Tags, Priorität) in die Kollisionsregel Spec-F1 aufnehmen.
|
- **SAM-02/SAM-04 (#7, #8):** Neue Felder an `collection_items` (Zustand, Standort, Tags, Priorität) in die Kollisionsregel Spec-F1 aufnehmen.
|
||||||
- **SAM-08 (#25) und Import-Spec:** Import läuft als Rolle `catalog_importer` mit `set local shuffle.catalog_import = 'on'` (E4). **Achtung:** Läuft der Import über eine `security definer`-Funktion, ist `current_user` deren Eigentümer, nicht `catalog_importer`; die Ausnahme greift dann nicht. Der Import muss sich deshalb direkt als `catalog_importer` anmelden oder Funktionen nutzen, die `catalog_importer` gehören. Weiter: pflegt `catalog_source_id`, `source_*`, `source_synced_at`; **speichert Cover und Texte einer Quelle ohne Anzeige-Erlaubnis gar nicht** (E6, Pflicht); ignoriert beim Abgleich über `bgg_id` ausgeblendete und zusammengeführte Einträge; legt Import-Meldungen mit `origin = 'import'` an (N12, N12a); bekommt gezielt Rechte, die in R1 allen API-Rollen entzogen sind.
|
- **SAM-08 (#25) und Import-Spec:** Import läuft als Rolle `catalog_importer` mit `set local shuffle.catalog_import = 'on'` (E4). **Achtung:** Läuft der Import über eine `security definer`-Funktion, ist `current_user` deren Eigentümer, nicht `catalog_importer`; die Ausnahme greift dann nicht. Der Import muss sich deshalb direkt als `catalog_importer` anmelden oder Funktionen nutzen, die `catalog_importer` gehören. Weiter: pflegt `catalog_source_id`, `source_*`, `source_synced_at`; **speichert Cover und Texte einer Quelle ohne Anzeige-Erlaubnis gar nicht** (E6, Pflicht); ignoriert beim Abgleich über `bgg_id` ausgeblendete und zusammengeführte Einträge; legt Import-Meldungen mit `origin = 'import'` an (N12, N12a); bekommt gezielt Rechte, die in R1 allen API-Rollen entzogen sind.
|
||||||
|
|
@ -739,7 +739,17 @@ 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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`.
|
- **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`.
|
||||||
|
|
||||||
|
|
||||||
|
### 2026-11-04 – Nachtrag: Entscheidung Chris (Option B), ändert Spec-F3
|
||||||
|
|
||||||
|
**Entscheidung von Chris am 2026-11-04** (im Rahmen von KAT-04 PR 13a): Es gibt kein „abgelehnt mit Verweis“ mehr. Wer auf einen vorhandenen Eintrag verweisen will, setzt Status `merged` (hängt direkt um, ohne Aktion des Nutzers). `rejected` gibt es nur ohne Ziel.
|
||||||
|
|
||||||
|
- **Spec-F3** (Umhängen bei „zusammengeführt“ / „abgelehnt mit Verweis“): Der erste Teil (Option A: `merged` hängt um) bleibt; der zweite Teil („abgelehnt mit Verweis“ speichert nur den Verweis) entfällt.
|
||||||
|
- **3.4 / 4.4 (`admin_resolve_report`):** per `create or replace` (Migration `game_reports_user`, PR 13a) um die Prüfung `rejected` mit Ziel → `report_reject_has_target` (P0001) ergänzt, sonst unverändert; zusätzlich der Check `game_reports_rejected_no_target` an `game_reports`. Das Ziel wird bei `rejected` also nie gespeichert.
|
||||||
|
- **Krit. 5 / Hinweis in der App:** nur noch pending / approved / merged / rejected (ohne Ziel); kein Wechsel-Angebot und kein `adopt_report_target` (siehe `docs/specs/13-fehlendes-spiel-melden.md`, Spec-F10).
|
||||||
|
- Runbook `docs/katalog-pflege.md` Abschnitt 6 und `08_game_reports.test.sql` sind entsprechend angepasst (Fixtures „rejected mit Ziel“ → `merged`).
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ supabase test db # alle pgTAP-Tests ausführen
|
||||||
|
|
||||||
## Katalogsuche (KAT-01)
|
## Katalogsuche (KAT-01)
|
||||||
- `public.search_normalize(text)` (Normalisierung für Suche und Titelvergleich) und die RPC `public.search_catalog_games(…)` sind **für API-Rollen ausführbar** (`search_normalize`: `anon`, `authenticated`, `service_role`, weil generierte Spalten mit den Rechten des Schreibenden rechnen; `search_catalog_games`: `anon`, `authenticated`). Beides ist die Ausnahme von der Regel „Funktionen sind für niemanden aufrufbar“; die Rechte prüft `tests/database/09_catalog_search.test.sql`.
|
- `public.search_normalize(text)` (Normalisierung für Suche und Titelvergleich) und die RPC `public.search_catalog_games(…)` sind **für API-Rollen ausführbar** (`search_normalize`: `anon`, `authenticated`, `service_role`, weil generierte Spalten mit den Rechten des Schreibenden rechnen; `search_catalog_games`: `anon`, `authenticated`). Beides ist die Ausnahme von der Regel „Funktionen sind für niemanden aufrufbar“; die Rechte prüft `tests/database/09_catalog_search.test.sql`.
|
||||||
|
- **Fehlendes Spiel melden (KAT-04, `tests/database/10_game_reports_user.test.sql`):** `public.find_similar_catalog_games(titel, jahr)` und `public.report_missing_game(...)` sind nur für `authenticated` ausführbar (`anon` und `service_role` nicht); ebenso die Hilfsfunktionen `strip_leading_article`, `normalize_game_title` und `report_day`. Keine dieser Funktionen ist `security definer`. `admin_resolve_report` weist seit KAT-04 `rejected` mit Ziel ab (`report_reject_has_target`); ein Verweis auf einen vorhandenen Eintrag ist `merged` (Entscheidung Chris, 2026-11-04). Der Check `game_reports_rejected_no_target` sichert das in der Tabelle. `authenticated` darf in `game_reports` nur `owner_id, title, publisher_name, year, link, provisional_game_id` einfügen und nur `result_seen_at` ändern. Das Tageslimit (10 Nutzer-Meldungen je Berliner Tag) setzt ein Trigger durch, der unter READ COMMITTED (Standard bei PostgREST) wirkt; er gilt für jede Rolle, auch `postgres`. Vertrag und Fehlercodes: `docs/datenmodell-r1.md`, Abschnitt „Meldungen (KAT-04)“.
|
||||||
- Alternative Titel (`game_alt_titles`) sind für API-Rollen nur lesbar; Pflege über Studio (`docs/katalog-pflege.md`, Abschnitt 3.1) und die CSV-Spalte `alt_titles` (`de:Titel;en:Title`, `;` im Titel nicht erlaubt).
|
- Alternative Titel (`game_alt_titles`) sind für API-Rollen nur lesbar; Pflege über Studio (`docs/katalog-pflege.md`, Abschnitt 3.1) und die CSV-Spalte `alt_titles` (`de:Titel;en:Title`, `;` im Titel nicht erlaubt).
|
||||||
- Nach einem Image-Upgrade der Datenbank die Normalisierung prüfen (`docs/katalog-pflege.md`, Abschnitt 8).
|
- Nach einem Image-Upgrade der Datenbank die Normalisierung prüfen (`docs/katalog-pflege.md`, Abschnitt 8).
|
||||||
- Messskript für das Zeitbudget der Suche (nur lokal, nie gegen Staging/Produktion, bewusst nicht unter `tests/`): `perf/catalog_search_perf.sql`, Aufruf im Kopfkommentar.
|
- Messskript für das Zeitbudget der Suche (nur lokal, nie gegen Staging/Produktion, bewusst nicht unter `tests/`): `perf/catalog_search_perf.sql`, Aufruf im Kopfkommentar.
|
||||||
|
|
|
||||||
400
supabase/migrations/20261104100000_game_reports_user.sql
Normal file
400
supabase/migrations/20261104100000_game_reports_user.sql
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
-- KAT-04 / PR 13a: Fehlendes Spiel melden (Spec docs/specs/13-fehlendes-spiel-melden.md, Abschnitte 3 und 4).
|
||||||
|
-- Nur additiv. Enthält:
|
||||||
|
-- strip_leading_article, normalize_game_title Titel-Normalisierung über search_normalize (Spec 3.3)
|
||||||
|
-- report_day Berliner Kalendertag (Tageslimit, Krit. 7)
|
||||||
|
-- game_reports.result_seen_at Ergebnishinweis weggeklickt (Spec-F5)
|
||||||
|
-- Spaltenrechte und Policies auf game_reports Anlegen eigener Meldungen, Wegklicken des Hinweises
|
||||||
|
-- game_reports_daily_limit Trigger: höchstens 10 Nutzer-Meldungen je Berliner Kalendertag
|
||||||
|
-- find_similar_catalog_games RPC: sehr ähnliche Katalogspiele (Krit. 2)
|
||||||
|
-- report_missing_game RPC: Meldung + vorläufiger Eintrag + optional Listeneintrag
|
||||||
|
-- admin_resolve_report per create or replace: rejected nur ohne Ziel (Entscheidung Chris, 2026-11-04)
|
||||||
|
-- game_reports_rejected_no_target Check: abgelehnte Meldungen haben nie ein Ziel
|
||||||
|
-- Rechte-Regel (Spec #2 3.2): jede Funktion zuerst allen entziehen, danach nur die Grants aus Spec 4.1.
|
||||||
|
-- editions_cleared stammt aus 2c.
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 1. Normalisierung (Spec 3.3). search_normalize (3a) entfernt nie Artikel; das tun nur diese beiden.
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- Erwartet bereits normalisierten Text (Kleinbuchstaben, ein Leerzeichen als Trenner). Entfernt
|
||||||
|
-- einmal ein führendes Artikelwort samt Leerzeichen; ein Titel, der nur aus dem Artikel besteht
|
||||||
|
-- ("das"), hat kein folgendes Leerzeichen und bleibt unverändert.
|
||||||
|
create function public.strip_leading_article(t text)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
parallel safe
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
select regexp_replace(t,
|
||||||
|
'^(?:der|die|das|den|dem|des|ein|eine|einen|einem|einer|eines|the|a|an) ', '')
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create function public.normalize_game_title(t text)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
parallel safe
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
select coalesce(
|
||||||
|
nullif(public.strip_leading_article(public.search_normalize(t)), ''),
|
||||||
|
nullif(public.search_normalize(t), ''),
|
||||||
|
lower(btrim(t)))
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Berliner Kalendertag eines Zeitpunkts. Eigene Funktion, damit pgTAP die Tagesgrenze mit festen
|
||||||
|
-- Zeitstempeln prüfen kann. Rein rechnend; authenticated braucht das Recht, weil der Trigger
|
||||||
|
-- game_reports_daily_limit als security invoker mit der Rolle des Einfügenden läuft.
|
||||||
|
create function public.report_day(p_at timestamptz)
|
||||||
|
returns date
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
select (p_at at time zone 'Europe/Berlin')::date
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke execute on function public.strip_leading_article(text) from public, anon, authenticated, service_role;
|
||||||
|
revoke execute on function public.normalize_game_title(text) from public, anon, authenticated, service_role;
|
||||||
|
revoke execute on function public.report_day(timestamptz) from public, anon, authenticated, service_role;
|
||||||
|
grant execute on function public.strip_leading_article(text) to authenticated;
|
||||||
|
grant execute on function public.normalize_game_title(text) to authenticated;
|
||||||
|
grant execute on function public.report_day(timestamptz) to authenticated;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 2. game_reports: neue Spalte, Spaltenrechte, Policies (Spec 3.2)
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
alter table public.game_reports
|
||||||
|
add column result_seen_at timestamptz,
|
||||||
|
add constraint game_reports_result_seen_check check (result_seen_at is null or status <> 'open');
|
||||||
|
|
||||||
|
-- Bewusst gegenüber 2c erweitert: Nutzer legen eigene Meldungen an und ändern an eigenen, bereits
|
||||||
|
-- bearbeiteten Meldungen genau eine Spalte. Nicht schreibbar: id, origin, status, bgg_id,
|
||||||
|
-- target_game_id, resolved_at, editions_cleared, created_at, updated_at (42501); die Datenbank
|
||||||
|
-- setzt origin = 'user', status = 'open' und created_at = now() selbst. Kein delete-Recht.
|
||||||
|
grant insert (owner_id, title, publisher_name, year, link, provisional_game_id)
|
||||||
|
on public.game_reports to authenticated;
|
||||||
|
grant update (result_seen_at) on public.game_reports to authenticated;
|
||||||
|
|
||||||
|
create policy game_reports_insert_own on public.game_reports
|
||||||
|
for insert to authenticated
|
||||||
|
with check (
|
||||||
|
owner_id = (select auth.uid())
|
||||||
|
and origin = 'user'
|
||||||
|
and status = 'open'
|
||||||
|
and target_game_id is null
|
||||||
|
and provisional_game_id is not null
|
||||||
|
and exists (
|
||||||
|
select 1 from public.games g
|
||||||
|
where g.id = provisional_game_id
|
||||||
|
and g.visibility = 'private'
|
||||||
|
and g.owner_id = (select auth.uid())
|
||||||
|
and g.hidden_at is null
|
||||||
|
and g.merged_into_id is null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy game_reports_update_seen on public.game_reports
|
||||||
|
for update to authenticated
|
||||||
|
using (owner_id = (select auth.uid()) and status <> 'open')
|
||||||
|
with check (owner_id = (select auth.uid()) and status <> 'open');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 3. Tageslimit (Krit. 7, Klärung 27, N12)
|
||||||
|
-- Setzt READ COMMITTED voraus (Standard bei PostgREST): Die Zählung nach der Sperre sieht die
|
||||||
|
-- inzwischen bestätigten Meldungen der anderen Transaktion. Die Zahl 10 steht nur hier und in
|
||||||
|
-- report_missing_game (Vorprüfung); eine Änderung braucht eine neue Migration.
|
||||||
|
-- security invoker: Der Nutzer sieht per RLS alle eigenen Meldungen; Studio (postgres) umgeht RLS.
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
create function public.game_reports_daily_limit()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0));
|
||||||
|
if (select count(*) from public.game_reports r
|
||||||
|
where r.owner_id = new.owner_id and r.origin = 'user'
|
||||||
|
and public.report_day(r.created_at) = public.report_day(now())) >= 10 then
|
||||||
|
raise exception 'report_daily_limit' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Import-Meldungen (SAM-08) werden weder gezählt noch begrenzt (N12).
|
||||||
|
create trigger game_reports_daily_limit before insert on public.game_reports
|
||||||
|
for each row when (new.origin = 'user') execute function public.game_reports_daily_limit();
|
||||||
|
|
||||||
|
revoke execute on function public.game_reports_daily_limit() from public, anon, authenticated, service_role;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 4. RPC find_similar_catalog_games (Krit. 2, Klärung 15; Spec 3.3 und 4.3)
|
||||||
|
-- security invoker: es gelten die Policies des Aufrufers; gelesen wird über catalog_games (nur
|
||||||
|
-- sichtbare Katalogspiele). Kandidatentitel: Haupttitel (games.title_search) und alle alternativen
|
||||||
|
-- Titel (game_alt_titles.title_search). Regel A: gleicher Schlüssel, jedes Jahr. Regel B:
|
||||||
|
-- Trigramm-Ähnlichkeit >= 0.8 und Jahr +-1 (fehlt ein Jahr, ohne Jahresbedingung, Spec-F2).
|
||||||
|
-- extensions.similarity ausdrücklich, nicht der Operator % (der nutzt pg_trgm.similarity_threshold).
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
create function public.find_similar_catalog_games(p_title text, p_year integer default null)
|
||||||
|
returns table (
|
||||||
|
game_id uuid,
|
||||||
|
title text,
|
||||||
|
matched_title text,
|
||||||
|
year smallint,
|
||||||
|
publishers text[],
|
||||||
|
match_kind text,
|
||||||
|
similarity real
|
||||||
|
)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
with q as (
|
||||||
|
-- Jahr außerhalb 1800-2100 gilt wie null (kein Überlauf bei integer-Arithmetik, kein 22003)
|
||||||
|
select public.normalize_game_title(btrim(p_title)) as nk,
|
||||||
|
case when p_year between 1800 and 2100 then p_year end as y
|
||||||
|
where p_title is not null
|
||||||
|
and char_length(btrim(p_title)) between 1 and 200
|
||||||
|
),
|
||||||
|
cand as (
|
||||||
|
select c.id as game_id, c.title as game_title, c.year as game_year,
|
||||||
|
k.cand_title, k.cand_key, k.ord
|
||||||
|
from public.catalog_games c
|
||||||
|
join public.games g on g.id = c.id
|
||||||
|
cross join lateral (
|
||||||
|
select g.title as cand_title,
|
||||||
|
coalesce(nullif(public.strip_leading_article(g.title_search), ''), g.title_search, lower(btrim(g.title))) as cand_key,
|
||||||
|
0 as ord
|
||||||
|
union all
|
||||||
|
select a.title,
|
||||||
|
coalesce(nullif(public.strip_leading_article(a.title_search), ''), a.title_search),
|
||||||
|
1
|
||||||
|
from public.game_alt_titles a
|
||||||
|
where a.game_id = g.id
|
||||||
|
) k
|
||||||
|
),
|
||||||
|
scored as (
|
||||||
|
select cd.*, q.y, (cd.cand_key = q.nk) as is_same,
|
||||||
|
extensions.similarity(cd.cand_key, q.nk) as sim
|
||||||
|
from cand cd cross join q
|
||||||
|
where q.nk <> ''
|
||||||
|
),
|
||||||
|
hit as (
|
||||||
|
select distinct on (s.game_id)
|
||||||
|
s.game_id, s.game_title, s.game_year, s.cand_title, s.is_same, s.sim
|
||||||
|
from scored s
|
||||||
|
where s.is_same
|
||||||
|
or (s.sim >= 0.8
|
||||||
|
and (s.y is null or s.game_year is null or abs(s.game_year - s.y) <= 1))
|
||||||
|
order by s.game_id, s.is_same desc, s.sim desc, s.ord, s.cand_title
|
||||||
|
)
|
||||||
|
select h.game_id,
|
||||||
|
h.game_title,
|
||||||
|
h.cand_title,
|
||||||
|
h.game_year,
|
||||||
|
coalesce((select array_agg(p.name order by p.name_search, p.name)
|
||||||
|
from public.game_publishers gp
|
||||||
|
join public.publishers p on p.id = gp.publisher_id
|
||||||
|
where gp.game_id = h.game_id), '{}'::text[]),
|
||||||
|
case when h.is_same then 'same_title' else 'similar_title' end,
|
||||||
|
case when h.is_same then 1.0::real else h.sim end
|
||||||
|
from hit h
|
||||||
|
order by h.is_same desc, h.sim desc, h.game_title, h.game_id
|
||||||
|
limit 5
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke execute on function public.find_similar_catalog_games(text, integer) from public, anon, authenticated, service_role;
|
||||||
|
grant execute on function public.find_similar_catalog_games(text, integer) to authenticated;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 5. RPC report_missing_game (Krit. 1, 2, 3, 7, 8; Spec 4.2)
|
||||||
|
-- security invoker: RLS, Spaltenrechte und Trigger gelten weiter. Eine Transaktion: jeder Fehler
|
||||||
|
-- rollt alles zurück (kein verwaistes vorläufiges Spiel). p_year ist integer und wird selbst
|
||||||
|
-- geprüft (smallint würde bei großen Werten schon beim Aufruf mit 22003 scheitern).
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
create function public.report_missing_game(
|
||||||
|
p_title text,
|
||||||
|
p_publisher_name text default null,
|
||||||
|
p_year integer default null,
|
||||||
|
p_link text default null,
|
||||||
|
p_list text default null,
|
||||||
|
p_confirmed_distinct boolean default false
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_uid uuid := auth.uid();
|
||||||
|
v_title text;
|
||||||
|
v_publisher text;
|
||||||
|
v_link text;
|
||||||
|
v_game_id uuid;
|
||||||
|
v_report_id uuid;
|
||||||
|
v_item_id uuid;
|
||||||
|
v_constraint text;
|
||||||
|
begin
|
||||||
|
if v_uid is null then
|
||||||
|
raise exception 'not_authenticated' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_title := nullif(btrim(p_title, E' \t\r\n'), '');
|
||||||
|
v_publisher := nullif(btrim(p_publisher_name, E' \t\r\n'), '');
|
||||||
|
v_link := nullif(btrim(p_link, E' \t\r\n'), '');
|
||||||
|
|
||||||
|
if v_title is null then
|
||||||
|
raise exception 'report_title_required' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
if char_length(v_title) > 200 then
|
||||||
|
raise exception 'report_title_too_long' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
if char_length(v_publisher) > 200 then
|
||||||
|
raise exception 'report_publisher_too_long' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
if p_year is not null and p_year not between 1800 and 2100 then
|
||||||
|
raise exception 'report_year_invalid' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
if v_link is not null and (char_length(v_link) > 500 or v_link !~ '^https?://') then
|
||||||
|
raise exception 'report_link_invalid' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
if p_list is not null and p_list not in ('owned', 'wishlist', 'shopping') then
|
||||||
|
raise exception 'report_list_invalid' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Vorprüfung vor der Ähnlichkeit: Der Nutzer soll nicht erst Kandidaten bestätigen und dann am
|
||||||
|
-- Limit scheitern. Maßgeblich bleibt der Trigger (Sperre).
|
||||||
|
if (select count(*) from public.game_reports r
|
||||||
|
where r.owner_id = v_uid and r.origin = 'user'
|
||||||
|
and public.report_day(r.created_at) = public.report_day(now())) >= 10 then
|
||||||
|
raise exception 'report_daily_limit' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not coalesce(p_confirmed_distinct, false)
|
||||||
|
and exists (select 1 from public.find_similar_catalog_games(v_title, p_year)) then
|
||||||
|
raise exception 'report_similar_exists' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.games (owner_id, title, year, visibility)
|
||||||
|
values (v_uid, v_title, p_year, 'private')
|
||||||
|
returning id into v_game_id;
|
||||||
|
|
||||||
|
begin
|
||||||
|
insert into public.game_reports (owner_id, title, publisher_name, year, link, provisional_game_id)
|
||||||
|
values (v_uid, v_title, v_publisher, p_year, v_link, v_game_id)
|
||||||
|
returning id into v_report_id;
|
||||||
|
exception when unique_violation then
|
||||||
|
get stacked diagnostics v_constraint = constraint_name;
|
||||||
|
if v_constraint = 'game_reports_provisional_game_id_key' then
|
||||||
|
raise exception 'report_provisional_in_use' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
raise;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if p_list is not null then
|
||||||
|
insert into public.collection_items (owner_id, game_id, list)
|
||||||
|
values (v_uid, v_game_id, p_list)
|
||||||
|
returning id into v_item_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'report_id', v_report_id,
|
||||||
|
'provisional_game_id', v_game_id,
|
||||||
|
'collection_item_id', v_item_id);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke execute on function public.report_missing_game(text, text, integer, text, text, boolean)
|
||||||
|
from public, anon, authenticated, service_role;
|
||||||
|
grant execute on function public.report_missing_game(text, text, integer, text, text, boolean) to authenticated;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 6. Entscheidung Chris (2026-11-04), ändert Spec-F3 aus Spec #2: Es gibt kein "abgelehnt mit Verweis"
|
||||||
|
-- mehr. Wer auf einen vorhandenen Eintrag verweisen will, setzt "merged" (hängt direkt um, ohne
|
||||||
|
-- Nutzeraktion). "rejected" gibt es nur ohne Ziel. Rumpf von admin_resolve_report unverändert aus 2c,
|
||||||
|
-- ergänzt nur um die Prüfung report_reject_has_target (und den Kommentar zu "rejected").
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
create or replace 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 = 'rejected' and p_target is not null then
|
||||||
|
raise exception 'report_reject_has_target' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
if p_status in ('approved', 'merged') and p_target is null then
|
||||||
|
raise exception 'report_target_required' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Beteiligte Spiele in fester Reihenfolge sperren (wie admin_merge_games).
|
||||||
|
perform 1 from public.games g
|
||||||
|
where g.id in (p_target, rep.provisional_game_id) order by g.id for update;
|
||||||
|
|
||||||
|
if p_target is not null and not exists (
|
||||||
|
select 1 from public.games g
|
||||||
|
where g.id = p_target and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null
|
||||||
|
) then
|
||||||
|
raise exception 'report_target_not_listed' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_status in ('approved', 'merged') and rep.provisional_game_id is not null then
|
||||||
|
select * into prov from public.games g where g.id = rep.provisional_game_id;
|
||||||
|
-- Zusatzprüfung (nicht in der Spec): Nur ein privater Eintrag des Meldenden darf umgehängt
|
||||||
|
-- werden. Ein Katalogspiel würde sonst über den Variantenfall "Katalog" kopiert, und ein
|
||||||
|
-- fremdes privates Spiel (Datenfehler im Studio) würde Daten eines anderen Nutzers umhängen.
|
||||||
|
-- "not found" deckt eine fehlende Zeile ab (null <> 'private' wäre sonst nicht wahr).
|
||||||
|
if not found or prov.visibility <> 'private' or prov.owner_id is distinct from rep.owner_id then
|
||||||
|
raise exception 'report_provisional_not_private' using errcode = 'P0001';
|
||||||
|
end if;
|
||||||
|
-- privat -> Katalog: nichts wird kopiert (RB1, Klärung N15)
|
||||||
|
result := public.repoint_game_references(prov.id, p_target);
|
||||||
|
repointed := true;
|
||||||
|
-- merged_into_id verlangt hidden_at (Check), deshalb in einem Statement.
|
||||||
|
update public.games g
|
||||||
|
set merged_into_id = p_target, hidden_at = coalesce(g.hidden_at, now())
|
||||||
|
where g.id = prov.id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Bei "rejected" (nur ohne Ziel) bleibt der vorläufige Eintrag unverändert und privat.
|
||||||
|
update public.game_reports r
|
||||||
|
set status = p_status, target_game_id = p_target, resolved_at = now(),
|
||||||
|
editions_cleared = coalesce((result ->> 'variants_cleared')::integer, 0)
|
||||||
|
where r.id = rep.id;
|
||||||
|
|
||||||
|
return result || jsonb_build_object(
|
||||||
|
'report_id', rep.id,
|
||||||
|
'status', p_status,
|
||||||
|
'target_id', p_target,
|
||||||
|
'provisional_game_id', rep.provisional_game_id,
|
||||||
|
'repointed', repointed
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- create or replace behält die Rechte; der Revoke wird trotzdem ausdrücklich wiederholt (Rechte-Regel).
|
||||||
|
revoke execute on function public.admin_resolve_report(uuid, text, uuid) from public, anon, authenticated, service_role;
|
||||||
|
|
||||||
|
-- Absicherung in der Tabelle (Studio-Handgriff): abgelehnte Meldungen haben nie ein Ziel.
|
||||||
|
alter table public.game_reports
|
||||||
|
add constraint game_reports_rejected_no_target check (status <> 'rejected' or target_game_id is null);
|
||||||
|
|
@ -5,13 +5,19 @@
|
||||||
-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix 7c1d/f8), gezählt wird nur darüber.
|
-- 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.
|
-- Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall, sonst würde "alles verweigern" bestehen.
|
||||||
begin;
|
begin;
|
||||||
select plan(160);
|
select plan(163);
|
||||||
|
|
||||||
insert into auth.users (id, email, raw_user_meta_data) values
|
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"}'),
|
('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"}'),
|
('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"}');
|
('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Cara"}');
|
||||||
|
|
||||||
|
-- Ab #13 (KAT-04) begrenzt der Trigger game_reports_daily_limit Nutzer-Meldungen auf 10 je Berliner Tag und
|
||||||
|
-- Nutzer, auch für Einfügungen als postgres. Die Fixtures dieser Datei legen für einzelne Nutzer mehr als 10
|
||||||
|
-- Meldungen "von heute" an; das Limit prüft 10_game_reports_user.test.sql. Hier ist der Trigger abgeschaltet
|
||||||
|
-- (DDL, mit dem Rollback am Ende zurückgenommen).
|
||||||
|
alter table public.game_reports disable trigger game_reports_daily_limit;
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------------------------
|
||||||
-- Tabellenrechte (KAT-05 Kriterium 9)
|
-- Tabellenrechte (KAT-05 Kriterium 9)
|
||||||
-- ---------------------------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
|
@ -190,10 +196,14 @@ select throws_ok($$insert into public.game_reports (owner_id, title, status, res
|
||||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: approved braucht ein Ziel');
|
'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())$$,
|
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');
|
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: merged braucht ein Ziel');
|
||||||
|
-- Ab Entscheidung Chris (2026-11-04) gibt es kein "abgelehnt mit Verweis" mehr: rejected nur ohne Ziel.
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at, target_game_id)
|
||||||
|
values ('22222222-2222-2222-2222-222222222222', 'X', 'rejected', now(), '7c1d2c3d-0000-4000-8000-000000000004')$$,
|
||||||
|
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_rejected_no_target"', 'Check: rejected hat kein Ziel');
|
||||||
select lives_ok($$insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values
|
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-000000000a03', '22222222-2222-2222-2222-222222222222', 'merged mit Ziel', 'merged', now(), '7c1d2c3d-0000-4000-8000-000000000004'),
|
||||||
('f8000000-0000-0000-0000-000000000a04', '22222222-2222-2222-2222-222222222222', 'rejected ohne Ziel', 'rejected', now(), null)$$,
|
('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');
|
'Positiv: merged mit Ziel und rejected ohne Ziel sind zulässig');
|
||||||
select is((select origin || ':' || status || ':' || editions_cleared::text from public.game_reports where id = 'f8000000-0000-0000-0000-000000000a01'), 'user:open:0',
|
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');
|
'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)$$,
|
select throws_ok($$insert into public.game_reports (owner_id, title, editions_cleared) values ('22222222-2222-2222-2222-222222222222', 'X', -1)$$,
|
||||||
|
|
@ -253,7 +263,7 @@ select is((select count(*) || ':' || count(provisional_game_id) from public.game
|
||||||
-- eigenes Ziel, auf das nur eine Meldung verweist (sonst würde ein anderer Verweis die Prüfung vortäuschen)
|
-- 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.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
|
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');
|
('f8000000-0000-0000-0000-000000000c04', '22222222-2222-2222-2222-222222222222', 'Verweist auf Ziel', 'merged', now(), 'f8000000-0000-0000-0000-000000000c03');
|
||||||
select throws_ok($$delete from public.games where id = 'f8000000-0000-0000-0000-000000000c03'$$, '23503', null,
|
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)');
|
'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,
|
select is((select target_game_id from public.game_reports where id = 'f8000000-0000-0000-0000-000000000c04'), 'f8000000-0000-0000-0000-000000000c03'::uuid,
|
||||||
|
|
@ -386,7 +396,11 @@ select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-0
|
||||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-0000000000ff')$$,
|
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)');
|
'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')$$,
|
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');
|
'P0001', 'report_reject_has_target', 'KAT-05 Kriterium 5: rejected mit Ziel wird abgewiesen (Entscheidung Chris 2026-11-04), auch bei nicht gelistetem Ziel');
|
||||||
|
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004')$$,
|
||||||
|
'P0001', 'report_reject_has_target', 'KAT-05 Kriterium 5: rejected mit gültigem Ziel wird abgewiesen');
|
||||||
|
select is((select status from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), 'open',
|
||||||
|
'KAT-05 Kriterium 5: die abgewiesene Meldung bleibt offen');
|
||||||
|
|
||||||
-- Meldung MIT vorläufigem Eintrag und ungültigem Ziel: die Zielprüfung der Funktion greift zuerst
|
-- Meldung MIT vorläufigem Eintrag und ungültigem Ziel: die Zielprüfung der Funktion greift zuerst
|
||||||
-- (sonst käme repoint_target_not_listed aus der internen Funktion)
|
-- (sonst käme repoint_target_not_listed aus der internen Funktion)
|
||||||
|
|
@ -602,24 +616,24 @@ union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(m
|
||||||
create temp table rb_rej1 as
|
create temp table rb_rej1 as
|
||||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected') as r;
|
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected') as r;
|
||||||
create temp table rb_rej2 as
|
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 public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected') as r;
|
||||||
select is((select r - 'report_id' from rb_rej1),
|
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,
|
'{"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');
|
'KAT-05 Kriterium 5: Runbook 6, rejected ohne Ziel: nichts umgehängt, keine Zähler');
|
||||||
select is((select r - 'report_id' from rb_rej2),
|
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,
|
'{"status":"rejected","target_id":null,"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');
|
'KAT-05 Kriterium 5: Runbook 6, rejected ohne Ziel (zweite Meldung): 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'),
|
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');
|
'(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'),
|
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)');
|
'(rejected,,t)', 'rejected ohne Ziel (zweite Meldung): Status und Zeitpunkt gespeichert, kein Ziel');
|
||||||
select results_eq($$select 'c5', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c5'
|
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 '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 '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 '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'$$,
|
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$$,
|
$$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');
|
'KAT-05 Kriterium 5: bei rejected bleiben vorläufige Einträge, Sammlung und Partien unverändert');
|
||||||
|
|
||||||
-- approved ohne vorläufigen Eintrag: nur Status und Ziel
|
-- approved ohne vorläufigen Eintrag: nur Status und Ziel
|
||||||
create temp table rb_noprov as
|
create temp table rb_noprov as
|
||||||
|
|
@ -637,7 +651,7 @@ select is((select string_agg(id::text || '=' || editions_cleared::text, ',' orde
|
||||||
where id in ('7c1d2c3d-0000-4000-8000-000000000001', '7c1d2c3d-0000-4000-8000-000000000005', '7c1d2c3d-0000-4000-8000-000000000006',
|
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-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',
|
'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');
|
'Spec-F6: editions_cleared = 1 bei approved mit geleerter Edition (…01), 0 bei merged ohne geleerte Edition, bei rejected und ohne vorläufigen Eintrag');
|
||||||
-- Der Nutzer sieht das Ergebnis seiner Meldung (KAT-04 Krit. 4-6), aber keine fremden
|
-- 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);
|
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
|
||||||
set local role authenticated;
|
set local role authenticated;
|
||||||
|
|
@ -695,7 +709,7 @@ insert into public.games (id, title, visibility) values
|
||||||
('f8000000-0000-0000-0000-000000000f03', 'Anderes 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
|
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-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-000000000f12', '22222222-2222-2222-2222-222222222222', 'Zusammengeführt auf Quelle', 'merged', 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');
|
('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
|
insert into public.game_reports (id, owner_id, title) values
|
||||||
('f8000000-0000-0000-0000-000000000f14', '22222222-2222-2222-2222-222222222222', 'Offen');
|
('f8000000-0000-0000-0000-000000000f14', '22222222-2222-2222-2222-222222222222', 'Offen');
|
||||||
|
|
|
||||||
670
supabase/tests/database/10_game_reports_user.test.sql
Normal file
670
supabase/tests/database/10_game_reports_user.test.sql
Normal file
|
|
@ -0,0 +1,670 @@
|
||||||
|
-- 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,
|
||||||
|
-- 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
|
||||||
|
-- request.jwt.claims. Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall.
|
||||||
|
begin;
|
||||||
|
select plan(190);
|
||||||
|
|
||||||
|
insert into auth.users (id, email, raw_user_meta_data) values
|
||||||
|
('a1300000-0000-4000-8000-0000000000a1', 'kat04-a@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KA"}'),
|
||||||
|
('a1300000-0000-4000-8000-0000000000b2', 'kat04-b@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KB"}'),
|
||||||
|
('a1300000-0000-4000-8000-0000000000c3', 'kat04-c@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KC"}'),
|
||||||
|
('a1300000-0000-4000-8000-0000000000d4', 'kat04-d@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KD"}'),
|
||||||
|
('a1300000-0000-4000-8000-0000000000e1', 'kat04-l1@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"L1"}'),
|
||||||
|
('a1300000-0000-4000-8000-0000000000e2', 'kat04-l2@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"L2"}'),
|
||||||
|
('a1300000-0000-4000-8000-0000000000e3', 'kat04-l3@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"L3"}'),
|
||||||
|
('a1300000-0000-4000-8000-0000000000e4', 'kat04-y@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KY"}'),
|
||||||
|
('a1300000-0000-4000-8000-0000000000e5', 'kat04-t1@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"T1"}'),
|
||||||
|
('a1300000-0000-4000-8000-0000000000e6', 'kat04-t2@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"T2"}');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 1. Funktionen: Rechte, Attribute (Spec 4.1)
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
create temp table fn (sig text primary key, names text, granted boolean);
|
||||||
|
grant all on fn to public;
|
||||||
|
insert into fn values
|
||||||
|
('public.strip_leading_article(text)', 'strip_leading_article', true),
|
||||||
|
('public.normalize_game_title(text)', 'normalize_game_title', true),
|
||||||
|
('public.report_day(timestamptz)', 'report_day', true),
|
||||||
|
('public.find_similar_catalog_games(text, integer)', 'find_similar_catalog_games', true),
|
||||||
|
('public.report_missing_game(text, text, integer, text, text, boolean)', 'report_missing_game', true),
|
||||||
|
('public.game_reports_daily_limit()', 'game_reports_daily_limit', false);
|
||||||
|
|
||||||
|
select is_empty($$select f.sig, r from fn f, unnest(array['anon', 'service_role']) r
|
||||||
|
where has_function_privilege(r, f.sig::regprocedure, 'execute')$$,
|
||||||
|
'Sicherheit Kriterium 2: anon und service_role dürfen keine der neuen Funktionen ausführen');
|
||||||
|
select is_empty($$select f.sig from fn f
|
||||||
|
where has_function_privilege('authenticated', f.sig::regprocedure, 'execute') <> f.granted$$,
|
||||||
|
'KAT-04: authenticated darf genau die RPCs und Hilfsfunktionen ausführen, die Trigger-Funktion nicht');
|
||||||
|
select is_empty($$select f.sig from fn f join pg_proc p on p.oid = f.sig::regprocedure
|
||||||
|
where p.proacl is null or exists (select 1 from aclexplode(p.proacl) a where a.grantee = 0)$$,
|
||||||
|
'KAT-04: keine neue Funktion ist für PUBLIC ausführbar (ACL gesetzt, kein Eintrag für grantee 0)');
|
||||||
|
select is_empty($$select f.sig from fn f join pg_proc p on p.oid = f.sig::regprocedure where p.prosecdef$$,
|
||||||
|
'KAT-04: keine der neuen Funktionen ist security definer (Entscheidung Chris 2026-11-04: kein adopt_report_target)');
|
||||||
|
select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'adopt_report_target'), 0::bigint,
|
||||||
|
'KAT-04 Kriterium 5: es gibt keine Funktion adopt_report_target');
|
||||||
|
select is_empty($$select f.sig from fn f join pg_proc p on p.oid = f.sig::regprocedure
|
||||||
|
where p.proconfig is null or not (p.proconfig @> array['search_path=""'])$$,
|
||||||
|
'KAT-04: alle neuen Funktionen haben search_path = "" als Funktionseinstellung');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 2. Normalisierung (Krit. 2, Klärung 15)
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
select is(public.strip_leading_article('die siedler von catan'), 'siedler von catan', 'KAT-04 Kriterium 2: führendes "die" entfällt');
|
||||||
|
select is(public.strip_leading_article('the crew'), 'crew', 'KAT-04 Kriterium 2: führendes "the" entfällt');
|
||||||
|
select is(public.strip_leading_article('a game of thrones'), 'game of thrones', 'KAT-04 Kriterium 2: führendes "a" entfällt');
|
||||||
|
select is(public.strip_leading_article('an apple'), 'apple', 'KAT-04 Kriterium 2: führendes "an" entfällt');
|
||||||
|
select is(public.strip_leading_article('einem haus'), 'haus', 'KAT-04 Kriterium 2: auch "einem" entfällt');
|
||||||
|
select is(public.strip_leading_article('das'), 'das', 'KAT-04 Kriterium 2: ein Titel nur aus dem Artikel bleibt unverändert');
|
||||||
|
select is(public.strip_leading_article('theater der welt'), 'theater der welt', 'KAT-04 Kriterium 2: kein Artikel, wenn das Wort nur damit beginnt');
|
||||||
|
select is(public.strip_leading_article('herr der ringe'), 'herr der ringe', 'KAT-04 Kriterium 2: Artikel mitten im Titel bleiben');
|
||||||
|
select is(public.strip_leading_article('der die das'), 'die das', 'KAT-04 Kriterium 2: nur einmal ein Artikel');
|
||||||
|
select is(public.strip_leading_article(null), null, 'KAT-04: strip_leading_article(null) ist null');
|
||||||
|
|
||||||
|
select is(public.normalize_game_title('Die Siedler von Catan'), public.normalize_game_title('siedler von catan'),
|
||||||
|
'KAT-04 Kriterium 2: "Die Siedler von Catan" und "siedler von catan" ergeben denselben Wert');
|
||||||
|
select is(public.normalize_game_title('CATAN!'), public.normalize_game_title('Catan'), 'KAT-04 Kriterium 2: Großbuchstaben und Sonderzeichen sind egal');
|
||||||
|
select is(public.normalize_game_title('The Crew'), public.normalize_game_title('Crew'), 'KAT-04 Kriterium 2: "The Crew" = "Crew"');
|
||||||
|
select is(public.normalize_game_title('Das'), 'das', 'KAT-04 Kriterium 2: ein Titel nur aus einem Artikel bleibt nicht leer');
|
||||||
|
select is(public.normalize_game_title('The'), 'the', 'KAT-04 Kriterium 2: auch "The" allein bleibt nicht leer');
|
||||||
|
select is(public.normalize_game_title('Mölkky'), public.normalize_game_title('Molkky'), 'KAT-04 Kriterium 2: Umlaute werden über search_normalize gefaltet');
|
||||||
|
select is(public.normalize_game_title('日本語'), '日本語', 'KAT-04 Kriterium 2: nicht-lateinischer Titel fällt auf lower(btrim) zurück');
|
||||||
|
select is(public.normalize_game_title(' !!! '), '!!!', 'KAT-04 Kriterium 2: Titel nur aus Sonderzeichen fällt auf lower(btrim) zurück');
|
||||||
|
select is(public.normalize_game_title(null), null, 'KAT-04: normalize_game_title(null) ist null');
|
||||||
|
|
||||||
|
select is(public.report_day('2026-01-15 23:30:00+00'), '2026-01-16'::date, 'KAT-04 Kriterium 7: 23:30 UTC im Winter ist schon der nächste Berliner Tag');
|
||||||
|
select is(public.report_day('2026-01-15 22:59:00+00'), '2026-01-15'::date, 'KAT-04 Kriterium 7: 22:59 UTC im Winter ist noch derselbe Tag');
|
||||||
|
select is(public.report_day('2026-07-15 21:59:00+00'), '2026-07-15'::date, 'KAT-04 Kriterium 7: 21:59 UTC im Sommer ist noch derselbe Tag');
|
||||||
|
select is(public.report_day('2026-07-15 22:00:00+00'), '2026-07-16'::date, 'KAT-04 Kriterium 7: 22:00 UTC im Sommer ist der nächste Tag');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 3. Rechte, Policies, Trigger auf game_reports (Spec 3.2)
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
select is((select array_agg(a.attname::text order by a.attname) from pg_attribute a
|
||||||
|
where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped
|
||||||
|
and has_column_privilege('authenticated', 'public.game_reports', a.attname, 'insert')),
|
||||||
|
array['link', 'owner_id', 'provisional_game_id', 'publisher_name', 'title', 'year'],
|
||||||
|
'Sicherheit Kriterium 1: authenticated darf beim Anlegen genau owner_id, title, publisher_name, year, link, provisional_game_id setzen');
|
||||||
|
select is((select array_agg(a.attname::text order by a.attname) from pg_attribute a
|
||||||
|
where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped
|
||||||
|
and has_column_privilege('authenticated', 'public.game_reports', a.attname, 'update')),
|
||||||
|
array['result_seen_at'],
|
||||||
|
'KAT-04 Kriterium 8: authenticated darf genau result_seen_at ändern');
|
||||||
|
select is_empty($$select a.attname, p from pg_attribute a, unnest(array['select', 'insert', 'update', 'references']) p
|
||||||
|
where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped
|
||||||
|
and has_column_privilege('anon', 'public.game_reports', a.attname, p)$$,
|
||||||
|
'Sicherheit Kriterium 2: anon hat auf keine Spalte von game_reports ein Recht');
|
||||||
|
select is_empty($$select p from unnest(array['delete', 'truncate']) p where has_table_privilege('authenticated', 'public.game_reports', p)$$,
|
||||||
|
'Sicherheit Kriterium 1: authenticated darf Meldungen nicht löschen');
|
||||||
|
select is((select array_agg(policyname::text order by policyname) from pg_policies where tablename = 'game_reports' and cmd in ('INSERT', 'UPDATE')),
|
||||||
|
array['game_reports_insert_own', 'game_reports_update_seen'], 'KAT-04: genau eine Insert- und eine Update-Policy');
|
||||||
|
select ok((select pg_get_triggerdef(t.oid) ~ 'BEFORE INSERT ON public.game_reports FOR EACH ROW WHEN \(\(?new.origin = ''user''::text\)?\)'
|
||||||
|
from pg_trigger t where t.tgname = 'game_reports_daily_limit' and t.tgrelid = 'public.game_reports'::regclass),
|
||||||
|
'KAT-04 Kriterium 7: der Limit-Trigger läuft vor dem Insert und nur für origin = user');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 4. Katalog-Fixtures (postgres)
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
insert into public.games (id, title, year, visibility) values
|
||||||
|
('f13c0000-0000-4000-8000-000000000001', 'Quorblaxwitz Towers', 2020, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000002', 'Die Zyxqwvland Saga', 2018, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000003', 'Sortzyxq Reihenfolge', 2000, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000004', 'Sortzyxq Reihenfolg', 2000, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000005', 'Sortzyxq Reihenfol', 2000, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-00000000000d', 'Verstecktzyxq Spiel', 2015, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-00000000000e', 'Verstecktzyxq Spiel', 2015, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-00000000000f', 'Verstecktzyxq Spiel', 2015, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000010', 'Jahrlosxyz Spiel', null, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000011', '日本語ゲーム', 2010, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000012', 'Das', 2010, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000013', 'Adoptzyxq Ziel', 2021, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000014', 'Adoptzyxq Verstecktes Ziel', 2021, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000015', 'Adoptzyxq Zusammengefuehrtes Ziel', 2021, 'catalog'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000017', repeat('x', 200), 2001, 'catalog');
|
||||||
|
insert into public.games (id, title, year, visibility)
|
||||||
|
select ('f13c0000-0000-4000-8000-' || lpad(to_hex(5 + i), 12, '0'))::uuid, 'Limitzyxq Spiel', 2005, 'catalog'
|
||||||
|
from generate_series(1, 7) i;
|
||||||
|
update public.games set hidden_at = now() where id in ('f13c0000-0000-4000-8000-00000000000e', 'f13c0000-0000-4000-8000-000000000014');
|
||||||
|
update public.games set hidden_at = now(), merged_into_id = 'f13c0000-0000-4000-8000-00000000000d'
|
||||||
|
where id in ('f13c0000-0000-4000-8000-00000000000f');
|
||||||
|
update public.games set hidden_at = now(), merged_into_id = 'f13c0000-0000-4000-8000-000000000013'
|
||||||
|
where id = 'f13c0000-0000-4000-8000-000000000015';
|
||||||
|
insert into public.game_alt_titles (game_id, title, locale) values
|
||||||
|
('f13c0000-0000-4000-8000-000000000002', 'The Zyxqwvland Chronicle', 'en');
|
||||||
|
insert into public.publishers (id, name) values
|
||||||
|
('f13c0000-0000-4000-8000-0000000000a1', 'Zyxq Verlag B'),
|
||||||
|
('f13c0000-0000-4000-8000-0000000000a2', 'Zyxq Verlag A');
|
||||||
|
insert into public.game_publishers (game_id, publisher_id) values
|
||||||
|
('f13c0000-0000-4000-8000-000000000001', 'f13c0000-0000-4000-8000-0000000000a1'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000001', 'f13c0000-0000-4000-8000-0000000000a2');
|
||||||
|
insert into public.game_variants (id, game_id, name) values
|
||||||
|
('f13c0000-0000-4000-8000-0000000000b1', 'f13c0000-0000-4000-8000-000000000013', 'Standard');
|
||||||
|
-- private Spiele mit dem Titel des sichtbaren Katalogspiels: Bens und Annas eigener vorläufiger Eintrag
|
||||||
|
insert into public.games (id, title, year, visibility, owner_id) values
|
||||||
|
('f13a0000-0000-4000-8000-000000000001', 'Verstecktzyxq Spiel', 2015, 'private', 'a1300000-0000-4000-8000-0000000000b2'),
|
||||||
|
('f13a0000-0000-4000-8000-000000000002', 'Verstecktzyxq Spiel', 2015, 'private', 'a1300000-0000-4000-8000-0000000000a1');
|
||||||
|
|
||||||
|
-- Schwellen vorab nachrechnen, damit die Trigramm-Fälle wirklich an der Grenze von 0,8 liegen
|
||||||
|
select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz towe'), '>=', 0.8::real, 'Vorbedingung: "towe" liegt knapp über 0,8');
|
||||||
|
select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz tow'), '<', 0.8::real, 'Vorbedingung: "tow" liegt unter 0,8');
|
||||||
|
select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz tow'), '>=', 0.7::real, 'Vorbedingung: "tow" liegt über 0,7 (fängt die Schwelle 0,7)');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 5. find_similar_catalog_games (Krit. 2)
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
|
||||||
|
select results_eq($$select game_id, title, matched_title, year, publishers, match_kind, similarity
|
||||||
|
from public.find_similar_catalog_games('Quorblaxwitz Towers', 2020)$$,
|
||||||
|
$$values ('f13c0000-0000-4000-8000-000000000001'::uuid, 'Quorblaxwitz Towers', 'Quorblaxwitz Towers', 2020::smallint,
|
||||||
|
array['Zyxq Verlag A', 'Zyxq Verlag B'], 'same_title', 1.0::real)$$,
|
||||||
|
'KAT-04 Kriterium 2: gleicher Titel liefert Treffer mit Haupttitel, Jahr, sortierten Verlagen, same_title und Ähnlichkeit 1');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towers', 2030)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2: gleicher normalisierter Titel gilt unabhängig vom Jahr');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towers', null)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2: gleicher Titel ohne Jahr trifft');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('The QUORBLAXWITZ-TOWERS!', 1999)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2: Artikel, Großbuchstaben und Sonderzeichen werden ignoriert');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games(' Quorblaxwitz Towers ', 2020)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2: Leerraum am Rand ändert nichts');
|
||||||
|
select results_eq($$select match_kind, similarity > 0.8 and similarity < 1 from public.find_similar_catalog_games('Quorblaxwitz Towe', 2021)$$,
|
||||||
|
$$values ('similar_title', true)$$,
|
||||||
|
'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr + 1 trifft als similar_title mit echter Ähnlichkeit');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2019)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr - 1 trifft');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2020)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei gleichem Jahr trifft');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2022)), 0::bigint,
|
||||||
|
'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr + 2 trifft nicht');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2018)), 0::bigint,
|
||||||
|
'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr - 2 trifft nicht');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', null)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2 (Spec-F2): ohne Jahr gilt die Trigramm-Regel ohne Jahresbedingung');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2147483647)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2: ein Jahr weit außerhalb 1800-2100 gilt wie ein fehlendes Jahr (kein Überlauf)');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', -2147483648)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2: ein stark negatives Jahr gilt wie ein fehlendes Jahr (kein 22003)');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 1700)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2: Jahr 1700 gilt wie ein fehlendes Jahr');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Tow', 2020)), 0::bigint,
|
||||||
|
'KAT-04 Kriterium 2: Ähnlichkeit < 0,8 trifft nicht, auch bei gleichem Jahr');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Tow', null)), 0::bigint,
|
||||||
|
'KAT-04 Kriterium 2: Ähnlichkeit < 0,8 trifft auch ohne Jahr nicht');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Jahrlosxyz Spie', 2020)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2 (Spec-F2): fehlt das Jahr am Katalogspiel, gilt die Trigramm-Regel ohne Jahresbedingung');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Jahrlosxyz Spi', 2020)), 0::bigint,
|
||||||
|
'KAT-04 Kriterium 2: auch ohne Katalogjahr zählt die Schwelle 0,8');
|
||||||
|
|
||||||
|
-- alternative Titel und Haupttitel mit Artikel
|
||||||
|
select results_eq($$select game_id, matched_title, match_kind from public.find_similar_catalog_games('Zyxqwvland Chronicle', 1900)$$,
|
||||||
|
$$values ('f13c0000-0000-4000-8000-000000000002'::uuid, 'The Zyxqwvland Chronicle', 'same_title')$$,
|
||||||
|
'KAT-04 Kriterium 2: Treffer über title_search des alternativen Titels (matched_title), Artikel am alternativen Titel ignoriert');
|
||||||
|
select results_eq($$select title, matched_title, match_kind from public.find_similar_catalog_games('zyxqwvland saga', 1900)$$,
|
||||||
|
$$values ('Die Zyxqwvland Saga', 'Die Zyxqwvland Saga', 'same_title')$$,
|
||||||
|
'KAT-04 Kriterium 2: Treffer über den Haupttitel (games.title_search), Artikel am Haupttitel ignoriert');
|
||||||
|
select results_eq($$select matched_title, match_kind from public.find_similar_catalog_games('Zyxqwvland Chronicl', 2019)$$,
|
||||||
|
$$values ('The Zyxqwvland Chronicle', 'similar_title')$$,
|
||||||
|
'KAT-04 Kriterium 2: Trigramm-Treffer über den alternativen Titel (Jahr + 1 zum Spiel)');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Zyxqwvland Chronicl', 2021)), 0::bigint,
|
||||||
|
'KAT-04 Kriterium 2: Trigramm-Treffer über den alternativen Titel gilt das Jahr des Spiels (± 2 trifft nicht)');
|
||||||
|
|
||||||
|
-- Sonderfälle der Normalisierung
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('日本語ゲーム', null)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2: Titel ohne normalisierbare Zeichen trifft über den Rückfall lower(btrim)');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('das', null)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 2: ein Titel nur aus einem Artikel trifft das gleichnamige Spiel');
|
||||||
|
|
||||||
|
-- nicht sichtbare Spiele (hidden, merged, privat von Ben und Anna) treffen nicht; das sichtbare Spiel schon
|
||||||
|
select results_eq($$select game_id from public.find_similar_catalog_games('Verstecktzyxq Spiel', 2015)$$,
|
||||||
|
$$values ('f13c0000-0000-4000-8000-00000000000d'::uuid)$$,
|
||||||
|
'KAT-04 Kriterium 2: nur das sichtbare Katalogspiel trifft, nicht ausgeblendete, zusammengeführte oder private Spiele (auch nicht Bens und das eigene)');
|
||||||
|
|
||||||
|
-- Eingabegrenzen, Reihenfolge, Höchstzahl
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games(repeat('x', 200), null)), 1::bigint, 'KAT-04 Kriterium 2: Titel mit genau 200 Zeichen ist erlaubt und trifft das gleichlange Katalogspiel');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games(repeat('x', 201), null)), 0::bigint, 'KAT-04 Kriterium 2: Titel über 200 Zeichen liefert eine leere Menge (obwohl dieselben Trigramme ein Katalogspiel träfen)');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games(' ', null)), 0::bigint, 'KAT-04 Kriterium 2: leerer Titel liefert eine leere Menge');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games(null, null)), 0::bigint, 'KAT-04 Kriterium 2: Titel null liefert eine leere Menge');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('!!!', null)), 0::bigint, 'KAT-04 Kriterium 2: Titel nur aus Sonderzeichen liefert keinen Treffer');
|
||||||
|
select is((select count(*) from public.find_similar_catalog_games('Limitzyxq Spiel', 2005)), 5::bigint,
|
||||||
|
'KAT-04 Kriterium 2: höchstens 5 Treffer');
|
||||||
|
select results_eq($$select game_id, match_kind from public.find_similar_catalog_games('Sortzyxq Reihenfolge', 2000)$$,
|
||||||
|
$$values ('f13c0000-0000-4000-8000-000000000003'::uuid, 'same_title'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000004'::uuid, 'similar_title'),
|
||||||
|
('f13c0000-0000-4000-8000-000000000005'::uuid, 'similar_title')$$,
|
||||||
|
'KAT-04 Kriterium 2: same_title zuerst, danach nach Ähnlichkeit absteigend');
|
||||||
|
reset role;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 6. report_missing_game (Krit. 1, 2, 3, 7, 8)
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- nicht angemeldet
|
||||||
|
select set_config('request.jwt.claims', '{"role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Ohne Anmeldung')$$, 'P0001', 'not_authenticated',
|
||||||
|
'Sicherheit Kriterium 2: ohne Nutzerkennung (auth.uid() null) wird nichts gemeldet');
|
||||||
|
reset role;
|
||||||
|
select set_config('request.jwt.claims', '{"role":"anon"}', true);
|
||||||
|
set local role anon;
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Anon')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf report_missing_game nicht aufrufen');
|
||||||
|
select throws_ok($$select * from public.find_similar_catalog_games('Zyxqtest Anon')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf find_similar_catalog_games nicht aufrufen');
|
||||||
|
select throws_ok($$select public.normalize_game_title('x')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf normalize_game_title nicht aufrufen');
|
||||||
|
select throws_ok($$select public.strip_leading_article('x')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf strip_leading_article nicht aufrufen');
|
||||||
|
select throws_ok($$select public.report_day(now())$$, '42501', null, 'Sicherheit Kriterium 2: anon darf report_day nicht aufrufen');
|
||||||
|
select throws_ok($$select 1 from public.game_reports$$, '42501', null, 'Sicherheit Kriterium 2: anon darf keine Meldungen lesen');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000a1', 'x', null)$$, '42501', null,
|
||||||
|
'Sicherheit Kriterium 2: anon darf keine Meldung anlegen');
|
||||||
|
select throws_ok($$update public.game_reports set result_seen_at = now()$$, '42501', null, 'Sicherheit Kriterium 2: anon darf keine Meldung ändern');
|
||||||
|
reset role;
|
||||||
|
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
|
||||||
|
select results_eq($$select game_id from public.find_similar_catalog_games('Verstecktzyxq Spiel', 2015)$$,
|
||||||
|
$$values ('f13c0000-0000-4000-8000-00000000000d'::uuid)$$,
|
||||||
|
'KAT-04 Kriterium 2 (Spec-F7): auch Annas eigener vorläufiger Eintrag mit demselben Titel trifft nicht');
|
||||||
|
|
||||||
|
-- Validierung (jeder Fehler mit Code, nichts bleibt zurück)
|
||||||
|
select throws_ok($$select public.report_missing_game('')$$, 'P0001', 'report_title_required', 'KAT-04 Kriterium 1: leerer Titel');
|
||||||
|
select throws_ok($$select public.report_missing_game(' ')$$, 'P0001', 'report_title_required', 'KAT-04 Kriterium 1: Titel nur aus Leerzeichen');
|
||||||
|
select throws_ok($$select public.report_missing_game(null)$$, 'P0001', 'report_title_required', 'KAT-04 Kriterium 1: Titel null');
|
||||||
|
select throws_ok($$select public.report_missing_game(repeat('t', 201))$$, 'P0001', 'report_title_too_long', 'KAT-04 Kriterium 1: Titel über 200 Zeichen');
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Verlag', repeat('v', 201))$$, 'P0001', 'report_publisher_too_long', 'KAT-04 Kriterium 1: Verlag über 200 Zeichen');
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Jahr', null, 1799)$$, 'P0001', 'report_year_invalid', 'KAT-04 Kriterium 1: Jahr 1799');
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Jahr', null, 2101)$$, 'P0001', 'report_year_invalid', 'KAT-04 Kriterium 1: Jahr 2101');
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Jahr', null, 99999)$$, 'P0001', 'report_year_invalid', 'KAT-04 Kriterium 1: Jahr 99999 ergibt report_year_invalid, kein 22003');
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'ftp://example.org')$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Link mit anderem Schema');
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'example.org/spiel')$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Link ohne Schema');
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'HTTP://example.org')$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Schema in Großbuchstaben (wie der Tabellen-Check)');
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'https://' || repeat('l', 493))$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Link mit 501 Zeichen');
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Liste', null, null, null, 'foo')$$, 'P0001', 'report_list_invalid', 'KAT-04 Kriterium 3: unbekannte Liste');
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Liste', null, null, null, '')$$, 'P0001', 'report_list_invalid', 'KAT-04 Kriterium 3: leere Liste ist keine Liste');
|
||||||
|
select is((select count(*) from public.game_reports) + (select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000a1' and title like 'Zyxqtest%'),
|
||||||
|
0::bigint, 'KAT-04 Kriterium 1: nach allen Validierungsfehlern bleibt nichts zurück');
|
||||||
|
select lives_ok($$select public.report_missing_game('Zyxqtest Grenzwerte ' || repeat('t', 180), repeat('v', 200), 1800, 'https://' || repeat('l', 492))$$,
|
||||||
|
'KAT-04 Kriterium 1: Grenzwerte (Titel 200, Verlag 200, Jahr 1800, Link 500) sind gültig');
|
||||||
|
select lives_ok($$select public.report_missing_game('Zyxqtest Jahr 2100', null, 2100, 'http://example.org')$$, 'KAT-04 Kriterium 1: Jahr 2100 und http-Link sind gültig');
|
||||||
|
reset role;
|
||||||
|
select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000a1'), 2::bigint, 'Vorbedingung: Anna hat 2 Meldungen von den Grenzwert-Fällen');
|
||||||
|
delete from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000a1';
|
||||||
|
delete from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000a1' and title like 'Zyxqtest%';
|
||||||
|
|
||||||
|
-- Erfolg ohne Liste: Trimmen, Felder, Rückgabe
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
create temp table res1 as select public.report_missing_game(' Zyxqtest Eins ', ' ', 2019, ' https://example.org/eins ') as j;
|
||||||
|
grant all on res1 to public;
|
||||||
|
select ok((select j ? 'report_id' and j ? 'provisional_game_id' and j ? 'collection_item_id' and j->'collection_item_id' = 'null'::jsonb from res1),
|
||||||
|
'KAT-04 Kriterium 1: Rückgabe enthält report_id, provisional_game_id und collection_item_id null');
|
||||||
|
select results_eq($$select r.owner_id, r.origin, r.status, r.title, r.publisher_name, r.year, r.link, r.target_game_id, r.resolved_at, r.result_seen_at,
|
||||||
|
r.provisional_game_id = (res1.j->>'provisional_game_id')::uuid, r.created_at >= now() - interval '1 minute'
|
||||||
|
from public.game_reports r, res1 where r.id = (res1.j->>'report_id')::uuid$$,
|
||||||
|
$$values ('a1300000-0000-4000-8000-0000000000a1'::uuid, 'user', 'open', 'Zyxqtest Eins', null::text, 2019::smallint,
|
||||||
|
'https://example.org/eins', null::uuid, null::timestamptz, null::timestamptz, true, true)$$,
|
||||||
|
'KAT-04 Kriterium 1 und 8: Meldung gespeichert (getrimmt, Leerverlag null), Status offen, Herkunft user, kein Ziel');
|
||||||
|
select results_eq($$select g.title, g.year, g.visibility, g.owner_id, g.description, g.image_path, g.bgg_id, g.hidden_at, g.merged_into_id
|
||||||
|
from public.games g, res1 where g.id = (res1.j->>'provisional_game_id')::uuid$$,
|
||||||
|
$$values ('Zyxqtest Eins', 2019::smallint, 'private', 'a1300000-0000-4000-8000-0000000000a1'::uuid, null::text, null::text, null::integer, null::timestamptz, null::uuid)$$,
|
||||||
|
'KAT-04 Kriterium 3: vorläufiger Eintrag ist privat, gehört Anna und hat nur Titel und Jahr');
|
||||||
|
select is((select count(*) from public.collection_items where owner_id = 'a1300000-0000-4000-8000-0000000000a1'), 0::bigint,
|
||||||
|
'KAT-04 Kriterium 3: ohne p_list entsteht kein Sammlungseintrag');
|
||||||
|
|
||||||
|
create temp table res_tab as select public.report_missing_game(E'\tZyxqtest Tab\n') as j;
|
||||||
|
grant all on res_tab to public;
|
||||||
|
select is((select title from public.games where id = (select (j->>'provisional_game_id')::uuid from res_tab)), 'Zyxqtest Tab',
|
||||||
|
'KAT-04 Kriterium 1: Tab und Zeilenumbruch am Rand des Titels werden getrimmt');
|
||||||
|
|
||||||
|
-- Erfolg mit Liste
|
||||||
|
create temp table res2 as select public.report_missing_game('Zyxqtest Zwei', 'Zyxq Eigenverlag', null, null, 'wishlist') as j;
|
||||||
|
grant all on res2 to public;
|
||||||
|
select results_eq($$select c.owner_id, c.game_id = (res2.j->>'provisional_game_id')::uuid, c.list, c.variant_id, c.id = (res2.j->>'collection_item_id')::uuid
|
||||||
|
from public.collection_items c, res2 where c.id = (res2.j->>'collection_item_id')::uuid$$,
|
||||||
|
$$values ('a1300000-0000-4000-8000-0000000000a1'::uuid, true, 'wishlist', null::uuid, true)$$,
|
||||||
|
'KAT-04 Kriterium 3: p_list legt den Sammlungseintrag auf den vorläufigen Eintrag an, ohne Edition');
|
||||||
|
select is((select publisher_name from public.game_reports where id = (select (j->>'report_id')::uuid from res2)), 'Zyxq Eigenverlag',
|
||||||
|
'KAT-04 Kriterium 1: der Verlag wird als Freitext gespeichert');
|
||||||
|
create temp table res_own as select public.report_missing_game('Zyxqtest Besitz', null, null, null, 'owned') as j;
|
||||||
|
grant all on res_own to public;
|
||||||
|
select is((select list from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_own)), 'owned', 'KAT-04 Kriterium 3: Liste owned');
|
||||||
|
select lives_ok($$select public.report_missing_game('Zyxqtest Einkauf', null, null, null, 'shopping')$$, 'KAT-04 Kriterium 3: Liste shopping');
|
||||||
|
reset role;
|
||||||
|
|
||||||
|
-- Isolation: Ben sieht nichts von Anna
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select is((select count(*) from public.game_reports where title like 'Zyxqtest%'), 0::bigint, 'Sicherheit Kriterium 1: Ben sieht Annas Meldungen nicht');
|
||||||
|
select is((select count(*) from public.games where title like 'Zyxqtest%'), 0::bigint, 'Sicherheit Kriterium 3: Ben sieht Annas vorläufige Einträge nicht');
|
||||||
|
select is((select count(*) from public.collection_items where game_id in (select (j->>'provisional_game_id')::uuid from res1 union all select (j->>'provisional_game_id')::uuid from res2)), 0::bigint,
|
||||||
|
'Sicherheit Kriterium 3: Ben sieht Annas Listeneinträge nicht');
|
||||||
|
reset role;
|
||||||
|
|
||||||
|
-- Ähnlichkeitsprüfung beim Speichern (Krit. 2)
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select throws_ok($$select public.report_missing_game('Die Quorblaxwitz Towers', null, 2020)$$, 'P0001', 'report_similar_exists',
|
||||||
|
'KAT-04 Kriterium 2: ein sehr ähnlicher Katalogeintrag ohne Bestätigung wird nicht gespeichert');
|
||||||
|
select throws_ok($$select public.report_missing_game('Quorblaxwitz Towers', null, 2020, null, null, null)$$, 'P0001', 'report_similar_exists',
|
||||||
|
'KAT-04 Kriterium 2: p_confirmed_distinct = null gilt als nicht bestätigt');
|
||||||
|
select is((select count(*) from public.game_reports) + (select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000c3'), 0::bigint,
|
||||||
|
'KAT-04 Kriterium 2: ohne Bestätigung bleibt nichts zurück');
|
||||||
|
select throws_ok($$select public.report_missing_game('Quorblaxwitz Towe', null, 2021)$$, 'P0001', 'report_similar_exists',
|
||||||
|
'KAT-04 Kriterium 2: die RPC prüft die Trigramm-Regel mit dem gemeldeten Jahr (± 1 trifft)');
|
||||||
|
select lives_ok($$select public.report_missing_game('Quorblaxwitz Towe', null, 2022)$$,
|
||||||
|
'KAT-04 Kriterium 2: die RPC prüft die Trigramm-Regel mit dem gemeldeten Jahr (± 2 trifft nicht, ohne Bestätigung gespeichert)');
|
||||||
|
select lives_ok($$select public.report_missing_game('Die Quorblaxwitz Towers', null, 2020, null, null, true)$$,
|
||||||
|
'KAT-04 Kriterium 2: mit ausdrücklicher Bestätigung wird gespeichert');
|
||||||
|
select is((select count(*) from public.game_reports where title in ('Die Quorblaxwitz Towers', 'Quorblaxwitz Towe')), 2::bigint, 'KAT-04 Kriterium 2: die bestätigte und die nicht ähnliche Meldung sind gespeichert');
|
||||||
|
reset role;
|
||||||
|
|
||||||
|
-- 23505 am eindeutigen Index auf provisional_game_id: über die RPC nicht erreichbar (jeder Aufruf legt ein frisches Spiel an).
|
||||||
|
-- Ein Hilfstrigger lenkt den Insert in game_reports auf ein schon verwendetes Spiel um.
|
||||||
|
create function pg_temp.reuse_prov() returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
new.provisional_game_id := (select r.provisional_game_id from public.game_reports r where r.title = 'Die Quorblaxwitz Towers' and r.owner_id = new.owner_id);
|
||||||
|
return new;
|
||||||
|
end $$;
|
||||||
|
create trigger zz_reuse_prov before insert on public.game_reports for each row execute function pg_temp.reuse_prov();
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Doppelt belegt')$$, 'P0001', 'report_provisional_in_use',
|
||||||
|
'KAT-04 Kriterium 3: ein schon verwendeter vorläufiger Eintrag wird als report_provisional_in_use gemeldet');
|
||||||
|
select is((select count(*) from public.games where title = 'Zyxqtest Doppelt belegt'), 0::bigint, 'KAT-04 Kriterium 3: der Konflikt lässt kein vorläufiges Spiel zurück');
|
||||||
|
reset role;
|
||||||
|
drop trigger zz_reuse_prov on public.game_reports;
|
||||||
|
|
||||||
|
-- Rollback bei Fehler im Listeneintrag: ein Hilfstrigger lässt den Insert in collection_items scheitern
|
||||||
|
create function pg_temp.fail_ci() returns trigger language plpgsql as $$ begin raise exception 'boom' using errcode = 'P0001'; end $$;
|
||||||
|
create trigger fail_ci before insert on public.collection_items for each row execute function pg_temp.fail_ci();
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest Rollback', null, null, null, 'owned')$$, 'P0001', 'boom',
|
||||||
|
'Vorbedingung: der Listeneintrag scheitert');
|
||||||
|
select is((select count(*) from public.game_reports where title = 'Zyxqtest Rollback') + (select count(*) from public.games where title = 'Zyxqtest Rollback'), 0::bigint,
|
||||||
|
'KAT-04 Kriterium 3: scheitert der Listeneintrag, bleiben weder Meldung noch vorläufiges Spiel zurück');
|
||||||
|
reset role;
|
||||||
|
drop trigger fail_ci on public.collection_items;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 7. Tageslimit (Krit. 7)
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e1","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select lives_ok($$select public.report_missing_game('Tageslimit Meldung ' || i) from generate_series(1, 10) i$$,
|
||||||
|
'KAT-04 Kriterium 7: 10 Meldungen über report_missing_game sind erlaubt');
|
||||||
|
select throws_ok($$select public.report_missing_game('Tageslimit Meldung 11')$$, 'P0001', 'report_daily_limit', 'KAT-04 Kriterium 7: die 11. Meldung scheitert am Tageslimit');
|
||||||
|
select throws_ok($$select public.report_missing_game('Quorblaxwitz Towers', null, 2020)$$, 'P0001', 'report_daily_limit',
|
||||||
|
'KAT-04 Kriterium 7: das Limit wird vor der Ähnlichkeitsprüfung gemeldet');
|
||||||
|
select is((select count(*) from public.game_reports), 10::bigint, 'KAT-04 Kriterium 7: es bleiben genau 10 Meldungen');
|
||||||
|
select is((select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000e1'), 10::bigint, 'KAT-04 Kriterium 7: kein vorläufiges Spiel der abgelehnten Meldung bleibt übrig');
|
||||||
|
reset role;
|
||||||
|
|
||||||
|
-- Meldungen von gestern zählen nicht
|
||||||
|
insert into public.games (id, title, visibility, owner_id)
|
||||||
|
select ('f13b0000-0000-4000-8000-' || lpad(to_hex(i), 12, '0'))::uuid, 'Gestern ' || i, 'private', 'a1300000-0000-4000-8000-0000000000e4'
|
||||||
|
from generate_series(1, 13) i;
|
||||||
|
insert into public.game_reports (owner_id, title, provisional_game_id, created_at)
|
||||||
|
select 'a1300000-0000-4000-8000-0000000000e4', 'Gestern ' || i, ('f13b0000-0000-4000-8000-' || lpad(to_hex(i), 12, '0'))::uuid,
|
||||||
|
-- created_at ist nach dem Anlegen unveränderlich: feste Zeitstempel gleich beim Einfügen (Nr. 1 und 2 an der Berliner Tagesgrenze)
|
||||||
|
case i when 1 then '2026-01-15 22:59:00+00'::timestamptz when 2 then '2026-01-15 23:30:00+00'::timestamptz else '2026-01-01 12:00:00+00'::timestamptz end
|
||||||
|
from generate_series(1, 10) i;
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e4","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select lives_ok($$select public.report_missing_game('Heute nach zehn Meldungen von gestern')$$, 'KAT-04 Kriterium 7: Meldungen von gestern zählen nicht');
|
||||||
|
reset role;
|
||||||
|
-- Grenze des Berliner Tages mit festem Zeitstempel: 23:30 UTC im Winter (00:30 am Folgetag in Berlin) zählt nicht zu 22:59 UTC desselben Tages
|
||||||
|
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).
|
||||||
|
-- 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 <= 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,
|
||||||
|
'Mitternacht ' || i, ('f13b0000-0000-4000-8000-' || lpad(to_hex(200 + i), 12, '0'))::uuid,
|
||||||
|
(date_trunc('day', now() at time zone 'Europe/Berlin') at time zone 'Europe/Berlin') + interval '1 minute'
|
||||||
|
from generate_series(1, 20) i;
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e5","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select throws_ok($$select public.report_missing_game('Mitternacht Vorprüfung')$$, 'P0001', 'report_daily_limit',
|
||||||
|
'KAT-04 Kriterium 7: 10 Meldungen kurz nach Mitternacht Berlin zählen für heute (Vorprüfung der RPC)');
|
||||||
|
select throws_ok($$select public.report_missing_game('Quorblaxwitz Towers', null, 2020)$$, 'P0001', 'report_daily_limit',
|
||||||
|
'KAT-04 Kriterium 7: die Vorprüfung zählt Meldungen nach Mitternacht Berlin auch vor der Ähnlichkeitsprüfung');
|
||||||
|
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-0000000000dd')$$, 'P0001', 'report_daily_limit',
|
||||||
|
'KAT-04 Kriterium 7: 10 Meldungen kurz nach Mitternacht Berlin zählen für heute (Trigger)');
|
||||||
|
reset role;
|
||||||
|
|
||||||
|
-- Direkter Insert als authenticated: Trigger zählt, report_day ist für authenticated ausführbar
|
||||||
|
insert into public.games (id, title, visibility, owner_id)
|
||||||
|
select ('f13b0000-0000-4000-8000-' || lpad(to_hex(100 + i), 12, '0'))::uuid, 'Direkt ' || i, 'private', 'a1300000-0000-4000-8000-0000000000e2'
|
||||||
|
from generate_series(1, 11) i;
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e2","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select lives_ok($$insert into public.game_reports (owner_id, title, provisional_game_id)
|
||||||
|
select 'a1300000-0000-4000-8000-0000000000e2', 'Direkt ' || i, ('f13b0000-0000-4000-8000-' || lpad(to_hex(100 + i), 12, '0'))::uuid from generate_series(1, 10) i$$,
|
||||||
|
'KAT-04 Kriterium 7: 10 direkte Inserts als authenticated sind erlaubt (der Trigger darf report_day ausführen)');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values
|
||||||
|
('a1300000-0000-4000-8000-0000000000e2', 'Direkt 11', 'f13b0000-0000-4000-8000-00000000006f')$$, 'P0001', 'report_daily_limit',
|
||||||
|
'KAT-04 Kriterium 7: auch der direkte 11. Insert scheitert am Limit');
|
||||||
|
reset role;
|
||||||
|
|
||||||
|
-- Import-Meldungen zählen nicht und werden nicht begrenzt (N12)
|
||||||
|
insert into public.game_reports (owner_id, origin, title, bgg_id)
|
||||||
|
select 'a1300000-0000-4000-8000-0000000000e3', 'import', 'Import ' || i, 9000 + i from generate_series(1, 12) i;
|
||||||
|
select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000e3' and origin = 'import'), 12::bigint,
|
||||||
|
'KAT-04 Kriterium 7 (N12): mehr als 10 Import-Meldungen werden nicht begrenzt');
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e3","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select lives_ok($$select public.report_missing_game('Zyxqtest nach Import ' || i) from generate_series(1, 10) i$$,
|
||||||
|
'KAT-04 Kriterium 7 (N12): Import-Meldungen zählen nicht auf das Limit des Nutzers');
|
||||||
|
select throws_ok($$select public.report_missing_game('Zyxqtest nach Import 11')$$, 'P0001', 'report_daily_limit',
|
||||||
|
'KAT-04 Kriterium 7: danach greift das Limit für Nutzer-Meldungen');
|
||||||
|
reset role;
|
||||||
|
-- Meldungen von Nutzer B zählen nicht für Nutzer A: Anna hat oben nur wenige, Ben (e1 hat 10) ändert nichts
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select lives_ok($$select public.report_missing_game('Zyxqtest Anna nach Limit anderer')$$, 'KAT-04 Kriterium 7: die Meldungen anderer Nutzer zählen nicht für Anna');
|
||||||
|
reset role;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 8. Direkter Zugriff auf game_reports: Policies und Spaltenrechte (Krit. 8, Sicherheit 1 und 3)
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
insert into public.games (id, title, visibility, owner_id, hidden_at, merged_into_id) values
|
||||||
|
('f13a0000-0000-4000-8000-000000000011', 'Direkt ok', 'private', 'a1300000-0000-4000-8000-0000000000c3', null, null),
|
||||||
|
('f13a0000-0000-4000-8000-000000000012', 'Direkt versteckt', 'private', 'a1300000-0000-4000-8000-0000000000c3', now(), null),
|
||||||
|
('f13a0000-0000-4000-8000-000000000013', 'Direkt zusammengeführt', 'private', 'a1300000-0000-4000-8000-0000000000c3', now(), 'f13c0000-0000-4000-8000-00000000000d'),
|
||||||
|
('f13a0000-0000-4000-8000-000000000014', 'Bens Privatspiel', 'private', 'a1300000-0000-4000-8000-0000000000b2', null, null),
|
||||||
|
('f13a0000-0000-4000-8000-000000000015', 'Direkt ok 2', 'private', 'a1300000-0000-4000-8000-0000000000c3', null, null);
|
||||||
|
-- Hilfsfunktion (läuft mit den Rechten des Aufrufers): result_seen_at setzen und die Zahl der geänderten Zeilen liefern.
|
||||||
|
-- (Ein datenändernder WITH-Block ist in einer Unterabfrage nicht erlaubt.)
|
||||||
|
create function pg_temp.mark_seen_all(p_at timestamptz) returns bigint language plpgsql as $$
|
||||||
|
declare n bigint;
|
||||||
|
begin
|
||||||
|
update public.game_reports set result_seen_at = p_at; -- bewusst ohne WHERE: nur die Update-Policy begrenzt die Zeilen
|
||||||
|
get diagnostics n = row_count;
|
||||||
|
return n;
|
||||||
|
end $$;
|
||||||
|
create function pg_temp.mark_seen(p_id uuid, p_at timestamptz) returns bigint language plpgsql as $$
|
||||||
|
declare n bigint;
|
||||||
|
begin
|
||||||
|
update public.game_reports set result_seen_at = p_at where p_id is null or id = p_id;
|
||||||
|
get diagnostics n = row_count;
|
||||||
|
return n;
|
||||||
|
end $$;
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select lives_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Direkt ok', 'f13a0000-0000-4000-8000-000000000011')$$,
|
||||||
|
'Sicherheit Kriterium 1: Cara legt direkt eine Meldung zu ihrem eigenen privaten Spiel an (Positivfall)');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000b2', 'Im Namen von Ben', 'f13a0000-0000-4000-8000-000000000014')$$, '42501', null,
|
||||||
|
'Sicherheit Kriterium 1: Cara kann keine Meldung für Ben anlegen');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000b2', 'Fremder Besitzer, eigenes Spiel', 'f13a0000-0000-4000-8000-000000000015')$$, '42501', null,
|
||||||
|
'Sicherheit Kriterium 1: owner_id eines anderen Nutzers wird abgelehnt, auch wenn das vorläufige Spiel Cara gehört');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title) values ('a1300000-0000-4000-8000-0000000000c3', 'Ohne vorläufigen Eintrag')$$, '42501', null,
|
||||||
|
'Sicherheit Kriterium 1: eine Meldung ohne vorläufigen Eintrag wird abgelehnt');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Bens Spiel', 'f13a0000-0000-4000-8000-000000000014')$$, '42501', null,
|
||||||
|
'Sicherheit Kriterium 1: ein fremdes privates Spiel als vorläufiger Eintrag wird abgelehnt');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Katalogspiel', 'f13c0000-0000-4000-8000-000000000001')$$, '42501', null,
|
||||||
|
'Sicherheit Kriterium 1: ein Katalogspiel als vorläufiger Eintrag wird abgelehnt');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Versteckt', 'f13a0000-0000-4000-8000-000000000012')$$, '42501', null,
|
||||||
|
'Sicherheit Kriterium 1: ein ausgeblendetes Spiel als vorläufiger Eintrag wird abgelehnt');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Zusammengeführt', 'f13a0000-0000-4000-8000-000000000013')$$, '42501', null,
|
||||||
|
'Sicherheit Kriterium 1: ein zusammengeführtes Spiel als vorläufiger Eintrag wird abgelehnt');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Doppelt', 'f13a0000-0000-4000-8000-000000000011')$$, '23505', null,
|
||||||
|
'Sicherheit Kriterium 1: eine zweite Meldung zum selben vorläufigen Eintrag scheitert am eindeutigen Index');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, status) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'approved')$$, '42501', null,
|
||||||
|
'KAT-04 Kriterium 8: status lässt sich beim Anlegen nicht setzen');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, origin) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'import')$$, '42501', null,
|
||||||
|
'KAT-04 Kriterium 7: origin lässt sich beim Anlegen nicht setzen (Import-Meldungen umgehen das Limit nicht)');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, created_at) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', '2026-01-01')$$, '42501', null,
|
||||||
|
'KAT-04 Kriterium 7: created_at lässt sich beim Anlegen nicht setzen');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, target_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'f13c0000-0000-4000-8000-000000000001')$$, '42501', null,
|
||||||
|
'KAT-04 Kriterium 8: target_game_id lässt sich beim Anlegen nicht setzen');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, resolved_at) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', now())$$, '42501', null,
|
||||||
|
'KAT-04 Kriterium 8: resolved_at lässt sich beim Anlegen nicht setzen');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, bgg_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 1)$$, '42501', null,
|
||||||
|
'KAT-04: bgg_id lässt sich beim Anlegen nicht setzen');
|
||||||
|
select throws_ok($$insert into public.game_reports (id, owner_id, title, provisional_game_id) values (gen_random_uuid(), 'a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015')$$, '42501', null,
|
||||||
|
'KAT-04: id lässt sich beim Anlegen nicht setzen');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', '', 'f13a0000-0000-4000-8000-000000000015')$$, '23514', null,
|
||||||
|
'KAT-04 Kriterium 1: ein leerer Titel scheitert am Tabellen-Check');
|
||||||
|
select throws_ok($$insert into public.game_reports (owner_id, title, year, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 1700, 'f13a0000-0000-4000-8000-000000000015')$$, '23514', null,
|
||||||
|
'KAT-04 Kriterium 1: ein Jahr außerhalb 1800-2100 scheitert am Tabellen-Check');
|
||||||
|
select throws_ok($$update public.game_reports set status = 'approved' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null,
|
||||||
|
'KAT-04 Kriterium 8: status lässt sich nicht ändern');
|
||||||
|
select throws_ok($$update public.game_reports set title = 'Umbenannt' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null,
|
||||||
|
'KAT-04: title lässt sich nicht ändern');
|
||||||
|
select throws_ok($$update public.game_reports set target_game_id = 'f13c0000-0000-4000-8000-000000000001' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null,
|
||||||
|
'KAT-04 Kriterium 8: target_game_id lässt sich nicht ändern');
|
||||||
|
select throws_ok($$update public.game_reports set resolved_at = now() where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null,
|
||||||
|
'KAT-04 Kriterium 8: resolved_at lässt sich nicht ändern');
|
||||||
|
select throws_ok($$delete from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null,
|
||||||
|
'Sicherheit Kriterium 1: eigene Meldungen lassen sich nicht löschen');
|
||||||
|
-- result_seen_at bei offener Meldung: 0 Zeilen
|
||||||
|
select is(pg_temp.mark_seen(null, now()), 0::bigint,
|
||||||
|
'KAT-04 Kriterium 8: result_seen_at an einer offenen Meldung ändert keine Zeile');
|
||||||
|
reset role;
|
||||||
|
select throws_ok($$update public.game_reports set result_seen_at = now() where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '23514', null,
|
||||||
|
'KAT-04 Kriterium 8: auch postgres kann result_seen_at bei einer offenen Meldung nicht setzen (Check)');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 9. Ergebnis nach admin_resolve_report (Krit. 4 und 6), result_seen_at
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
create temp table res_ap as select public.report_missing_game('Freigabezyxq Neu', 'Freigabe Verlag', 2023, null, 'owned') as j;
|
||||||
|
grant all on res_ap to public;
|
||||||
|
create temp table res_rej as select public.report_missing_game('Ablehnzyxq Spiel', null, null, null, 'wishlist') as j;
|
||||||
|
grant all on res_rej to public;
|
||||||
|
update public.collection_items set note = 'Annas Notiz', acquired_on = '2025-05-05'
|
||||||
|
where id = (select (j->>'collection_item_id')::uuid from res_ap);
|
||||||
|
reset role;
|
||||||
|
insert into public.game_variants (id, game_id, name) values
|
||||||
|
('f13a0000-0000-4000-8000-0000000000b1', (select (j->>'provisional_game_id')::uuid from res_ap), 'Eigene Edition');
|
||||||
|
update public.collection_items set variant_id = 'f13a0000-0000-4000-8000-0000000000b1'
|
||||||
|
where id = (select (j->>'collection_item_id')::uuid from res_ap);
|
||||||
|
insert into public.games (id, title, year, visibility) values ('f13c0000-0000-4000-8000-000000000016', 'Freigabezyxq Neu', 2023, 'catalog');
|
||||||
|
select lives_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_ap), 'approved', 'f13c0000-0000-4000-8000-000000000016')$$,
|
||||||
|
'Vorbedingung: der Admin gibt die Meldung frei');
|
||||||
|
select lives_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_rej), 'rejected')$$,
|
||||||
|
'Vorbedingung: der Admin lehnt die zweite Meldung ohne Ziel ab');
|
||||||
|
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select results_eq($$select game_id, list, note, acquired_on, variant_id from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_ap)$$,
|
||||||
|
$$values ('f13c0000-0000-4000-8000-000000000016'::uuid, 'owned', 'Annas Notiz', '2025-05-05'::date, null::uuid)$$,
|
||||||
|
'KAT-04 Kriterium 4: der Katalogeintrag ersetzt den vorläufigen; Liste, Notiz und Kaufdatum bleiben, die Edition ohne gleichnamige Variante ist leer');
|
||||||
|
select results_eq($$select r.status, r.target_game_id, r.editions_cleared, r.result_seen_at from public.game_reports r where r.id = (select (j->>'report_id')::uuid from res_ap)$$,
|
||||||
|
$$values ('approved', 'f13c0000-0000-4000-8000-000000000016'::uuid, 1, null::timestamptz)$$,
|
||||||
|
'KAT-04 Kriterium 4: Meldung freigegeben, editions_cleared zählt die geleerte Edition, Hinweis noch ungesehen');
|
||||||
|
select is((select count(*) from public.game_variants where id = 'f13a0000-0000-4000-8000-0000000000b1'), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 4: Annas private Variante bleibt für sie lesbar');
|
||||||
|
select results_eq($$select g.visibility, g.merged_into_id, g.hidden_at is not null from public.games g where g.id = (select (j->>'provisional_game_id')::uuid from res_ap)$$,
|
||||||
|
$$values ('private', 'f13c0000-0000-4000-8000-000000000016'::uuid, true)$$,
|
||||||
|
'KAT-04 Kriterium 4: der vorläufige Eintrag ist ausgeblendet und zeigt auf den Katalogeintrag, Anna liest ihn weiter');
|
||||||
|
select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_ap), '2026-11-04 10:00:00+00'), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 4: Anna kann den Hinweis an der freigegebenen Meldung wegklicken (result_seen_at)');
|
||||||
|
select is((select result_seen_at from public.game_reports where id = (select (j->>'report_id')::uuid from res_ap)), '2026-11-04 10:00:00+00'::timestamptz,
|
||||||
|
'KAT-04 Kriterium 4: result_seen_at ist gesetzt');
|
||||||
|
-- Krit. 6
|
||||||
|
select results_eq($$select r.status, r.target_game_id, r.provisional_game_id is not null from public.game_reports r where r.id = (select (j->>'report_id')::uuid from res_rej)$$,
|
||||||
|
$$values ('rejected', null::uuid, true)$$, 'KAT-04 Kriterium 6: abgelehnt ohne Ziel, der vorläufige Eintrag ist noch verknüpft');
|
||||||
|
select results_eq($$select g.visibility, g.merged_into_id, g.hidden_at from public.games g where g.id = (select (j->>'provisional_game_id')::uuid from res_rej)$$,
|
||||||
|
$$values ('private', null::uuid, null::timestamptz)$$, 'KAT-04 Kriterium 6: der vorläufige Eintrag bleibt privat, sichtbar und lesbar');
|
||||||
|
select is((select count(*) from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_rej) and game_id = (select (j->>'provisional_game_id')::uuid from res_rej)), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 6: der Listeneintrag bleibt am vorläufigen Eintrag');
|
||||||
|
select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_rej), now()), 1::bigint,
|
||||||
|
'KAT-04 Kriterium 6: auch an der abgelehnten Meldung lässt sich der Hinweis wegklicken');
|
||||||
|
reset role;
|
||||||
|
-- Fremder Nutzer kann nichts wegklicken
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_ap), now()), 0::bigint,
|
||||||
|
'Sicherheit Kriterium 1: Ben kann bei fremden Meldungen nichts wegklicken (0 Zeilen)');
|
||||||
|
select is(pg_temp.mark_seen_all(now()), 0::bigint,
|
||||||
|
'Sicherheit Kriterium 1: auch ein Update ohne WHERE trifft bei Ben keine fremde Meldung (Update-Policy)');
|
||||||
|
reset role;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 10. Verweis auf vorhandenen Eintrag = merged, rejected nur ohne Ziel (Krit. 5; Entscheidung Chris 2026-11-04)
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
create temp table res_mg as select public.report_missing_game('Verweiszyxq Spiel', null, null, null, 'wishlist') as j;
|
||||||
|
grant all on res_mg to public;
|
||||||
|
reset role;
|
||||||
|
select throws_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_mg), 'rejected', 'f13c0000-0000-4000-8000-000000000013')$$,
|
||||||
|
'P0001', 'report_reject_has_target', 'KAT-04 Kriterium 5: rejected mit Ziel wird abgewiesen');
|
||||||
|
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',
|
||||||
|
'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');
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select results_eq($$select c.game_id, c.list from public.collection_items c where c.id = (select (j->>'collection_item_id')::uuid from res_mg)$$,
|
||||||
|
$$values ('f13c0000-0000-4000-8000-000000000013'::uuid, 'wishlist')$$,
|
||||||
|
'KAT-04 Kriterium 5: der Listeneintrag zeigt direkt auf den vorhandenen Eintrag, ohne Aktion des Nutzers');
|
||||||
|
select results_eq($$select g.merged_into_id, g.hidden_at is not null from public.games g where g.id = (select (j->>'provisional_game_id')::uuid from res_mg)$$,
|
||||||
|
$$values ('f13c0000-0000-4000-8000-000000000013'::uuid, true)$$,
|
||||||
|
'KAT-04 Kriterium 5: der vorläufige Eintrag verweist auf den richtigen Eintrag und bleibt für Anna lesbar');
|
||||||
|
select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_mg), now()), 1::bigint,
|
||||||
|
'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',
|
||||||
|
'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')$$,
|
||||||
|
'KAT-04 Kriterium 6: rejected ohne Ziel ist zulässig');
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
-- 11. Datenschutz: Konto löschen (QUE-04, Klärung 10)
|
||||||
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000d4","role":"authenticated"}', true);
|
||||||
|
set local role authenticated;
|
||||||
|
select lives_ok($$select public.report_missing_game('Zyxqtest Danas Spiel', null, null, null, 'owned')$$, 'Vorbedingung: Dana meldet ein Spiel');
|
||||||
|
reset role;
|
||||||
|
select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000d4'), 1::bigint, 'Vorbedingung: Dana hat eine Meldung');
|
||||||
|
select lives_ok($$delete from auth.users where id = 'a1300000-0000-4000-8000-0000000000d4'$$, 'QUE-04: Danas Konto lässt sich löschen');
|
||||||
|
select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000d4')
|
||||||
|
+ (select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000d4')
|
||||||
|
+ (select count(*) from public.collection_items where owner_id = 'a1300000-0000-4000-8000-0000000000d4'), 0::bigint,
|
||||||
|
'QUE-04: Meldungen, vorläufige Spiele und Listeneinträge sind mit dem Konto gelöscht');
|
||||||
|
select cmp_ok((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000a1'), '>', 0::bigint, 'QUE-04: Anna hat weiterhin ihre Meldungen');
|
||||||
|
|
||||||
|
select * from finish();
|
||||||
|
rollback;
|
||||||
Loading…
Reference in a new issue