All checks were successful
Kandidatenschlüssel über title_search, gemeinsame Normalisierung wörtlich aus Spec #3, pg_trgm aus 3a, Abhängigkeiten 3a/3b, Spec-F6 korrigiert (nur 2c-Nachtrag, vor Merge von PR #57). Refs #13 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
511 lines
57 KiB
Markdown
511 lines
57 KiB
Markdown
# Spec #13 – KAT-04 Fehlendes Spiel melden
|
||
|
||
| | |
|
||
|---|---|
|
||
| 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.** |
|
||
| Autor | architect |
|
||
| Stand | 2026-11-03 |
|
||
| Etappe | R1.2 Sammlung |
|
||
| 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).
|
||
|
||
## 1. Ziel
|
||
|
||
Ein Nutzer, der ein Spiel im Katalog nicht findet, meldet es mit wenigen Angaben und kann es **sofort** als vorläufigen privaten Eintrag nutzen (Entscheidung O5). Der Admin (Chris) prüft jede Meldung manuell im Studio (Klärungen 27, 44; Werkzeug aus #2 PR 2c). Das Ergebnis sieht der Nutzer als Hinweis in der App, nie als E-Mail (Klärungen 26, 45).
|
||
|
||
Diese Spec baut auf `game_reports` und `admin_resolve_report` aus Spec #2 § 3.4 (PR 2c) und auf der Normalisierung aus Spec #3 auf. Sie ergänzt nur, was der **Nutzer** braucht: Anlegen mit Tageslimit, Ähnlichkeitsprüfung, vorläufigen Eintrag, Ergebnishinweise und das Wechseln auf den Zieleintrag.
|
||
|
||
**Nicht im Scope** (aus dem Issue): öffentliche Anzeige gemeldeter Spiele, Bild-Upload, E-Mail/Push, Admin-Oberfläche in der App, automatische Import-Meldungen (SAM-08 #25). Außerdem nicht: Bearbeiten weiterer Felder des vorläufigen Eintrags, Zurückziehen einer Meldung, Auswahl vorläufiger Spiele in Partien (PAR-01 #15 Krit. 3).
|
||
|
||
### Kernentscheidungen (Kurzfassung)
|
||
|
||
| # | Entscheidung | Begründung |
|
||
|---|---|---|
|
||
| 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 `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`, 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` 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
|
||
|
||
Übernommen aus Issue #13 / `20-r1-akzeptanzkriterien.md` § KAT-04 (Stand 2026-11-02 inkl. Klärungen und N12). Referenz in Tests und PRs: „KAT-04 Kriterium n“.
|
||
|
||
1. Gegeben ich finde ein Spiel nicht, wenn ich Titel (Pflicht), Verlag, Erscheinungsjahr und optional einen Link angebe, dann wird die Meldung gespeichert und ich erhalte eine Bestätigung.
|
||
2. Gegeben es gibt schon einen sehr ähnlichen Katalogeintrag, wenn ich melden will, dann sehe ich ihn zuerst und muss ausdrücklich bestätigen, dass mein Spiel ein anderes ist. „Sehr ähnlich“ heißt: gleicher normalisierter Titel (Groß-/Kleinschreibung, Sonderzeichen und Artikel ignoriert) oder Titel-Ähnlichkeit von mindestens 0,8 (Trigramm) bei Erscheinungsjahr ± 1 (Klärung 15).
|
||
3. Gegeben ich habe ein Spiel gemeldet, dann kann ich es als vorläufigen Eintrag in meine Sammlung oder Listen aufnehmen; der Eintrag ist nur für mich sichtbar.
|
||
4. Gegeben die Meldung wurde freigegeben, dann ersetzt der Katalogeintrag meinen vorläufigen Eintrag, ohne dass meine eigenen Angaben verloren gehen, und ich sehe das als Hinweis in der App am Eintrag (Klärung 26).
|
||
5. Gegeben die Meldung wurde mit einem anderen Eintrag zusammengeführt oder unter Verweis auf einen vorhandenen Eintrag abgelehnt, dann sehe ich das als Hinweis in der App am vorläufigen Eintrag (keine E-Mail), und mein vorläufiger Eintrag verweist auf den richtigen Eintrag (Klärungen 26, 45).
|
||
6. Gegeben die Meldung wurde ohne Zieleintrag abgelehnt, dann bleibt mein vorläufiger Eintrag privat bestehen und ist als „abgelehnt“ markiert (Klärung 26).
|
||
7. Gegeben ich habe heute schon 10 Meldungen abgeschickt, wenn ich eine weitere melden will, dann wird sie nicht gespeichert und ich sehe einen Hinweis auf das Tageslimit (Klärung 27). Meldungen, die der BGG-Import automatisch anlegt (SAM-08 Kriterium 4), zählen nicht auf dieses Limit (N12).
|
||
8. Gegeben ich habe eine Meldung abgeschickt, dann bleibt sie im Status „offen“, bis der Admin sie manuell bearbeitet; eine automatische Freigabe gibt es nicht (Klärungen 6, 27, 44).
|
||
|
||
**Abweichung bei Krit. 4 (Klärung N15, entschieden von Chris am 2026-11-03):** Beim Ersetzen bleibt die **Edition** nur erhalten, wenn der Katalogeintrag eine gleichnamige Variante hat; sonst wird sie an Sammlungseintrag und Partien geleert, und die App fordert auf, sie neu zu wählen (E7). Alle anderen eigenen Angaben (Liste, Notiz, Kaufdatum, Angaben aus SAM-02/SAM-04, Partien) bleiben erhalten (`repoint_game_references`, 2b).
|
||
|
||
**Lesart von Krit. 2 (Klärung 15):** „Erscheinungsjahr ± 1“ gehört nur zur Trigramm-Regel; gleicher normalisierter Titel gilt unabhängig vom Jahr. Fehlt ein Jahr, siehe Spec-F2.
|
||
|
||
**Lesart von Krit. 1:** Nur der Titel ist Pflicht; Verlag und Jahr sind optional (Spec-F1).
|
||
|
||
**Relevante Querschnittskriterien**
|
||
|
||
- **Sicherheit der Daten Krit. 1 und 3:** Meldungen und vorläufige Einträge sieht nur der Meldende (und der Admin im Studio). pgTAP prüft fremden Zugriff auf `game_reports`, vorläufige Spiele und die neuen RPCs.
|
||
- **Sicherheit der Daten Krit. 2:** `anon` kann nichts melden und keine Meldungen lesen.
|
||
- **Datenschutz (QUE-04):** Meldungen und vorläufige Einträge werden mit dem Konto hart gelöscht (Klärung 10) und gehören in den Export (KTO-05 #23, Klärung 20). Freigegebene Katalogeinträge übernehmen nur Fakten aus den Meldungsfeldern (Runbook aus 2c). Der Link wird in der App nicht geöffnet und nicht vorgeladen.
|
||
- **Sprachen (QUE-05) Krit. 2, 4:** alle Texte aus ARB (de/en).
|
||
- **Barrierefreiheit (QUE-06) Krit. 1–4:** Feldbeschriftungen, Status mit Text (nicht nur Farbe), Touch-Ziele ≥ 48 dp, `textScaler` 2.0, Tastaturbedienung im Web inkl. Bestätigungsschritt.
|
||
- **Zustände, Fehler und Netz Krit. 1–3:** „Meine Meldungen“ mit Lade-, Leer-, Fehler- und Datenzustand; offline Hinweis statt Fehler; bei Limit- oder Netzfehler bleiben die Eingaben erhalten.
|
||
- **Plattformen Krit. 1, 2:** identisch auf iOS, Android und Web; breites Layout im Web.
|
||
|
||
## 3. Datenmodell
|
||
|
||
### 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). 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.
|
||
|
||
### 3.2 Änderungen in 13a (Migration `supabase/migrations/<ts>_game_reports_user.sql`)
|
||
|
||
**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`**
|
||
|
||
| Spalte | Typ | Hinweis |
|
||
|---|---|---|
|
||
| `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) 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)
|
||
|
||
```
|
||
grant insert (owner_id, title, publisher_name, year, link, provisional_game_id)
|
||
on public.game_reports to authenticated;
|
||
grant update (result_seen_at) on public.game_reports to authenticated;
|
||
```
|
||
|
||
**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.
|
||
|
||
**Merkpunkt für den Client (aus #2 PR 2a):** Clients schreiben `games` und `game_reports` nie mit `id`, `created_at` oder `updated_at`, und ein `toJson()` eines Modells geht nie ungefiltert an `insert`/`update`. Die App schreibt in #13 nur über die RPCs (4.2, 4.4) und das Update von `result_seen_at` (4.5) mit ausdrücklicher Spaltenliste.
|
||
|
||
**RLS in Klartext (neu in 13a)**
|
||
|
||
- **Anlegen (`game_reports_insert_own`, `for insert to authenticated`):** `owner_id = auth.uid()`, `origin = 'user'`, `status = 'open'`, `target_game_id is null` und **`provisional_game_id is not null`**, und dieser zeigt auf ein eigenes privates, nicht ausgeblendetes und nicht zusammengeführtes Spiel (`exists (select 1 from games g where g.id = provisional_game_id and g.visibility = 'private' and g.owner_id = auth.uid() and g.hidden_at is null and g.merged_into_id is null)`). Ein zweiter Insert mit demselben vorläufigen Eintrag scheitert am Teilindex aus 2c (23505); `report_missing_game` bildet das auf `report_provisional_in_use` ab, die App auf „unbekannter Fehler“.
|
||
- **Ändern (`game_reports_update_seen`, `for update to authenticated`):** `using (owner_id = auth.uid() and status <> 'open')`, `with check` gleich. Über die Spaltenrechte ist nur `result_seen_at` änderbar.
|
||
- **Lesen:** unverändert aus 2c (nur eigene). **Löschen:** kein Recht.
|
||
- **Vorläufige Spiele:** unverändert; der Ersteller sieht sie auch ausgeblendet oder zusammengeführt (wichtig für die Ableitung in E7). Er kann sie über `games_delete_private` löschen, solange nichts darauf verweist; dann wird `provisional_game_id` per `on delete set null` leer (E6, 5.4).
|
||
|
||
**Trigger `game_reports_daily_limit`** (Krit. 7)
|
||
|
||
- `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. 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).
|
||
|
||
**Keine neue Tabelle, kein neuer Fremdschlüssel auf `games`.** Die Tabellenzahl in `00_schema.test.sql` bleibt gegenüber `main` unverändert; der Fremdschlüssel-Wächter aus #2 bleibt durch 13a unberührt.
|
||
|
||
### 3.3 Normalisierung und „sehr ähnlich“ (Krit. 2, Klärung 15)
|
||
|
||
> **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.
|
||
|
||
`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)))`.
|
||
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 (Kandidatentitel `c`, **verpflichtend**):
|
||
|
||
- **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.
|
||
|
||
## 4. Backend
|
||
|
||
### 4.1 Überblick und Rechte
|
||
|
||
| Objekt | Art | `execute` | Zweck |
|
||
|---|---|---|---|
|
||
| `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) |
|
||
| `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 |
|
||
|
||
Jede Funktion: zuerst `revoke execute … from public, anon, authenticated, service_role`, dann nur die Grants dieser Tabelle. pgTAP prüft mit `has_function_privilege` für **alle vier Rollen** (`public` über eine Rolle ohne eigene Rechte, `anon`, `authenticated`, `service_role`).
|
||
|
||
Fehler: `raise exception '<code>' using errcode = 'P0001'`; die App bildet den Code auf einen Domänenfehler ab (5.3).
|
||
|
||
### 4.2 RPC `public.report_missing_game`
|
||
|
||
```
|
||
report_missing_game(
|
||
p_title text,
|
||
p_publisher_name text default null,
|
||
p_year integer default null,
|
||
p_link text default null,
|
||
p_list text default null, -- 'owned' | 'wishlist' | 'shopping' | null
|
||
p_confirmed_distinct boolean default false
|
||
) returns jsonb -- {"report_id": uuid, "provisional_game_id": uuid, "collection_item_id": uuid | null}
|
||
```
|
||
|
||
`language plpgsql`, `security invoker`, `search_path = ''`. `p_year` ist bewusst `integer` und wird selbst geprüft (ein `smallint`-Parameter würde bei großen Werten schon beim Aufruf mit 22003 scheitern, ohne verständlichen Code).
|
||
|
||
Ablauf (eine Transaktion; jeder Fehler rollt alles zurück):
|
||
|
||
1. `auth.uid()` ist null → `not_authenticated`.
|
||
2. `btrim` auf Titel, Verlag, Link; leere Zeichenketten werden `null`.
|
||
3. Prüfungen in dieser Reihenfolge:
|
||
|
||
| Prüfung | Fehler |
|
||
|---|---|
|
||
| Titel leer | `report_title_required` |
|
||
| Titel > 200 Zeichen | `report_title_too_long` |
|
||
| Verlag > 200 Zeichen | `report_publisher_too_long` |
|
||
| Jahr außerhalb 1800–2100 | `report_year_invalid` |
|
||
| Link > 500 Zeichen oder nicht `^https?://` | `report_link_invalid` |
|
||
| `p_list` nicht in `owned`, `wishlist`, `shopping`, null | `report_list_invalid` |
|
||
| **Vorprüfung Tageslimit:** schon 10 eigene Nutzer-Meldungen heute (`report_day`) | `report_daily_limit` |
|
||
| `p_confirmed_distinct = false` und `find_similar_catalog_games` liefert ≥ 1 Treffer | `report_similar_exists` |
|
||
|
||
Die Limit-Vorprüfung steht **vor** der Ähnlichkeitsprüfung, damit der Nutzer nicht erst Kandidaten bestätigt und dann am Limit scheitert. Maßgeblich bleibt der Trigger (Sperre, Schritt 5). Die übrigen Prüfungen spiegeln die Checks aus 2c für gezielte Feldfehler; Tabellen-Checks bleiben die Grenze (23514 → „unbekannter Fehler“).
|
||
4. `insert into public.games (owner_id, title, year, visibility) values (auth.uid(), title, year, 'private') returning id` (keine `id`).
|
||
5. `insert into public.game_reports (owner_id, title, publisher_name, year, link, provisional_game_id) … returning id`. Hier greift der Limit-Trigger; 23505 aus dem Teilindex → `report_provisional_in_use`.
|
||
6. Wenn `p_list` gesetzt: `insert into public.collection_items (owner_id, game_id, list) values (auth.uid(), provisional_id, p_list) returning id`. Keine Edition.
|
||
7. Rückgabe als `jsonb`.
|
||
|
||
Aufrufreihenfolge in der App: erst `find_similar_catalog_games`, dann `report_missing_game` mit `p_confirmed_distinct = true` nach Bestätigung. Ein Client, der direkt `true` sendet, umgeht nur den UX-Schritt (E3).
|
||
|
||
### 4.3 RPC `public.find_similar_catalog_games`
|
||
|
||
```
|
||
find_similar_catalog_games(p_title text, p_year integer default null)
|
||
returns table (
|
||
game_id uuid,
|
||
title text, -- Haupttitel
|
||
matched_title text, -- Titel, der getroffen hat (Haupt- oder alternativer Titel)
|
||
year smallint,
|
||
publishers text[], -- sortiert
|
||
match_kind text, -- 'same_title' | 'similar_title'
|
||
similarity real -- 1.0 bei 'same_title'
|
||
)
|
||
```
|
||
|
||
`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`
|
||
|
||
```
|
||
adopt_report_target(p_report uuid) returns jsonb -- Zähler aus repoint_game_references
|
||
```
|
||
|
||
`language plpgsql`, `security definer`, Eigentümer `postgres`, `set search_path = ''` (als Funktionseinstellung). Nötig, weil Nutzer `merged_into_id`/`hidden_at` nicht schreiben und `repoint_game_references` nicht ausführen dürfen. **Innerhalb der Funktion ist `current_user = postgres`;** deshalb darf sie keine `admin_*`-Funktion aufrufen (deren Rollenprüfung würde sonst bestehen), weder jetzt noch in späteren Änderungen. pgTAP prüft `prosecdef = true`, `proowner = postgres` und `proconfig` mit `search_path=""`.
|
||
|
||
**Sperrreihenfolge** (wie im 2c-Entwurf von `admin_resolve_report`, gegen Verklemmungen): zuerst die Meldung `select … for update`, danach beide Spiele `select … from games where id in (provisional, target) order by id for update`.
|
||
|
||
| Prüfung | Fehler |
|
||
|---|---|
|
||
| `auth.uid()` ist null | `not_authenticated` |
|
||
| Meldung existiert nicht oder gehört nicht `auth.uid()` (gleicher Fehler) | `report_not_found` |
|
||
| `status <> 'rejected'` oder `target_game_id is null` | `report_not_adoptable` |
|
||
| `provisional_game_id is null` (z. B. vom Nutzer gelöscht) | `report_no_provisional` |
|
||
| vorläufiger Eintrag ist nicht `private` mit `owner_id = auth.uid()` | `report_not_found` |
|
||
| vorläufiger Eintrag ist schon zusammengeführt | `report_already_adopted` |
|
||
| Ziel ist kein sichtbares Katalogspiel (`visibility = 'catalog' and hidden_at is null and merged_into_id is null`) | `report_target_not_listed` |
|
||
| Verweise auf den vorläufigen Eintrag mit fremdem oder fehlendem Eigentümer: `collection_items.owner_id`, `favorite_games.profile_id`, `plays.owner_id`, `score_sheets.owner_id` jeweils `is distinct from auth.uid()` (null zählt als fremd) | `report_foreign_references` |
|
||
|
||
Ablauf: `result := repoint_game_references(provisional, target)` (privat → Katalog, keine Varianten kopiert, gleichnamige abgebildet, sonst `null`; Spec #2 RB1/N15), danach am vorläufigen Eintrag `merged_into_id = target`, `hidden_at = coalesce(hidden_at, now())`. Die Meldung bleibt `rejected`. **`result_seen_at` bleibt unverändert (null)**, damit der Hinweis „Dein Eintrag zeigt jetzt auf …“ (inkl. Edition-Hinweis) am Zieleintrag einmal erscheint, bis der Nutzer „Verstanden“ wählt (5.4). Bei Spec-F6 = Zähler setzt die Funktion zusätzlich `editions_cleared = coalesce((result->>'variants_cleared')::int, 0)`, genau wie `admin_resolve_report`. Rückgabe: `result`.
|
||
|
||
**Wirkung auf Partien:** `plays.game_id` zeigt danach auf das Katalogspiel; Mitspieler (`play_players` mit `profile_id`) sehen in der Partie dann das Katalogspiel statt eines für sie unsichtbaren privaten Spiels. Das ist gewollt und gilt ebenso für `admin_resolve_report`.
|
||
|
||
### 4.5 Ergebnishinweis wegklicken
|
||
|
||
Kein RPC. Die App sendet `PATCH /rest/v1/game_reports?id=eq.<id>` mit genau `{'result_seen_at': <aktuelle Zeit, ISO-8601 UTC>}` (PostgREST setzt Werte, keine Ausdrücke wie `now()`). Die Client-Zeit ist unkritisch (steuert nur die eigene Anzeige). Bei offener oder fremder Meldung ändert sich keine Zeile.
|
||
|
||
### 4.6 Lesen für die App
|
||
|
||
- **Meine Meldungen:** `game_reports` mit `id, origin, title, publisher_name, year, link, status, target_game_id, provisional_game_id, result_seen_at, created_at, resolved_at` (bei Spec-F6 = Zähler zusätzlich `editions_cleared`), `created_at desc`, je 20. Eingebettet: `provisional:games!provisional_game_id(id, merged_into_id, hidden_at, game_variants(id))`, `target:games!target_game_id(id, title, year)`. Fremdschlüsselnamen prüft flutter-dev gegen die 2c-Migration.
|
||
- **Hinweis zu einem Spiel** (5.4): `game_reports` mit `or=(provisional_game_id.eq.<id>,target_game_id.eq.<id>)` (Indizes aus 2c) plus eingebettetem `provisional`.
|
||
- **Edition, nur bei Rückfall Ableitung:** `collection_items` des Nutzers zum Zielspiel mit `list = 'owned'` (`variant_id`) und `provisional.game_variants`.
|
||
|
||
### 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.
|
||
- `supabase/README.md`: neue RPCs und Rechte.
|
||
|
||
## 5. Frontend
|
||
|
||
### 5.1 Struktur
|
||
|
||
Neues Feature `app/lib/features/game_reports/` (Feature-first, Abhängigkeiten nach innen):
|
||
|
||
```
|
||
features/game_reports/
|
||
domain/
|
||
game_report.dart freezed: GameReport
|
||
game_report_status.dart enum GameReportStatus { open, approved, rejected, merged }
|
||
game_report_origin.dart enum GameReportOrigin { user, import }
|
||
report_outcome.dart sealed ReportOutcome (5.4) + reine Funktion outcomeOf(...)
|
||
game_report_draft.dart freezed: Eingaben + Validierung
|
||
similar_game.dart freezed: SimilarGame (gameId, title, matchedTitle, year, publishers, matchKind)
|
||
game_report_failure.dart sealed: dailyLimit, similarExists, validation(field, code), notAdoptable,
|
||
noProvisional, alreadyAdopted, targetNotListed, offline, unknown
|
||
game_report_repository.dart abstrakte Schnittstelle
|
||
data/
|
||
supabase_game_report_repository.dart RPC-Aufrufe und Abfragen (4.2–4.6), explizite Parameter-Maps
|
||
game_report_dto.dart nur fromJson; kein toJson für Schreibzugriffe
|
||
game_report_failure_mapper.dart P0001-Codes → GameReportFailure
|
||
presentation/
|
||
report_form/ similar_games/ my_reports/ report_detail/
|
||
widgets/report_outcome_notice.dart wiederverwendbar (auch für #4/#6/#11)
|
||
providers/ Repository-, Listen- und Hinweis-Provider (Riverpod, generiert)
|
||
game_report_routes.dart
|
||
```
|
||
|
||
Die Domäne importiert weder Flutter noch Supabase; Validierung in `GameReportDraft`, nicht im Widget.
|
||
|
||
### 5.2 Routen (go_router)
|
||
|
||
| Route | Screen | Hinweis |
|
||
|---|---|---|
|
||
| `/reports/new?title=<suchbegriff>` | `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“ |
|
||
|
||
Konstanten in `AppRoutes`. Weitere Einstiege zu `/reports` (Einstellungen, Dashboard) stimmt ui-designer mit Chris ab.
|
||
|
||
### 5.3 Melden (Krit. 1, 2, 3, 7, 8)
|
||
|
||
`ReportFormController` (Riverpod-Notifier, Zustände als freezed-Union):
|
||
|
||
| Zustand | UI |
|
||
|---|---|
|
||
| `editing(draft, fieldErrors)` | Titel (Pflicht), Verlag, Jahr, Link (optional); „Gleich aufnehmen in“: Sammlung / Wunschliste / Einkaufsliste / nicht aufnehmen (Standard Sammlung, Spec-F9) |
|
||
| `checking` | `find_similar_catalog_games` läuft; Ladeanzeige, Felder gesperrt |
|
||
| `similarFound(draft, candidates)` | Kandidaten (Titel, getroffener Titel falls alternativ, Jahr, Verlage, `GameCover`). „Das ist mein Spiel“ → Detailseite des Katalogspiels (#4), keine Meldung. Ausdrückliche Bestätigung: Checkbox „Mein Spiel ist keines davon“ + „Trotzdem melden“ (erst mit Haken aktiv) |
|
||
| `submitting` | `report_missing_game` läuft |
|
||
| `submitted(result)` | Bestätigung (Krit. 1): „Danke, deine Meldung ist eingegangen. Wir prüfen sie von Hand; bis dahin kannst du das Spiel als vorläufigen Eintrag nutzen.“ ggf. Liste; Aktionen „Zur Meldung“, „Zur Sammlung“ |
|
||
| `failure(draft, failure)` | Eingaben bleiben erhalten. `dailyLimit` → „Du hast heute schon 10 Spiele gemeldet. Morgen geht es wieder.“ (Krit. 7); `similarExists` → zurück zu `similarFound`; Feldfehler am Feld; offline → „Melden braucht eine Verbindung“ |
|
||
|
||
Ohne Treffer geht `checking` direkt in `submitting` (`p_confirmed_distinct = false`), mit Treffern nach Bestätigung mit `true`. Lokale Vorprüfung derselben Feldregeln wie 4.2; das Limit prüft nur der Server (die Vorprüfung in der RPC meldet es vor dem Kandidatenschritt, wenn der Nutzer ohne Treffer sendet; bei Treffern spätestens beim Absenden).
|
||
|
||
**Krit. 3 nachträglich:** Bei „nicht aufnehmen“ kann der Nutzer den vorläufigen Eintrag später auf der Meldungsdetailseite zu einer Liste hinzufügen, über die Funktion aus SAM-01/SAM-04/SAM-05 (#6/#8/#9) bzw. einen Insert in `collection_items` mit Spaltenliste `owner_id`, `game_id`, `list` (Spec-F8).
|
||
|
||
### 5.4 Ergebnishinweise (Krit. 4, 5, 6, 8)
|
||
|
||
`outcomeOf(report, provisional?, ownedItemOnTarget?)` ist eine reine Funktion (Unit-Tests je Fall). **Fehlt der vorläufige Eintrag** (`provisional_game_id` null, vom Nutzer gelöscht), folgt das Ergebnis nur aus `status` und `target_game_id`; Aktion „wechseln“ und Edition-Ableitung entfallen.
|
||
|
||
| Fall | Erkennung | Hinweis (Kurzform, ARB) | Aktionen | Wo sichtbar |
|
||
|---|---|---|---|---|
|
||
| `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.“
|
||
|
||
- 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).
|
||
- Aktion „Edition wählen“ → Bearbeitung des Sammlungseintrags (KAT-03 #12 / SAM-02 #7), sobald vorhanden.
|
||
|
||
**„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.
|
||
|
||
**Aktualität:** kein Realtime; Neuladen beim Öffnen und per Pull-to-Refresh.
|
||
|
||
### 5.5 Meine Meldungen
|
||
|
||
`MyReportsController` (AsyncNotifier mit Paging, 20 je Seite): Laden, Leer („Du hast noch keine Spiele gemeldet“ + Weg zur Suche), Fehler mit „Erneut versuchen“, Daten. Je Zeile Titel, Jahr, Datum, Status-Badge, ggf. kompakter Hinweis; Import-Meldungen mit Kennzeichen „aus BGG-Import“ (Text liefert #25).
|
||
|
||
### 5.6 l10n (Auszug, de/en)
|
||
|
||
Präfix `gameReport…`: Felder und Fehler (`gameReportTitleRequired`, `gameReportTitleTooLong`, `gameReportPublisherTooLong`, `gameReportYearInvalid`, `gameReportLinkInvalid`), Ähnlichkeit (`gameReportSimilarHeading`, `gameReportSimilarIsMine`, `gameReportSimilarConfirmDistinct`, `gameReportSubmitAnyway`), Listenwahl, Bestätigung, `gameReportDailyLimit`, Offline, Status-Badges, `gameReportProvisionalBadge`, Hinweise aus 5.4 inkl. Edition, Aktionen, Leer-/Fehlerzustand, Semantics-Labels; Platzhalter `{title}`, Datum über `intl`.
|
||
|
||
## 6. Plattform-Besonderheiten
|
||
|
||
- **iOS, Android, Web:** gleiches Verhalten; Link-Feld `TextInputType.url`, Jahr mit Ziffern-Tastatur; der Link wird nur als Text angezeigt, nicht geöffnet.
|
||
- **Web:** teilbare URLs; Bestätigungsschritt per Tastatur bedienbar; breites Layout (Formular und Kandidaten nebeneinander ab ≥ 840 dp, ui-designer).
|
||
- **Offline:** Melden braucht eine Verbindung (wie SAM-01 Krit. 3); Hinweis statt Fehler, Eingaben bleiben. Keine Offline-Warteschlange.
|
||
- **Push, Kamera, Dateien:** nicht betroffen.
|
||
|
||
## 7. Teststrategie
|
||
|
||
Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (Web-E2E). Testnamen enthalten „KAT-04 Kriterium n“.
|
||
|
||
**Neue pgTAP-Datei `supabase/tests/database/10_game_reports_user.test.sql`** (`08` gehört 2c, `09` gehört #3). Fixtures mit festen Test-UUIDs, Nutzer A und B, unabhängig vom Startbestand. Einfügungen „als Nutzer“ laufen wirklich als Rolle `authenticated` mit gesetztem `request.jwt.claims`, nicht als `postgres`.
|
||
|
||
### 7.1 Abdeckungsmatrix
|
||
|
||
| 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 `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. 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 |
|
||
| Sicherheit 1, 3 | – | – | – | B liest A's Meldungen und Spiele nicht, kann sie nicht wegklicken (0 Zeilen) und nicht übernehmen; Insert mit `owner_id = A` als B, ohne `provisional_game_id`, mit fremdem/Katalog-/ausgeblendetem Spiel → Policy-Fehler; zweite Meldung zum selben vorläufigen Eintrag → 23505; Spaltenrechte per `has_column_privilege` | B2 |
|
||
| Sicherheit 2 | – | – | – | `anon`: kein `select`, `insert`, `update`, keine RPC | B2 |
|
||
| Datenschutz | – | – | – | Konto A löschen → Meldungen, vorläufige Spiele, Listeneinträge weg; B unverändert | B2 |
|
||
| QUE-05/06, Zustände | – | de/en, `meetsGuideline`, `textScaler` 2.0, Lade/Leer/Fehler/Daten, Offline | Playwright: Tastatur im Bestätigungsschritt | – | F2, F3, T2 |
|
||
|
||
**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:** 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
|
||
|
||
### 8.1 PR 13a – Backend (`Refs #13`), Branch `feature/13a-meldungen-backend`
|
||
|
||
| # | 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 |
|
||
| 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 |
|
||
|
||
### 8.2 PR 13b – Flutter (`Closes #13`), Branch `feature/13b-meldungen-app`
|
||
|
||
| # | Agent | Aufgabe | abhängig von |
|
||
|---|---|---|---|
|
||
| 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 ü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 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
|
||
13b wird nach 13a gemergt.
|
||
```
|
||
|
||
F1 und U1 starten nach Freigabe der Spec, parallel zu B1. Merge nur durch Chris.
|
||
|
||
## 9. Risiken & offene Fragen
|
||
|
||
### 9.1 Risiken
|
||
|
||
| # | 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. |
|
||
| 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)
|
||
|
||
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 |
|
||
|---|---|---|---|
|
||
| Spec-F1 | **Krit. 1:** Sind Verlag und Jahr Pflicht? | **Optional**, nur der Titel ist Pflicht. | Nutzer kennen das Jahr oft nicht; 2c hat beide Spalten nullable. |
|
||
| Spec-F2 | **Krit. 2:** Trigramm-Regel bei fehlendem Jahr? | ≥ 0,8 **ohne Jahresbedingung**. | Lieber einmal mehr nachfragen; der Schritt verlangt nur eine Bestätigung. |
|
||
| 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? **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. |
|
||
|
||
### 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 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 / 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=<suchbegriff>` ü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`).
|
||
- **PAR-01 (#15):** vorläufige Spiele wählbar (Klärung 36), mit Badge; nach Freigabe/Wechsel zeigen Partien das Katalogspiel, auch für Mitspieler.
|
||
- **KTO-05 (#23):** Export enthält `game_reports` und vorläufige Spiele.
|
||
- **SAM-08 (#25):** Import-Meldungen mit `origin = 'import'`, `bgg_id` und vorläufigem Eintrag; Limit-Trigger greift nicht (N12); Hinweise aus 5.4 gelten.
|
||
|
||
### 9.5 Ausstehende Prozessschritte
|
||
|
||
- Freigabe der Spec und Antworten zu Spec-F1 bis Spec-F9 (Label `status/spec-review`).
|
||
- **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.
|
||
|
||
## 10. Änderungsprotokoll
|
||
|
||
### 2026-11-03 – Entwurf
|
||
|
||
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)
|
||
|
||
- **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).
|
||
- 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`.
|
||
- 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`.
|