diff --git a/docs/specs/13-fehlendes-spiel-melden.md b/docs/specs/13-fehlendes-spiel-melden.md index bae1af4..14558d2 100644 --- a/docs/specs/13-fehlendes-spiel-melden.md +++ b/docs/specs/13-fehlendes-spiel-melden.md @@ -3,12 +3,12 @@ | | | |---|---| | Issue | #13 (KAT-04) | -| Status | Entwurf, Review-Befunde eingearbeitet; wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2) | +| 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.** | | Autor | architect | | Stand | 2026-11-03 | | Etappe | R1.2 Sammlung | -| Abhängigkeiten | **Vor 13a gemergt:** #2 PR 2b und 2c (Spec #2 E12: `game_reports`, `admin_resolve_report`, `repoint_game_references`, Trigger `games_visibility_immutable`) und **#3 KAT-01** (`search_normalize`, `game_alt_titles`, Einstieg aus der leeren Suche, Krit. 6). Außerdem #1. Fachlich zusätzlich #6 SAM-01 und #4 KAT-02 (Orte für „in die Sammlung“ und den Hinweis „am Eintrag“), in Forgejo bisher **nicht** als Abhängigkeit gesetzt (Spec-F8). | -| Quellen | Issue #13 inkl. Kommentare (u. a. Hinweis aus PR #51 vom 2026-11-03), `requirements/20-r1-akzeptanzkriterien.md` § KAT-04 und § Querschnitt, `requirements/r1-klaerungen.md` (6, 10, 12, 15, 20, 23, 26, 27, 28, 36, 44/25, 45, N12, N12a, N15), `requirements/backlog.md`, `docs/specs/2-katalog-daten.md` (E10–E14, 3.2–3.4, 4.4, 9.5), Spec #3 (`search_normalize`, `game_alt_titles`), `docs/datenmodell-r1.md`, `supabase/migrations/…catalog.sql`, `…collection.sql`, `…plays.sql`, `…catalog_foundation.sql`, CLAUDE.md | +| Abhängigkeiten | **Vorbedingung 13a:** #2 PR 2b und 2c gemergt (Spec #2 E12: `game_reports`, `admin_resolve_report`, `repoint_game_references`, Trigger `games_visibility_immutable`); #3 PR 3a gemergt (`search_normalize`, `game_alt_titles`, `title_search`, `pg_trgm`, `unaccent`). **Vorbedingung 13b (Merge):** #3 PR 3b gemergt (`onReportGame` im Leerzustand, Krit. 6 von KAT-01). Außerdem #1. Fachlich zusätzlich #6 SAM-01 und #4 KAT-02 (Orte für „in die Sammlung“ und den Hinweis „am Eintrag“), in Forgejo bisher **nicht** als Abhängigkeit gesetzt (Spec-F8). | +| Quellen | Issue #13 inkl. Kommentare (u. a. Hinweis aus PR #51 vom 2026-11-03), `requirements/20-r1-akzeptanzkriterien.md` § KAT-04 und § Querschnitt, `requirements/r1-klaerungen.md` (6, 10, 12, 15, 20, 23, 26, 27, 28, 36, 44/25, 45, N12, N12a, N15), `requirements/backlog.md`, `docs/specs/2-katalog-daten.md` (E10–E14, 3.2–3.4, 4.4, 9.5), Spec #3 (3.2: `search_normalize`, `title_search`, `game_alt_titles`), `docs/datenmodell-r1.md`, `supabase/migrations/…catalog.sql`, `…collection.sql`, `…plays.sql`, `…catalog_foundation.sql`, CLAUDE.md | **Namenskonvention:** „Spec-F1“ bis „Spec-F9“ sind die Fragen an Chris aus § 9.2. „E1“ usw. sind Kernentscheidungen dieser Spec, „#2 E12“ verweist auf Spec #2. „B1“, „F1“, „U1“, „T1“ sind Aufgaben (§ 8). @@ -27,12 +27,12 @@ Diese Spec baut auf `game_reports` und `admin_resolve_report` aus Spec #2 § 3.4 | E1 | **Jede Nutzer-Meldung legt genau einen vorläufigen privaten Eintrag an** (1:1; der eindeutige Teilindex auf `provisional_game_id` kommt aus 2c). Er bekommt nur `title` und `year`. | Der Hinweis braucht einen festen Ort, Krit. 3 verlangt die sofortige Nutzung; entspricht Backlog (N12) und Import (SAM-08 Krit. 4). Mit nur Titel und Jahr kann außer der Edition nichts verloren gehen (Krit. 4, N15). | | 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 `pg_trgm`. Normalisierung: `search_normalize` aus **Spec #3** (Eigentümer #3), dazu in 13a nur der dünne Wrapper `normalize_game_title` (führende Artikel). Haupttitel und alternative Titel (`game_alt_titles`), nur sichtbare Katalogspiele. | Klärung 15 ist eine Datenbankregel. Eine einzige Normalisierung für Suche und Meldung. Die RPC prüft beim Absenden noch einmal; ohne Bestätigung wird nicht gespeichert (Krit. 2). | +| E4 | **Ähnlichkeitsprüfung serverseitig** über `find_similar_catalog_games` mit `extensions.similarity`. Grundlage sind die von #3 gepflegten Spalten `title_search` (Haupttitel in `games`, alternative Titel in `game_alt_titles`); 13a entfernt darüber nur führende Artikel (`strip_leading_article`, `normalize_game_title`). Nur sichtbare Katalogspiele. | Klärung 15 ist eine Datenbankregel. Eine einzige Normalisierung für Suche und Meldung (gemeinsamer Absatz in 3.3). Die RPC prüft beim Absenden noch einmal; ohne Bestätigung wird nicht gespeichert (Krit. 2). | | E5 | **„Abgelehnt mit Verweis“: Der Nutzer entscheidet selbst** über den Wechsel (Spec #2 Spec-F3) per `adopt_report_target` (security definer, Eigentümer `postgres`), streng geprüft; nutzt `repoint_game_references` aus 2b. | Krit. 5. Erster Weg, auf dem ein Nutzer `repoint_game_references` indirekt auslöst; deshalb ausführliche Prüfungen, feste Sperrreihenfolge und Rechte-Tests (4.4). | | 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` (Nachtrag in 2b/2c, solange beide noch nicht gemergt sind); Rückfall ist die Ableitung aus den Daten (nur Sammlung, `list = 'owned'`). | Der Zähler ist genau und jetzt günstig; die Ableitung braucht keine Änderung an 2b/2c, ist aber ungenau. | +| 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. | | 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` gehört zum Supabase-Postgres-Image (ADR-0002). | CLAUDE.md, ADR-0002. | +| E9 | **Keine neuen Pakete, keine Edge Function.** `pg_trgm` kommt aus 3a (Spec #3 E14). | CLAUDE.md, ADR-0002. | | E10 | **Zwei PRs:** 13a Backend (`Refs #13`), 13b Flutter (`Closes #13`). 13b beginnt gegen ein gemocktes Repository, sobald der RPC-Vertrag (4.2–4.5) freigegeben ist. | Der Vertrag steht vollständig in dieser Spec. | ## 2. Akzeptanzkriterien @@ -66,25 +66,21 @@ Diese Spec baut auf `game_reports` und `admin_resolve_report` aus Spec #2 § 3.4 ## 3. Datenmodell -### 3.1 Ausgangslage (nach #2 PR 2c und #3) +### 3.1 Ausgangslage (nach #2 PR 2c und #3 PR 3a) Aus Spec #2 § 3.4 (Spaltennamen werden so vorausgesetzt; weicht 2c ab, passt B1 die Namen an): - `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). +- `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). - Trigger `games_visibility_immutable`: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14). -Aus Spec #3: `search_normalize(text)` und die Tabelle `game_alt_titles` (alternative Titel je Spiel mit Sprache). Der Fremdschlüssel `game_alt_titles.game_id` und seine Einordnung im Fremdschlüssel-Wächter gehören zu #3. +Aus Spec #3 PR 3a: `search_normalize(text)`, die generierten Spalten `games.title_search` und `game_alt_titles.title_search`, die Tabelle `game_alt_titles` sowie die Erweiterungen `pg_trgm` und `unaccent`. Der Fremdschlüssel `game_alt_titles.game_id` und seine Einordnung im Fremdschlüssel-Wächter gehören zu #3. ### 3.2 Änderungen in 13a (Migration `supabase/migrations/_game_reports_user.sql`) -Nur additiv; der Zeitstempel liegt nach den Migrationen aus 2c und #3. Rechte-Regel aus Spec #2 § 3.2: jede neue Funktion zuerst `revoke execute … from public, anon, authenticated, service_role`, danach nur die Grants aus 4.1. - -``` -create extension if not exists pg_trgm with schema extensions; -``` +**Vorbedingung 13a:** #2 PR 2b und 2c gemergt; #3 PR 3a gemergt (`search_normalize`, `game_alt_titles`). Nur additiv; der Zeitstempel liegt nach diesen Migrationen. `pg_trgm` und `unaccent` legt Spec #3 (3a) an; 13a nutzt `extensions.similarity`. Rechte-Regel aus Spec #2 § 3.2: jede neue Funktion zuerst `revoke execute … from public, anon, authenticated, service_role`, danach nur die Grants aus 4.1. **Neue Spalte in `game_reports`** @@ -92,7 +88,7 @@ create extension if not exists pg_trgm with schema extensions; |---|---|---| | `result_seen_at` | timestamptz | nullable; der Nutzer hat den Ergebnishinweis weggeklickt (E6, Spec-F5). Check `result_seen_at is null or status <> 'open'`. | -Bei Spec-F6 = Zähler (Empfehlung) kommt `editions_cleared integer not null default 0` schon in 2c (Nachtrag Spec #2), nicht in 13a. +Bei Spec-F6 = Zähler (Empfehlung) legt **2c** die Spalte `editions_cleared integer not null default 0 check (editions_cleared >= 0)` an (Nachtrag Spec #2 § 3.4 / PR #57), nicht 13a. **Rechte auf `game_reports`** (E3) @@ -102,7 +98,7 @@ grant insert (owner_id, title, publisher_name, year, link, provisional_game_id) grant update (result_seen_at) on public.game_reports to authenticated; ``` -**Bewusste Änderung gegenüber Spec #2 § 3.4:** Dort haben Nutzer kein Insert- und kein Update-Recht, und `08_game_reports.test.sql` prüft, dass nur `postgres` Meldungen ändert. 13a erlaubt das Anlegen eigener Meldungen und das Ändern genau einer Spalte (`result_seen_at`) an **eigenen**, bereits bearbeiteten Meldungen. Das bleibt mit KAT-05 Krit. 9 vereinbar (verboten ist das Ändern **fremder** Meldungen); B2 passt `08` an. +**Bewusste Änderung gegenüber Spec #2 § 3.4:** Dort haben Nutzer kein Insert- und kein Update-Recht, und `08_game_reports.test.sql` prüft, dass nur `postgres` Meldungen ändert. 13a erlaubt das Anlegen eigener Meldungen und das Ändern genau einer Spalte (`result_seen_at`) an **eigenen**, bereits bearbeiteten Meldungen. Das bleibt mit KAT-05 Krit. 9 vereinbar (verboten ist das Ändern **fremder** Meldungen); zu den Tests aus `08` siehe § 7 „Bestehende Tests“. `select` bleibt wie in 2c. **Nicht** schreibbar für Nutzer: `id`, `origin`, `status`, `bgg_id`, `target_game_id`, `resolved_at`, `created_at`, `updated_at` (42501). Damit setzt die Datenbank `origin = 'user'`, `status = 'open'` (Krit. 8) und `created_at = now()` (Tageslimit) selbst. `delete` bekommt der Nutzer nicht. @@ -119,7 +115,7 @@ grant update (result_seen_at) on public.game_reports to authenticated; - `create trigger … before insert on public.game_reports for each row when (new.origin = 'user') execute function public.game_reports_daily_limit()`. - Sperre gegen gleichzeitige Inserts: `perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0))`. **Setzt die Isolationsstufe READ COMMITTED voraus** (Standard bei PostgREST und Supabase): Die Zählung nach der Sperre sieht dann die inzwischen bestätigten Meldungen der anderen Transaktion. Unter REPEATABLE READ oder SERIALIZABLE wäre die Sperre wirkungslos, weil der Snapshot vor der Sperre feststeht. -- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `public.report_day(created_at) = public.report_day(now())`. Bei ≥ 10 Fehler `report_daily_limit` (P0001). Die Zahl 10 ist eine Konstante in der Funktion; Änderung = neue Migration. +- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `public.report_day(created_at) = public.report_day(now())`. Bei ≥ 10 Fehler `report_daily_limit` (P0001). Die Zahl 10 ist eine Konstante in der Funktion; Änderung = neue Migration. Optional (backend-dev): als Bereich `created_at >= (date_trunc('day', now() at time zone 'Europe/Berlin') at time zone 'Europe/Berlin')` formulieren, damit der Index `(owner_id, created_at)` greift; die Bedeutung bleibt gleich. - Hilfsfunktion `public.report_day(p_at timestamptz) returns date` = `(p_at at time zone 'Europe/Berlin')::date`, `language sql`, `immutable`, `search_path = ''`. **`grant execute … to authenticated`** (rechnet nur): Der Trigger läuft als `security invoker` mit der Rolle des Einfügenden; ohne dieses Recht scheitert jede Meldung mit 42501. Herausgezogen, damit pgTAP die Tagesgrenze mit festen Zeitstempeln prüfen kann. - Trigger-Funktion `security invoker`, `search_path = ''`: Der Nutzer sieht per RLS alle eigenen Meldungen; Studio (`postgres`) umgeht RLS ohnehin. - Import-Meldungen (`origin = 'import'`, SAM-08) werden weder gezählt noch geprüft (N12). @@ -128,21 +124,29 @@ grant update (result_seen_at) on public.game_reports to authenticated; ### 3.3 Normalisierung und „sehr ähnlich“ (Krit. 2, Klärung 15) -**Zuständigkeit und Rechte (gleicher Satz in Spec #3):** Spec #3 besitzt `search_normalize` (u. a. `unaccent`, `lower`, Entfernen von Sonderzeichen) und gibt sie per `grant execute … to anon, authenticated` frei, weil generierte Spalten mit den Rechten der einfügenden Rolle berechnet werden; ein Entzug würde jeden Insert in `games` brechen. `unaccent` mit festem Wörterbuch als `immutable` zu deklarieren ist eine verbreitete Konvention, keine Garantie von PostgreSQL. #13 ändert `search_normalize` nicht und legt keine zweite Normalisierung an. +> **Gemeinsame Normalisierung (gleichlautend in Spec #3 und Spec #13):** `public.search_normalize(text)` ist die einzige Basisfunktion für normalisierte Titel. Sie wird von #3 (PR 3a) angelegt, ist rein rechnend und für `anon`, `authenticated` und `service_role` ausführbar, weil generierte Spalten und `security invoker`-Funktionen sie mit den Rechten des Aufrufers berechnen. #13 legt `public.normalize_game_title(text)` darüber an und entfernt nur dort Artikel; `search_normalize` entfernt nie Artikel. -**In 13a neu** (beide `language sql`, `immutable`, `parallel safe`, `search_path = ''`, `grant execute … to anon, authenticated`): +`unaccent` mit festem Wörterbuch als `immutable` zu deklarieren ist eine verbreitete Konvention, keine Garantie von PostgreSQL. + +**In 13a neu** (beide `language sql`, `immutable`, `parallel safe`, `search_path = ''`, `grant execute … to authenticated`; `anon` braucht sie nicht): - `public.strip_leading_article(t text) returns text`: entfernt **einmal ein führendes** Artikelwort (Deutsch `der`, `die`, `das`, `den`, `dem`, `des`, `ein`, `eine`, `einen`, `einem`, `einer`, `eines`; Englisch `the`, `a`, `an`) samt folgendem Leerzeichen; erwartet bereits normalisierten Text (Kleinbuchstaben, ein Leerzeichen als Trenner). - `public.normalize_game_title(t text) returns text` = `coalesce(nullif(public.strip_leading_article(public.search_normalize(t)), ''), nullif(public.search_normalize(t), ''), lower(btrim(t)))`. - Der letzte Rückfall greift bei Titeln, die nach `search_normalize` leer sind (z. B. nur nicht-lateinische Schriftzeichen oder nur Sonderzeichen). + Normalisiert den **gemeldeten** Titel. Der letzte Rückfall greift bei Titeln, die nach `search_normalize` leer sind (z. B. nur nicht-lateinische Schriftzeichen oder nur Sonderzeichen). + +**Kandidatenschlüssel** je Kandidatentitel `c` eines Katalogspiels `g` (verbindlich, Spec #3 3.2 Nr. 4): Haupttitel über `games.title_search`, alternative Titel über `game_alt_titles.title_search` (nicht neu aus dem Rohtitel berechnet): + +`key(c) = coalesce(nullif(public.strip_leading_article(c.title_search), ''), c.title_search, lower(btrim(c.title)))` + +Der letzte Rückfall `lower(btrim(c.title))` gilt nur beim Haupttitel (für alternative Titel gibt es keinen Rohtitel-Rückfall). Beispiele (pgTAP; das genaue Ergebnis von `search_normalize` legt #3 fest): „Die Siedler von Catan“ und „siedler von catan“ ergeben denselben Wert; „CATAN!“ = „Catan“; „The Crew“ = „Crew“; „Das“ bleibt nicht leer; „Mölkky“ = „Molkky“ (Faltung durch `unaccent`); ein Titel nur aus japanischen Schriftzeichen → `lower(btrim(t))`. -**Regel „sehr ähnlich“** zwischen Meldung (Titel `t`, Jahr `y`) und Katalogspiel `g` mit Haupttitel und allen alternativen Titeln aus `game_alt_titles` (Kandidatentitel `c`, **verpflichtend**): +**Regel „sehr ähnlich“** zwischen Meldung (Titel `t`, Jahr `y`) und Katalogspiel `g` mit Haupttitel und allen alternativen Titeln (Kandidatentitel `c`, **verpflichtend**): -- **A (gleicher Titel):** `normalize_game_title(c) = normalize_game_title(t)` für mindestens ein `c`, unabhängig vom Jahr. -- **B (Trigramm):** `extensions.similarity(normalize_game_title(c), normalize_game_title(t)) >= 0.8` für mindestens ein `c` **und** `abs(g.year - y) <= 1`. Fehlt `y` oder `g.year`, gilt B ohne Jahresbedingung (Spec-F2). +- **A (gleicher Titel):** `key(c) = normalize_game_title(t)` für mindestens ein `c`, unabhängig vom Jahr. +- **B (Trigramm):** `extensions.similarity(key(c), normalize_game_title(t)) >= 0.8` für mindestens ein `c` **und** `abs(g.year - y) <= 1`. Fehlt `y` oder `g.year`, gilt B ohne Jahresbedingung (Spec-F2). - Je Spiel zählt die beste Übereinstimmung über alle Kandidatentitel. - Nur sichtbare Katalogspiele (`catalog_games`): keine ausgeblendeten, zusammengeführten oder privaten (auch nicht eigene vorläufige, Spec-F7). - In Funktionen mit `search_path = ''` ausdrücklich `extensions.similarity(...) >= 0.8` verwenden, **nicht** den Operator `%` (nutzt `pg_trgm.similarity_threshold`, Standard 0,3). Deshalb kein Trigramm-Index in 13a; bei ~200–2000 Katalogspielen reicht ein sequenzieller Durchlauf. @@ -153,7 +157,7 @@ Beispiele (pgTAP; das genaue Ergebnis von `search_normalize` legt #3 fest): „D | Objekt | Art | `execute` | Zweck | |---|---|---|---| -| `strip_leading_article(text)`, `normalize_game_title(text)` | Funktion, sql, immutable | `anon`, `authenticated` | Normalisierung (3.3) | +| `strip_leading_article(text)`, `normalize_game_title(text)` | Funktion, sql, immutable | `authenticated` | Normalisierung (3.3) | | `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) | @@ -220,7 +224,7 @@ returns table ( ) ``` -`language sql`, `stable`, `security invoker`, `search_path = ''`. Liest `catalog_games`, `game_alt_titles`, `game_publishers`, `publishers`. Regel nach 3.3. Sortierung: `same_title` zuerst, dann `similarity` absteigend, dann `title`; höchstens **5** Treffer. Leerer Titel oder Titel > 200 Zeichen → leere Menge (kein Fehler; `report_missing_game` meldet die Länge selbst). Nur `authenticated`. +`language sql`, `stable`, `security invoker`, `search_path = ''`. Liest `catalog_games` und verbindet per Primärschlüssel mit `games` (für `title_search`), außerdem `game_alt_titles`, `game_publishers`, `publishers`. Regel und Kandidatenschlüssel nach 3.3. Sortierung: `same_title` zuerst, dann `similarity` absteigend, dann `title`; höchstens **5** Treffer. 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` @@ -243,7 +247,7 @@ adopt_report_target(p_report uuid) returns jsonb -- Zähler aus repoint_game_r | 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: `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 = 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`. Rückgabe: Zähler aus `repoint_game_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`. @@ -259,8 +263,8 @@ Kein RPC. Die App sendet `PATCH /rest/v1/game_reports?id=eq.` 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; Satz zur Zuständigkeit für `search_normalize` (3.3). -- `docs/katalog-pflege.md`, Abschnitt „Meldungen bearbeiten“ (2c) ergänzen: Nutzer-Meldungen haben einen vorläufigen Eintrag (sofern nicht gelöscht); „abgelehnt mit Verweis“ lässt dem Nutzer die Wahl (`adopt_report_target`); Status nie von Hand setzen, nur über `admin_resolve_report`, weil die App den Hinweis aus Status und `merged_into_id` ableitet. +- `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. - `supabase/README.md`: neue RPCs und Rechte. ## 5. Frontend @@ -298,7 +302,7 @@ Die Domäne importiert weder Flutter noch Supabase; Validierung in `GameReportDr | Route | Screen | Hinweis | |---|---|---| -| `/reports/new?title=` | `ReportFormScreen` | Einstieg aus der leeren Trefferliste von KAT-01 (#3 Krit. 6); Anmeldung nötig (Redirect `/login?from=…`, Spec #1) | +| `/reports/new?title=` | `ReportFormScreen` | Einstieg aus der leeren Trefferliste von KAT-01 (`onReportGame`, #3 PR 3b, Krit. 6); Anmeldung nötig (Redirect `/login?from=…`, Spec #1) | | `/reports` | `MyReportsScreen` | eigene Meldungen | | `/reports/:reportId` | `ReportDetailScreen` | Status, Angaben, Hinweis, Aktionen; fremde/unbekannte ID → „nicht gefunden“ | @@ -373,10 +377,10 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We | AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task | |---|---|---|---|---|---| | Krit. 1 | `GameReportDraft`: Titel Pflicht (auch nur Leerzeichen), Längen, Jahr, Link; Fehler-Mapping | Feldfehler, Bestätigung, Listenwahl | Playwright: leere Suche → „Spiel melden“ → Titel vorbelegt → Bestätigung | `report_missing_game` als `authenticated`: Meldung (`open`, `user`) und vorläufiges Spiel (`private`, nur Titel/Jahr); Trimmen; alle Validierungsfehler; Jahr 99999 → `report_year_invalid` (kein 22003); `anon` → 42501 | B1, B2, F2, T1, T2 | -| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` → zurück | Kandidatenliste, Checkbox aktiviert Button, Tastatur, Semantics | Playwright: Meldung „Catan“ zeigt „CATAN“ | `strip_leading_article`/`normalize_game_title`: Artikel de/en nur führend, Großbuchstaben, Sonderzeichen, Umlaute über `search_normalize`, nur Artikel, nicht-lateinischer Titel (Rückfall); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr; Trigramm ≥ 0,8 bei ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; fehlendes Jahr nach Spec-F2; **Treffer über alternativen Titel** (`matched_title`); ausgeblendete, zusammengeführte, private Spiele nicht; Titel > 200 → leere Menge; höchstens 5, Sortierung; `report_similar_exists` ohne, Speichern mit Bestätigung | B1, B2, F2, U1, T1, T2 | +| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` → zurück | Kandidatenliste, Checkbox aktiviert Button, Tastatur, Semantics | Playwright: Meldung „Catan“ zeigt „CATAN“ | `strip_leading_article`/`normalize_game_title`: Artikel de/en nur führend, Großbuchstaben, Sonderzeichen, Umlaute über `search_normalize`, nur Artikel, nicht-lateinischer Titel (Rückfall); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr; Trigramm ≥ 0,8 bei ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; fehlendes Jahr nach Spec-F2; **Treffer über `title_search` des alternativen Titels** (`matched_title`); Haupttitel über `games.title_search`; ausgeblendete, zusammengeführte, private Spiele nicht; Titel > 200 → leere Menge; höchstens 5, Sortierung; `report_similar_exists` ohne, Speichern mit Bestätigung | B1, B2, F2, U1, T1, T2 | | Krit. 3 | – | Bestätigung zeigt Liste; Badge „vorläufig“ | Playwright: gemeldetes Spiel in der Wunschliste (sofern #6/#8 da) | `p_list` legt `collection_items` an; B sieht nichts von A; `anon` nichts; Rollback bei Fehler im Listeneintrag | B1, B2, F2, F3 | | Krit. 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; 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`, `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. 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 | @@ -387,7 +391,7 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We **Nicht automatisiert:** echte Parallelität des Limits (Code-Review und dokumentierter manueller Test mit zwei `psql`-Sitzungen unter READ COMMITTED); Bearbeitung echter Meldungen auf Staging (Chris). -**Bestehende Tests:** `08_game_reports.test.sql` (2c) erwartet „kein Insert und kein Update für `authenticated`“; B2 passt beide Erwartungen an (Insert der erlaubten Spalten, Update nur `result_seen_at` an eigenen bearbeiteten Meldungen, alles andere weiter 42501). Tabellenzahl in `00_schema.test.sql` unverändert gegenüber `main`. +**Bestehende Tests:** Die Tests aus `08_game_reports.test.sql` (2c), die einen Insert oder ein Update als `authenticated` mit 42501 erwarten, bleiben nach 13a grün, aber aus anderem Grund: Statt des fehlenden Tabellenrechts greifen jetzt Spaltenrechte bzw. RLS. B2 passt nur die **Testbeschreibungen** an, nicht die Erwartungen. Tabellenzahl in `00_schema.test.sql` unverändert gegenüber `main`. ## 8. Aufgaben @@ -395,8 +399,8 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We | # | Agent | Aufgabe | abhängig von | |---|---|---|---| -| B1 | backend-dev | Migration nach 3.2–4.5: `pg_trgm`, `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`, `report_missing_game`, `adopt_report_target` (Sperrreihenfolge, `is distinct from`); Rechte nach 4.1 | #2 PR 2c und #3 gemergt | -| B2 | backend-dev | pgTAP `10_game_reports_user.test.sql` nach 7.1; `08` an Insert- und Update-Rechte anpassen | B1 | +| 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 | +| 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 | @@ -406,14 +410,15 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We |---|---|---|---| | U1 | ui-designer | Formular, Kandidatenschritt, Bestätigung, „Meine Meldungen“, Detail, `ReportOutcomeNotice`, Badge, Web-Layout; Texte de/en | Spec freigegeben | | F1 | flutter-dev | Domäne (5.1) inkl. `outcomeOf` mit allen Fällen aus 5.4 (auch „vorläufiger Eintrag fehlt“); Unit-Tests | Spec freigegeben (parallel zu B1) | -| F2 | flutter-dev | Data-Schicht (explizite Parameter, kein `toJson()` an Schreibzugriffe, Fehler-Mapper inkl. `report_provisional_in_use`) und Melden-Ablauf (5.3), Routen, Einstieg aus #3, l10n | F1, U1; gegen Backend: B1 | +| F2 | flutter-dev | Data-Schicht (explizite Parameter, kein `toJson()` an Schreibzugriffe, Fehler-Mapper inkl. `report_provisional_in_use`) und Melden-Ablauf (5.3), Routen, Einstieg über `onReportGame` aus #3 PR 3b, l10n | F1, U1; gegen Backend: B1 | | F3 | flutter-dev | „Meine Meldungen“, Detail, Hinweise (5.4), Einbau in #4/#6/#11 soweit gemergt | F1, F2 | | T2 | test-engineer | Widget-Tests und Playwright nach 7.1 | F2, F3, B1 | ### 8.3 Reihenfolge und Parallelität ``` -Vorbedingung: #2 PR 2b → 2c gemergt; #3 KAT-01 gemergt +Vorbedingung 13a: #2 PR 2b und 2c gemergt; #3 PR 3a gemergt (search_normalize, game_alt_titles) +Vorbedingung 13b (Merge): #3 PR 3b gemergt (onReportGame im Leerzustand, Krit. 6 von KAT-01) 13a: B1 → B2 → B3, T1 13b: U1 ─┐ F1 ─┴→ F2 (Mock-Repository, danach gegen B1) → F3 → T2 @@ -429,17 +434,18 @@ 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 | +| 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. | | 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 | | 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). | ### 9.2 Offene Fragen an Chris (Spec-F1 bis Spec-F9) -Keine Frage blockiert die Umsetzung: Ohne andere Entscheidung gilt mit der Freigabe die Empfehlung. +Ohne andere Entscheidung gilt mit der Freigabe die Empfehlung. **Ausnahme Spec-F6:** Sie blockiert nicht die Umsetzung von #13, muss aber **vor dem Merge von PR #57 (2c)** entschieden werden. Alle anderen Fragen blockieren nichts. | # | Frage | Empfehlung | Begründung | |---|---|---|---| @@ -448,7 +454,7 @@ Keine Frage blockiert die Umsetzung: Ohne andere Entscheidung gilt mit der Freig | Spec-F3 | **Krit. 2 „Artikel ignoriert“:** welche Artikel und wo? (Sonderzeichen, Groß-/Kleinschreibung und Akzente regelt `search_normalize` aus Spec #3.) | Nur **ein führender** Artikel auf Deutsch oder Englisch (Liste in 3.3). | Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“); andere Sprachen sind im Katalog selten. | | Spec-F4 | **Krit. 7 „heute“:** Kalendertag oder 24 Stunden? | **Kalendertag Europe/Berlin.** | Entspricht „heute“; verständlich („morgen geht es wieder“). | | Spec-F5 | **Krit. 4–6:** Wie lange bleibt der Hinweis sichtbar? | **Bis „Verstanden“**; „abgelehnt“ bleibt dauerhaft markiert. | Dauerhafte Hinweise stören, zeitgesteuertes Ausblenden wird übersehen. | -| Spec-F6 | **N15, Edition:** Woran erkennt die App die geleerte Edition? | **Neu bewertet: Zähler `game_reports.editions_cleared integer not null default 0`** (Nachtrag in Spec #2: 2b liefert in `repoint_game_references` die Zahl der geleerten **Verweise** in Sammlung und Partien, wie es `variants_cleared` bei `admin_merge_games` schon für Verweise tut; 2c legt die Spalte an und `admin_resolve_report` setzt sie; 13a setzt sie in `adopt_report_target`). Rückfall: Ableitung (5.4). | Genau, erfasst auch Partien, und **jetzt günstig**, weil 2b und 2c noch nicht gemergt sind; später wäre ein `create or replace` der Admin-Funktionen nötig. Die Ableitung kommt ohne Änderung an Spec #2 aus, ist aber ungenau (R5). | +| Spec-F6 | **N15, Edition:** Woran erkennt die App die geleerte Edition? **Entscheidung vor dem Merge von PR #57.** | **Zähler `game_reports.editions_cleared integer not null default 0 check (editions_cleared >= 0)`.** Nachtrag **nur** in Spec #2 § 3.4 / PR 2c: Spalte anlegen und in `admin_resolve_report` im bestehenden Update `editions_cleared = coalesce((result->>'variants_cleared')::int, 0)` setzen. 2b bleibt unverändert: `repoint_game_references` liefert `variants_cleared` bereits (geleerte Verweise in `collection_items` inkl. Kollisionsfall und in `plays`). 13a setzt den Wert in `adopt_report_target` genauso. Fällt die Entscheidung erst nach dem Merge von 2c, bleibt nur ein `create or replace` von `admin_resolve_report` in 13a. Rückfall: Ableitung (5.4). | Genau, erfasst Sammlung und Partien, und vor dem Merge von PR #57 nur eine Spalte und eine Zuweisung. Die Ableitung kommt ohne Änderung an 2c aus, ist aber ungenau (R5). | | 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. | @@ -456,13 +462,13 @@ Keine Frage blockiert die Umsetzung: Ohne andere Entscheidung gilt mit der Freig ### 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). -- Normalisierung gehört #3; 13a ergänzt nur den Artikel-Wrapper (E4). +- Normalisierung gehört #3; 13a ergänzt nur den Artikel-Wrapper und den Kandidatenschlüssel über `title_search` (E4). - Kein Realtime, keine Edge Function, keine neuen Pakete. ### 9.4 Hinweise für andere Stories -- **Spec #2 (2b/2c), falls Spec-F6 = Zähler:** Nachtrag vor dem Bau (Spalte `editions_cleared`, Zähler der geleerten Verweise, Setzen in `admin_resolve_report`). -- **KAT-01 (#3):** derselbe Satz zu Zuständigkeit und Rechten von `search_normalize` (3.3); Einstieg `/reports/new?title=` (Krit. 6); `game_alt_titles` wird von `find_similar_catalog_games` gelesen. +- **Spec #2 / PR 2c (PR #57), falls Spec-F6 = Zähler:** vor dem Merge Spalte `editions_cleared` anlegen und in `admin_resolve_report` setzen; 2b unverändert. +- **KAT-01 (#3):** Einstieg `/reports/new?title=` über `onReportGame` (PR 3b, Krit. 6); `find_similar_catalog_games` liest `games.title_search` und `game_alt_titles.title_search`. - **KAT-02 (#4), SAM-01 (#6), SAM-03 (#11):** `ReportOutcomeNotice` und `reportNoticeForGameProvider` einbauen; Badge „vorläufig“; eigene private Spiele direkt aus `games` lesen. - **KAT-03 (#12):** Aktion „Edition wählen“ anbinden. - **KTO-02 (#14):** `favorite_games_own` erlaubt derzeit jedes sichtbare Spiel, auch eigene vorläufige; Klärung 23 verlangt Katalogspiele. #14 schränkt die Policy (neue Migration) auf ein **sichtbares Katalogspiel** ein (Bedingung wie `catalog_games`: `visibility = 'catalog' and hidden_at is null and merged_into_id is null`). @@ -473,15 +479,16 @@ Keine Frage blockiert die Umsetzung: Ohne andere Entscheidung gilt mit der Freig ### 9.5 Ausstehende Prozessschritte - Freigabe der Spec und Antworten zu Spec-F1 bis Spec-F9 (Label `status/spec-review`). -- Bei Spec-F6 = Zähler: Nachtrag in Spec #2 vor dem Bau von 2b/2c. +- **Spec-F6 vor dem Merge von PR #57:** bei Zähler Nachtrag in Spec #2 § 3.4 und in PR 2c (PR #57, vor dem Merge). - Bei Spec-F8 = Ja: Forgejo-Abhängigkeiten #13 → #4, #6 setzen. -- Spec #3 erhält denselben Satz zu Zuständigkeit und Rechten von `search_normalize`. ## 10. Änderungsprotokoll ### 2026-11-03 – Entwurf -Erste Fassung durch architect, inkl. Hinweis aus PR #51 (Klärung N15). Nachtrag am selben Tag: Trigramm-Index gestrichen, Abweichung von Spec #2 § 3.4 vermerkt, `report_day` für testbare Tagesgrenze. +Erste Fassung durch architect. Berücksichtigt den Hinweis aus der KAT-05-Spec (Issue-Kommentar vom 2026-11-03, Klärung N15) zur eingeschränkten Erhaltung der Edition bei Krit. 4. + +Nachtrag am selben Tag: Normalisierung faltet Akzente per `translate()` (Spec-F3 neu begründet: ohne Faltung läge „Mölkky/Molkky“ bei etwa 0,4); Trigramm-Index gestrichen (wäre mit `similarity() >= 0.8` nicht nutzbar); Abweichung von Spec #2 § 3.4 (Update-Recht auf `result_seen_at`) ausdrücklich vermerkt und Testanpassung in B2 ergänzt; Hilfsfunktion `report_day` für testbare Tagesgrenze. ### 2026-11-03 – Review (code-reviewer) @@ -493,3 +500,12 @@ Erste Fassung durch architect, inkl. Hinweis aus PR #51 (Klärung N15). Nachtrag - Indizes aus 2c übernommen; Testdatei `10_…`; Tabellenzahl unverändert gegenüber `main`. - Edition: Ableitung nur `list = 'owned'`; Spec-F6 neu bewertet, Empfehlung Zähler `editions_cleared`. - `p_year integer`, Titel > 200 → leere Menge, Limit-Vorprüfung vor der Ähnlichkeit, Großbuchstaben-Testfall, Partien/Mitspieler, Hinweis #14 präzisiert. + +### 2026-11-03 – Konsistenz-Review mit Spec #3 + +- **B-1:** Kandidatenschlüssel `key(c)` über `games.title_search` und `game_alt_titles.title_search` (Spec #3 3.2 Nr. 4); Regeln A und B darauf; `find_similar_catalog_games` verbindet `catalog_games` per Primärschlüssel mit `games`; Testfall über `title_search` des alternativen Titels. +- **B-2:** Spec-F6 korrigiert: 2b braucht keinen Nachtrag (`variants_cleared` gibt es schon); Nachtrag nur in 2c (PR #57), Entscheidung vor dessen Merge; E7, 9.2, 9.4, 9.5 angepasst. +- **W-1:** `pg_trgm` und `unaccent` kommen aus 3a; Codeblock in 3.2 und `pg_trgm` in B1 gestrichen; E9 angepasst. +- **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`.