55 KiB
Spec #13 – KAT-04 Fehlendes Spiel melden
| Issue | #13 (KAT-04) |
| Status | Entwurf, Review-Befunde eingearbeitet; von Chris zur Umsetzung von 13a freigegeben (Spec-F1 bis Spec-F9 gelten mit den Empfehlungen aus § 9.2, Spec-F6 ist mit 2c umgesetzt, Spec-F10 am 2026-11-04 von Chris entschieden). Änderung vom 2026-11-04 (Entscheidung Chris, Option B, Spec-F10): Es gibt kein „abgelehnt mit Verweis“ und kein adopt_report_target mehr; rejected nur ohne Ziel, Verweis auf einen vorhandenen Eintrag über merged. |
| Autor | architect |
| Stand | 2026-11-03 |
| Etappe | R1.2 Sammlung |
| Abhängigkeiten | Vorbedingung 13a: #2 PR 2b und 2c gemergt (Spec #2 E12: game_reports, admin_resolve_report, repoint_game_references, Trigger games_visibility_immutable); #3 PR 3a gemergt (search_normalize, game_alt_titles, title_search, pg_trgm, unaccent). Vorbedingung 13b (Merge): #3 PR 3b gemergt (onReportGame im Leerzustand, Krit. 6 von KAT-01). Außerdem #1. Fachlich zusätzlich #6 SAM-01 und #4 KAT-02 (Orte für „in die Sammlung“ und den Hinweis „am Eintrag“), in Forgejo bisher nicht als Abhängigkeit gesetzt (Spec-F8). |
| Quellen | Issue #13 inkl. Kommentare (u. a. Hinweis aus PR #51 vom 2026-11-03), requirements/20-r1-akzeptanzkriterien.md § KAT-04 und § Querschnitt, requirements/r1-klaerungen.md (6, 10, 12, 15, 20, 23, 26, 27, 28, 36, 44/25, 45, N12, N12a, N15), requirements/backlog.md, docs/specs/2-katalog-daten.md (E10–E14, 3.2–3.4, 4.4, 9.5), Spec #3 (3.2: search_normalize, title_search, game_alt_titles), docs/datenmodell-r1.md, supabase/migrations/…catalog.sql, …collection.sql, …plays.sql, …catalog_foundation.sql, CLAUDE.md |
Namenskonvention: „Spec-F1“ bis „Spec-F9“ sind die Fragen an Chris aus § 9.2. „E1“ usw. sind Kernentscheidungen dieser Spec, „#2 E12“ verweist auf Spec #2. „B1“, „F1“, „U1“, „T1“ sind Aufgaben (§ 8).
1. Ziel
Ein Nutzer, der ein Spiel im Katalog nicht findet, meldet es mit wenigen Angaben und kann es sofort als vorläufigen privaten Eintrag nutzen (Entscheidung O5). Der Admin (Chris) prüft jede Meldung manuell im Studio (Klärungen 27, 44; Werkzeug aus #2 PR 2c). Das Ergebnis sieht der Nutzer als Hinweis in der App, nie als E-Mail (Klärungen 26, 45).
Diese Spec baut auf game_reports und admin_resolve_report aus Spec #2 § 3.4 (PR 2c) und auf der Normalisierung aus Spec #3 auf. Sie ergänzt nur, was der Nutzer braucht: Anlegen mit Tageslimit, Ähnlichkeitsprüfung, vorläufigen Eintrag, Ergebnishinweise und das Wechseln auf den Zieleintrag.
Nicht im Scope (aus dem Issue): öffentliche Anzeige gemeldeter Spiele, Bild-Upload, E-Mail/Push, Admin-Oberfläche in der App, automatische Import-Meldungen (SAM-08 #25). Außerdem nicht: Bearbeiten weiterer Felder des vorläufigen Eintrags, Zurückziehen einer Meldung, Auswahl vorläufiger Spiele in Partien (PAR-01 #15 Krit. 3).
Kernentscheidungen (Kurzfassung)
| # | Entscheidung | Begründung |
|---|---|---|
| E1 | Jede Nutzer-Meldung legt genau einen vorläufigen privaten Eintrag an (1:1; der eindeutige Teilindex auf provisional_game_id kommt aus 2c). Er bekommt nur title und year. |
Der Hinweis braucht einen festen Ort, Krit. 3 verlangt die sofortige Nutzung; entspricht Backlog (N12) und Import (SAM-08 Krit. 4). Mit nur Titel und Jahr kann außer der Edition nichts verloren gehen (Krit. 4, N15). |
| E2 | Anlegen über die RPC report_missing_game (security invoker): Limit-Vorprüfung, Ähnlichkeitsprüfung, vorläufiger Eintrag, Meldung, optional Listeneintrag in einer Transaktion. |
Getrennte Client-Aufrufe ließen verwaiste vorläufige Spiele zurück. security invoker: RLS, Spaltenrechte und Trigger gelten weiter. |
| E3 | Schutz liegt in der Datenbank: Spaltenrechte auf game_reports, Insert-Policy, Limit-Trigger mit Sperre je Nutzer. |
Ein direkter REST-Insert umgeht nur den UX-Schritt der Ähnlichkeitsprüfung, nicht Limit oder Rechte. created_at ist nicht schreibbar, das Limit lässt sich nicht durch ein falsches Datum umgehen. |
| E4 | Ähnlichkeitsprüfung serverseitig über find_similar_catalog_games mit extensions.similarity. Grundlage sind die von #3 gepflegten Spalten title_search (Haupttitel in games, alternative Titel in game_alt_titles); 13a entfernt darüber nur führende Artikel (strip_leading_article, normalize_game_title). Nur sichtbare Katalogspiele. |
Klärung 15 ist eine Datenbankregel. Eine einzige Normalisierung für Suche und Meldung (gemeinsamer Absatz in 3.3). Die RPC prüft beim Absenden noch einmal; ohne Bestätigung wird nicht gespeichert (Krit. 2). |
| E5 | Entscheidung Chris, 2026-11-04 (Option B, Spec-F10), ändert Spec #2 Spec-F3: Kein „abgelehnt mit Verweis“. Wer auf einen vorhandenen Eintrag verweisen will, setzt merged (admin_resolve_report hängt direkt um, ohne Nutzeraktion). rejected gibt es nur ohne Ziel; admin_resolve_report weist rejected mit Ziel ab (report_reject_has_target), der Check game_reports_rejected_no_target sichert die Tabelle. Die frühere Funktion adopt_report_target (security definer) entfällt. |
Der Nutzer muss nichts entscheiden; weniger Zustände in der App (nur pending / approved / merged / rejected) und keine Rechteausweitung durch eine Nutzer-RPC auf repoint_game_references. |
| E6 | Ergebnishinweise werden aus vorhandenen Daten abgeleitet und lassen sich wegklicken (result_seen_at, Spec-F5). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft. Fehlt der vorläufige Eintrag (vom Nutzer gelöscht), folgt das Ergebnis nur aus status. |
Kein Benachrichtigungssystem in R1 (QUE-01 erst R2). |
| E7 | Edition (N15): Empfehlung (Spec-F6) ist ein Zähler game_reports.editions_cleared, angelegt und gesetzt in 2c (PR #57, vor dem Merge) aus dem schon vorhandenen variants_cleared von repoint_game_references; 2b bleibt unverändert; adopt_report_target entfällt (Spec-F10), ihn setzt nur admin_resolve_report. Rückfall ist die Ableitung aus den Daten (nur Sammlung, list = 'owned'). |
Der Zähler ist genau und erfasst Sammlung und Partien; der Nachtrag ist eine Spalte und eine Zuweisung im bestehenden Update. Nach dem Merge von 2c wäre ein create or replace von admin_resolve_report in 13a nötig. |
| E8 | Verlag bleibt Freitext in game_reports.publisher_name. |
publishers ist nur für den Admin schreibbar (Spec #2 3.2). |
| E9 | Keine neuen Pakete, keine Edge Function. pg_trgm kommt aus 3a (Spec #3 E14). |
CLAUDE.md, ADR-0002. |
| E10 | Zwei PRs: 13a Backend (Refs #13), 13b Flutter (Closes #13). 13b beginnt gegen ein gemocktes Repository, sobald der RPC-Vertrag (4.2–4.5) freigegeben ist. |
Der Vertrag steht vollständig in dieser Spec. |
2. Akzeptanzkriterien
Übernommen aus Issue #13 / 20-r1-akzeptanzkriterien.md § KAT-04 (Stand 2026-11-02 inkl. Klärungen und N12). Referenz in Tests und PRs: „KAT-04 Kriterium n“.
- 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.
- 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).
- 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.
- 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).
- 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).
- Gegeben die Meldung wurde ohne Zieleintrag abgelehnt, dann bleibt mein vorläufiger Eintrag privat bestehen und ist als „abgelehnt“ markiert (Klärung 26).
- 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).
- 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:
anonkann 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,
textScaler2.0, Tastaturbedienung im Web inkl. Bestätigungsschritt. - Zustände, Fehler und Netz Krit. 1–3: „Meine Meldungen“ mit Lade-, Leer-, Fehler- und Datenzustand; offline Hinweis statt Fehler; bei Limit- oder Netzfehler bleiben die Eingaben erhalten.
- Plattformen Krit. 1, 2: identisch auf iOS, Android und Web; breites Layout im Web.
3. Datenmodell
3.1 Ausgangslage (nach #2 PR 2c und #3 PR 3a)
Aus Spec #2 § 3.4 (Spaltennamen werden so vorausgesetzt; weicht 2c ab, passt B1 die Namen an):
game_reportsmitid,owner_id(FK →profiles,on delete cascade),origin(user/import, Standarduser),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 undresolved_at.- Indizes aus 2c:
(owner_id, created_at)(Tageslimit),game_reports_target_game_id_idx(Hinweis am Zieleintrag), eindeutiger Teilindex aufprovisional_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): beiapproved/mergedUmhängen perrepoint_game_references, am vorläufigen Eintragmerged_into_idundhidden_at; beirejectednichts (ab 13a, Spec-F10:rejectednur ohne Ziel). Gibt die Zähler ausrepoint_game_referenceszurück, daruntervariants_cleared(geleerte Verweise incollection_itemsinkl. Kollisionsfall und inplays), speichert sie aber nicht (Stand PR #57).- Trigger
games_visibility_immutable: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14).
Aus Spec #3 PR 3a: search_normalize(text), die generierten Spalten games.title_search und game_alt_titles.title_search, die Tabelle game_alt_titles sowie die Erweiterungen pg_trgm und unaccent. Der Fremdschlüssel game_alt_titles.game_id und seine Einordnung im Fremdschlüssel-Wächter gehören zu #3.
3.2 Änderungen in 13a (Migration supabase/migrations/<ts>_game_reports_user.sql)
Vorbedingung 13a: #2 PR 2b und 2c gemergt; #3 PR 3a gemergt (search_normalize, game_alt_titles). Nur additiv; der Zeitstempel liegt nach diesen Migrationen. pg_trgm und unaccent legt Spec #3 (3a) an; 13a nutzt extensions.similarity. Rechte-Regel aus Spec #2 § 3.2: jede neue Funktion zuerst revoke execute … from public, anon, authenticated, service_role, danach nur die Grants aus 4.1.
Neue Spalte in game_reports
| Spalte | Typ | Hinweis |
|---|---|---|
result_seen_at |
timestamptz | nullable; der Nutzer hat den Ergebnishinweis weggeklickt (E6, Spec-F5). Check result_seen_at is null or status <> 'open'. |
Bei Spec-F6 = Zähler (Empfehlung) legt 2c die Spalte editions_cleared integer not null default 0 check (editions_cleared >= 0) an (Nachtrag Spec #2 § 3.4 / PR #57), nicht 13a.
Rechte auf game_reports (E3)
grant insert (owner_id, title, publisher_name, year, link, provisional_game_id)
on public.game_reports to authenticated;
grant update (result_seen_at) on public.game_reports to authenticated;
Bewusste Änderung gegenüber Spec #2 § 3.4: Dort haben Nutzer kein Insert- und kein Update-Recht, und 08_game_reports.test.sql prüft, dass nur postgres Meldungen ändert. 13a erlaubt das Anlegen eigener Meldungen und das Ändern genau einer Spalte (result_seen_at) an eigenen, bereits bearbeiteten Meldungen. Das bleibt mit KAT-05 Krit. 9 vereinbar (verboten ist das Ändern fremder Meldungen); zu den Tests aus 08 siehe § 7 „Bestehende Tests“.
select bleibt wie in 2c. Nicht schreibbar für Nutzer: id, origin, status, bgg_id, target_game_id, resolved_at, created_at, updated_at (42501). Damit setzt die Datenbank origin = 'user', status = 'open' (Krit. 8) und created_at = now() (Tageslimit) selbst. delete bekommt der Nutzer nicht.
Merkpunkt für den Client (aus #2 PR 2a): Clients schreiben games und game_reports nie mit id, created_at oder updated_at, und ein toJson() eines Modells geht nie ungefiltert an insert/update. Die App schreibt in #13 nur über die RPCs (4.2, 4.4) und das Update von result_seen_at (4.5) mit ausdrücklicher Spaltenliste.
RLS in Klartext (neu in 13a)
- Anlegen (
game_reports_insert_own,for insert to authenticated):owner_id = auth.uid(),origin = 'user',status = 'open',target_game_id is nullundprovisional_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_gamebildet das aufreport_provisional_in_useab, die App auf „unbekannter Fehler“. - Ändern (
game_reports_update_seen,for update to authenticated):using (owner_id = auth.uid() and status <> 'open'),with checkgleich. Über die Spaltenrechte ist nurresult_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_privatelöschen, solange nichts darauf verweist; dann wirdprovisional_game_idperon delete set nullleer (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'undowner_id = new.owner_idmitpublic.report_day(created_at) = public.report_day(now()). Bei ≥ 10 Fehlerreport_daily_limit(P0001). Die Zahl 10 ist eine Konstante in der Funktion; Änderung = neue Migration. Optional (backend-dev): als Bereichcreated_at >= (date_trunc('day', now() at time zone 'Europe/Berlin') at time zone 'Europe/Berlin')formulieren, damit der Index(owner_id, created_at)greift; die Bedeutung bleibt gleich. - Hilfsfunktion
public.report_day(p_at timestamptz) returns date=(p_at at time zone 'Europe/Berlin')::date,language sql,immutable,search_path = ''.grant execute … to authenticated(rechnet nur): Der Trigger läuft alssecurity invokermit der Rolle des Einfügenden; ohne dieses Recht scheitert jede Meldung mit 42501. Herausgezogen, damit pgTAP die Tagesgrenze mit festen Zeitstempeln prüfen kann. - Trigger-Funktion
security invoker,search_path = '': Der Nutzer sieht per RLS alle eigenen Meldungen; Studio (postgres) umgeht RLS ohnehin. - Import-Meldungen (
origin = 'import', SAM-08) werden weder gezählt noch geprüft (N12).
Keine neue Tabelle, kein neuer Fremdschlüssel auf games. Die Tabellenzahl in 00_schema.test.sql bleibt gegenüber main unverändert; der Fremdschlüssel-Wächter aus #2 bleibt durch 13a unberührt.
3.3 Normalisierung und „sehr ähnlich“ (Krit. 2, Klärung 15)
Gemeinsame Normalisierung (gleichlautend in Spec #3 und Spec #13):
public.search_normalize(text)ist die einzige Basisfunktion für normalisierte Titel. Sie wird von #3 (PR 3a) angelegt, ist rein rechnend und füranon,authenticatedundservice_roleausführbar, weil generierte Spalten undsecurity invoker-Funktionen sie mit den Rechten des Aufrufers berechnen. #13 legtpublic.normalize_game_title(text)darüber an und entfernt nur dort Artikel;search_normalizeentfernt nie Artikel.
unaccent mit festem Wörterbuch als immutable zu deklarieren ist eine verbreitete Konvention, keine Garantie von PostgreSQL.
In 13a neu (beide language sql, immutable, parallel safe, search_path = '', grant execute … to authenticated; anon braucht sie nicht):
public.strip_leading_article(t text) returns text: entfernt einmal ein führendes Artikelwort (Deutschder,die,das,den,dem,des,ein,eine,einen,einem,einer,eines; Englischthe,a,an) samt folgendem Leerzeichen; erwartet bereits normalisierten Text (Kleinbuchstaben, ein Leerzeichen als Trenner).public.normalize_game_title(t text) returns text=coalesce(nullif(public.strip_leading_article(public.search_normalize(t)), ''), nullif(public.search_normalize(t), ''), lower(btrim(t))). Normalisiert den gemeldeten Titel. Der letzte Rückfall greift bei Titeln, die nachsearch_normalizeleer sind (z. B. nur nicht-lateinische Schriftzeichen oder nur Sonderzeichen).
Kandidatenschlüssel je Kandidatentitel c eines Katalogspiels g (verbindlich, Spec #3 3.2 Nr. 4): Haupttitel über games.title_search, alternative Titel über game_alt_titles.title_search (nicht neu aus dem Rohtitel berechnet):
key(c) = coalesce(nullif(public.strip_leading_article(c.title_search), ''), c.title_search, lower(btrim(c.title)))
Der letzte Rückfall lower(btrim(c.title)) gilt nur beim Haupttitel (für alternative Titel gibt es keinen Rohtitel-Rückfall).
Beispiele (pgTAP; das genaue Ergebnis von search_normalize legt #3 fest): „Die Siedler von Catan“ und „siedler von catan“ ergeben denselben Wert; „CATAN!“ = „Catan“; „The Crew“ = „Crew“; „Das“ bleibt nicht leer; „Mölkky“ = „Molkky“ (Faltung durch unaccent); ein Titel nur aus japanischen Schriftzeichen → lower(btrim(t)).
Regel „sehr ähnlich“ zwischen Meldung (Titel t, Jahr y) und Katalogspiel g mit Haupttitel und allen alternativen Titeln (Kandidatentitel c, verpflichtend):
- A (gleicher Titel):
key(c) = normalize_game_title(t)für mindestens einc, unabhängig vom Jahr. - B (Trigramm):
extensions.similarity(key(c), normalize_game_title(t)) >= 0.8für mindestens eincundabs(g.year - y) <= 1. Fehltyoderg.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ücklichextensions.similarity(...) >= 0.8verwenden, nicht den Operator%(nutztpg_trgm.similarity_threshold, Standard 0,3). Deshalb kein Trigramm-Index in 13a; bei ~200–2000 Katalogspielen reicht ein sequenzieller Durchlauf.
4. Backend
4.1 Überblick und Rechte
| Objekt | Art | execute |
Zweck |
|---|---|---|---|
strip_leading_article(text), normalize_game_title(text) |
Funktion, sql, immutable | authenticated |
Normalisierung (3.3) |
report_day(timestamptz) |
Funktion, sql, immutable | authenticated |
Berliner Kalendertag (3.2) |
find_similar_catalog_games(p_title text, p_year integer) |
RPC, security invoker | authenticated |
Ähnliche Katalogspiele (Krit. 2) |
report_missing_game(...) |
RPC, security invoker | authenticated |
Meldung + vorläufiger Eintrag + optional Listeneintrag (Krit. 1, 2, 3, 7, 8) |
game_reports_daily_limit() |
Trigger-Funktion, security invoker | – | Tageslimit (Krit. 7) |
Policies game_reports_insert_own, game_reports_update_seen, Spaltenrechte |
RLS | – | 3.2 |
Jede Funktion: zuerst revoke execute … from public, anon, authenticated, service_role, dann nur die Grants dieser Tabelle. pgTAP prüft mit has_function_privilege für alle vier Rollen (public über eine Rolle ohne eigene Rechte, anon, authenticated, service_role).
Fehler: raise exception '<code>' using errcode = 'P0001'; die App bildet den Code auf einen Domänenfehler ab (5.3).
4.2 RPC public.report_missing_game
report_missing_game(
p_title text,
p_publisher_name text default null,
p_year integer default null,
p_link text default null,
p_list text default null, -- 'owned' | 'wishlist' | 'shopping' | null
p_confirmed_distinct boolean default false
) returns jsonb -- {"report_id": uuid, "provisional_game_id": uuid, "collection_item_id": uuid | null}
language plpgsql, security invoker, search_path = ''. p_year ist bewusst integer und wird selbst geprüft (ein smallint-Parameter würde bei großen Werten schon beim Aufruf mit 22003 scheitern, ohne verständlichen Code).
Ablauf (eine Transaktion; jeder Fehler rollt alles zurück):
auth.uid()ist null →not_authenticated.btrimauf Titel, Verlag, Link; leere Zeichenketten werdennull.- Prüfungen in dieser Reihenfolge:
| Prüfung | Fehler |
|---|---|
| Titel leer | report_title_required |
| Titel > 200 Zeichen | report_title_too_long |
| Verlag > 200 Zeichen | report_publisher_too_long |
| Jahr außerhalb 1800–2100 | report_year_invalid |
Link > 500 Zeichen oder nicht ^https?:// |
report_link_invalid |
p_list nicht in owned, wishlist, shopping, null |
report_list_invalid |
Vorprüfung Tageslimit: schon 10 eigene Nutzer-Meldungen heute (report_day) |
report_daily_limit |
p_confirmed_distinct = false und find_similar_catalog_games liefert ≥ 1 Treffer |
report_similar_exists |
Die Limit-Vorprüfung steht vor der Ähnlichkeitsprüfung, damit der Nutzer nicht erst Kandidaten bestätigt und dann am Limit scheitert. Maßgeblich bleibt der Trigger (Sperre, Schritt 5). Die übrigen Prüfungen spiegeln die Checks aus 2c für gezielte Feldfehler; Tabellen-Checks bleiben die Grenze (23514 → „unbekannter Fehler“).
4. insert into public.games (owner_id, title, year, visibility) values (auth.uid(), title, year, 'private') returning id (keine id).
5. insert into public.game_reports (owner_id, title, publisher_name, year, link, provisional_game_id) … returning id. Hier greift der Limit-Trigger; 23505 aus dem Teilindex → report_provisional_in_use.
6. Wenn p_list gesetzt: insert into public.collection_items (owner_id, game_id, list) values (auth.uid(), provisional_id, p_list) returning id. Keine Edition.
7. Rückgabe als jsonb.
Aufrufreihenfolge in der App: erst find_similar_catalog_games, dann report_missing_game mit p_confirmed_distinct = true nach Bestätigung. Ein Client, der direkt true sendet, umgeht nur den UX-Schritt (E3).
4.3 RPC public.find_similar_catalog_games
find_similar_catalog_games(p_title text, p_year integer default null)
returns table (
game_id uuid,
title text, -- Haupttitel
matched_title text, -- Titel, der getroffen hat (Haupt- oder alternativer Titel)
year smallint,
publishers text[], -- sortiert
match_kind text, -- 'same_title' | 'similar_title'
similarity real -- 1.0 bei 'same_title'
)
language sql, stable, security invoker, search_path = ''. Liest catalog_games und verbindet per Primärschlüssel mit games (für title_search), außerdem game_alt_titles, game_publishers, publishers. Regel und Kandidatenschlüssel nach 3.3. Sortierung: same_title zuerst, dann similarity absteigend, dann title; höchstens 5 Treffer. Ein Jahr außerhalb 1800–2100 gilt wie null (kein Überlauf, kein 22003). Leerer Titel oder Titel > 200 Zeichen → leere Menge (kein Fehler; report_missing_game meldet die Länge selbst). Nur authenticated.
4.4 entfällt (Entscheidung Chris, 2026-11-04, Option B, Spec-F10)
Die frühere RPC adopt_report_target (Wechsel auf den Zieleintrag nach „abgelehnt mit Verweis“) wird nicht gebaut. Stattdessen ändert 13a admin_resolve_report per create or replace (Rumpf aus 2c unverändert) um genau eine Prüfung: p_status = 'rejected' mit p_target is not null löst report_reject_has_target (P0001) aus. Zusätzlich verbietet der Check game_reports_rejected_no_target (status <> 'rejected' or target_game_id is null) ein Ziel bei abgelehnten Meldungen in der Tabelle. Der Verweis auf einen vorhandenen Eintrag läuft über merged (Umhängen durch den Admin, repoint_game_references, wie bei approved).
4.5 Ergebnishinweis wegklicken
Kein RPC. Die App sendet PATCH /rest/v1/game_reports?id=eq.<id> mit genau {'result_seen_at': <aktuelle Zeit, ISO-8601 UTC>} (PostgREST setzt Werte, keine Ausdrücke wie now()). Die Client-Zeit ist unkritisch (steuert nur die eigene Anzeige). Bei offener oder fremder Meldung ändert sich keine Zeile.
4.6 Lesen für die App
- Meine Meldungen:
game_reportsmitid, 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ätzlicheditions_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_reportsmitor=(provisional_game_id.eq.<id>,target_game_id.eq.<id>)(Indizes aus 2c) plus eingebettetemprovisional. - Edition, nur bei Rückfall Ableitung:
collection_itemsdes Nutzers zum Zielspiel mitlist = 'owned'(variant_id) undprovisional.game_variants.
4.7 Doku
docs/datenmodell-r1.md:result_seen_at, Spaltenrechte, Policies, Trigger, Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes; Verweis auf den gemeinsamen Absatz zur Normalisierung (3.3).docs/katalog-pflege.md, Abschnitt „Meldungen bearbeiten“ (2c) ergänzen: Nutzer-Meldungen haben einen vorläufigen Eintrag (sofern nicht gelöscht); Verweis auf einen vorhandenen Eintrag =merged(Spec-F10),rejectednur ohne Ziel; vorapprovedundmergeddie Edition berücksichtigen (Runbook 2c); Status nie von Hand setzen, nur überadmin_resolve_report, weil die App den Hinweis aus Status undmerged_into_idableitet.supabase/README.md: neue RPCs und Rechte.
5. Frontend
5.1 Struktur
Neues Feature app/lib/features/game_reports/ (Feature-first, Abhängigkeiten nach innen):
features/game_reports/
domain/
game_report.dart freezed: GameReport
game_report_status.dart enum GameReportStatus { open, approved, rejected, merged }
game_report_origin.dart enum GameReportOrigin { user, import }
report_outcome.dart sealed ReportOutcome (5.4) + reine Funktion outcomeOf(...)
game_report_draft.dart freezed: Eingaben + Validierung
similar_game.dart freezed: SimilarGame (gameId, title, matchedTitle, year, publishers, matchKind)
game_report_failure.dart sealed: dailyLimit, similarExists, validation(field, code), offline, unknown
game_report_repository.dart abstrakte Schnittstelle
data/
supabase_game_report_repository.dart RPC-Aufrufe und Abfragen (4.2–4.6), explizite Parameter-Maps
game_report_dto.dart nur fromJson; kein toJson für Schreibzugriffe
game_report_failure_mapper.dart P0001-Codes → GameReportFailure
presentation/
report_form/ similar_games/ my_reports/ report_detail/
widgets/report_outcome_notice.dart wiederverwendbar (auch für #4/#6/#11)
providers/ Repository-, Listen- und Hinweis-Provider (Riverpod, generiert)
game_report_routes.dart
Die Domäne importiert weder Flutter noch Supabase; Validierung in GameReportDraft, nicht im Widget.
5.2 Routen (go_router)
| Route | Screen | Hinweis |
|---|---|---|
/reports/new?title=<suchbegriff> |
ReportFormScreen |
Einstieg aus der leeren Trefferliste von KAT-01 (onReportGame, #3 PR 3b, Krit. 6); Anmeldung nötig (Redirect /login?from=…, Spec #1) |
/reports |
MyReportsScreen |
eigene Meldungen |
/reports/:reportId |
ReportDetailScreen |
Status, Angaben, Hinweis, Aktionen; fremde/unbekannte ID → „nicht gefunden“ |
Konstanten in AppRoutes. Weitere Einstiege zu /reports (Einstellungen, Dashboard) stimmt ui-designer mit Chris ab.
5.3 Melden (Krit. 1, 2, 3, 7, 8)
ReportFormController (Riverpod-Notifier, Zustände als freezed-Union):
| Zustand | UI |
|---|---|
editing(draft, fieldErrors) |
Titel (Pflicht), Verlag, Jahr, Link (optional); „Gleich aufnehmen in“: Sammlung / Wunschliste / Einkaufsliste / nicht aufnehmen (Standard Sammlung, Spec-F9) |
checking |
find_similar_catalog_games läuft; Ladeanzeige, Felder gesperrt |
similarFound(draft, candidates) |
Kandidaten (Titel, getroffener Titel falls alternativ, Jahr, Verlage, GameCover). „Das ist mein Spiel“ → Detailseite des Katalogspiels (#4), keine Meldung. Ausdrückliche Bestätigung: Checkbox „Mein Spiel ist keines davon“ + „Trotzdem melden“ (erst mit Haken aktiv) |
submitting |
report_missing_game läuft |
submitted(result) |
Bestätigung (Krit. 1): „Danke, deine Meldung ist eingegangen. Wir prüfen sie von Hand; bis dahin kannst du das Spiel als vorläufigen Eintrag nutzen.“ ggf. Liste; Aktionen „Zur Meldung“, „Zur Sammlung“ |
failure(draft, failure) |
Eingaben bleiben erhalten. dailyLimit → „Du hast heute schon 10 Spiele gemeldet. Morgen geht es wieder.“ (Krit. 7); similarExists → zurück zu similarFound; Feldfehler am Feld; offline → „Melden braucht eine Verbindung“ |
Ohne Treffer geht checking direkt in submitting (p_confirmed_distinct = false), mit Treffern nach Bestätigung mit true. Lokale Vorprüfung derselben Feldregeln wie 4.2; das Limit prüft nur der Server (die Vorprüfung in der RPC meldet es vor dem Kandidatenschritt, wenn der Nutzer ohne Treffer sendet; bei Treffern spätestens beim Absenden).
Krit. 3 nachträglich: Bei „nicht aufnehmen“ kann der Nutzer den vorläufigen Eintrag später auf der Meldungsdetailseite zu einer Liste hinzufügen, über die Funktion aus SAM-01/SAM-04/SAM-05 (#6/#8/#9) bzw. einen Insert in collection_items mit Spaltenliste owner_id, game_id, list (Spec-F8).
5.4 Ergebnishinweise (Krit. 4, 5, 6, 8)
outcomeOf(report, provisional?, ownedItemOnTarget?) ist eine reine Funktion (Unit-Tests je Fall). Fehlt der vorläufige Eintrag (provisional_game_id null, vom Nutzer gelöscht), folgt das Ergebnis nur aus status und target_game_id; Aktion „wechseln“ und Edition-Ableitung entfallen.
| Fall | Erkennung | Hinweis (Kurzform, ARB) | Aktionen | Wo sichtbar |
|---|---|---|---|---|
pending (Krit. 8) |
status = open |
„Gemeldet am …, wird geprüft“ | – | Meldung; vorläufiger Eintrag (Badge „vorläufig“) |
approved (Krit. 4) |
status = approved |
„Dein gemeldetes Spiel ist jetzt im Katalog. Deine Angaben wurden übernommen.“ | „Zum Spiel“, „Verstanden“ | Meldung; Eintrag am Zielspiel, nur wenn provisional.merged_into_id = target |
merged (Krit. 5) |
status = merged |
„Dein Spiel gab es schon im Katalog als „{title}“. Dein Eintrag zeigt jetzt dorthin.“ | „Zum Spiel“, „Verstanden“ | wie approved |
rejected (Krit. 6) |
status = rejected, kein Ziel |
dauerhafte Markierung „Abgelehnt“ + „Dieses Spiel wird nicht in den Katalog aufgenommen. Dein Eintrag bleibt privat für dich.“ | „Verstanden“ (blendet nur den Text aus) | Meldung; vorläufiger Eintrag |
Zusatz Edition (E7, N15) bei approved und merged: „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“
- Bei Spec-F6 = Zähler (Empfehlung): wenn
editions_cleared > 0. Der Text nennt Sammlung und Partien („in deiner Sammlung oder deinen Partien“). - Rückfall Ableitung: wenn der vorläufige Eintrag mindestens eine eigene Variante hat und der Sammlungseintrag am Ziel mit
list = 'owned'variant_id = nullhat. Wunsch- und Einkaufsliste haben nach Klärung 28 nie eine Edition und werden nicht geprüft; Partien werden nicht einzeln geprüft (Grenze der Ableitung). - Aktion „Edition wählen“ → Bearbeitung des Sammlungseintrags (KAT-03 #12 / SAM-02 #7), sobald vorhanden.
„Verstanden“ setzt result_seen_at (4.5); danach kein Hinweis mehr am Eintrag, in „Meine Meldungen“ bleibt der Status sichtbar.
Hinweis „am Eintrag“: reportNoticeForGameProvider(gameId) liefert den passenden Fall: am vorläufigen Eintrag (provisional_game_id = gameId) die Fälle pending und rejected; am Zielspiel (target_game_id = gameId, result_seen_at is null) nur approved und merged, und nur wenn provisional.merged_into_id = gameId. ReportOutcomeNotice (kompakt/ausführlich) wird eingebaut in 13b in „Meine Meldungen“ und die Meldungsdetailseite, in #4/#6/#11 soweit gemergt, sonst übernehmen diese Stories den Einbau (Spec-F8, 9.4). Vorläufige Einträge zeigen ein Badge „vorläufig“ (Text) und den GameCover-Platzhalter. Zustände nach der Entscheidung vom 2026-11-04: nur pending, approved, merged, rejected (ohne Ziel).
Aktualität: kein Realtime; Neuladen beim Öffnen und per Pull-to-Refresh.
5.5 Meine Meldungen
MyReportsController (AsyncNotifier mit Paging, 20 je Seite): Laden, Leer („Du hast noch keine Spiele gemeldet“ + Weg zur Suche), Fehler mit „Erneut versuchen“, Daten. Je Zeile Titel, Jahr, Datum, Status-Badge, ggf. kompakter Hinweis; Import-Meldungen mit Kennzeichen „aus BGG-Import“ (Text liefert #25).
5.6 l10n (Auszug, de/en)
Präfix gameReport…: Felder und Fehler (gameReportTitleRequired, gameReportTitleTooLong, gameReportPublisherTooLong, gameReportYearInvalid, gameReportLinkInvalid), Ähnlichkeit (gameReportSimilarHeading, gameReportSimilarIsMine, gameReportSimilarConfirmDistinct, gameReportSubmitAnyway), Listenwahl, Bestätigung, gameReportDailyLimit, Offline, Status-Badges, gameReportProvisionalBadge, Hinweise aus 5.4 inkl. Edition, Aktionen, Leer-/Fehlerzustand, Semantics-Labels; Platzhalter {title}, Datum über intl.
6. Plattform-Besonderheiten
- iOS, Android, Web: gleiches Verhalten; Link-Feld
TextInputType.url, Jahr mit Ziffern-Tastatur; der Link wird nur als Text angezeigt, nicht geöffnet. - Web: teilbare URLs; Bestätigungsschritt per Tastatur bedienbar; breites Layout (Formular und Kandidaten nebeneinander ab ≥ 840 dp, ui-designer).
- Offline: Melden braucht eine Verbindung (wie SAM-01 Krit. 3); Hinweis statt Fehler, Eingaben bleiben. Keine Offline-Warteschlange.
- Push, Kamera, Dateien: nicht betroffen.
7. Teststrategie
Werkzeuge: pgTAP (supabase test db), flutter_test + mocktail, Playwright (Web-E2E). Testnamen enthalten „KAT-04 Kriterium n“.
Neue pgTAP-Datei supabase/tests/database/10_game_reports_user.test.sql (08 gehört 2c, 09 gehört #3). Fixtures mit festen Test-UUIDs, Nutzer A und B, unabhängig vom Startbestand. Einfügungen „als Nutzer“ laufen wirklich als Rolle authenticated mit gesetztem request.jwt.claims, nicht als postgres.
7.1 Abdeckungsmatrix
| AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task |
|---|---|---|---|---|---|
| Krit. 1 | GameReportDraft: Titel Pflicht (auch nur Leerzeichen), Längen, Jahr, Link; Fehler-Mapping |
Feldfehler, Bestätigung, Listenwahl | Playwright: leere Suche → „Spiel melden“ → Titel vorbelegt → Bestätigung | report_missing_game als authenticated: Meldung (open, user) und vorläufiges Spiel (private, nur Titel/Jahr); Trimmen; alle Validierungsfehler; Jahr 99999 → report_year_invalid (kein 22003); anon → 42501 |
B1, B2, F2, T1, T2 |
| Krit. 2 | Controller: Treffer → similarFound; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; similarExists → zurück |
Kandidatenliste, Checkbox aktiviert Button, Tastatur, Semantics | Playwright: Meldung „Catan“ zeigt „CATAN“ | strip_leading_article/normalize_game_title: Artikel de/en nur führend, Großbuchstaben, Sonderzeichen, Umlaute über search_normalize, nur Artikel, nicht-lateinischer Titel (Rückfall); find_similar_catalog_games: gleicher normalisierter Titel bei beliebigem Jahr; Trigramm ≥ 0,8 bei ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; fehlendes Jahr nach Spec-F2; Treffer über title_search des alternativen Titels (matched_title); Haupttitel über games.title_search; ausgeblendete, zusammengeführte, private Spiele nicht; Titel > 200 → leere Menge; höchstens 5, Sortierung; report_similar_exists ohne, Speichern mit Bestätigung |
B1, B2, F2, U1, T1, T2 |
| Krit. 3 | – | Bestätigung zeigt Liste; Badge „vorläufig“ | Playwright: gemeldetes Spiel in der Wunschliste (sofern #6/#8 da) | p_list legt collection_items an; B sieht nichts von A; anon nichts; Rollback bei Fehler im Listeneintrag |
B1, B2, F2, F3 |
| Krit. 4 | outcomeOf: approved mit/ohne Edition; vorläufiger Eintrag fehlt |
Hinweis de/en, „Verstanden“ | Playwright: Testsetup bearbeitet per admin_resolve_report(…, 'approved', …) → Hinweis am Eintrag |
nach admin_resolve_report: Sammlungseintrag von A am Ziel mit Liste/Notiz/Kaufdatum; Edition ohne gleichnamige Variante → null; private Variante für A lesbar; bei Zähler editions_cleared korrekt; result_seen_at setzbar |
B2, F3, T1, T2 |
| Krit. 5 | outcomeOf: merged (mit/ohne Edition-Hinweis, vorläufiger Eintrag fehlt) |
Hinweise am Eintrag und am Zielspiel, „Verstanden“ | Playwright: Testsetup bearbeitet per admin_resolve_report(…, 'merged', …) → Eintrag zeigt das Zielspiel |
admin_resolve_report(…, 'merged', ziel): Listeneintrag und Partien zeigen aufs Ziel, merged_into_id/hidden_at gesetzt, result_seen_at setzbar; rejected mit Ziel → report_reject_has_target; Check game_reports_rejected_no_target (Insert und Update) |
B1, B2, F3, T1, T2 |
| Krit. 6 | outcomeOf: rejected ohne Ziel |
Markierung bleibt nach „Verstanden“ | – | nach admin_resolve_report(…, 'rejected') bleibt das vorläufige Spiel privat und lesbar |
B2, F3, T2 |
| Krit. 7 | Mapping report_daily_limit |
Limit-Hinweis, Eingaben bleiben | – | als authenticated 10 Meldungen über report_missing_game ok, 11. → report_daily_limit, kein vorläufiges Spiel übrig; ebenso direkter Insert als authenticated (prüft, dass der Trigger report_day ausführen darf); Meldungen von gestern (created_at als postgres gesetzt) zählen nicht; report_day mit festen Zeitstempeln (2026-01-15 23:30 UTC → 16.01., 2026-07-15 21:59 UTC → 15.07., 22:00 UTC → 16.07.); origin = 'import' als postgres zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; Insert mit created_at, origin oder status → 42501 |
B1, B2, F2, T1 |
| Krit. 8 | – | Status „offen“ | – | Nutzer setzt status, target_game_id, resolved_at weder per Insert noch Update (42501); Update von result_seen_at bei open → 0 Zeilen |
B2, T1 |
| Sicherheit 1, 3 | – | – | – | B liest A's Meldungen und Spiele nicht, kann sie nicht wegklicken (0 Zeilen) und nicht übernehmen; Insert mit owner_id = A als B, ohne provisional_game_id, mit fremdem/Katalog-/ausgeblendetem Spiel → Policy-Fehler; zweite Meldung zum selben vorläufigen Eintrag → 23505; Spaltenrechte per has_column_privilege |
B2 |
| Sicherheit 2 | – | – | – | anon: kein select, insert, update, keine RPC |
B2 |
| Datenschutz | – | – | – | Konto A löschen → Meldungen, vorläufige Spiele, Listeneinträge weg; B unverändert | B2 |
| QUE-05/06, Zustände | – | de/en, meetsGuideline, textScaler 2.0, Lade/Leer/Fehler/Daten, Offline |
Playwright: Tastatur im Bestätigungsschritt | – | F2, F3, T2 |
Nicht automatisiert: echte Parallelität des Limits (Code-Review und dokumentierter manueller Test mit zwei psql-Sitzungen unter READ COMMITTED); Bearbeitung echter Meldungen auf Staging (Chris).
Bestehende Tests: Die Tests aus 08_game_reports.test.sql (2c), die einen Insert oder ein Update als authenticated mit 42501 erwarten, bleiben nach 13a grün, aber aus anderem Grund: Statt des fehlenden Tabellenrechts greifen jetzt Spaltenrechte bzw. RLS. B2 passt nur die Testbeschreibungen an, nicht die Erwartungen. Tabellenzahl in 00_schema.test.sql unverändert gegenüber main.
8. Aufgaben
8.1 PR 13a – Backend (Refs #13), Branch feature/13a-meldungen-backend
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| B1 | backend-dev | Migration nach 3.2–4.3 und 4.5: strip_leading_article, normalize_game_title, report_day (mit execute für authenticated), Spalte result_seen_at, Spaltenrechte, Policies, Trigger game_reports_daily_limit (when (new.origin='user'), Sperre), RPCs find_similar_catalog_games (Kandidatenschlüssel über title_search) und report_missing_game; admin_resolve_report per create or replace mit report_reject_has_target und Check game_reports_rejected_no_target (4.4, Spec-F10); Rechte nach 4.1. adopt_report_target entfällt. |
#2 PR 2b und 2c gemergt; #3 PR 3a gemergt |
| B2 | backend-dev | pgTAP 10_game_reports_user.test.sql nach 7.1; Testbeschreibungen in 08 anpassen |
B1 |
| B3 | backend-dev | Doku nach 4.7 | B1 |
| T1 | test-engineer | Review der pgTAP-Abdeckung; manueller Paralleltest des Limits dokumentieren | B2 |
8.2 PR 13b – Flutter (Closes #13), Branch feature/13b-meldungen-app
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| U1 | ui-designer | Formular, Kandidatenschritt, Bestätigung, „Meine Meldungen“, Detail, ReportOutcomeNotice, Badge, Web-Layout; Texte de/en |
Spec freigegeben |
| F1 | flutter-dev | Domäne (5.1) inkl. outcomeOf mit allen Fällen aus 5.4 (auch „vorläufiger Eintrag fehlt“); Unit-Tests |
Spec freigegeben (parallel zu B1) |
| F2 | flutter-dev | Data-Schicht (explizite Parameter, kein toJson() an Schreibzugriffe, Fehler-Mapper inkl. report_provisional_in_use) und Melden-Ablauf (5.3), Routen, Einstieg über onReportGame aus #3 PR 3b, l10n |
F1, U1; gegen Backend: B1 |
| F3 | flutter-dev | „Meine Meldungen“, Detail, Hinweise (5.4), Einbau in #4/#6/#11 soweit gemergt | F1, F2 |
| T2 | test-engineer | Widget-Tests und Playwright nach 7.1 | F2, F3, B1 |
8.3 Reihenfolge und Parallelität
Vorbedingung 13a: #2 PR 2b und 2c gemergt; #3 PR 3a gemergt (search_normalize, game_alt_titles)
Vorbedingung 13b (Merge): #3 PR 3b gemergt (onReportGame im Leerzustand, Krit. 6 von KAT-01)
13a: B1 → B2 → B3, T1
13b: U1 ─┐
F1 ─┴→ F2 (Mock-Repository, danach gegen B1) → F3 → T2
13b wird nach 13a gemergt.
F1 und U1 starten nach Freigabe der Spec, parallel zu B1. Merge nur durch Chris.
9. Risiken & offene Fragen
9.1 Risiken
| # | Risiko | Auswirkung | Gegenmaßnahme |
|---|---|---|---|
| R1 | 2b/2c/#3 weichen von den beschriebenen Namen oder Rückgaben ab | 13a passt nicht | B1 liest die gemergten Migrationen zuerst; Abweichungen im PR nennen |
| R2 | adopt_report_target ist security definersecurity definer-Funktion mehr; das Umhängen macht ausschließlich der Admin in admin_resolve_report (security invoker, nur postgres/supabase_admin). |
– | – |
| R3 | Schwelle 0,8 ist bei kurzen Titeln streng, bei Reihen großzügig | zu wenige/zu viele Kandidaten | Klärung 15 verbindlich; Kandidatenschritt verlangt nur eine Bestätigung |
| R4 | Limit-Sperre wirkt nur unter READ COMMITTED | unter höherer Isolation 11+ Meldungen möglich | PostgREST-Standard; in 3.2 festgehalten; manueller Paralleltest |
| R5 | Ableitung der Edition (Rückfall) ist ungenau | unnötige oder fehlende Hinweise, Partien nicht erfasst | Empfehlung Zähler editions_cleared (Spec-F6) |
| R6 | Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11 | Krit. 4–6 sonst nur in „Meine Meldungen“ | Spec-F8 |
| R7 | Mehrfachmeldungen desselben Spiels | mehr Arbeit für Chris | Tageslimit; Mit Verweis auf den vorhandenen Eintrag merged setzen |
| R8 | Kein Realtime | Ergebnis erst beim nächsten Laden | akzeptiert |
| R9 | pg_trgm bildet Trigramme abhängig von der Datenbank-Locale |
Ähnlichkeitswerte könnten zwischen Umgebungen abweichen | Die Normalisierung liefert a–z0–9; nur der Rückfall lower(btrim(t)) ist locale-abhängig. Akzeptiert (Spec #3 Spec-F7: nur de/en). |
9.2 Offene Fragen an Chris (Spec-F1 bis Spec-F9)
Ohne andere Entscheidung gilt mit der Freigabe die Empfehlung. Ausnahme Spec-F6: Sie blockiert nicht die Umsetzung von #13, muss aber vor dem Merge von PR #57 (2c) entschieden werden. Alle anderen Fragen blockieren nichts.
| # | Frage | Empfehlung | Begründung |
|---|---|---|---|
| Spec-F1 | Krit. 1: Sind Verlag und Jahr Pflicht? | Optional, nur der Titel ist Pflicht. | Nutzer kennen das Jahr oft nicht; 2c hat beide Spalten nullable. |
| Spec-F2 | Krit. 2: Trigramm-Regel bei fehlendem Jahr? | ≥ 0,8 ohne Jahresbedingung. | Lieber einmal mehr nachfragen; der Schritt verlangt nur eine Bestätigung. |
| Spec-F3 | Krit. 2 „Artikel ignoriert“: welche Artikel und wo? (Sonderzeichen, Groß-/Kleinschreibung und Akzente regelt search_normalize aus Spec #3.) |
Nur ein führender Artikel auf Deutsch oder Englisch (Liste in 3.3). | Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“); andere Sprachen sind im Katalog selten. |
| Spec-F4 | Krit. 7 „heute“: Kalendertag oder 24 Stunden? | Kalendertag Europe/Berlin. | Entspricht „heute“; verständlich („morgen geht es wieder“). |
| Spec-F5 | Krit. 4–6: Wie lange bleibt der Hinweis sichtbar? | Bis „Verstanden“; „abgelehnt“ bleibt dauerhaft markiert. | Dauerhafte Hinweise stören, zeitgesteuertes Ausblenden wird übersehen. |
| Spec-F6 | N15, Edition: Woran erkennt die App die geleerte Edition? Entscheidung vor dem Merge von PR #57. | Zähler game_reports.editions_cleared integer not null default 0 check (editions_cleared >= 0). Nachtrag nur in Spec #2 § 3.4 / PR 2c: Spalte anlegen und in admin_resolve_report im bestehenden Update editions_cleared = coalesce((result->>'variants_cleared')::int, 0) setzen. 2b bleibt unverändert: repoint_game_references liefert variants_cleared bereits (geleerte Verweise in collection_items inkl. Kollisionsfall und in plays). 13a setzt den Wert in adopt_report_target genauso. Fällt die Entscheidung erst nach dem Merge von 2c, bleibt nur ein create or replace von admin_resolve_report in 13a. Rückfall: Ableitung (5.4). |
Genau, erfasst Sammlung und Partien, und vor dem Merge von PR #57 nur eine Spalte und eine Zuweisung. Die Ableitung kommt ohne Änderung an 2c aus, ist aber ungenau (R5). |
| Spec-F7 | Krit. 2: auch eigene frühere Meldungen als ähnlich zeigen? | Nein in R1. | Kriterium nennt nur Katalogeinträge. |
| Spec-F8 | Abhängigkeiten: Soll #13 zusätzlich von #4 und #6 abhängen? | Ja (beide liegen ohnehin vorher); #11 übernimmt den Einbau selbst. | Sonst Zwischenlösungen, die später ersetzt werden. |
| Spec-F9 | Krit. 3: vorausgewählte Liste? | Sammlung. | Häufigster Fall laut Story. |
| Spec-F10 | Verweis auf vorhandenen Eintrag: Soll es „abgelehnt mit Verweis“ geben, bei dem der Nutzer selbst wechselt? | Entschieden 2026-11-04 durch Chris (Option B): Nein. Verweis = merged (Admin hängt direkt um); rejected nur ohne Ziel. Ändert Spec #2 Spec-F3. |
Weniger Zustände in der App, keine Nutzer-RPC mit Rechteausweitung. (Nummer F10, weil Spec-F9 oben schon die vorausgewählte Liste betrifft.) |
9.3 Entscheidungen innerhalb der Architektur (kein ADR nötig)
- RPC als Bündelung mit
security invoker, Rechte in der Datenbank; keinesecurity definer-Funktion (E2, E3, E5). - Normalisierung gehört #3; 13a ergänzt nur den Artikel-Wrapper und den Kandidatenschlüssel über
title_search(E4). - Kein Realtime, keine Edge Function, keine neuen Pakete.
9.4 Hinweise für andere Stories
- Spec #2 / PR 2c (PR #57), falls Spec-F6 = Zähler: vor dem Merge Spalte
editions_clearedanlegen und inadmin_resolve_reportsetzen; 2b unverändert. - KAT-01 (#3): Einstieg
/reports/new?title=<suchbegriff>überonReportGame(PR 3b, Krit. 6);find_similar_catalog_gamesliestgames.title_searchundgame_alt_titles.title_search. - KAT-02 (#4), SAM-01 (#6), SAM-03 (#11):
ReportOutcomeNoticeundreportNoticeForGameProvidereinbauen; Badge „vorläufig“; eigene private Spiele direkt ausgameslesen. - KAT-03 (#12): Aktion „Edition wählen“ anbinden.
- KTO-02 (#14):
favorite_games_ownerlaubt 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 wiecatalog_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_reportsund vorläufige Spiele. - SAM-08 (#25): Import-Meldungen mit
origin = 'import',bgg_idund vorläufigem Eintrag; Limit-Trigger greift nicht (N12); Hinweise aus 5.4 gelten.
9.5 Ausstehende Prozessschritte
- Freigabe der Spec und Antworten zu Spec-F1 bis Spec-F9 (Label
status/spec-review). - Spec-F6 vor dem Merge von PR #57: bei Zähler Nachtrag in Spec #2 § 3.4 und in PR 2c (PR #57, vor dem Merge).
- Bei Spec-F8 = Ja: Forgejo-Abhängigkeiten #13 → #4, #6 setzen.
10. Änderungsprotokoll
2026-11-03 – Entwurf
Erste Fassung durch architect. Berücksichtigt den Hinweis aus der KAT-05-Spec (Issue-Kommentar vom 2026-11-03, Klärung N15) zur eingeschränkten Erhaltung der Edition bei Krit. 4.
Nachtrag am selben Tag: Normalisierung faltet Akzente per translate() (Spec-F3 neu begründet: ohne Faltung läge „Mölkky/Molkky“ bei etwa 0,4); Trigramm-Index gestrichen (wäre mit similarity() >= 0.8 nicht nutzbar); Abweichung von Spec #2 § 3.4 (Update-Recht auf result_seen_at) ausdrücklich vermerkt und Testanpassung in B2 ergänzt; Hilfsfunktion report_day für testbare Tagesgrenze.
2026-11-03 – Review (code-reviewer)
- B1:
report_daymitexecutefürauthenticated(sonst 42501 im Trigger); Trigger mitwhen (new.origin = 'user'); Voraussetzung READ COMMITTED; pgTAP fügt alsauthenticatedein. - B2/B3: Normalisierung gehört Spec #3 (
search_normalize); 13a nurstrip_leading_articleund Wrappernormalize_game_title;translate()-Faltung gestrichen; Regeln A und B aufnormalize_game_title; alternative Titel verpflichtend; alle „falls #3“-Bedingungen entfernt (#3 vor 13a); Satz zur Zuständigkeit/Rechten;unaccent-Immutability als Konvention. - (überholt durch Spec-F10)
adopt_report_target: Sperrreihenfolge,is distinct from, Attribut- und Rechte-Tests, keinadmin_*-Aufruf;result_seen_atbleibt null. - Insert-Policy verlangt
provisional_game_id; 23505 →report_provisional_in_use. - Gelöschter vorläufiger Eintrag in
outcomeOf(rejectedReference, nur Status); Hinweis am Ziel nur beimerged_into_id = target. - Indizes aus 2c übernommen; Testdatei
10_…; Tabellenzahl unverändert gegenübermain. - Edition: Ableitung nur
list = 'owned'; Spec-F6 neu bewertet, Empfehlung Zählereditions_cleared. p_year integer, Titel > 200 → leere Menge, Limit-Vorprüfung vor der Ähnlichkeit, Großbuchstaben-Testfall, Partien/Mitspieler, Hinweis #14 präzisiert.
2026-11-03 – Konsistenz-Review mit Spec #3
- B-1: Kandidatenschlüssel
key(c)übergames.title_searchundgame_alt_titles.title_search(Spec #3 3.2 Nr. 4); Regeln A und B darauf;find_similar_catalog_gamesverbindetcatalog_gamesper Primärschlüssel mitgames; Testfall übertitle_searchdes alternativen Titels. - B-2: Spec-F6 korrigiert: 2b braucht keinen Nachtrag (
variants_clearedgibt es schon); Nachtrag nur in 2c (PR #57), Entscheidung vor dessen Merge; E7, 9.2, 9.4, 9.5 angepasst. - W-1:
pg_trgmundunaccentkommen aus 3a; Codeblock in 3.2 undpg_trgmin B1 gestrichen; E9 angepasst. - W-2: gemeinsamer Absatz zur Normalisierung wörtlich aus Spec #3 übernommen; veraltete Hinweise in 9.4/9.5 gestrichen.
- W-3: Vorbedingungen 13a (2b, 2c, 3a) und 13b (3b) präzisiert.
- Hinweise: R9 (Locale) aufgenommen; Runbook-Satz in 4.7;
hidden_at = coalesce(hidden_at, now()); Sperrreihenfolge gegenüberadmin_merge_gamesin R2; optionale Bereichsform des Limits; Testbeschreibungen in08statt Erwartungen; Wrapper-Funktionen nur fürauthenticated.
2026-11-04 – Entscheidung Chris (Option B, Spec-F10)
Kein „abgelehnt mit Verweis“ mehr. Geändert: E5, 4.1 (Zeile adopt_report_target entfällt), 4.4 (entfällt, ersetzt durch Änderung an admin_resolve_report und Check), 5.4 (Zustände rejectedWithTarget, rejectedReference, rejectedAdopted entfallen; Hinweis „am Eintrag“, Zusatz Edition), 7.1 Krit. 5, 8.1 B1, 9.1 R2 und R7, 9.2 neue Zeile Spec-F10, 9.3. Die Hinweise zu adopt_report_target in früheren Änderungsprotokoll-Einträgen sind überholt. Spec #2 trägt einen Nachtrag (Spec-F3).