docs(catalog): Spec KAT-04 fehlendes Spiel melden

Refs #13

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 19:48:13 +00:00
parent 8c003ddece
commit ab438aca38

View file

@ -0,0 +1,497 @@
# Spec #13 – KAT-04 Fehlendes Spiel melden
| | |
|---|---|
| Issue | #13 (KAT-04) |
| Status | Entwurf, wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2) |
| Autor | architect |
| Stand | 2026-11-03 |
| Etappe | R1.2 Sammlung |
| Abhängigkeiten | **#2 PR 2b und 2c müssen gemergt sein**, bevor 13a beginnt (Spec #2 E12): `game_reports`, `admin_resolve_report`, `repoint_game_references`, Trigger `games_visibility_immutable`. #1 (Anmeldung), #3 KAT-01 (Einstieg aus der leeren Suche, Krit. 6). 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), `docs/datenmodell-r1.md`, `supabase/migrations/20261102100200_catalog.sql`, `…100300_collection.sql`, `…100400_plays.sql`, `…20261103142204_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 der Meldungstabelle `game_reports` und der Funktion `admin_resolve_report` aus Spec #2 § 3.4 (PR 2c) 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 in dieser Spec: Bearbeiten weiterer Felder des vorläufigen Eintrags (Spielerzahl, Dauer usw.), Zurückziehen einer Meldung, Auswahl vorläufiger Spiele in Partien (gehört zu PAR-01 #15 Krit. 3).
### Kernentscheidungen (Kurzfassung)
| # | Entscheidung | Begründung |
|---|---|---|
| E1 | **Jede Nutzer-Meldung legt genau einen vorläufigen privaten Eintrag an** (1:1, eindeutiger Index auf `game_reports.provisional_game_id`). Er bekommt nur `title` und `year` aus der Meldung. | Der Hinweis zum Ergebnis braucht einen festen Ort, und Krit. 3 verlangt die sofortige Nutzung. Das entspricht dem Backlog („eine Meldung je vorläufigem Eintrag“, N12) und dem Import (SAM-08 Krit. 4). Mit nur Titel und Jahr gibt es außer der Edition nichts, was beim Ersetzen verloren gehen kann (Krit. 4, N15). |
| E2 | **Anlegen über eine RPC `report_missing_game` (security invoker)**, die in einer Transaktion Ähnlichkeit prüft, den vorläufigen Eintrag, die Meldung und optional den Listeneintrag anlegt. | Getrennte Client-Aufrufe ließen verwaiste vorläufige Spiele zurück, wenn das Tageslimit die Meldung ablehnt. `security invoker` heißt: RLS, Spaltenrechte und Trigger gelten weiter, die RPC ist nur Bündelung, keine Rechte-Erweiterung. |
| E3 | **Schutz liegt in der Datenbank, nicht in der RPC:** Spaltenrechte auf `game_reports` (kein `id`, `created_at`, `origin`, `status`, `target_game_id`, `resolved_at`, `bgg_id`), Insert-Policy und ein Limit-Trigger mit Sperre je Nutzer. | Ein direkter REST-Insert umgeht nur den UX-Schritt der Ähnlichkeitsprüfung, nicht Limit oder Rechte. Weil `created_at` nicht schreibbar ist, lässt sich das Tageslimit nicht durch ein falsches Datum umgehen. |
| E4 | **Ähnlichkeitsprüfung serverseitig** über `find_similar_catalog_games` mit `pg_trgm` und einer unveränderlichen (`immutable`) Normalisierungsfunktion `normalize_game_title`. Nur sichtbare Katalogspiele (`catalog_games`). | Klärung 15 ist eine Datenbankregel (Trigramm). Dieselbe Regel prüft die RPC beim Absenden noch einmal; ohne ausdrückliche Bestätigung (`p_confirmed_distinct`) wird nicht gespeichert (Krit. 2). |
| E5 | **„Abgelehnt mit Verweis“: Der Nutzer entscheidet selbst**, ob er auf den Zieleintrag wechselt (Spec #2 Spec-F3, entschieden). Dafür gibt es die RPC `adopt_report_target` (security definer) mit strenger Prüfung von Eigentümer und Status; sie nutzt `repoint_game_references` aus 2b. | Krit. 5 verlangt, dass der vorläufige Eintrag „auf den richtigen Eintrag verweist“. Der Verweis steht in `target_game_id`; das Umhängen der eigenen Daten bleibt eine Entscheidung des Nutzers. Das ist der **erste Weg, auf dem ein Nutzer `repoint_game_references` indirekt auslöst**; die Prüfungen sind deshalb ausführlich (4.4). |
| E6 | **Ergebnishinweise werden aus vorhandenen Daten abgeleitet** (`status`, `target_game_id`, `merged_into_id` des vorläufigen Eintrags) und lassen sich wegklicken (`game_reports.result_seen_at`, Spec-F5). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft. | Kein Benachrichtigungssystem in R1 (QUE-01 erst R2). Eine Spalte reicht. |
| E7 | **Edition (N15) ohne Änderung an 2c:** Die App zeigt „Edition neu wählen“, wenn der vorläufige Eintrag mindestens eine eigene Variante hatte und der Eintrag des Nutzers am Zielspiel keine Edition mehr hat (Spec-F6). | Die privaten Varianten bleiben am ausgeblendeten vorläufigen Eintrag erhalten und sind für den Eigentümer weiter lesbar (Spec #2 § 3.3). Alternative mit einer Spalte `edition_cleared` steht in Spec-F6. |
| E8 | **Verlag bleibt Freitext** in `game_reports.publisher_name`; der vorläufige Eintrag bekommt keinen Verlag. | `publishers` ist nur für den Admin schreibbar (Spec #2 3.2). Der Admin legt den Verlag beim Freigeben selbst an (Runbook). |
| E9 | **Keine neuen Pakete, keine Edge Function.** `pg_trgm` ist Teil des Supabase-Postgres-Images und self-hosted verfügbar (ADR-0002). | CLAUDE.md, ADR-0002. |
| E10 | **Zwei PRs:** 13a Backend (`Refs #13`), 13b Flutter (`Closes #13`). 13b kann gegen ein gemocktes Repository parallel beginnen, 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; das leistet `repoint_game_references` aus 2b.
**Lesart von Krit. 2 (Klärung 15):** Die Bedingung „Erscheinungsjahr ± 1“ gehört nur zur Trigramm-Regel. Gleicher normalisierter Titel gilt unabhängig vom Jahr als „sehr ähnlich“. Fehlt das Jahr auf einer Seite, 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`, auf vorläufige Spiele und auf 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 (`on delete cascade`, 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 (keine Verbindung zu Fremdseiten).
- **Sprachen (QUE-05) Krit. 2, 4:** alle Texte, Fehler- und Statushinweise aus ARB (de/en).
- **Barrierefreiheit (QUE-06) Krit. 1–4:** Formular mit Feldbeschriftungen, Statushinweise mit Text (nicht nur Farbe), Touch-Ziele ≥ 48 dp, `textScaler` 2.0, Tastaturbedienung im Web inkl. Bestätigungsdialog.
- **Zustände, Fehler und Netz Krit. 1–3:** Liste „Meine Meldungen“ mit Lade-, Leer-, Fehler- und Datenzustand; offline Hinweis statt technischem Fehler; bei Limit- oder Netzfehler bleiben die Eingaben im Formular erhalten (Issue: „besonders relevant“).
- **Plattformen Krit. 1, 2:** identisch auf iOS, Android und Web; breites Layout im Web.
## 3. Datenmodell
### 3.1 Ausgangslage (nach #2 PR 2c)
Spec #2 § 3.4 legt an (Spaltennamen werden hier so vorausgesetzt; weicht 2c davon 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`, Standard `open`), `target_game_id` (FK → `games`, `on delete restrict`), `resolved_at`, `created_at`, `updated_at`; Checks zu Status, Ziel und `resolved_at`; Index `(owner_id, created_at)`; RLS: Lesen nur eigene, **kein** Insert/Update/Delete für `authenticated`.
- `admin_resolve_report(p_report, p_status, p_target)`: bei `approved`/`merged` hängt sie den vorläufigen Eintrag per `repoint_game_references` auf das Ziel um und setzt am vorläufigen Eintrag `merged_into_id` und `hidden_at`; bei `rejected` speichert sie nur den Verweis (oder nichts).
- Trigger `games_visibility_immutable`: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14).
### 3.2 Änderungen in 13a (Migration `supabase/migrations/<ts>_game_reports_user.sql`)
Nur additiv; der Zeitstempel liegt nach der Migration aus 2c. Rechte-Regel aus Spec #2 § 3.2 gilt: neue Funktionen zuerst `revoke execute … from public, anon, authenticated, service_role`, danach nur die Grants aus 4.1.
**Erweiterungen**
```
create extension if not exists pg_trgm with schema extensions;
```
Falls #3 die Erweiterung schon anlegt, ist die Zeile ein No-op. Kein `unaccent` (Spec-F3).
**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'`. |
**Neue Indizes**
| Index | Zweck |
|---|---|
| `unique (provisional_game_id) where provisional_game_id is not null` | 1:1 Meldung ↔ vorläufiger Eintrag (E1). Falls 2c ihn schon anlegt: entfällt. |
| `(target_game_id) where target_game_id is not null` | Hinweis am Eintrag: „gibt es eine Meldung von mir mit diesem Ziel?“ (5.4) |
| GIN `(extensions.gin_trgm_ops)` auf `public.normalize_game_title(title)` in `games`, `where visibility = 'catalog' and hidden_at is null and merged_into_id is null` | Ähnlichkeitssuche (4.3). Bei ~200–2000 Katalogspielen nicht zwingend, aber billig; KAT-01 kann ihn mitnutzen. |
**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;
```
`select` bleibt wie in 2c. **Nicht** schreibbar für Nutzer: `id`, `origin`, `status`, `bgg_id`, `target_game_id`, `resolved_at`, `created_at`, `updated_at` (Fehler 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`. In #13 schreibt die App ohnehin nur über die RPCs (4.2, 4.4) und das eine Update von `result_seen_at` (4.5) mit einer ausdrücklichen Spaltenliste.
**RLS in Klartext (neu in 13a)**
- **Anlegen (`game_reports_insert_own`, `for insert to authenticated`):** `owner_id = auth.uid()` und, falls `provisional_game_id` gesetzt ist, zeigt er 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)`). `origin`, `status` usw. sind über die Spaltenrechte festgelegt; die Policy prüft zusätzlich `origin = 'user' and status = 'open' and target_game_id is null` (zweite Sicherung).
- **Ä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 (Meldungen verschwinden nur mit dem Konto).
- **Vorläufige Spiele:** unverändert; `games_select` zeigt private Spiele nur dem Ersteller, auch wenn sie ausgeblendet oder zusammengeführt sind. So bleiben die privaten Varianten für den Hinweis zur Edition lesbar (E7).
**Trigger `game_reports_daily_limit`** (Krit. 7)
- `before insert on public.game_reports for each row`, nur wenn `new.origin = 'user'`.
- Sperre gegen gleichzeitige Inserts: `perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0))`. Ohne Sperre könnten zwei parallele Meldungen beide die Zählung „9“ sehen.
- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `created_at` am **selben Kalendertag in Europe/Berlin** (Spec-F4): `(created_at at time zone 'Europe/Berlin')::date = (now() at time zone 'Europe/Berlin')::date`. Bei ≥ 10 Fehler `report_daily_limit` (P0001).
- Import-Meldungen (`origin = 'import'`, SAM-08) werden weder gezählt noch geprüft (N12).
- Funktion `security invoker` mit `search_path = ''`. Der Nutzer sieht per RLS alle eigenen Meldungen, mehr braucht die Zählung nicht; Studio (`postgres`) umgeht RLS ohnehin. `execute` wie oben entzogen (Trigger-Funktionen brauchen kein `execute`-Recht des Aufrufers).
- Die Zahl 10 steht als Konstante in der Funktion (kein Konfigurationswert); eine Änderung ist eine neue Migration.
**Keine neue Tabelle, kein neuer Fremdschlüssel auf `games`.** Der Fremdschlüssel-Wächter aus #2 (7) und `00_schema.test.sql` (21 Tabellen) bleiben unverändert.
### 3.3 Normalisierung und „sehr ähnlich“ (Krit. 2, Klärung 15)
**Funktion `public.normalize_game_title(p_title text) returns text`**, `language sql`, `immutable`, `strict`, `parallel safe`, `search_path = ''`. Schritte in dieser Reihenfolge:
1. `lower()`; `ß` → `ss`.
2. Alles, was kein Buchstabe und keine Ziffer ist (Satz- und Sonderzeichen, Bindestriche, Doppelpunkte, Apostrophe, Emojis), wird zu einem Leerzeichen. Umlaute und andere Buchstaben mit Akzent **bleiben** (Spec-F3). Umsetzung mit `regexp_replace(…, '[^[:alnum:]]+', ' ', 'g')`; backend-dev prüft per Test, dass `[:alnum:]` in der Datenbank-Locale Umlaute als Buchstaben erkennt (sonst explizite Zeichenklasse `[^a-z0-9äöüàáâèéêìíîòóôùúûñçåæøœ]`).
3. **Führende Artikel** entfernen, einmal, nur am Anfang (Spec-F3): Deutsch `der`, `die`, `das`, `den`, `dem`, `des`, `ein`, `eine`, `einen`, `einem`, `einer`, `eines`; Englisch `the`, `a`, `an`.
4. Mehrfache Leerzeichen zu einem, `trim`.
5. Ist das Ergebnis leer (Titel bestand nur aus einem Artikel oder Sonderzeichen), wird der Wert aus Schritt 2 (ohne Artikelentfernung) verwendet.
Beispiele (werden pgTAP-Fälle): „Die Siedler von Catan“ → `siedler von catan`; „CATAN!“ → `catan`; „7 Wonders: Duel“ → `7 wonders duel`; „The Crew“ → `crew`; „Café International“ → `café international`; „Das“ → `das`.
**Regel „sehr ähnlich“** zwischen Meldung (Titel `t`, Jahr `y`) und Katalogspiel `g`:
- **A (gleicher Titel):** `normalize_game_title(g.title) = normalize_game_title(t)`, unabhängig vom Jahr.
- **B (Trigramm):** `extensions.similarity(normalize_game_title(g.title), normalize_game_title(t)) >= 0.8` **und** `abs(g.year - y) <= 1`. Fehlt `y` oder `g.year`, gilt B ohne Jahresbedingung (Spec-F2).
- Grundlage sind nur sichtbare Katalogspiele (`catalog_games`), keine ausgeblendeten, zusammengeführten oder privaten Spiele (auch nicht eigene vorläufige, Spec-F7).
- **Wichtig für die Umsetzung:** In Funktionen mit `search_path = ''` muss `extensions.similarity(...) >= 0.8` ausdrücklich verwendet werden, **nicht** der Operator `%`; dieser nutzt die Einstellung `pg_trgm.similarity_threshold` (Standard 0,3).
- **Alternative Titel (Klärung 3, #3):** Hat #3 bei der Umsetzung von 13a eine Tabelle für alternative Titel, werden diese in A und B mitgeprüft (beste Übereinstimmung je Spiel). Sonst nur der Haupttitel; #3 ergänzt die Prüfung dann in seiner Migration (Hinweis 9.4).
**Zusammenspiel mit #3:** Die Suche in KAT-01 (Krit. 1: „unabhängig von Groß- und Kleinschreibung und Sonderzeichen“) soll dieselbe Funktion nutzen. Wird #3 vor 13a umgesetzt und legt eine Normalisierungsfunktion an, die die Regeln oben erfüllt, verwendet 13a sie statt einer zweiten; sonst legt 13a `normalize_game_title` an und #3 nutzt sie später mit. Eine zweite, abweichende Normalisierung ist zu vermeiden (zweiter Anwendungsfall vorhanden, deshalb eine gemeinsame Funktion).
## 4. Backend
### 4.1 Überblick
| Objekt | Art | `execute`/Recht | Zweck |
|---|---|---|---|
| `normalize_game_title(text)` | Funktion, sql, immutable | `anon`, `authenticated` (harmlos, rein rechnend; nötig für Index und Abfragen als Aufrufer) | Normalisierung (3.3) |
| `find_similar_catalog_games(p_title text, p_year smallint)` | RPC, security invoker | `authenticated` | Ähnliche Katalogspiele anzeigen (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 bei „abgelehnt mit Verweis“ (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 |
`anon` darf keine der RPCs ausführen (42501). `service_role` bekommt nichts (keine Edge Function; der Import ergänzt später eigene Rechte, Spec #2 9.5).
Fehler werden wie in den bestehenden Migrationen als `raise exception '<code>' using errcode = 'P0001'` gemeldet; 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 smallint 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 = ''`, `volatile`.
Ablauf (eine Transaktion; jeder Fehler rollt alles zurück):
1. `auth.uid()` ist null → `not_authenticated`.
2. Eingaben bereinigen: `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` |
| `p_confirmed_distinct = false` und `find_similar_catalog_games` liefert ≥ 1 Treffer | `report_similar_exists` |
Die Prüfungen spiegeln die Checks aus 2c, damit die App gezielte Feldfehler zeigen kann; die Checks der Tabelle bleiben die eigentliche Grenze (23514 als Rückfall → „unbekannter Fehler“).
4. `insert into public.games (owner_id, title, year, visibility) values (auth.uid(), title, year, 'private') returning id` (nur erlaubte Spalten, 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 (`report_daily_limit`); weil Schritt 4 in derselben Transaktion liegt, bleibt kein verwaistes Spiel zurück.
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 (der vorläufige Eintrag hat keine Varianten; KAT-03 Krit. 7 für Listen).
7. Rückgabe als `jsonb`.
Aufrufreihenfolge in der App: erst `find_similar_catalog_games` (4.3) zeigen, dann `report_missing_game` mit `p_confirmed_distinct = true`, wenn der Nutzer bestätigt hat. Ruft ein Client direkt mit `true` auf, ohne die Liste gezeigt zu haben, umgeht er nur den UX-Schritt; Rechte und Limit sind nicht betroffen (E3).
### 4.3 RPC `public.find_similar_catalog_games`
```
find_similar_catalog_games(p_title text, p_year smallint default null)
returns table (
game_id uuid,
title text,
year smallint,
publishers text[], -- Namen aus game_publishers/publishers, sortiert
match_kind text, -- 'same_title' | 'similar_title'
similarity real -- 1.0 bei 'same_title'
)
```
`language sql`, `stable`, `security invoker`, `search_path = ''`. Liest `public.catalog_games` (plus `game_publishers`/`publishers`). Regel nach 3.3. Sortierung: `same_title` zuerst, dann `similarity` absteigend, dann `title`. Höchstens **5** Treffer. Leerer oder nur aus Leerzeichen bestehender Titel → leere Menge (kein Fehler).
Wird für `authenticated` freigegeben, nicht für `anon` (Melden setzt Anmeldung voraus).
### 4.4 RPC `public.adopt_report_target`
```
adopt_report_target(p_report uuid) returns jsonb
-- Zähler aus repoint_game_references, z. B. {"collection_items": 1, "plays": 0, "variants_mapped": 0, "variants_cleared": 1, …}
```
`language plpgsql`, `security definer`, Eigentümer `postgres`, `search_path = ''`. Nötig, weil Nutzer `merged_into_id`/`hidden_at` nicht schreiben und `repoint_game_references` nicht ausführen dürfen (Spec #2 3.2, 3.3).
Prüfungen (alle mit `for update` auf Meldung und vorläufigem Eintrag, damit ein gleichzeitiges `admin_resolve_report` nicht dazwischenkommt):
| Prüfung | Fehler |
|---|---|
| `auth.uid()` ist null | `not_authenticated` |
| Meldung existiert nicht **oder** gehört nicht `auth.uid()` (gleicher Fehler, verrät keine fremden IDs) | `report_not_found` |
| `status <> 'rejected'` oder `target_game_id is null` | `report_not_adoptable` |
| `provisional_game_id is null` | `report_no_provisional` |
| vorläufiger Eintrag ist nicht `private` mit `owner_id = auth.uid()` | `report_not_found` |
| vorläufiger Eintrag ist schon zusammengeführt (`merged_into_id is not null`) | `report_already_adopted` |
| Ziel ist kein sichtbares Katalogspiel (`catalog_games`) | `report_target_not_listed` |
| Es gibt Verweise auf den vorläufigen Eintrag von **anderen** Nutzern (`collection_items`, `favorite_games`, `plays`, `score_sheets` mit `owner_id`/`profile_id` ≠ `auth.uid()`) | `report_foreign_references` |
Die letzte Prüfung ist eine Schutzschicht: Über die API können andere Nutzer ein fremdes privates Spiel nicht referenzieren (Policies verlangen ein sichtbares Spiel), aber `security definer` umgeht RLS, und ein Studio-Handgriff wäre denkbar.
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` (das ist das Ergebnis der Prüfung durch den Admin); `result_seen_at` wird auf `now()` gesetzt, weil der Nutzer gehandelt hat. Rückgabe: Zähler aus `repoint_game_references`.
`favorite_games` sind bei vorläufigen Spielen fachlich ausgeschlossen (Klärung 23), technisch aber derzeit erlaubt (Hinweis an #14, 9.4); `repoint_game_references` behandelt sie ohnehin.
### 4.5 Ergebnishinweis wegklicken
Kein eigener RPC. Die App sendet per PostgREST `PATCH /rest/v1/game_reports?id=eq.<id>` genau die Map `{'result_seen_at': <aktuelle Zeit als ISO-8601 in UTC>}` (PostgREST setzt Werte, keine SQL-Ausdrücke wie `now()`). Dass die Zeit vom Client kommt, ist unkritisch: Der Wert steuert nur die Anzeige des eigenen Hinweises. Policy und Spaltenrecht (3.2) begrenzen das Update auf eigene, bereits bearbeitete Meldungen und genau diese Spalte; bei einer offenen oder fremden Meldung ändert sich keine Zeile.
### 4.6 Lesen für die App
Kein RPC nötig; PostgREST-Abfragen als angemeldeter Nutzer:
- **Meine Meldungen:** `game_reports` (eigene, RLS) mit Spalten `id, origin, title, publisher_name, year, link, status, target_game_id, provisional_game_id, result_seen_at, created_at, resolved_at`, sortiert nach `created_at desc`, seitenweise (20). Eingebettet: `provisional:games!provisional_game_id(id, merged_into_id, hidden_at, game_variants(id))` und `target:games!target_game_id(id, title, year)`. Die Fremdschlüsselnamen für das Embedding 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>)`; der Index aus 3.2 deckt beide Fälle.
- **Edition (E7):** `collection_items` des Nutzers zum Zielspiel (`variant_id`) und `provisional.game_variants` aus der eingebetteten Abfrage.
### 4.7 Doku
- `docs/datenmodell-r1.md`: `game_reports.result_seen_at`, Indizes, Spaltenrechte, Policies, Trigger `game_reports_daily_limit`, die vier Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes.
- `docs/katalog-pflege.md`, Abschnitt „Meldungen bearbeiten“ (aus 2c) ergänzen: Nutzer-Meldungen haben immer einen vorläufigen Eintrag; „abgelehnt mit Verweis“ lässt dem Nutzer die Wahl (`adopt_report_target`), der Admin hängt dabei nichts um; vor `approved` die Edition berücksichtigen (Runbook aus 2c); Meldungen nie von Hand auf einen anderen Status 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 (Titel Pflicht, Längen, Jahr, Link)
similar_game.dart freezed: SimilarGame (gameId, title, year, publishers, matchKind)
game_report_failure.dart freezed/sealed: dailyLimit, similarExists, validation(field, code),
notAdoptable, 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 Mapping aus Zeilen (nur fromJson; kein toJson für Schreibzugriffe)
game_report_failure_mapper.dart P0001-Codes → GameReportFailure
presentation/
report_form/ Screen, Controller (Notifier), State
similar_games/ Abschnitt/Dialog mit Kandidaten und ausdrücklicher Bestätigung
my_reports/ Liste „Meine Meldungen“, Controller
report_detail/ Detail einer Meldung inkl. Hinweis und Aktionen
widgets/report_outcome_notice.dart wiederverwendbarer Hinweis (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. Die Validierung der Eingaben liegt in `GameReportDraft` (Unit-Tests), nicht im Widget.
### 5.2 Routen (go_router)
| Route | Screen | Hinweis |
|---|---|---|
| `/reports/new?title=<suchbegriff>` | `ReportFormScreen` | Einstieg aus der leeren Trefferliste von KAT-01 (#3 Krit. 6) mit vorbelegtem Titel; Anmeldung nötig (Redirect auf `/login?from=…` wie in Spec #1) |
| `/reports` | `MyReportsScreen` | Liste der eigenen Meldungen |
| `/reports/:reportId` | `ReportDetailScreen` | Status, Angaben, Hinweis, Aktionen; Deep Link und Web-URL möglich, fremde/unbekannte ID → „nicht gefunden“ (RLS liefert keine Zeile) |
Konstanten in `AppRoutes`. Einstieg zu `/reports`: aus der Suche (#3) und aus jedem Hinweis; ob zusätzlich in den Einstellungen (KTO-11 #28) oder im Dashboard (QUE-09), entscheidet ui-designer mit Chris (kein Kriterium verlangt es).
### 5.3 Melden (Krit. 1, 2, 3, 7, 8)
**Ablauf im `ReportFormController`** (Riverpod-Notifier, Zustände als freezed-Union):
| Zustand | Bedeutung | UI |
|---|---|---|
| `editing(draft, fieldErrors)` | Eingabe | Felder Titel (Pflicht), Verlag, Erscheinungsjahr, Link (optional), Auswahl „Gleich aufnehmen in“: Sammlung / Wunschliste / Einkaufsliste / nicht aufnehmen (Standard: Sammlung, Spec-F9) |
| `checking` | `find_similar_catalog_games` läuft | Button mit Ladeanzeige, Felder gesperrt |
| `similarFound(draft, candidates)` | Treffer nach 3.3 | Kandidatenliste (Titel, Jahr, Verlage, `GameCover`). Je Kandidat „Das ist mein Spiel“ → Detailseite des Katalogspiels (#4), keine Meldung. Darunter ausdrückliche Bestätigung: Checkbox „Mein Spiel ist keines davon“ + Button „Trotzdem melden“ (erst aktiv mit Haken). |
| `submitting` | `report_missing_game` läuft | Ladeanzeige |
| `submitted(result)` | gespeichert | 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. „Steht jetzt in deiner Sammlung/Wunschliste/Einkaufsliste“; Aktionen „Zur Meldung“, „Zur Sammlung“ (falls aufgenommen) |
| `failure(draft, failure)` | Fehler | Eingaben bleiben erhalten (Zustände Krit. 3). `dailyLimit` → Hinweis „Du hast heute schon 10 Spiele gemeldet. Morgen geht es wieder.“ (Krit. 7); `similarExists` (Rennen: Katalog hat sich geändert) → zurück zu `similarFound`; Feldfehler am Feld; offline → „Melden braucht eine Verbindung“ |
Gibt es keine Treffer, geht `checking` direkt in `submitting` über (`p_confirmed_distinct = false`). Bei Treffern wird mit `true` gesendet.
Das Formular prüft vor dem Senden lokal dieselben Regeln wie 4.2 (Titel Pflicht, Längen, Jahr, Link-Format), damit Fehler sofort am Feld stehen. Das Tageslimit prüft nur der Server.
**Krit. 3 nachträglich:** Wurde „nicht aufnehmen“ gewählt, kann der Nutzer den vorläufigen Eintrag später auf der Detailseite der Meldung zu einer Liste hinzufügen. 13b nutzt dafür die Funktion „zur Sammlung/Liste hinzufügen“ aus SAM-01/SAM-04/SAM-05 (#6/#8/#9), falls vorhanden; sonst ein einfacher Insert in `collection_items` über das Repository (Spalten `owner_id`, `game_id`, `list`) mit Hinweis an #6, ihn später zu ersetzen (Spec-F8).
### 5.4 Ergebnishinweise (Krit. 4, 5, 6, 8)
`ReportOutcome` wird rein aus den Daten abgeleitet (`outcomeOf(report, provisional, collectionItemOnTarget)`, Unit-Tests je Fall):
| Fall | Erkennung | Hinweis (Kurzform, Texte über 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`, vorläufiger Eintrag `merged_into_id = target` | „Dein gemeldetes Spiel ist jetzt im Katalog. Deine Angaben wurden übernommen.“ | „Zum Spiel“, „Verstanden“ | Meldung; **Eintrag am Zielspiel** (Sammlung/Liste, Detailseite) |
| `merged` (Krit. 5) | `status = merged`, vorläufiger Eintrag `merged_into_id = target` | „Dein Spiel gab es schon im Katalog als „{title}“. Dein Eintrag zeigt jetzt dorthin.“ | „Zum Spiel“, „Verstanden“ | wie `approved` |
| `rejectedWithTarget` (Krit. 5) | `status = rejected`, `target_game_id` gesetzt, vorläufiger Eintrag nicht zusammengeführt | „Dein Spiel gibt es bereits als „{title}“.“ | „Zu „{title}“ wechseln“ (→ `adopt_report_target`, mit Bestätigungsdialog: „Deine Angaben werden auf den Katalogeintrag übertragen.“), „Vorläufig behalten“ | Meldung; vorläufiger Eintrag |
| `rejectedAdopted` | `status = rejected`, `target_game_id` gesetzt, vorläufiger Eintrag `merged_into_id = target` | wie `merged` | „Zum Spiel“ | wie `approved` |
| `rejected` (Krit. 6) | `status = rejected`, kein Ziel | Dauerhafte Markierung „Abgelehnt“ + Text „Dieses Spiel wird nicht in den Katalog aufgenommen. Dein Eintrag bleibt privat für dich.“ | „Verstanden“ (blendet nur den Text aus, die Markierung bleibt) | Meldung; vorläufiger Eintrag |
**Zusatz Edition (E7, N15):** Bei `approved`, `merged` und `rejectedAdopted` zusätzlich „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“, wenn der vorläufige Eintrag mindestens eine eigene Variante hat **und** der Sammlungseintrag des Nutzers am Zielspiel `variant_id = null` hat. Aktion „Edition wählen“ führt zur Bearbeitung des Sammlungseintrags (KAT-03 #12 / SAM-02 #7), sobald vorhanden. Bei Partien wird nicht einzeln geprüft (Spec-F6).
**„Verstanden“** setzt `result_seen_at` (4.5). Danach erscheint der Hinweis nicht mehr am Eintrag; in „Meine Meldungen“ bleibt der Status immer sichtbar.
**Hinweis „am Eintrag“:** Provider `reportNoticeForGameProvider(gameId)` liefert den passenden `ReportOutcome` (oder keinen) für ein Spiel, an dem der Nutzer einen Eintrag hat: Meldungen mit `provisional_game_id = gameId` (vorläufiger Eintrag) oder `target_game_id = gameId` und `result_seen_at is null` (ersetzter Eintrag). Das Widget `ReportOutcomeNotice` (kompakt für Listenzeilen, ausführlich für Detailseiten) wird eingebaut
- in 13b: in „Meine Meldungen“ und die Meldungsdetailseite,
- in die Detailseite eines Spiels (#4) und die Sammlungs-/Listenansicht (#6/#11), soweit diese bei der Umsetzung von 13b gemergt sind; sonst übernehmen #4/#6/#11 den Einbau (Spec-F8, Hinweis 9.4).
Vorläufige Einträge werden in Listen mit einem Badge „vorläufig“ (Text, nicht nur Farbe) und dem `GameCover`-Platzhalter gezeigt.
**Aktualität:** Kein Realtime in R1. Die Hinweise werden beim Öffnen der Liste/Detailseite und per Pull-to-Refresh neu geladen; das reicht, weil der Admin Meldungen selten und manuell bearbeitet.
### 5.5 Meine Meldungen
`MyReportsController` (AsyncNotifier mit Paging, 20 je Seite): Zustände Laden, Leer („Du hast noch keine Spiele gemeldet“ + Weg zur Suche), Fehler mit „Erneut versuchen“, Daten. Je Zeile: Titel, Jahr, Datum, Status-Badge (Text), ggf. kompakter Hinweis. Import-Meldungen (`origin = import`, SAM-08) erscheinen mit Kennzeichen „aus BGG-Import“ (Text liefert #25; 13b zeigt `origin` nur an, wenn vorhanden).
### 5.6 l10n (Auszug, de/en)
Neue ARB-Schlüssel mit Präfix `gameReport…`: Formularfelder und Fehler (`gameReportTitleRequired`, `gameReportTitleTooLong`, `gameReportPublisherTooLong`, `gameReportYearInvalid`, `gameReportLinkInvalid`), Ähnlichkeit (`gameReportSimilarHeading`, `gameReportSimilarIsMine`, `gameReportSimilarConfirmDistinct`, `gameReportSubmitAnyway`), Listenwahl, Bestätigung, Tageslimit (`gameReportDailyLimit`), Offline, Status-Badges (`gameReportStatusOpen` … `gameReportStatusRejected`, `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 mit `TextInputType.url`, Jahr mit Ziffern-Tastatur; der Link wird in der App nur als Text angezeigt, nicht geöffnet (Datenschutz, 2).
- **Web:** Routen sind teilbare URLs; der Bestätigungsschritt bei ähnlichen Spielen ist per Tastatur bedienbar (Checkbox, Fokus-Reihenfolge); breites Layout: Formular und Kandidaten nebeneinander ab Breite ≥ 840 dp (ui-designer).
- **Offline:** Melden braucht eine Verbindung (wie SAM-01 Krit. 3); Hinweis statt Fehler, Eingaben bleiben im Formular. Keine Offline-Warteschlange für Meldungen.
- **Push, Kamera, Dateien:** nicht betroffen (kein Bild-Upload, keine Push in R1).
## 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/09_game_reports_user.test.sql`** (Nummer nach `08_game_reports.test.sql` aus 2c). Fixtures mit festen Test-UUIDs, zwei Nutzer A und B, unabhängig vom Startbestand.
### 7.1 Abdeckungsmatrix
| AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task |
|---|---|---|---|---|---|
| Krit. 1 | `GameReportDraft`: Titel Pflicht (auch nur Leerzeichen), Längen, Jahr, Link-Format; Fehlercode-Mapping | Formular: Feldfehler am Feld, Bestätigung nach Erfolg, Listenwahl | Playwright: leere Suche → „Spiel melden“ → Titel vorbelegt → absenden → Bestätigung | `report_missing_game` legt Meldung (`status = open`, `origin = user`) und vorläufiges Spiel (`private`, `owner_id = A`, nur Titel/Jahr) an; Trimmen; alle Validierungsfehler (`report_title_required` … `report_list_invalid`); `anon` → 42501 | B1, B2, F2, T1, T2 |
| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` beim Senden → zurück zu Kandidaten | Kandidatenliste, Bestätigungs-Checkbox aktiviert Button, Tastatur im Web, Semantics | Playwright: Meldung zu „Catan“ zeigt vorhandenes „CATAN“ | `normalize_game_title`: Beispiele aus 3.3 (Artikel de/en, Sonderzeichen, Umlaute bleiben, `ß`, nur Artikel); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr trifft; Trigramm ≥ 0,8 bei Jahr ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; Jahr fehlt → Regel nach Spec-F2; ausgeblendete, zusammengeführte und private Spiele (auch eigene) werden nicht geliefert; höchstens 5, Sortierung; `report_missing_game` ohne Bestätigung bei Treffer → `report_similar_exists`, mit Bestätigung gespeichert | B1, B2, F2, U1, T1, T2 |
| Krit. 3 | – | Bestätigung zeigt Liste; Badge „vorläufig“ | Playwright: gemeldetes Spiel steht in der Wunschliste (sofern #6/#8 vorhanden) | `p_list` legt `collection_items` zum vorläufigen Spiel an; B sieht weder vorläufiges Spiel noch Listeneintrag noch Meldung von A; `anon` sieht nichts; Rollback: schlägt der Listeneintrag fehl, gibt es weder Spiel noch Meldung | B1, B2, F2, F3 |
| Krit. 4 | `outcomeOf`: `approved` mit/ohne Edition-Hinweis | `ReportOutcomeNotice` für `approved` (de/en), „Verstanden“ blendet aus | integration_test/Playwright: Meldung per SQL-Hilfsfunktion im Testsetup mit `admin_resolve_report(…, 'approved', …)` bearbeiten → Hinweis am Eintrag | nach `admin_resolve_report` (2c) ist der Sammlungseintrag von A am Zielspiel mit Liste/Notiz/Kaufdatum; vorläufige Variante → `variant_id` null, private Variante bleibt am vorläufigen Eintrag und ist für A lesbar (E7); `result_seen_at` setzbar | B2, F3, T1, T2 |
| Krit. 5 | `outcomeOf`: `merged`, `rejectedWithTarget`, `rejectedAdopted` | Hinweise und Aktion „wechseln“ mit Bestätigungsdialog | Playwright: abgelehnt mit Verweis → wechseln → Eintrag zeigt Zielspiel | `adopt_report_target`: Erfolg hängt Sammlung und Partien von A um, setzt `merged_into_id`/`hidden_at`, Status bleibt `rejected`, `result_seen_at` gesetzt; Fehler `report_not_found` (fremde Meldung von B, unbekannte ID), `report_not_adoptable` (`open`, `approved`, `rejected` ohne Ziel), `report_already_adopted` (zweiter Aufruf), `report_target_not_listed` (Ziel inzwischen ausgeblendet), `report_foreign_references` (Fixture als `postgres`); `anon` → 42501 | B1, B2, F3, T1, T2 |
| Krit. 6 | `outcomeOf`: `rejected` ohne Ziel | dauerhafte Markierung „Abgelehnt“ bleibt nach „Verstanden“ | – | nach `admin_resolve_report(…, 'rejected')` bleibt das vorläufige Spiel unverändert privat und für A lesbar | B2, F3, T2 |
| Krit. 7 | Mapping `report_daily_limit` → `dailyLimit` | Limit-Hinweis, Eingaben bleiben erhalten | – | 10 Meldungen am selben Berliner Kalendertag ok, 11. → `report_daily_limit` und **kein** vorläufiges Spiel übrig (Rollback); Meldungen von gestern (Berliner Zeit, Fixture mit `created_at` als `postgres` gesetzt) zählen nicht; Grenzfall 23:30 UTC (= nächster Tag in Berlin im Winter); `origin = 'import'` als `postgres` eingefügt zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; direkter Insert mit `created_at`, `origin` oder `status` → 42501 | B1, B2, F2, T1 |
| Krit. 8 | – | Status „offen“ in Liste und Detail | – | Nutzer kann `status`, `target_game_id`, `resolved_at` weder per Insert noch per Update setzen (42501); Update von `result_seen_at` bei `status = open` → 0 Zeilen; nach dem Insert ist der Status `open`; kein Trigger/Job ändert ihn | B2, T1 |
| Sicherheit Krit. 1, 3 | – | – | – | B liest A's Meldungen und vorläufige Spiele nicht; B kann A's Meldung nicht wegklicken (0 Zeilen) und nicht übernehmen; Insert mit `owner_id = A` als B → Policy-Fehler; `provisional_game_id` auf ein fremdes, ein Katalog- oder ein ausgeblendetes Spiel → Policy-Fehler; Spaltenrechte per `has_column_privilege` | B2 |
| Sicherheit Krit. 2 | – | – | – | `anon`: kein `select`, `insert`, `update` auf `game_reports`, keine RPC ausführbar | B2 |
| Datenschutz | – | – | – | Konto von A löschen → Meldungen, vorläufige Spiele, Listeneinträge weg; Meldungen und Spiele von B unverändert | B2 |
| QUE-05/06, Zustände | – | Alle Screens de/en, `meetsGuideline` (Tap-Ziele, Kontrast, Labels), `textScaler` 2.0 ohne Überlauf, Lade-/Leer-/Fehler-/Datenzustand „Meine Meldungen“, Offline-Hinweis | Playwright: Tastaturbedienung des Bestätigungsschritts | – | F2, F3, T2 |
**Nicht automatisiert:** echte Parallelität des Limits (die Sperre wird per Code-Review und einem dokumentierten manuellen Test mit zwei `psql`-Sitzungen geprüft); Bearbeitung echter Meldungen im Studio auf Staging (Chris).
**Bestehende Tests:** `08_game_reports.test.sql` aus 2c erwartet „kein Insert für `authenticated`“; 13a passt diesen Test auf die neuen Spaltenrechte an (B2). `00_schema.test.sql` bleibt bei 21 Tabellen.
## 8. Aufgaben
### 8.1 PR 13a – Backend (`Refs #13`), Branch `feature/13a-meldungen-backend`
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| B1 | backend-dev | Migration `<ts>_game_reports_user.sql` nach 3.2–4.5: `pg_trgm`, `normalize_game_title` (oder Wiederverwendung aus #3, 3.3), Spalte `result_seen_at` mit Check, Indizes, Spaltenrechte, Policies `game_reports_insert_own` und `game_reports_update_seen`, Trigger `game_reports_daily_limit` (Berliner Kalendertag, Sperre je Nutzer, nur `origin = 'user'`), RPCs `find_similar_catalog_games`, `report_missing_game`, `adopt_report_target`; Rechte nach 4.1 | #2 PR 2c gemergt |
| B2 | backend-dev | pgTAP `09_game_reports_user.test.sql` nach 7.1; `08_game_reports.test.sql` an die neuen Insert-Rechte anpassen | B1 |
| B3 | backend-dev | Doku nach 4.7 (`datenmodell-r1.md`, `katalog-pflege.md` Abschnitt Meldungen, `supabase/README.md`) | B1 |
| T1 | test-engineer | Review der pgTAP-Abdeckung gegen 7.1; manueller Paralleltest des Limits (zwei Sitzungen) dokumentieren | B2 |
### 8.2 PR 13b – Flutter (`Closes #13`), Branch `feature/13b-meldungen-app`
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| U1 | ui-designer | Entwurf Formular, Kandidatenschritt mit ausdrücklicher Bestätigung, Bestätigung, „Meine Meldungen“, Meldungsdetail, `ReportOutcomeNotice` (kompakt/ausführlich), Badge „vorläufig“, breites Web-Layout; Texte de/en; Einstieg zu `/reports` mit Chris abstimmen | Spec freigegeben |
| F1 | flutter-dev | Domäne (5.1): Modelle, `ReportOutcome` + `outcomeOf`, `GameReportDraft`-Validierung, Fehler, Repository-Schnittstelle; Unit-Tests | Spec freigegeben (parallel zu B1) |
| F2 | flutter-dev | Data-Schicht (RPC-Aufrufe mit expliziten Parametern, kein `toJson()` an Schreibzugriffe; Fehler-Mapper) und Melden-Ablauf (5.3) inkl. Routen, Einstieg aus der leeren Suche (#3), l10n | F1, U1; gegen echtes Backend: B1 |
| F3 | flutter-dev | „Meine Meldungen“, Meldungsdetail, Hinweise (5.4) inkl. „Verstanden“, „wechseln“, Edition-Hinweis; Einbau von `ReportOutcomeNotice` in #4/#6/#11, soweit gemergt (sonst Hinweis-Kommentar in den Issues) | F1, F2 |
| T2 | test-engineer | Widget-Tests nach 7.1 (alle Zustände, alle Hinweisfälle, de/en, a11y, `textScaler` 2.0); Playwright-Ablauf Krit. 1–5 gegen lokales Supabase, Bearbeitung der Meldung im Testsetup per SQL als `postgres` | F2, F3, B1 |
### 8.3 Reihenfolge und Parallelität
```
Vorbedingung: #2 PR 2b → 2c gemergt; #3 KAT-01 gemergt (Einstieg, ggf. Normalisierung)
13a: B1 → B2 → B3, T1
13b: U1 ─┐
F1 ─┴→ F2 (Mock-Repository, danach gegen B1) → F3 → T2
13b wird erst nach 13a gemergt (RPC-Vertrag muss in main sein).
```
F1 und U1 starten sofort nach Freigabe der Spec, parallel zu B1. 13b schließt #13. Merge nur durch Chris.
## 9. Risiken & offene Fragen
### 9.1 Risiken
| # | Risiko | Auswirkung | Gegenmaßnahme |
|---|---|---|---|
| R1 | 2b/2c weichen bei der Umsetzung von den in Spec #2 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`, das RLS umgeht | Fehler würden fremde Daten berühren | Strenge Prüfungen (4.4) inkl. Prüfung auf fremde Verweise, `for update`, nur eigene private, nicht zusammengeführte Spiele; pgTAP-Negativfälle; Code-Review mit Fokus Sicherheit |
| R3 | Trigramm-Schwelle 0,8 ist bei kurzen Titeln streng oder bei Reihen („Catan“, „Catan: Seefahrer“) großzügig | Ähnliche werden nicht gezeigt bzw. zu viele | Klärung 15 ist verbindlich; Kandidatenschritt blockiert nicht, er verlangt nur eine Bestätigung; Erfahrungen nach R1 sammeln |
| R4 | `[:alnum:]` hängt von der Datenbank-Locale ab | Umlaute würden entfernt, Normalisierung falsch | Test mit Umlauten in pgTAP; Rückfall auf explizite Zeichenklasse (3.3) |
| R5 | Edition-Hinweis per Ableitung (E7) zeigt auch dann „Edition neu wählen“, wenn der Nutzer nie eine Edition gewählt hatte, aber eine private Variante existiert | Unnötiger Hinweis | Selten (vorläufige Einträge haben anfangs keine Varianten); wegklickbar; Alternative in Spec-F6 |
| R6 | Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11, die evtl. noch fehlen | Krit. 4–6 nur in „Meine Meldungen“ sichtbar | Spec-F8: Abhängigkeiten setzen oder Einbau dort nachziehen |
| R7 | Nutzer melden das gleiche Spiel mehrfach (eigene offene Meldungen werden nicht geprüft, Spec-F7) | Mehr Arbeit für Chris, doppelte vorläufige Einträge | Tageslimit; Chris lehnt Duplikate mit Verweis ab |
| R8 | Kein Realtime | Ergebnis erscheint erst beim nächsten Laden | Akzeptiert (manuelle Bearbeitung, kein Zeitdruck) |
### 9.2 Offene Fragen an Chris (Spec-F1 bis Spec-F9)
Keine Frage blockiert die Umsetzung: Für jede gibt es eine umsetzbare Empfehlung, die mit der Freigabe der Spec gilt, falls Chris nichts anderes entscheidet.
| # | Frage | Empfehlung | Begründung |
|---|---|---|---|
| Spec-F1 | **Krit. 1:** Sind Verlag und Erscheinungsjahr Pflicht oder optional? Der Text nennt nur den Titel als Pflicht. | **Optional**, nur der Titel ist Pflicht. | Nutzer kennen das Jahr oft nicht; eine Pflicht würde zu geratenen Werten führen. 2c hat beide Spalten nullable. |
| Spec-F2 | **Krit. 2:** Wie gilt die Trigramm-Regel, wenn das Jahr in der Meldung oder im Katalogeintrag fehlt? | Trigramm ≥ 0,8 **ohne Jahresbedingung**. | Lieber einmal mehr nachfragen als ein Duplikat; der Schritt verlangt nur eine Bestätigung. |
| Spec-F3 | **Krit. 2 „Artikel und Sonderzeichen ignoriert“:** Welche Artikel, wo, und zählen Umlaute/Akzente als Sonderzeichen? | Nur **führende** Artikel auf Deutsch und Englisch (Liste in 3.3); Umlaute und Akzente **bleiben** Buchstaben, `ß` = `ss`; alle anderen Zeichen werden ignoriert. | Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“). Akzent-Faltung (`unaccent`) ist nicht `immutable` und damit für Index und gemeinsame Funktion ungeeignet; der Trigramm-Vergleich fängt „Molkky/Mölkky“ meist trotzdem. |
| Spec-F4 | **Krit. 7 „heute“:** Kalendertag in welcher Zeitzone, oder gleitende 24 Stunden? | **Kalendertag in Europe/Berlin.** | Entspricht „heute“ im Kriterium und ist für Nutzer verständlich („morgen geht es wieder“). Eine Profil-Zeitzone gibt es nicht. |
| Spec-F5 | **Krit. 4–6:** Wie lange bleibt der Hinweis am Eintrag sichtbar? | **Bis der Nutzer „Verstanden“ wählt** (`result_seen_at`). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft; in „Meine Meldungen“ bleibt der Status immer sichtbar. | Ein dauerhafter Hinweis an einem längst ersetzten Eintrag stört; ein automatisches Ausblenden nach Zeit könnte übersehen werden. |
| Spec-F6 | **N15, Edition:** Woran erkennt die App, dass die Edition geleert wurde? | **Ableitung** (E7): vorläufiger Eintrag hat eine eigene Variante und der Eintrag am Ziel hat keine Edition. Alternative: Spalte `game_reports.edition_cleared`, gesetzt von `admin_resolve_report`/`adopt_report_target`; dafür müsste `repoint_game_references` (2b) zusätzlich zählen, wie viele **Verweise** geleert wurden, und 2c bzw. 13a die Funktionen anpassen. | Die Ableitung braucht keine Änderung an den bereits freigegebenen Teilen 2b/2c. Die Alternative ist genauer und lohnt sich, wenn 2b/2c ohnehin noch nicht gebaut sind; dann sollte die Änderung in Spec #2 nachgetragen werden, nicht in 13a per `create or replace`. |
| Spec-F7 | **Krit. 2:** Sollen auch eigene offene Meldungen bzw. eigene vorläufige Einträge mit ähnlichem Titel angezeigt werden („Du hast dieses Spiel schon gemeldet“)? | **Nein in R1.** | Kriterium nennt nur Katalogeinträge; Duplikate begrenzt das Tageslimit, Chris lehnt sie mit Verweis ab. |
| Spec-F8 | **Abhängigkeiten:** Krit. 3 („in Sammlung oder Listen“) und der Hinweis „am Eintrag“ (Krit. 4–6) leben in Screens von SAM-01 (#6), KAT-02 (#4) und SAM-03 (#11). Sollen #4 und #6 als Forgejo-Abhängigkeit von #13 gesetzt werden? | **Ja, #13 hängt zusätzlich von #4 und #6 ab** (beide liegen in der Reihenfolge ohnehin vor #13). #11 nicht: Den Einbau des Hinweises in die durchsuchbare Sammlung übernimmt #11, falls es später kommt. | Sonst müsste #13 eigene Zwischenlösungen für Listeneintrag und Detailseite bauen, die später ersetzt werden. |
| Spec-F9 | **Krit. 3:** Welche Liste ist im Formular vorausgewählt? | **Sammlung**, änderbar auf Wunsch-/Einkaufsliste oder „nicht aufnehmen“. | Häufigster Fall: Nutzer will ein Spiel erfassen, das er besitzt (Story: „Katalogloch blockiert beim Erfassen“). |
### 9.3 Entscheidungen innerhalb der Architektur (zur Kenntnis, kein ADR nötig)
- RPC als Bündelung mit `security invoker`, Rechte in der Datenbank (E2, E3); nur `adopt_report_target` ist `security definer` (E5).
- Kein Realtime, keine Edge Function, keine neuen Pakete (E9, R8).
- Kein ADR: Die Entscheidungen bleiben innerhalb von ADR-0001/0002 und sind ohne großen Aufwand änderbar.
### 9.4 Hinweise für andere Stories
- **KAT-01 (#3):** Einstieg „Spiel melden“ aus der leeren Trefferliste mit `/reports/new?title=<suchbegriff>` (Krit. 6); Normalisierung `normalize_game_title` für die Suche mitnutzen (3.3); legt #3 alternative Titel an, auch in `find_similar_catalog_games` berücksichtigen.
- **KAT-02 (#4), SAM-01 (#6), SAM-03 (#11):** `ReportOutcomeNotice` und `reportNoticeForGameProvider` am Eintrag einbauen; vorläufige Einträge mit Badge „vorläufig“; eine Detailansicht für ein eigenes privates Spiel (nicht in `catalog_games`) lesen sie direkt aus `games`.
- **KAT-03 (#12):** Aktion „Edition wählen“ aus dem Edition-Hinweis anbinden.
- **KTO-02 (#14):** Die Policy `favorite_games_own` erlaubt derzeit jedes sichtbare Spiel, also auch eigene vorläufige; Klärung 23 verlangt nur Katalogspiele. #14 muss die Policy (neue Migration) auf `visibility = 'catalog'` einschränken.
- **PAR-01 (#15):** vorläufige Spiele als wählbare Spiele anbieten (Krit. 3, Klärung 36), mit Badge.
- **KTO-05 (#23):** Export enthält `game_reports` (inkl. Status und Link) und vorläufige Spiele; Löschen per Kaskade ist durch pgTAP in 13a abgedeckt.
- **SAM-08 (#25):** Import-Meldungen mit `origin = 'import'` und `bgg_id`, einem vorläufigen Eintrag je Meldung (E1); der Limit-Trigger ignoriert sie (N12); die Hinweise aus 5.4 gelten unverändert; Kennzeichen „aus BGG-Import“ in „Meine Meldungen“ (5.5).
### 9.5 Ausstehende Prozessschritte
- Freigabe der Spec und Antworten zu Spec-F1 bis Spec-F9 durch Chris (Label `status/spec-review`).
- Bei Spec-F6 Alternative: Nachtrag in Spec #2 (2b/2c) vor deren Umsetzung.
- Bei Spec-F8 Ja: Forgejo-Abhängigkeiten #13 → #4, #6 setzen (Chris oder Orchestrator).
## 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.