Shuffle/docs/specs/13-fehlendes-spiel-melden.md
cschlaefke f7a7c1e54c docs(catalog): Review-Befunde zur Spec KAT-04 eingearbeitet
Tageslimit-Rechte (report_day), Normalisierung als Wrapper um
search_normalize aus Spec #3, Sperrreihenfolge und Rechte von
adopt_report_target, Indizes aus 2c, Testdatei 10, Edition-Hinweis
mit editions_cleared als Empfehlung.

Refs #13

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 20:07:44 +00:00

52 KiB
Raw Blame History

Spec #13 – KAT-04 Fehlendes Spiel melden

Issue #13 (KAT-04)
Status Entwurf, Review-Befunde eingearbeitet; wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2)
Autor architect
Stand 2026-11-03
Etappe R1.2 Sammlung
Abhängigkeiten Vor 13a gemergt: #2 PR 2b und 2c (Spec #2 E12: game_reports, admin_resolve_report, repoint_game_references, Trigger games_visibility_immutable) und #3 KAT-01 (search_normalize, game_alt_titles, Einstieg aus der leeren Suche, Krit. 6). Außerdem #1. Fachlich zusätzlich #6 SAM-01 und #4 KAT-02 (Orte für „in die Sammlung“ und den Hinweis „am Eintrag“), in Forgejo bisher nicht als Abhängigkeit gesetzt (Spec-F8).
Quellen Issue #13 inkl. Kommentare (u. a. Hinweis aus PR #51 vom 2026-11-03), requirements/20-r1-akzeptanzkriterien.md § KAT-04 und § Querschnitt, requirements/r1-klaerungen.md (6, 10, 12, 15, 20, 23, 26, 27, 28, 36, 44/25, 45, N12, N12a, N15), requirements/backlog.md, docs/specs/2-katalog-daten.md (E10–E14, 3.2–3.4, 4.4, 9.5), Spec #3 (search_normalize, game_alt_titles), docs/datenmodell-r1.md, supabase/migrations/…catalog.sql, …collection.sql, …plays.sql, …catalog_foundation.sql, CLAUDE.md

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 pg_trgm. Normalisierung: search_normalize aus Spec #3 (Eigentümer #3), dazu in 13a nur der dünne Wrapper normalize_game_title (führende Artikel). Haupttitel und alternative Titel (game_alt_titles), nur sichtbare Katalogspiele. Klärung 15 ist eine Datenbankregel. Eine einzige Normalisierung für Suche und Meldung. Die RPC prüft beim Absenden noch einmal; ohne Bestätigung wird nicht gespeichert (Krit. 2).
E5 „Abgelehnt mit Verweis“: Der Nutzer entscheidet selbst über den Wechsel (Spec #2 Spec-F3) per adopt_report_target (security definer, Eigentümer postgres), streng geprüft; nutzt repoint_game_references aus 2b. Krit. 5. Erster Weg, auf dem ein Nutzer repoint_game_references indirekt auslöst; deshalb ausführliche Prüfungen, feste Sperrreihenfolge und Rechte-Tests (4.4).
E6 Ergebnishinweise werden aus vorhandenen Daten abgeleitet und lassen sich wegklicken (result_seen_at, Spec-F5). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft. Fehlt der vorläufige Eintrag (vom Nutzer gelöscht), folgt das Ergebnis nur aus status. Kein Benachrichtigungssystem in R1 (QUE-01 erst R2).
E7 Edition (N15): Empfehlung (Spec-F6) ist ein Zähler game_reports.editions_cleared (Nachtrag in 2b/2c, solange beide noch nicht gemergt sind); Rückfall ist die Ableitung aus den Daten (nur Sammlung, list = 'owned'). Der Zähler ist genau und jetzt günstig; die Ableitung braucht keine Änderung an 2b/2c, ist aber ungenau.
E8 Verlag bleibt Freitext in game_reports.publisher_name. publishers ist nur für den Admin schreibbar (Spec #2 3.2).
E9 Keine neuen Pakete, keine Edge Function. pg_trgm gehört zum Supabase-Postgres-Image (ADR-0002). CLAUDE.md, ADR-0002.
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)

Aus Spec #2 § 3.4 (Spaltennamen werden so vorausgesetzt; weicht 2c ab, passt B1 die Namen an):

  • game_reports mit id, owner_id (FK → profiles, on delete cascade), origin (user/import, Standard user), title (1–200), publisher_name (≤ 200), year (1800–2100), link (≤ 500, ^https?://), bgg_id, provisional_game_id (FK → games, on delete set null), status (open/approved/rejected/merged), target_game_id (FK → games, on delete restrict), resolved_at, created_at, updated_at; Checks zu Status, Ziel und resolved_at.
  • Indizes aus 2c: (owner_id, created_at) (Tageslimit), game_reports_target_game_id_idx (Hinweis am Zieleintrag), eindeutiger Teilindex auf provisional_game_id (E1). 13a legt keine eigenen Indizes an.
  • RLS: Lesen nur eigene, kein Insert/Update/Delete für authenticated.
  • admin_resolve_report(p_report, p_status, p_target): bei approved/merged Umhängen per repoint_game_references, am vorläufigen Eintrag merged_into_id und hidden_at; bei rejected nur der Verweis (oder nichts).
  • Trigger games_visibility_immutable: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14).

Aus Spec #3: search_normalize(text) und die Tabelle game_alt_titles (alternative Titel je Spiel mit Sprache). Der Fremdschlüssel game_alt_titles.game_id und seine Einordnung im Fremdschlüssel-Wächter gehören zu #3.

3.2 Änderungen in 13a (Migration supabase/migrations/<ts>_game_reports_user.sql)

Nur additiv; der Zeitstempel liegt nach den Migrationen aus 2c und #3. Rechte-Regel aus Spec #2 § 3.2: jede neue Funktion zuerst revoke execute … from public, anon, authenticated, service_role, danach nur die Grants aus 4.1.

create extension if not exists pg_trgm with schema extensions;

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) kommt editions_cleared integer not null default 0 schon in 2c (Nachtrag Spec #2), nicht in 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); B2 passt 08 an.

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.
  • 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)

Zuständigkeit und Rechte (gleicher Satz in Spec #3): Spec #3 besitzt search_normalize (u. a. unaccent, lower, Entfernen von Sonderzeichen) und gibt sie per grant execute … to anon, authenticated frei, weil generierte Spalten mit den Rechten der einfügenden Rolle berechnet werden; ein Entzug würde jeden Insert in games brechen. unaccent mit festem Wörterbuch als immutable zu deklarieren ist eine verbreitete Konvention, keine Garantie von PostgreSQL. #13 ändert search_normalize nicht und legt keine zweite Normalisierung an.

In 13a neu (beide language sql, immutable, parallel safe, search_path = '', grant execute … to anon, authenticated):

  • public.strip_leading_article(t text) returns text: entfernt einmal ein führendes Artikelwort (Deutsch der, die, das, den, dem, des, ein, eine, einen, einem, einer, eines; Englisch the, a, an) samt folgendem Leerzeichen; erwartet bereits normalisierten Text (Kleinbuchstaben, ein Leerzeichen als Trenner).
  • public.normalize_game_title(t text) returns text = coalesce(nullif(public.strip_leading_article(public.search_normalize(t)), ''), nullif(public.search_normalize(t), ''), lower(btrim(t))). Der letzte Rückfall greift bei Titeln, die nach search_normalize leer sind (z. B. nur nicht-lateinische Schriftzeichen oder nur Sonderzeichen).

Beispiele (pgTAP; das genaue Ergebnis von search_normalize legt #3 fest): „Die Siedler von Catan“ und „siedler von catan“ ergeben denselben Wert; „CATAN!“ = „Catan“; „The Crew“ = „Crew“; „Das“ bleibt nicht leer; „Mölkky“ = „Molkky“ (Faltung durch unaccent); ein Titel nur aus japanischen Schriftzeichen → lower(btrim(t)).

Regel „sehr ähnlich“ zwischen Meldung (Titel t, Jahr y) und Katalogspiel g mit Haupttitel und allen alternativen Titeln aus game_alt_titles (Kandidatentitel c, verpflichtend):

  • A (gleicher Titel): normalize_game_title(c) = normalize_game_title(t) für mindestens ein c, unabhängig vom Jahr.
  • B (Trigramm): extensions.similarity(normalize_game_title(c), normalize_game_title(t)) >= 0.8 für mindestens ein c und abs(g.year - y) <= 1. Fehlt y oder g.year, gilt B ohne Jahresbedingung (Spec-F2).
  • 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 anon, authenticated Normalisierung (3.3)
report_day(timestamptz) Funktion, sql, immutable authenticated Berliner Kalendertag (3.2)
find_similar_catalog_games(p_title text, p_year integer) RPC, security invoker authenticated Ähnliche Katalogspiele (Krit. 2)
report_missing_game(...) RPC, security invoker authenticated Meldung + vorläufiger Eintrag + optional Listeneintrag (Krit. 1, 2, 3, 7, 8)
adopt_report_target(p_report uuid) RPC, security definer, Eigentümer postgres authenticated Wechsel auf den Zieleintrag (Krit. 5)
game_reports_daily_limit() Trigger-Funktion, security invoker – Tageslimit (Krit. 7)
Policies game_reports_insert_own, game_reports_update_seen, Spaltenrechte RLS – 3.2

Jede Funktion: zuerst revoke execute … from public, anon, authenticated, service_role, dann nur die Grants dieser Tabelle. pgTAP prüft mit has_function_privilege für alle vier Rollen (public über eine Rolle ohne eigene Rechte, anon, authenticated, service_role).

Fehler: raise exception '<code>' using errcode = 'P0001'; die App bildet den Code auf einen Domänenfehler ab (5.3).

4.2 RPC public.report_missing_game

report_missing_game(
  p_title              text,
  p_publisher_name     text    default null,
  p_year               integer default null,
  p_link               text    default null,
  p_list               text    default null,   -- 'owned' | 'wishlist' | 'shopping' | null
  p_confirmed_distinct boolean default false
) returns jsonb   -- {"report_id": uuid, "provisional_game_id": uuid, "collection_item_id": uuid | null}

language plpgsql, security invoker, search_path = ''. p_year ist bewusst integer und wird selbst geprüft (ein smallint-Parameter würde bei großen Werten schon beim Aufruf mit 22003 scheitern, ohne verständlichen Code).

Ablauf (eine Transaktion; jeder Fehler rollt alles zurück):

  1. auth.uid() ist null → not_authenticated.
  2. btrim auf Titel, Verlag, Link; leere Zeichenketten werden null.
  3. Prüfungen in dieser Reihenfolge:
Prüfung Fehler
Titel leer report_title_required
Titel > 200 Zeichen report_title_too_long
Verlag > 200 Zeichen report_publisher_too_long
Jahr außerhalb 1800–2100 report_year_invalid
Link > 500 Zeichen oder nicht ^https?:// report_link_invalid
p_list nicht in owned, wishlist, shopping, null report_list_invalid
Vorprüfung Tageslimit: schon 10 eigene Nutzer-Meldungen heute (report_day) report_daily_limit
p_confirmed_distinct = false und find_similar_catalog_games liefert ≥ 1 Treffer report_similar_exists

Die Limit-Vorprüfung steht vor der Ähnlichkeitsprüfung, damit der Nutzer nicht erst Kandidaten bestätigt und dann am Limit scheitert. Maßgeblich bleibt der Trigger (Sperre, Schritt 5). Die übrigen Prüfungen spiegeln die Checks aus 2c für gezielte Feldfehler; Tabellen-Checks bleiben die Grenze (23514 → „unbekannter Fehler“). 4. insert into public.games (owner_id, title, year, visibility) values (auth.uid(), title, year, 'private') returning id (keine id). 5. insert into public.game_reports (owner_id, title, publisher_name, year, link, provisional_game_id) … returning id. Hier greift der Limit-Trigger; 23505 aus dem Teilindex → report_provisional_in_use. 6. Wenn p_list gesetzt: insert into public.collection_items (owner_id, game_id, list) values (auth.uid(), provisional_id, p_list) returning id. Keine Edition. 7. Rückgabe als jsonb.

Aufrufreihenfolge in der App: erst find_similar_catalog_games, dann report_missing_game mit p_confirmed_distinct = true nach Bestätigung. Ein Client, der direkt true sendet, umgeht nur den UX-Schritt (E3).

4.3 RPC public.find_similar_catalog_games

find_similar_catalog_games(p_title text, p_year integer default null)
returns table (
  game_id      uuid,
  title        text,       -- Haupttitel
  matched_title text,      -- Titel, der getroffen hat (Haupt- oder alternativer Titel)
  year         smallint,
  publishers   text[],     -- sortiert
  match_kind   text,       -- 'same_title' | 'similar_title'
  similarity   real        -- 1.0 bei 'same_title'
)

language sql, stable, security invoker, search_path = ''. Liest catalog_games, game_alt_titles, game_publishers, publishers. Regel nach 3.3. Sortierung: same_title zuerst, dann similarity absteigend, dann title; höchstens 5 Treffer. Leerer Titel oder Titel > 200 Zeichen → leere Menge (kein Fehler; report_missing_game meldet die Länge selbst). Nur authenticated.

4.4 RPC public.adopt_report_target

adopt_report_target(p_report uuid) returns jsonb   -- Zähler aus repoint_game_references

language plpgsql, security definer, Eigentümer postgres, set search_path = '' (als Funktionseinstellung). Nötig, weil Nutzer merged_into_id/hidden_at nicht schreiben und repoint_game_references nicht ausführen dürfen. Innerhalb der Funktion ist current_user = postgres; deshalb darf sie keine admin_*-Funktion aufrufen (deren Rollenprüfung würde sonst bestehen), weder jetzt noch in späteren Änderungen. pgTAP prüft prosecdef = true, proowner = postgres und proconfig mit search_path="".

Sperrreihenfolge (wie im 2c-Entwurf von admin_resolve_report, gegen Verklemmungen): zuerst die Meldung select … for update, danach beide Spiele select … from games where id in (provisional, target) order by id for update.

Prüfung Fehler
auth.uid() ist null not_authenticated
Meldung existiert nicht oder gehört nicht auth.uid() (gleicher Fehler) report_not_found
status <> 'rejected' oder target_game_id is null report_not_adoptable
provisional_game_id is null (z. B. vom Nutzer gelöscht) report_no_provisional
vorläufiger Eintrag ist nicht private mit owner_id = auth.uid() report_not_found
vorläufiger Eintrag ist schon zusammengeführt report_already_adopted
Ziel ist kein sichtbares Katalogspiel (visibility = 'catalog' and hidden_at is null and merged_into_id is null) report_target_not_listed
Verweise auf den vorläufigen Eintrag mit fremdem oder fehlendem Eigentümer: collection_items.owner_id, favorite_games.profile_id, plays.owner_id, score_sheets.owner_id jeweils is distinct from auth.uid() (null zählt als fremd) report_foreign_references

Ablauf: repoint_game_references(provisional, target) (privat → Katalog, keine Varianten kopiert, gleichnamige abgebildet, sonst null; Spec #2 RB1/N15), danach am vorläufigen Eintrag merged_into_id = target, hidden_at = now(). Die Meldung bleibt rejected. result_seen_at bleibt unverändert (null), damit der Hinweis „Dein Eintrag zeigt jetzt auf …“ (inkl. Edition-Hinweis) am Zieleintrag einmal erscheint, bis der Nutzer „Verstanden“ wählt (5.4). Bei Spec-F6 = Zähler setzt die Funktion zusätzlich editions_cleared. Rückgabe: Zähler aus repoint_game_references.

Wirkung auf Partien: plays.game_id zeigt danach auf das Katalogspiel; Mitspieler (play_players mit profile_id) sehen in der Partie dann das Katalogspiel statt eines für sie unsichtbaren privaten Spiels. Das ist gewollt und gilt ebenso für admin_resolve_report.

4.5 Ergebnishinweis wegklicken

Kein RPC. Die App sendet PATCH /rest/v1/game_reports?id=eq.<id> mit genau {'result_seen_at': <aktuelle Zeit, ISO-8601 UTC>} (PostgREST setzt Werte, keine Ausdrücke wie now()). Die Client-Zeit ist unkritisch (steuert nur die eigene Anzeige). Bei offener oder fremder Meldung ändert sich keine Zeile.

4.6 Lesen für die App

  • Meine Meldungen: game_reports mit id, origin, title, publisher_name, year, link, status, target_game_id, provisional_game_id, result_seen_at, created_at, resolved_at (bei Spec-F6 = Zähler zusätzlich editions_cleared), created_at desc, je 20. Eingebettet: provisional:games!provisional_game_id(id, merged_into_id, hidden_at, game_variants(id)), target:games!target_game_id(id, title, year). Fremdschlüsselnamen prüft flutter-dev gegen die 2c-Migration.
  • Hinweis zu einem Spiel (5.4): game_reports mit or=(provisional_game_id.eq.<id>,target_game_id.eq.<id>) (Indizes aus 2c) plus eingebettetem provisional.
  • Edition, nur bei Rückfall Ableitung: collection_items des Nutzers zum Zielspiel mit list = 'owned' (variant_id) und provisional.game_variants.

4.7 Doku

  • docs/datenmodell-r1.md: result_seen_at, Spaltenrechte, Policies, Trigger, Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes; Satz zur Zuständigkeit für search_normalize (3.3).
  • docs/katalog-pflege.md, Abschnitt „Meldungen bearbeiten“ (2c) ergänzen: Nutzer-Meldungen haben einen vorläufigen Eintrag (sofern nicht gelöscht); „abgelehnt mit Verweis“ lässt dem Nutzer die Wahl (adopt_report_target); Status nie von Hand setzen, nur über admin_resolve_report, weil die App den Hinweis aus Status und merged_into_id ableitet.
  • supabase/README.md: neue RPCs und Rechte.

5. Frontend

5.1 Struktur

Neues Feature app/lib/features/game_reports/ (Feature-first, Abhängigkeiten nach innen):

features/game_reports/
  domain/
    game_report.dart              freezed: GameReport
    game_report_status.dart       enum GameReportStatus { open, approved, rejected, merged }
    game_report_origin.dart       enum GameReportOrigin { user, import }
    report_outcome.dart           sealed ReportOutcome (5.4) + reine Funktion outcomeOf(...)
    game_report_draft.dart        freezed: Eingaben + Validierung
    similar_game.dart             freezed: SimilarGame (gameId, title, matchedTitle, year, publishers, matchKind)
    game_report_failure.dart      sealed: dailyLimit, similarExists, validation(field, code), notAdoptable,
                                  noProvisional, alreadyAdopted, targetNotListed, offline, unknown
    game_report_repository.dart   abstrakte Schnittstelle
  data/
    supabase_game_report_repository.dart   RPC-Aufrufe und Abfragen (4.2–4.6), explizite Parameter-Maps
    game_report_dto.dart                   nur fromJson; kein toJson für Schreibzugriffe
    game_report_failure_mapper.dart        P0001-Codes → GameReportFailure
  presentation/
    report_form/  similar_games/  my_reports/  report_detail/
    widgets/report_outcome_notice.dart     wiederverwendbar (auch für #4/#6/#11)
    providers/                             Repository-, Listen- und Hinweis-Provider (Riverpod, generiert)
    game_report_routes.dart

Die Domäne importiert weder Flutter noch Supabase; Validierung in GameReportDraft, nicht im Widget.

5.2 Routen (go_router)

Route Screen Hinweis
/reports/new?title=<suchbegriff> ReportFormScreen Einstieg aus der leeren Trefferliste von KAT-01 (#3 Krit. 6); Anmeldung nötig (Redirect /login?from=…, Spec #1)
/reports MyReportsScreen eigene Meldungen
/reports/:reportId ReportDetailScreen Status, Angaben, Hinweis, Aktionen; fremde/unbekannte ID → „nicht gefunden“

Konstanten in AppRoutes. Weitere Einstiege zu /reports (Einstellungen, Dashboard) stimmt ui-designer mit Chris ab.

5.3 Melden (Krit. 1, 2, 3, 7, 8)

ReportFormController (Riverpod-Notifier, Zustände als freezed-Union):

Zustand UI
editing(draft, fieldErrors) Titel (Pflicht), Verlag, Jahr, Link (optional); „Gleich aufnehmen in“: Sammlung / Wunschliste / Einkaufsliste / nicht aufnehmen (Standard Sammlung, Spec-F9)
checking find_similar_catalog_games läuft; Ladeanzeige, Felder gesperrt
similarFound(draft, candidates) Kandidaten (Titel, getroffener Titel falls alternativ, Jahr, Verlage, GameCover). „Das ist mein Spiel“ → Detailseite des Katalogspiels (#4), keine Meldung. Ausdrückliche Bestätigung: Checkbox „Mein Spiel ist keines davon“ + „Trotzdem melden“ (erst mit Haken aktiv)
submitting report_missing_game läuft
submitted(result) Bestätigung (Krit. 1): „Danke, deine Meldung ist eingegangen. Wir prüfen sie von Hand; bis dahin kannst du das Spiel als vorläufigen Eintrag nutzen.“ ggf. Liste; Aktionen „Zur Meldung“, „Zur Sammlung“
failure(draft, failure) Eingaben bleiben erhalten. dailyLimit → „Du hast heute schon 10 Spiele gemeldet. Morgen geht es wieder.“ (Krit. 7); similarExists → zurück zu similarFound; Feldfehler am Feld; offline → „Melden braucht eine Verbindung“

Ohne Treffer geht checking direkt in submitting (p_confirmed_distinct = false), mit Treffern nach Bestätigung mit true. Lokale Vorprüfung derselben Feldregeln wie 4.2; das Limit prüft nur der Server (die Vorprüfung in der RPC meldet es vor dem Kandidatenschritt, wenn der Nutzer ohne Treffer sendet; bei Treffern spätestens beim Absenden).

Krit. 3 nachträglich: Bei „nicht aufnehmen“ kann der Nutzer den vorläufigen Eintrag später auf der Meldungsdetailseite zu einer Liste hinzufügen, über die Funktion aus SAM-01/SAM-04/SAM-05 (#6/#8/#9) bzw. einen Insert in collection_items mit Spaltenliste owner_id, game_id, list (Spec-F8).

5.4 Ergebnishinweise (Krit. 4, 5, 6, 8)

outcomeOf(report, provisional?, ownedItemOnTarget?) ist eine reine Funktion (Unit-Tests je Fall). Fehlt der vorläufige Eintrag (provisional_game_id null, vom Nutzer gelöscht), folgt das Ergebnis nur aus status und target_game_id; Aktion „wechseln“ und Edition-Ableitung entfallen.

Fall Erkennung Hinweis (Kurzform, ARB) Aktionen Wo sichtbar
pending (Krit. 8) status = open „Gemeldet am …, wird geprüft“ – Meldung; vorläufiger Eintrag (Badge „vorläufig“)
approved (Krit. 4) status = approved „Dein gemeldetes Spiel ist jetzt im Katalog. Deine Angaben wurden übernommen.“ „Zum Spiel“, „Verstanden“ Meldung; Eintrag am Zielspiel, nur wenn provisional.merged_into_id = target
merged (Krit. 5) status = merged „Dein Spiel gab es schon im Katalog als „{title}“. Dein Eintrag zeigt jetzt dorthin.“ „Zum Spiel“, „Verstanden“ wie approved
rejectedWithTarget (Krit. 5) status = rejected, Ziel gesetzt, vorläufiger Eintrag vorhanden und nicht zusammengeführt „Dein Spiel gibt es bereits als „{title}“.“ „Zu „{title}“ wechseln“ (adopt_report_target, Bestätigungsdialog „Deine Angaben werden auf den Katalogeintrag übertragen.“), „Vorläufig behalten“ Meldung; nur am vorläufigen Eintrag, nicht am Ziel
rejectedReference status = rejected, Ziel gesetzt, vorläufiger Eintrag fehlt „Dein Spiel gibt es bereits als „{title}“.“ „Zum Spiel“ nur Meldung
rejectedAdopted status = rejected, Ziel gesetzt, provisional.merged_into_id = target wie merged „Zum Spiel“, „Verstanden“ Meldung; Eintrag am Zielspiel bis „Verstanden“ (result_seen_at bleibt nach dem Wechsel null, 4.4)
rejected (Krit. 6) status = rejected, kein Ziel dauerhafte Markierung „Abgelehnt“ + „Dieses Spiel wird nicht in den Katalog aufgenommen. Dein Eintrag bleibt privat für dich.“ „Verstanden“ (blendet nur den Text aus) Meldung; vorläufiger Eintrag

Zusatz Edition (E7, N15) bei approved, merged, rejectedAdopted: „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“

  • Bei Spec-F6 = Zähler (Empfehlung): wenn editions_cleared > 0. Der Text nennt Sammlung und Partien („in deiner Sammlung oder deinen Partien“).
  • Rückfall Ableitung: wenn der vorläufige Eintrag mindestens eine eigene Variante hat und der Sammlungseintrag am Ziel mit list = 'owned' variant_id = null hat. Wunsch- und Einkaufsliste haben nach Klärung 28 nie eine Edition und werden nicht geprüft; Partien werden nicht einzeln geprüft (Grenze der Ableitung).
  • Aktion „Edition wählen“ → Bearbeitung des Sammlungseintrags (KAT-03 #12 / SAM-02 #7), sobald vorhanden.

„Verstanden“ setzt result_seen_at (4.5); danach kein Hinweis mehr am Eintrag, in „Meine Meldungen“ bleibt der Status sichtbar.

Hinweis „am Eintrag“: reportNoticeForGameProvider(gameId) liefert den passenden Fall: am vorläufigen Eintrag (provisional_game_id = gameId) die Fälle pending, rejectedWithTarget, rejected; am Zielspiel (target_game_id = gameId, result_seen_at is null) nur approved, merged, rejectedAdopted, und nur wenn provisional.merged_into_id = gameId. ReportOutcomeNotice (kompakt/ausführlich) wird eingebaut in 13b in „Meine Meldungen“ und die Meldungsdetailseite, in #4/#6/#11 soweit gemergt, sonst übernehmen diese Stories den Einbau (Spec-F8, 9.4). Vorläufige Einträge zeigen ein Badge „vorläufig“ (Text) und den GameCover-Platzhalter.

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

5.5 Meine Meldungen

MyReportsController (AsyncNotifier mit Paging, 20 je Seite): Laden, Leer („Du hast noch keine Spiele gemeldet“ + Weg zur Suche), Fehler mit „Erneut versuchen“, Daten. Je Zeile Titel, Jahr, Datum, Status-Badge, ggf. kompakter Hinweis; Import-Meldungen mit Kennzeichen „aus BGG-Import“ (Text liefert #25).

5.6 l10n (Auszug, de/en)

Präfix gameReport…: Felder und Fehler (gameReportTitleRequired, gameReportTitleTooLong, gameReportPublisherTooLong, gameReportYearInvalid, gameReportLinkInvalid), Ähnlichkeit (gameReportSimilarHeading, gameReportSimilarIsMine, gameReportSimilarConfirmDistinct, gameReportSubmitAnyway), Listenwahl, Bestätigung, gameReportDailyLimit, Offline, Status-Badges, gameReportProvisionalBadge, Hinweise aus 5.4 inkl. Edition, Aktionen, Leer-/Fehlerzustand, Semantics-Labels; Platzhalter {title}, Datum über intl.

6. Plattform-Besonderheiten

  • iOS, Android, Web: gleiches Verhalten; Link-Feld TextInputType.url, Jahr mit Ziffern-Tastatur; der Link wird nur als Text angezeigt, nicht geöffnet.
  • Web: teilbare URLs; Bestätigungsschritt per Tastatur bedienbar; breites Layout (Formular und Kandidaten nebeneinander ab ≥ 840 dp, ui-designer).
  • Offline: Melden braucht eine Verbindung (wie SAM-01 Krit. 3); Hinweis statt Fehler, Eingaben bleiben. Keine Offline-Warteschlange.
  • Push, Kamera, Dateien: nicht betroffen.

7. Teststrategie

Werkzeuge: pgTAP (supabase test db), flutter_test + mocktail, Playwright (Web-E2E). Testnamen enthalten „KAT-04 Kriterium n“.

Neue pgTAP-Datei supabase/tests/database/10_game_reports_user.test.sql (08 gehört 2c, 09 gehört #3). Fixtures mit festen Test-UUIDs, Nutzer A und B, unabhängig vom Startbestand. Einfügungen „als Nutzer“ laufen wirklich als Rolle authenticated mit gesetztem request.jwt.claims, nicht als postgres.

7.1 Abdeckungsmatrix

AK Unit Widget Integration/E2E RLS/pgTAP Task
Krit. 1 GameReportDraft: Titel Pflicht (auch nur Leerzeichen), Längen, Jahr, Link; Fehler-Mapping Feldfehler, Bestätigung, Listenwahl Playwright: leere Suche → „Spiel melden“ → Titel vorbelegt → Bestätigung report_missing_game als authenticated: Meldung (open, user) und vorläufiges Spiel (private, nur Titel/Jahr); Trimmen; alle Validierungsfehler; Jahr 99999 → report_year_invalid (kein 22003); anon → 42501 B1, B2, F2, T1, T2
Krit. 2 Controller: Treffer → similarFound; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; similarExists → zurück Kandidatenliste, Checkbox aktiviert Button, Tastatur, Semantics Playwright: Meldung „Catan“ zeigt „CATAN“ strip_leading_article/normalize_game_title: Artikel de/en nur führend, Großbuchstaben, Sonderzeichen, Umlaute über search_normalize, nur Artikel, nicht-lateinischer Titel (Rückfall); find_similar_catalog_games: gleicher normalisierter Titel bei beliebigem Jahr; Trigramm ≥ 0,8 bei ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; fehlendes Jahr nach Spec-F2; Treffer über alternativen Titel (matched_title); ausgeblendete, zusammengeführte, private Spiele nicht; Titel > 200 → leere Menge; höchstens 5, Sortierung; report_similar_exists ohne, Speichern mit Bestätigung B1, B2, F2, U1, T1, T2
Krit. 3 – Bestätigung zeigt Liste; Badge „vorläufig“ Playwright: gemeldetes Spiel in der Wunschliste (sofern #6/#8 da) p_list legt collection_items an; B sieht nichts von A; anon nichts; Rollback bei Fehler im Listeneintrag B1, B2, F2, F3
Krit. 4 outcomeOf: approved mit/ohne Edition; vorläufiger Eintrag fehlt Hinweis de/en, „Verstanden“ Playwright: Testsetup bearbeitet per admin_resolve_report(…, 'approved', …) → Hinweis am Eintrag nach admin_resolve_report: Sammlungseintrag von A am Ziel mit Liste/Notiz/Kaufdatum; Edition ohne gleichnamige Variante → null; private Variante für A lesbar; bei Zähler editions_cleared korrekt; result_seen_at setzbar B2, F3, T1, T2
Krit. 5 outcomeOf: merged, rejectedWithTarget, rejectedReference, rejectedAdopted Hinweise, „wechseln“ mit Dialog, Hinweis nur am richtigen Eintrag Playwright: abgelehnt mit Verweis → wechseln → Eintrag zeigt Zielspiel adopt_report_target: hängt Sammlung und Partien um, setzt merged_into_id/hidden_at, Status bleibt rejected, result_seen_at bleibt null; Fehler report_not_found (B, unbekannt), report_not_adoptable, report_no_provisional (gelöschter Eintrag), report_already_adopted, report_target_not_listed, report_foreign_references (fremder Eigentümer und plays.owner_id = null, Fixture als postgres); prosecdef, proowner = postgres, proconfig search_path=""; has_function_privilege für public, anon, authenticated, service_role B1, B2, F3, T1, T2
Krit. 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: 08_game_reports.test.sql (2c) erwartet „kein Insert und kein Update für authenticated“; B2 passt beide Erwartungen an (Insert der erlaubten Spalten, Update nur result_seen_at an eigenen bearbeiteten Meldungen, alles andere weiter 42501). Tabellenzahl in 00_schema.test.sql unverändert gegenüber main.

8. Aufgaben

8.1 PR 13a – Backend (Refs #13), Branch feature/13a-meldungen-backend

# Agent Aufgabe abhängig von
B1 backend-dev Migration nach 3.2–4.5: pg_trgm, strip_leading_article, normalize_game_title, report_day (mit execute für authenticated), Spalte result_seen_at, Spaltenrechte, Policies, Trigger game_reports_daily_limit (when (new.origin = 'user'), Sperre), RPCs find_similar_catalog_games, report_missing_game, adopt_report_target (Sperrreihenfolge, is distinct from); Rechte nach 4.1 #2 PR 2c und #3 gemergt
B2 backend-dev pgTAP 10_game_reports_user.test.sql nach 7.1; 08 an Insert- und Update-Rechte anpassen B1
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 aus #3, l10n F1, U1; gegen Backend: B1
F3 flutter-dev „Meine Meldungen“, Detail, Hinweise (5.4), Einbau in #4/#6/#11 soweit gemergt F1, F2
T2 test-engineer Widget-Tests und Playwright nach 7.1 F2, F3, B1

8.3 Reihenfolge und Parallelität

Vorbedingung: #2 PR 2b → 2c gemergt; #3 KAT-01 gemergt
13a:  B1 → B2 → B3, T1
13b:  U1 ─┐
      F1 ─┴→ F2 (Mock-Repository, danach gegen B1) → F3 → T2
13b wird nach 13a gemergt.

F1 und U1 starten nach Freigabe der Spec, parallel zu B1. Merge nur durch Chris.

9. Risiken & offene Fragen

9.1 Risiken

# Risiko Auswirkung Gegenmaßnahme
R1 2b/2c/#3 weichen von den beschriebenen Namen oder Rückgaben ab 13a passt nicht B1 liest die gemergten Migrationen zuerst; Abweichungen im PR nennen
R2 adopt_report_target ist security definer und erreicht repoint_game_references Fehler würden fremde Daten berühren Prüfungen 4.4 inkl. fremder Verweise mit is distinct from, feste Sperrreihenfolge, kein Aufruf von admin_*, Rechte- und Attribut-Tests
R3 Schwelle 0,8 ist bei kurzen Titeln streng, bei Reihen großzügig zu wenige/zu viele Kandidaten Klärung 15 verbindlich; Kandidatenschritt verlangt nur eine Bestätigung
R4 Limit-Sperre wirkt nur unter READ COMMITTED unter höherer Isolation 11+ Meldungen möglich PostgREST-Standard; in 3.2 festgehalten; manueller Paralleltest
R5 Ableitung der Edition (Rückfall) ist ungenau unnötige oder fehlende Hinweise, Partien nicht erfasst Empfehlung Zähler editions_cleared (Spec-F6)
R6 Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11 Krit. 4–6 sonst nur in „Meine Meldungen“ Spec-F8
R7 Mehrfachmeldungen desselben Spiels mehr Arbeit für Chris Tageslimit; Ablehnen mit Verweis
R8 Kein Realtime Ergebnis erst beim nächsten Laden akzeptiert

9.2 Offene Fragen an Chris (Spec-F1 bis Spec-F9)

Keine Frage blockiert die Umsetzung: Ohne andere Entscheidung gilt mit der Freigabe die Empfehlung.

# 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? Neu bewertet: Zähler game_reports.editions_cleared integer not null default 0 (Nachtrag in Spec #2: 2b liefert in repoint_game_references die Zahl der geleerten Verweise in Sammlung und Partien, wie es variants_cleared bei admin_merge_games schon für Verweise tut; 2c legt die Spalte an und admin_resolve_report setzt sie; 13a setzt sie in adopt_report_target). Rückfall: Ableitung (5.4). Genau, erfasst auch Partien, und jetzt günstig, weil 2b und 2c noch nicht gemergt sind; später wäre ein create or replace der Admin-Funktionen nötig. Die Ableitung kommt ohne Änderung an Spec #2 aus, ist aber ungenau (R5).
Spec-F7 Krit. 2: auch eigene frühere Meldungen als ähnlich zeigen? Nein in R1. Kriterium nennt nur Katalogeinträge.
Spec-F8 Abhängigkeiten: Soll #13 zusätzlich von #4 und #6 abhängen? Ja (beide liegen ohnehin vorher); #11 übernimmt den Einbau selbst. Sonst Zwischenlösungen, die später ersetzt werden.
Spec-F9 Krit. 3: vorausgewählte Liste? Sammlung. Häufigster Fall laut Story.

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

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

9.4 Hinweise für andere Stories

  • Spec #2 (2b/2c), falls Spec-F6 = Zähler: Nachtrag vor dem Bau (Spalte editions_cleared, Zähler der geleerten Verweise, Setzen in admin_resolve_report).
  • KAT-01 (#3): derselbe Satz zu Zuständigkeit und Rechten von search_normalize (3.3); Einstieg /reports/new?title=<suchbegriff> (Krit. 6); game_alt_titles wird von find_similar_catalog_games gelesen.
  • 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).
  • Bei Spec-F6 = Zähler: Nachtrag in Spec #2 vor dem Bau von 2b/2c.
  • Bei Spec-F8 = Ja: Forgejo-Abhängigkeiten #13 → #4, #6 setzen.
  • Spec #3 erhält denselben Satz zu Zuständigkeit und Rechten von search_normalize.

10. Änderungsprotokoll

2026-11-03 – Entwurf

Erste Fassung durch architect, inkl. Hinweis aus PR #51 (Klärung N15). Nachtrag am selben Tag: Trigramm-Index gestrichen, Abweichung von Spec #2 § 3.4 vermerkt, report_day für testbare Tagesgrenze.

2026-11-03 – Review (code-reviewer)

  • B1: report_day mit execute für authenticated (sonst 42501 im Trigger); Trigger mit when (new.origin = 'user'); Voraussetzung READ COMMITTED; pgTAP fügt als authenticated ein.
  • B2/B3: Normalisierung gehört Spec #3 (search_normalize); 13a nur strip_leading_article und Wrapper normalize_game_title; translate()-Faltung gestrichen; Regeln A und B auf normalize_game_title; alternative Titel verpflichtend; alle „falls #3“-Bedingungen entfernt (#3 vor 13a); Satz zur Zuständigkeit/Rechten; unaccent-Immutability als Konvention.
  • adopt_report_target: Sperrreihenfolge, is distinct from, Attribut- und Rechte-Tests, kein admin_*-Aufruf; result_seen_at bleibt null (Hinweis am Ziel nach Wechsel).
  • Insert-Policy verlangt provisional_game_id; 23505 → report_provisional_in_use.
  • Gelöschter vorläufiger Eintrag in outcomeOf (rejectedReference, nur Status); Hinweis am Ziel nur bei merged_into_id = target.
  • Indizes aus 2c übernommen; Testdatei 10_…; Tabellenzahl unverändert gegenüber main.
  • Edition: Ableitung nur list = 'owned'; Spec-F6 neu bewertet, Empfehlung Zähler editions_cleared.
  • p_year integer, Titel > 200 → leere Menge, Limit-Vorprüfung vor der Ähnlichkeit, Großbuchstaben-Testfall, Partien/Mitspieler, Hinweis #14 präzisiert.