Tageslimit-Rechte (report_day), Normalisierung als Wrapper um search_normalize aus Spec #3, Sperrreihenfolge und Rechte von adopt_report_target, Indizes aus 2c, Testdatei 10, Edition-Hinweis mit editions_cleared als Empfehlung. Refs #13 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
52 KiB
Spec #13 – KAT-04 Fehlendes Spiel melden
| Issue | #13 (KAT-04) |
| Status | Entwurf, Review-Befunde eingearbeitet; wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2) |
| Autor | architect |
| Stand | 2026-11-03 |
| Etappe | R1.2 Sammlung |
| Abhängigkeiten | Vor 13a gemergt: #2 PR 2b und 2c (Spec #2 E12: game_reports, admin_resolve_report, repoint_game_references, Trigger games_visibility_immutable) und #3 KAT-01 (search_normalize, game_alt_titles, Einstieg aus der leeren Suche, Krit. 6). Außerdem #1. Fachlich zusätzlich #6 SAM-01 und #4 KAT-02 (Orte für „in die Sammlung“ und den Hinweis „am Eintrag“), in Forgejo bisher nicht als Abhängigkeit gesetzt (Spec-F8). |
| Quellen | Issue #13 inkl. Kommentare (u. a. Hinweis aus PR #51 vom 2026-11-03), requirements/20-r1-akzeptanzkriterien.md § KAT-04 und § Querschnitt, requirements/r1-klaerungen.md (6, 10, 12, 15, 20, 23, 26, 27, 28, 36, 44/25, 45, N12, N12a, N15), requirements/backlog.md, docs/specs/2-katalog-daten.md (E10–E14, 3.2–3.4, 4.4, 9.5), Spec #3 (search_normalize, game_alt_titles), docs/datenmodell-r1.md, supabase/migrations/…catalog.sql, …collection.sql, …plays.sql, …catalog_foundation.sql, CLAUDE.md |
Namenskonvention: „Spec-F1“ bis „Spec-F9“ sind die Fragen an Chris aus § 9.2. „E1“ usw. sind Kernentscheidungen dieser Spec, „#2 E12“ verweist auf Spec #2. „B1“, „F1“, „U1“, „T1“ sind Aufgaben (§ 8).
1. Ziel
Ein Nutzer, der ein Spiel im Katalog nicht findet, meldet es mit wenigen Angaben und kann es sofort als vorläufigen privaten Eintrag nutzen (Entscheidung O5). Der Admin (Chris) prüft jede Meldung manuell im Studio (Klärungen 27, 44; Werkzeug aus #2 PR 2c). Das Ergebnis sieht der Nutzer als Hinweis in der App, nie als E-Mail (Klärungen 26, 45).
Diese Spec baut auf game_reports und admin_resolve_report aus Spec #2 § 3.4 (PR 2c) und auf der Normalisierung aus Spec #3 auf. Sie ergänzt nur, was der Nutzer braucht: Anlegen mit Tageslimit, Ähnlichkeitsprüfung, vorläufigen Eintrag, Ergebnishinweise und das Wechseln auf den Zieleintrag.
Nicht im Scope (aus dem Issue): öffentliche Anzeige gemeldeter Spiele, Bild-Upload, E-Mail/Push, Admin-Oberfläche in der App, automatische Import-Meldungen (SAM-08 #25). Außerdem nicht: Bearbeiten weiterer Felder des vorläufigen Eintrags, Zurückziehen einer Meldung, Auswahl vorläufiger Spiele in Partien (PAR-01 #15 Krit. 3).
Kernentscheidungen (Kurzfassung)
| # | Entscheidung | Begründung |
|---|---|---|
| E1 | Jede Nutzer-Meldung legt genau einen vorläufigen privaten Eintrag an (1:1; der eindeutige Teilindex auf provisional_game_id kommt aus 2c). Er bekommt nur title und year. |
Der Hinweis braucht einen festen Ort, Krit. 3 verlangt die sofortige Nutzung; entspricht Backlog (N12) und Import (SAM-08 Krit. 4). Mit nur Titel und Jahr kann außer der Edition nichts verloren gehen (Krit. 4, N15). |
| E2 | Anlegen über die RPC report_missing_game (security invoker): Limit-Vorprüfung, Ähnlichkeitsprüfung, vorläufiger Eintrag, Meldung, optional Listeneintrag in einer Transaktion. |
Getrennte Client-Aufrufe ließen verwaiste vorläufige Spiele zurück. security invoker: RLS, Spaltenrechte und Trigger gelten weiter. |
| E3 | Schutz liegt in der Datenbank: Spaltenrechte auf game_reports, Insert-Policy, Limit-Trigger mit Sperre je Nutzer. |
Ein direkter REST-Insert umgeht nur den UX-Schritt der Ähnlichkeitsprüfung, nicht Limit oder Rechte. created_at ist nicht schreibbar, das Limit lässt sich nicht durch ein falsches Datum umgehen. |
| E4 | Ähnlichkeitsprüfung serverseitig über find_similar_catalog_games mit pg_trgm. Normalisierung: search_normalize aus Spec #3 (Eigentümer #3), dazu in 13a nur der dünne Wrapper normalize_game_title (führende Artikel). Haupttitel und alternative Titel (game_alt_titles), nur sichtbare Katalogspiele. |
Klärung 15 ist eine Datenbankregel. Eine einzige Normalisierung für Suche und Meldung. Die RPC prüft beim Absenden noch einmal; ohne Bestätigung wird nicht gespeichert (Krit. 2). |
| E5 | „Abgelehnt mit Verweis“: Der Nutzer entscheidet selbst über den Wechsel (Spec #2 Spec-F3) per adopt_report_target (security definer, Eigentümer postgres), streng geprüft; nutzt repoint_game_references aus 2b. |
Krit. 5. Erster Weg, auf dem ein Nutzer repoint_game_references indirekt auslöst; deshalb ausführliche Prüfungen, feste Sperrreihenfolge und Rechte-Tests (4.4). |
| E6 | Ergebnishinweise werden aus vorhandenen Daten abgeleitet und lassen sich wegklicken (result_seen_at, Spec-F5). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft. Fehlt der vorläufige Eintrag (vom Nutzer gelöscht), folgt das Ergebnis nur aus status. |
Kein Benachrichtigungssystem in R1 (QUE-01 erst R2). |
| E7 | Edition (N15): Empfehlung (Spec-F6) ist ein Zähler game_reports.editions_cleared (Nachtrag in 2b/2c, solange beide noch nicht gemergt sind); Rückfall ist die Ableitung aus den Daten (nur Sammlung, list = 'owned'). |
Der Zähler ist genau und jetzt günstig; die Ableitung braucht keine Änderung an 2b/2c, ist aber ungenau. |
| E8 | Verlag bleibt Freitext in game_reports.publisher_name. |
publishers ist nur für den Admin schreibbar (Spec #2 3.2). |
| E9 | Keine neuen Pakete, keine Edge Function. pg_trgm gehört zum Supabase-Postgres-Image (ADR-0002). |
CLAUDE.md, ADR-0002. |
| E10 | Zwei PRs: 13a Backend (Refs #13), 13b Flutter (Closes #13). 13b beginnt gegen ein gemocktes Repository, sobald der RPC-Vertrag (4.2–4.5) freigegeben ist. |
Der Vertrag steht vollständig in dieser Spec. |
2. Akzeptanzkriterien
Übernommen aus Issue #13 / 20-r1-akzeptanzkriterien.md § KAT-04 (Stand 2026-11-02 inkl. Klärungen und N12). Referenz in Tests und PRs: „KAT-04 Kriterium n“.
- 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)
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; beirejectednur der Verweis (oder nichts).- Trigger
games_visibility_immutable: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14).
Aus Spec #3: search_normalize(text) und die Tabelle game_alt_titles (alternative Titel je Spiel mit Sprache). Der Fremdschlüssel game_alt_titles.game_id und seine Einordnung im Fremdschlüssel-Wächter gehören zu #3.
3.2 Änderungen in 13a (Migration supabase/migrations/<ts>_game_reports_user.sql)
Nur additiv; der Zeitstempel liegt nach den Migrationen aus 2c und #3. Rechte-Regel aus Spec #2 § 3.2: jede neue Funktion zuerst revoke execute … from public, anon, authenticated, service_role, danach nur die Grants aus 4.1.
create extension if not exists pg_trgm with schema extensions;
Neue Spalte in game_reports
| Spalte | Typ | Hinweis |
|---|---|---|
result_seen_at |
timestamptz | nullable; der Nutzer hat den Ergebnishinweis weggeklickt (E6, Spec-F5). Check result_seen_at is null or status <> 'open'. |
Bei Spec-F6 = Zähler (Empfehlung) kommt editions_cleared integer not null default 0 schon in 2c (Nachtrag Spec #2), nicht in 13a.
Rechte auf game_reports (E3)
grant insert (owner_id, title, publisher_name, year, link, provisional_game_id)
on public.game_reports to authenticated;
grant update (result_seen_at) on public.game_reports to authenticated;
Bewusste Änderung gegenüber Spec #2 § 3.4: Dort haben Nutzer kein Insert- und kein Update-Recht, und 08_game_reports.test.sql prüft, dass nur postgres Meldungen ändert. 13a erlaubt das Anlegen eigener Meldungen und das Ändern genau einer Spalte (result_seen_at) an eigenen, bereits bearbeiteten Meldungen. Das bleibt mit KAT-05 Krit. 9 vereinbar (verboten ist das Ändern fremder Meldungen); B2 passt 08 an.
select bleibt wie in 2c. Nicht schreibbar für Nutzer: id, origin, status, bgg_id, target_game_id, resolved_at, created_at, updated_at (42501). Damit setzt die Datenbank origin = 'user', status = 'open' (Krit. 8) und created_at = now() (Tageslimit) selbst. delete bekommt der Nutzer nicht.
Merkpunkt für den Client (aus #2 PR 2a): Clients schreiben games und game_reports nie mit id, created_at oder updated_at, und ein toJson() eines Modells geht nie ungefiltert an insert/update. Die App schreibt in #13 nur über die RPCs (4.2, 4.4) und das Update von result_seen_at (4.5) mit ausdrücklicher Spaltenliste.
RLS in Klartext (neu in 13a)
- Anlegen (
game_reports_insert_own,for insert to authenticated):owner_id = auth.uid(),origin = 'user',status = 'open',target_game_id is 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. - 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)
Zuständigkeit und Rechte (gleicher Satz in Spec #3): Spec #3 besitzt search_normalize (u. a. unaccent, lower, Entfernen von Sonderzeichen) und gibt sie per grant execute … to anon, authenticated frei, weil generierte Spalten mit den Rechten der einfügenden Rolle berechnet werden; ein Entzug würde jeden Insert in games brechen. unaccent mit festem Wörterbuch als immutable zu deklarieren ist eine verbreitete Konvention, keine Garantie von PostgreSQL. #13 ändert search_normalize nicht und legt keine zweite Normalisierung an.
In 13a neu (beide language sql, immutable, parallel safe, search_path = '', grant execute … to anon, authenticated):
public.strip_leading_article(t text) returns text: entfernt einmal ein führendes Artikelwort (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))). Der letzte Rückfall greift bei Titeln, die nachsearch_normalizeleer sind (z. B. nur nicht-lateinische Schriftzeichen oder nur Sonderzeichen).
Beispiele (pgTAP; das genaue Ergebnis von search_normalize legt #3 fest): „Die Siedler von Catan“ und „siedler von catan“ ergeben denselben Wert; „CATAN!“ = „Catan“; „The Crew“ = „Crew“; „Das“ bleibt nicht leer; „Mölkky“ = „Molkky“ (Faltung durch unaccent); ein Titel nur aus japanischen Schriftzeichen → lower(btrim(t)).
Regel „sehr ähnlich“ zwischen Meldung (Titel t, Jahr y) und Katalogspiel g mit Haupttitel und allen alternativen Titeln aus game_alt_titles (Kandidatentitel c, verpflichtend):
- A (gleicher Titel):
normalize_game_title(c) = normalize_game_title(t)für mindestens einc, unabhängig vom Jahr. - B (Trigramm):
extensions.similarity(normalize_game_title(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 | anon, authenticated |
Normalisierung (3.3) |
report_day(timestamptz) |
Funktion, sql, immutable | authenticated |
Berliner Kalendertag (3.2) |
find_similar_catalog_games(p_title text, p_year integer) |
RPC, security invoker | authenticated |
Ähnliche Katalogspiele (Krit. 2) |
report_missing_game(...) |
RPC, security invoker | authenticated |
Meldung + vorläufiger Eintrag + optional Listeneintrag (Krit. 1, 2, 3, 7, 8) |
adopt_report_target(p_report uuid) |
RPC, security definer, Eigentümer postgres |
authenticated |
Wechsel auf den Zieleintrag (Krit. 5) |
game_reports_daily_limit() |
Trigger-Funktion, security invoker | – | Tageslimit (Krit. 7) |
Policies game_reports_insert_own, game_reports_update_seen, Spaltenrechte |
RLS | – | 3.2 |
Jede Funktion: zuerst revoke execute … from public, anon, authenticated, service_role, dann nur die Grants dieser Tabelle. pgTAP prüft mit has_function_privilege für alle vier Rollen (public über eine Rolle ohne eigene Rechte, anon, authenticated, service_role).
Fehler: raise exception '<code>' using errcode = 'P0001'; die App bildet den Code auf einen Domänenfehler ab (5.3).
4.2 RPC public.report_missing_game
report_missing_game(
p_title text,
p_publisher_name text default null,
p_year integer default null,
p_link text default null,
p_list text default null, -- 'owned' | 'wishlist' | 'shopping' | null
p_confirmed_distinct boolean default false
) returns jsonb -- {"report_id": uuid, "provisional_game_id": uuid, "collection_item_id": uuid | null}
language plpgsql, security invoker, search_path = ''. p_year ist bewusst integer und wird selbst geprüft (ein smallint-Parameter würde bei großen Werten schon beim Aufruf mit 22003 scheitern, ohne verständlichen Code).
Ablauf (eine Transaktion; jeder Fehler rollt alles zurück):
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, game_alt_titles, game_publishers, publishers. Regel nach 3.3. Sortierung: same_title zuerst, dann similarity absteigend, dann title; höchstens 5 Treffer. Leerer Titel oder Titel > 200 Zeichen → leere Menge (kein Fehler; report_missing_game meldet die Länge selbst). Nur authenticated.
4.4 RPC public.adopt_report_target
adopt_report_target(p_report uuid) returns jsonb -- Zähler aus repoint_game_references
language plpgsql, security definer, Eigentümer postgres, set search_path = '' (als Funktionseinstellung). Nötig, weil Nutzer merged_into_id/hidden_at nicht schreiben und repoint_game_references nicht ausführen dürfen. Innerhalb der Funktion ist current_user = postgres; deshalb darf sie keine admin_*-Funktion aufrufen (deren Rollenprüfung würde sonst bestehen), weder jetzt noch in späteren Änderungen. pgTAP prüft prosecdef = true, proowner = postgres und proconfig mit search_path="".
Sperrreihenfolge (wie im 2c-Entwurf von admin_resolve_report, gegen Verklemmungen): zuerst die Meldung select … for update, danach beide Spiele select … from games where id in (provisional, target) order by id for update.
| Prüfung | Fehler |
|---|---|
auth.uid() ist null |
not_authenticated |
Meldung existiert nicht oder gehört nicht auth.uid() (gleicher Fehler) |
report_not_found |
status <> 'rejected' oder target_game_id is null |
report_not_adoptable |
provisional_game_id is null (z. B. vom Nutzer gelöscht) |
report_no_provisional |
vorläufiger Eintrag ist nicht private mit owner_id = auth.uid() |
report_not_found |
| vorläufiger Eintrag ist schon zusammengeführt | report_already_adopted |
Ziel ist kein sichtbares Katalogspiel (visibility = 'catalog' and hidden_at is null and merged_into_id is null) |
report_target_not_listed |
Verweise auf den vorläufigen Eintrag mit fremdem oder fehlendem Eigentümer: collection_items.owner_id, favorite_games.profile_id, plays.owner_id, score_sheets.owner_id jeweils is distinct from auth.uid() (null zählt als fremd) |
report_foreign_references |
Ablauf: repoint_game_references(provisional, target) (privat → Katalog, keine Varianten kopiert, gleichnamige abgebildet, sonst null; Spec #2 RB1/N15), danach am vorläufigen Eintrag merged_into_id = target, hidden_at = now(). Die Meldung bleibt rejected. result_seen_at bleibt unverändert (null), damit der Hinweis „Dein Eintrag zeigt jetzt auf …“ (inkl. Edition-Hinweis) am Zieleintrag einmal erscheint, bis der Nutzer „Verstanden“ wählt (5.4). Bei Spec-F6 = Zähler setzt die Funktion zusätzlich editions_cleared. Rückgabe: Zähler aus repoint_game_references.
Wirkung auf Partien: plays.game_id zeigt danach auf das Katalogspiel; Mitspieler (play_players mit profile_id) sehen in der Partie dann das Katalogspiel statt eines für sie unsichtbaren privaten Spiels. Das ist gewollt und gilt ebenso für admin_resolve_report.
4.5 Ergebnishinweis wegklicken
Kein RPC. Die App sendet PATCH /rest/v1/game_reports?id=eq.<id> mit genau {'result_seen_at': <aktuelle Zeit, ISO-8601 UTC>} (PostgREST setzt Werte, keine Ausdrücke wie now()). Die Client-Zeit ist unkritisch (steuert nur die eigene Anzeige). Bei offener oder fremder Meldung ändert sich keine Zeile.
4.6 Lesen für die App
- Meine Meldungen:
game_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; Satz zur Zuständigkeit fürsearch_normalize(3.3).docs/katalog-pflege.md, Abschnitt „Meldungen bearbeiten“ (2c) ergänzen: Nutzer-Meldungen haben einen vorläufigen Eintrag (sofern nicht gelöscht); „abgelehnt mit Verweis“ lässt dem Nutzer die Wahl (adopt_report_target); Status nie von Hand setzen, nur ü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), notAdoptable,
noProvisional, alreadyAdopted, targetNotListed, offline, unknown
game_report_repository.dart abstrakte Schnittstelle
data/
supabase_game_report_repository.dart RPC-Aufrufe und Abfragen (4.2–4.6), explizite Parameter-Maps
game_report_dto.dart nur fromJson; kein toJson für Schreibzugriffe
game_report_failure_mapper.dart P0001-Codes → GameReportFailure
presentation/
report_form/ similar_games/ my_reports/ report_detail/
widgets/report_outcome_notice.dart wiederverwendbar (auch für #4/#6/#11)
providers/ Repository-, Listen- und Hinweis-Provider (Riverpod, generiert)
game_report_routes.dart
Die Domäne importiert weder Flutter noch Supabase; Validierung in GameReportDraft, nicht im Widget.
5.2 Routen (go_router)
| Route | Screen | Hinweis |
|---|---|---|
/reports/new?title=<suchbegriff> |
ReportFormScreen |
Einstieg aus der leeren Trefferliste von KAT-01 (#3 Krit. 6); Anmeldung nötig (Redirect /login?from=…, Spec #1) |
/reports |
MyReportsScreen |
eigene Meldungen |
/reports/:reportId |
ReportDetailScreen |
Status, Angaben, Hinweis, Aktionen; fremde/unbekannte ID → „nicht gefunden“ |
Konstanten in AppRoutes. Weitere Einstiege zu /reports (Einstellungen, Dashboard) stimmt ui-designer mit Chris ab.
5.3 Melden (Krit. 1, 2, 3, 7, 8)
ReportFormController (Riverpod-Notifier, Zustände als freezed-Union):
| Zustand | UI |
|---|---|
editing(draft, fieldErrors) |
Titel (Pflicht), Verlag, Jahr, Link (optional); „Gleich aufnehmen in“: Sammlung / Wunschliste / Einkaufsliste / nicht aufnehmen (Standard Sammlung, Spec-F9) |
checking |
find_similar_catalog_games läuft; Ladeanzeige, Felder gesperrt |
similarFound(draft, candidates) |
Kandidaten (Titel, getroffener Titel falls alternativ, Jahr, Verlage, GameCover). „Das ist mein Spiel“ → Detailseite des Katalogspiels (#4), keine Meldung. Ausdrückliche Bestätigung: Checkbox „Mein Spiel ist keines davon“ + „Trotzdem melden“ (erst mit Haken aktiv) |
submitting |
report_missing_game läuft |
submitted(result) |
Bestätigung (Krit. 1): „Danke, deine Meldung ist eingegangen. Wir prüfen sie von Hand; bis dahin kannst du das Spiel als vorläufigen Eintrag nutzen.“ ggf. Liste; Aktionen „Zur Meldung“, „Zur Sammlung“ |
failure(draft, failure) |
Eingaben bleiben erhalten. dailyLimit → „Du hast heute schon 10 Spiele gemeldet. Morgen geht es wieder.“ (Krit. 7); similarExists → zurück zu similarFound; Feldfehler am Feld; offline → „Melden braucht eine Verbindung“ |
Ohne Treffer geht checking direkt in submitting (p_confirmed_distinct = false), mit Treffern nach Bestätigung mit true. Lokale Vorprüfung derselben Feldregeln wie 4.2; das Limit prüft nur der Server (die Vorprüfung in der RPC meldet es vor dem Kandidatenschritt, wenn der Nutzer ohne Treffer sendet; bei Treffern spätestens beim Absenden).
Krit. 3 nachträglich: Bei „nicht aufnehmen“ kann der Nutzer den vorläufigen Eintrag später auf der Meldungsdetailseite zu einer Liste hinzufügen, über die Funktion aus SAM-01/SAM-04/SAM-05 (#6/#8/#9) bzw. einen Insert in collection_items mit Spaltenliste owner_id, game_id, list (Spec-F8).
5.4 Ergebnishinweise (Krit. 4, 5, 6, 8)
outcomeOf(report, provisional?, ownedItemOnTarget?) ist eine reine Funktion (Unit-Tests je Fall). Fehlt der vorläufige Eintrag (provisional_game_id null, vom Nutzer gelöscht), folgt das Ergebnis nur aus status und target_game_id; Aktion „wechseln“ und Edition-Ableitung entfallen.
| Fall | Erkennung | Hinweis (Kurzform, ARB) | Aktionen | Wo sichtbar |
|---|---|---|---|---|
pending (Krit. 8) |
status = open |
„Gemeldet am …, wird geprüft“ | – | Meldung; vorläufiger Eintrag (Badge „vorläufig“) |
approved (Krit. 4) |
status = approved |
„Dein gemeldetes Spiel ist jetzt im Katalog. Deine Angaben wurden übernommen.“ | „Zum Spiel“, „Verstanden“ | Meldung; Eintrag am Zielspiel, nur wenn provisional.merged_into_id = target |
merged (Krit. 5) |
status = merged |
„Dein Spiel gab es schon im Katalog als „{title}“. Dein Eintrag zeigt jetzt dorthin.“ | „Zum Spiel“, „Verstanden“ | wie approved |
rejectedWithTarget (Krit. 5) |
status = rejected, Ziel gesetzt, vorläufiger Eintrag vorhanden und nicht zusammengeführt |
„Dein Spiel gibt es bereits als „{title}“.“ | „Zu „{title}“ wechseln“ (adopt_report_target, Bestätigungsdialog „Deine Angaben werden auf den Katalogeintrag übertragen.“), „Vorläufig behalten“ |
Meldung; nur am vorläufigen Eintrag, nicht am Ziel |
rejectedReference |
status = rejected, Ziel gesetzt, vorläufiger Eintrag fehlt |
„Dein Spiel gibt es bereits als „{title}“.“ | „Zum Spiel“ | nur Meldung |
rejectedAdopted |
status = rejected, Ziel gesetzt, provisional.merged_into_id = target |
wie merged |
„Zum Spiel“, „Verstanden“ | Meldung; Eintrag am Zielspiel bis „Verstanden“ (result_seen_at bleibt nach dem Wechsel null, 4.4) |
rejected (Krit. 6) |
status = rejected, kein Ziel |
dauerhafte Markierung „Abgelehnt“ + „Dieses Spiel wird nicht in den Katalog aufgenommen. Dein Eintrag bleibt privat für dich.“ | „Verstanden“ (blendet nur den Text aus) | Meldung; vorläufiger Eintrag |
Zusatz Edition (E7, N15) bei approved, merged, rejectedAdopted: „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“
- Bei Spec-F6 = Zähler (Empfehlung): wenn
editions_cleared > 0. Der Text nennt Sammlung und Partien („in deiner Sammlung oder deinen Partien“). - Rückfall Ableitung: wenn der vorläufige Eintrag mindestens eine eigene Variante hat und der Sammlungseintrag am Ziel mit
list = 'owned'variant_id = 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, rejectedWithTarget, rejected; am Zielspiel (target_game_id = gameId, result_seen_at is null) nur approved, merged, rejectedAdopted, und nur wenn provisional.merged_into_id = gameId. ReportOutcomeNotice (kompakt/ausführlich) wird eingebaut in 13b in „Meine Meldungen“ und die Meldungsdetailseite, in #4/#6/#11 soweit gemergt, sonst übernehmen diese Stories den Einbau (Spec-F8, 9.4). Vorläufige Einträge zeigen ein Badge „vorläufig“ (Text) und den GameCover-Platzhalter.
Aktualität: kein Realtime; Neuladen beim Öffnen und per Pull-to-Refresh.
5.5 Meine Meldungen
MyReportsController (AsyncNotifier mit Paging, 20 je Seite): Laden, Leer („Du hast noch keine Spiele gemeldet“ + Weg zur Suche), Fehler mit „Erneut versuchen“, Daten. Je Zeile Titel, Jahr, Datum, Status-Badge, ggf. kompakter Hinweis; Import-Meldungen mit Kennzeichen „aus BGG-Import“ (Text liefert #25).
5.6 l10n (Auszug, de/en)
Präfix gameReport…: Felder und Fehler (gameReportTitleRequired, gameReportTitleTooLong, gameReportPublisherTooLong, gameReportYearInvalid, gameReportLinkInvalid), Ähnlichkeit (gameReportSimilarHeading, gameReportSimilarIsMine, gameReportSimilarConfirmDistinct, gameReportSubmitAnyway), Listenwahl, Bestätigung, gameReportDailyLimit, Offline, Status-Badges, gameReportProvisionalBadge, Hinweise aus 5.4 inkl. Edition, Aktionen, Leer-/Fehlerzustand, Semantics-Labels; Platzhalter {title}, Datum über intl.
6. Plattform-Besonderheiten
- iOS, Android, Web: gleiches Verhalten; Link-Feld
TextInputType.url, Jahr mit Ziffern-Tastatur; der Link wird nur als Text angezeigt, nicht geöffnet. - Web: teilbare URLs; Bestätigungsschritt per Tastatur bedienbar; breites Layout (Formular und Kandidaten nebeneinander ab ≥ 840 dp, ui-designer).
- Offline: Melden braucht eine Verbindung (wie SAM-01 Krit. 3); Hinweis statt Fehler, Eingaben bleiben. Keine Offline-Warteschlange.
- Push, Kamera, Dateien: nicht betroffen.
7. Teststrategie
Werkzeuge: pgTAP (supabase test db), flutter_test + mocktail, Playwright (Web-E2E). Testnamen enthalten „KAT-04 Kriterium n“.
Neue pgTAP-Datei supabase/tests/database/10_game_reports_user.test.sql (08 gehört 2c, 09 gehört #3). Fixtures mit festen Test-UUIDs, Nutzer A und B, unabhängig vom Startbestand. Einfügungen „als Nutzer“ laufen wirklich als Rolle authenticated mit gesetztem request.jwt.claims, nicht als postgres.
7.1 Abdeckungsmatrix
| AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task |
|---|---|---|---|---|---|
| Krit. 1 | GameReportDraft: Titel Pflicht (auch nur Leerzeichen), Längen, Jahr, Link; Fehler-Mapping |
Feldfehler, Bestätigung, Listenwahl | Playwright: leere Suche → „Spiel melden“ → Titel vorbelegt → Bestätigung | report_missing_game als authenticated: Meldung (open, user) und vorläufiges Spiel (private, nur Titel/Jahr); Trimmen; alle Validierungsfehler; Jahr 99999 → report_year_invalid (kein 22003); anon → 42501 |
B1, B2, F2, T1, T2 |
| Krit. 2 | Controller: Treffer → similarFound; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; similarExists → zurück |
Kandidatenliste, Checkbox aktiviert Button, Tastatur, Semantics | Playwright: Meldung „Catan“ zeigt „CATAN“ | strip_leading_article/normalize_game_title: Artikel de/en nur führend, Großbuchstaben, Sonderzeichen, Umlaute über search_normalize, nur Artikel, nicht-lateinischer Titel (Rückfall); find_similar_catalog_games: gleicher normalisierter Titel bei beliebigem Jahr; Trigramm ≥ 0,8 bei ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; fehlendes Jahr nach Spec-F2; Treffer über alternativen Titel (matched_title); ausgeblendete, zusammengeführte, private Spiele nicht; Titel > 200 → leere Menge; höchstens 5, Sortierung; report_similar_exists ohne, Speichern mit Bestätigung |
B1, B2, F2, U1, T1, T2 |
| Krit. 3 | – | Bestätigung zeigt Liste; Badge „vorläufig“ | Playwright: gemeldetes Spiel in der Wunschliste (sofern #6/#8 da) | p_list legt collection_items an; B sieht nichts von A; anon nichts; Rollback bei Fehler im Listeneintrag |
B1, B2, F2, F3 |
| Krit. 4 | outcomeOf: approved mit/ohne Edition; vorläufiger Eintrag fehlt |
Hinweis de/en, „Verstanden“ | Playwright: Testsetup bearbeitet per admin_resolve_report(…, 'approved', …) → Hinweis am Eintrag |
nach admin_resolve_report: Sammlungseintrag von A am Ziel mit Liste/Notiz/Kaufdatum; Edition ohne gleichnamige Variante → null; private Variante für A lesbar; bei Zähler editions_cleared korrekt; result_seen_at setzbar |
B2, F3, T1, T2 |
| Krit. 5 | outcomeOf: merged, rejectedWithTarget, rejectedReference, rejectedAdopted |
Hinweise, „wechseln“ mit Dialog, Hinweis nur am richtigen Eintrag | Playwright: abgelehnt mit Verweis → wechseln → Eintrag zeigt Zielspiel | adopt_report_target: hängt Sammlung und Partien um, setzt merged_into_id/hidden_at, Status bleibt rejected, result_seen_at bleibt null; Fehler report_not_found (B, unbekannt), report_not_adoptable, report_no_provisional (gelöschter Eintrag), report_already_adopted, report_target_not_listed, report_foreign_references (fremder Eigentümer und plays.owner_id = null, Fixture als postgres); prosecdef, proowner = postgres, proconfig search_path=""; has_function_privilege für public, anon, authenticated, service_role |
B1, B2, F3, T1, T2 |
| Krit. 6 | outcomeOf: rejected ohne Ziel |
Markierung bleibt nach „Verstanden“ | – | nach admin_resolve_report(…, 'rejected') bleibt das vorläufige Spiel privat und lesbar |
B2, F3, T2 |
| Krit. 7 | Mapping report_daily_limit |
Limit-Hinweis, Eingaben bleiben | – | als authenticated 10 Meldungen über report_missing_game ok, 11. → report_daily_limit, kein vorläufiges Spiel übrig; ebenso direkter Insert als authenticated (prüft, dass der Trigger report_day ausführen darf); Meldungen von gestern (created_at als postgres gesetzt) zählen nicht; report_day mit festen Zeitstempeln (2026-01-15 23:30 UTC → 16.01., 2026-07-15 21:59 UTC → 15.07., 22:00 UTC → 16.07.); origin = 'import' als postgres zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; Insert mit created_at, origin oder status → 42501 |
B1, B2, F2, T1 |
| Krit. 8 | – | Status „offen“ | – | Nutzer setzt status, target_game_id, resolved_at weder per Insert noch Update (42501); Update von result_seen_at bei open → 0 Zeilen |
B2, T1 |
| Sicherheit 1, 3 | – | – | – | B liest A's Meldungen und Spiele nicht, kann sie nicht wegklicken (0 Zeilen) und nicht übernehmen; Insert mit owner_id = A als B, ohne provisional_game_id, mit fremdem/Katalog-/ausgeblendetem Spiel → Policy-Fehler; zweite Meldung zum selben vorläufigen Eintrag → 23505; Spaltenrechte per has_column_privilege |
B2 |
| Sicherheit 2 | – | – | – | anon: kein select, insert, update, keine RPC |
B2 |
| Datenschutz | – | – | – | Konto A löschen → Meldungen, vorläufige Spiele, Listeneinträge weg; B unverändert | B2 |
| QUE-05/06, Zustände | – | de/en, meetsGuideline, textScaler 2.0, Lade/Leer/Fehler/Daten, Offline |
Playwright: Tastatur im Bestätigungsschritt | – | F2, F3, T2 |
Nicht automatisiert: echte Parallelität des Limits (Code-Review und dokumentierter manueller Test mit zwei psql-Sitzungen unter READ COMMITTED); Bearbeitung echter Meldungen auf Staging (Chris).
Bestehende Tests: 08_game_reports.test.sql (2c) erwartet „kein Insert und kein Update für authenticated“; B2 passt beide Erwartungen an (Insert der erlaubten Spalten, Update nur result_seen_at an eigenen bearbeiteten Meldungen, alles andere weiter 42501). Tabellenzahl in 00_schema.test.sql unverändert gegenüber main.
8. Aufgaben
8.1 PR 13a – Backend (Refs #13), Branch feature/13a-meldungen-backend
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| B1 | backend-dev | Migration nach 3.2–4.5: pg_trgm, strip_leading_article, normalize_game_title, report_day (mit execute für authenticated), Spalte result_seen_at, Spaltenrechte, Policies, Trigger game_reports_daily_limit (when (new.origin = 'user'), Sperre), RPCs find_similar_catalog_games, report_missing_game, adopt_report_target (Sperrreihenfolge, is distinct from); Rechte nach 4.1 |
#2 PR 2c und #3 gemergt |
| B2 | backend-dev | pgTAP 10_game_reports_user.test.sql nach 7.1; 08 an Insert- und Update-Rechte anpassen |
B1 |
| B3 | backend-dev | Doku nach 4.7 | B1 |
| T1 | test-engineer | Review der pgTAP-Abdeckung; manueller Paralleltest des Limits dokumentieren | B2 |
8.2 PR 13b – Flutter (Closes #13), Branch feature/13b-meldungen-app
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| U1 | ui-designer | Formular, Kandidatenschritt, Bestätigung, „Meine Meldungen“, Detail, ReportOutcomeNotice, Badge, Web-Layout; Texte de/en |
Spec freigegeben |
| F1 | flutter-dev | Domäne (5.1) inkl. outcomeOf mit allen Fällen aus 5.4 (auch „vorläufiger Eintrag fehlt“); Unit-Tests |
Spec freigegeben (parallel zu B1) |
| F2 | flutter-dev | Data-Schicht (explizite Parameter, kein toJson() an Schreibzugriffe, Fehler-Mapper inkl. report_provisional_in_use) und Melden-Ablauf (5.3), Routen, Einstieg aus #3, l10n |
F1, U1; gegen Backend: B1 |
| F3 | flutter-dev | „Meine Meldungen“, Detail, Hinweise (5.4), Einbau in #4/#6/#11 soweit gemergt | F1, F2 |
| T2 | test-engineer | Widget-Tests und Playwright nach 7.1 | F2, F3, B1 |
8.3 Reihenfolge und Parallelität
Vorbedingung: #2 PR 2b → 2c gemergt; #3 KAT-01 gemergt
13a: B1 → B2 → B3, T1
13b: U1 ─┐
F1 ─┴→ F2 (Mock-Repository, danach gegen B1) → F3 → T2
13b wird nach 13a gemergt.
F1 und U1 starten nach Freigabe der Spec, parallel zu B1. Merge nur durch Chris.
9. Risiken & offene Fragen
9.1 Risiken
| # | Risiko | Auswirkung | Gegenmaßnahme |
|---|---|---|---|
| R1 | 2b/2c/#3 weichen von den beschriebenen Namen oder Rückgaben ab | 13a passt nicht | B1 liest die gemergten Migrationen zuerst; Abweichungen im PR nennen |
| R2 | adopt_report_target ist security definer und erreicht repoint_game_references |
Fehler würden fremde Daten berühren | Prüfungen 4.4 inkl. fremder Verweise mit is distinct from, feste Sperrreihenfolge, kein Aufruf von admin_*, Rechte- und Attribut-Tests |
| R3 | Schwelle 0,8 ist bei kurzen Titeln streng, bei Reihen großzügig | zu wenige/zu viele Kandidaten | Klärung 15 verbindlich; Kandidatenschritt verlangt nur eine Bestätigung |
| R4 | Limit-Sperre wirkt nur unter READ COMMITTED | unter höherer Isolation 11+ Meldungen möglich | PostgREST-Standard; in 3.2 festgehalten; manueller Paralleltest |
| R5 | Ableitung der Edition (Rückfall) ist ungenau | unnötige oder fehlende Hinweise, Partien nicht erfasst | Empfehlung Zähler editions_cleared (Spec-F6) |
| R6 | Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11 | Krit. 4–6 sonst nur in „Meine Meldungen“ | Spec-F8 |
| R7 | Mehrfachmeldungen desselben Spiels | mehr Arbeit für Chris | Tageslimit; Ablehnen mit Verweis |
| R8 | Kein Realtime | Ergebnis erst beim nächsten Laden | akzeptiert |
9.2 Offene Fragen an Chris (Spec-F1 bis Spec-F9)
Keine Frage blockiert die Umsetzung: Ohne andere Entscheidung gilt mit der Freigabe die Empfehlung.
| # | Frage | Empfehlung | Begründung |
|---|---|---|---|
| Spec-F1 | Krit. 1: Sind Verlag und Jahr Pflicht? | Optional, nur der Titel ist Pflicht. | Nutzer kennen das Jahr oft nicht; 2c hat beide Spalten nullable. |
| Spec-F2 | Krit. 2: Trigramm-Regel bei fehlendem Jahr? | ≥ 0,8 ohne Jahresbedingung. | Lieber einmal mehr nachfragen; der Schritt verlangt nur eine Bestätigung. |
| Spec-F3 | Krit. 2 „Artikel ignoriert“: welche Artikel und wo? (Sonderzeichen, Groß-/Kleinschreibung und Akzente regelt search_normalize aus Spec #3.) |
Nur ein führender Artikel auf Deutsch oder Englisch (Liste in 3.3). | Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“); andere Sprachen sind im Katalog selten. |
| Spec-F4 | Krit. 7 „heute“: Kalendertag oder 24 Stunden? | Kalendertag Europe/Berlin. | Entspricht „heute“; verständlich („morgen geht es wieder“). |
| Spec-F5 | Krit. 4–6: Wie lange bleibt der Hinweis sichtbar? | Bis „Verstanden“; „abgelehnt“ bleibt dauerhaft markiert. | Dauerhafte Hinweise stören, zeitgesteuertes Ausblenden wird übersehen. |
| Spec-F6 | N15, Edition: Woran erkennt die App die geleerte Edition? | Neu bewertet: Zähler game_reports.editions_cleared integer not null default 0 (Nachtrag in Spec #2: 2b liefert in repoint_game_references die Zahl der geleerten Verweise in Sammlung und Partien, wie es variants_cleared bei admin_merge_games schon für Verweise tut; 2c legt die Spalte an und admin_resolve_report setzt sie; 13a setzt sie in adopt_report_target). Rückfall: Ableitung (5.4). |
Genau, erfasst auch Partien, und jetzt günstig, weil 2b und 2c noch nicht gemergt sind; später wäre ein create or replace der Admin-Funktionen nötig. Die Ableitung kommt ohne Änderung an Spec #2 aus, ist aber ungenau (R5). |
| Spec-F7 | Krit. 2: auch eigene frühere Meldungen als ähnlich zeigen? | Nein in R1. | Kriterium nennt nur Katalogeinträge. |
| Spec-F8 | Abhängigkeiten: Soll #13 zusätzlich von #4 und #6 abhängen? | Ja (beide liegen ohnehin vorher); #11 übernimmt den Einbau selbst. | Sonst Zwischenlösungen, die später ersetzt werden. |
| Spec-F9 | Krit. 3: vorausgewählte Liste? | Sammlung. | Häufigster Fall laut Story. |
9.3 Entscheidungen innerhalb der Architektur (kein ADR nötig)
- RPC als Bündelung mit
security invoker, Rechte in der Datenbank; nuradopt_report_targetistsecurity definer(E2, E3, E5). - Normalisierung gehört #3; 13a ergänzt nur den Artikel-Wrapper (E4).
- Kein Realtime, keine Edge Function, keine neuen Pakete.
9.4 Hinweise für andere Stories
- Spec #2 (2b/2c), falls Spec-F6 = Zähler: Nachtrag vor dem Bau (Spalte
editions_cleared, Zähler der geleerten Verweise, Setzen inadmin_resolve_report). - KAT-01 (#3): derselbe Satz zu Zuständigkeit und Rechten von
search_normalize(3.3); Einstieg/reports/new?title=<suchbegriff>(Krit. 6);game_alt_titleswird vonfind_similar_catalog_gamesgelesen. - 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). - Bei Spec-F6 = Zähler: Nachtrag in Spec #2 vor dem Bau von 2b/2c.
- Bei Spec-F8 = Ja: Forgejo-Abhängigkeiten #13 → #4, #6 setzen.
- Spec #3 erhält denselben Satz zu Zuständigkeit und Rechten von
search_normalize.
10. Änderungsprotokoll
2026-11-03 – Entwurf
Erste Fassung durch architect, inkl. Hinweis aus PR #51 (Klärung N15). Nachtrag am selben Tag: Trigramm-Index gestrichen, Abweichung von Spec #2 § 3.4 vermerkt, report_day für testbare Tagesgrenze.
2026-11-03 – Review (code-reviewer)
- B1:
report_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. adopt_report_target: Sperrreihenfolge,is distinct from, Attribut- und Rechte-Tests, keinadmin_*-Aufruf;result_seen_atbleibt null (Hinweis am Ziel nach Wechsel).- Insert-Policy verlangt
provisional_game_id; 23505 →report_provisional_in_use. - Gelöschter vorläufiger Eintrag in
outcomeOf(rejectedReference, nur Status); Hinweis am Ziel nur 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.