Shuffle/docs/specs/13-fehlendes-spiel-melden.md
cschlaefke ece8e3eef2 docs(catalog): Spec KAT-04 präzisiert
Normalisierung mit Akzent-Faltung, Trigramm-Index gestrichen,
Abweichung von Spec #2 3.4 vermerkt, testbare Tagesgrenze.

Refs #13

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

57 KiB
Raw Blame History

Spec #13 – KAT-04 Fehlendes Spiel melden

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

Namenskonvention: „Spec-F1“ bis „Spec-F9“ sind die Fragen an Chris aus § 9.2. „E1“ usw. sind Kernentscheidungen dieser Spec, „#2 E12“ verweist auf Spec #2. „B1“, „F1“, „U1“, „T1“ sind Aufgaben (§ 8).

1. Ziel

Ein Nutzer, der ein Spiel im Katalog nicht findet, meldet es mit wenigen Angaben und kann es sofort als vorläufigen privaten Eintrag nutzen (Entscheidung O5). Der Admin (Chris) prüft jede Meldung manuell im Studio (Klärungen 27, 44; Werkzeug aus #2 PR 2c). Das Ergebnis sieht der Nutzer als Hinweis in der App, nie als E-Mail (Klärungen 26, 45).

Diese Spec baut auf der Meldungstabelle game_reports und der Funktion admin_resolve_report aus Spec #2 § 3.4 (PR 2c) auf. Sie ergänzt nur, was der Nutzer braucht: Anlegen mit Tageslimit, Ähnlichkeitsprüfung, vorläufigen Eintrag, Ergebnishinweise und das Wechseln auf den Zieleintrag.

Nicht im Scope (aus dem Issue): öffentliche Anzeige gemeldeter Spiele, Bild-Upload, E-Mail/Push, Admin-Oberfläche in der App, automatische Import-Meldungen (SAM-08 #25). Außerdem nicht in dieser Spec: Bearbeiten weiterer Felder des vorläufigen Eintrags (Spielerzahl, Dauer usw.), Zurückziehen einer Meldung, Auswahl vorläufiger Spiele in Partien (gehört zu PAR-01 #15 Krit. 3).

Kernentscheidungen (Kurzfassung)

# Entscheidung Begründung
E1 Jede Nutzer-Meldung legt genau einen vorläufigen privaten Eintrag an (1:1, eindeutiger Index auf game_reports.provisional_game_id). Er bekommt nur title und year aus der Meldung. Der Hinweis zum Ergebnis braucht einen festen Ort, und Krit. 3 verlangt die sofortige Nutzung. Das entspricht dem Backlog („eine Meldung je vorläufigem Eintrag“, N12) und dem Import (SAM-08 Krit. 4). Mit nur Titel und Jahr gibt es außer der Edition nichts, was beim Ersetzen verloren gehen kann (Krit. 4, N15).
E2 Anlegen über eine RPC report_missing_game (security invoker), die in einer Transaktion Ähnlichkeit prüft, den vorläufigen Eintrag, die Meldung und optional den Listeneintrag anlegt. Getrennte Client-Aufrufe ließen verwaiste vorläufige Spiele zurück, wenn das Tageslimit die Meldung ablehnt. security invoker heißt: RLS, Spaltenrechte und Trigger gelten weiter, die RPC ist nur Bündelung, keine Rechte-Erweiterung.
E3 Schutz liegt in der Datenbank, nicht in der RPC: Spaltenrechte auf game_reports (kein id, created_at, origin, status, target_game_id, resolved_at, bgg_id), Insert-Policy und ein Limit-Trigger mit Sperre je Nutzer. Ein direkter REST-Insert umgeht nur den UX-Schritt der Ähnlichkeitsprüfung, nicht Limit oder Rechte. Weil created_at nicht schreibbar ist, lässt sich das Tageslimit nicht durch ein falsches Datum umgehen.
E4 Ähnlichkeitsprüfung serverseitig über find_similar_catalog_games mit pg_trgm und einer unveränderlichen (immutable) Normalisierungsfunktion normalize_game_title. Nur sichtbare Katalogspiele (catalog_games). Klärung 15 ist eine Datenbankregel (Trigramm). Dieselbe Regel prüft die RPC beim Absenden noch einmal; ohne ausdrückliche Bestätigung (p_confirmed_distinct) wird nicht gespeichert (Krit. 2).
E5 „Abgelehnt mit Verweis“: Der Nutzer entscheidet selbst, ob er auf den Zieleintrag wechselt (Spec #2 Spec-F3, entschieden). Dafür gibt es die RPC adopt_report_target (security definer) mit strenger Prüfung von Eigentümer und Status; sie nutzt repoint_game_references aus 2b. Krit. 5 verlangt, dass der vorläufige Eintrag „auf den richtigen Eintrag verweist“. Der Verweis steht in target_game_id; das Umhängen der eigenen Daten bleibt eine Entscheidung des Nutzers. Das ist der erste Weg, auf dem ein Nutzer repoint_game_references indirekt auslöst; die Prüfungen sind deshalb ausführlich (4.4).
E6 Ergebnishinweise werden aus vorhandenen Daten abgeleitet (status, target_game_id, merged_into_id des vorläufigen Eintrags) und lassen sich wegklicken (game_reports.result_seen_at, Spec-F5). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft. Kein Benachrichtigungssystem in R1 (QUE-01 erst R2). Eine Spalte reicht.
E7 Edition (N15) ohne Änderung an 2c: Die App zeigt „Edition neu wählen“, wenn der vorläufige Eintrag mindestens eine eigene Variante hatte und der Eintrag des Nutzers am Zielspiel keine Edition mehr hat (Spec-F6). Die privaten Varianten bleiben am ausgeblendeten vorläufigen Eintrag erhalten und sind für den Eigentümer weiter lesbar (Spec #2 § 3.3). Alternative mit einer Spalte edition_cleared steht in Spec-F6.
E8 Verlag bleibt Freitext in game_reports.publisher_name; der vorläufige Eintrag bekommt keinen Verlag. publishers ist nur für den Admin schreibbar (Spec #2 3.2). Der Admin legt den Verlag beim Freigeben selbst an (Runbook).
E9 Keine neuen Pakete, keine Edge Function. pg_trgm ist Teil des Supabase-Postgres-Images und self-hosted verfügbar (ADR-0002). CLAUDE.md, ADR-0002.
E10 Zwei PRs: 13a Backend (Refs #13), 13b Flutter (Closes #13). 13b kann gegen ein gemocktes Repository parallel beginnen, sobald der RPC-Vertrag (4.2–4.5) freigegeben ist. Der Vertrag steht vollständig in dieser Spec.

2. Akzeptanzkriterien

Übernommen aus Issue #13 / 20-r1-akzeptanzkriterien.md § KAT-04 (Stand 2026-11-02 inkl. Klärungen und N12). Referenz in Tests und PRs: „KAT-04 Kriterium n“.

  1. Gegeben ich finde ein Spiel nicht, wenn ich Titel (Pflicht), Verlag, Erscheinungsjahr und optional einen Link angebe, dann wird die Meldung gespeichert und ich erhalte eine Bestätigung.
  2. Gegeben es gibt schon einen sehr ähnlichen Katalogeintrag, wenn ich melden will, dann sehe ich ihn zuerst und muss ausdrücklich bestätigen, dass mein Spiel ein anderes ist. „Sehr ähnlich“ heißt: gleicher normalisierter Titel (Groß-/Kleinschreibung, Sonderzeichen und Artikel ignoriert) oder Titel-Ähnlichkeit von mindestens 0,8 (Trigramm) bei Erscheinungsjahr ± 1 (Klärung 15).
  3. Gegeben ich habe ein Spiel gemeldet, dann kann ich es als vorläufigen Eintrag in meine Sammlung oder Listen aufnehmen; der Eintrag ist nur für mich sichtbar.
  4. Gegeben die Meldung wurde freigegeben, dann ersetzt der Katalogeintrag meinen vorläufigen Eintrag, ohne dass meine eigenen Angaben verloren gehen, und ich sehe das als Hinweis in der App am Eintrag (Klärung 26).
  5. Gegeben die Meldung wurde mit einem anderen Eintrag zusammengeführt oder unter Verweis auf einen vorhandenen Eintrag abgelehnt, dann sehe ich das als Hinweis in der App am vorläufigen Eintrag (keine E-Mail), und mein vorläufiger Eintrag verweist auf den richtigen Eintrag (Klärungen 26, 45).
  6. Gegeben die Meldung wurde ohne Zieleintrag abgelehnt, dann bleibt mein vorläufiger Eintrag privat bestehen und ist als „abgelehnt“ markiert (Klärung 26).
  7. Gegeben ich habe heute schon 10 Meldungen abgeschickt, wenn ich eine weitere melden will, dann wird sie nicht gespeichert und ich sehe einen Hinweis auf das Tageslimit (Klärung 27). Meldungen, die der BGG-Import automatisch anlegt (SAM-08 Kriterium 4), zählen nicht auf dieses Limit (N12).
  8. Gegeben ich habe eine Meldung abgeschickt, dann bleibt sie im Status „offen“, bis der Admin sie manuell bearbeitet; eine automatische Freigabe gibt es nicht (Klärungen 6, 27, 44).

Abweichung bei Krit. 4 (Klärung N15, entschieden von Chris am 2026-11-03): Beim Ersetzen bleibt die Edition nur erhalten, wenn der Katalogeintrag eine gleichnamige Variante hat; sonst wird sie an Sammlungseintrag und Partien geleert, und die App fordert auf, sie neu zu wählen (E7). Alle anderen eigenen Angaben (Liste, Notiz, Kaufdatum, Angaben aus SAM-02/SAM-04, Partien) bleiben erhalten; das leistet repoint_game_references aus 2b.

Lesart von Krit. 2 (Klärung 15): Die Bedingung „Erscheinungsjahr ± 1“ gehört nur zur Trigramm-Regel. Gleicher normalisierter Titel gilt unabhängig vom Jahr als „sehr ähnlich“. Fehlt das Jahr auf einer Seite, siehe Spec-F2.

Lesart von Krit. 1: Nur der Titel ist Pflicht; Verlag und Jahr sind optional (Spec-F1).

Relevante Querschnittskriterien

  • Sicherheit der Daten Krit. 1 und 3: Meldungen und vorläufige Einträge sieht nur der Meldende (und der Admin im Studio). pgTAP prüft fremden Zugriff auf game_reports, auf vorläufige Spiele und auf die neuen RPCs.
  • Sicherheit der Daten Krit. 2: anon kann nichts melden und keine Meldungen lesen.
  • Datenschutz (QUE-04): Meldungen und vorläufige Einträge werden mit dem Konto hart gelöscht (on delete cascade, Klärung 10) und gehören in den Export (KTO-05 #23, Klärung 20). Freigegebene Katalogeinträge übernehmen nur Fakten aus den Meldungsfeldern (Runbook aus 2c). Der Link wird in der App nicht geöffnet und nicht vorgeladen (keine Verbindung zu Fremdseiten).
  • Sprachen (QUE-05) Krit. 2, 4: alle Texte, Fehler- und Statushinweise aus ARB (de/en).
  • Barrierefreiheit (QUE-06) Krit. 1–4: Formular mit Feldbeschriftungen, Statushinweise mit Text (nicht nur Farbe), Touch-Ziele ≥ 48 dp, textScaler 2.0, Tastaturbedienung im Web inkl. Bestätigungsdialog.
  • Zustände, Fehler und Netz Krit. 1–3: Liste „Meine Meldungen“ mit Lade-, Leer-, Fehler- und Datenzustand; offline Hinweis statt technischem Fehler; bei Limit- oder Netzfehler bleiben die Eingaben im Formular erhalten (Issue: „besonders relevant“).
  • Plattformen Krit. 1, 2: identisch auf iOS, Android und Web; breites Layout im Web.

3. Datenmodell

3.1 Ausgangslage (nach #2 PR 2c)

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

  • game_reports mit id, owner_id (FK → profiles, on delete cascade), origin (user/import, Standard user), title (1–200), publisher_name (≤ 200), year (1800–2100), link (≤ 500, ^https?://), bgg_id, provisional_game_id (FK → games, on delete set null), status (open/approved/rejected/merged, Standard open), target_game_id (FK → games, on delete restrict), resolved_at, created_at, updated_at; Checks zu Status, Ziel und resolved_at; Index (owner_id, created_at); RLS: Lesen nur eigene, kein Insert/Update/Delete für authenticated.
  • admin_resolve_report(p_report, p_status, p_target): bei approved/merged hängt sie den vorläufigen Eintrag per repoint_game_references auf das Ziel um und setzt am vorläufigen Eintrag merged_into_id und hidden_at; bei rejected speichert sie nur den Verweis (oder nichts).
  • Trigger games_visibility_immutable: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14).

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

Nur additiv; der Zeitstempel liegt nach der Migration aus 2c. Rechte-Regel aus Spec #2 § 3.2 gilt: neue Funktionen zuerst revoke execute … from public, anon, authenticated, service_role, danach nur die Grants aus 4.1.

Erweiterungen

create extension if not exists pg_trgm with schema extensions;

Falls #3 die Erweiterung schon anlegt, ist die Zeile ein No-op. Kein unaccent (Spec-F3).

Neue Spalte in game_reports

Spalte Typ Hinweis
result_seen_at timestamptz nullable; der Nutzer hat den Ergebnishinweis weggeklickt (E6, Spec-F5). Check result_seen_at is null or status <> 'open'.

Neue Indizes

Index Zweck
unique (provisional_game_id) where provisional_game_id is not null 1:1 Meldung ↔ vorläufiger Eintrag (E1). Falls 2c ihn schon anlegt: entfällt.
(target_game_id) where target_game_id is not null Hinweis am Eintrag: „gibt es eine Meldung von mir mit diesem Ziel?“ (5.4)

Kein Trigramm-Index: Ein GIN-Index mit gin_trgm_ops würde nur über den Operator % genutzt, nicht über similarity(...) >= 0.8 (3.3). Bei ~200–2000 Katalogspielen ist ein sequenzieller Durchlauf schnell genug (einfach vor clever). Braucht KAT-01 einen Trigramm-Index für die Suche, legt #3 ihn an.

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 Update-Recht auf game_reports, und 08_game_reports.test.sql prüft, dass nur postgres Meldungen ändert. 13a erlaubt Nutzern genau eine 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 den Test aus 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 (Fehler 42501). Damit setzt die Datenbank origin = 'user', status = 'open' (Krit. 8) und created_at = now() (Tageslimit) selbst. delete bekommt der Nutzer nicht.

Merkpunkt für den Client (aus #2 PR 2a): Clients schreiben games und game_reports nie mit id, created_at oder updated_at, und ein toJson() eines Modells geht nie ungefiltert an insert/update. In #13 schreibt die App ohnehin nur über die RPCs (4.2, 4.4) und das eine Update von result_seen_at (4.5) mit einer ausdrücklichen Spaltenliste.

RLS in Klartext (neu in 13a)

  • Anlegen (game_reports_insert_own, for insert to authenticated): owner_id = auth.uid() und, falls provisional_game_id gesetzt ist, zeigt er auf ein eigenes privates, nicht ausgeblendetes und nicht zusammengeführtes Spiel (exists (select 1 from games g where g.id = provisional_game_id and g.visibility = 'private' and g.owner_id = auth.uid() and g.hidden_at is null and g.merged_into_id is null)). origin, status usw. sind über die Spaltenrechte festgelegt; die Policy prüft zusätzlich origin = 'user' and status = 'open' and target_game_id is null (zweite Sicherung).
  • Ändern (game_reports_update_seen, for update to authenticated): using (owner_id = auth.uid() and status <> 'open'), with check gleich. Über die Spaltenrechte ist nur result_seen_at änderbar.
  • Lesen: unverändert aus 2c (nur eigene).
  • Löschen: kein Recht (Meldungen verschwinden nur mit dem Konto).
  • Vorläufige Spiele: unverändert; games_select zeigt private Spiele nur dem Ersteller, auch wenn sie ausgeblendet oder zusammengeführt sind. So bleiben die privaten Varianten für den Hinweis zur Edition lesbar (E7).

Trigger game_reports_daily_limit (Krit. 7)

  • before insert on public.game_reports for each row, nur wenn new.origin = 'user'.
  • Sperre gegen gleichzeitige Inserts: perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0)). Ohne Sperre könnten zwei parallele Meldungen beide die Zählung „9“ sehen.
  • Zählt origin = 'user' und owner_id = new.owner_id mit created_at am selben Kalendertag in Europe/Berlin (Spec-F4): public.report_day(created_at) = public.report_day(now()). Bei ≥ 10 Fehler report_daily_limit (P0001).
  • Hilfsfunktion public.report_day(p_at timestamptz) returns date = (p_at at time zone 'Europe/Berlin')::date, language sql, immutable, search_path = '', execute nur für den Eigentümer. Sie ist eigens herausgezogen, damit pgTAP die Tagesgrenze mit festen Zeitstempeln prüfen kann (now() lässt sich im Test nicht steuern).
  • Import-Meldungen (origin = 'import', SAM-08) werden weder gezählt noch geprüft (N12).
  • Funktion security invoker mit search_path = ''. Der Nutzer sieht per RLS alle eigenen Meldungen, mehr braucht die Zählung nicht; Studio (postgres) umgeht RLS ohnehin. execute wie oben entzogen (Trigger-Funktionen brauchen kein execute-Recht des Aufrufers).
  • Die Zahl 10 steht als Konstante in der Funktion (kein Konfigurationswert); eine Änderung ist eine neue Migration.

Keine neue Tabelle, kein neuer Fremdschlüssel auf games. Der Fremdschlüssel-Wächter aus #2 (7) und 00_schema.test.sql (21 Tabellen) bleiben unverändert.

3.3 Normalisierung und „sehr ähnlich“ (Krit. 2, Klärung 15)

Funktion public.normalize_game_title(p_title text) returns text, language sql, immutable, strict, parallel safe, search_path = ''. Schritte in dieser Reihenfolge:

  1. lower(); ß → ss, æ → ae, œ → oe.
  2. Akzente falten (Spec-F3) mit einer festen, handgeschriebenen Zuordnung über translate() (z. B. äàáâãå → a, ëèéê → e, ïìíî → i, öòóôõø → o, üùúû → u, ñ → n, ç → c, ÿý → y). translate() ist immutable; unaccent wird nicht verwendet (nur stable).
  3. Alles, was danach kein Zeichen aus a–z oder 0–9 ist (Satz- und Sonderzeichen, Bindestriche, Doppelpunkte, Apostrophe, Emojis, übrige Schriftzeichen), wird zu einem Leerzeichen: regexp_replace(…, '[^a-z0-9]+', ' ', 'g'). Die explizite Zeichenklasse hängt nicht von der Datenbank-Locale ab.
  4. Führende Artikel entfernen, einmal, nur am Anfang (Spec-F3): Deutsch der, die, das, den, dem, des, ein, eine, einen, einem, einer, eines; Englisch the, a, an.
  5. Mehrfache Leerzeichen zu einem, trim.
  6. Ist das Ergebnis leer, wird der Wert aus Schritt 3 (ohne Artikelentfernung) verwendet; ist auch der leer (Titel nur aus Sonderzeichen oder nicht-lateinischen Schriftzeichen), lower(btrim(p_title)).

Beispiele (werden pgTAP-Fälle): „Die Siedler von Catan“ → siedler von catan; „CATAN!“ → catan; „7 Wonders: Duel“ → 7 wonders duel; „The Crew“ → crew; „Café International“ → cafe international; „Mölkky“ und „Molkky“ → molkky; „Das“ → das.

Regel „sehr ähnlich“ zwischen Meldung (Titel t, Jahr y) und Katalogspiel g:

  • A (gleicher Titel): normalize_game_title(g.title) = normalize_game_title(t), unabhängig vom Jahr.
  • B (Trigramm): extensions.similarity(normalize_game_title(g.title), normalize_game_title(t)) >= 0.8 und abs(g.year - y) <= 1. Fehlt y oder g.year, gilt B ohne Jahresbedingung (Spec-F2).
  • Grundlage sind nur sichtbare Katalogspiele (catalog_games), keine ausgeblendeten, zusammengeführten oder privaten Spiele (auch nicht eigene vorläufige, Spec-F7).
  • Wichtig für die Umsetzung: In Funktionen mit search_path = '' muss extensions.similarity(...) >= 0.8 ausdrücklich verwendet werden, nicht der Operator %; dieser nutzt die Einstellung pg_trgm.similarity_threshold (Standard 0,3).
  • Alternative Titel (Klärung 3, #3): Hat #3 bei der Umsetzung von 13a eine Tabelle für alternative Titel, werden diese in A und B mitgeprüft (beste Übereinstimmung je Spiel). Sonst nur der Haupttitel; #3 ergänzt die Prüfung dann in seiner Migration (Hinweis 9.4).

Zusammenspiel mit #3: Die Suche in KAT-01 (Krit. 1: „unabhängig von Groß- und Kleinschreibung und Sonderzeichen“) soll dieselbe Funktion nutzen. Wird #3 vor 13a umgesetzt und legt eine Normalisierungsfunktion an, die die Regeln oben erfüllt, verwendet 13a sie statt einer zweiten; sonst legt 13a normalize_game_title an und #3 nutzt sie später mit. Eine zweite, abweichende Normalisierung ist zu vermeiden (zweiter Anwendungsfall vorhanden, deshalb eine gemeinsame Funktion).

4. Backend

4.1 Überblick

Objekt Art execute/Recht Zweck
normalize_game_title(text) Funktion, sql, immutable anon, authenticated (harmlos, rein rechnend; nötig für Abfragen als Aufrufer, später auch für die Suche in #3) Normalisierung (3.3)
find_similar_catalog_games(p_title text, p_year smallint) RPC, security invoker authenticated Ähnliche Katalogspiele anzeigen (Krit. 2)
report_missing_game(...) RPC, security invoker authenticated Meldung + vorläufiger Eintrag + optional Listeneintrag (Krit. 1, 2, 3, 7, 8)
adopt_report_target(p_report uuid) RPC, security definer (Eigentümer postgres) authenticated Wechsel auf den Zieleintrag bei „abgelehnt mit Verweis“ (Krit. 5)
game_reports_daily_limit() Trigger-Funktion, security invoker – Tageslimit (Krit. 7)
Policies game_reports_insert_own, game_reports_update_seen, Spaltenrechte RLS – 3.2

anon darf keine der RPCs ausführen (42501). service_role bekommt nichts (keine Edge Function; der Import ergänzt später eigene Rechte, Spec #2 9.5).

Fehler werden wie in den bestehenden Migrationen als raise exception '<code>' using errcode = 'P0001' gemeldet; die App bildet den Code auf einen Domänenfehler ab (5.3).

4.2 RPC public.report_missing_game

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

language plpgsql, security invoker, search_path = '', volatile.

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

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

Die Prüfungen spiegeln die Checks aus 2c, damit die App gezielte Feldfehler zeigen kann; die Checks der Tabelle bleiben die eigentliche Grenze (23514 als Rückfall → „unbekannter Fehler“). 4. insert into public.games (owner_id, title, year, visibility) values (auth.uid(), title, year, 'private') returning id (nur erlaubte Spalten, keine id). 5. insert into public.game_reports (owner_id, title, publisher_name, year, link, provisional_game_id) … returning id. Hier greift der Limit-Trigger (report_daily_limit); weil Schritt 4 in derselben Transaktion liegt, bleibt kein verwaistes Spiel zurück. 6. Wenn p_list gesetzt: insert into public.collection_items (owner_id, game_id, list) values (auth.uid(), provisional_id, p_list) returning id. Keine Edition (der vorläufige Eintrag hat keine Varianten; KAT-03 Krit. 7 für Listen). 7. Rückgabe als jsonb.

Aufrufreihenfolge in der App: erst find_similar_catalog_games (4.3) zeigen, dann report_missing_game mit p_confirmed_distinct = true, wenn der Nutzer bestätigt hat. Ruft ein Client direkt mit true auf, ohne die Liste gezeigt zu haben, umgeht er nur den UX-Schritt; Rechte und Limit sind nicht betroffen (E3).

4.3 RPC public.find_similar_catalog_games

find_similar_catalog_games(p_title text, p_year smallint default null)
returns table (
  game_id      uuid,
  title        text,
  year         smallint,
  publishers   text[],      -- Namen aus game_publishers/publishers, sortiert
  match_kind   text,        -- 'same_title' | 'similar_title'
  similarity   real         -- 1.0 bei 'same_title'
)

language sql, stable, security invoker, search_path = ''. Liest public.catalog_games (plus game_publishers/publishers). Regel nach 3.3. Sortierung: same_title zuerst, dann similarity absteigend, dann title. Höchstens 5 Treffer. Leerer oder nur aus Leerzeichen bestehender Titel → leere Menge (kein Fehler).

Wird für authenticated freigegeben, nicht für anon (Melden setzt Anmeldung voraus).

4.4 RPC public.adopt_report_target

adopt_report_target(p_report uuid) returns jsonb
-- Zähler aus repoint_game_references, z. B. {"collection_items": 1, "plays": 0, "variants_mapped": 0, "variants_cleared": 1, …}

language plpgsql, security definer, Eigentümer postgres, search_path = ''. Nötig, weil Nutzer merged_into_id/hidden_at nicht schreiben und repoint_game_references nicht ausführen dürfen (Spec #2 3.2, 3.3).

Prüfungen (alle mit for update auf Meldung und vorläufigem Eintrag, damit ein gleichzeitiges admin_resolve_report nicht dazwischenkommt):

Prüfung Fehler
auth.uid() ist null not_authenticated
Meldung existiert nicht oder gehört nicht auth.uid() (gleicher Fehler, verrät keine fremden IDs) report_not_found
status <> 'rejected' oder target_game_id is null report_not_adoptable
provisional_game_id is null report_no_provisional
vorläufiger Eintrag ist nicht private mit owner_id = auth.uid() report_not_found
vorläufiger Eintrag ist schon zusammengeführt (merged_into_id is not null) report_already_adopted
Ziel ist kein sichtbares Katalogspiel (catalog_games) report_target_not_listed
Es gibt Verweise auf den vorläufigen Eintrag von anderen Nutzern (collection_items, favorite_games, plays, score_sheets mit owner_id/profile_id ≠ auth.uid()) report_foreign_references

Die letzte Prüfung ist eine Schutzschicht: Über die API können andere Nutzer ein fremdes privates Spiel nicht referenzieren (Policies verlangen ein sichtbares Spiel), aber security definer umgeht RLS, und ein Studio-Handgriff wäre denkbar.

Ablauf: repoint_game_references(provisional, target) (privat → Katalog, keine Varianten kopiert, gleichnamige abgebildet, sonst null; Spec #2 RB1/N15), danach am vorläufigen Eintrag merged_into_id = target, hidden_at = now(). Die Meldung bleibt rejected (das ist das Ergebnis der Prüfung durch den Admin); result_seen_at wird auf now() gesetzt, weil der Nutzer gehandelt hat. Rückgabe: Zähler aus repoint_game_references.

favorite_games sind bei vorläufigen Spielen fachlich ausgeschlossen (Klärung 23), technisch aber derzeit erlaubt (Hinweis an #14, 9.4); repoint_game_references behandelt sie ohnehin.

4.5 Ergebnishinweis wegklicken

Kein eigener RPC. Die App sendet per PostgREST PATCH /rest/v1/game_reports?id=eq.<id> genau die Map {'result_seen_at': <aktuelle Zeit als ISO-8601 in UTC>} (PostgREST setzt Werte, keine SQL-Ausdrücke wie now()). Dass die Zeit vom Client kommt, ist unkritisch: Der Wert steuert nur die Anzeige des eigenen Hinweises. Policy und Spaltenrecht (3.2) begrenzen das Update auf eigene, bereits bearbeitete Meldungen und genau diese Spalte; bei einer offenen oder fremden Meldung ändert sich keine Zeile.

4.6 Lesen für die App

Kein RPC nötig; PostgREST-Abfragen als angemeldeter Nutzer:

  • Meine Meldungen: game_reports (eigene, RLS) mit Spalten id, origin, title, publisher_name, year, link, status, target_game_id, provisional_game_id, result_seen_at, created_at, resolved_at, sortiert nach created_at desc, seitenweise (20). Eingebettet: provisional:games!provisional_game_id(id, merged_into_id, hidden_at, game_variants(id)) und target:games!target_game_id(id, title, year). Die Fremdschlüsselnamen für das Embedding prüft flutter-dev gegen die 2c-Migration.
  • Hinweis zu einem Spiel (5.4): game_reports mit or=(provisional_game_id.eq.<id>,target_game_id.eq.<id>); der Index aus 3.2 deckt beide Fälle.
  • Edition (E7): collection_items des Nutzers zum Zielspiel (variant_id) und provisional.game_variants aus der eingebetteten Abfrage.

4.7 Doku

  • docs/datenmodell-r1.md: game_reports.result_seen_at, Indizes, Spaltenrechte, Policies, Trigger game_reports_daily_limit, die Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes.
  • docs/katalog-pflege.md, Abschnitt „Meldungen bearbeiten“ (aus 2c) ergänzen: Nutzer-Meldungen haben immer einen vorläufigen Eintrag; „abgelehnt mit Verweis“ lässt dem Nutzer die Wahl (adopt_report_target), der Admin hängt dabei nichts um; vor approved die Edition berücksichtigen (Runbook aus 2c); Meldungen nie von Hand auf einen anderen Status setzen (nur über admin_resolve_report), weil die App den Hinweis aus Status und merged_into_id ableitet.
  • supabase/README.md: neue RPCs und Rechte.

5. Frontend

5.1 Struktur

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

features/game_reports/
  domain/
    game_report.dart              freezed: GameReport
    game_report_status.dart       enum GameReportStatus { open, approved, rejected, merged }
    game_report_origin.dart       enum GameReportOrigin { user, import }
    report_outcome.dart           sealed ReportOutcome (5.4) + reine Funktion outcomeOf(...)
    game_report_draft.dart        freezed: Eingaben + Validierung (Titel Pflicht, Längen, Jahr, Link)
    similar_game.dart             freezed: SimilarGame (gameId, title, year, publishers, matchKind)
    game_report_failure.dart      freezed/sealed: dailyLimit, similarExists, validation(field, code),
                                  notAdoptable, alreadyAdopted, targetNotListed, offline, unknown
    game_report_repository.dart   abstrakte Schnittstelle
  data/
    supabase_game_report_repository.dart   RPC-Aufrufe und Abfragen (4.2–4.6), explizite Parameter-Maps
    game_report_dto.dart                   Mapping aus Zeilen (nur fromJson; kein toJson für Schreibzugriffe)
    game_report_failure_mapper.dart        P0001-Codes → GameReportFailure
  presentation/
    report_form/                  Screen, Controller (Notifier), State
    similar_games/                Abschnitt/Dialog mit Kandidaten und ausdrücklicher Bestätigung
    my_reports/                   Liste „Meine Meldungen“, Controller
    report_detail/                Detail einer Meldung inkl. Hinweis und Aktionen
    widgets/report_outcome_notice.dart   wiederverwendbarer Hinweis (auch für #4/#6/#11)
    providers/                    Repository-, Listen- und Hinweis-Provider (Riverpod, generiert)
    game_report_routes.dart

Die Domäne importiert weder Flutter noch Supabase. Die Validierung der Eingaben liegt in GameReportDraft (Unit-Tests), nicht im Widget.

5.2 Routen (go_router)

Route Screen Hinweis
/reports/new?title=<suchbegriff> ReportFormScreen Einstieg aus der leeren Trefferliste von KAT-01 (#3 Krit. 6) mit vorbelegtem Titel; Anmeldung nötig (Redirect auf /login?from=… wie in Spec #1)
/reports MyReportsScreen Liste der eigenen Meldungen
/reports/:reportId ReportDetailScreen Status, Angaben, Hinweis, Aktionen; Deep Link und Web-URL möglich, fremde/unbekannte ID → „nicht gefunden“ (RLS liefert keine Zeile)

Konstanten in AppRoutes. Einstieg zu /reports: aus der Suche (#3) und aus jedem Hinweis; ob zusätzlich in den Einstellungen (KTO-11 #28) oder im Dashboard (QUE-09), entscheidet ui-designer mit Chris (kein Kriterium verlangt es).

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

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

Zustand Bedeutung UI
editing(draft, fieldErrors) Eingabe Felder Titel (Pflicht), Verlag, Erscheinungsjahr, Link (optional), Auswahl „Gleich aufnehmen in“: Sammlung / Wunschliste / Einkaufsliste / nicht aufnehmen (Standard: Sammlung, Spec-F9)
checking find_similar_catalog_games läuft Button mit Ladeanzeige, Felder gesperrt
similarFound(draft, candidates) Treffer nach 3.3 Kandidatenliste (Titel, Jahr, Verlage, GameCover). Je Kandidat „Das ist mein Spiel“ → Detailseite des Katalogspiels (#4), keine Meldung. Darunter ausdrückliche Bestätigung: Checkbox „Mein Spiel ist keines davon“ + Button „Trotzdem melden“ (erst aktiv mit Haken).
submitting report_missing_game läuft Ladeanzeige
submitted(result) gespeichert Bestätigung (Krit. 1): „Danke, deine Meldung ist eingegangen. Wir prüfen sie von Hand; bis dahin kannst du das Spiel als vorläufigen Eintrag nutzen.“ Ggf. „Steht jetzt in deiner Sammlung/Wunschliste/Einkaufsliste“; Aktionen „Zur Meldung“, „Zur Sammlung“ (falls aufgenommen)
failure(draft, failure) Fehler Eingaben bleiben erhalten (Zustände Krit. 3). dailyLimit → Hinweis „Du hast heute schon 10 Spiele gemeldet. Morgen geht es wieder.“ (Krit. 7); similarExists (Rennen: Katalog hat sich geändert) → zurück zu similarFound; Feldfehler am Feld; offline → „Melden braucht eine Verbindung“

Gibt es keine Treffer, geht checking direkt in submitting über (p_confirmed_distinct = false). Bei Treffern wird mit true gesendet.

Das Formular prüft vor dem Senden lokal dieselben Regeln wie 4.2 (Titel Pflicht, Längen, Jahr, Link-Format), damit Fehler sofort am Feld stehen. Das Tageslimit prüft nur der Server.

Krit. 3 nachträglich: Wurde „nicht aufnehmen“ gewählt, kann der Nutzer den vorläufigen Eintrag später auf der Detailseite der Meldung zu einer Liste hinzufügen. 13b nutzt dafür die Funktion „zur Sammlung/Liste hinzufügen“ aus SAM-01/SAM-04/SAM-05 (#6/#8/#9), falls vorhanden; sonst ein einfacher Insert in collection_items über das Repository (Spalten owner_id, game_id, list) mit Hinweis an #6, ihn später zu ersetzen (Spec-F8).

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

ReportOutcome wird rein aus den Daten abgeleitet (outcomeOf(report, provisional, collectionItemOnTarget), Unit-Tests je Fall):

Fall Erkennung Hinweis (Kurzform, Texte über ARB) Aktionen Wo sichtbar
pending (Krit. 8) status = open „Gemeldet am …, wird geprüft“ – Meldung; vorläufiger Eintrag (Badge „vorläufig“)
approved (Krit. 4) status = approved, vorläufiger Eintrag merged_into_id = target „Dein gemeldetes Spiel ist jetzt im Katalog. Deine Angaben wurden übernommen.“ „Zum Spiel“, „Verstanden“ Meldung; Eintrag am Zielspiel (Sammlung/Liste, Detailseite)
merged (Krit. 5) status = merged, vorläufiger Eintrag merged_into_id = target „Dein Spiel gab es schon im Katalog als „{title}“. Dein Eintrag zeigt jetzt dorthin.“ „Zum Spiel“, „Verstanden“ wie approved
rejectedWithTarget (Krit. 5) status = rejected, target_game_id gesetzt, vorläufiger Eintrag nicht zusammengeführt „Dein Spiel gibt es bereits als „{title}“.“ „Zu „{title}“ wechseln“ (→ adopt_report_target, mit Bestätigungsdialog: „Deine Angaben werden auf den Katalogeintrag übertragen.“), „Vorläufig behalten“ Meldung; vorläufiger Eintrag
rejectedAdopted status = rejected, target_game_id gesetzt, vorläufiger Eintrag merged_into_id = target wie merged „Zum Spiel“ wie approved
rejected (Krit. 6) status = rejected, kein Ziel Dauerhafte Markierung „Abgelehnt“ + Text „Dieses Spiel wird nicht in den Katalog aufgenommen. Dein Eintrag bleibt privat für dich.“ „Verstanden“ (blendet nur den Text aus, die Markierung bleibt) Meldung; vorläufiger Eintrag

Zusatz Edition (E7, N15): Bei approved, merged und rejectedAdopted zusätzlich „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“, wenn der vorläufige Eintrag mindestens eine eigene Variante hat und der Sammlungseintrag des Nutzers am Zielspiel variant_id = null hat. Aktion „Edition wählen“ führt zur Bearbeitung des Sammlungseintrags (KAT-03 #12 / SAM-02 #7), sobald vorhanden. Bei Partien wird nicht einzeln geprüft (Spec-F6).

„Verstanden“ setzt result_seen_at (4.5). Danach erscheint der Hinweis nicht mehr am Eintrag; in „Meine Meldungen“ bleibt der Status immer sichtbar.

Hinweis „am Eintrag“: Provider reportNoticeForGameProvider(gameId) liefert den passenden ReportOutcome (oder keinen) für ein Spiel, an dem der Nutzer einen Eintrag hat: Meldungen mit provisional_game_id = gameId (vorläufiger Eintrag) oder target_game_id = gameId und result_seen_at is null (ersetzter Eintrag). Das Widget ReportOutcomeNotice (kompakt für Listenzeilen, ausführlich für Detailseiten) wird eingebaut

  • in 13b: in „Meine Meldungen“ und die Meldungsdetailseite,
  • in die Detailseite eines Spiels (#4) und die Sammlungs-/Listenansicht (#6/#11), soweit diese bei der Umsetzung von 13b gemergt sind; sonst übernehmen #4/#6/#11 den Einbau (Spec-F8, Hinweis 9.4).

Vorläufige Einträge werden in Listen mit einem Badge „vorläufig“ (Text, nicht nur Farbe) und dem GameCover-Platzhalter gezeigt.

Aktualität: Kein Realtime in R1. Die Hinweise werden beim Öffnen der Liste/Detailseite und per Pull-to-Refresh neu geladen; das reicht, weil der Admin Meldungen selten und manuell bearbeitet.

5.5 Meine Meldungen

MyReportsController (AsyncNotifier mit Paging, 20 je Seite): Zustände Laden, Leer („Du hast noch keine Spiele gemeldet“ + Weg zur Suche), Fehler mit „Erneut versuchen“, Daten. Je Zeile: Titel, Jahr, Datum, Status-Badge (Text), ggf. kompakter Hinweis. Import-Meldungen (origin = import, SAM-08) erscheinen mit Kennzeichen „aus BGG-Import“ (Text liefert #25; 13b zeigt origin nur an, wenn vorhanden).

5.6 l10n (Auszug, de/en)

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

6. Plattform-Besonderheiten

  • iOS, Android, Web: gleiches Verhalten. Link-Feld mit TextInputType.url, Jahr mit Ziffern-Tastatur; der Link wird in der App nur als Text angezeigt, nicht geöffnet (Datenschutz, 2).
  • Web: Routen sind teilbare URLs; der Bestätigungsschritt bei ähnlichen Spielen ist per Tastatur bedienbar (Checkbox, Fokus-Reihenfolge); breites Layout: Formular und Kandidaten nebeneinander ab Breite ≥ 840 dp (ui-designer).
  • Offline: Melden braucht eine Verbindung (wie SAM-01 Krit. 3); Hinweis statt Fehler, Eingaben bleiben im Formular. Keine Offline-Warteschlange für Meldungen.
  • Push, Kamera, Dateien: nicht betroffen (kein Bild-Upload, keine Push in R1).

7. Teststrategie

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

Neue pgTAP-Datei supabase/tests/database/09_game_reports_user.test.sql (Nummer nach 08_game_reports.test.sql aus 2c). Fixtures mit festen Test-UUIDs, zwei Nutzer A und B, unabhängig vom Startbestand.

7.1 Abdeckungsmatrix

AK Unit Widget Integration/E2E RLS/pgTAP Task
Krit. 1 GameReportDraft: Titel Pflicht (auch nur Leerzeichen), Längen, Jahr, Link-Format; Fehlercode-Mapping Formular: Feldfehler am Feld, Bestätigung nach Erfolg, Listenwahl Playwright: leere Suche → „Spiel melden“ → Titel vorbelegt → absenden → Bestätigung report_missing_game legt Meldung (status = open, origin = user) und vorläufiges Spiel (private, owner_id = A, nur Titel/Jahr) an; Trimmen; alle Validierungsfehler (report_title_required … report_list_invalid); anon → 42501 B1, B2, F2, T1, T2
Krit. 2 Controller: Treffer → similarFound; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; similarExists beim Senden → zurück zu Kandidaten Kandidatenliste, Bestätigungs-Checkbox aktiviert Button, Tastatur im Web, Semantics Playwright: Meldung zu „Catan“ zeigt vorhandenes „CATAN“ normalize_game_title: Beispiele aus 3.3 (Artikel de/en, Sonderzeichen, Akzente und Umlaute gefaltet, ß, nur Artikel, nur nicht-lateinische Zeichen); find_similar_catalog_games: gleicher normalisierter Titel bei beliebigem Jahr trifft; Trigramm ≥ 0,8 bei Jahr ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; Jahr fehlt → Regel nach Spec-F2; ausgeblendete, zusammengeführte und private Spiele (auch eigene) werden nicht geliefert; höchstens 5, Sortierung; report_missing_game ohne Bestätigung bei Treffer → report_similar_exists, mit Bestätigung gespeichert B1, B2, F2, U1, T1, T2
Krit. 3 – Bestätigung zeigt Liste; Badge „vorläufig“ Playwright: gemeldetes Spiel steht in der Wunschliste (sofern #6/#8 vorhanden) p_list legt collection_items zum vorläufigen Spiel an; B sieht weder vorläufiges Spiel noch Listeneintrag noch Meldung von A; anon sieht nichts; Rollback: schlägt der Listeneintrag fehl, gibt es weder Spiel noch Meldung B1, B2, F2, F3
Krit. 4 outcomeOf: approved mit/ohne Edition-Hinweis ReportOutcomeNotice für approved (de/en), „Verstanden“ blendet aus integration_test/Playwright: Meldung per SQL-Hilfsfunktion im Testsetup mit admin_resolve_report(…, 'approved', …) bearbeiten → Hinweis am Eintrag nach admin_resolve_report (2c) ist der Sammlungseintrag von A am Zielspiel mit Liste/Notiz/Kaufdatum; vorläufige Variante → variant_id null, private Variante bleibt am vorläufigen Eintrag und ist für A lesbar (E7); result_seen_at setzbar B2, F3, T1, T2
Krit. 5 outcomeOf: merged, rejectedWithTarget, rejectedAdopted Hinweise und Aktion „wechseln“ mit Bestätigungsdialog Playwright: abgelehnt mit Verweis → wechseln → Eintrag zeigt Zielspiel adopt_report_target: Erfolg hängt Sammlung und Partien von A um, setzt merged_into_id/hidden_at, Status bleibt rejected, result_seen_at gesetzt; Fehler report_not_found (fremde Meldung von B, unbekannte ID), report_not_adoptable (open, approved, rejected ohne Ziel), report_already_adopted (zweiter Aufruf), report_target_not_listed (Ziel inzwischen ausgeblendet), report_foreign_references (Fixture als postgres); anon → 42501 B1, B2, F3, T1, T2
Krit. 6 outcomeOf: rejected ohne Ziel dauerhafte Markierung „Abgelehnt“ bleibt nach „Verstanden“ – nach admin_resolve_report(…, 'rejected') bleibt das vorläufige Spiel unverändert privat und für A lesbar B2, F3, T2
Krit. 7 Mapping report_daily_limit → dailyLimit Limit-Hinweis, Eingaben bleiben erhalten – 10 Meldungen am selben Berliner Kalendertag ok, 11. → report_daily_limit und kein vorläufiges Spiel übrig (Rollback); Meldungen von gestern (Fixture mit created_at = now() - interval '1 day' als postgres gesetzt) zählen nicht; Tagesgrenze über 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 eingefügt zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; direkter Insert mit created_at, origin oder status → 42501 B1, B2, F2, T1
Krit. 8 – Status „offen“ in Liste und Detail – Nutzer kann status, target_game_id, resolved_at weder per Insert noch per Update setzen (42501); Update von result_seen_at bei status = open → 0 Zeilen; nach dem Insert ist der Status open; kein Trigger/Job ändert ihn B2, T1
Sicherheit Krit. 1, 3 – – – B liest A's Meldungen und vorläufige Spiele nicht; B kann A's Meldung nicht wegklicken (0 Zeilen) und nicht übernehmen; Insert mit owner_id = A als B → Policy-Fehler; provisional_game_id auf ein fremdes, ein Katalog- oder ein ausgeblendetes Spiel → Policy-Fehler; Spaltenrechte per has_column_privilege B2
Sicherheit Krit. 2 – – – anon: kein select, insert, update auf game_reports, keine RPC ausführbar B2
Datenschutz – – – Konto von A löschen → Meldungen, vorläufige Spiele, Listeneinträge weg; Meldungen und Spiele von B unverändert B2
QUE-05/06, Zustände – Alle Screens de/en, meetsGuideline (Tap-Ziele, Kontrast, Labels), textScaler 2.0 ohne Überlauf, Lade-/Leer-/Fehler-/Datenzustand „Meine Meldungen“, Offline-Hinweis Playwright: Tastaturbedienung des Bestätigungsschritts – F2, F3, T2

Nicht automatisiert: echte Parallelität des Limits (die Sperre wird per Code-Review und einem dokumentierten manuellen Test mit zwei psql-Sitzungen geprüft); Bearbeitung echter Meldungen im Studio auf Staging (Chris).

Bestehende Tests: 08_game_reports.test.sql aus 2c erwartet „kein Insert und kein Update für authenticated“ bzw. „nur postgres ändert Meldungen“; 13a passt beide Erwartungen auf die neuen Spaltenrechte an (Insert der erlaubten Spalten, Update nur result_seen_at an eigenen bearbeiteten Meldungen; alles andere weiter 42501) (B2). 00_schema.test.sql bleibt bei 21 Tabellen.

8. Aufgaben

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

# Agent Aufgabe abhängig von
B1 backend-dev Migration <ts>_game_reports_user.sql nach 3.2–4.5: pg_trgm, normalize_game_title (oder Wiederverwendung aus #3, 3.3), Spalte result_seen_at mit Check, Indizes, Spaltenrechte, Policies game_reports_insert_own und game_reports_update_seen, Trigger game_reports_daily_limit mit Hilfsfunktion report_day (Berliner Kalendertag, Sperre je Nutzer, nur origin = 'user'), RPCs find_similar_catalog_games, report_missing_game, adopt_report_target; Rechte nach 4.1 #2 PR 2c gemergt
B2 backend-dev pgTAP 09_game_reports_user.test.sql nach 7.1; 08_game_reports.test.sql an die neuen Insert- und Update-Rechte anpassen B1
B3 backend-dev Doku nach 4.7 (datenmodell-r1.md, katalog-pflege.md Abschnitt Meldungen, supabase/README.md) B1
T1 test-engineer Review der pgTAP-Abdeckung gegen 7.1; manueller Paralleltest des Limits (zwei Sitzungen) dokumentieren B2

8.2 PR 13b – Flutter (Closes #13), Branch feature/13b-meldungen-app

# Agent Aufgabe abhängig von
U1 ui-designer Entwurf Formular, Kandidatenschritt mit ausdrücklicher Bestätigung, Bestätigung, „Meine Meldungen“, Meldungsdetail, ReportOutcomeNotice (kompakt/ausführlich), Badge „vorläufig“, breites Web-Layout; Texte de/en; Einstieg zu /reports mit Chris abstimmen Spec freigegeben
F1 flutter-dev Domäne (5.1): Modelle, ReportOutcome + outcomeOf, GameReportDraft-Validierung, Fehler, Repository-Schnittstelle; Unit-Tests Spec freigegeben (parallel zu B1)
F2 flutter-dev Data-Schicht (RPC-Aufrufe mit expliziten Parametern, kein toJson() an Schreibzugriffe; Fehler-Mapper) und Melden-Ablauf (5.3) inkl. Routen, Einstieg aus der leeren Suche (#3), l10n F1, U1; gegen echtes Backend: B1
F3 flutter-dev „Meine Meldungen“, Meldungsdetail, Hinweise (5.4) inkl. „Verstanden“, „wechseln“, Edition-Hinweis; Einbau von ReportOutcomeNotice in #4/#6/#11, soweit gemergt (sonst Hinweis-Kommentar in den Issues) F1, F2
T2 test-engineer Widget-Tests nach 7.1 (alle Zustände, alle Hinweisfälle, de/en, a11y, textScaler 2.0); Playwright-Ablauf Krit. 1–5 gegen lokales Supabase, Bearbeitung der Meldung im Testsetup per SQL als postgres F2, F3, B1

8.3 Reihenfolge und Parallelität

Vorbedingung: #2 PR 2b → 2c gemergt; #3 KAT-01 gemergt (Einstieg, ggf. Normalisierung)
13a:  B1 → B2 → B3, T1
13b:  U1 ─┐
      F1 ─┴→ F2 (Mock-Repository, danach gegen B1) → F3 → T2
13b wird erst nach 13a gemergt (RPC-Vertrag muss in main sein).

F1 und U1 starten sofort nach Freigabe der Spec, parallel zu B1. 13b schließt #13. Merge nur durch Chris.

9. Risiken & offene Fragen

9.1 Risiken

# Risiko Auswirkung Gegenmaßnahme
R1 2b/2c weichen bei der Umsetzung von den in Spec #2 beschriebenen Namen oder Rückgaben ab 13a passt nicht B1 liest die gemergten Migrationen zuerst; Abweichungen im PR nennen
R2 adopt_report_target ist security definer und erreicht repoint_game_references, das RLS umgeht Fehler würden fremde Daten berühren Strenge Prüfungen (4.4) inkl. Prüfung auf fremde Verweise, for update, nur eigene private, nicht zusammengeführte Spiele; pgTAP-Negativfälle; Code-Review mit Fokus Sicherheit
R3 Trigramm-Schwelle 0,8 ist bei kurzen Titeln streng oder bei Reihen („Catan“, „Catan: Seefahrer“) großzügig Ähnliche werden nicht gezeigt bzw. zu viele Klärung 15 ist verbindlich; Kandidatenschritt blockiert nicht, er verlangt nur eine Bestätigung; Erfahrungen nach R1 sammeln
R4 pg_trgm erkennt Wortzeichen abhängig von der Datenbank-Locale Ähnlichkeitswerte weichen zwischen lokal und Produktion ab Normalisierung liefert nur a–z, 0–9 und Leerzeichen (3.3); damit ist die Trigrammbildung locale-unabhängig; pgTAP-Fälle mit Umlauten und Akzenten
R5 Edition-Hinweis per Ableitung (E7) zeigt auch dann „Edition neu wählen“, wenn der Nutzer nie eine Edition gewählt hatte, aber eine private Variante existiert Unnötiger Hinweis Selten (vorläufige Einträge haben anfangs keine Varianten); wegklickbar; Alternative in Spec-F6
R6 Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11, die evtl. noch fehlen Krit. 4–6 nur in „Meine Meldungen“ sichtbar Spec-F8: Abhängigkeiten setzen oder Einbau dort nachziehen
R7 Nutzer melden das gleiche Spiel mehrfach (eigene offene Meldungen werden nicht geprüft, Spec-F7) Mehr Arbeit für Chris, doppelte vorläufige Einträge Tageslimit; Chris lehnt Duplikate mit Verweis ab
R8 Kein Realtime Ergebnis erscheint erst beim nächsten Laden Akzeptiert (manuelle Bearbeitung, kein Zeitdruck)

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

Keine Frage blockiert die Umsetzung: Für jede gibt es eine umsetzbare Empfehlung, die mit der Freigabe der Spec gilt, falls Chris nichts anderes entscheidet.

# Frage Empfehlung Begründung
Spec-F1 Krit. 1: Sind Verlag und Erscheinungsjahr Pflicht oder optional? Der Text nennt nur den Titel als Pflicht. Optional, nur der Titel ist Pflicht. Nutzer kennen das Jahr oft nicht; eine Pflicht würde zu geratenen Werten führen. 2c hat beide Spalten nullable.
Spec-F2 Krit. 2: Wie gilt die Trigramm-Regel, wenn das Jahr in der Meldung oder im Katalogeintrag fehlt? Trigramm ≥ 0,8 ohne Jahresbedingung. Lieber einmal mehr nachfragen als ein Duplikat; der Schritt verlangt nur eine Bestätigung.
Spec-F3 Krit. 2 „Artikel und Sonderzeichen ignoriert“: Welche Artikel, wo, und zählen Umlaute/Akzente als Sonderzeichen? Nur führende Artikel auf Deutsch und Englisch (Liste in 3.3); Umlaute und Akzente werden gefaltet (ö = o, é = e, ß = ss) über eine feste translate()-Zuordnung; alle anderen Zeichen werden ignoriert. Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“). Ohne Faltung würden „Mölkky“ und „Molkky“ nicht als ähnlich erkannt: Die Trigramm-Ähnlichkeit liegt bei etwa 0,4, weit unter 0,8. unaccent scheidet aus, weil es nicht immutable ist; translate() ist es und braucht keine Erweiterung. Nachteil: „ae“-Schreibweisen („Raeuber“) werden nicht auf „ä“ abgebildet. Alternative: Umlaute als eigene Buchstaben behalten (strenger, dann werden Schreibvarianten mit und ohne Umlaut nicht erkannt).
Spec-F4 Krit. 7 „heute“: Kalendertag in welcher Zeitzone, oder gleitende 24 Stunden? Kalendertag in Europe/Berlin. Entspricht „heute“ im Kriterium und ist für Nutzer verständlich („morgen geht es wieder“). Eine Profil-Zeitzone gibt es nicht.
Spec-F5 Krit. 4–6: Wie lange bleibt der Hinweis am Eintrag sichtbar? Bis der Nutzer „Verstanden“ wählt (result_seen_at). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft; in „Meine Meldungen“ bleibt der Status immer sichtbar. Ein dauerhafter Hinweis an einem längst ersetzten Eintrag stört; ein automatisches Ausblenden nach Zeit könnte übersehen werden.
Spec-F6 N15, Edition: Woran erkennt die App, dass die Edition geleert wurde? Ableitung (E7): vorläufiger Eintrag hat eine eigene Variante und der Eintrag am Ziel hat keine Edition. Alternative: Spalte game_reports.edition_cleared, gesetzt von admin_resolve_report/adopt_report_target; dafür müsste repoint_game_references (2b) zusätzlich zählen, wie viele Verweise geleert wurden, und 2c bzw. 13a die Funktionen anpassen. Die Ableitung braucht keine Änderung an den bereits freigegebenen Teilen 2b/2c. Die Alternative ist genauer und lohnt sich, wenn 2b/2c ohnehin noch nicht gebaut sind; dann sollte die Änderung in Spec #2 nachgetragen werden, nicht in 13a per create or replace.
Spec-F7 Krit. 2: Sollen auch eigene offene Meldungen bzw. eigene vorläufige Einträge mit ähnlichem Titel angezeigt werden („Du hast dieses Spiel schon gemeldet“)? Nein in R1. Kriterium nennt nur Katalogeinträge; Duplikate begrenzt das Tageslimit, Chris lehnt sie mit Verweis ab.
Spec-F8 Abhängigkeiten: Krit. 3 („in Sammlung oder Listen“) und der Hinweis „am Eintrag“ (Krit. 4–6) leben in Screens von SAM-01 (#6), KAT-02 (#4) und SAM-03 (#11). Sollen #4 und #6 als Forgejo-Abhängigkeit von #13 gesetzt werden? Ja, #13 hängt zusätzlich von #4 und #6 ab (beide liegen in der Reihenfolge ohnehin vor #13). #11 nicht: Den Einbau des Hinweises in die durchsuchbare Sammlung übernimmt #11, falls es später kommt. Sonst müsste #13 eigene Zwischenlösungen für Listeneintrag und Detailseite bauen, die später ersetzt werden.
Spec-F9 Krit. 3: Welche Liste ist im Formular vorausgewählt? Sammlung, änderbar auf Wunsch-/Einkaufsliste oder „nicht aufnehmen“. Häufigster Fall: Nutzer will ein Spiel erfassen, das er besitzt (Story: „Katalogloch blockiert beim Erfassen“).

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

  • RPC als Bündelung mit security invoker, Rechte in der Datenbank (E2, E3); nur adopt_report_target ist security definer (E5).
  • Kein Realtime, keine Edge Function, keine neuen Pakete (E9, R8).
  • Kein ADR: Die Entscheidungen bleiben innerhalb von ADR-0001/0002 und sind ohne großen Aufwand änderbar.

9.4 Hinweise für andere Stories

  • KAT-01 (#3): Einstieg „Spiel melden“ aus der leeren Trefferliste mit /reports/new?title=<suchbegriff> (Krit. 6); Normalisierung normalize_game_title für die Suche mitnutzen (3.3); legt #3 alternative Titel an, auch in find_similar_catalog_games berücksichtigen.
  • KAT-02 (#4), SAM-01 (#6), SAM-03 (#11): ReportOutcomeNotice und reportNoticeForGameProvider am Eintrag einbauen; vorläufige Einträge mit Badge „vorläufig“; eine Detailansicht für ein eigenes privates Spiel (nicht in catalog_games) lesen sie direkt aus games.
  • KAT-03 (#12): Aktion „Edition wählen“ aus dem Edition-Hinweis anbinden.
  • KTO-02 (#14): Die Policy favorite_games_own erlaubt derzeit jedes sichtbare Spiel, also auch eigene vorläufige; Klärung 23 verlangt nur Katalogspiele. #14 muss die Policy (neue Migration) auf visibility = 'catalog' einschränken.
  • PAR-01 (#15): vorläufige Spiele als wählbare Spiele anbieten (Krit. 3, Klärung 36), mit Badge.
  • KTO-05 (#23): Export enthält game_reports (inkl. Status und Link) und vorläufige Spiele; Löschen per Kaskade ist durch pgTAP in 13a abgedeckt.
  • SAM-08 (#25): Import-Meldungen mit origin = 'import' und bgg_id, einem vorläufigen Eintrag je Meldung (E1); der Limit-Trigger ignoriert sie (N12); die Hinweise aus 5.4 gelten unverändert; Kennzeichen „aus BGG-Import“ in „Meine Meldungen“ (5.5).

9.5 Ausstehende Prozessschritte

  • Freigabe der Spec und Antworten zu Spec-F1 bis Spec-F9 durch Chris (Label status/spec-review).
  • Bei Spec-F6 Alternative: Nachtrag in Spec #2 (2b/2c) vor deren Umsetzung.
  • Bei Spec-F8 Ja: Forgejo-Abhängigkeiten #13 → #4, #6 setzen (Chris oder Orchestrator).

10. Änderungsprotokoll

2026-11-03 – Entwurf

Erste Fassung durch architect. Berücksichtigt den Hinweis aus der KAT-05-Spec (Issue-Kommentar vom 2026-11-03, Klärung N15) zur eingeschränkten Erhaltung der Edition bei Krit. 4.

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.