Shuffle/docs/specs/13-fehlendes-spiel-melden.md
cschlaefke ec42421312
All checks were successful
CI / flutter (pull_request) Successful in 2m9s
CI / web-build (pull_request) Successful in 1m33s
CI / supabase (pull_request) Successful in 10s
CI / supabase-tests (pull_request) Successful in 37s
CI / e2e (pull_request) Successful in 4m14s
docs(db): Review-Nachbesserungen PR #67 (Spec, Nachtrag, Release-Checkliste, Testkorrekturen) Refs #13
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 20:03:50 +01:00

55 KiB
Raw Blame History

Spec #13 – KAT-04 Fehlendes Spiel melden

Issue #13 (KAT-04)
Status Entwurf, Review-Befunde eingearbeitet; von Chris zur Umsetzung von 13a freigegeben (Spec-F1 bis Spec-F9 gelten mit den Empfehlungen aus § 9.2, Spec-F6 ist mit 2c umgesetzt, Spec-F10 am 2026-11-04 von Chris entschieden). Änderung vom 2026-11-04 (Entscheidung Chris, Option B, Spec-F10): Es gibt kein „abgelehnt mit Verweis“ und kein adopt_report_target mehr; rejected nur ohne Ziel, Verweis auf einen vorhandenen Eintrag über merged.
Autor architect
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 Entscheidung Chris, 2026-11-04 (Option B, Spec-F10), ändert Spec #2 Spec-F3: Kein „abgelehnt mit Verweis“. Wer auf einen vorhandenen Eintrag verweisen will, setzt merged (admin_resolve_report hängt direkt um, ohne Nutzeraktion). rejected gibt es nur ohne Ziel; admin_resolve_report weist rejected mit Ziel ab (report_reject_has_target), der Check game_reports_rejected_no_target sichert die Tabelle. Die frühere Funktion adopt_report_target (security definer) entfällt. Der Nutzer muss nichts entscheiden; weniger Zustände in der App (nur pending / approved / merged / rejected) und keine Rechteausweitung durch eine Nutzer-RPC auf repoint_game_references.
E6 Ergebnishinweise werden aus vorhandenen Daten abgeleitet und lassen sich wegklicken (result_seen_at, Spec-F5). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft. Fehlt der vorläufige Eintrag (vom Nutzer gelöscht), folgt das Ergebnis nur aus status. Kein Benachrichtigungssystem in R1 (QUE-01 erst R2).
E7 Edition (N15): Empfehlung (Spec-F6) ist ein Zähler game_reports.editions_cleared, angelegt und gesetzt in 2c (PR #57, vor dem Merge) aus dem schon vorhandenen variants_cleared von repoint_game_references; 2b bleibt unverändert; adopt_report_target entfällt (Spec-F10), ihn setzt nur admin_resolve_report. Rückfall ist die Ableitung aus den Daten (nur Sammlung, list = 'owned'). Der Zähler ist genau und erfasst Sammlung und Partien; der Nachtrag ist eine Spalte und eine Zuweisung im bestehenden Update. Nach dem Merge von 2c wäre ein create or replace von admin_resolve_report in 13a nötig.
E8 Verlag bleibt Freitext in game_reports.publisher_name. publishers ist nur für den Admin schreibbar (Spec #2 3.2).
E9 Keine neuen Pakete, keine Edge Function. pg_trgm kommt aus 3a (Spec #3 E14). CLAUDE.md, ADR-0002.
E10 Zwei PRs: 13a Backend (Refs #13), 13b Flutter (Closes #13). 13b beginnt gegen ein gemocktes Repository, sobald der RPC-Vertrag (4.2–4.5) freigegeben ist. Der Vertrag steht vollständig in dieser Spec.

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 nichts (ab 13a, Spec-F10: rejected nur ohne Ziel). Gibt die Zähler aus repoint_game_references zurück, darunter variants_cleared (geleerte Verweise in collection_items inkl. Kollisionsfall und in plays), speichert sie aber nicht (Stand PR #57).
  • Trigger games_visibility_immutable: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14).

Aus Spec #3 PR 3a: search_normalize(text), die generierten Spalten games.title_search und game_alt_titles.title_search, die Tabelle game_alt_titles sowie die Erweiterungen pg_trgm und unaccent. Der Fremdschlüssel game_alt_titles.game_id und seine Einordnung im Fremdschlüssel-Wächter gehören zu #3.

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)
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. Ein Jahr außerhalb 1800–2100 gilt wie null (kein Überlauf, kein 22003). Leerer Titel oder Titel > 200 Zeichen → leere Menge (kein Fehler; report_missing_game meldet die Länge selbst). Nur authenticated.

4.4 entfällt (Entscheidung Chris, 2026-11-04, Option B, Spec-F10)

Die frühere RPC adopt_report_target (Wechsel auf den Zieleintrag nach „abgelehnt mit Verweis“) wird nicht gebaut. Stattdessen ändert 13a admin_resolve_report per create or replace (Rumpf aus 2c unverändert) um genau eine Prüfung: p_status = 'rejected' mit p_target is not null löst report_reject_has_target (P0001) aus. Zusätzlich verbietet der Check game_reports_rejected_no_target (status <> 'rejected' or target_game_id is null) ein Ziel bei abgelehnten Meldungen in der Tabelle. Der Verweis auf einen vorhandenen Eintrag läuft über merged (Umhängen durch den Admin, repoint_game_references, wie bei approved).

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); Verweis auf einen vorhandenen Eintrag = merged (Spec-F10), rejected nur ohne Ziel; vor approved und merged die Edition berücksichtigen (Runbook 2c); Status nie von Hand setzen, nur über admin_resolve_report, weil die App den Hinweis aus Status und merged_into_id ableitet.
  • supabase/README.md: neue RPCs und Rechte.

5. Frontend

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), 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
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 und merged: „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“

  • Bei Spec-F6 = Zähler (Empfehlung): wenn editions_cleared > 0. Der Text nennt Sammlung und Partien („in deiner Sammlung oder deinen Partien“).
  • Rückfall Ableitung: wenn der vorläufige Eintrag mindestens eine eigene Variante hat und der Sammlungseintrag am Ziel mit list = 'owned' variant_id = null hat. Wunsch- und Einkaufsliste haben nach Klärung 28 nie eine Edition und werden nicht geprüft; Partien werden nicht einzeln geprüft (Grenze der Ableitung).
  • 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 und rejected; am Zielspiel (target_game_id = gameId, result_seen_at is null) nur approved und merged, und nur wenn provisional.merged_into_id = gameId. ReportOutcomeNotice (kompakt/ausführlich) wird eingebaut in 13b in „Meine Meldungen“ und die Meldungsdetailseite, in #4/#6/#11 soweit gemergt, sonst übernehmen diese Stories den Einbau (Spec-F8, 9.4). Vorläufige Einträge zeigen ein Badge „vorläufig“ (Text) und den GameCover-Platzhalter. Zustände nach der Entscheidung vom 2026-11-04: nur pending, approved, merged, rejected (ohne Ziel).

Aktualität: kein Realtime; Neuladen beim Öffnen und per Pull-to-Refresh.

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 (mit/ohne Edition-Hinweis, vorläufiger Eintrag fehlt) Hinweise am Eintrag und am Zielspiel, „Verstanden“ Playwright: Testsetup bearbeitet per admin_resolve_report(…, 'merged', …) → Eintrag zeigt das Zielspiel admin_resolve_report(…, 'merged', ziel): Listeneintrag und Partien zeigen aufs Ziel, merged_into_id/hidden_at gesetzt, result_seen_at setzbar; rejected mit Ziel → report_reject_has_target; Check game_reports_rejected_no_target (Insert und Update) B1, B2, F3, T1, T2
Krit. 6 outcomeOf: rejected ohne Ziel Markierung bleibt nach „Verstanden“ – nach admin_resolve_report(…, 'rejected') bleibt das vorläufige Spiel privat und lesbar B2, F3, T2
Krit. 7 Mapping report_daily_limit Limit-Hinweis, Eingaben bleiben – als authenticated 10 Meldungen über report_missing_game ok, 11. → report_daily_limit, kein vorläufiges Spiel übrig; ebenso direkter Insert als authenticated (prüft, dass der Trigger report_day ausführen darf); Meldungen von gestern (created_at als postgres gesetzt) zählen nicht; report_day mit festen Zeitstempeln (2026-01-15 23:30 UTC → 16.01., 2026-07-15 21:59 UTC → 15.07., 22:00 UTC → 16.07.); origin = 'import' als postgres zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; Insert mit created_at, origin oder status → 42501 B1, B2, F2, T1
Krit. 8 – Status „offen“ – Nutzer setzt status, target_game_id, resolved_at weder per Insert noch Update (42501); Update von result_seen_at bei open → 0 Zeilen B2, T1
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.3 und 4.5: strip_leading_article, normalize_game_title, report_day (mit execute für authenticated), Spalte result_seen_at, Spaltenrechte, Policies, Trigger game_reports_daily_limit (when (new.origin='user'), Sperre), RPCs find_similar_catalog_games (Kandidatenschlüssel über title_search) und report_missing_game; admin_resolve_report per create or replace mit report_reject_has_target und Check game_reports_rejected_no_target (4.4, Spec-F10); Rechte nach 4.1. adopt_report_target entfällt. #2 PR 2b und 2c gemergt; #3 PR 3a gemergt
B2 backend-dev pgTAP 10_game_reports_user.test.sql nach 7.1; Testbeschreibungen in 08 anpassen B1
B3 backend-dev Doku nach 4.7 B1
T1 test-engineer Review der pgTAP-Abdeckung; manueller Paralleltest des Limits dokumentieren B2

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 entfällt (Spec-F10): 13a enthält keine security definer-Funktion mehr; das Umhängen macht ausschließlich der Admin in admin_resolve_report (security invoker, nur postgres/supabase_admin). – –
R3 Schwelle 0,8 ist bei kurzen Titeln streng, bei Reihen großzügig zu wenige/zu viele Kandidaten Klärung 15 verbindlich; Kandidatenschritt verlangt nur eine Bestätigung
R4 Limit-Sperre wirkt nur unter READ COMMITTED unter höherer Isolation 11+ Meldungen möglich PostgREST-Standard; in 3.2 festgehalten; manueller Paralleltest
R5 Ableitung der Edition (Rückfall) ist ungenau unnötige oder fehlende Hinweise, Partien nicht erfasst Empfehlung Zähler editions_cleared (Spec-F6)
R6 Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11 Krit. 4–6 sonst nur in „Meine Meldungen“ Spec-F8
R7 Mehrfachmeldungen desselben Spiels mehr Arbeit für Chris Tageslimit; Mit Verweis auf den vorhandenen Eintrag merged setzen
R8 Kein Realtime Ergebnis erst beim nächsten Laden akzeptiert
R9 pg_trgm bildet Trigramme abhängig von der Datenbank-Locale Ähnlichkeitswerte könnten zwischen Umgebungen abweichen Die Normalisierung liefert a–z0–9; nur der Rückfall lower(btrim(t)) ist locale-abhängig. Akzeptiert (Spec #3 Spec-F7: nur de/en).

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.
Spec-F10 Verweis auf vorhandenen Eintrag: Soll es „abgelehnt mit Verweis“ geben, bei dem der Nutzer selbst wechselt? Entschieden 2026-11-04 durch Chris (Option B): Nein. Verweis = merged (Admin hängt direkt um); rejected nur ohne Ziel. Ändert Spec #2 Spec-F3. Weniger Zustände in der App, keine Nutzer-RPC mit Rechteausweitung. (Nummer F10, weil Spec-F9 oben schon die vorausgewählte Liste betrifft.)

9.3 Entscheidungen innerhalb der Architektur (kein ADR nötig)

  • RPC als Bündelung mit security invoker, Rechte in der Datenbank; keine security definer-Funktion (E2, E3, E5).
  • Normalisierung gehört #3; 13a ergänzt nur den Artikel-Wrapper und den Kandidatenschlüssel über title_search (E4).
  • Kein Realtime, keine Edge Function, keine neuen Pakete.

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.
  • (überholt durch Spec-F10) adopt_report_target: Sperrreihenfolge, is distinct from, Attribut- und Rechte-Tests, kein admin_*-Aufruf; result_seen_at bleibt null.
  • Insert-Policy verlangt provisional_game_id; 23505 → report_provisional_in_use.
  • Gelöschter vorläufiger Eintrag in outcomeOf (rejectedReference, nur Status); Hinweis am Ziel nur bei merged_into_id = target.
  • Indizes aus 2c übernommen; Testdatei 10_…; Tabellenzahl unverändert gegenüber main.
  • 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.

2026-11-04 – Entscheidung Chris (Option B, Spec-F10)

Kein „abgelehnt mit Verweis“ mehr. Geändert: E5, 4.1 (Zeile adopt_report_target entfällt), 4.4 (entfällt, ersetzt durch Änderung an admin_resolve_report und Check), 5.4 (Zustände rejectedWithTarget, rejectedReference, rejectedAdopted entfallen; Hinweis „am Eintrag“, Zusatz Edition), 7.1 Krit. 5, 8.1 B1, 9.1 R2 und R7, 9.2 neue Zeile Spec-F10, 9.3. Die Hinweise zu adopt_report_target in früheren Änderungsprotokoll-Einträgen sind überholt. Spec #2 trägt einen Nachtrag (Spec-F3).