feat(db): kein 'abgelehnt mit Verweis' mehr, adopt_report_target entfällt (Entscheidung Chris, Refs #13)
All checks were successful
All checks were successful
admin_resolve_report weist rejected mit Ziel ab (report_reject_has_target), Check game_reports_rejected_no_target, Tests und Docs angepasst. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
154e5e4bb5
commit
3af18073a6
9 changed files with 172 additions and 261 deletions
|
|
@ -267,11 +267,11 @@ Eindeutig ist `(game_id, locale, title_search)`: derselbe Titel in derselben Spr
|
|||
| `status` | text | ja | `'open'` | `open`, `approved`, `rejected`, `merged` |
|
||||
| `target_game_id` | uuid | nein | | FK → games (`on delete restrict`) |
|
||||
| `resolved_at` | timestamptz | nein | | gesetzt genau dann, wenn `status <> 'open'` |
|
||||
| `editions_cleared` | integer | ja | `0` | Anzahl der Verweise (Sammlung, Partien), deren Edition beim Umhängen geleert wurde (`variants_cleared`); Check `>= 0`; setzen nur `admin_resolve_report` und `adopt_report_target` (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()` | |
|
||||
|
||||
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:
|
||||
|
||||
|
|
@ -383,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).
|
||||
- **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); `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` 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.
|
||||
- **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_*`.
|
||||
|
|
@ -395,7 +395,7 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle
|
|||
|
||||
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). Bei „abgelehnt mit Verweis“ entscheidet der Nutzer selbst über den Wechsel auf den Zieleintrag (`adopt_report_target`). Den Ergebnishinweis klickt die App mit einem Update von `result_seen_at` weg.
|
||||
**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):
|
||||
|
||||
|
|
@ -405,23 +405,21 @@ Spec: `docs/specs/13-fehlendes-spiel-melden.md` (Umsetzungsnachtrag: `docs/specs
|
|||
| `report_day(timestamptz)` | sql, immutable | `authenticated` | Berliner Kalendertag (Tageslimit); `authenticated` braucht das Recht, weil der Trigger als `security invoker` läuft |
|
||||
| `find_similar_catalog_games(p_title, p_year)` | RPC, `security invoker`, stable | `authenticated` | höchstens 5 sichtbare Katalogspiele mit gleichem normalisierten Titel (Regel A, jedes Jahr) oder Titel-Ähnlichkeit >= 0,8 und Jahr ± 1 (Regel B; fehlt ein Jahr, ohne Jahresbedingung); Kandidatentitel sind der Haupttitel und alle alternativen Titel (`title_search`); Rückgabe `game_id, title, matched_title, year, publishers, match_kind ('same_title'/'similar_title'), similarity`; Titel leer oder über 200 Zeichen ergibt eine leere Menge |
|
||||
| `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"}` |
|
||||
| `adopt_report_target(p_report)` | RPC, **`security definer`**, Eigentümer `postgres` | `authenticated` | Wechsel auf den Zieleintrag einer mit Verweis abgelehnten Meldung; Rückgabe: Zähler aus `repoint_game_references` |
|
||||
| `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).
|
||||
`adopt_report_target`: `not_authenticated`, `report_not_found` (unbekannt, fremd oder vorläufiger Eintrag gehört nicht dem Aufrufer), `report_not_adoptable`, `report_no_provisional`, `report_already_adopted`, `report_target_not_listed`, `report_foreign_references`.
|
||||
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.
|
||||
|
||||
**`adopt_report_target` (security definer, Begründung):** Nutzer dürfen `merged_into_id` und `hidden_at` nicht schreiben und `repoint_game_references` nicht ausführen. Die Funktion bestimmt den Aufrufer nur über `auth.uid()`, prüft Eigentum an Meldung und vorläufigem Eintrag, dass das Ziel ein sichtbares Katalogspiel ist und dass keine Verweise mit fremdem oder fehlendem Eigentümer (`collection_items`, `favorite_games`, `plays`, `score_sheets`) am vorläufigen Eintrag hängen; sie ruft nie eine `admin_*`-Funktion auf. Sperrreihenfolge: erst die Meldung, dann beide Spiele nach `id`. `result_seen_at` bleibt unverändert, damit der Hinweis am Zieleintrag erscheint, bis der Nutzer ihn wegklickt. `editions_cleared` wird wie in `admin_resolve_report` gesetzt.
|
||||
**`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`, `report_missing_game`, `adopt_report_target`; `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.
|
||||
**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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -398,13 +398,13 @@ Nutzer melden fehlende Spiele (KAT-04, #13); Meldungen, die ein Import anlegt, g
|
|||
| Entscheidung | Aufruf | Wirkung |
|
||||
|---|---|---|
|
||||
| **freigeben** | `'approved'` + Ziel | Der Eintrag ist wegen dieser Meldung **neu** entstanden. Der vorläufige Eintrag des Nutzers wird auf das Ziel umgehängt (Sammlung, Favoriten, Partien, Wertungsbögen) und ausgeblendet. |
|
||||
| **zusammenführen** | `'merged'` + Ziel | Der Eintrag war **schon vorhanden**. Wirkung wie bei `'approved'`. |
|
||||
| **ablehnen** | `'rejected'`, optional mit Ziel | Mit Ziel wird nur der Verweis gespeichert (die App kann dem Nutzer den Zieleintrag anbieten); der vorläufige Eintrag bleibt unverändert. Ohne Ziel ändert sich nichts außer Status und Zeitpunkt. |
|
||||
| **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'`, **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:
|
||||
|
||||
- **Bei „abgelehnt mit Verweis“ (`'rejected'` + Ziel) hängt der Admin nichts um.** Der Nutzer entscheidet selbst, ob er auf den Zieleintrag wechselt (Funktion `adopt_report_target`, die dieselbe Umhängelogik `repoint_game_references` nutzt). Status und Ziel nur über `admin_resolve_report` setzen, nie von Hand: Die App leitet den Hinweis an den Nutzer aus Status, Ziel, `merged_into_id` und `result_seen_at` ab.
|
||||
- **Vor `approved` 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`.
|
||||
- **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**
|
||||
|
|
@ -495,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');
|
||||
```
|
||||
|
||||
**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
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected');
|
||||
```
|
||||
|
||||
```sql
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004');
|
||||
```
|
||||
|
||||
**Schritt 4: Nachprüfen.** Status der Meldung und Zustand des vorläufigen Eintrags (bei `approved`/`merged`: ausgeblendet und verwiesen; bei `rejected`: unverändert):
|
||||
|
||||
```sql
|
||||
|
|
@ -534,6 +530,7 @@ where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
|
|||
| `report_not_open` | Die Meldung ist schon bearbeitet (`approved`, `merged` oder `rejected`) |
|
||||
| `report_status_invalid` | Der Status ist nicht `approved`, `merged` oder `rejected` |
|
||||
| `report_target_required` | `approved` und `merged` brauchen ein Ziel |
|
||||
| `report_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_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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
| | |
|
||||
|---|---|
|
||||
| Issue | #13 (KAT-04) |
|
||||
| Status | Entwurf, Review-Befunde eingearbeitet; wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2). **Spec-F6 muss vor dem Merge von PR #57 (2c) entschieden werden.** |
|
||||
| Status | Entwurf, Review-Befunde eingearbeitet; wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2). **Spec-F6 muss vor dem Merge von PR #57 (2c) entschieden werden.** **Änderung vom 2026-11-04 (Entscheidung Chris, Option B, Spec-F10):** Es gibt kein „abgelehnt mit Verweis“ und kein `adopt_report_target` mehr; `rejected` nur ohne Ziel, Verweis auf einen vorhandenen Eintrag über `merged`. |
|
||||
| Autor | architect |
|
||||
| Stand | 2026-11-03 |
|
||||
| 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. |
|
||||
| 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). |
|
||||
| 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). |
|
||||
| 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). |
|
||||
| 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. |
|
||||
|
|
@ -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`.
|
||||
- **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`.
|
||||
- `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).
|
||||
|
||||
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) |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| Policies `game_reports_insert_own`, `game_reports_update_seen`, Spaltenrechte | RLS | – | 3.2 |
|
||||
|
||||
|
|
@ -226,30 +225,9 @@ 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`.
|
||||
|
||||
### 4.4 RPC `public.adopt_report_target`
|
||||
### 4.4 entfällt (Entscheidung Chris, 2026-11-04, Option B, Spec-F10)
|
||||
|
||||
```
|
||||
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`.
|
||||
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`).
|
||||
|
||||
### 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
|
||||
|
||||
- `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.
|
||||
|
||||
## 5. Frontend
|
||||
|
|
@ -334,12 +312,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“) |
|
||||
| `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` |
|
||||
| `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 |
|
||||
|
||||
**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“).
|
||||
- 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 +322,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.
|
||||
|
||||
**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.
|
||||
|
||||
|
|
@ -380,7 +355,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. 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. 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. 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 |
|
||||
|
|
@ -399,7 +374,7 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We
|
|||
|
||||
| # | 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 |
|
||||
| B3 | backend-dev | Doku nach 4.7 | B1 |
|
||||
| T1 | test-engineer | Review der pgTAP-Abdeckung; manueller Paralleltest des Limits dokumentieren | B2 |
|
||||
|
|
@ -434,12 +409,12 @@ F1 und U1 starten nach Freigabe der Spec, parallel zu B1. Merge nur durch Chris.
|
|||
| # | 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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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 +433,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-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-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)
|
||||
|
||||
- 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).
|
||||
- Kein Realtime, keine Edge Function, keine neuen Pakete.
|
||||
|
||||
|
|
@ -494,7 +470,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.
|
||||
- **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`.
|
||||
- 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`.
|
||||
|
|
@ -509,3 +485,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-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`.
|
||||
|
||||
|
||||
### 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).
|
||||
|
|
@ -2,16 +2,24 @@
|
|||
|
||||
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 und ändert `admin_resolve_report` nicht; `adopt_report_target` setzt `editions_cleared` genauso.
|
||||
- **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.
|
||||
- **`adopt_report_target`:** Alle Prüfungen laufen nach dem Sperren von Meldung und Spielen, in der Reihenfolge der Spec-Tabelle. Eine Meldung, die nicht dem Aufrufer gehört, wird schon beim Sperren nicht gefunden (`report_not_found`, `p_report = null` ebenso). `current_user` wird nirgends ausgewertet.
|
||||
- **Eigentümer von `adopt_report_target`:** Die Migration ändert den Eigentümer nicht ausdrücklich (`alter function … owner to`); sie läuft als `postgres` (lokal und per Supabase CLI). Der Test prüft `proowner = postgres`.
|
||||
- **Testplan:** `00_schema` unverändert (22 Tabellen), `07`/`08`-Fremdschlüssel-Wächter unberührt (kein neuer Fremdschlüssel), `08` nur um das Abschalten des Limit-Triggers ergänzt (Erwartungen und Beschreibungen unverändert gültig: die Spaltenrechte aus 08 waren schon auf `result_seen_at` und die Insert-Spalten ausgelegt).
|
||||
|
||||
- **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.
|
||||
|
|
@ -24,5 +32,4 @@ Die Mutanten sind veränderte Kopien der Migration, jeweils in einer Transaktion
|
|||
|
||||
## 3. Offene Punkte
|
||||
|
||||
- Zeitpunkt des Hinweises: Klickt der Nutzer den Hinweis „abgelehnt mit Verweis“ weg (`result_seen_at` gesetzt) und wechselt danach auf den Zieleintrag, bleibt `result_seen_at` gesetzt (Spec 4.4: unverändert). Der Hinweis „Dein Eintrag zeigt jetzt auf …“ erscheint dann am Zieleintrag nicht mehr. Siehe Bericht, Frage an Chris.
|
||||
- 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` |
|
||||
| `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 `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. 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. 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/`).
|
||||
|
||||
|
|
@ -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-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-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 |
|
||||
|
|
@ -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-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-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.
|
||||
- **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.
|
||||
|
|
@ -743,3 +743,13 @@ Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_
|
|||
- **Fremdschlüssel-Wächter:** erweiterte Liste in `07` und in `08` (`game_reports.target_game_id` behandelt, `game_reports.provisional_game_id` bewusst ausgelassen). `00_schema.test.sql`: 21 Tabellen.
|
||||
- **Runbook Abschnitt 6:** Alle SQL-Bausteine laufen wörtlich in `08` (einmalige Prüfung per Skript, keine CI-Prüfung). Der Trockenlauf-Baustein aus 2b gilt entsprechend (`begin; …; rollback;` in einem Lauf).
|
||||
- **Mutationsprüfung:** 25 Mutanten (u. a. Trigger entfernt/ohne `owner_id`, Grants, Policies, RLS aus, Fremdschlüssel-Aktionen, N12a-Index, Funktionsrumpf-Änderungen) gegen `08`; alle werden erkannt. Bei der Prüfung fiel ein Scheintest auf (Löschen eines Ziels scheiterte an anderen Verweisen), jetzt mit eigenem Ziel. Nicht erkennbar: das Weglassen des wiederholten `revoke` allein (Postgres behält die Rechte bei `create or replace`); erkannt wird stattdessen ein ausdrückliches `grant execute`.
|
||||
|
||||
|
||||
### 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,7 +45,7 @@ supabase test db # alle pgTAP-Tests ausführen
|
|||
|
||||
## 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`.
|
||||
- **Fehlendes Spiel melden (KAT-04, `tests/database/10_game_reports_user.test.sql`):** `public.find_similar_catalog_games(titel, jahr)`, `public.report_missing_game(...)` und `public.adopt_report_target(meldung)` sind nur für `authenticated` ausführbar (`anon` und `service_role` nicht); ebenso die Hilfsfunktionen `strip_leading_article`, `normalize_game_title` und `report_day`. `adopt_report_target` ist die einzige `security definer`-Funktion dieser Gruppe (Eigentümer `postgres`, `search_path` leer, Aufrufer nur über `auth.uid()`, ruft keine `admin_*`-Funktion auf). `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)“.
|
||||
- **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).
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@
|
|||
-- 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
|
||||
-- adopt_report_target RPC (security definer): Wechsel auf den Zieleintrag (Krit. 5)
|
||||
-- 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 und admin_resolve_report stammen aus 2c (kein create or replace hier).
|
||||
-- editions_cleared stammt aus 2c.
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. Normalisierung (Spec 3.3). search_normalize (3a) entfernt nie Artikel; das tun nur diese beiden.
|
||||
|
|
@ -308,89 +309,92 @@ revoke execute on function public.report_missing_game(text, text, integer, text,
|
|||
grant execute on function public.report_missing_game(text, text, integer, text, text, boolean) to authenticated;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 6. RPC adopt_report_target (Krit. 5; Spec 4.4)
|
||||
-- security definer, Eigentümer postgres. Begründung: Nutzer dürfen merged_into_id und hidden_at nicht
|
||||
-- schreiben und repoint_game_references nicht ausführen; der Wechsel auf den Zieleintrag
|
||||
-- ("abgelehnt mit Verweis") entscheidet aber der Nutzer selbst (Spec #2 Spec-F3).
|
||||
-- Sicherheit: search_path leer (alles schema-qualifiziert); der Aufrufer wird nur über auth.uid()
|
||||
-- bestimmt (nie über current_user); Meldung und vorläufiger Eintrag müssen dem Aufrufer gehören;
|
||||
-- Ziel muss ein sichtbares Katalogspiel sein; hängen fremde Verweise am vorläufigen Eintrag, wird
|
||||
-- abgebrochen. Innerhalb der Funktion ist current_user = postgres: Sie darf deshalb KEINE
|
||||
-- admin_*-Funktion aufrufen (deren Rollenprüfung würde bestehen), weder jetzt noch später.
|
||||
-- Sperrreihenfolge wie admin_resolve_report: erst die Meldung, dann beide Spiele nach id.
|
||||
-- result_seen_at bleibt unverändert, damit der Hinweis am Zieleintrag erscheint, bis "Verstanden".
|
||||
-- 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 function public.adopt_report_target(p_report uuid)
|
||||
create or replace function public.admin_resolve_report(p_report uuid, p_status text, p_target uuid default null)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
security invoker
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_uid uuid := auth.uid();
|
||||
rep public.game_reports;
|
||||
prov public.games;
|
||||
result jsonb;
|
||||
rep public.game_reports;
|
||||
prov public.games;
|
||||
result jsonb := '{}'::jsonb;
|
||||
repointed boolean := false;
|
||||
begin
|
||||
if v_uid is null then
|
||||
raise exception 'not_authenticated' using errcode = 'P0001';
|
||||
if current_user not in ('postgres', 'supabase_admin') then
|
||||
raise exception 'admin_only' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
-- Meldung des Aufrufers sperren; fremde und unbekannte Meldungen liefern denselben Fehler.
|
||||
select * into rep from public.game_reports r
|
||||
where r.id = p_report and r.owner_id = v_uid
|
||||
for update;
|
||||
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 <> 'rejected' or rep.target_game_id is null then
|
||||
raise exception 'report_not_adoptable' using errcode = 'P0001';
|
||||
if rep.status <> 'open' then
|
||||
raise exception 'report_not_open' using errcode = 'P0001';
|
||||
end if;
|
||||
if rep.provisional_game_id is null then
|
||||
raise exception 'report_no_provisional' using errcode = 'P0001';
|
||||
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 (rep.provisional_game_id, rep.target_game_id)
|
||||
order by g.id for update;
|
||||
where g.id in (p_target, rep.provisional_game_id) order by g.id for update;
|
||||
|
||||
select * into prov from public.games g where g.id = rep.provisional_game_id;
|
||||
if not found or prov.visibility <> 'private' or prov.owner_id is distinct from v_uid then
|
||||
raise exception 'report_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
if prov.merged_into_id is not null then
|
||||
raise exception 'report_already_adopted' using errcode = 'P0001';
|
||||
end if;
|
||||
if not exists (
|
||||
if p_target is not null and not exists (
|
||||
select 1 from public.games g
|
||||
where g.id = rep.target_game_id and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null
|
||||
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;
|
||||
|
||||
-- Verweise mit fremdem oder fehlendem Eigentümer (null zählt als fremd) brechen ab.
|
||||
if exists (select 1 from public.collection_items x where x.game_id = prov.id and x.owner_id is distinct from v_uid)
|
||||
or exists (select 1 from public.favorite_games x where x.game_id = prov.id and x.profile_id is distinct from v_uid)
|
||||
or exists (select 1 from public.plays x where x.game_id = prov.id and x.owner_id is distinct from v_uid)
|
||||
or exists (select 1 from public.score_sheets x where x.game_id = prov.id and x.owner_id is distinct from v_uid) then
|
||||
raise exception 'report_foreign_references' using errcode = 'P0001';
|
||||
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;
|
||||
|
||||
-- privat -> Katalog: nichts wird kopiert; gleichnamige Varianten abgebildet, sonst null (RB1, N15)
|
||||
result := public.repoint_game_references(prov.id, rep.target_game_id);
|
||||
|
||||
-- merged_into_id verlangt hidden_at (Check), deshalb in einem Statement.
|
||||
update public.games g
|
||||
set merged_into_id = rep.target_game_id, hidden_at = coalesce(g.hidden_at, now())
|
||||
where g.id = prov.id;
|
||||
|
||||
-- Bei "rejected" (nur ohne Ziel) bleibt der vorläufige Eintrag unverändert und privat.
|
||||
update public.game_reports r
|
||||
set editions_cleared = coalesce((result ->> 'variants_cleared')::integer, 0)
|
||||
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;
|
||||
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;
|
||||
$$;
|
||||
|
||||
revoke execute on function public.adopt_report_target(uuid) from public, anon, authenticated, service_role;
|
||||
grant execute on function public.adopt_report_target(uuid) to authenticated;
|
||||
-- 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,7 +5,7 @@
|
|||
-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix 7c1d/f8), gezählt wird nur darüber.
|
||||
-- Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall, sonst würde "alles verweigern" bestehen.
|
||||
begin;
|
||||
select plan(160);
|
||||
select plan(163);
|
||||
|
||||
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"}'),
|
||||
|
|
@ -196,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');
|
||||
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');
|
||||
-- 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
|
||||
('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)$$,
|
||||
'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',
|
||||
'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)$$,
|
||||
|
|
@ -259,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)
|
||||
insert into public.games (id, title, visibility) values ('f8000000-0000-0000-0000-000000000c03', 'Ziel nur für eine Meldung', 'catalog');
|
||||
insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values
|
||||
('f8000000-0000-0000-0000-000000000c04', '22222222-2222-2222-2222-222222222222', 'Verweist auf Ziel', 'rejected', now(), 'f8000000-0000-0000-0000-000000000c03');
|
||||
('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,
|
||||
'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,
|
||||
|
|
@ -392,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')$$,
|
||||
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: unbekanntes Ziel (report_target_not_listed)');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected', 'f8000000-0000-0000-0000-000000000d01')$$,
|
||||
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: auch bei rejected wird das Ziel geprüft');
|
||||
'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
|
||||
-- (sonst käme repoint_target_not_listed aus der internen Funktion)
|
||||
|
|
@ -608,24 +616,24 @@ union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(m
|
|||
create temp table rb_rej1 as
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected') as r;
|
||||
create temp table rb_rej2 as
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004') as r;
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected') as r;
|
||||
select is((select r - 'report_id' from rb_rej1),
|
||||
'{"status":"rejected","target_id":null,"provisional_game_id":"7c1d2c3d-0000-4000-8000-000000000009","repointed":false}'::jsonb,
|
||||
'KAT-05 Kriterium 5: Runbook 6, rejected ohne Ziel: nichts umgehängt, keine Zähler');
|
||||
select is((select r - 'report_id' from rb_rej2),
|
||||
'{"status":"rejected","target_id":"7c1d2c3d-0000-4000-8000-000000000004","provisional_game_id":"7c1d2c3d-0000-4000-8000-00000000000a","repointed":false}'::jsonb,
|
||||
'KAT-05 Kriterium 5: Runbook 6, rejected mit Ziel: nur der Verweis, nichts umgehängt');
|
||||
'{"status":"rejected","target_id":null,"provisional_game_id":"7c1d2c3d-0000-4000-8000-00000000000a","repointed":false}'::jsonb,
|
||||
'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'),
|
||||
'(rejected,,t)', 'rejected ohne Ziel: Status und Zeitpunkt gespeichert, kein Ziel');
|
||||
select is((select row(status, target_game_id, resolved_at is not null)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000007'),
|
||||
'(rejected,7c1d2c3d-0000-4000-8000-000000000004,t)', 'rejected mit Ziel: der Verweis auf den Zieleintrag ist gespeichert (Spec-F3)');
|
||||
'(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'
|
||||
union all select 'c6', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c6'
|
||||
union all select 'b4', game_id::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b4'
|
||||
union all select 'g9', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-000000000009'
|
||||
union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-00000000000a'$$,
|
||||
$$select k, v from rb_rej_snapshot$$,
|
||||
'KAT-05 Kriterium 5: bei rejected (mit und ohne Ziel) bleiben vorläufige Einträge, Sammlung und Partien unverändert');
|
||||
'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
|
||||
create temp table rb_noprov as
|
||||
|
|
@ -643,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',
|
||||
'7c1d2c3d-0000-4000-8000-000000000007', '7c1d2c3d-0000-4000-8000-00000000000b')),
|
||||
'7c1d2c3d-0000-4000-8000-000000000001=1,7c1d2c3d-0000-4000-8000-000000000005=0,7c1d2c3d-0000-4000-8000-000000000006=0,7c1d2c3d-0000-4000-8000-000000000007=0,7c1d2c3d-0000-4000-8000-00000000000b=0',
|
||||
'Spec-F6: editions_cleared = 1 bei approved mit geleerter Edition (…01), 0 bei merged ohne geleerte Edition, bei rejected (mit und ohne Ziel) und ohne vorläufigen Eintrag');
|
||||
'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
|
||||
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
|
|
@ -701,7 +709,7 @@ insert into public.games (id, title, visibility) values
|
|||
('f8000000-0000-0000-0000-000000000f03', 'Anderes Ziel', 'catalog');
|
||||
insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values
|
||||
('f8000000-0000-0000-0000-000000000f11', '22222222-2222-2222-2222-222222222222', 'Freigegeben auf Quelle', 'approved', now(), 'f8000000-0000-0000-0000-000000000f01'),
|
||||
('f8000000-0000-0000-0000-000000000f12', '22222222-2222-2222-2222-222222222222', 'Abgelehnt mit Verweis auf Quelle', 'rejected', now(), 'f8000000-0000-0000-0000-000000000f01'),
|
||||
('f8000000-0000-0000-0000-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');
|
||||
insert into public.game_reports (id, owner_id, title) values
|
||||
('f8000000-0000-0000-0000-000000000f14', '22222222-2222-2222-2222-222222222222', 'Offen');
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
-- 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(213);
|
||||
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"}'),
|
||||
|
|
@ -31,7 +31,6 @@ insert into fn values
|
|||
('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.adopt_report_target(uuid)', 'adopt_report_target', 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
|
||||
|
|
@ -43,11 +42,10 @@ select is_empty($$select f.sig from fn f
|
|||
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 <> (f.sig = 'public.adopt_report_target(uuid)')$$,
|
||||
'KAT-04: nur adopt_report_target ist security definer');
|
||||
select is((select pg_get_userbyid(proowner) from pg_proc where oid = 'public.adopt_report_target(uuid)'::regprocedure), 'postgres'::name,
|
||||
'KAT-04 Kriterium 5: adopt_report_target gehört postgres');
|
||||
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');
|
||||
|
|
@ -253,7 +251,6 @@ 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.adopt_report_target('a1300000-0000-4000-8000-0000000000ff')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf adopt_report_target 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');
|
||||
|
|
@ -616,130 +613,39 @@ select is(pg_temp.mark_seen_all(now()), 0::bigint,
|
|||
reset role;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 10. adopt_report_target (Krit. 5)
|
||||
-- 10. Verweis auf vorhandenen Eintrag = merged, rejected nur ohne Ziel (Krit. 5; Entscheidung Chris 2026-11-04)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- R1: vollständiger Wechsel. Anna hat am vorläufigen Eintrag Sammlung, Favorit, Partien, Wertungsbogen, zwei Varianten.
|
||||
insert into public.games (id, title, year, visibility, owner_id) values
|
||||
('f13a0000-0000-4000-8000-000000000101', 'Adoptzyxq Eigen', 2021, 'private', 'a1300000-0000-4000-8000-0000000000a1');
|
||||
insert into public.game_variants (id, game_id, name) values
|
||||
('f13a0000-0000-4000-8000-0000000001b1', 'f13a0000-0000-4000-8000-000000000101', ' standard '),
|
||||
('f13a0000-0000-4000-8000-0000000001b2', 'f13a0000-0000-4000-8000-000000000101', 'Prototyp');
|
||||
insert into public.collection_items (id, owner_id, game_id, variant_id, list, note, acquired_on) values
|
||||
('f13a0000-0000-4000-8000-0000000001c1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'f13a0000-0000-4000-8000-0000000001b2', 'owned', 'Notiz bleibt', '2025-01-02');
|
||||
insert into public.favorite_games (id, profile_id, game_id, position) values
|
||||
('f13a0000-0000-4000-8000-0000000001d1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 1);
|
||||
insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values
|
||||
('f13a0000-0000-4000-8000-0000000001e1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'f13a0000-0000-4000-8000-0000000001b1', '2026-02-01', 'f13a0000-0000-4000-8000-0000000001e1'),
|
||||
('f13a0000-0000-4000-8000-0000000001e2', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'f13a0000-0000-4000-8000-0000000001b2', '2026-02-02', 'f13a0000-0000-4000-8000-0000000001e2');
|
||||
insert into public.score_sheets (id, owner_id, game_id, name) values
|
||||
('f13a0000-0000-4000-8000-0000000001f1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'Bogen');
|
||||
insert into public.game_reports (id, owner_id, title, provisional_game_id, status, target_game_id, resolved_at, created_at) values
|
||||
('f13a0000-0000-4000-8000-000000000a01', 'a1300000-0000-4000-8000-0000000000a1', 'Adoptzyxq Eigen', 'f13a0000-0000-4000-8000-000000000101', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01');
|
||||
|
||||
-- Hilfsfälle für die Fehlerpfade
|
||||
insert into public.games (id, title, visibility, owner_id) values
|
||||
('f13a0000-0000-4000-8000-000000000102', 'Adopt offen', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
|
||||
('f13a0000-0000-4000-8000-000000000103', 'Adopt ohne Ziel', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
|
||||
('f13a0000-0000-4000-8000-000000000104', 'Adopt freigegeben', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
|
||||
('f13a0000-0000-4000-8000-000000000105', 'Adopt Ziel versteckt', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
|
||||
('f13a0000-0000-4000-8000-000000000106', 'Adopt Ziel zusammengefuehrt', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
|
||||
('f13a0000-0000-4000-8000-000000000107', 'Adopt Bens Eintrag', 'private', 'a1300000-0000-4000-8000-0000000000b2'),
|
||||
('f13a0000-0000-4000-8000-0000000001a1', 'Adopt fremd Partie', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
|
||||
('f13a0000-0000-4000-8000-0000000001a2', 'Adopt Partie ohne Eigentuemer', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
|
||||
('f13a0000-0000-4000-8000-0000000001a3', 'Adopt fremd Sammlung', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
|
||||
('f13a0000-0000-4000-8000-0000000001a4', 'Adopt fremd Favorit', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
|
||||
('f13a0000-0000-4000-8000-0000000001a5', 'Adopt fremd Bogen', 'private', 'a1300000-0000-4000-8000-0000000000a1');
|
||||
insert into public.game_reports (id, owner_id, title, provisional_game_id, status, target_game_id, resolved_at, created_at) values
|
||||
('f13a0000-0000-4000-8000-000000000a02', 'a1300000-0000-4000-8000-0000000000a1', 'offen', 'f13a0000-0000-4000-8000-000000000102', 'open', null, null, '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a03', 'a1300000-0000-4000-8000-0000000000a1', 'ohne Ziel', 'f13a0000-0000-4000-8000-000000000103', 'rejected', null, now(), '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a04', 'a1300000-0000-4000-8000-0000000000a1', 'freigegeben', 'f13a0000-0000-4000-8000-000000000104', 'approved', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a05', 'a1300000-0000-4000-8000-0000000000a1', 'ohne vorläufigen Eintrag', null, 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a06', 'a1300000-0000-4000-8000-0000000000a1', 'Ziel versteckt', 'f13a0000-0000-4000-8000-000000000105', 'rejected', 'f13c0000-0000-4000-8000-000000000014', now(), '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a07', 'a1300000-0000-4000-8000-0000000000a1', 'Ziel zusammengeführt', 'f13a0000-0000-4000-8000-000000000106', 'rejected', 'f13c0000-0000-4000-8000-000000000015', now(), '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a08', 'a1300000-0000-4000-8000-0000000000a1', 'Eintrag gehört Ben', 'f13a0000-0000-4000-8000-000000000107', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a09', 'a1300000-0000-4000-8000-0000000000a1', 'Eintrag ist Katalog', 'f13c0000-0000-4000-8000-000000000001', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a11', 'a1300000-0000-4000-8000-0000000000a1', 'fremde Partie', 'f13a0000-0000-4000-8000-0000000001a1', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a12', 'a1300000-0000-4000-8000-0000000000a1', 'Partie ohne Eigentümer', 'f13a0000-0000-4000-8000-0000000001a2', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a13', 'a1300000-0000-4000-8000-0000000000a1', 'fremde Sammlung', 'f13a0000-0000-4000-8000-0000000001a3', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a14', 'a1300000-0000-4000-8000-0000000000a1', 'fremder Favorit', 'f13a0000-0000-4000-8000-0000000001a4', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
|
||||
('f13a0000-0000-4000-8000-000000000a15', 'a1300000-0000-4000-8000-0000000000a1', 'fremder Bogen', 'f13a0000-0000-4000-8000-0000000001a5', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01');
|
||||
insert into public.plays (id, owner_id, game_id, played_at, client_id) values
|
||||
('f13a0000-0000-4000-8000-0000000001e3', 'a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a1', '2026-02-03', 'f13a0000-0000-4000-8000-0000000001e3'),
|
||||
('f13a0000-0000-4000-8000-0000000001e4', null, 'f13a0000-0000-4000-8000-0000000001a2', '2026-02-04', 'f13a0000-0000-4000-8000-0000000001e4');
|
||||
insert into public.collection_items (owner_id, game_id) values ('a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a3');
|
||||
insert into public.favorite_games (profile_id, game_id, position) values ('a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a4', 1);
|
||||
insert into public.score_sheets (owner_id, game_id, name) values ('a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a5', 'Bogen Ben');
|
||||
|
||||
-- Fremde und ungültige Aufrufe
|
||||
select set_config('request.jwt.claims', '{"role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01')$$, 'P0001', 'not_authenticated',
|
||||
'Sicherheit Kriterium 2: ohne Nutzerkennung (auth.uid() null) wird nichts übernommen');
|
||||
reset role;
|
||||
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01')$$, 'P0001', 'report_not_found',
|
||||
'Sicherheit Kriterium 1: Ben kann Annas Meldung nicht übernehmen');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-00000000ffff')$$, 'P0001', 'report_not_found',
|
||||
'KAT-04 Kriterium 5: unbekannte Meldung (gleicher Fehler wie fremde)');
|
||||
select throws_ok($$select public.adopt_report_target(null)$$, 'P0001', 'report_not_found', 'KAT-04 Kriterium 5: Meldung null');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a08')$$, 'P0001', 'report_not_found',
|
||||
'Sicherheit Kriterium 3: Ben kann Annas Meldung nicht mit seinem eigenen privaten Spiel als vorläufigem Eintrag übernehmen');
|
||||
reset role;
|
||||
select is((select merged_into_id from public.games where id = 'f13a0000-0000-4000-8000-000000000107'), null::uuid,
|
||||
'Sicherheit Kriterium 3: Bens Spiel wurde dabei nicht zusammengeführt');
|
||||
select is((select merged_into_id from public.games where id = 'f13a0000-0000-4000-8000-000000000101'), null::uuid,
|
||||
'Sicherheit Kriterium 1: Bens Versuch hat Annas vorläufigen Eintrag nicht verändert');
|
||||
select is((select game_id from public.collection_items where id = 'f13a0000-0000-4000-8000-0000000001c1'), 'f13a0000-0000-4000-8000-000000000101'::uuid,
|
||||
'Sicherheit Kriterium 1: Bens Versuch hat Annas Sammlung nicht umgehängt');
|
||||
|
||||
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a02')$$, 'P0001', 'report_not_adoptable', 'KAT-04 Kriterium 5: offene Meldung');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a03')$$, 'P0001', 'report_not_adoptable', 'KAT-04 Kriterium 6: abgelehnt ohne Ziel gibt es nichts zu übernehmen');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a04')$$, 'P0001', 'report_not_adoptable', 'KAT-04 Kriterium 5: freigegebene Meldung');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a05')$$, 'P0001', 'report_no_provisional', 'KAT-04 Kriterium 5: vorläufiger Eintrag fehlt (vom Nutzer gelöscht)');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a06')$$, 'P0001', 'report_target_not_listed', 'KAT-04 Kriterium 5: Ziel ist ausgeblendet');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a07')$$, 'P0001', 'report_target_not_listed', 'KAT-04 Kriterium 5: Ziel ist zusammengeführt');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a08')$$, 'P0001', 'report_not_found', 'Sicherheit Kriterium 3: der vorläufige Eintrag gehört einem anderen Nutzer');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a09')$$, 'P0001', 'report_not_found', 'Sicherheit Kriterium 3: der vorläufige Eintrag ist ein Katalogspiel');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a11')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Partie eines anderen Eigentümers am vorläufigen Eintrag');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a12')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Partie ohne Eigentümer (null zählt als fremd)');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a13')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Sammlungseintrag eines anderen Nutzers');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a14')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Favorit eines anderen Nutzers');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a15')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Wertungsbogen eines anderen Nutzers');
|
||||
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 is((select count(*) from public.games where id::text like 'f13a0000-0000-4000-8000-0000000001a%' and merged_into_id is null and hidden_at is null), 5::bigint,
|
||||
'Sicherheit Kriterium 3: nach allen Fehlern ist keiner der geprüften vorläufigen Einträge verändert');
|
||||
select is((select count(*) from public.plays where id in ('f13a0000-0000-4000-8000-0000000001e3', 'f13a0000-0000-4000-8000-0000000001e4') and game_id::text like 'f13a0000-0000-4000-8000-0000000001a%'), 2::bigint,
|
||||
'Sicherheit Kriterium 3: fremde Partien wurden nicht umgehängt');
|
||||
|
||||
-- Erfolg
|
||||
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', null,
|
||||
'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;
|
||||
create temp table res_adopt as select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01') as j;
|
||||
grant all on res_adopt to public;
|
||||
select results_eq($$select (j->>'collection_items')::int, (j->>'favorite_games')::int, (j->>'plays')::int, (j->>'score_sheets')::int,
|
||||
(j->>'variants_copied')::int, (j->>'variants_mapped')::int, (j->>'variants_cleared')::int from res_adopt$$,
|
||||
$$values (1, 1, 2, 1, 0, 1, 2)$$,
|
||||
'KAT-04 Kriterium 5: Rückgabe zählt Sammlung, Favorit, Partien, Wertungsbogen; keine Variante kopiert, eine abgebildet, zwei geleert');
|
||||
select results_eq($$select game_id, list, note, acquired_on, variant_id from public.collection_items where id = 'f13a0000-0000-4000-8000-0000000001c1'$$,
|
||||
$$values ('f13c0000-0000-4000-8000-000000000013'::uuid, 'owned', 'Notiz bleibt', '2025-01-02'::date, null::uuid)$$,
|
||||
'KAT-04 Kriterium 5: Sammlungseintrag zeigt aufs Ziel, Liste/Notiz/Kaufdatum bleiben, die nicht passende Edition ist geleert');
|
||||
select results_eq($$select id, game_id, variant_id from public.plays where owner_id = 'a1300000-0000-4000-8000-0000000000a1' and id in ('f13a0000-0000-4000-8000-0000000001e1', 'f13a0000-0000-4000-8000-0000000001e2') order by id$$,
|
||||
$$values ('f13a0000-0000-4000-8000-0000000001e1'::uuid, 'f13c0000-0000-4000-8000-000000000013'::uuid, 'f13c0000-0000-4000-8000-0000000000b1'::uuid),
|
||||
('f13a0000-0000-4000-8000-0000000001e2'::uuid, 'f13c0000-0000-4000-8000-000000000013'::uuid, null::uuid)$$,
|
||||
'KAT-04 Kriterium 5: Partien zeigen aufs Ziel, die gleichnamige Edition wird abgebildet, die andere geleert');
|
||||
select is((select game_id from public.favorite_games where id = 'f13a0000-0000-4000-8000-0000000001d1'), 'f13c0000-0000-4000-8000-000000000013'::uuid, 'KAT-04 Kriterium 5: Favorit zeigt aufs Ziel');
|
||||
select is((select game_id from public.score_sheets where id = 'f13a0000-0000-4000-8000-0000000001f1'), 'f13c0000-0000-4000-8000-000000000013'::uuid, 'KAT-04 Kriterium 5: Wertungsbogen zeigt aufs Ziel');
|
||||
select results_eq($$select visibility, merged_into_id, hidden_at is not null from public.games where id = 'f13a0000-0000-4000-8000-000000000101'$$,
|
||||
$$values ('private', 'f13c0000-0000-4000-8000-000000000013'::uuid, true)$$,
|
||||
'KAT-04 Kriterium 5: der vorläufige Eintrag ist ausgeblendet und zeigt aufs Ziel, bleibt privat');
|
||||
select results_eq($$select status, target_game_id, result_seen_at, editions_cleared from public.game_reports where id = 'f13a0000-0000-4000-8000-000000000a01'$$,
|
||||
$$values ('rejected', 'f13c0000-0000-4000-8000-000000000013'::uuid, null::timestamptz, 2)$$,
|
||||
'KAT-04 Kriterium 5: Status bleibt rejected, result_seen_at bleibt null, editions_cleared ist gesetzt');
|
||||
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01')$$, 'P0001', 'report_already_adopted', 'KAT-04 Kriterium 5: ein zweiter Wechsel scheitert');
|
||||
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', null,
|
||||
'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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue