From c43a4afd3d85586d12ee6d001e9fdfd7bc1e2cc3 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:37:54 +0100 Subject: [PATCH 01/30] docs: Release-Checkliste (D1) mit checks.sql, Zugang, Auth/SMTP und Runner Refs #2 Refs #46 Co-Authored-By: Claude Sonnet 5.5 --- .claude/commands/release.md | 1 + README.md | 2 +- docs/auth-konfiguration.md | 3 +- docs/katalog-pflege.md | 6 +- docs/release-checkliste.md | 119 ++++++++++++++++++++++++++++++++++++ supabase/README.md | 2 +- 6 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 docs/release-checkliste.md diff --git a/.claude/commands/release.md b/.claude/commands/release.md index b30cd15..ddf2a19 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -4,6 +4,7 @@ argument-hint: --- Bereite Release $ARGUMENTS vor: +0. Gehe `docs/release-checkliste.md` durch (Katalog/`checks.sql`, Zugang, Auth/SMTP, Runner) und melde offene Punkte an Chris. 1. `devops`: prüft, dass `main` grün ist, setzt `version:` in `app/pubspec.yaml` (Build-Nummer +1) auf einem Branch `release/$ARGUMENTS` und erstellt einen PR. 2. `docs-writer`: ergänzt `CHANGELOG.md` aus den gemergten PRs seit dem letzten Tag und diff --git a/README.md b/README.md index c366734..b0c6121 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Die vollständige Schritt-für-Schritt-Anleitung liegt als separates Dokument be | `.claude/hooks/` | Auto-Format für Dart, Schutz bestehender Migrationen | | `.mcp.json` | MCP-Server: Forgejo, Outline, Dart, Playwright | | `.forgejo/` | CI-Pipeline, Issue- und PR-Vorlagen | -| `docs/` | ADR-0001 (Tech-Stack-Vorschlag), Vorlagen für Specs und ADRs | +| `docs/` | ADR-0001 (Tech-Stack-Vorschlag), Vorlagen für Specs und ADRs, [Release-Checkliste](docs/release-checkliste.md) | | `requirements/` | Ablage für deine fachlichen Anforderungen | | `supabase/` | R1-Datenbank: Migrationen, RLS-Policies, pgTAP-Tests, Seed | | `scripts/` | Setup-Check, Forgejo-Labels | diff --git a/docs/auth-konfiguration.md b/docs/auth-konfiguration.md index 5096816..ca96abb 100644 --- a/docs/auth-konfiguration.md +++ b/docs/auth-konfiguration.md @@ -148,7 +148,7 @@ Mit der committeten `config.toml` geprüft (Mailpit-API): Registrierung mit `loc ## 5. Release-Checkliste (vor jedem Release auf Staging/Produktion) -Automatisiert nicht abdeckbar (Spec § 7.1); vor dem Release abhaken: +Zentrale Liste über alle Bereiche (Katalog, Zugang, Runner): [`release-checkliste.md`](release-checkliste.md). Automatisiert nicht abdeckbar (Spec § 7.1); vor dem Release abhaken: - [ ] GoTrue-Image ist `supabase/gotrue:v2.197.0` (oder bewusst aktualisiert, dann Spike-Punkte (c), (d), (f) wiederholen). - [ ] Env-Block aus Abschnitt 2 ist vollständig gesetzt; `GOTRUE_PASSWORD_HIBP_ENABLED=false` (AUTH-CFG-21). @@ -160,6 +160,7 @@ Automatisiert nicht abdeckbar (Spec § 7.1); vor dem Release abhaken: - [ ] `GOTRUE_RATE_LIMIT_EMAIL_SENT=30` gesetzt und Monitoring auf 429 `over_email_send_rate_limit` aktiv (AUTH-CFG-09, R3). - [ ] Nach Passwortänderung kommt keine Benachrichtigungs-Mail (AUTH-CFG-19). - [ ] **Monitoring/Alarm auf SMTP-Fehler** (Release-Blocker, D1, Issue #37/F13): GoTrue-Logs mit `unexpected_failure`/HTTP 500 bei `/recover`, `/resend` und `/signup` alarmieren. Die App zeigt bei einem SMTP-Ausfall bewusst die neutrale „Prüfe dein Postfach“-Seite (Konten-Schutz); Nutzer bekommen keinen Hinweis und keine Mail, nur der Betrieb sieht den Ausfall. Die App loggt zusätzlich Operation, Fehlerart, Status und Code (ohne E-Mail). +- [ ] **#46:** Gateway-/Proxy- und GoTrue-SMTP-Timeouts festgelegt (bei hängendem SMTP kommt ein 504 mit Status vor dem Client-Timeout); Monitoring zusätzlich auf Gateway-5xx/504 für `/recover`, `/resend`, `/signup`. - [ ] SMTP-Anbieter mit AVV und Absender-Domain entschieden und eingerichtet (AUTH-CFG-22, F7). - [ ] SPA-Rewrite: `/auth/confirm` wird von der Web-App ausgeliefert. - [ ] Rechtstexte-URLs und `TERMS_VERSION` final (R11); Bundle-/Application-ID final (R15). diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index ea3d551..aeb8cd1 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -9,7 +9,7 @@ Stand: PR 2a (Datenmodell, Startbestand, Ausblenden). Die Abschnitte **Zusammenf ## 1. Zugang - Studio läuft als Datenbankrolle `postgres` (bzw. `supabase_admin`) und **umgeht Row Level Security**. Wer Studio erreicht, kann alles lesen und ändern, auch private Nutzerdaten. Mit `select current_user;` im SQL-Editor prüfen, unter welcher Rolle man arbeitet. -- **Nicht öffentlich erreichbar** sein dürfen: Studio, der Postgres-Port 5432, der Pooler (Supavisor, Standard 6543) und die Kong-Route `/pg/` (pg-meta). Zugriff nur über SSH-Tunnel oder VPN; die Basic-Auth des Gateways (`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD`) bleibt mit starkem Passwort als zweite Schicht. Gilt ebenso für Staging. (Einrichtung und Bestätigung: Aufgabe D1, devops.) +- **Nicht öffentlich erreichbar** sein dürfen: Studio, der Postgres-Port 5432, der Pooler (Supavisor, Standard 6543) und die Kong-Route `/pg/` (pg-meta). Zugriff nur über SSH-Tunnel oder VPN; die Basic-Auth des Gateways (`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD`) bleibt mit starkem Passwort als zweite Schicht. Gilt ebenso für Staging. (Einrichtung und Bestätigung: Aufgabe D1, devops; Prüfschritte in [`release-checkliste.md`](release-checkliste.md) Abschnitt 4.) - **Immer in einer Transaktion arbeiten:** `begin; … commit;` (bei Fehler `rollback;`). Vor größeren Änderungen den Stand des Backups prüfen. - **Niemals `set …` ohne `local` verwenden** (zum Beispiel `set shuffle.catalog_import`): Ohne `local` gilt die Einstellung für die ganze Sitzung. Der Import-Schalter `shuffle.catalog_import` ist ohnehin nur für die Rolle `catalog_importer` wirksam und gehört nicht ins Studio. Hinweis: `set local` statt `set` ist eine Konvention des Imports; ein sitzungsweites `set` bleibt hinter einem Pooler bis zum Ende der Verbindung bestehen und kann auf die nächste Anfrage durchschlagen. - Die Rolle `app_metadata.role = 'admin'` (JWT) wird in R1 **niemandem vergeben** (siehe 7). @@ -100,7 +100,7 @@ git add supabase/catalog supabase/tests/support && git commit psql "$DB_URL" -f supabase/catalog/checks.sql # oder Inhalt im Studio-SQL-Editor ``` -Je Regel eine Zeile (Soll, Ist, Status `ok`/`Verstoß`/`Information`). **Abnahme (Krit. 7 und 10):** SB1 meldet mindestens 180 sichtbare Spiele, SB2, SB4 und SB6 melden 0. Die Abfrage betrachtet nur Spiele aus `startbestand-keys.csv`; im Studio angelegte Spiele und das Beispielspiel aus `seed.sql` (nur lokal) zählen nicht. Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird. Die Herkunft der Fakten (SB3) und der Kurztexte (SB4) bestätigt Chris selbst; die rechtliche Prüfung folgt vor dem Release. +Je Regel eine Zeile (Soll, Ist, Status `ok`/`Verstoß`/`Information`). **Abnahme (Krit. 7 und 10):** SB1 meldet mindestens 180 sichtbare Spiele, SB2, SB4 und SB6 melden 0. Die Abfrage betrachtet nur Spiele aus `startbestand-keys.csv`; im Studio angelegte Spiele und das Beispielspiel aus `seed.sql` (nur lokal) zählen nicht. Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird. Vor dem Go-live ist `checks.sql` Teil der [Release-Checkliste](release-checkliste.md) (Abschnitt 2). Die Herkunft der Fakten (SB3) und der Kurztexte (SB4) bestätigt Chris selbst; die rechtliche Prüfung folgt vor dem Release. ### 2.7 Ergebnis der Prüfung der CLI-Annahmen (Spec R11, Supabase CLI 3.0.0, geprüft am 2026-11-03) @@ -113,7 +113,7 @@ Je Regel eine Zeile (Soll, Ist, Status `ok`/`Verstoß`/`Information`). **Abnahme | Pfad ohne Treffer in `sql_paths` | Nur die Warnung `no files matched pattern: …`, **kein Fehler**, Exit-Code 0. Ein Tippfehler im Pfad überspringt die Datei also still; nach dem Reset `checks.sql` bzw. die Zeilenzahlen ansehen. | | Datei, die nur Kommentare enthält | läuft ohne Fehler | | psql-Metabefehle (`\i` …) in Seed-Dateien | nicht nutzbar (die Datei wird als SQL-Batch gesendet) | -| `supabase start` lädt Seeds nur beim ersten Start einer leeren Datenbank | **nicht geprüft** (hätte Chris' laufenden lokalen Stack stoppen müssen). Nicht darauf verlassen: nach Änderungen an Migrationen oder Seeds immer `supabase db reset`. | +| `supabase start` lädt Seeds nur beim ersten Start einer leeren Datenbank | **nicht geprüft** (hätte Chris' laufenden lokalen Stack stoppen müssen). Nicht darauf verlassen: nach Änderungen an Migrationen oder Seeds immer `supabase db reset`. Auf Staging/Prod nicht für Seeds nutzen, sondern per `psql` einspielen (2.5, [Release-Checkliste](release-checkliste.md) Abschnitt 2). | ## 3. Spiel anlegen und bearbeiten diff --git a/docs/release-checkliste.md b/docs/release-checkliste.md new file mode 100644 index 0000000..c68c389 --- /dev/null +++ b/docs/release-checkliste.md @@ -0,0 +1,119 @@ +# Release-Checkliste (Staging und Produktion) + +| | | +|---|---| +| Gilt für | jedes Release auf Staging/Produktion; **Go-live** (erster Release mit echten Nutzern) zusätzlich die Punkte mit „Go-live“ | +| Stand | 2026-11-03 | +| Quellen | `docs/specs/2-katalog-daten.md` (§ 4.2, § 8.1 Aufgabe D1), `docs/auth-konfiguration.md` (§ 3, § 5), `docs/katalog-pflege.md` (§ 1, § 2), ADR-0002, ADR-0003, Issue #46 | +| Bezug | Refs #2 (Katalog), Refs #1 (Auth), Refs #46 (SMTP-Timeouts), Refs #43 (CI-Runner) | + +Diese Datei ist die zentrale Stelle. Die ausführliche Auth-Liste bleibt in `docs/auth-konfiguration.md` § 5; die Release-Blocker daraus stehen hier, damit nichts übersehen wird. + +**Grundregeln (CLAUDE.md, Rolle devops):** Kein Tag, kein Deployment, kein Store-Upload ohne ausdrückliche Freigabe von Chris. Infrastruktur (Server, DNS, Reverse Proxy, Firewall) richtet Chris ein bzw. gibt sie frei; devops liefert Anleitung und Prüfschritte. Secrets nie in Logs, Tickets oder dieser Datei. In den Prüfbefehlen stehen Platzhalter in `<…>`; echte Werte stehen nicht im Repo. + +**Wer:** `Chris` = Entscheidung, Freigabe, Eingriff in die Infrastruktur; `devops` = Prüfung und Dokumentation. + +Ergebnis je Punkt mit Datum und Umgebung (Staging/Prod) im Release-PR oder als Kommentar zu #2 festhalten. + +## 1. Vor jedem Release + +| ☐ | Punkt | Wer | Prüfung | +|---|---|---|---| +| ☐ | `main` ist grün (Format, Analyse, Tests, Web-Build, pgTAP, `e2e`, Deno-Schritte) | devops | Forgejo Actions, letzter Lauf auf `main` | +| ☐ | Version in `app/pubspec.yaml` gesetzt (`x.y.z+build`), Changelog und Release Notes erstellt | devops, docs-writer | `.claude/commands/release.md` | +| ☐ | Migrationen auf Staging eingespielt und geprüft; Produktion erst nach Freigabe | Chris | Verfahren **offen**, siehe 8 | +| ☐ | Backups laufen und Restore ist getestet (ADR-0002, Pflichtbaustein) | Chris | Nachweis des letzten Restore-Tests, **offen**, siehe 8 | + +## 2. Katalog und Startbestand (Spec #2, KAT-05) + +Reihenfolge immer: Migrationen, `vocabulary.sql`, `startbestand.sql` (Runbook `docs/katalog-pflege.md` § 2.5). + +| ☐ | Punkt | Wer | Prüfung | +|---|---|---|---| +| ☐ | **C0:** Vokabular (`supabase/catalog/vocabulary.csv`, Genres und Mechaniken) von Chris freigegeben oder korrigiert; `vocabulary.sql` ist aus der freigegebenen CSV erzeugt | Chris | Freigabe als Kommentar in #2 oder im PR; CI-Schritt „erzeugte Dateien aktuell“ grün | +| ☐ | Startbestand in `startbestand.csv` gepflegt (mindestens 180, Ziel ca. 200), `startbestand.sql` und `startbestand-keys.csv` erzeugt und committet (C1) | Chris | Ablauf Runbook § 2.4; CI grün | +| ☐ | Herkunft der Fakten (SB3) und der Kurztexte (SB4) bestätigt; **rechtliche Prüfung** vor dem Release | Chris | schriftliche Bestätigung in #2 | +| ☐ | Vokabular und Startbestand auf der Zielumgebung eingespielt (nur per Tunnel/VPN, siehe 4) | Chris | `psql "$DB_URL" -v ON_ERROR_STOP=1 --single-transaction -f supabase/catalog/vocabulary.sql -f supabase/catalog/startbestand.sql` | +| ☐ | **Seed-Verhalten:** Auf Staging/Prod nicht auf `supabase start`/`supabase db reset` für Seeds verlassen. Runbook § 2.7: Das Verhalten von `supabase start` (Seeds nur beim ersten Start einer leeren DB) ist **nicht geprüft**. Seeds ausdrücklich per `psql` (Zeile oben) einspielen und mit `checks.sql` bestätigen | Chris, devops | Zeile oben plus nächster Punkt | +| ☐ | **`checks.sql` (SB1):** mindestens 180 sichtbare Startbestandsspiele; SB2, SB4 und SB6 melden 0 Verstöße | Chris (Abnahme C1) | `psql "$DB_URL" -f supabase/catalog/checks.sql` oder Inhalt im Studio-SQL-Editor. Erwartung: Status `ok` bei SB1/SB2/SB4/SB6, SB1-Istwert >= 180. Lokal/CI ist SB1 nur Information; auf Produktion zählt der Wert | +| ☐ | Das lokale Beispielspiel aus `seed.sql` ist auf Produktion nicht vorhanden (reiner Sicherheitsnetz-Check; `checks.sql` zählt es nicht mit) | devops | `select count(*) from public.games where title = 'Beispielspiel';` erwartet 0 | +| ☐ | **Go-live:** danach ist `startbestand.sql` eingefroren und wird nicht mehr eingespielt (Korrekturen nur im Studio, Spec-F6) | Chris | Hinweis im Release-PR | + +Issue #2 wird erst nach der Abnahme von `checks.sql` auf Produktion (C1) und nach Fertigstellung von #4 (KAT-02) geschlossen, und nur von Chris. + +## 3. Auth, SMTP und Gateway (Spec #1, D1, Issue #46) + +Vollständige Liste mit AUTH-CFG-Verweisen: `docs/auth-konfiguration.md` § 5; Gateway-Vorgaben § 3. Release-Blocker: + +| ☐ | Punkt | Wer | Prüfung | +|---|---|---|---| +| ☐ | GoTrue-Image gepinnt auf `supabase/gotrue:v2.197.0` (oder bewusst aktualisiert, dann Spike-Punkte wiederholen); Env-Block aus Auth § 2 vollständig; `GOTRUE_PASSWORD_HIBP_ENABLED=false` | Chris, devops | Auth § 5, erste Punkte; Env-Werte prüfen, ohne Secrets zu posten | +| ☐ | `GOTRUE_SMS_AUTOCONFIRM=false` (die Self-Hosting-Vorlage setzt `true`): Enumerationsschutz | devops | Signup mit bereits bestätigter Adresse liefert 200 mit leeren `identities`, nicht 422 `user_already_exists` (AUTH-CFG-24) | +| ☐ | **Proxy vor GoTrue mit `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For`** (AUTH-CFG-13): Proxy **überschreibt** den Header, GoTrue ist von außen nicht direkt erreichbar | Chris (Einrichtung), devops (Test) | 31 Fehllogins derselben IP gegen `https:///auth/v1/token?grant_type=password` mit einem Testkonto: Nr. 31 liefert 429 `over_request_rate_limit`. Zweiter Test mit gefälschtem Header `X-Forwarded-For: 203.0.113.7`: darf die Drosselung nicht umgehen. Direktzugriff auf den GoTrue-Port von außen muss scheitern | +| ☐ | **Gateway sperrt `POST /auth/v1/otp` und `POST /auth/v1/magiclink`** (AUTH-CFG-20; `/otp` legt sonst Konten an) | Chris (Einrichtung), devops (Test) | `curl -s -o /dev/null -w '%{http_code}\n' -X POST https:///auth/v1/otp -H 'Content-Type: application/json' -d '{}'`, ebenso für `/auth/v1/magiclink`: erwartet Ablehnung durch das Gateway (z. B. 403/404), keine GoTrue-Antwort | +| ☐ | **SPA-Rewrite:** `/auth/confirm` wird von der Web-App ausgeliefert (alle Pfade auf `index.html`) | Chris (Einrichtung), devops (Test) | `curl -s -o /dev/null -w '%{http_code}\n' 'https:///auth/confirm?token_hash=x&type=email'` erwartet 200 und HTML der App | +| ☐ | **Mailvorlagen-URL** erreichbar (GoTrue lädt Vorlagen per URL und fällt bei Fehlern **still** auf Englisch zurück, Risiko R5); URL im Health-Check | Chris, devops | URL aus dem Env-Block per `curl -I` vom Server aus. **Testmail de und en** (Bestätigung und Reset) auf Staging: Betreff und Text in der richtigen Sprache, Link `/auth/confirm?token_hash=…` | +| ☐ | SMTP-Anbieter mit AVV und Absender-Domain entschieden und eingerichtet (AUTH-CFG-22, F7, bisher **vertagt**) | Chris | echter Versand auf Staging | +| ☐ | **Alarm auf SMTP-Fehler:** GoTrue-Logs mit `unexpected_failure`/HTTP 500 bei `/recover`, `/resend`, `/signup` lösen einen Alarm aus (die App zeigt bei SMTP-Ausfall bewusst die neutrale „Prüfe dein Postfach“-Seite, Nutzer merken nichts) | Chris (Einrichtung), devops (Test) | Alarm einmal auslösen (z. B. SMTP-Host auf Staging kurz falsch setzen) und Eingang bestätigen | +| ☐ | **#46 Timeouts und Monitoring:** Gateway-/Proxy-Timeout und GoTrue-SMTP-Timeout so gesetzt, dass bei hängendem SMTP ein 504 **mit Status** vor dem Client-Timeout kommt (Werte noch nicht festgelegt, Spike aus #46 offen); Monitoring umfasst zusätzlich **Gateway-5xx/504** für `/recover`, `/resend`, `/signup`, nicht nur `unexpected_failure`/500 | Chris, devops | Spike aus #46 auf Staging (`GOTRUE_SMTP_HOST=10.255.255.1`, nicht routbar): Status und Dauer für bekannte und unbekannte Adresse; Ergebnis in `docs/auth-konfiguration.md` eintragen. **Solange #46 offen ist, nicht abhakbar** | +| ☐ | `GOTRUE_RATE_LIMIT_EMAIL_SENT=30` und Monitoring auf 429 `over_email_send_rate_limit`; Mindestabstand 60 s (AUTH-CFG-08); Link-Gültigkeit 1 h (AUTH-CFG-07) | devops | Auth § 5 | +| ☐ | Nach Passwortänderung geht keine Benachrichtigungs-Mail aus (AUTH-CFG-19) | devops | Testkonto auf Staging, Postfach prüfen | +| ☐ | Rechtstexte-URLs und `TERMS_VERSION` final (R11); Bundle-/Application-ID final (R15) | Chris | vor dem Store-Release | + +## 4. Zugang zu Studio, Datenbank, Pooler und `/pg/` (Spec #2 § 4.2, Risiko R1) + +Studio läuft als Rolle `postgres` bzw. `supabase_admin` und **umgeht RLS**; im Self-Hosting gibt es in Studio keine Benutzerverwaltung. Wer Studio, 5432, den Pooler oder `/pg/` erreicht, kann alle Daten lesen und ändern. Gilt für **Staging und Produktion**. + +| ☐ | Punkt | Wer | Prüfung | +|---|---|---|---| +| ☐ | Studio nicht öffentlich erreichbar, nur per SSH-Tunnel oder VPN | Chris (Einrichtung), devops (Prüfung) | Von einem Rechner **außerhalb** von Tunnel/VPN: `curl -s -o /dev/null -m 5 -w '%{http_code}\n' https:///` darf keine Studio-Seite liefern (Timeout, 403/404 oder Verbindungsfehler) | +| ☐ | Postgres-Port 5432 nicht öffentlich | Chris, devops | von außen: `nc -zv -w 5 5432` schlägt fehl | +| ☐ | Pooler (Supavisor, Standard 6543) nicht öffentlich | Chris, devops | von außen: `nc -zv -w 5 6543` schlägt fehl (tatsächlicher Port **offen**, siehe 8) | +| ☐ | Kong-Route `/pg/` (pg-meta) nicht öffentlich | Chris, devops | `curl -s -o /dev/null -w '%{http_code}\n' https:///pg/tables` erwartet 403/404, nicht 200 | +| ☐ | **Basic-Auth** des Gateways gesetzt (`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD`, starkes Passwort, Standardwerte der Vorlage ersetzt) als zweite Schicht | Chris | Dashboard (per Tunnel) ohne Zugangsdaten liefert 401. Passwort nie ins Repo oder in Logs | +| ☐ | **Studio-Rolle bestätigt:** im Studio-SQL-Editor `select current_user;` ausführen; Ergebnis `postgres` oder `supabase_admin` (die Admin-Funktionen akzeptieren genau diese beiden Rollen, Spec § 4.2) | Chris | Ergebnis im Release-PR oder in #2 festhalten; bei anderer Rolle melden und Spec § 4.2 anpassen | +| ☐ | Im Studio nie `set …` ohne `local`, immer in einer Transaktion (Runbook § 1) | Chris | Dauerregel, kein Prüfbefehl | + +Eingriffe an Firewall, Reverse Proxy und DNS führt Chris aus; devops liefert nur Anleitung und die Außen-Tests oben. + +## 5. CI-Runner (ADR-0003, Issue #43, manuelle Schritte) + +ADR-0003 hat den Status „Vorgeschlagen“. Solange die Schritte offen sind, gilt `docker-host` als nicht für externe Beiträge geeignet; **vor dem ersten externen Beitrag, spätestens vor Release**, Schritt B umsetzen oder das Repo privat stellen. Issue #43 erst danach schließen. + +| ☐ | Punkt (ADR-0003, Abschnitt) | Wer | Prüfung | +|---|---|---|---| +| ☐ | **A. Verhalten prüfen:** Test-Fork-PR mit manipulierter `ci.yml`: Lauf wartet auf Freigabe; zweiter Push läuft ohne erneute Freigabe; danach Testkonto und Fork löschen | Chris | Ergebnis in #43 notieren; startet der Lauf ohne Freigabe: Repo sofort privat stellen | +| ☐ | **B.1** Erreichbarkeit des dind aus `docker`-Jobs prüfen | Chris | `curl -s -m 3 http://:2375/version` im Job (Gateway wie in `ci.yml`) | +| ☐ | **B.2** Runner in Wegwerf-VM bzw. separaten Host ohne Host-Mounts, ohne LAN-Zugriff, ohne persönliche Daten verlagern | Chris | Beschreibung des Setups in #43 | +| ☐ | **B.3** Notbremse (Runner stoppen) bekannt und einmal geprobt | Chris | Weg laut ADR **unverifiziert** | +| ☐ | **B.4 / C.1** Runner nur für dieses Repo registriert; Fork-PR-Läufe werden nicht freigegeben, solange nicht isoliert | Chris | Forgejo: Einstellungen, Actions, Runner | +| ☐ | **C.5** Runner-Token rotiert (Vorschlag: alle 3 Monate, sofort bei Verdacht) | Chris | Datum der letzten Rotation notieren | + +## 6. Mobile Stores (nur bei Store-Release) + +Android-AAB und iOS-Build: Upload nur nach ausdrücklicher Freigabe von Chris. iOS benötigt einen macOS-Runner oder lokalen Mac. Signierung und Zugänge liegen bei Chris (nicht im Repo). Details sind noch nicht festgelegt (siehe 8). + +## 7. Nach dem Deployment + +| ☐ | Punkt | Wer | Prüfung | +|---|---|---|---| +| ☐ | Smoke-Test Web: Start, Registrierung, Bestätigungs-Link, Anmeldung, Katalog zeigt Spiele | Chris | manuell auf der Zielumgebung | +| ☐ | Tag `v` erst nach Freigabe; der Release-Workflow übernimmt | Chris | `.claude/commands/release.md`, Schritt 4 | + +## 8. Offene Punkte für Chris (fehlende Angaben, nichts davon wurde erfunden) + +1. Adressen von Staging und Produktion (Domain, Studio-Adresse, Server-IP) und der Zugangsweg (SSH-Tunnel oder VPN, welches). +2. Welcher Reverse Proxy sitzt vor Kong/GoTrue; wie wird `X-Forwarded-For` dort gesetzt; wo werden `/auth/v1/otp` und `/magiclink` gesperrt (Proxy oder Kong). +3. Port des Pooler im tatsächlichen Setup; ob `/pg/` über Kong erreichbar ist. +4. Logging und Alarmierung: welches Werkzeug soll SMTP-Fehler, Gateway-5xx/504 und 429 `over_email_send_rate_limit` melden. +5. SMTP-Anbieter und Absender-Domain (F7 vertagt); Hosting der Mailvorlagen (URL). +6. Konkrete Timeout-Werte (GoTrue-SMTP, Proxy, Client) und Ergebnis des Spikes aus #46. +7. Verfahren für Migrationen auf Staging/Prod, Backup-/Restore-Runbook und Nachweis des Restore-Tests (ADR-0002; ein Betriebs-Runbook in `docs/` existiert noch nicht). +8. Ob Staging und Produktion auf demselben Server laufen (ADR-0002: getrennt, sobald echte Nutzer da sind). +9. Wie `DB_URL` für `psql` gebildet wird (Tunnel, Rolle); nicht ins Repo. +10. Entscheidung über ADR-0003 (Status „Vorgeschlagen“, Maßnahmen A/B/C). +11. Store-Release: Signierung, Bundle-/Application-ID (R15), Rechtstexte (R11). + +## 9. Nicht geprüft (Stand dieser Datei) + +Reine Dokumentation, erstellt ohne Zugriff auf Staging/Prod. Die Prüfbefehle wurden **nicht** ausgeführt; erwartete Ergebnisse (z. B. 403/404 bei gesperrten Routen) sind aus den Quellen abgeleitet. Ebenfalls nicht geprüft: das Verhalten von `supabase start` mit Seeds (Runbook § 2.7), das Verhalten von GoTrue bei hängendem SMTP (#46) und die Aussagen aus ADR-0003 zu Runner und Forgejo (laut ADR teils unverifiziert). diff --git a/supabase/README.md b/supabase/README.md index 3a4af99..7fd7c6d 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -20,7 +20,7 @@ supabase test db # alle pgTAP-Tests ausführen - Der Trigger `handle_new_user` erzwingt Altersbestätigung, Zustimmung, `terms_version` und Anzeigename (1–30 Zeichen). Fehlertexte: siehe `docs/datenmodell-r1.md`. Clients senden diese Werte als `user_metadata` bei `signUp`. - pgTAP-Fixtures, die `auth.users` direkt füllen, brauchen daher `age_confirmed`, `terms_accepted`, `terms_version` und `display_name` in `raw_user_meta_data`. - Ergebnisse der GoTrue-Prüfung: `docs/specs/1-auth-spike-ergebnisse.md`. -- Auth-Einstellungen (`AUTH-CFG-nn` in `config.toml`), Env-Block für Staging/Produktion, Release-Checkliste und bekannte Grenzen: [`docs/auth-konfiguration.md`](../docs/auth-konfiguration.md). +- Auth-Einstellungen (`AUTH-CFG-nn` in `config.toml`), Env-Block für Staging/Produktion, Auth-Checkliste und bekannte Grenzen: [`docs/auth-konfiguration.md`](../docs/auth-konfiguration.md). Zentrale Release-Checkliste (Katalog, `checks.sql`, Zugang, Auth/SMTP, Runner): [`docs/release-checkliste.md`](../docs/release-checkliste.md). - Mailvorlagen (Bestätigung, Passwort-Reset; de/en nach `user_metadata.locale`): `templates/`. Lokal landen Mails in Mailpit (`http://127.0.0.1:54324`). ## E2E-Tests (Playwright) From 5e2c11b49fb18bbbaf2d31f041f9f1a7c6be7790 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:45:30 +0100 Subject: [PATCH 02/30] docs: Review-Korrekturen an Release-Checkliste Refs #2 Refs #46 Co-Authored-By: Claude Sonnet 5.5 --- .claude/commands/release.md | 2 +- docs/katalog-pflege.md | 2 +- docs/release-checkliste.md | 51 ++++++++++++++++++++----------------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/.claude/commands/release.md b/.claude/commands/release.md index ddf2a19..91dbd29 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -4,7 +4,7 @@ argument-hint: --- Bereite Release $ARGUMENTS vor: -0. Gehe `docs/release-checkliste.md` durch (Katalog/`checks.sql`, Zugang, Auth/SMTP, Runner) und melde offene Punkte an Chris. +0. `docs/release-checkliste.md` nur lesen und den Status erheben (was ist erledigt, was offen). Keine Prüfbefehle gegen Staging/Prod ohne Freigabe von Chris. Offene Release-Blocker (Abschnitte 2 bis 5) halten Schritt 1 nicht auf (Version und Changelog dürfen vorbereitet werden), verhindern aber den Tag-Vorschlag in Schritt 4; Chris entscheidet. 1. `devops`: prüft, dass `main` grün ist, setzt `version:` in `app/pubspec.yaml` (Build-Nummer +1) auf einem Branch `release/$ARGUMENTS` und erstellt einen PR. 2. `docs-writer`: ergänzt `CHANGELOG.md` aus den gemergten PRs seit dem letzten Tag und diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index aeb8cd1..48046fe 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -113,7 +113,7 @@ Je Regel eine Zeile (Soll, Ist, Status `ok`/`Verstoß`/`Information`). **Abnahme | Pfad ohne Treffer in `sql_paths` | Nur die Warnung `no files matched pattern: …`, **kein Fehler**, Exit-Code 0. Ein Tippfehler im Pfad überspringt die Datei also still; nach dem Reset `checks.sql` bzw. die Zeilenzahlen ansehen. | | Datei, die nur Kommentare enthält | läuft ohne Fehler | | psql-Metabefehle (`\i` …) in Seed-Dateien | nicht nutzbar (die Datei wird als SQL-Batch gesendet) | -| `supabase start` lädt Seeds nur beim ersten Start einer leeren Datenbank | **nicht geprüft** (hätte Chris' laufenden lokalen Stack stoppen müssen). Nicht darauf verlassen: nach Änderungen an Migrationen oder Seeds immer `supabase db reset`. Auf Staging/Prod nicht für Seeds nutzen, sondern per `psql` einspielen (2.5, [Release-Checkliste](release-checkliste.md) Abschnitt 2). | +| `supabase start` lädt Seeds nur beim ersten Start einer leeren Datenbank | **nicht geprüft** (hätte Chris' laufenden lokalen Stack stoppen müssen). Nicht darauf verlassen: nach Änderungen an Migrationen oder Seeds immer `supabase db reset`. Nur lokal relevant. `supabase db reset` niemals gegen Staging/Prod (löscht alle Daten); Seeds dort ausschließlich per `psql` (2.5, [Release-Checkliste](release-checkliste.md) Abschnitt 2). | ## 3. Spiel anlegen und bearbeiten diff --git a/docs/release-checkliste.md b/docs/release-checkliste.md index c68c389..f42f9bf 100644 --- a/docs/release-checkliste.md +++ b/docs/release-checkliste.md @@ -7,7 +7,7 @@ | Quellen | `docs/specs/2-katalog-daten.md` (§ 4.2, § 8.1 Aufgabe D1), `docs/auth-konfiguration.md` (§ 3, § 5), `docs/katalog-pflege.md` (§ 1, § 2), ADR-0002, ADR-0003, Issue #46 | | Bezug | Refs #2 (Katalog), Refs #1 (Auth), Refs #46 (SMTP-Timeouts), Refs #43 (CI-Runner) | -Diese Datei ist die zentrale Stelle. Die ausführliche Auth-Liste bleibt in `docs/auth-konfiguration.md` § 5; die Release-Blocker daraus stehen hier, damit nichts übersehen wird. +Diese Datei ist die zentrale Stelle. Die ausführliche Auth-Liste bleibt in `docs/auth-konfiguration.md` § 5; die Release-Blocker daraus stehen hier, damit nichts übersehen wird. **Auth § 5 und Abschnitt 3 dieser Datei beide pflegen.** **Grundregeln (CLAUDE.md, Rolle devops):** Kein Tag, kein Deployment, kein Store-Upload ohne ausdrückliche Freigabe von Chris. Infrastruktur (Server, DNS, Reverse Proxy, Firewall) richtet Chris ein bzw. gibt sie frei; devops liefert Anleitung und Prüfschritte. Secrets nie in Logs, Tickets oder dieser Datei. In den Prüfbefehlen stehen Platzhalter in `<…>`; echte Werte stehen nicht im Repo. @@ -22,22 +22,22 @@ Ergebnis je Punkt mit Datum und Umgebung (Staging/Prod) im Release-PR oder als K | ☐ | `main` ist grün (Format, Analyse, Tests, Web-Build, pgTAP, `e2e`, Deno-Schritte) | devops | Forgejo Actions, letzter Lauf auf `main` | | ☐ | Version in `app/pubspec.yaml` gesetzt (`x.y.z+build`), Changelog und Release Notes erstellt | devops, docs-writer | `.claude/commands/release.md` | | ☐ | Migrationen auf Staging eingespielt und geprüft; Produktion erst nach Freigabe | Chris | Verfahren **offen**, siehe 8 | -| ☐ | Backups laufen und Restore ist getestet (ADR-0002, Pflichtbaustein) | Chris | Nachweis des letzten Restore-Tests, **offen**, siehe 8 | +| ☐ | **ADR-0002-Pflichtbausteine vor dem ersten echten Nutzer (Go-live):** automatisierte Backups (DB + Storage) an getrenntem Ort, **getesteter** Restore, TLS und Reverse Proxy, Firewall (nur nötige Ports), Update-Routine, Monitoring und Alarm, Secrets nicht im Repo | Chris | je Baustein Nachweis (z. B. Restore-Test mit Datum); Umsetzung und Verfahren **offen**, siehe 8 | ## 2. Katalog und Startbestand (Spec #2, KAT-05) -Reihenfolge immer: Migrationen, `vocabulary.sql`, `startbestand.sql` (Runbook `docs/katalog-pflege.md` § 2.5). +Reihenfolge: Migrationen, `vocabulary.sql`, `startbestand.sql` (Runbook `docs/katalog-pflege.md` § 2.5). **`startbestand.sql` wird nur bis einschließlich Go-live eingespielt**; danach ist es eingefroren, weil es Korrekturen aus dem Studio überschreiben würde (Spec § 4.3, Spec-F6). Danach bei Bedarf nur noch `vocabulary.sql` (idempotent). | ☐ | Punkt | Wer | Prüfung | |---|---|---|---| -| ☐ | **C0:** Vokabular (`supabase/catalog/vocabulary.csv`, Genres und Mechaniken) von Chris freigegeben oder korrigiert; `vocabulary.sql` ist aus der freigegebenen CSV erzeugt | Chris | Freigabe als Kommentar in #2 oder im PR; CI-Schritt „erzeugte Dateien aktuell“ grün | +| ☐ | **C0:** Vokabular (`supabase/catalog/vocabulary.csv`, Genres und Mechaniken) von Chris freigegeben oder korrigiert; `vocabulary.sql` ist aus der freigegebenen CSV erzeugt | Chris | Freigabe als Kommentar in #2 oder im PR; CI-Schritt „Erzeugte Katalogdateien aktuell?“ grün | | ☐ | Startbestand in `startbestand.csv` gepflegt (mindestens 180, Ziel ca. 200), `startbestand.sql` und `startbestand-keys.csv` erzeugt und committet (C1) | Chris | Ablauf Runbook § 2.4; CI grün | | ☐ | Herkunft der Fakten (SB3) und der Kurztexte (SB4) bestätigt; **rechtliche Prüfung** vor dem Release | Chris | schriftliche Bestätigung in #2 | -| ☐ | Vokabular und Startbestand auf der Zielumgebung eingespielt (nur per Tunnel/VPN, siehe 4) | Chris | `psql "$DB_URL" -v ON_ERROR_STOP=1 --single-transaction -f supabase/catalog/vocabulary.sql -f supabase/catalog/startbestand.sql` | -| ☐ | **Seed-Verhalten:** Auf Staging/Prod nicht auf `supabase start`/`supabase db reset` für Seeds verlassen. Runbook § 2.7: Das Verhalten von `supabase start` (Seeds nur beim ersten Start einer leeren DB) ist **nicht geprüft**. Seeds ausdrücklich per `psql` (Zeile oben) einspielen und mit `checks.sql` bestätigen | Chris, devops | Zeile oben plus nächster Punkt | -| ☐ | **`checks.sql` (SB1):** mindestens 180 sichtbare Startbestandsspiele; SB2, SB4 und SB6 melden 0 Verstöße | Chris (Abnahme C1) | `psql "$DB_URL" -f supabase/catalog/checks.sql` oder Inhalt im Studio-SQL-Editor. Erwartung: Status `ok` bei SB1/SB2/SB4/SB6, SB1-Istwert >= 180. Lokal/CI ist SB1 nur Information; auf Produktion zählt der Wert | +| ☐ | Vokabular und Startbestand auf der Zielumgebung eingespielt (nur per Tunnel/VPN, siehe 4; `startbestand.sql` nur bis Go-live) | Chris | `psql "$DB_URL" -v ON_ERROR_STOP=1 --single-transaction -f supabase/catalog/vocabulary.sql -f supabase/catalog/startbestand.sql` | +| ☐ | **Seeds nur per `psql`:** `supabase db reset` niemals gegen Staging/Prod (löscht alle Daten); Seeds dort ausschließlich per `psql` (Runbook § 2.5). Das Verhalten von `supabase start` mit Seeds (nur beim ersten Start einer leeren DB) ist **nicht geprüft** (Runbook § 2.7) und nur lokal relevant | Chris, devops | Zeile oben plus nächster Punkt | +| ☐ | **`checks.sql` (SB1, Go-live-Kriterium):** mindestens 180 sichtbare Startbestandsspiele; SB2, SB4 und SB6 melden 0 Verstöße. `checks.sql` muss aus **demselben Commit** stammen wie das eingespielte `startbestand.sql` (die committete Fassung ohne Startbestand enthält eine leere ID-Liste `'[]'`, dann sind SB2/SB4/SB6 aussagelos). Zusätzlich SB0 (Istwert = Soll „alle N“) und die Informationszeile zu abweichenden Vokabular-IDs ansehen | Chris (Abnahme C1) | `psql "$DB_URL" -v ON_ERROR_STOP=1 -f supabase/catalog/checks.sql` oder Inhalt im Studio-SQL-Editor. Erwartung: Status `ok` bei SB0/SB1/SB2/SB4/SB6, SB1-Istwert >= 180. Bei < 180 zeigt `checks.sql` „Verstoß“; nur der CI-Schritt wertet SB1 lokal als Information | | ☐ | Das lokale Beispielspiel aus `seed.sql` ist auf Produktion nicht vorhanden (reiner Sicherheitsnetz-Check; `checks.sql` zählt es nicht mit) | devops | `select count(*) from public.games where title = 'Beispielspiel';` erwartet 0 | -| ☐ | **Go-live:** danach ist `startbestand.sql` eingefroren und wird nicht mehr eingespielt (Korrekturen nur im Studio, Spec-F6) | Chris | Hinweis im Release-PR | +| ☐ | **Go-live:** `startbestand.sql` ist ab jetzt eingefroren und wird nicht mehr eingespielt (Korrekturen nur im Studio, Spec-F6); dieser Schritt kommt **nach** dem Einspielen und der `checks.sql`-Abnahme | Chris | Hinweis im Release-PR | Issue #2 wird erst nach der Abnahme von `checks.sql` auf Produktion (C1) und nach Fertigstellung von #4 (KAT-02) geschlossen, und nur von Chris. @@ -48,28 +48,30 @@ Vollständige Liste mit AUTH-CFG-Verweisen: `docs/auth-konfiguration.md` § 5; G | ☐ | Punkt | Wer | Prüfung | |---|---|---|---| | ☐ | GoTrue-Image gepinnt auf `supabase/gotrue:v2.197.0` (oder bewusst aktualisiert, dann Spike-Punkte wiederholen); Env-Block aus Auth § 2 vollständig; `GOTRUE_PASSWORD_HIBP_ENABLED=false` | Chris, devops | Auth § 5, erste Punkte; Env-Werte prüfen, ohne Secrets zu posten | -| ☐ | `GOTRUE_SMS_AUTOCONFIRM=false` (die Self-Hosting-Vorlage setzt `true`): Enumerationsschutz | devops | Signup mit bereits bestätigter Adresse liefert 200 mit leeren `identities`, nicht 422 `user_already_exists` (AUTH-CFG-24) | -| ☐ | **Proxy vor GoTrue mit `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For`** (AUTH-CFG-13): Proxy **überschreibt** den Header, GoTrue ist von außen nicht direkt erreichbar | Chris (Einrichtung), devops (Test) | 31 Fehllogins derselben IP gegen `https:///auth/v1/token?grant_type=password` mit einem Testkonto: Nr. 31 liefert 429 `over_request_rate_limit`. Zweiter Test mit gefälschtem Header `X-Forwarded-For: 203.0.113.7`: darf die Drosselung nicht umgehen. Direktzugriff auf den GoTrue-Port von außen muss scheitern | -| ☐ | **Gateway sperrt `POST /auth/v1/otp` und `POST /auth/v1/magiclink`** (AUTH-CFG-20; `/otp` legt sonst Konten an) | Chris (Einrichtung), devops (Test) | `curl -s -o /dev/null -w '%{http_code}\n' -X POST https:///auth/v1/otp -H 'Content-Type: application/json' -d '{}'`, ebenso für `/auth/v1/magiclink`: erwartet Ablehnung durch das Gateway (z. B. 403/404), keine GoTrue-Antwort | +| ☐ | `GOTRUE_SMS_AUTOCONFIRM=false` bzw. `ENABLE_PHONE_AUTOCONFIRM=false` (die Self-Hosting-Vorlage setzt `true`): Enumerationsschutz | devops | Signup mit bereits bestätigter Adresse liefert 200 mit leeren `identities`, nicht 422 `user_already_exists` (AUTH-CFG-24) | +| ☐ | **Proxy vor GoTrue mit `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For`** (AUTH-CFG-13): Proxy **überschreibt** den Header, GoTrue ist von außen nicht direkt erreichbar | Chris (Einrichtung), devops (Test) | **Nur auf Staging testen** (der Test sperrt die eigene IP für ca. 5 Minuten). 31 Fehllogins derselben IP gegen `https:///auth/v1/token?grant_type=password` mit Header `-H "apikey: "` (Platzhalter; ohne Key antwortet das Gateway mit 401 und der Test sagt nichts) und einem Testkonto: Nr. 31 liefert 429 `over_request_rate_limit`. Zweiter Test mit gefälschtem Header `X-Forwarded-For: 203.0.113.7`: darf die Drosselung nicht umgehen. Direktzugriff auf den GoTrue-Port von außen muss scheitern | +| ☐ | **Gateway sperrt `POST /auth/v1/otp` und `POST /auth/v1/magiclink`** (AUTH-CFG-20; `/otp` legt sonst Konten an) | Chris (Einrichtung), devops (Test) | `curl -s -i -X POST https:///auth/v1/otp -H "apikey: " -H 'Content-Type: application/json' -d '{}'`, ebenso für `/auth/v1/magiclink` (der `apikey`-Header ist nötig, sonst kommt 401 und der Test ist wertlos). Erwartet: Ablehnung durch das Gateway (z. B. 403/404) **ohne** GoTrue-JSON (kein `code`/`error_code`/`msg` im Body); ein 400/422 mit GoTrue-Fehlerbody heißt: nicht gesperrt | | ☐ | **SPA-Rewrite:** `/auth/confirm` wird von der Web-App ausgeliefert (alle Pfade auf `index.html`) | Chris (Einrichtung), devops (Test) | `curl -s -o /dev/null -w '%{http_code}\n' 'https:///auth/confirm?token_hash=x&type=email'` erwartet 200 und HTML der App | -| ☐ | **Mailvorlagen-URL** erreichbar (GoTrue lädt Vorlagen per URL und fällt bei Fehlern **still** auf Englisch zurück, Risiko R5); URL im Health-Check | Chris, devops | URL aus dem Env-Block per `curl -I` vom Server aus. **Testmail de und en** (Bestätigung und Reset) auf Staging: Betreff und Text in der richtigen Sprache, Link `/auth/confirm?token_hash=…` | +| ☐ | **Mailvorlagen-URL** erreichbar (GoTrue lädt Vorlagen per URL und fällt bei Fehlern **still** auf Englisch zurück, Spec #1 Risiko R5); URL im Health-Check | Chris, devops | URL aus dem Env-Block per `curl -I` vom Server aus. **Testmail de und en** (Bestätigung und Reset) auf Staging: Betreff und Text in der richtigen Sprache, Link `/auth/confirm?token_hash=…` | | ☐ | SMTP-Anbieter mit AVV und Absender-Domain entschieden und eingerichtet (AUTH-CFG-22, F7, bisher **vertagt**) | Chris | echter Versand auf Staging | | ☐ | **Alarm auf SMTP-Fehler:** GoTrue-Logs mit `unexpected_failure`/HTTP 500 bei `/recover`, `/resend`, `/signup` lösen einen Alarm aus (die App zeigt bei SMTP-Ausfall bewusst die neutrale „Prüfe dein Postfach“-Seite, Nutzer merken nichts) | Chris (Einrichtung), devops (Test) | Alarm einmal auslösen (z. B. SMTP-Host auf Staging kurz falsch setzen) und Eingang bestätigen | -| ☐ | **#46 Timeouts und Monitoring:** Gateway-/Proxy-Timeout und GoTrue-SMTP-Timeout so gesetzt, dass bei hängendem SMTP ein 504 **mit Status** vor dem Client-Timeout kommt (Werte noch nicht festgelegt, Spike aus #46 offen); Monitoring umfasst zusätzlich **Gateway-5xx/504** für `/recover`, `/resend`, `/signup`, nicht nur `unexpected_failure`/500 | Chris, devops | Spike aus #46 auf Staging (`GOTRUE_SMTP_HOST=10.255.255.1`, nicht routbar): Status und Dauer für bekannte und unbekannte Adresse; Ergebnis in `docs/auth-konfiguration.md` eintragen. **Solange #46 offen ist, nicht abhakbar** | +| ☐ | **#46 Timeouts und Monitoring:** Gateway-/Proxy-Timeout und GoTrue-SMTP-Timeout so gesetzt, dass bei hängendem SMTP ein 504 **mit Status** vor dem Client-Timeout kommt (Werte noch nicht festgelegt, Spike aus #46 offen); Monitoring umfasst zusätzlich **Gateway-5xx/504** für `/recover`, `/resend`, `/signup`, nicht nur `unexpected_failure`/500 | Chris, devops | Spike aus #46 nur auf Staging und **nur mit Freigabe von Chris** (`GOTRUE_SMTP_HOST=10.255.255.1`, nicht routbar): Status und Dauer für bekannte und unbekannte Adresse; Ergebnis in `docs/auth-konfiguration.md` eintragen. **Solange #46 offen ist, nicht abhakbar** | | ☐ | `GOTRUE_RATE_LIMIT_EMAIL_SENT=30` und Monitoring auf 429 `over_email_send_rate_limit`; Mindestabstand 60 s (AUTH-CFG-08); Link-Gültigkeit 1 h (AUTH-CFG-07) | devops | Auth § 5 | | ☐ | Nach Passwortänderung geht keine Benachrichtigungs-Mail aus (AUTH-CFG-19) | devops | Testkonto auf Staging, Postfach prüfen | | ☐ | Rechtstexte-URLs und `TERMS_VERSION` final (R11); Bundle-/Application-ID final (R15) | Chris | vor dem Store-Release | -## 4. Zugang zu Studio, Datenbank, Pooler und `/pg/` (Spec #2 § 4.2, Risiko R1) +## 4. Zugang zu Studio, Datenbank, Pooler und `/pg/` (Spec #2 § 4.2, Spec #2 Risiko R1) + +**Herleitung:** Die Routen und Ports stammen aus der Supabase-Self-Hosting-Vorlage (`kong.yml`, `docker-compose.yml`); diese Dateien liegen nicht im Repo und wurden nicht gelesen. Die Prüfungen unten sind daraus abgeleitet und am echten Setup zu bestätigen. Studio läuft als Rolle `postgres` bzw. `supabase_admin` und **umgeht RLS**; im Self-Hosting gibt es in Studio keine Benutzerverwaltung. Wer Studio, 5432, den Pooler oder `/pg/` erreicht, kann alle Daten lesen und ändern. Gilt für **Staging und Produktion**. | ☐ | Punkt | Wer | Prüfung | |---|---|---|---| -| ☐ | Studio nicht öffentlich erreichbar, nur per SSH-Tunnel oder VPN | Chris (Einrichtung), devops (Prüfung) | Von einem Rechner **außerhalb** von Tunnel/VPN: `curl -s -o /dev/null -m 5 -w '%{http_code}\n' https:///` darf keine Studio-Seite liefern (Timeout, 403/404 oder Verbindungsfehler) | -| ☐ | Postgres-Port 5432 nicht öffentlich | Chris, devops | von außen: `nc -zv -w 5 5432` schlägt fehl | -| ☐ | Pooler (Supavisor, Standard 6543) nicht öffentlich | Chris, devops | von außen: `nc -zv -w 5 6543` schlägt fehl (tatsächlicher Port **offen**, siehe 8) | -| ☐ | Kong-Route `/pg/` (pg-meta) nicht öffentlich | Chris, devops | `curl -s -o /dev/null -w '%{http_code}\n' https:///pg/tables` erwartet 403/404, nicht 200 | +| ☐ | Studio nicht öffentlich erreichbar, nur per SSH-Tunnel oder VPN | Chris (Einrichtung), devops (Prüfung) | Von einem Rechner **außerhalb** von Tunnel/VPN: `curl -s -o /dev/null -m 5 -w '%{http_code}\n' https:///` darf keine Studio-Seite liefern (Timeout, 403/404 oder Verbindungsfehler). Auch **401 (Basic-Auth-Abfrage) von außen gilt als Verstoß**: Studio ist nur über Tunnel/VPN erreichbar, Basic-Auth ist nur die zweite Schicht | +| ☐ | Postgres-Port 5432 nicht öffentlich (IPv4 **und** IPv6) | Chris, devops | von außen: `nc -zv -w 5 5432` und `nc -6zv -w 5 5432` schlagen fehl. Zusätzlich allgemeiner Port-Scan der öffentlichen Adressen (z. B. `nmap -Pn -p- `, nur gegen eigene Server): nur die gewollten Ports (443, ggf. 80) offen | +| ☐ | Pooler (Supavisor, Standard 6543; auch 5432 kann der Pooler belegen) nicht öffentlich (IPv4 und IPv6) | Chris, devops | von außen: `nc -zv -w 5 6543` schlägt fehl; abgedeckt durch den Port-Scan oben (tatsächlicher Port **offen**, siehe 8) | +| ☐ | Kong-Route `/pg/` (pg-meta) von außen gar nicht erreichbar | Chris, devops | `curl -s -i https:///pg/tables -H "apikey: "` von außen: erwartet 403/404 oder Verbindungsfehler. **401 oder 200 gilt als Verstoß** (die Vorlage schützt `/pg/` per Key-Auth und Admin-ACL, das reicht hier nicht). Ohne `apikey` ist ein 401 wertlos | | ☐ | **Basic-Auth** des Gateways gesetzt (`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD`, starkes Passwort, Standardwerte der Vorlage ersetzt) als zweite Schicht | Chris | Dashboard (per Tunnel) ohne Zugangsdaten liefert 401. Passwort nie ins Repo oder in Logs | | ☐ | **Studio-Rolle bestätigt:** im Studio-SQL-Editor `select current_user;` ausführen; Ergebnis `postgres` oder `supabase_admin` (die Admin-Funktionen akzeptieren genau diese beiden Rollen, Spec § 4.2) | Chris | Ergebnis im Release-PR oder in #2 festhalten; bei anderer Rolle melden und Spec § 4.2 anpassen | | ☐ | Im Studio nie `set …` ohne `local`, immer in einer Transaktion (Runbook § 1) | Chris | Dauerregel, kein Prüfbefehl | @@ -78,13 +80,13 @@ Eingriffe an Firewall, Reverse Proxy und DNS führt Chris aus; devops liefert nu ## 5. CI-Runner (ADR-0003, Issue #43, manuelle Schritte) -ADR-0003 hat den Status „Vorgeschlagen“. Solange die Schritte offen sind, gilt `docker-host` als nicht für externe Beiträge geeignet; **vor dem ersten externen Beitrag, spätestens vor Release**, Schritt B umsetzen oder das Repo privat stellen. Issue #43 erst danach schließen. +ADR-0003 hat den Status „Vorgeschlagen“. Solange die Schritte offen sind, gilt `docker-host` als nicht für externe Beiträge geeignet; **vor dem ersten externen Beitrag, spätestens vor Release**, Schritt B umsetzen oder das Repo privat stellen (dann entfallen die B-Zeilen weitgehend). Issue #43 erst danach schließen. | ☐ | Punkt (ADR-0003, Abschnitt) | Wer | Prüfung | |---|---|---|---| -| ☐ | **A. Verhalten prüfen:** Test-Fork-PR mit manipulierter `ci.yml`: Lauf wartet auf Freigabe; zweiter Push läuft ohne erneute Freigabe; danach Testkonto und Fork löschen | Chris | Ergebnis in #43 notieren; startet der Lauf ohne Freigabe: Repo sofort privat stellen | -| ☐ | **B.1** Erreichbarkeit des dind aus `docker`-Jobs prüfen | Chris | `curl -s -m 3 http://:2375/version` im Job (Gateway wie in `ci.yml`) | -| ☐ | **B.2** Runner in Wegwerf-VM bzw. separaten Host ohne Host-Mounts, ohne LAN-Zugriff, ohne persönliche Daten verlagern | Chris | Beschreibung des Setups in #43 | +| ☐ | **A. Verhalten prüfen:** Test-Fork-PR mit manipulierter `ci.yml`: Lauf wartet auf Freigabe; zweiter Push läuft ohne erneute Freigabe; danach Testkonto und Fork löschen | Chris | Ergebnis in #43 notieren; startet der Lauf ohne Freigabe: Repo sofort privat stellen und Runner pausieren (B.3) | +| ☐ | **B.1** Erreichbarkeit des dind aus `docker`-Jobs prüfen | Chris | `curl -s -m 3 http://:2375/version` im Job (Gateway wie in `ci.yml`). Antwortet der dind, gilt die Warnung für alle Jobs des Runners | +| ☐ | **B.2** Runner in Wegwerf-VM bzw. separaten Host ohne Host-Mounts, ohne LAN-Zugriff, ohne Zugangsdaten und ohne persönliche Daten verlagern | Chris | Beschreibung des Setups in #43 | | ☐ | **B.3** Notbremse (Runner stoppen) bekannt und einmal geprobt | Chris | Weg laut ADR **unverifiziert** | | ☐ | **B.4 / C.1** Runner nur für dieses Repo registriert; Fork-PR-Läufe werden nicht freigegeben, solange nicht isoliert | Chris | Forgejo: Einstellungen, Actions, Runner | | ☐ | **C.5** Runner-Token rotiert (Vorschlag: alle 3 Monate, sofort bei Verdacht) | Chris | Datum der letzten Rotation notieren | @@ -98,7 +100,7 @@ Android-AAB und iOS-Build: Upload nur nach ausdrücklicher Freigabe von Chris. i | ☐ | Punkt | Wer | Prüfung | |---|---|---|---| | ☐ | Smoke-Test Web: Start, Registrierung, Bestätigungs-Link, Anmeldung, Katalog zeigt Spiele | Chris | manuell auf der Zielumgebung | -| ☐ | Tag `v` erst nach Freigabe; der Release-Workflow übernimmt | Chris | `.claude/commands/release.md`, Schritt 4 | +| ☐ | Tag `v` erst nach Freigabe. Ein Release-/Deploy-Workflow existiert noch nicht (`.forgejo/workflows/` enthält nur `ci.yml`), siehe 8 | Chris | `.claude/commands/release.md`, Schritt 4 | ## 8. Offene Punkte für Chris (fehlende Angaben, nichts davon wurde erfunden) @@ -112,7 +114,8 @@ Android-AAB und iOS-Build: Upload nur nach ausdrücklicher Freigabe von Chris. i 8. Ob Staging und Produktion auf demselben Server laufen (ADR-0002: getrennt, sobald echte Nutzer da sind). 9. Wie `DB_URL` für `psql` gebildet wird (Tunnel, Rolle); nicht ins Repo. 10. Entscheidung über ADR-0003 (Status „Vorgeschlagen“, Maßnahmen A/B/C). -11. Store-Release: Signierung, Bundle-/Application-ID (R15), Rechtstexte (R11). +11. Release-/Deploy-Workflow (Web-Deployment, Tag `v*`) existiert noch nicht; Ziel, Verfahren und Freigabe-Schritt sind festzulegen. +12. Store-Release: Signierung, Bundle-/Application-ID (R15), Rechtstexte (R11). ## 9. Nicht geprüft (Stand dieser Datei) From daeae999ddbb65d1279d281e76f2d72c988e9e65 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:46:19 +0100 Subject: [PATCH 03/30] feat(db): add admin_merge_games and repoint_game_references (KAT-05, 2b) Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/katalog-pflege.md | 116 ++++- .../20261103193928_catalog_merge.sql | 280 ++++++++++ .../tests/database/07_catalog_merge.test.sql | 484 ++++++++++++++++++ 3 files changed, 875 insertions(+), 5 deletions(-) create mode 100644 supabase/migrations/20261103193928_catalog_merge.sql create mode 100644 supabase/tests/database/07_catalog_merge.test.sql diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index ea3d551..5d0b200 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -2,9 +2,9 @@ Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`. -Stand: PR 2a (Datenmodell, Startbestand, Ausblenden). Die Abschnitte **Zusammenführen** (PR 2b) und **Meldungen bearbeiten** (PR 2c) kommen mit den jeweiligen PRs dazu. +Stand: PR 2b (Datenmodell, Startbestand, Ausblenden, Zusammenführen). Der Abschnitt **Meldungen bearbeiten** (PR 2c) kommt mit dem jeweiligen PR dazu. -**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (`supabase/tests/database/06_catalog_foundation.test.sql`, Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). +**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). ## 1. Zugang @@ -119,7 +119,7 @@ Je Regel eine Zeile (Soll, Ist, Status `ok`/`Verstoß`/`Information`). **Abnahme Jede Änderung an einem Fakt eines Katalogspiels (und jede Zuordnung zu Verlag, Genre, Mechanik oder Kategorie) trägt der Trigger automatisch in `games.manual_fields` ein, damit ein späterer Import sie nicht überschreibt. Im Studio muss dafür nichts zusätzlich gepflegt werden. Als Rolle `postgres` bleibt das Kennzeichnen auch dann aktiv, wenn jemand `shuffle.catalog_import` setzt. -**Spiel finden, bevor man es ändert.** Titel sind nicht eindeutig (ab PR 2b kann es gleichnamige Duplikate geben). Deshalb zuerst die ID nachschlagen und alle weiteren Bausteine über `id` ausführen: +**Spiel finden, bevor man es ändert.** Titel sind nicht eindeutig (es kann gleichnamige Duplikate geben, siehe 5). Deshalb zuerst die ID nachschlagen und alle weiteren Bausteine über `id` ausführen: ```sql select id, title, year, hidden_at from public.games @@ -164,7 +164,7 @@ where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog'; Regeln: - **Katalogspiele nie löschen, sondern ausblenden** (siehe 4). Zugeordnete Sammlungen, Favoriten und Partien verweisen auf sie. -- **`merged_into_id` nie von Hand setzen**; Zusammenführen kommt mit PR 2b über die Funktion `admin_merge_games`. Der Trigger lehnt einen Verweis auf ein privates Spiel ab (`merged_into_not_catalog`). +- **`merged_into_id` nie von Hand setzen**; Zusammenführen geht nur über die Funktion `admin_merge_games` (siehe 5). Der Trigger lehnt einen Verweis auf ein privates Spiel ab (`merged_into_not_catalog`). - Private Spiele von Nutzern im Studio nicht anfassen. - Zulässige Schlüssel in `manual_fields`: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`. @@ -186,7 +186,113 @@ Hinweis: Der Eintrag bleibt über die rohe Schnittstelle per ID lesbar (`games` ## 5. Zusammenführen -Kommt mit PR 2b. +Zwei Katalogspiele, die dasselbe Spiel beschreiben (Duplikat), führt man mit der Funktion `public.admin_merge_games(quelle, ziel)` zusammen. **Nie per Hand-SQL** (`update … set game_id`): Das Zusammenführen berührt Sammlungen, Favoriten, Partien (mit Edition), Wertungsbögen und Varianten, dazu eindeutige Schlüssel und zusammengesetzte Fremdschlüssel. Die Funktion macht alles in einer Transaktion und meldet, was passiert ist. + +- **Quelle** ist das Duplikat, das verschwindet. Sie wird **nicht gelöscht**, sondern ausgeblendet und auf das Ziel verwiesen (`hidden_at`, `merged_into_id`); ihre ID bleibt gültig, die App leitet alte Links auf das Ziel um (KAT-02). +- **Ziel** ist der Eintrag, der bleibt. Er muss sichtbar sein (nicht ausgeblendet, nicht selbst zusammengeführt). Das Ziel behält seine Fakten, Kurztexte und Zuordnungen (Verlag, Genre, Mechanik, Kategorie); die Zuordnungen der Quelle bleiben am ausgeblendeten Quell-Eintrag. Was am Ziel fehlt, ergänzt man vorher oder nachher mit den Bausteinen aus 3. +- Beide müssen **Katalogspiele** sein. Einen vorläufigen privaten Eintrag eines Nutzers führt man nicht hiermit zusammen (dafür gibt es ab PR 2c die Meldungs-Funktion). +- **Es gibt kein Rückgängigmachen.** Vor dem Zusammenführen den Stand des Backups prüfen; im Zweifel zuerst die Vorschau (unten) lesen. + +**Schritt 1: Duplikate finden** (normalisierter Titel + Jahr, nur sichtbare Katalogspiele; die älteste ID steht zuerst): + +```sql +select lower(btrim(title)) as titel_normalisiert, year, + array_agg(id order by created_at, id) as ids, count(*) as anzahl +from public.catalog_games +group by lower(btrim(title)), year +having count(*) > 1 +order by 1, 2; +``` + +Ein Duplikat kann auch einen leicht anderen Titel haben. Dann gezielt suchen und **die IDs ablesen** (Titel sind nicht eindeutig, alle weiteren Bausteine laufen über `id`): + +```sql +select id, title, year, hidden_at, merged_into_id, bgg_id, created_at from public.games +where visibility = 'catalog' and lower(btrim(title)) = lower(btrim('Duplikat Runbook')) +order by created_at, id; +``` + +Welcher Eintrag das Ziel wird, entscheidet der Admin; sinnvoll ist der mit den besseren Fakten, der BGG-Kennung oder den meisten Verweisen. In den Bausteinen unten stehen Beispiel-IDs: `7a1b2c3d-0000-4000-8000-000000000002` ist die Quelle, `7a1b2c3d-0000-4000-8000-000000000003` das Ziel. Immer durch die echten IDs ersetzen. + +**Schritt 2: Vorschau.** Wie viele Verweise hängen an der Quelle? + +```sql +select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'favorite_games', count(*) from public.favorite_games where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'plays', count(*) from public.plays where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'score_sheets', count(*) from public.score_sheets where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'game_variants', count(*) from public.game_variants where game_id = '7a1b2c3d-0000-4000-8000-000000000002'; +``` + +Wer hat beide Spiele in der Sammlung (Doppelbesitz)? Für diese Nutzer gilt die Kollisionsregel (siehe unten): + +```sql +select s.owner_id, s.list as liste_quelle, t.list as liste_ziel +from public.collection_items s +join public.collection_items t on t.owner_id = s.owner_id and t.game_id = '7a1b2c3d-0000-4000-8000-000000000003' +where s.game_id = '7a1b2c3d-0000-4000-8000-000000000002'; +``` + +**Schritt 3: Zusammenführen.** In einer Transaktion ausführen (`begin;` davor, nach dem Lesen des Ergebnisses `commit;`, bei Zweifel `rollback;`; die Bausteine stehen wie überall ohne `begin`/`commit`). Bei einem Fehler wird nichts geändert: + +```sql +select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); +``` + +**Schritt 4: Nachprüfen.** Der Vorschau-Baustein aus Schritt 2 (mit der Quelle) muss jetzt überall `0` liefern. Zustand beider Einträge: + +```sql +select id, title, hidden_at is not null as ausgeblendet, merged_into_id, bgg_id, catalog_source_id +from public.games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); +``` + +Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert sichtbar. + +**Was die Funktion macht** + +| Was | Regel | +|---|---| +| Sammlung (`collection_items`) | Hat der Nutzer nur die Quelle, wechselt der Eintrag auf das Ziel. Hat er beide (Doppelbesitz, Spec-F1): Der Zieleintrag bleibt; leere Felder (Notiz, Erwerbsdatum, Edition) füllt der Quell-Eintrag; bei unterschiedlicher Liste gilt Sammlung vor Wunschliste vor Einkaufsliste; der Quell-Eintrag wird gelöscht. Eine nicht leere Notiz am Ziel wird nie überschrieben. | +| Favoriten | Hat der Nutzer das Ziel auch als Favorit, entfällt der Favorit der Quelle (die Lücke in der Reihenfolge ist zulässig); sonst wechselt er auf das Ziel. | +| Partien, Wertungsbögen | wechseln auf das Ziel; die Edition einer Partie wird über die Variantenzuordnung übertragen. | +| Varianten (Editionen) | Für jede Variante der Quelle: Hat das Ziel eine gleichnamige Variante (Vergleich ohne Groß-/Kleinschreibung und ohne Leerzeichen am Rand), wird darauf abgebildet; gibt es mehrere gleichnamige, gilt die älteste (bei Gleichstand die kleinste ID). Sonst wird die Variante am Ziel neu angelegt (Name, Jahr, Sprache, BGG-Kennung). Danach werden die Varianten der Quelle gelöscht. | +| BGG-Kennung und Quelle | Hat das Ziel keine, aber die Quelle, übernimmt das Ziel sie (`bgg_id`; ebenso Quelle, `source_ref`, `source_url`, `source_synced_at` als Gruppe). An der Quelle werden sie in jedem Fall geleert, damit der BGG-Abgleich später den ausgeblendeten Eintrag nicht trifft. | +| Ketten | Spiele, die bisher auf die Quelle verwiesen, verweisen jetzt auf das Ziel. | + +**Ergebnis lesen** (Beispiel): + +``` +{"plays": 7, "target_id": "…", "source_id": "…", "score_sheets": 1, "favorite_games": 1, "collection_items": 3, + "variants_copied": 2, "variants_mapped": 1, "variants_cleared": 0, "chain_flattened": 0, + "favorite_conflicts": 0, "bgg_id_transferred": true, "source_transferred": false, + "collection_conflicts": 1} +``` + +| Schlüssel | Bedeutung | +|---|---| +| `collection_items` | Sammlungseinträge, die ohne Kollision auf das Ziel gewechselt sind | +| `collection_conflicts` | Doppelbesitz: in den Zieleintrag eingearbeitet, Quell-Eintrag gelöscht | +| `favorite_games`, `favorite_conflicts` | Favoriten umgehängt bzw. wegen vorhandenem Favoriten am Ziel entfallen | +| `plays`, `score_sheets` | umgehängte Partien und Wertungsbögen | +| `variants_copied`, `variants_mapped` | Varianten, die am Ziel neu angelegt bzw. auf eine gleichnamige abgebildet wurden | +| `variants_cleared` | Sammlungseinträge und Partien, deren Edition geleert wurde. Beim Zusammenführen zweier Katalogspiele bleibt das `0`; es kommt nur beim Umhängen privater Einträge vor (PR 2c) | +| `bgg_id_transferred`, `source_transferred` | `true`, wenn das Ziel die BGG-Kennung bzw. die Quelle der Quelle übernommen hat | +| `bgg_id_conflict` | nur vorhanden, wenn beide eine **unterschiedliche** BGG-Kennung hatten: `{"source": 13, "target": 42}`. Das Ziel behält seinen Wert, der Wert der Quelle ist geleert und steht **nur noch hier**. Bei Bedarf notieren und prüfen, ob das Ziel das richtige Spiel ist | +| `source_conflict` | wie `bgg_id_conflict` für die Quelle (`source` und `target` mit `catalog_source_id`, `source_ref`, `source_url`, `source_synced_at`) | +| `chain_flattened` | Anzahl früher zusammengeführter Spiele, die jetzt direkt auf das Ziel zeigen | + +**Fehlermeldungen** (alle ohne Änderung, `P0001`): + +| Meldung | Bedeutung | +|---|---| +| `admin_only` | Die Sitzung läuft nicht als `postgres` bzw. `supabase_admin` (`select current_user;` prüfen) | +| `merge_same_game` | Quelle und Ziel sind dieselbe ID | +| `merge_game_not_found` | Quelle oder Ziel gibt es nicht (ID vertippt?) | +| `merge_not_catalog` | Quelle oder Ziel ist ein privater Eintrag; Zusammenführen gilt nur für Katalogspiele | +| `merge_target_not_listed` | Das Ziel ist ausgeblendet oder selbst schon zusammengeführt; das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) | +| `merge_source_already_merged` | Die Quelle ist bereits zusammengeführt | + +Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Bei Zeitüberschreitung oder anderem Fehler `rollback;` und erneut prüfen. Löscht man ein Zielspiel, auf das etwas verweist, schlägt das mit `23503` fehl (gewollt: ausblenden statt löschen). ## 6. Meldungen bearbeiten diff --git a/supabase/migrations/20261103193928_catalog_merge.sql b/supabase/migrations/20261103193928_catalog_merge.sql new file mode 100644 index 0000000..bf7e096 --- /dev/null +++ b/supabase/migrations/20261103193928_catalog_merge.sql @@ -0,0 +1,280 @@ +-- KAT-05 / PR 2b: Zusammenführen (Spec docs/specs/2-katalog-daten.md, Abschnitt 3.3). +-- Nur additiv: zwei neue Funktionen, keine neuen Tabellen. +-- repoint_game_references(p_from, p_to) interne Funktion: hängt alle Verweise um +-- admin_merge_games(p_source, p_target) Admin-Funktion: zwei Katalogspiele zusammenführen +-- Beide sind security invoker mit leerem search_path (alles schema-qualifiziert) und nur für den +-- Eigentümer (postgres) ausführbar. Es gibt in R1 keinen API-Aufrufer (E10). +-- admin_resolve_report (privater Eintrag -> Katalogspiel) und der Verweis game_reports.target_game_id +-- kommen mit PR 2c; repoint_game_references ist dafür schon vorbereitet. + +-- --------------------------------------------------------------------------------------------- +-- Interne Funktion: alle Verweise von p_from auf p_to umhängen (in der Transaktion des Aufrufers) +-- --------------------------------------------------------------------------------------------- +create function public.repoint_game_references(p_from uuid, p_to uuid) +returns jsonb +language plpgsql +security invoker +set search_path = '' +as $$ +declare + from_visibility text; + -- Zuordnung "alte Variante -> neue Variante" (Schlüssel: alte ID als Text; JSON null = geleert). + -- Bewusst keine temporäre Tabelle: mehrere Aufrufe in einer Transaktion bleiben möglich. + vmap jsonb := '{}'::jsonb; + existing_variants uuid[]; + v record; + match_id uuid; + new_id uuid; + r record; + n_copied integer := 0; + n_mapped integer := 0; + n_cleared integer := 0; + n_cleared_part integer; + n_ci integer := 0; + n_ci_conflicts integer := 0; + n_fav integer := 0; + n_fav_conflicts integer := 0; + n_plays integer; + n_sheets integer; + new_variant uuid; + new_list text; + list_rank constant jsonb := '{"owned": 1, "wishlist": 2, "shopping": 3}'::jsonb; +begin + if p_from = p_to then + raise exception 'repoint_same_game' using errcode = 'P0001'; + end if; + if not exists ( + select 1 from public.games g + where g.id = p_to and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null + ) then + raise exception 'repoint_target_not_listed' using errcode = 'P0001'; + end if; + + select g.visibility into from_visibility from public.games g where g.id = p_from; + + -- Varianten: Ob kopiert wird, folgt aus der Sichtbarkeit der Quelle (Spec 3.3, RB1). + -- Katalogspiel -> gleichnamige Zielvariante, sonst Kopie am Ziel + -- privater Eintrag -> gleichnamige Zielvariante, sonst keine (null); nichts wird kopiert + -- Namensabgleich: lower(trim(name)). Mehrere gleichnamige Zielvarianten: immer die älteste + -- (created_at), bei Gleichstand die kleinste id. Abgeglichen wird nur gegen die Varianten, die + -- das Ziel vor diesem Aufruf schon hatte (nicht gegen eben kopierte). + existing_variants := array(select gv.id from public.game_variants gv where gv.game_id = p_to); + for v in + select gv.id, gv.name, gv.year, gv.language, gv.bgg_id + from public.game_variants gv + where gv.game_id = p_from + order by gv.created_at, gv.id + loop + select gv.id into match_id + from public.game_variants gv + where gv.id = any(existing_variants) and lower(btrim(gv.name)) = lower(btrim(v.name)) + order by gv.created_at, gv.id + limit 1; + + if match_id is not null then + vmap := vmap || jsonb_build_object(v.id::text, match_id); + n_mapped := n_mapped + 1; + elsif from_visibility = 'catalog' then + new_id := gen_random_uuid(); + insert into public.game_variants (id, game_id, name, year, language, bgg_id) + values (new_id, p_to, v.name, v.year, v.language, v.bgg_id); + vmap := vmap || jsonb_build_object(v.id::text, new_id); + n_copied := n_copied + 1; + else + vmap := vmap || jsonb_build_object(v.id::text, null); + end if; + end loop; + + -- 1. collection_items. Kollision (derselbe Nutzer hat p_to schon, Spec-F1): Der Zieleintrag + -- bleibt, leere Felder füllt der Quell-Eintrag, die Liste gewinnt in der Reihenfolge + -- owned > wishlist > shopping (Klärung 34); die Variante läuft immer über vmap. Danach wird der + -- Quell-Eintrag gelöscht. Felder aus SAM-02/SAM-04 gibt es noch nicht; sie kommen dort dazu. + for r in + select s.id as sid, s.list as slist, s.note as snote, s.acquired_on as sacq, s.variant_id as svar, + t.id as tid, t.list as tlist, t.note as tnote, t.acquired_on as tacq, t.variant_id as tvar + from public.collection_items s + join public.collection_items t on t.owner_id = s.owner_id and t.game_id = p_to + where s.game_id = p_from + order by s.id + loop + new_variant := coalesce(r.tvar, (vmap ->> r.svar::text)::uuid); + if r.tvar is null and r.svar is not null and new_variant is null then + n_cleared := n_cleared + 1; + end if; + new_list := case when (list_rank ->> r.slist)::int < (list_rank ->> r.tlist)::int + then r.slist else r.tlist end; + update public.collection_items c + set list = new_list, + note = case when c.note is null or c.note = '' then r.snote else c.note end, + acquired_on = coalesce(c.acquired_on, r.sacq), + variant_id = new_variant + where c.id = r.tid; + delete from public.collection_items c where c.id = r.sid; + n_ci_conflicts := n_ci_conflicts + 1; + end loop; + + select count(*) into n_cleared_part from public.collection_items c + where c.game_id = p_from and c.variant_id is not null and (vmap ->> c.variant_id::text) is null; + n_cleared := n_cleared + n_cleared_part; + update public.collection_items c + set game_id = p_to, variant_id = (vmap ->> c.variant_id::text)::uuid + where c.game_id = p_from; + get diagnostics n_ci = row_count; + + -- 2. favorite_games: Hat der Nutzer p_to schon als Favorit, entfällt die Zeile zu p_from + -- (die Lücke in position ist zulässig). + delete from public.favorite_games f + where f.game_id = p_from + and exists (select 1 from public.favorite_games t + where t.profile_id = f.profile_id and t.game_id = p_to); + get diagnostics n_fav_conflicts = row_count; + update public.favorite_games f set game_id = p_to where f.game_id = p_from; + get diagnostics n_fav = row_count; + + -- 3. plays (mit Variante über vmap) + select count(*) into n_cleared_part from public.plays p + where p.game_id = p_from and p.variant_id is not null and (vmap ->> p.variant_id::text) is null; + n_cleared := n_cleared + n_cleared_part; + update public.plays p + set game_id = p_to, variant_id = (vmap ->> p.variant_id::text)::uuid + where p.game_id = p_from; + get diagnostics n_plays = row_count; + + -- 4. score_sheets. Sobald PAR-04 (#18) "ein Bogen je Spiel und Nutzer" erzwingt, braucht + -- dieser Schritt eine Kollisionsregel (Spec 9.5). + update public.score_sheets s set game_id = p_to where s.game_id = p_from; + get diagnostics n_sheets = row_count; + + -- 5. Nur Katalog -> Katalog: die alten Varianten sind jetzt unreferenziert (kopiert oder + -- abgebildet) und werden gelöscht. Private Varianten bleiben am ausgeblendeten vorläufigen + -- Eintrag, sie gehören dem Nutzer. + if from_visibility = 'catalog' then + delete from public.game_variants gv where gv.game_id = p_from; + end if; + + -- 6. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. + + return jsonb_build_object( + 'collection_items', n_ci, + 'collection_conflicts', n_ci_conflicts, + 'favorite_games', n_fav, + 'favorite_conflicts', n_fav_conflicts, + 'plays', n_plays, + 'score_sheets', n_sheets, + 'variants_copied', n_copied, + 'variants_mapped', n_mapped, + 'variants_cleared', n_cleared + ); +end; +$$; + +-- --------------------------------------------------------------------------------------------- +-- Admin-Funktion: zwei Katalogspiele zusammenführen +-- security invoker (kein security definer): Die Rollenprüfung ist die einzige Schranke, und der +-- Aufrufer braucht ohnehin die Rechte von postgres/supabase_admin. Kein is_admin()-Pfad (E10). +-- --------------------------------------------------------------------------------------------- +create function public.admin_merge_games(p_source uuid, p_target uuid) +returns jsonb +language plpgsql +security invoker +set search_path = '' +as $$ +declare + s public.games; + t public.games; + result jsonb; + n_chain integer; + bgg_transferred boolean := false; + source_transferred boolean := false; +begin + if current_user not in ('postgres', 'supabase_admin') then + raise exception 'admin_only' using errcode = 'P0001'; + end if; + if p_source = p_target then + raise exception 'merge_same_game' using errcode = 'P0001'; + end if; + if p_source is null or p_target is null then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + + -- Beide Zeilen in fester Reihenfolge sperren, damit zwei gleichzeitige Aufrufe nicht verklemmen. + perform 1 from public.games g where g.id in (p_source, p_target) order by g.id for update; + select * into s from public.games g where g.id = p_source; + if not found then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + select * into t from public.games g where g.id = p_target; + if not found then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + if s.visibility <> 'catalog' or t.visibility <> 'catalog' then + raise exception 'merge_not_catalog' using errcode = 'P0001'; + end if; + if t.hidden_at is not null or t.merged_into_id is not null then + raise exception 'merge_target_not_listed' using errcode = 'P0001'; + end if; + if s.merged_into_id is not null then + raise exception 'merge_source_already_merged' using errcode = 'P0001'; + end if; + + -- 1. Verweise umhängen (beide Katalog -> Varianten werden kopiert) + result := public.repoint_game_references(p_source, p_target); + + -- 2. BGG-Kennung und Quelle übernehmen (W6). Wegen der eindeutigen Indizes (bgg_id im Katalog, + -- (Quelle, source_ref) im Katalog) zuerst an der Quelle leeren, dann am Ziel setzen. + -- Gleichzeitig wird die Quelle als zusammengeführt und ausgeblendet markiert (ein Statement: + -- merged_into_id verlangt hidden_at). Bereits ausgeblendete Quellen behalten ihr hidden_at. + update public.games g + set bgg_id = null, + catalog_source_id = null, source_ref = null, source_url = null, source_synced_at = null, + merged_into_id = p_target, + hidden_at = coalesce(g.hidden_at, now()) + where g.id = p_source; + + if s.bgg_id is not null then + if t.bgg_id is null then + update public.games g set bgg_id = s.bgg_id where g.id = p_target; + bgg_transferred := true; + elsif t.bgg_id <> s.bgg_id then + -- Das Ziel behält seinen Wert; der geleerte Wert der Quelle bleibt hier erhalten. + result := result || jsonb_build_object('bgg_id_conflict', + jsonb_build_object('source', s.bgg_id, 'target', t.bgg_id)); + end if; + end if; + + if s.catalog_source_id is not null then + if t.catalog_source_id is null then + update public.games g + set catalog_source_id = s.catalog_source_id, source_ref = s.source_ref, + source_url = s.source_url, source_synced_at = s.source_synced_at + where g.id = p_target; + source_transferred := true; + elsif (t.catalog_source_id, t.source_ref) is distinct from (s.catalog_source_id, s.source_ref) then + result := result || jsonb_build_object('source_conflict', jsonb_build_object( + 'source', jsonb_build_object('catalog_source_id', s.catalog_source_id, 'source_ref', s.source_ref, + 'source_url', s.source_url, 'source_synced_at', s.source_synced_at), + 'target', jsonb_build_object('catalog_source_id', t.catalog_source_id, 'source_ref', t.source_ref, + 'source_url', t.source_url, 'source_synced_at', t.source_synced_at))); + end if; + end if; + + -- 3. Ketten glätten: Spiele, die bisher auf die Quelle zeigten, zeigen jetzt auf das Ziel. + update public.games g set merged_into_id = p_target where g.merged_into_id = p_source; + get diagnostics n_chain = row_count; + + -- 4. Ab 2c zusätzlich: game_reports.target_game_id von p_source auf p_target. + + return result || jsonb_build_object( + 'source_id', p_source, + 'target_id', p_target, + 'bgg_id_transferred', bgg_transferred, + 'source_transferred', source_transferred, + 'chain_flattened', n_chain + ); +end; +$$; + +-- Rechte-Regel: nur der Eigentümer darf ausführen (Supabase vergibt sonst an anon, authenticated, +-- service_role). Der Import und spätere Stories ergänzen gezielt Rechte, falls nötig. +revoke execute on function public.repoint_game_references(uuid, uuid) from public, anon, authenticated, service_role; +revoke execute on function public.admin_merge_games(uuid, uuid) from public, anon, authenticated, service_role; diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql new file mode 100644 index 0000000..e157d3e --- /dev/null +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -0,0 +1,484 @@ +-- KAT-05 (PR 2b): Zusammenführen. repoint_game_references, admin_merge_games, Rechte, +-- Fremdschlüssel-Wächter und die SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 5 +-- (unverändert, ohne begin/commit; Auswahl immer per id). +-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix f7/7a1b), gezählt wird nur darüber. +-- Hinweis für 2c: Der Fremdschlüssel-Wächter am Ende muss um game_reports.target_game_id (behandelt) und +-- game_reports.provisional_game_id (bewusst ausgelassen) erweitert werden, sonst wird er rot. +begin; +select plan(89); + +insert into auth.users (id, email, raw_user_meta_data) values + ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), + ('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'), + ('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Cara"}'), + ('44444444-4444-4444-4444-444444444444', 'dora@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Dora"}'); + +-- --------------------------------------------------------------------------------------------- +-- Rechte (KAT-05 Kriterium 9) +-- --------------------------------------------------------------------------------------------- +select is_empty($$select p.proname, r from pg_proc p join pg_namespace n on n.oid = p.pronamespace, + unnest(array['anon', 'authenticated', 'service_role']) as r + where n.nspname = 'public' and p.proname in ('repoint_game_references', 'admin_merge_games') + and has_function_privilege(r, p.oid, 'execute')$$, + 'KAT-05 Kriterium 9: repoint_game_references und admin_merge_games sind für anon, authenticated und service_role nicht ausführbar'); +select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname in ('repoint_game_references', 'admin_merge_games') + and not p.prosecdef and p.proconfig = array['search_path=""']), 2::bigint, + 'Beide Funktionen sind security invoker mit leerem search_path'); + +-- Aufruf wirklich versuchen, auch mit Admin-Claims im JWT (E10: kein is_admin()-Pfad) +select set_config('request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"role":"admin"}}', true); +set local role authenticated; +select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, + 'KAT-05 Kriterium 9: authenticated (auch mit app_metadata.role = admin) kann admin_merge_games nicht aufrufen'); +select throws_ok($$select public.repoint_game_references(gen_random_uuid(), gen_random_uuid())$$, '42501', null, + 'KAT-05 Kriterium 9: authenticated kann repoint_game_references nicht aufrufen'); +reset role; +set local role anon; +select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, + 'KAT-05 Kriterium 9: anon kann admin_merge_games nicht aufrufen'); +reset role; +set local role service_role; +select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, + 'KAT-05 Kriterium 9: service_role kann admin_merge_games nicht aufrufen'); +select throws_ok($$select public.repoint_game_references(gen_random_uuid(), gen_random_uuid())$$, '42501', null, + 'KAT-05 Kriterium 9: service_role kann repoint_game_references nicht aufrufen'); +reset role; + +-- Die Rollenprüfung im Funktionskörper: eine Rolle MIT execute-Recht, aber ohne postgres/supabase_admin +create role merge_tester nologin; +grant merge_tester to postgres with set true; +grant usage on schema public to merge_tester; +grant execute on function public.admin_merge_games(uuid, uuid) to merge_tester; +set local role merge_tester; +do $$ +begin + perform public.admin_merge_games('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000002'); + perform set_config('t.merge_err', 'kein Fehler', true); +exception when others then + perform set_config('t.merge_err', sqlstate || ':' || sqlerrm, true); +end +$$; +reset role; +select is(current_setting('t.merge_err'), 'P0001:admin_only', + 'KAT-05 Kriterium 9: admin_merge_games prüft die Datenbankrolle (admin_only)'); + +-- --------------------------------------------------------------------------------------------- +-- Fehlerfälle von admin_merge_games +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility) values + ('f7000000-0000-0000-0000-000000000001', 'Fehler Eins', 'catalog'), + ('f7000000-0000-0000-0000-000000000002', 'Fehler Zwei', 'catalog'); +insert into public.games (id, title, visibility, hidden_at) values + ('f7000000-0000-0000-0000-000000000003', 'Fehler Versteckt', 'catalog', '2020-01-01'); +insert into public.games (id, title, visibility, hidden_at, merged_into_id) values + ('f7000000-0000-0000-0000-000000000004', 'Fehler Zusammengeführt', 'catalog', '2020-01-01', 'f7000000-0000-0000-0000-000000000001'); +insert into public.games (id, title, visibility, owner_id) values + ('f7000000-0000-0000-0000-0000000000a1', 'Fehler Privat', 'private', '11111111-1111-1111-1111-111111111111'); + +select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000001')$$, + 'P0001', 'merge_same_game', 'KAT-05 Kriterium 4: Quelle gleich Ziel wird abgelehnt (merge_same_game)'); +select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000000ff', 'f7000000-0000-0000-0000-000000000001')$$, + 'P0001', 'merge_game_not_found', 'KAT-05 Kriterium 4: unbekannte Quelle (merge_game_not_found)'); +select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-0000000000ff')$$, + 'P0001', 'merge_game_not_found', 'KAT-05 Kriterium 4: unbekanntes Ziel (merge_game_not_found)'); +select throws_ok($$select public.admin_merge_games(null, 'f7000000-0000-0000-0000-000000000001')$$, + 'P0001', 'merge_game_not_found', 'KAT-05 Kriterium 4: fehlende Quelle (null) ist merge_game_not_found'); +select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000000a1', 'f7000000-0000-0000-0000-000000000001')$$, + 'P0001', 'merge_not_catalog', 'KAT-05 Kriterium 4: private Quelle wird abgelehnt (merge_not_catalog)'); +select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-0000000000a1')$$, + 'P0001', 'merge_not_catalog', 'KAT-05 Kriterium 4: privates Ziel wird abgelehnt (merge_not_catalog)'); +select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000003')$$, + 'P0001', 'merge_target_not_listed', 'KAT-05 Kriterium 4: ausgeblendetes Ziel wird abgelehnt (merge_target_not_listed)'); +select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000002', 'f7000000-0000-0000-0000-000000000004')$$, + 'P0001', 'merge_target_not_listed', 'KAT-05 Kriterium 4: zusammengeführtes Ziel wird abgelehnt (merge_target_not_listed)'); +select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000004', 'f7000000-0000-0000-0000-000000000002')$$, + 'P0001', 'merge_source_already_merged', 'KAT-05 Kriterium 4: bereits zusammengeführte Quelle wird abgelehnt (merge_source_already_merged)'); +select is((select count(*) from public.games where id in ('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000002') + and hidden_at is null and merged_into_id is null), 2::bigint, + 'Nach Fehlern ist nichts verändert'); + +-- repoint_game_references: eigene Prüfungen (admin_merge_games kommt ihnen sonst zuvor) +select throws_ok($$select public.repoint_game_references('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000001')$$, + 'P0001', 'repoint_same_game', 'KAT-05 Kriterium 4: repoint mit p_from = p_to (repoint_same_game)'); +select throws_ok($$select public.repoint_game_references('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000003')$$, + 'P0001', 'repoint_target_not_listed', 'KAT-05 Kriterium 4: repoint auf ein ausgeblendetes Ziel'); +select throws_ok($$select public.repoint_game_references('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000004')$$, + 'P0001', 'repoint_target_not_listed', 'KAT-05 Kriterium 4: repoint auf ein zusammengeführtes Ziel'); +select throws_ok($$select public.repoint_game_references('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-0000000000a1')$$, + 'P0001', 'repoint_target_not_listed', 'KAT-05 Kriterium 4: repoint auf ein privates Ziel'); +select throws_ok($$select public.repoint_game_references('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-0000000000ff')$$, + 'P0001', 'repoint_target_not_listed', 'KAT-05 Kriterium 4: repoint auf ein unbekanntes Ziel'); + +-- --------------------------------------------------------------------------------------------- +-- Hauptfall nach Runbook Abschnitt 5: zwei Katalogspiele (Quelle ...0002, Ziel ...0003) +-- --------------------------------------------------------------------------------------------- +insert into public.catalog_sources (key, name) values ('test_src', 'Testquelle'); + +-- Quelle ist älter als das Ziel; Titel der Quelle mit Kleinschreibung und Leerzeichen am Rand +insert into public.games (id, title, year, visibility, bgg_id, catalog_source_id, source_ref, source_url, source_synced_at, created_at) values + ('7a1b2c3d-0000-4000-8000-000000000002', 'duplikat runbook ', 2021, 'catalog', 13, + (select id from public.catalog_sources where key = 'test_src'), 'ref-13', 'https://example.org/13', '2026-01-01', '2020-01-01'); +insert into public.games (id, title, year, visibility, created_at) values + ('7a1b2c3d-0000-4000-8000-000000000003', 'Duplikat Runbook', 2021, 'catalog', '2021-01-01'); +insert into public.genres (id, name_de, name_en) values ('f7000000-0000-0000-0000-0000000000e1', 'Merge-Genre', 'Merge genre'); +insert into public.game_genres (game_id, genre_id) values ('7a1b2c3d-0000-4000-8000-000000000002', 'f7000000-0000-0000-0000-0000000000e1'); + +-- Varianten. Ziel: zwei gleichnamige ("deluxe"/"DELUXE"); die ÄLTERE hat die größere id (...0f9 > ...0f0). +insert into public.game_variants (id, game_id, name, year, language, bgg_id, created_at) values + ('f7000000-0000-0000-0000-0000000000f1', '7a1b2c3d-0000-4000-8000-000000000002', ' Deluxe ', 2021, 'de', null, '2020-01-01'), + ('f7000000-0000-0000-0000-0000000000f2', '7a1b2c3d-0000-4000-8000-000000000002', 'Collector', 2022, 'en', 777, '2020-01-02'), + ('f7000000-0000-0000-0000-0000000000f9', '7a1b2c3d-0000-4000-8000-000000000003', 'deluxe', null, null, null, '2020-01-01'), + ('f7000000-0000-0000-0000-0000000000f0', '7a1b2c3d-0000-4000-8000-000000000003', 'DELUXE', null, null, null, '2021-01-01'); + +-- Sammlung: Anna (Wunschliste an Quelle, Sammlung am Ziel), Ben (nur Quelle), Cara (Einkauf/Wunsch, Notizen), Dora (Quelle besser) +insert into public.collection_items (id, owner_id, game_id, variant_id, list, note, acquired_on) values + ('f7000000-0000-0000-0000-0000000000c1', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 'f7000000-0000-0000-0000-0000000000f1', 'wishlist', 'Annas Notiz', '2020-02-02'), + ('f7000000-0000-0000-0000-0000000000c2', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000003', null, 'owned', null, null), + ('f7000000-0000-0000-0000-0000000000c3', '22222222-2222-2222-2222-222222222222', '7a1b2c3d-0000-4000-8000-000000000002', 'f7000000-0000-0000-0000-0000000000f2', 'owned', 'Bens Notiz', null), + ('f7000000-0000-0000-0000-0000000000c4', '33333333-3333-3333-3333-333333333333', '7a1b2c3d-0000-4000-8000-000000000002', null, 'shopping', 'Caras Quellnotiz', '2020-03-03'), + ('f7000000-0000-0000-0000-0000000000c5', '33333333-3333-3333-3333-333333333333', '7a1b2c3d-0000-4000-8000-000000000003', null, 'wishlist', 'Caras Zielnotiz', '2019-03-03'), + ('f7000000-0000-0000-0000-0000000000c6', '44444444-4444-4444-4444-444444444444', '7a1b2c3d-0000-4000-8000-000000000002', null, 'owned', 'Doras Quellnotiz', null), + ('f7000000-0000-0000-0000-0000000000c7', '44444444-4444-4444-4444-444444444444', '7a1b2c3d-0000-4000-8000-000000000003', null, 'shopping', '', null); + +-- Favoriten: Anna hat beide (Quelle Platz 1, Ziel Platz 2), Ben nur die Quelle (Platz 3) +insert into public.favorite_games (id, profile_id, game_id, position) values + ('f7000000-0000-0000-0000-0000000000d1', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 1), + ('f7000000-0000-0000-0000-0000000000d2', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000003', 2), + ('f7000000-0000-0000-0000-0000000000d3', '22222222-2222-2222-2222-222222222222', '7a1b2c3d-0000-4000-8000-000000000002', 3); + +-- Partien (mit zwei Editionen und ohne) und ein Wertungsbogen +insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values + ('f7000000-0000-0000-0000-0000000000b1', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 'f7000000-0000-0000-0000-0000000000f1', '2026-01-01', 'f7000000-0000-0000-0000-0000000000b1'), + ('f7000000-0000-0000-0000-0000000000b2', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 'f7000000-0000-0000-0000-0000000000f2', '2026-01-02', 'f7000000-0000-0000-0000-0000000000b2'), + ('f7000000-0000-0000-0000-0000000000b3', '22222222-2222-2222-2222-222222222222', '7a1b2c3d-0000-4000-8000-000000000002', null, '2026-01-03', 'f7000000-0000-0000-0000-0000000000b3'); +insert into public.score_sheets (id, owner_id, game_id, name) values + ('f7000000-0000-0000-0000-0000000000b5', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 'Bogen'); + +-- Runbook 5, Schritt 1: Duplikate finden (global; geprüft wird nur die Fixture-Zeile) +create temp table rb_dup as +select lower(btrim(title)) as titel_normalisiert, year, + array_agg(id order by created_at, id) as ids, count(*) as anzahl +from public.catalog_games +group by lower(btrim(title)), year +having count(*) > 1 +order by 1, 2; +select results_eq($$select titel_normalisiert, year, ids, anzahl from rb_dup where ids && array['7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003']::uuid[]$$, + $$values ('duplikat runbook'::text, 2021::smallint, array['7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003']::uuid[], 2::bigint)$$, + 'KAT-05 Kriterium 8: Runbook 5, Duplikate finden (Titel normalisiert, älteste ID zuerst)'); + +-- Runbook 5, Schritt 1: Kandidaten per Titel, IDs ablesen +create temp table rb_lookup as +select id, title, year, hidden_at, merged_into_id, bgg_id, created_at from public.games +where visibility = 'catalog' and lower(btrim(title)) = lower(btrim('Duplikat Runbook')) +order by created_at, id; +select results_eq($$select id from rb_lookup$$, + $$values ('7a1b2c3d-0000-4000-8000-000000000002'::uuid), ('7a1b2c3d-0000-4000-8000-000000000003'::uuid)$$, + 'KAT-05 Kriterium 8: Runbook 5, Kandidaten per Titel (IDs, älteste zuerst)'); + +-- Runbook 5, Schritt 2: Vorschau Verweise an der Quelle +create temp table rb_preview as +select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'favorite_games', count(*) from public.favorite_games where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'plays', count(*) from public.plays where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'score_sheets', count(*) from public.score_sheets where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'game_variants', count(*) from public.game_variants where game_id = '7a1b2c3d-0000-4000-8000-000000000002'; +select set_eq($$select tabelle, anzahl from rb_preview$$, + $$values ('collection_items'::text, 4::bigint), ('favorite_games', 2), ('plays', 3), ('score_sheets', 1), ('game_variants', 2)$$, + 'KAT-05 Kriterium 8: Runbook 5, Vorschau der Verweise an der Quelle'); + +-- Runbook 5, Schritt 2: Vorschau Doppelbesitz +create temp table rb_dbl as +select s.owner_id, s.list as liste_quelle, t.list as liste_ziel +from public.collection_items s +join public.collection_items t on t.owner_id = s.owner_id and t.game_id = '7a1b2c3d-0000-4000-8000-000000000003' +where s.game_id = '7a1b2c3d-0000-4000-8000-000000000002'; +select set_eq($$select owner_id, liste_quelle, liste_ziel from rb_dbl$$, + $$values ('11111111-1111-1111-1111-111111111111'::uuid, 'wishlist'::text, 'owned'::text), + ('33333333-3333-3333-3333-333333333333', 'shopping', 'wishlist'), + ('44444444-4444-4444-4444-444444444444', 'owned', 'shopping')$$, + 'KAT-05 Kriterium 8: Runbook 5, Vorschau Doppelbesitz'); + +-- Runbook 5, Schritt 3: Zusammenführen +create temp table rb_merge as +select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003') as ergebnis; + +select is((select ergebnis from rb_merge), + jsonb_build_object( + 'source_id', '7a1b2c3d-0000-4000-8000-000000000002', 'target_id', '7a1b2c3d-0000-4000-8000-000000000003', + 'collection_items', 1, 'collection_conflicts', 3, 'favorite_games', 1, 'favorite_conflicts', 1, + 'plays', 3, 'score_sheets', 1, 'variants_copied', 1, 'variants_mapped', 1, 'variants_cleared', 0, + 'bgg_id_transferred', true, 'source_transferred', true, 'chain_flattened', 0), + 'KAT-05 Kriterium 8: Runbook 5, das Ergebnis-JSON zählt alles richtig'); + +-- Runbook 5, Schritt 4: Nachprüfen (der Vorschau-Baustein aus Schritt 2 liefert überall 0) +drop table rb_preview; +create temp table rb_preview as +select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'favorite_games', count(*) from public.favorite_games where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'plays', count(*) from public.plays where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'score_sheets', count(*) from public.score_sheets where game_id = '7a1b2c3d-0000-4000-8000-000000000002' +union all select 'game_variants', count(*) from public.game_variants where game_id = '7a1b2c3d-0000-4000-8000-000000000002'; +select set_eq($$select tabelle, anzahl from rb_preview$$, + $$values ('collection_items'::text, 0::bigint), ('favorite_games', 0), ('plays', 0), ('score_sheets', 0), ('game_variants', 0)$$, + 'KAT-05 Kriterium 4: nach dem Zusammenführen hängt nichts mehr an der Quelle'); + +-- Runbook 5, Schritt 4: Zustand beider Einträge +create temp table rb_state as +select id, title, hidden_at is not null as ausgeblendet, merged_into_id, bgg_id, catalog_source_id +from public.games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); +select set_eq($$select id, ausgeblendet, merged_into_id, bgg_id, catalog_source_id is not null from rb_state$$, + $$values ('7a1b2c3d-0000-4000-8000-000000000002'::uuid, true, '7a1b2c3d-0000-4000-8000-000000000003'::uuid, null::integer, false), + ('7a1b2c3d-0000-4000-8000-000000000003', false, null, 13, true)$$, + 'KAT-05 Kriterium 4: Quelle ausgeblendet und verwiesen, Ziel sichtbar und mit BGG-Kennung und Quelle der Quelle'); + +-- Weitere Prüfungen am Ergebnis des Hauptfalls +select is((select count(*) from public.catalog_games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003')), 1::bigint, + 'KAT-05 Kriterium 4: catalog_games enthält nur noch das Ziel'); +select is((select count(*) from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002'), 1::bigint, + 'Die Quelle wird nicht gelöscht, ihre ID bleibt gültig'); +select is((select row(catalog_source_id, source_ref, source_url, source_synced_at)::text from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002'), + '(,,,)', 'Die Quellen-Angaben sind an der Quelle geleert'); +select is((select row(catalog_source_id = (select id from public.catalog_sources where key = 'test_src'), source_ref, source_url, source_synced_at)::text + from public.games where id = '7a1b2c3d-0000-4000-8000-000000000003'), + '(t,ref-13,https://example.org/13,"2026-01-01 00:00:00+00")', 'Das Ziel hat Quelle, Kennung, URL und Abgleichszeit übernommen'); +select is((select title from public.games where id = '7a1b2c3d-0000-4000-8000-000000000003'), 'Duplikat Runbook', + 'Das Ziel behält seine Fakten'); +select is((select count(*) from public.game_genres where game_id = '7a1b2c3d-0000-4000-8000-000000000002'), 1::bigint, + 'Zuordnungen bleiben am ausgeblendeten Quell-Eintrag'); +select is((select count(*) from public.game_genres where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 0::bigint, + 'Zuordnungen werden nicht auf das Ziel kopiert'); + +-- Sammlung (Spec-F1): Kollisionsregel +select is((select row(list, note, acquired_on, variant_id)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c2'), + '(owned,"Annas Notiz",2020-02-02,f7000000-0000-0000-0000-0000000000f9)', + 'Spec-F1: Anna: Ziel gewinnt die Liste (owned vor wishlist), leere Felder aus der Quelle, Edition über die Variantenzuordnung (älteste gleichnamige Zielvariante)'); +select is((select count(*) from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c1'), 0::bigint, + 'Spec-F1: der Quell-Eintrag der Kollision ist gelöscht'); +select isnt((select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c3'), 'f7000000-0000-0000-0000-0000000000f2'::uuid, + 'Ben: die Edition zeigt nicht mehr auf die alte Variante'); +select is((select row(c.game_id, c.list, c.note)::text || ':' || v.name || ':' || v.game_id::text || ':' || v.year::text || ':' || v.language || ':' || v.bgg_id::text + from public.collection_items c join public.game_variants v on v.id = c.variant_id where c.id = 'f7000000-0000-0000-0000-0000000000c3'), + '(7a1b2c3d-0000-4000-8000-000000000003,owned,"Bens Notiz"):Collector:7a1b2c3d-0000-4000-8000-000000000003:2022:en:777', + 'Ben: ohne Kollision wechselt der Eintrag auf das Ziel, die Edition ist eine Kopie am Ziel (Name, Jahr, Sprache, BGG-Kennung)'); +select is((select row(list, note, acquired_on)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c5'), + '(wishlist,"Caras Zielnotiz",2019-03-03)', + 'Spec-F1: Cara: wishlist vor shopping; eine vorhandene Notiz und ein vorhandenes Datum am Ziel werden nicht überschrieben'); +select is((select row(list, note, acquired_on)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c7'), + '(owned,"Doras Quellnotiz",)', + 'Spec-F1: Dora: owned (Quelle) vor shopping (Ziel); eine leere Notiz am Ziel wird aus der Quelle gefüllt'); +select is((select count(*) from public.collection_items where owner_id = '44444444-4444-4444-4444-444444444444'), 1::bigint, + 'Spec-F1: Doras Quell-Eintrag ist gelöscht'); +select is((select count(*) from public.collection_items where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 4::bigint, + 'Jeder der vier Nutzer hat genau einen Eintrag am Ziel'); + +-- Favoriten +select is((select array_agg(position order by position) from public.favorite_games + where profile_id = '11111111-1111-1111-1111-111111111111'), array[2::smallint], + 'Favoriten: Anna hatte beide; nur der Favorit am Ziel (Platz 2) bleibt'); +select is((select row(game_id, position)::text from public.favorite_games where id = 'f7000000-0000-0000-0000-0000000000d3'), + '(7a1b2c3d-0000-4000-8000-000000000003,3)', 'Favoriten: Ben wechselt auf das Ziel und behält seinen Platz'); + +-- Partien +select is((select row(game_id, variant_id)::text from public.plays where id = 'f7000000-0000-0000-0000-0000000000b1'), + '(7a1b2c3d-0000-4000-8000-000000000003,f7000000-0000-0000-0000-0000000000f9)', + 'Partien: Edition wird auf die älteste gleichnamige Zielvariante abgebildet'); +select is((select p.game_id::text || ':' || v.game_id::text || ':' || v.name + from public.plays p join public.game_variants v on v.id = p.variant_id where p.id = 'f7000000-0000-0000-0000-0000000000b2'), + '7a1b2c3d-0000-4000-8000-000000000003:7a1b2c3d-0000-4000-8000-000000000003:Collector', + 'Partien: die Edition "Collector" zeigt auf die Kopie am Ziel'); +select is((select row(game_id, variant_id)::text from public.plays where id = 'f7000000-0000-0000-0000-0000000000b3'), + '(7a1b2c3d-0000-4000-8000-000000000003,)', 'Partien: ohne Edition bleibt es ohne Edition'); +select is((select game_id from public.score_sheets where id = 'f7000000-0000-0000-0000-0000000000b5'), '7a1b2c3d-0000-4000-8000-000000000003'::uuid, + 'Wertungsbogen am Ziel'); + +-- Varianten +select is((select count(*) from public.game_variants where game_id = '7a1b2c3d-0000-4000-8000-000000000002'), 0::bigint, + 'Die Varianten der Quelle sind gelöscht'); +select is((select count(*) from public.game_variants where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 3::bigint, + 'Das Ziel hat 2 eigene + 1 kopierte Variante (die gleichnamige wurde abgebildet, nicht kopiert)'); +select is((select count(*) from public.game_variants where id in ('f7000000-0000-0000-0000-0000000000f9', 'f7000000-0000-0000-0000-0000000000f0')), 2::bigint, + 'Eigene Zielvarianten bleiben erhalten'); +select is((select count(distinct variant_id) from ( + select variant_id from public.plays where id = 'f7000000-0000-0000-0000-0000000000b2' + union all select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c3') x), 1::bigint, + 'Dieselbe Quellvariante wird nur einmal kopiert (Partie und Sammlung zeigen auf dieselbe Kopie)'); + +-- Löschen eines Ziels mit Verweis (nur der Verweis merged_into_id, ohne andere Verweise) +insert into public.games (id, title, visibility) values + ('f7000000-0000-0000-0000-0000000000b8', 'Löschziel', 'catalog'), ('f7000000-0000-0000-0000-0000000000b9', 'Löschquelle', 'catalog'); +select public.admin_merge_games('f7000000-0000-0000-0000-0000000000b9', 'f7000000-0000-0000-0000-0000000000b8'); +select throws_ok($$delete from public.games where id = 'f7000000-0000-0000-0000-0000000000b8'$$, '23503', + null, 'KAT-05 Kriterium 4: Ein Ziel mit Verweis lässt sich nicht löschen (on delete restrict)'); + +-- --------------------------------------------------------------------------------------------- +-- Konflikte bei BGG-Kennung und Quelle (W6), Ketten, bereits ausgeblendete Quelle +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility, bgg_id, catalog_source_id, source_ref, source_url, source_synced_at) values + ('f7000000-0000-0000-0000-000000000101', 'Konflikt Quelle', 'catalog', 14, + (select id from public.catalog_sources where key = 'test_src'), 'quelle-ref', 'https://example.org/q', '2026-02-02'), + ('f7000000-0000-0000-0000-000000000102', 'Konflikt Ziel', 'catalog', 42, + (select id from public.catalog_sources where key = 'test_src'), 'ziel-ref', 'https://example.org/z', '2026-03-03'); +create temp table rb_conflict as select public.admin_merge_games('f7000000-0000-0000-0000-000000000101', 'f7000000-0000-0000-0000-000000000102') as r; +select is((select r -> 'bgg_id_conflict' from rb_conflict), '{"source": 14, "target": 42}'::jsonb, + 'KAT-05 Kriterium 4: bgg_id-Konflikt wird mit den alten Werten gemeldet'); +select is((select r -> 'source_conflict' from rb_conflict), jsonb_build_object( + 'source', jsonb_build_object('catalog_source_id', (select id from public.catalog_sources where key = 'test_src'), 'source_ref', 'quelle-ref', 'source_url', 'https://example.org/q', 'source_synced_at', '2026-02-02T00:00:00+00:00'), + 'target', jsonb_build_object('catalog_source_id', (select id from public.catalog_sources where key = 'test_src'), 'source_ref', 'ziel-ref', 'source_url', 'https://example.org/z', 'source_synced_at', '2026-03-03T00:00:00+00:00')), + 'KAT-05 Kriterium 4: Quellen-Konflikt wird mit den alten Werten gemeldet'); +select is((select r -> 'bgg_id_transferred' from rb_conflict), 'false'::jsonb, 'Bei Konflikt wird nichts übertragen (bgg_id)'); +select is((select r -> 'source_transferred' from rb_conflict), 'false'::jsonb, 'Bei Konflikt wird nichts übertragen (Quelle)'); +select is((select row(bgg_id, source_ref, source_url)::text from public.games where id = 'f7000000-0000-0000-0000-000000000102'), + '(42,ziel-ref,https://example.org/z)', 'Das Ziel behält bei Konflikt seine Werte'); +select is((select row(bgg_id, catalog_source_id, source_ref, source_url, source_synced_at)::text from public.games where id = 'f7000000-0000-0000-0000-000000000101'), + '(,,,,)', 'Die Quelle ist bei Konflikt trotzdem geleert'); + +-- Ohne Konflikt gibt es keine Konfliktschlüssel; nur BGG-Kennung auf der Quelle +insert into public.games (id, title, visibility, bgg_id) values + ('f7000000-0000-0000-0000-000000000111', 'Ohne Konflikt Quelle', 'catalog', 99), + ('f7000000-0000-0000-0000-000000000112', 'Ohne Konflikt Ziel', 'catalog', null); +create temp table rb_noconf as select public.admin_merge_games('f7000000-0000-0000-0000-000000000111', 'f7000000-0000-0000-0000-000000000112') as r; +select is((select (r ? 'bgg_id_conflict') or (r ? 'source_conflict') from rb_noconf), false, 'Ohne Konflikt gibt es keine Konfliktschlüssel'); +select is((select bgg_id from public.games where id = 'f7000000-0000-0000-0000-000000000112'), 99, 'BGG-Kennung geht auf das Ziel über, ohne den Unique-Index zu verletzen'); +select is((select r -> 'bgg_id_transferred' from rb_noconf), 'true'::jsonb, 'bgg_id_transferred ist true'); +select is((select r -> 'source_transferred' from rb_noconf), 'false'::jsonb, 'source_transferred ist false (die Quelle hatte keine)'); + +-- Ziel hat BGG-Kennung, Quelle nicht: Ziel bleibt, kein Konflikt +insert into public.games (id, title, visibility, bgg_id) values + ('f7000000-0000-0000-0000-000000000121', 'Ziel hat BGG Quelle', 'catalog', null), + ('f7000000-0000-0000-0000-000000000122', 'Ziel hat BGG Ziel', 'catalog', 55); +create temp table rb_tgt as select public.admin_merge_games('f7000000-0000-0000-0000-000000000121', 'f7000000-0000-0000-0000-000000000122') as r; +select is((select bgg_id from public.games where id = 'f7000000-0000-0000-0000-000000000122'), 55, 'Ziel behält seine BGG-Kennung'); +select is((select r ? 'bgg_id_conflict' from rb_tgt), false, 'Kein Konflikt, wenn nur das Ziel eine BGG-Kennung hat'); + +-- Bereits ausgeblendete Quelle behält ihren Zeitpunkt (coalesce) +insert into public.games (id, title, visibility, hidden_at) values + ('f7000000-0000-0000-0000-000000000131', 'Versteckte Quelle', 'catalog', '2020-05-05 00:00:00+00'), + ('f7000000-0000-0000-0000-000000000132', 'Ziel zu Versteckt', 'catalog', null); +select public.admin_merge_games('f7000000-0000-0000-0000-000000000131', 'f7000000-0000-0000-0000-000000000132'); +select is((select hidden_at from public.games where id = 'f7000000-0000-0000-0000-000000000131'), '2020-05-05 00:00:00+00'::timestamptz, + 'Eine bereits ausgeblendete Quelle behält ihr hidden_at'); +select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000131'), 'f7000000-0000-0000-0000-000000000132'::uuid, + 'Die ausgeblendete Quelle ist zusammengeführt'); +select is((select hidden_at is not null from public.games where id = 'f7000000-0000-0000-0000-000000000101'), true, + 'Eine sichtbare Quelle wird ausgeblendet'); + +-- Ketten glätten: C -> A, dann A -> B ergibt C -> B +insert into public.games (id, title, visibility) values + ('f7000000-0000-0000-0000-000000000141', 'Kette A', 'catalog'), + ('f7000000-0000-0000-0000-000000000142', 'Kette B', 'catalog'), + ('f7000000-0000-0000-0000-000000000143', 'Kette C', 'catalog'); +select public.admin_merge_games('f7000000-0000-0000-0000-000000000143', 'f7000000-0000-0000-0000-000000000141'); +select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000143'), 'f7000000-0000-0000-0000-000000000141'::uuid, 'Kette: C zeigt zuerst auf A'); +create temp table rb_chain as select public.admin_merge_games('f7000000-0000-0000-0000-000000000141', 'f7000000-0000-0000-0000-000000000142') as r; +select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000143'), 'f7000000-0000-0000-0000-000000000142'::uuid, + 'KAT-05 Kriterium 4: Ketten werden geglättet (C zeigt danach direkt auf B)'); +select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000141'), 'f7000000-0000-0000-0000-000000000142'::uuid, 'A zeigt auf B'); +select is((select r -> 'chain_flattened' from rb_chain), '1'::jsonb, 'chain_flattened zählt die geglättete Kette'); + +-- --------------------------------------------------------------------------------------------- +-- Namensabgleich: Gleichstand bei created_at -> kleinste id +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility) values + ('f7000000-0000-0000-0000-000000000151', 'Gleichstand Quelle', 'catalog'), + ('f7000000-0000-0000-0000-000000000152', 'Gleichstand Ziel', 'catalog'); +-- zuerst die größeren ids einfügen, die kleinste zuletzt (die Zeilenreihenfolge darf nicht entscheiden) +insert into public.game_variants (id, game_id, name, created_at) values + ('f7000000-0000-0000-0000-0000000001a2', 'f7000000-0000-0000-0000-000000000152', 'Edition', '2022-01-01'), + ('f7000000-0000-0000-0000-0000000001a3', 'f7000000-0000-0000-0000-000000000152', 'EDITION', '2022-01-01'), + ('f7000000-0000-0000-0000-0000000001a1', 'f7000000-0000-0000-0000-000000000152', ' edition', '2022-01-01'), + ('f7000000-0000-0000-0000-0000000001b1', 'f7000000-0000-0000-0000-000000000151', 'edition ', '2020-01-01'); +insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values + ('f7000000-0000-0000-0000-0000000001c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000151', + 'f7000000-0000-0000-0000-0000000001b1', '2026-02-01', 'f7000000-0000-0000-0000-0000000001c1'); +select public.admin_merge_games('f7000000-0000-0000-0000-000000000151', 'f7000000-0000-0000-0000-000000000152'); +select is((select variant_id from public.plays where id = 'f7000000-0000-0000-0000-0000000001c1'), 'f7000000-0000-0000-0000-0000000001a1'::uuid, + 'KAT-05 Kriterium 4: bei Gleichstand von created_at gilt die kleinste id'); +select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000152'), 3::bigint, + 'Eine gleichnamige Variante (ohne Groß-/Kleinschreibung und Randleerzeichen) wird nicht kopiert'); + +-- Quelle ohne Verweise: Zähler 0 +insert into public.games (id, title, visibility) values + ('f7000000-0000-0000-0000-000000000161', 'Leer Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000162', 'Leer Ziel', 'catalog'); +select is(public.admin_merge_games('f7000000-0000-0000-0000-000000000161', 'f7000000-0000-0000-0000-000000000162') - 'source_id' - 'target_id', + '{"collection_items":0,"collection_conflicts":0,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":0,"bgg_id_transferred":false,"source_transferred":false,"chain_flattened":0}'::jsonb, + 'Zusammenführen ohne Verweise liefert lauter Nullen'); + +-- --------------------------------------------------------------------------------------------- +-- repoint_game_references: privater Eintrag -> Katalogspiel (RB1, Klärung N15): nichts kopieren +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility, owner_id) values + ('f7000000-0000-0000-0000-0000000002a1', 'Vorläufig Anna', 'private', '11111111-1111-1111-1111-111111111111'); +insert into public.games (id, title, visibility) values + ('f7000000-0000-0000-0000-0000000002b1', 'Katalogziel', 'catalog'); +insert into public.game_variants (id, game_id, name) values + ('f7000000-0000-0000-0000-0000000002e1', 'f7000000-0000-0000-0000-0000000002a1', 'Deluxe'), + ('f7000000-0000-0000-0000-0000000002e2', 'f7000000-0000-0000-0000-0000000002a1', 'Prototyp'), + ('f7000000-0000-0000-0000-0000000002e3', 'f7000000-0000-0000-0000-0000000002b1', ' deluxe'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list) values + ('f7000000-0000-0000-0000-0000000002c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-0000000002a1', 'f7000000-0000-0000-0000-0000000002e2', 'owned'); +insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values + ('f7000000-0000-0000-0000-0000000002d1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-0000000002a1', 'f7000000-0000-0000-0000-0000000002e1', '2026-03-01', 'f7000000-0000-0000-0000-0000000002d1'), + ('f7000000-0000-0000-0000-0000000002d2', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-0000000002a1', 'f7000000-0000-0000-0000-0000000002e2', '2026-03-02', 'f7000000-0000-0000-0000-0000000002d2'); +create temp table rb_priv as select public.repoint_game_references('f7000000-0000-0000-0000-0000000002a1', 'f7000000-0000-0000-0000-0000000002b1') as r; +select is((select r from rb_priv), + '{"collection_items":1,"collection_conflicts":0,"favorite_games":0,"favorite_conflicts":0,"plays":2,"score_sheets":0,"variants_copied":0,"variants_mapped":1,"variants_cleared":2}'::jsonb, + 'KAT-05 Kriterium 4: privater Eintrag: nichts kopiert, eine Variante abgebildet, zwei Verweise geleert (variants_cleared)'); +select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-0000000002b1'), 1::bigint, + 'Privater Eintrag: das Katalogziel bekommt keine Variante (RB1)'); +select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-0000000002a1'), 2::bigint, + 'Privater Eintrag: die privaten Varianten bleiben am vorläufigen Eintrag'); +select is((select row(game_id, variant_id)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000002c1'), + '(f7000000-0000-0000-0000-0000000002b1,)', 'Privater Eintrag: Edition ohne gleichnamige Zielvariante wird am Sammlungseintrag geleert'); +select is((select row(game_id, variant_id)::text from public.plays where id = 'f7000000-0000-0000-0000-0000000002d1'), + '(f7000000-0000-0000-0000-0000000002b1,f7000000-0000-0000-0000-0000000002e3)', 'Privater Eintrag: gleichnamige Zielvariante wird abgebildet'); +select is((select row(game_id, variant_id)::text from public.plays where id = 'f7000000-0000-0000-0000-0000000002d2'), + '(f7000000-0000-0000-0000-0000000002b1,)', 'Privater Eintrag: Edition der Partie ohne Gegenstück wird geleert'); + +-- Kollision bei privatem Eintrag: das Ziel hat die Edition schon; sie bleibt, nichts gilt als geleert +insert into public.games (id, title, visibility, owner_id) values + ('f7000000-0000-0000-0000-0000000002a2', 'Vorläufig Ben', 'private', '22222222-2222-2222-2222-222222222222'); +insert into public.game_variants (id, game_id, name) values ('f7000000-0000-0000-0000-0000000002e4', 'f7000000-0000-0000-0000-0000000002a2', 'Nur privat'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list) values + ('f7000000-0000-0000-0000-0000000002c2', '22222222-2222-2222-2222-222222222222', 'f7000000-0000-0000-0000-0000000002a2', 'f7000000-0000-0000-0000-0000000002e4', 'wishlist'), + ('f7000000-0000-0000-0000-0000000002c3', '22222222-2222-2222-2222-222222222222', 'f7000000-0000-0000-0000-0000000002b1', 'f7000000-0000-0000-0000-0000000002e3', 'owned'); +select is((select (r ->> 'variants_cleared')::int from (select public.repoint_game_references('f7000000-0000-0000-0000-0000000002a2', 'f7000000-0000-0000-0000-0000000002b1') as r) x), 0, + 'Kollision: hat das Ziel schon eine Edition, geht keine verloren'); +select is((select row(list, variant_id)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000002c3'), + '(owned,f7000000-0000-0000-0000-0000000002e3)', 'Kollision: die Edition des Zieleintrags bleibt'); + +-- Kollision mit leerer Zieledition: die Variante der Quelle kommt über die Zuordnung in den Zieleintrag (Spec-F1) +insert into public.games (id, title, visibility) values + ('f7000000-0000-0000-0000-0000000003a1', 'Kollision Quelle', 'catalog'), ('f7000000-0000-0000-0000-0000000003b1', 'Kollision Ziel', 'catalog'); +insert into public.game_variants (id, game_id, name) values + ('f7000000-0000-0000-0000-0000000003e1', 'f7000000-0000-0000-0000-0000000003a1', 'Jubiläum'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list) values + ('f7000000-0000-0000-0000-0000000003c1', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003e1', 'owned'), + ('f7000000-0000-0000-0000-0000000003c2', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-0000000003b1', null, 'owned'); +select public.admin_merge_games('f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003b1'); +select is((select v.name || '/' || v.game_id::text from public.collection_items c join public.game_variants v on v.id = c.variant_id + where c.id = 'f7000000-0000-0000-0000-0000000003c2'), + 'Jubiläum/f7000000-0000-0000-0000-0000000003b1', 'Spec-F1: Kollision: die Edition der Quelle kommt über die Zuordnung (Kopie am Ziel) in den Zieleintrag'); + +-- --------------------------------------------------------------------------------------------- +-- Fremdschlüssel-Wächter: jeder Verweis auf games ist entweder behandelt oder bewusst ausgelassen +-- --------------------------------------------------------------------------------------------- +create temp table fk_found as +select (select relname from pg_class where oid = c.conrelid) || '.' || + (select attname from pg_attribute where attrelid = c.conrelid and attnum = c.conkey[1]) as fk +from pg_constraint c +where c.contype = 'f' and c.confrelid = 'public.games'::regclass; +select is((select count(*) from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.games'::regclass and cardinality(c.conkey) > 1), 0::bigint, + 'Fremdschlüssel-Wächter: alle Verweise auf games sind einspaltig'); +select set_eq($$select fk from fk_found$$, + $$values + -- behandelt von repoint_game_references / admin_merge_games + ('collection_items.game_id'), ('favorite_games.game_id'), ('plays.game_id'), ('score_sheets.game_id'), + ('game_variants.game_id'), ('games.merged_into_id'), + -- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag + ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id')$$, + 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); + +select * from finish(); +rollback; From ef1fddfb728d447036a98e8639137b5c9fbdb9e6 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 19:46:25 +0000 Subject: [PATCH 04/30] docs(auth): add spec for mobile sign-in entry and app links Spec for issue #49: sign-in prompt on the public home screen and Universal Links / App Links for /auth/confirm on iOS and Android, web fallback unchanged. Includes options, test strategy, task split and tasks only Chris can do (Apple/Google accounts, domain files, SITE_URL on prod). Refs #49 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/49-mobil-anmeldung-app-links.md | 351 +++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/specs/49-mobil-anmeldung-app-links.md diff --git a/docs/specs/49-mobil-anmeldung-app-links.md b/docs/specs/49-mobil-anmeldung-app-links.md new file mode 100644 index 0000000..9dd9b64 --- /dev/null +++ b/docs/specs/49-mobil-anmeldung-app-links.md @@ -0,0 +1,351 @@ +# Spec #49 – Mobil: Einstieg in die Anmeldung und Rückweg aus E-Mail-Links (iOS/Android) + +| | | +|---|---| +| Issue | #49 (`type/tech`, `prio/should`, Milestone R1), Refs #1 | +| Status | Entwurf, wartet auf Freigabe durch Chris | +| Autor | architect | +| Stand | 2026-11-03 | +| Abhängigkeit | KTO-01 (#1) umgesetzt; berührt QUE-09 (#29) Kriterium 13 und KTO-11 (#28); Betrieb nach ADR-0002 (Aufgabe D1 aus Spec #1, noch ohne eigenes Issue) | +| Quellen | Issue #49, `docs/specs/1-auth-registrierung-anmeldung.md` (E3, E4, § 5.3, § 5.4, § 6), `docs/auth-konfiguration.md` (AUTH-CFG-01, -02, § 3), `app/lib/core/routing/`, `app/lib/features/auth/presentation/providers/auth_redirect.dart`, `requirements/20-r1-akzeptanzkriterien.md` § QUE-09, § KTO-11, `requirements/r1-klaerungen.md` N9, ADR-0001, ADR-0002, Quelltext `supabase_flutter` 2.x (`SupabaseAuth`) und `app_links` 7.2.2 (iOS-Plugin) | + +## 1. Ziel + +Auf iOS und Android sollen Nutzer (a) aus der App heraus zur Anmeldung und Registrierung kommen und (b) mit den Links aus der Bestätigungs- und der Reset-Mail **in der App** landen, wenn sie installiert ist. Ohne App und auf anderen Geräten bleibt die Web-App der Zielort (Spec #1 E3, E4). Das Verhalten im Web ändert sich bei den Mail-Links nicht. + +Befund (Issue #49, iOS-Simulator, main @ 455b3bf): + +1. Die öffentliche Startseite (`HomeScreen`) hat keinen Weg zu `/login` oder `/register`. `privateRoutePaths` ist leer, deshalb leitet auch kein Guard dorthin. Auf Mobilgeräten sind die Auth-Screens dadurch nicht erreichbar. +2. Die Mail-Links zeigen auf `/auth/confirm?token_hash=…&type=email|recovery`. Die App beansprucht diese URLs nicht, deshalb öffnet der Link den Browser. Lokal zeigt `SITE_URL` auf `http://localhost:3000`, wo im Simulator keine Web-App läuft; dort lässt sich die Bestätigung gar nicht abschließen. In Produktion würde der Link in der mobilen Web-App funktionieren, der Nutzer wäre dann aber im Browser angemeldet und nicht in der App. + +### 1.1 Optionen + +**Einstieg in die Anmeldung** + +| Option | Beschreibung | Vorteile | Nachteile | +|---|---|---|---| +| E-a | **Hinweis-Karte auf der Startseite** (abgemeldet): kurzer Text, „Anmelden“, „Konto erstellen“ | Entspricht QUE-09 Kriterium 13 („Hinweis mit dem Weg zum Anmelden und Registrieren“, N9); QUE-09 kann die Karte übernehmen; klein | Nur auf der Startseite sichtbar (bisher die einzige öffentliche Seite) | +| E-b | Aktion „Anmelden“ in der AppBar | Von jeder Seite mit AppBar erreichbar | Es gibt noch keine gemeinsame AppBar bzw. Shell; das wäre eine Shell-Entscheidung vor QUE-09/KTO-11; schwächeres Touch-Ziel und Text-Platz bei 200 % Schrift | +| E-c | Navigationsleiste mit Tab „Konto“ | Späterer Ort für KTO-11 | Vorgriff auf Navigation und Einstellungen ohne Inhalt; größerer Eingriff | +| E-d | Private Startseite (Guard leitet abgemeldet nach `/login`) | Kein UI nötig | Widerspricht QUE-09 Kriterium 13 und Sicherheit Kriterium 2 (Katalog öffentlich), Web-Startseite wäre nicht mehr öffentlich | + +**Rückweg aus den Mail-Links** + +| Option | Beschreibung | Vorteile | Nachteile | +|---|---|---|---| +| L-a | **Universal Links (iOS) und App Links (Android) auf der bestehenden https-Adresse `/auth/confirm`** | Ein Link für alle Geräte; ohne App öffnet derselbe Link die Web-App (Fallback ohne Zusatzaufwand); Mailvorlagen, GoTrue und Web bleiben unverändert; kein Fremdanbieter; Links sind an Domain, Team-ID und Signatur gebunden, eine fremde App kann sie nicht abfangen | Braucht Apple-Developer-Konto (Team-ID), Play-Signaturschlüssel, endgültige Domain und IDs sowie öffentlich erreichbare Zuordnungsdateien; lokal kaum Ende-zu-Ende testbar; manche Mail-Programme (z. B. Outlook Safe Links, In-App-Browser) lösen Universal Links nicht aus | +| L-b | Eigenes URL-Schema (`shuffle://auth/confirm?…`) direkt in der Mail | Lokal sofort testbar, keine Konten nötig | **Abgelehnt.** Ohne App öffnet der Link nichts, das widerspricht dem Fallback im Web (Kriterium 4) und dem geräteübergreifenden Ablauf (E3, Registrierung am Handy, Mail am PC); viele Mail-Programme machen Nicht-http-Links nicht klickbar; jede App kann dasselbe Schema registrieren und den `token_hash` abfangen, beim Reset-Link wäre das eine Kontoübernahme | +| L-c | Eigenes Schema als `redirect_to` über `{{ .ConfirmationURL }}` (GoTrue `/verify` leitet zur App weiter) | Supabase-Standardweg | **Abgelehnt.** Hebt E3 auf: Der Link würde beim ersten Aufruf eingelöst, Mail-Scanner verbrauchen ihn; geräteübergreifend nur mit PKCE-Verifier; Abfang-Risiko wie L-b; Allow-List müsste das Schema aufnehmen | +| L-d | Code statt Link (6-stelliges OTP `{{ .Token }}` in der Mail, Eingabe in der App) | Keine Domain-Dateien, lokal testbar | Widerspricht KTO-01 Kriterium 8 („Link“); neue UX und neue Mailvorlagen; 6 Ziffern sind ohne Drosselung je Konto ratbar (Drosselung nur je IP, AUTH-CFG-12) | +| L-e | Status quo: Link öffnet immer die Web-App, danach in der App anmelden | Kein Aufwand, funktioniert in Produktion | Erfüllt Kriterien 2 und 3 nicht; lokal im Simulator nicht abschließbar | + +### 1.2 Empfehlung und Kernentscheidungen + +| # | Entscheidung (Vorschlag) | Begründung | +|---|---|---| +| E1 | **Einstieg über eine Hinweis-Karte auf der Startseite (E-a)**, auf allen Plattformen inklusive Web, nur im Status `signedOut`. Die Karte ist ein eigenes Widget `SignInPrompt` in `features/auth/presentation/widgets/`, das QUE-09 später auf der neuen Startseite wiederverwendet. Keine AppBar-Aktion, keine Navigationsleiste. | Deckt Kriterium 1 und vorab den Anmelde-Teil von QUE-09 Kriterium 13 ab. Eine Shell oder Navigationsleiste ist eine eigene Entscheidung mit QUE-09/KTO-11. Gleiches Verhalten auf allen Plattformen ist einfacher als eine Weiche nur für Mobile. | +| E2 | **Universal Links und App Links (L-a), beschränkt auf genau den Pfad `/auth/confirm`.** `/reset-password` gehört **nicht** dazu: Es ist nie Ziel eines Mail-Links, die App erreicht es per Redirect nach `verifyOTP` (Spec #1 § 5.3). Alle anderen Web-Adressen öffnen weiter den Browser. | Kleinster nötiger Umfang. Spätere öffentliche Web-Adressen (Katalog, Spieldetail) sollen nicht ungeplant in der App landen. Eine Erweiterung ist eine eigene Entscheidung (F1). KTO-11 (E-Mail-Wechsel, `type=email_change`) nutzt denselben Pfad und profitiert ohne Änderung. | +| E3 | **Kein eigenes URL-Schema**, weder in Mails noch als Fallback (L-b, L-c abgelehnt). | Sicherheit (Abfangen des Reset-Tokens), Fallback ohne App, E3 aus Spec #1. | +| E4 | **Keine Backend-Änderung.** Keine Migration, keine Edge Function, Mailvorlagen und `GOTRUE_URI_ALLOW_LIST` (AUTH-CFG-02) bleiben unverändert. Einzige Bedingung: Der Host von `SITE_URL` (AUTH-CFG-01) ist genau die Domain, die die Zuordnungsdateien ausliefert und in den App-Projekten eingetragen ist. | Die Vorlagen bauen den Link aus `{{ .SiteURL }}` und `{{ .TokenHash }}`, nicht aus GoTrue `/verify?redirect_to=…`. Die Allow-List greift dabei nicht. | +| E5 | **Deep Links über das eingebaute Flutter-Deep-Linking an go_router**, kein zusätzliches Paket. `FlutterDeepLinkingEnabled` (iOS) und `flutter_deeplinking_enabled` (Android) werden ausdrücklich auf `true` gesetzt. `EmailLinkScreen` bleibt unverändert der Zielort, auch der Knopfdruck vor `verifyOTP` (Spec #1 E3) bleibt. | go_router verarbeitet Pfad und Query bereits (Route `/auth/confirm` existiert). `supabase_flutter` hört über `app_links` (transitiv vorhanden) mit, reagiert aber nur auf `access_token`, `code`, `error*` (Quelltext `SupabaseAuth._defaultIsAuthCallbackDeeplink`); unsere Links mit `token_hash` ignoriert es. Das iOS-Plugin von `app_links` gibt Links standardmäßig an andere Empfänger weiter (`defaultUrlHandling = .never`). Beides bestätigt der Spike S1 am Gerät bzw. Simulator. | +| E6 | **Ein Issue, zwei PRs:** PR A „Einstieg“ (sofort umsetzbar, Kriterium 1), PR B „App Links“ (Kriterien 2–4). PR B ist ohne Chris-Aufgaben mergebar (Dateien mit Platzhaltern, geprüft durch Tests), die Gerätebestätigung der Kriterien 2 und 3 hängt an den Chris-Aufgaben C1–C6 (§ 8.3). | PR A soll nicht auf Konten und Domain warten. | +| E7 | **Zuordnungsdateien liegen als Vorlage im Repo** (Team-ID und Zertifikat-Fingerabdrücke sind nicht geheim); ein Test prüft sie gegen Bundle-ID, Application-ID, Pfad und Host. Wo sie ausgeliefert werden (Web-Build oder Gateway), klärt S1. | Repo und Betrieb bleiben deckungsgleich (ADR-0002: Einstellungen im Repo festhalten). | +| E8 | **Host je Plattform an genau einer Stelle konfiguriert** (Android: Gradle-Property, iOS: xcconfig-Variable), Standard ist die Produktions-Domain (Platzhalter `shuffle.cshomelabs.work`, F8 aus Spec #1). | Domain ist noch Platzhalter; eine Änderung soll eine Zeile je Plattform sein. Staging-Builds sind offen (F5). | + +## 2. Akzeptanzkriterien + +Übernommen aus Issue #49 (Entwurf). Referenz in Tests und PRs: „#49 Kriterium n“. + +1. Auf iOS und Android ist die Anmeldung aus der App heraus erreichbar. +2. Der Bestätigungslink aus der Mail öffnet die App und schließt die Registrierung ab. +3. Der Passwort-Reset-Link öffnet die App und führt in den Reset-Ablauf. +4. Verhalten ohne installierte App (Fallback im Web) ist beschrieben. + +**Vorschläge zur Präzisierung (nicht entschieden, Freigabe durch Chris, siehe F10):** + +- Zu 1: Bin ich abgemeldet, zeigt die Startseite einen Hinweis mit „Anmelden“ und „Konto erstellen“, die zu `/login` bzw. `/register` führen. Das gilt auch im Web. Bin ich angemeldet, erscheint der Hinweis nicht. +- Zu 2: Gegeben die App ist installiert und ich tippe auf dem Gerät auf den Bestätigungslink, dann öffnet sich die Bestätigungsseite in der App; nach dem Knopfdruck „E-Mail-Adresse bestätigen“ bin ich **in der App** angemeldet. Das gilt bei kaltem Start und bei laufender App. +- Zu 3: wie 2 für den Reset-Link; nach dem Knopfdruck führt die App zu „Neues Passwort“, danach gelten KTO-01 Kriterien 8 und 9 unverändert. +- Zu 4 (testbar formuliert): Gegeben die App ist nicht installiert, ich öffne den Link an einem anderen Gerät, oder das Mail-Programm übergibt den Link nicht an die App, dann öffnet sich die Web-App und schließt Bestätigung bzw. Reset ab wie bisher (Spec #1 § 5.4); nach einer Bestätigung im mobilen Browser sehe ich den bestehenden Hinweis „Du kannst dich jetzt auch in der App anmelden.“ +- Zusätzlich 5: Im Web ändert sich das Verhalten der Mail-Links nicht (die bestehenden Playwright-Tests `web/*` bleiben unverändert grün). + +**Relevante Querschnittskriterien:** QUE-05 Kriterien 2 und 4 (neue Texte in ARB, de/en); QUE-06 Kriterien 1–4 (Karte: Beschriftung, Kontrast AA, Touch-Ziele ≥ 48 dp, 200 % Schrift, Tastatur im Web); Sicherheit der Daten Kriterium 2 (Startseite bleibt öffentlich, keine privaten Daten); Plattformen Kriterien 1 und 2. + +## 3. Datenmodell + +Keine Änderung. Keine neuen Tabellen, Spalten, Policies oder pgTAP-Tests. + +**RLS:** unverändert. Der Deep Link überträgt nur `token_hash` und `type`; eingelöst wird er wie bisher per `verifyOTP` gegen GoTrue. + +## 4. Backend + +### 4.1 Supabase / GoTrue + +| Punkt | Wert | Änderung | +|---|---|---| +| Mailvorlagen (AUTH-CFG-17, -18) | Link `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email` bzw. `…&type=recovery` | **keine** | +| `GOTRUE_SITE_URL` (AUTH-CFG-01) | Prod: `https://` | Bedingung: Host = Associated Domain (APPLINK-01). Auf Prod setzt das Chris (C5). | +| `GOTRUE_URI_ALLOW_LIST` (AUTH-CFG-02) | `/auth/confirm` | **keine** (kein `redirect_to` im Einsatz) | +| lokal `config.toml` | `site_url = "http://localhost:3000"` | **keine**; lokal gibt es keine Universal Links (siehe 6.2) | +| RPCs / Edge Functions | – | keine | + +### 4.2 Neue Konfigurations-IDs (Abschnitt in `docs/auth-konfiguration.md`) + +Neuer Abschnitt „App Links / Universal Links“ mit eigenen IDs, damit Tests und Release-Checkliste darauf verweisen. Die bestehenden AUTH-CFG-IDs bleiben unverändert. + +| ID | Einstellung | Wert | Ort | +|---|---|---|---| +| APPLINK-01 | Associated Domain | Host von `SITE_URL` (Prod), Platzhalter `shuffle.cshomelabs.work` | Android: Gradle-Property `shuffleAppLinkHost` (Standard in `android/gradle.properties`), über `manifestPlaceholders["appLinkHost"]`; iOS: `APP_LINK_HOST` in `ios/Flutter/AppLinks.xcconfig`, eingebunden in `Debug.xcconfig` und `Release.xcconfig` | +| APPLINK-02 | Beanspruchter Pfad | genau `/auth/confirm` (mit beliebiger Query) | Android-Intent-Filter, AASA `components`, assetlinks (Android verifiziert je Host, der Pfad steht im Intent-Filter) | +| APPLINK-03 | iOS-App-ID | `.work.cshomelabs.shuffle` (Bundle-ID Platzhalter, R15 aus Spec #1) | `apple-app-site-association` | +| APPLINK-04 | Android-Paket und Signatur | `work.cshomelabs.shuffle`, SHA-256 des Upload-Schlüssels **und** des Play-App-Signing-Schlüssels | `assetlinks.json` | +| APPLINK-05 | Auslieferung | `https:///.well-known/apple-app-site-association` und `/.well-known/assetlinks.json`: HTTPS mit gültigem Zertifikat, Status 200 **ohne Weiterleitung**, `Content-Type: application/json`, öffentlich erreichbar (Apple lädt über sein CDN), **vom SPA-Rewrite ausgenommen** | Gateway bzw. Web-Hosting (D1, C5) | +| APPLINK-06 | Flutter-Deep-Linking | `FlutterDeepLinkingEnabled = true` (Info.plist), `flutter_deeplinking_enabled = true` (Manifest) | App-Projekte | + +### 4.3 Zuordnungsdateien (Vorlagen) + +Ablageort laut S1: bevorzugt `app/web/.well-known/` (wenn `flutter build web` versteckte Verzeichnisse mitkopiert und das Hosting sie korrekt ausliefert), sonst `deploy/well-known/` mit Auslieferung am Gateway. Platzhalter werden im Repo durch die echten Werte ersetzt, sobald C1–C3 erledigt sind. + +`apple-app-site-association` (ohne Dateiendung): + +```json +{ + "applinks": { + "details": [ + { + "appIDs": [".work.cshomelabs.shuffle"], + "components": [ + { "/": "/auth/confirm", "comment": "E-Mail-Links (Bestätigung, Reset, später E-Mail-Wechsel)" } + ] + } + ] + }, + "webcredentials": { + "apps": [".work.cshomelabs.shuffle"] + } +} +``` + +`assetlinks.json`: + +```json +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "work.cshomelabs.shuffle", + "sha256_cert_fingerprints": ["", ""] + } + } +] +``` + +`webcredentials` (iOS) und `delegate_permission/common.get_login_creds` (Android, optional) verbinden die Passwortspeicher von App und Web. Ob sie aufgenommen werden, entscheidet F2 (Empfehlung: `webcredentials` ja). + +### 4.4 Fehlerfälle + +Keine neuen Server-Fehler. Alle Fehler beim Einlösen laufen wie bisher über das Mapping aus Spec #1 § 4.4 (`linkInvalidOrExpired`, `network`, `rateLimited`, `unexpected`). Ein Link einer anderen Umgebung (z. B. Staging-Link in der Prod-App) ergibt beim Einlösen `linkInvalidOrExpired`; das ist harmlos. + +## 5. Frontend + +### 5.1 PR A: Einstieg (Kriterium 1) + +**Neues Widget `SignInPrompt`** (`app/lib/features/auth/presentation/widgets/sign_in_prompt.dart`): + +- Inhalt: kurze Überschrift und ein Satz (Vorschlag ui-designer, z. B. „Melde dich an, um deine Sammlung und Partien zu verwalten.“), Primärknopf „Anmelden“ → `context.go(AppRoutes.login)`, Sekundärknopf „Konto erstellen“ → `context.go(AppRoutes.register)`. Kein `from`-Parameter (Ziel nach dem Login ist ohnehin `/`). +- Komponenten aus `core/widgets` (`AppPrimaryButton`, für den Sekundärknopf gegebenenfalls eine neue Variante durch ui-designer). Touch-Ziele ≥ 48 dp, Semantics über die Knopf-Labels, Überschrift als `Semantics(header: true)`. +- Dumm: bekommt keine Logik, nur Navigation per Callback oder `context.go`. + +**`HomeScreen`** wird `ConsumerWidget`: + +- Liest `authStatusProvider`. Zustände: + - `signedOut` → Platzhaltertext und darunter `SignInPrompt`. + - `signedIn` → Platzhaltertext wie bisher, **kein** Prompt (keine Abmelden-Funktion, siehe F4). + - `passwordRecovery` → tritt auf `/` nicht auf (Redirect nach `/reset-password`). + - Laden (Stream ohne ersten Wert) → Platzhaltertext ohne Prompt, damit beim Start mit gespeicherter Sitzung der Prompt nicht kurz aufblinkt. Der Wert kommt praktisch sofort (`initialSession`). + - Fehler im Stream → wie `signedOut` behandeln (Prompt anzeigen), Fehler nur ins Log. +- Die Entscheidung „Prompt ja/nein“ trifft ein kleiner Provider `showSignInPromptProvider` (bool) in `features/auth/presentation/providers/`, nicht das Widget. Damit bleibt der HomeScreen ohne Logik und QUE-09 übernimmt Provider und Widget. +- Breite Bildschirme: Karte max. 480 dp breit, zentriert (wie `AppFormScaffold`). + +**Texte (ARB, de/en):** `signInPromptTitle`, `signInPromptBody`, `signInPromptSignIn`, `signInPromptRegister`. Bestehende Schlüssel für „Anmelden“/„Konto erstellen“ dürfen wiederverwendet werden, wenn der Wortlaut passt. + +### 5.2 PR B: App Links (Kriterien 2–4) + +**Keine neue Route, kein neuer Screen.** Der Deep Link landet auf der bestehenden Route `/auth/confirm` (`EmailLinkScreen` mit `token_hash` und `type`). + +Abläufe in der App: + +| Situation | Verhalten | +|---|---| +| Kaltstart über den Link | Flutter übergibt `/auth/confirm?token_hash=…&type=…` als Startroute; go_router nutzt sie statt `initialLocation` (S1 bestätigt; sonst `overridePlatformDefaultLocation` prüfen). Redirect-Regeln wie bisher: `signedOut`/`signedIn` → bleibt; `passwordRecovery` → `/reset-password`. | +| App läuft (warm) | Flutter meldet die neue Route, go_router ersetzt den Stack (`go`). Nicht gespeicherte Eingaben auf dem vorherigen Screen gehen verloren (R10). | +| Bestätigung erfolgreich | Hinweis „E-Mail bestätigt“, Knopf „Weiter“ → `/`. Nutzer ist in der App angemeldet, die Startseite zeigt keinen Prompt mehr. Der Zusatz „auch in der App anmelden“ erscheint in der App nicht (`isMobileWebProvider` ist dort `false`). | +| Reset erfolgreich | Status `passwordRecovery` → `/reset-password` (Spec #1 § 5.4, 5.5 unverändert, Guard nur im Prozess). | +| Link ungültig/abgelaufen | Fehlerzustand mit „Neuen Link anfordern“ wie bisher. | +| Zurück-Taste (Android) auf `/auth/confirm` nach Kaltstart | Kein Eintrag darunter: Die App schließt sich. Akzeptiert; „Weiter“ führt zur Startseite. | +| Link öffnet, während ein anderes Konto angemeldet ist | `verifyOTP` ersetzt die Sitzung, wie heute im Web (F3). | + +**Plattform-Dateien** (Umsetzung flutter-dev): + +- `android/app/src/main/AndroidManifest.xml`: in `MainActivity` zusätzlicher Intent-Filter mit `android:autoVerify="true"`, `action VIEW`, `category DEFAULT` und `BROWSABLE`, `data android:scheme="https" android:host="${appLinkHost}" android:path="/auth/confirm"`. Dazu `meta-data flutter_deeplinking_enabled = true`. `launchMode="singleTop"` bleibt (warme Links kommen über `onNewIntent`). +- `android/app/build.gradle.kts`: `manifestPlaceholders["appLinkHost"]` aus der Gradle-Property `shuffleAppLinkHost` (APPLINK-01). +- `ios/Runner/Runner.entitlements` (neu): `com.apple.developer.associated-domains` = `applinks:$(APP_LINK_HOST)` und (F2) `webcredentials:$(APP_LINK_HOST)`; `CODE_SIGN_ENTITLEMENTS` im Xcode-Projekt für alle Build-Konfigurationen setzen. Für Tests auf Geräten im Entwicklermodus optional `applinks:$(APP_LINK_HOST)?mode=developer` (umgeht den Apple-CDN-Cache), **nur** in Debug. +- `ios/Runner/Info.plist`: `FlutterDeepLinkingEnabled = true`. +- `ios/Runner/SceneDelegate.swift`: unverändert, sofern S1 zeigt, dass `FlutterSceneDelegate` Universal Links (`scene(_:willConnectTo:options:)` und `scene(_:continue:)`) an die Engine weitergibt. + +**Logging:** `token_hash` darf in keinem Log erscheinen (Spec #1: keine personenbezogenen bzw. geheimen Daten). Zu prüfen: go_router `debugLogDiagnostics` bleibt aus, `AppLogger` und der globale Fehler-Handler loggen keine vollständigen Routen-URIs, `EmailLinkController` loggt nur Typ und Ergebnis. `supabase_flutter` loggt den URI nur für Links, die es selbst verarbeitet (nicht unsere). + +### 5.3 Domänenmodelle, Provider + +- Neu: `showSignInPromptProvider` (bool aus `authStatusProvider`, Regeln aus 5.1). +- Unverändert: `AuthRepository`, `AuthStatus`, `EmailLinkController`, Redirect-Regeln (`authRedirectFor`), `privateRoutePaths` (bleibt leer). + +### 5.4 Neue Pakete + +**Keine.** `app_links` ist über `supabase_flutter` bereits transitiv vorhanden (7.2.2, Apache-2.0, 160/160 Punkte, pub.dev geprüft 2026-11-03) und wird **nicht** direkt genutzt. Rückfall, falls S1 zeigt, dass Flutters eingebautes Deep-Linking mit dem `app_links`-Plugin kollidiert: `FlutterDeepLinkingEnabled=false` setzen, `app_links` als direkte Abhängigkeit aufnehmen und dessen `uriLinkStream` in einem kleinen Adapter in `core/routing` an `router.go()` weitergeben. Diese Variante bräuchte eine kurze Ergänzung der Spec. + +## 6. Plattform-Besonderheiten + +### 6.1 Übersicht + +| Thema | iOS | Android | Web | +|---|---|---|---| +| Einstieg (Krit. 1) | Hinweis-Karte auf `/` | Hinweis-Karte auf `/` | Hinweis-Karte auf `/` (auch hier, QUE-09 Krit. 13); URLs `/login`, `/register` weiter direkt aufrufbar | +| Link-Zuordnung | Universal Links: Entitlement `applinks:`, AASA wird bei Installation und regelmäßig über Apples CDN geladen | App Links: Intent-Filter mit `autoVerify`, `assetlinks.json` wird bei Installation geprüft (Android 12+: ohne erfolgreiche Prüfung öffnet der Browser) | keine Änderung; `/auth/confirm` wie bisher (SPA-Rewrite, D1) | +| Voraussetzungen (Konten) | Bezahltes Apple-Developer-Programm (Team-ID, Capability „Associated Domains“; mit einem kostenlosen Personal Team nach unserem Kenntnisstand nicht möglich, C1 bestätigt) | Play Console und Play App Signing (Fingerabdruck), eigener Upload-Schlüssel statt Debug-Signatur (heute signiert `release` mit dem Debug-Schlüssel) | – | +| Wann öffnet die App nicht? | Link in Safari-Adresszeile eingegeben; Link über Weiterleitungsdienste (Outlook Safe Links, Tracking-Redirects); manche In-App-Browser von Mail-Apps; Nutzer hat „In Safari öffnen“ gewählt (iOS merkt sich das je Domain) | App-Link-Prüfung fehlgeschlagen; Nutzer hat in den Einstellungen „Unterstützte Links öffnen“ deaktiviert; manche In-App-Browser | – | +| Fallback | Web-App öffnet `/auth/confirm` und schließt den Schritt ab; der Nutzer ist dann im Browser angemeldet, nicht in der App; Hinweis „auch in der App anmelden“ (bestehend) | gleich | ist der Normalfall | +| Debug-Builds | Kaltstart einer Debug-Build per Link funktioniert nicht ohne Flutter-Tooling (bekannte Flutter-Einschränkung); Gerätetests mit Profile/Release | Debug funktioniert, solange der Debug-Fingerabdruck in `assetlinks.json` steht (nur Staging, F5) | – | +| Kamera, Dateien, Push, Offline | nicht betroffen; Einlösen braucht Netz (Offline-Hinweis aus Spec #1) | gleich | gleich | + +### 6.2 Lokale Entwicklung + +- Lokal (`site_url = http://localhost:3000`) gibt es keine Universal/App Links. Die Mails in Mailpit zeigen weiter auf `localhost:3000`, dort funktioniert der Web-Ablauf. +- **Android lokal prüfbar:** Den `token_hash` aus der Mailpit-Mail nehmen und mit dem App-Link-Host aufrufen: `adb shell am start -W -a android.intent.action.VIEW -d "https:///auth/confirm?token_hash=<…>&type=email" work.cshomelabs.shuffle`. Mit expliziter Paketangabe startet die App auch ohne erfolgreiche Verifizierung; sie löst den Token gegen das lokale Supabase ein (Env `dev.json`). Verifizierungsstatus: `adb shell pm get-app-links work.cshomelabs.shuffle`. +- **iOS lokal:** Ohne Team-ID und erreichbare AASA nicht Ende-zu-Ende prüfbar. Die App-Logik deckt ein Widget-Test mit simulierter Plattform-Route ab (7.1). Ende-zu-Ende erst auf Staging/Prod mit echter Domain (C6). + +## 7. Teststrategie + +Werkzeuge: `flutter_test` + `mocktail` (Unit/Widget), bestehende Playwright-Tests (`e2e/`, Projekt `web`) als Regression, ein Konfigurations-Test für die Plattform-Dateien, manuelle Gerätematrix. + +### 7.1 Abdeckungsmatrix + +| AK | Unit | Widget | Integration/E2E | RLS | Task | +|---|---|---|---|---|---| +| #49 Krit. 1 | `showSignInPromptProvider`: `signedOut` → true, `signedIn` → false, Laden → false, Fehler → true | HomeScreen abgemeldet: Prompt mit zwei Knöpfen sichtbar; Tipp „Anmelden“ → LoginScreen, „Konto erstellen“ → RegisterScreen; angemeldet: kein Prompt; a11y-Guidelines (`labeledTapTargetGuideline`, `androidTapTargetGuideline`, `iOSTapTargetGuideline`, `textContrastGuideline`), `textScaler` 2.0 ohne Overflow, de/en | Playwright `web`: Startseite abgemeldet zeigt den Prompt, Klick führt zu `/login` und `/register` (Web gehört dazu, E1) | – | U1, A1, T1 | +| #49 Krit. 2 | – | Kaltstart: `tester.binding.platformDispatcher.defaultRouteNameTestValue = '/auth/confirm?token_hash=x&type=email'` → `EmailLinkScreen` mit Typ Bestätigung; warm: `tester.binding.handlePushRoute(...)` bzw. `SystemChannels.navigation` `pushRouteInformation` → `EmailLinkScreen`; Knopfdruck ruft `verifyEmailLink` (Mock) genau einmal; Erfolg → „Weiter“ → `/`, Prompt verschwunden | manuell M1–M4 (7.2) | – | A2, T2 | +| #49 Krit. 3 | – | wie Krit. 2 mit `type=recovery`: nach Erfolg und Status `passwordRecovery` → ResetPasswordScreen; Deep Link während `passwordRecovery` → bleibt auf `/reset-password` | manuell M5–M6 | – | A2, T2 | +| #49 Krit. 4 | – | – | Playwright `web` (bestehend, unverändert): kompletter Bestätigungs- und Reset-Ablauf im Browser; manuell M7–M9 (App nicht installiert, PC, Outlook) | – | T2 | +| Vorschlag 5 (Web unverändert) | bestehende Redirect-Tests (`authRedirectFor`) unverändert grün | – | alle Playwright-Projekte `api`, `web`, `throttle` unverändert grün | – | T2 | +| APPLINK-01 bis -06 | **Konfigurations-Test** `app/test/platform/app_links_config_test.dart` (liest Dateien relativ zu `app/`): Manifest enthält Intent-Filter mit `autoVerify="true"`, `scheme=https`, `host=${appLinkHost}`, `path=/auth/confirm` und `flutter_deeplinking_enabled=true`; Gradle-Property gesetzt; Entitlements enthalten `applinks:$(APP_LINK_HOST)`; `CODE_SIGN_ENTITLEMENTS` in allen Build-Konfigurationen; Info.plist `FlutterDeepLinkingEnabled=true`; AASA und assetlinks sind gültiges JSON, Bundle-/Package-ID stimmen mit Xcode-Projekt bzw. `applicationId` überein, Pfad ist genau `/auth/confirm`; xcconfig-Host = Gradle-Host | – | Release-Checkliste (7.3) | – | A2, T2 | +| Logging | Test: Router-Fehler- und Logpfade erhalten bei einem `/auth/confirm`-URI keinen `token_hash` (z. B. `AppLogger` mit Test-Handler, Route mit Token aufrufen, Logausgabe prüfen) | – | – | – | T2 | + +### 7.2 Manuelle Gerätematrix (Profile- oder Release-Build, nach C1–C6) + +Je Plattform (iOS-Gerät, Android-Gerät bzw. Emulator mit Play-Services) und jeweils kalt und warm: + +| # | Fall | Erwartung | +|---|---|---| +| M1 | Bestätigungslink in Apple Mail (iOS) bzw. Gmail (Android) | App öffnet `/auth/confirm`, Knopf, angemeldet in der App | +| M2 | Bestätigungslink in Gmail-App (iOS) | App öffnet oder Fallback Web (dokumentieren, welches) | +| M3 | Bestätigungslink in Outlook (beide) | voraussichtlich Fallback Web wegen Safe Links (dokumentieren) | +| M4 | App läuft im Hintergrund auf einer anderen Seite | App kommt nach vorn, `/auth/confirm` | +| M5 | Reset-Link | App, Knopf, „Neues Passwort“, Erfolg, andere Sitzung wird abgemeldet (KTO-01 Krit. 9) | +| M6 | Reset-Link zweimal benutzt | zweites Mal „Link abgelaufen oder schon benutzt“ | +| M7 | App nicht installiert | Web-App schließt den Schritt ab | +| M8 | Link am PC geöffnet, Registrierung war am Handy | Web-App am PC bestätigt, Anmeldung in der App danach möglich | +| M9 | iOS: Nutzer wählt „In Safari öffnen“ | Web-App; beim nächsten Link bleibt iOS beim Browser (bekanntes iOS-Verhalten, dokumentieren) | + +Ergebnis als Tabelle im PR B bzw. im Issue #49; Abweichungen (z. B. M2, M3) werden beschrieben, nicht als Fehler gewertet, solange der Fallback greift. + +### 7.3 Ergänzung Release-Checkliste (`docs/auth-konfiguration.md` § 5) + +- [ ] `curl -sI https:///.well-known/apple-app-site-association` und `/.well-known/assetlinks.json`: Status 200, keine Weiterleitung, `Content-Type: application/json`, Inhalt identisch zum Repo (APPLINK-05). +- [ ] Apple-CDN-Sicht: `https://app-site-association.cdn-apple.com/a/v1/` liefert die aktuelle Datei. +- [ ] Google-Prüfung: Digital Asset Links API (`https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://&relation=delegate_permission/common.handle_all_urls`) listet Paket und Fingerabdrücke. +- [ ] `GOTRUE_SITE_URL`-Host = APPLINK-01. +- [ ] Gerätematrix 7.2 auf der Release-Build durchgeführt. + +## 8. Aufgaben + +### 8.1 PR A – Einstieg (`feature/49-anmeldung-einstieg`, `Refs #49`) + +| # | Agent | Aufgabe | abhängig von | +|---|---|---|---| +| U1 | ui-designer | Gestaltung `SignInPrompt` (Karte, Primär- und Sekundärknopf, Abstände, breite Bildschirme), Sekundärknopf-Variante in `core/widgets` falls nötig, Textvorschläge de/en, a11y (Überschrift als Header, Fokusreihenfolge Web) | – | +| A1 | flutter-dev | `SignInPrompt`, `showSignInPromptProvider`, `HomeScreen` als `ConsumerWidget` (5.1), ARB-Schlüssel de/en, `build_runner` | U1 | +| T1 | test-engineer | Unit-Test Provider, Widget-Tests HomeScreen/Prompt (7.1 Krit. 1), Playwright `web`: Prompt und Navigation | A1 | + +### 8.2 PR B – App Links (`feature/49-app-links`, `Refs #49`; #49 wird erst nach der Gerätebestätigung geschlossen, siehe F7) + +| # | Agent | Aufgabe | abhängig von | +|---|---|---|---| +| S1 | flutter-dev | **Spike (max. ½ Tag), Ergebnis als Änderungsprotokoll in dieser Spec.** Zu klären: (a) Flutter 3.47: Ist Deep-Linking ohne Flag aktiv, und übergibt die Engine Pfad **und** Query an go_router (Kaltstart, warm)? (b) iOS mit UIScene (`SceneDelegate.swift`, `FlutterSceneDelegate`): kommen `scene(_:willConnectTo:)` und `scene(_:continue:)` mit `NSUserActivityTypeBrowsingWeb` bei go_router an, während das `app_links`-Plugin registriert ist? (c) Android mit `singleTop`: `onNewIntent` erreicht go_router? (d) Verarbeitet `supabase_flutter` die Links tatsächlich nicht (kein `getSessionFromUrl`)? (e) Kopiert `flutter build web` `web/.well-known/` in `build/web/`, und liefert `serve -s` bzw. das geplante Hosting die AASA mit `application/json` aus? (f) Ersetzt go_router bei warmem Link den Stack (`go`) oder legt er ihn oben drauf? Prüfweg: Android-Emulator per `adb … am start` (6.2), iOS-Simulator mit `xcrun simctl openurl` **mit einem eigenen Test-Schema nur im lokalen Spike-Branch** (nicht committen), um den Weg bis go_router zu sehen; Universal-Link-Zuordnung selbst ist ohne C1 nicht prüfbar. | – | +| A2 | flutter-dev | Plattform-Dateien nach 5.2 (Manifest-Intent-Filter, Gradle-Property, Entitlements, xcconfig, `CODE_SIGN_ENTITLEMENTS`, Info.plist-Flag); Logging-Prüfung (5.2); falls S1 negativ: Rückfall-Adapter nach 5.4 (vorher Spec-Ergänzung) | S1 | +| D1a | devops | AASA- und assetlinks-Vorlagen nach 4.3 am Ort aus S1 (e); Doku-Abschnitt APPLINK-01 bis -06 in `docs/auth-konfiguration.md`; Release-Checkliste 7.3; Gateway-Vorgaben (Auslieferung ohne Redirect, `application/json`, `/.well-known/` vom SPA-Rewrite ausgenommen, öffentlich erreichbar, nicht hinter VPN/Basic-Auth) als Ergänzung zu D1 aus Spec #1; Android-Release-Signatur vorbereiten (Upload-Keystore außerhalb des Repos, `key.properties` in `.gitignore`, Signing-Config in `build.gradle.kts`), ohne Schlüssel selbst anzulegen | S1 | +| T2 | test-engineer | Konfigurations-Test (7.1 APPLINK), Widget-Tests Deep Link kalt/warm (Krit. 2, 3), Logging-Test, Regression Playwright; Vorlage der Gerätematrix 7.2 im PR | A2, D1a | +| W1 | docs-writer | Outline-Seite KTO-01 bzw. neue Seite „App Links“ (Ablauf, Fallback, Grenzen) nach Merge | A2, D1a | + +### 8.3 Aufgaben, die nur Chris erledigen kann + +| # | Aufgabe | Warum Chris | blockiert | +|---|---|---|---| +| C1 | **Apple Developer Program** beitreten (kostenpflichtig) und Team-ID mitteilen; bestätigen, dass „Associated Domains“ für das App-ID-Profil aktiviert ist | Vertrag, Zahlung, Identität | iOS-Teil von Krit. 2/3, M1–M9 iOS | +| C2 | **Google Play Console** einrichten, Play App Signing aktivieren, SHA-256 des App-Signing-Schlüssels mitteilen; Upload-Keystore erzeugen und sicher verwahren (nicht im Repo), SHA-256 mitteilen | Konto, Schlüsselhoheit | Android-Teil von Krit. 2/3 in Release-Builds | +| C3 | **Bundle-ID / Application-ID endgültig festlegen** (heute Platzhalter `work.cshomelabs.shuffle`, R15 aus Spec #1) | Nach Store-Veröffentlichung nicht mehr änderbar; AASA und assetlinks hängen daran | C1, C2, finale Dateien | +| C4 | **Produktions-Domain endgültig festlegen** (heute Platzhalter `shuffle.cshomelabs.work`, F8 aus Spec #1) und sicherstellen, dass sie öffentlich per HTTPS erreichbar ist (kein VPN, keine Zugangssperre vor `/.well-known/`) | Domain und DNS gehören Chris | APPLINK-01, -05 | +| C5 | **Auf Prod:** Zuordnungsdateien ausliefern (Werte aus C1–C3), `GOTRUE_SITE_URL` auf diese Domain setzen, Release-Checkliste 7.3 abhaken | Zugriff auf Server und Prod-Konfiguration nur Chris (kein Agent greift auf Server zu) | Krit. 2/3 Ende-zu-Ende | +| C6 | Gerätematrix 7.2 auf echten Geräten durchführen oder abnehmen (iPhone, Android) | Physische Geräte, Apple-ID | Abschluss #49 | +| C7 | Offene Fragen F1–F10 entscheiden, Spec freigeben | Freigabe | Umsetzung | + +### 8.4 Reihenfolge + +``` +sofort PR A: U1 → A1 → T1 (unabhängig von allem anderen) +parallel S1 (Spike) → A2, D1a → T2 → PR B (mergebar mit Platzhaltern) +Chris C3, C4 → C1, C2 → C5 → C6 → #49 schließen (F7) +danach W1 +``` + +PR A und PR B sind unabhängig; PR B ändert `HomeScreen` nicht. Kein Backend-PR nötig (backend-dev hat in #49 keine Aufgabe). + +## 9. Risiken & offene Fragen + +### 9.1 Risiken + +| # | Risiko | Auswirkung | Gegenmaßnahme | +|---|---|---|---| +| R1 | Mail-Programme und Weiterleitungsdienste (Outlook Safe Links, Tracking-Redirects, In-App-Browser) lösen Universal/App Links nicht aus | Link öffnet die Web-App statt der App | Fallback Web ist vollwertig (Krit. 4); Gerätematrix dokumentiert das Verhalten; kein Tracking-Redirect in eigenen Mails (bereits so, Spec #1 § 4.1) | +| R2 | SPA-Rewrite liefert `index.html` statt der AASA aus, oder es gibt eine Weiterleitung (z. B. http→https, `www`) | Universal/App Links fallen still aus | APPLINK-05, Release-Checkliste 7.3 mit `curl`; `/.well-known/` ausdrücklich vom Rewrite ausnehmen (D1a) | +| R3 | Apple lädt die AASA über sein CDN und cacht sie (Stunden bis Tage); Android prüft `assetlinks.json` vor allem bei der Installation | Änderungen wirken verzögert; eine bei Installation fehlende Datei lässt die App-Link-Prüfung scheitern | Dateien **vor** der ersten Store-Installation ausliefern; iOS-Entwicklermodus (`?mode=developer`) für Tests; Android: `pm verify-app-links --re-verify` | +| R4 | Signatur passt nicht zu `assetlinks.json` (heute `release` mit Debug-Schlüssel, Play App Signing signiert neu) | App Links auf Android ohne Wirkung | Beide Fingerabdrücke eintragen (C2); Release-Signatur vorbereiten (D1a); Prüfung über die Digital Asset Links API | +| R5 | Bundle-/Application-ID oder Domain ändern sich nach Veröffentlichung | Dateien und Builds müssen neu, alte Installationen öffnen keine Links mehr | C3, C4 vor dem ersten Store-Build; Werte nur an einer Stelle (E8) | +| R6 | Ein späterer, breiterer Pfad-Umfang zieht Web-Adressen ungewollt in die App | Nutzer landen in der App, wo die Seite fehlt (404) | Genau `/auth/confirm` (E2); Erweiterung nur mit Entscheidung (F1) | +| R7 | `token_hash` gelangt in Logs (App-Logger, Router-Diagnose, Android-System-Log bei `am start`) | Einmal-Token lesbar, beim Reset Kontoübernahme bis zum Ablauf (1 h) | Logging-Test (7.1); `debugLogDiagnostics` aus; Token ist einmalig und läuft nach 1 h ab (AUTH-CFG-07); `adb`-Aufrufe nur lokal | +| R8 | Flutter-Deep-Linking und das transitive `app_links`-Plugin stören sich (iOS UIScene), oder eine künftige `supabase_flutter`-Version wertet `token_hash` selbst aus | Links kommen nicht bei go_router an oder werden doppelt eingelöst (E3 verletzt) | S1; Rückfall 5.4; bei Updates von `supabase_flutter` `_defaultIsAuthCallbackDeeplink` prüfen (Hinweis im PR). Als weitere Option bleibt `detectSessionInUri: false` in `Supabase.initialize`. Wir nutzen weder OAuth noch PKCE-Redirects, die Option wirkt aber auch im Web; sie wird deshalb nur mit eigener Prüfung eingesetzt. | +| R9 | Homelab-Domain (`cshomelabs.work`) ist nicht öffentlich oder hinter einer Zugangssperre | Apple-CDN und Google erreichen die Dateien nicht | C4; APPLINK-05 | +| R10 | Warmer Deep Link ersetzt den Navigationsstack | Nicht gespeicherte Eingaben auf dem vorherigen Screen gehen verloren | Akzeptiert: Mail-Links sind ein bewusster Wechsel; S1 (f) bestätigt das Verhalten | +| R11 | iOS-Debug-Builds starten nicht kalt über einen Link | Fehlalarm beim Testen | Gerätetests mit Profile/Release (6.1) | +| R12 | Bestätigung im mobilen Browser (Fallback) meldet den Nutzer im Browser an, nicht in der App | Nutzer muss sich in der App zusätzlich anmelden | Bestehender Hinweis „Du kannst dich jetzt auch in der App anmelden.“; die Startseite der App bietet den Einstieg (Krit. 1) | + +### 9.2 Offene Fragen (Entscheidung Chris) + +| # | Frage | Empfehlung | betrifft | +|---|---|---|---| +| F1 | Welche Pfade beansprucht die App? Nur `/auth/confirm` oder alle Web-Adressen? | **Nur `/auth/confirm`.** Erweiterung (z. B. Spieldetail-Links) erst, wenn es teilbare Inhalte gibt und die App die Route kennt. | E2, APPLINK-02, R6 | +| F2 | Passwortspeicher von App und Web verbinden (`webcredentials` iOS, `get_login_creds` Android)? | **Ja für iOS** (`webcredentials`, gleiche Datei, kein Mehraufwand außer Entitlement-Zeile; Passwort-Vorschläge der Web-Anmeldung erscheinen in der App). Android `get_login_creds` optional, nein in #49. | 4.3, 5.2 | +| F3 | Was passiert, wenn ein Link geöffnet wird, während in der App ein anderes Konto angemeldet ist? | **Wie im Web:** Nach Knopfdruck gilt die neue Sitzung, kein zusätzlicher Dialog. Der Knopfdruck ist bereits eine bewusste Handlung; ein Dialog wäre neue UX ohne zweiten Anwendungsfall. | 5.2 | +| F4 | Braucht die App vor KTO-11 ein vorläufiges „Abmelden“ (z. B. für Gerätetests)? | **Nein.** Abmelden ist KTO-11 Kriterium 3; Tester installieren neu oder nutzen den Reset-Abbruch. | 5.1 | +| F5 | Eigene Staging-App-Builds mit Staging-Host (und Debug-Fingerabdruck in der Staging-`assetlinks.json`)? | **In #49 nein.** Ein Host je Build über die zentrale Property (E8); Staging-Builds entscheidet devops mit dem Hosting-Setup. Bis dahin Android lokal per `adb` (6.2) und iOS erst gegen Prod/Staging-Domain mit Entwicklermodus. | E8, APPLINK-01 | +| F6 | Erscheint der Hinweis zur Anmeldung auch im Web? | **Ja.** QUE-09 Kriterium 13 verlangt ihn für App und Web-Adresse; „Web unverändert“ gilt nur für die Mail-Links. | E1 | +| F7 | Wann wird #49 geschlossen: mit dem Merge von PR B oder erst nach der Gerätebestätigung (C6)? | **Nach C6.** PR B referenziert `Refs #49`; Chris schließt #49 nach der Gerätematrix. Alternativ: PR B schließt #49, Gerätetest wird ein eigenes Issue. | 8.2 | +| F8 | Wo liegen die Zuordnungsdateien im Repo? | **`app/web/.well-known/`**, wenn S1 (e) zeigt, dass Build und Hosting sie korrekt ausliefern; sonst `deploy/well-known/` mit Gateway-Regel. | E7, D1a | +| F9 | Für das Produktions-Setup (D1 aus Spec #1, ADR-0002) gibt es kein Forgejo-Issue. Sollen C4/C5 und D1a dort hinein? | **Ja:** Issue „Produktions-Setup nach ADR-0002“ anlegen (requirements-analyst oder devops), Chris-Aufgaben C4/C5 und die Gateway-Vorgaben dort bündeln; #49 verweist darauf. | 8.3 | +| F10 | Übernimmst du die Präzisierungen der Akzeptanzkriterien (§ 2, Vorschläge zu 1–4 und Kriterium 5)? | **Ja**, sie machen Kriterien 2–4 testbar und schreiben das Web-Verhalten fest. Danach Issue-Text anpassen. | § 2 | + +## 10. Änderungsprotokoll + +| Datum | Änderung | +|---|---| +| 2026-11-03 | Entwurf (architect) | From ab438aca38085ae5394b888f0a88fa1aadf127ab Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 19:48:13 +0000 Subject: [PATCH 05/30] docs(catalog): Spec KAT-04 fehlendes Spiel melden Refs #13 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/13-fehlendes-spiel-melden.md | 497 ++++++++++++++++++++++++ 1 file changed, 497 insertions(+) create mode 100644 docs/specs/13-fehlendes-spiel-melden.md diff --git a/docs/specs/13-fehlendes-spiel-melden.md b/docs/specs/13-fehlendes-spiel-melden.md new file mode 100644 index 0000000..e4ed896 --- /dev/null +++ b/docs/specs/13-fehlendes-spiel-melden.md @@ -0,0 +1,497 @@ +# Spec #13 – KAT-04 Fehlendes Spiel melden + +| | | +|---|---| +| Issue | #13 (KAT-04) | +| Status | Entwurf, wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2) | +| Autor | architect | +| Stand | 2026-11-03 | +| Etappe | R1.2 Sammlung | +| Abhängigkeiten | **#2 PR 2b und 2c müssen gemergt sein**, bevor 13a beginnt (Spec #2 E12): `game_reports`, `admin_resolve_report`, `repoint_game_references`, Trigger `games_visibility_immutable`. #1 (Anmeldung), #3 KAT-01 (Einstieg aus der leeren Suche, Krit. 6). Fachlich zusätzlich #6 SAM-01 und #4 KAT-02 (Orte für „in die Sammlung“ und den Hinweis „am Eintrag“), in Forgejo bisher **nicht** als Abhängigkeit gesetzt (Spec-F8). | +| Quellen | Issue #13 inkl. Kommentare (u. a. Hinweis aus PR #51 vom 2026-11-03), `requirements/20-r1-akzeptanzkriterien.md` § KAT-04 und § Querschnitt, `requirements/r1-klaerungen.md` (6, 10, 12, 15, 20, 23, 26, 27, 28, 36, 44/25, 45, N12, N12a, N15), `requirements/backlog.md`, `docs/specs/2-katalog-daten.md` (E10–E14, 3.2–3.4, 4.4, 9.5), `docs/datenmodell-r1.md`, `supabase/migrations/20261102100200_catalog.sql`, `…100300_collection.sql`, `…100400_plays.sql`, `…20261103142204_catalog_foundation.sql`, CLAUDE.md | + +**Namenskonvention:** „Spec-F1“ bis „Spec-F9“ sind die Fragen an Chris aus § 9.2. „E1“ usw. sind Kernentscheidungen dieser Spec, „#2 E12“ verweist auf Spec #2. „B1“, „F1“, „U1“, „T1“ sind Aufgaben (§ 8). + +## 1. Ziel + +Ein Nutzer, der ein Spiel im Katalog nicht findet, meldet es mit wenigen Angaben und kann es **sofort** als vorläufigen privaten Eintrag nutzen (Entscheidung O5). Der Admin (Chris) prüft jede Meldung manuell im Studio (Klärungen 27, 44; Werkzeug aus #2 PR 2c). Das Ergebnis sieht der Nutzer als Hinweis in der App, nie als E-Mail (Klärungen 26, 45). + +Diese Spec baut auf der Meldungstabelle `game_reports` und der Funktion `admin_resolve_report` aus Spec #2 § 3.4 (PR 2c) auf. Sie ergänzt nur, was der **Nutzer** braucht: Anlegen mit Tageslimit, Ähnlichkeitsprüfung, vorläufigen Eintrag, Ergebnishinweise und das Wechseln auf den Zieleintrag. + +**Nicht im Scope** (aus dem Issue): öffentliche Anzeige gemeldeter Spiele, Bild-Upload, E-Mail/Push, Admin-Oberfläche in der App, automatische Import-Meldungen (SAM-08 #25). Außerdem nicht in dieser Spec: Bearbeiten weiterer Felder des vorläufigen Eintrags (Spielerzahl, Dauer usw.), Zurückziehen einer Meldung, Auswahl vorläufiger Spiele in Partien (gehört zu PAR-01 #15 Krit. 3). + +### Kernentscheidungen (Kurzfassung) + +| # | Entscheidung | Begründung | +|---|---|---| +| E1 | **Jede Nutzer-Meldung legt genau einen vorläufigen privaten Eintrag an** (1:1, eindeutiger Index auf `game_reports.provisional_game_id`). Er bekommt nur `title` und `year` aus der Meldung. | Der Hinweis zum Ergebnis braucht einen festen Ort, und Krit. 3 verlangt die sofortige Nutzung. Das entspricht dem Backlog („eine Meldung je vorläufigem Eintrag“, N12) und dem Import (SAM-08 Krit. 4). Mit nur Titel und Jahr gibt es außer der Edition nichts, was beim Ersetzen verloren gehen kann (Krit. 4, N15). | +| E2 | **Anlegen über eine RPC `report_missing_game` (security invoker)**, die in einer Transaktion Ähnlichkeit prüft, den vorläufigen Eintrag, die Meldung und optional den Listeneintrag anlegt. | Getrennte Client-Aufrufe ließen verwaiste vorläufige Spiele zurück, wenn das Tageslimit die Meldung ablehnt. `security invoker` heißt: RLS, Spaltenrechte und Trigger gelten weiter, die RPC ist nur Bündelung, keine Rechte-Erweiterung. | +| E3 | **Schutz liegt in der Datenbank, nicht in der RPC:** Spaltenrechte auf `game_reports` (kein `id`, `created_at`, `origin`, `status`, `target_game_id`, `resolved_at`, `bgg_id`), Insert-Policy und ein Limit-Trigger mit Sperre je Nutzer. | Ein direkter REST-Insert umgeht nur den UX-Schritt der Ähnlichkeitsprüfung, nicht Limit oder Rechte. Weil `created_at` nicht schreibbar ist, lässt sich das Tageslimit nicht durch ein falsches Datum umgehen. | +| E4 | **Ähnlichkeitsprüfung serverseitig** über `find_similar_catalog_games` mit `pg_trgm` und einer unveränderlichen (`immutable`) Normalisierungsfunktion `normalize_game_title`. Nur sichtbare Katalogspiele (`catalog_games`). | Klärung 15 ist eine Datenbankregel (Trigramm). Dieselbe Regel prüft die RPC beim Absenden noch einmal; ohne ausdrückliche Bestätigung (`p_confirmed_distinct`) wird nicht gespeichert (Krit. 2). | +| E5 | **„Abgelehnt mit Verweis“: Der Nutzer entscheidet selbst**, ob er auf den Zieleintrag wechselt (Spec #2 Spec-F3, entschieden). Dafür gibt es die RPC `adopt_report_target` (security definer) mit strenger Prüfung von Eigentümer und Status; sie nutzt `repoint_game_references` aus 2b. | Krit. 5 verlangt, dass der vorläufige Eintrag „auf den richtigen Eintrag verweist“. Der Verweis steht in `target_game_id`; das Umhängen der eigenen Daten bleibt eine Entscheidung des Nutzers. Das ist der **erste Weg, auf dem ein Nutzer `repoint_game_references` indirekt auslöst**; die Prüfungen sind deshalb ausführlich (4.4). | +| E6 | **Ergebnishinweise werden aus vorhandenen Daten abgeleitet** (`status`, `target_game_id`, `merged_into_id` des vorläufigen Eintrags) und lassen sich wegklicken (`game_reports.result_seen_at`, Spec-F5). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft. | Kein Benachrichtigungssystem in R1 (QUE-01 erst R2). Eine Spalte reicht. | +| E7 | **Edition (N15) ohne Änderung an 2c:** Die App zeigt „Edition neu wählen“, wenn der vorläufige Eintrag mindestens eine eigene Variante hatte und der Eintrag des Nutzers am Zielspiel keine Edition mehr hat (Spec-F6). | Die privaten Varianten bleiben am ausgeblendeten vorläufigen Eintrag erhalten und sind für den Eigentümer weiter lesbar (Spec #2 § 3.3). Alternative mit einer Spalte `edition_cleared` steht in Spec-F6. | +| E8 | **Verlag bleibt Freitext** in `game_reports.publisher_name`; der vorläufige Eintrag bekommt keinen Verlag. | `publishers` ist nur für den Admin schreibbar (Spec #2 3.2). Der Admin legt den Verlag beim Freigeben selbst an (Runbook). | +| E9 | **Keine neuen Pakete, keine Edge Function.** `pg_trgm` ist Teil des Supabase-Postgres-Images und self-hosted verfügbar (ADR-0002). | CLAUDE.md, ADR-0002. | +| E10 | **Zwei PRs:** 13a Backend (`Refs #13`), 13b Flutter (`Closes #13`). 13b kann gegen ein gemocktes Repository parallel beginnen, sobald der RPC-Vertrag (4.2–4.5) freigegeben ist. | Der Vertrag steht vollständig in dieser Spec. | + +## 2. Akzeptanzkriterien + +Übernommen aus Issue #13 / `20-r1-akzeptanzkriterien.md` § KAT-04 (Stand 2026-11-02 inkl. Klärungen und N12). Referenz in Tests und PRs: „KAT-04 Kriterium n“. + +1. Gegeben ich finde ein Spiel nicht, wenn ich Titel (Pflicht), Verlag, Erscheinungsjahr und optional einen Link angebe, dann wird die Meldung gespeichert und ich erhalte eine Bestätigung. +2. Gegeben es gibt schon einen sehr ähnlichen Katalogeintrag, wenn ich melden will, dann sehe ich ihn zuerst und muss ausdrücklich bestätigen, dass mein Spiel ein anderes ist. „Sehr ähnlich“ heißt: gleicher normalisierter Titel (Groß-/Kleinschreibung, Sonderzeichen und Artikel ignoriert) oder Titel-Ähnlichkeit von mindestens 0,8 (Trigramm) bei Erscheinungsjahr ± 1 (Klärung 15). +3. Gegeben ich habe ein Spiel gemeldet, dann kann ich es als vorläufigen Eintrag in meine Sammlung oder Listen aufnehmen; der Eintrag ist nur für mich sichtbar. +4. Gegeben die Meldung wurde freigegeben, dann ersetzt der Katalogeintrag meinen vorläufigen Eintrag, ohne dass meine eigenen Angaben verloren gehen, und ich sehe das als Hinweis in der App am Eintrag (Klärung 26). +5. Gegeben die Meldung wurde mit einem anderen Eintrag zusammengeführt oder unter Verweis auf einen vorhandenen Eintrag abgelehnt, dann sehe ich das als Hinweis in der App am vorläufigen Eintrag (keine E-Mail), und mein vorläufiger Eintrag verweist auf den richtigen Eintrag (Klärungen 26, 45). +6. Gegeben die Meldung wurde ohne Zieleintrag abgelehnt, dann bleibt mein vorläufiger Eintrag privat bestehen und ist als „abgelehnt“ markiert (Klärung 26). +7. Gegeben ich habe heute schon 10 Meldungen abgeschickt, wenn ich eine weitere melden will, dann wird sie nicht gespeichert und ich sehe einen Hinweis auf das Tageslimit (Klärung 27). Meldungen, die der BGG-Import automatisch anlegt (SAM-08 Kriterium 4), zählen nicht auf dieses Limit (N12). +8. Gegeben ich habe eine Meldung abgeschickt, dann bleibt sie im Status „offen“, bis der Admin sie manuell bearbeitet; eine automatische Freigabe gibt es nicht (Klärungen 6, 27, 44). + +**Abweichung bei Krit. 4 (Klärung N15, entschieden von Chris am 2026-11-03):** Beim Ersetzen bleibt die **Edition** nur erhalten, wenn der Katalogeintrag eine gleichnamige Variante hat; sonst wird sie an Sammlungseintrag und Partien geleert, und die App fordert auf, sie neu zu wählen (E7). Alle anderen eigenen Angaben (Liste, Notiz, Kaufdatum, Angaben aus SAM-02/SAM-04, Partien) bleiben erhalten; das leistet `repoint_game_references` aus 2b. + +**Lesart von Krit. 2 (Klärung 15):** Die Bedingung „Erscheinungsjahr ± 1“ gehört nur zur Trigramm-Regel. Gleicher normalisierter Titel gilt unabhängig vom Jahr als „sehr ähnlich“. Fehlt das Jahr auf einer Seite, siehe Spec-F2. + +**Lesart von Krit. 1:** Nur der Titel ist Pflicht; Verlag und Jahr sind optional (Spec-F1). + +**Relevante Querschnittskriterien** + +- **Sicherheit der Daten Krit. 1 und 3:** Meldungen und vorläufige Einträge sieht nur der Meldende (und der Admin im Studio). pgTAP prüft fremden Zugriff auf `game_reports`, auf vorläufige Spiele und auf die neuen RPCs. +- **Sicherheit der Daten Krit. 2:** `anon` kann nichts melden und keine Meldungen lesen. +- **Datenschutz (QUE-04):** Meldungen und vorläufige Einträge werden mit dem Konto hart gelöscht (`on delete cascade`, Klärung 10) und gehören in den Export (KTO-05 #23, Klärung 20). Freigegebene Katalogeinträge übernehmen nur Fakten aus den Meldungsfeldern (Runbook aus 2c). Der Link wird in der App nicht geöffnet und nicht vorgeladen (keine Verbindung zu Fremdseiten). +- **Sprachen (QUE-05) Krit. 2, 4:** alle Texte, Fehler- und Statushinweise aus ARB (de/en). +- **Barrierefreiheit (QUE-06) Krit. 1–4:** Formular mit Feldbeschriftungen, Statushinweise mit Text (nicht nur Farbe), Touch-Ziele ≥ 48 dp, `textScaler` 2.0, Tastaturbedienung im Web inkl. Bestätigungsdialog. +- **Zustände, Fehler und Netz Krit. 1–3:** Liste „Meine Meldungen“ mit Lade-, Leer-, Fehler- und Datenzustand; offline Hinweis statt technischem Fehler; bei Limit- oder Netzfehler bleiben die Eingaben im Formular erhalten (Issue: „besonders relevant“). +- **Plattformen Krit. 1, 2:** identisch auf iOS, Android und Web; breites Layout im Web. + +## 3. Datenmodell + +### 3.1 Ausgangslage (nach #2 PR 2c) + +Spec #2 § 3.4 legt an (Spaltennamen werden hier so vorausgesetzt; weicht 2c davon ab, passt B1 die Namen an): + +- `game_reports` mit `id`, `owner_id` (FK → `profiles`, `on delete cascade`), `origin` (`user`/`import`, Standard `user`), `title` (1–200), `publisher_name` (≤ 200), `year` (1800–2100), `link` (≤ 500, `^https?://`), `bgg_id`, `provisional_game_id` (FK → `games`, `on delete set null`), `status` (`open`/`approved`/`rejected`/`merged`, Standard `open`), `target_game_id` (FK → `games`, `on delete restrict`), `resolved_at`, `created_at`, `updated_at`; Checks zu Status, Ziel und `resolved_at`; Index `(owner_id, created_at)`; RLS: Lesen nur eigene, **kein** Insert/Update/Delete für `authenticated`. +- `admin_resolve_report(p_report, p_status, p_target)`: bei `approved`/`merged` hängt sie den vorläufigen Eintrag per `repoint_game_references` auf das Ziel um und setzt am vorläufigen Eintrag `merged_into_id` und `hidden_at`; bei `rejected` speichert sie nur den Verweis (oder nichts). +- Trigger `games_visibility_immutable`: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14). + +### 3.2 Änderungen in 13a (Migration `supabase/migrations/_game_reports_user.sql`) + +Nur additiv; der Zeitstempel liegt nach der Migration aus 2c. Rechte-Regel aus Spec #2 § 3.2 gilt: neue Funktionen zuerst `revoke execute … from public, anon, authenticated, service_role`, danach nur die Grants aus 4.1. + +**Erweiterungen** + +``` +create extension if not exists pg_trgm with schema extensions; +``` + +Falls #3 die Erweiterung schon anlegt, ist die Zeile ein No-op. Kein `unaccent` (Spec-F3). + +**Neue Spalte in `game_reports`** + +| Spalte | Typ | Hinweis | +|---|---|---| +| `result_seen_at` | timestamptz | nullable; der Nutzer hat den Ergebnishinweis weggeklickt (E6, Spec-F5). Check `result_seen_at is null or status <> 'open'`. | + +**Neue Indizes** + +| Index | Zweck | +|---|---| +| `unique (provisional_game_id) where provisional_game_id is not null` | 1:1 Meldung ↔ vorläufiger Eintrag (E1). Falls 2c ihn schon anlegt: entfällt. | +| `(target_game_id) where target_game_id is not null` | Hinweis am Eintrag: „gibt es eine Meldung von mir mit diesem Ziel?“ (5.4) | +| GIN `(extensions.gin_trgm_ops)` auf `public.normalize_game_title(title)` in `games`, `where visibility = 'catalog' and hidden_at is null and merged_into_id is null` | Ähnlichkeitssuche (4.3). Bei ~200–2000 Katalogspielen nicht zwingend, aber billig; KAT-01 kann ihn mitnutzen. | + +**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; +``` + +`select` bleibt wie in 2c. **Nicht** schreibbar für Nutzer: `id`, `origin`, `status`, `bgg_id`, `target_game_id`, `resolved_at`, `created_at`, `updated_at` (Fehler 42501). Damit setzt die Datenbank `origin = 'user'`, `status = 'open'` (Krit. 8) und `created_at = now()` (Tageslimit) selbst. `delete` bekommt der Nutzer nicht. + +**Merkpunkt für den Client (aus #2 PR 2a):** Clients schreiben `games` und `game_reports` nie mit `id`, `created_at` oder `updated_at`, und ein `toJson()` eines Modells geht nie ungefiltert an `insert`/`update`. In #13 schreibt die App ohnehin nur über die RPCs (4.2, 4.4) und das eine Update von `result_seen_at` (4.5) mit einer ausdrücklichen Spaltenliste. + +**RLS in Klartext (neu in 13a)** + +- **Anlegen (`game_reports_insert_own`, `for insert to authenticated`):** `owner_id = auth.uid()` und, falls `provisional_game_id` gesetzt ist, zeigt er auf ein **eigenes privates**, nicht ausgeblendetes und nicht zusammengeführtes Spiel (`exists (select 1 from games g where g.id = provisional_game_id and g.visibility = 'private' and g.owner_id = auth.uid() and g.hidden_at is null and g.merged_into_id is null)`). `origin`, `status` usw. sind über die Spaltenrechte festgelegt; die Policy prüft zusätzlich `origin = 'user' and status = 'open' and target_game_id is null` (zweite Sicherung). +- **Ändern (`game_reports_update_seen`, `for update to authenticated`):** `using (owner_id = auth.uid() and status <> 'open')`, `with check` gleich. Über die Spaltenrechte ist nur `result_seen_at` änderbar. +- **Lesen:** unverändert aus 2c (nur eigene). +- **Löschen:** kein Recht (Meldungen verschwinden nur mit dem Konto). +- **Vorläufige Spiele:** unverändert; `games_select` zeigt private Spiele nur dem Ersteller, auch wenn sie ausgeblendet oder zusammengeführt sind. So bleiben die privaten Varianten für den Hinweis zur Edition lesbar (E7). + +**Trigger `game_reports_daily_limit`** (Krit. 7) + +- `before insert on public.game_reports for each row`, nur wenn `new.origin = 'user'`. +- Sperre gegen gleichzeitige Inserts: `perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0))`. Ohne Sperre könnten zwei parallele Meldungen beide die Zählung „9“ sehen. +- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `created_at` am **selben Kalendertag in Europe/Berlin** (Spec-F4): `(created_at at time zone 'Europe/Berlin')::date = (now() at time zone 'Europe/Berlin')::date`. Bei ≥ 10 Fehler `report_daily_limit` (P0001). +- Import-Meldungen (`origin = 'import'`, SAM-08) werden weder gezählt noch geprüft (N12). +- Funktion `security invoker` mit `search_path = ''`. Der Nutzer sieht per RLS alle eigenen Meldungen, mehr braucht die Zählung nicht; Studio (`postgres`) umgeht RLS ohnehin. `execute` wie oben entzogen (Trigger-Funktionen brauchen kein `execute`-Recht des Aufrufers). +- Die Zahl 10 steht als Konstante in der Funktion (kein Konfigurationswert); eine Änderung ist eine neue Migration. + +**Keine neue Tabelle, kein neuer Fremdschlüssel auf `games`.** Der Fremdschlüssel-Wächter aus #2 (7) und `00_schema.test.sql` (21 Tabellen) bleiben unverändert. + +### 3.3 Normalisierung und „sehr ähnlich“ (Krit. 2, Klärung 15) + +**Funktion `public.normalize_game_title(p_title text) returns text`**, `language sql`, `immutable`, `strict`, `parallel safe`, `search_path = ''`. Schritte in dieser Reihenfolge: + +1. `lower()`; `ß` → `ss`. +2. Alles, was kein Buchstabe und keine Ziffer ist (Satz- und Sonderzeichen, Bindestriche, Doppelpunkte, Apostrophe, Emojis), wird zu einem Leerzeichen. Umlaute und andere Buchstaben mit Akzent **bleiben** (Spec-F3). Umsetzung mit `regexp_replace(…, '[^[:alnum:]]+', ' ', 'g')`; backend-dev prüft per Test, dass `[:alnum:]` in der Datenbank-Locale Umlaute als Buchstaben erkennt (sonst explizite Zeichenklasse `[^a-z0-9äöüàáâèéêìíîòóôùúûñçåæøœ]`). +3. **Führende Artikel** entfernen, einmal, nur am Anfang (Spec-F3): Deutsch `der`, `die`, `das`, `den`, `dem`, `des`, `ein`, `eine`, `einen`, `einem`, `einer`, `eines`; Englisch `the`, `a`, `an`. +4. Mehrfache Leerzeichen zu einem, `trim`. +5. Ist das Ergebnis leer (Titel bestand nur aus einem Artikel oder Sonderzeichen), wird der Wert aus Schritt 2 (ohne Artikelentfernung) verwendet. + +Beispiele (werden pgTAP-Fälle): „Die Siedler von Catan“ → `siedler von catan`; „CATAN!“ → `catan`; „7 Wonders: Duel“ → `7 wonders duel`; „The Crew“ → `crew`; „Café International“ → `café international`; „Das“ → `das`. + +**Regel „sehr ähnlich“** zwischen Meldung (Titel `t`, Jahr `y`) und Katalogspiel `g`: + +- **A (gleicher Titel):** `normalize_game_title(g.title) = normalize_game_title(t)`, unabhängig vom Jahr. +- **B (Trigramm):** `extensions.similarity(normalize_game_title(g.title), normalize_game_title(t)) >= 0.8` **und** `abs(g.year - y) <= 1`. Fehlt `y` oder `g.year`, gilt B ohne Jahresbedingung (Spec-F2). +- Grundlage sind nur sichtbare Katalogspiele (`catalog_games`), keine ausgeblendeten, zusammengeführten oder privaten Spiele (auch nicht eigene vorläufige, Spec-F7). +- **Wichtig für die Umsetzung:** In Funktionen mit `search_path = ''` muss `extensions.similarity(...) >= 0.8` ausdrücklich verwendet werden, **nicht** der Operator `%`; dieser nutzt die Einstellung `pg_trgm.similarity_threshold` (Standard 0,3). +- **Alternative Titel (Klärung 3, #3):** Hat #3 bei der Umsetzung von 13a eine Tabelle für alternative Titel, werden diese in A und B mitgeprüft (beste Übereinstimmung je Spiel). Sonst nur der Haupttitel; #3 ergänzt die Prüfung dann in seiner Migration (Hinweis 9.4). + +**Zusammenspiel mit #3:** Die Suche in KAT-01 (Krit. 1: „unabhängig von Groß- und Kleinschreibung und Sonderzeichen“) soll dieselbe Funktion nutzen. Wird #3 vor 13a umgesetzt und legt eine Normalisierungsfunktion an, die die Regeln oben erfüllt, verwendet 13a sie statt einer zweiten; sonst legt 13a `normalize_game_title` an und #3 nutzt sie später mit. Eine zweite, abweichende Normalisierung ist zu vermeiden (zweiter Anwendungsfall vorhanden, deshalb eine gemeinsame Funktion). + +## 4. Backend + +### 4.1 Überblick + +| Objekt | Art | `execute`/Recht | Zweck | +|---|---|---|---| +| `normalize_game_title(text)` | Funktion, sql, immutable | `anon`, `authenticated` (harmlos, rein rechnend; nötig für Index und Abfragen als Aufrufer) | Normalisierung (3.3) | +| `find_similar_catalog_games(p_title text, p_year smallint)` | RPC, security invoker | `authenticated` | Ähnliche Katalogspiele anzeigen (Krit. 2) | +| `report_missing_game(...)` | RPC, security invoker | `authenticated` | Meldung + vorläufiger Eintrag + optional Listeneintrag (Krit. 1, 2, 3, 7, 8) | +| `adopt_report_target(p_report uuid)` | RPC, **security definer** (Eigentümer `postgres`) | `authenticated` | Wechsel auf den Zieleintrag bei „abgelehnt mit Verweis“ (Krit. 5) | +| `game_reports_daily_limit()` | Trigger-Funktion, security invoker | – | Tageslimit (Krit. 7) | +| Policies `game_reports_insert_own`, `game_reports_update_seen`, Spaltenrechte | RLS | – | 3.2 | + +`anon` darf keine der RPCs ausführen (42501). `service_role` bekommt nichts (keine Edge Function; der Import ergänzt später eigene Rechte, Spec #2 9.5). + +Fehler werden wie in den bestehenden Migrationen als `raise exception '' using errcode = 'P0001'` gemeldet; die App bildet den Code auf einen Domänenfehler ab (5.3). + +### 4.2 RPC `public.report_missing_game` + +``` +report_missing_game( + p_title text, + p_publisher_name text default null, + p_year smallint default null, + p_link text default null, + p_list text default null, -- 'owned' | 'wishlist' | 'shopping' | null + p_confirmed_distinct boolean default false +) returns jsonb -- {"report_id": uuid, "provisional_game_id": uuid, "collection_item_id": uuid | null} +``` + +`language plpgsql`, `security invoker`, `search_path = ''`, `volatile`. + +Ablauf (eine Transaktion; jeder Fehler rollt alles zurück): + +1. `auth.uid()` ist null → `not_authenticated`. +2. Eingaben bereinigen: `btrim` auf Titel, Verlag, Link; leere Zeichenketten werden `null`. +3. Prüfungen in dieser Reihenfolge: + +| Prüfung | Fehler | +|---|---| +| Titel leer | `report_title_required` | +| Titel > 200 Zeichen | `report_title_too_long` | +| Verlag > 200 Zeichen | `report_publisher_too_long` | +| Jahr außerhalb 1800–2100 | `report_year_invalid` | +| Link > 500 Zeichen oder nicht `^https?://` | `report_link_invalid` | +| `p_list` nicht in `owned`, `wishlist`, `shopping`, null | `report_list_invalid` | +| `p_confirmed_distinct = false` und `find_similar_catalog_games` liefert ≥ 1 Treffer | `report_similar_exists` | + + Die Prüfungen spiegeln die Checks aus 2c, damit die App gezielte Feldfehler zeigen kann; die Checks der Tabelle bleiben die eigentliche Grenze (23514 als Rückfall → „unbekannter Fehler“). +4. `insert into public.games (owner_id, title, year, visibility) values (auth.uid(), title, year, 'private') returning id` (nur erlaubte Spalten, keine `id`). +5. `insert into public.game_reports (owner_id, title, publisher_name, year, link, provisional_game_id) … returning id`. Hier greift der Limit-Trigger (`report_daily_limit`); weil Schritt 4 in derselben Transaktion liegt, bleibt kein verwaistes Spiel zurück. +6. Wenn `p_list` gesetzt: `insert into public.collection_items (owner_id, game_id, list) values (auth.uid(), provisional_id, p_list) returning id`. Keine Edition (der vorläufige Eintrag hat keine Varianten; KAT-03 Krit. 7 für Listen). +7. Rückgabe als `jsonb`. + +Aufrufreihenfolge in der App: erst `find_similar_catalog_games` (4.3) zeigen, dann `report_missing_game` mit `p_confirmed_distinct = true`, wenn der Nutzer bestätigt hat. Ruft ein Client direkt mit `true` auf, ohne die Liste gezeigt zu haben, umgeht er nur den UX-Schritt; Rechte und Limit sind nicht betroffen (E3). + +### 4.3 RPC `public.find_similar_catalog_games` + +``` +find_similar_catalog_games(p_title text, p_year smallint default null) +returns table ( + game_id uuid, + title text, + year smallint, + publishers text[], -- Namen aus game_publishers/publishers, sortiert + match_kind text, -- 'same_title' | 'similar_title' + similarity real -- 1.0 bei 'same_title' +) +``` + +`language sql`, `stable`, `security invoker`, `search_path = ''`. Liest `public.catalog_games` (plus `game_publishers`/`publishers`). Regel nach 3.3. Sortierung: `same_title` zuerst, dann `similarity` absteigend, dann `title`. Höchstens **5** Treffer. Leerer oder nur aus Leerzeichen bestehender Titel → leere Menge (kein Fehler). + +Wird für `authenticated` freigegeben, nicht für `anon` (Melden setzt Anmeldung voraus). + +### 4.4 RPC `public.adopt_report_target` + +``` +adopt_report_target(p_report uuid) returns jsonb +-- Zähler aus repoint_game_references, z. B. {"collection_items": 1, "plays": 0, "variants_mapped": 0, "variants_cleared": 1, …} +``` + +`language plpgsql`, `security definer`, Eigentümer `postgres`, `search_path = ''`. Nötig, weil Nutzer `merged_into_id`/`hidden_at` nicht schreiben und `repoint_game_references` nicht ausführen dürfen (Spec #2 3.2, 3.3). + +Prüfungen (alle mit `for update` auf Meldung und vorläufigem Eintrag, damit ein gleichzeitiges `admin_resolve_report` nicht dazwischenkommt): + +| Prüfung | Fehler | +|---|---| +| `auth.uid()` ist null | `not_authenticated` | +| Meldung existiert nicht **oder** gehört nicht `auth.uid()` (gleicher Fehler, verrät keine fremden IDs) | `report_not_found` | +| `status <> 'rejected'` oder `target_game_id is null` | `report_not_adoptable` | +| `provisional_game_id is null` | `report_no_provisional` | +| vorläufiger Eintrag ist nicht `private` mit `owner_id = auth.uid()` | `report_not_found` | +| vorläufiger Eintrag ist schon zusammengeführt (`merged_into_id is not null`) | `report_already_adopted` | +| Ziel ist kein sichtbares Katalogspiel (`catalog_games`) | `report_target_not_listed` | +| Es gibt Verweise auf den vorläufigen Eintrag von **anderen** Nutzern (`collection_items`, `favorite_games`, `plays`, `score_sheets` mit `owner_id`/`profile_id` ≠ `auth.uid()`) | `report_foreign_references` | + +Die letzte Prüfung ist eine Schutzschicht: Über die API können andere Nutzer ein fremdes privates Spiel nicht referenzieren (Policies verlangen ein sichtbares Spiel), aber `security definer` umgeht RLS, und ein Studio-Handgriff wäre denkbar. + +Ablauf: `repoint_game_references(provisional, target)` (privat → Katalog, keine Varianten kopiert, gleichnamige abgebildet, sonst `null`; Spec #2 RB1/N15), danach am vorläufigen Eintrag `merged_into_id = target`, `hidden_at = now()`. Die Meldung bleibt `rejected` (das ist das Ergebnis der Prüfung durch den Admin); `result_seen_at` wird auf `now()` gesetzt, weil der Nutzer gehandelt hat. Rückgabe: Zähler aus `repoint_game_references`. + +`favorite_games` sind bei vorläufigen Spielen fachlich ausgeschlossen (Klärung 23), technisch aber derzeit erlaubt (Hinweis an #14, 9.4); `repoint_game_references` behandelt sie ohnehin. + +### 4.5 Ergebnishinweis wegklicken + +Kein eigener RPC. Die App sendet per PostgREST `PATCH /rest/v1/game_reports?id=eq.` genau die Map `{'result_seen_at': }` (PostgREST setzt Werte, keine SQL-Ausdrücke wie `now()`). Dass die Zeit vom Client kommt, ist unkritisch: Der Wert steuert nur die Anzeige des eigenen Hinweises. Policy und Spaltenrecht (3.2) begrenzen das Update auf eigene, bereits bearbeitete Meldungen und genau diese Spalte; bei einer offenen oder fremden Meldung ändert sich keine Zeile. + +### 4.6 Lesen für die App + +Kein RPC nötig; PostgREST-Abfragen als angemeldeter Nutzer: + +- **Meine Meldungen:** `game_reports` (eigene, RLS) mit Spalten `id, origin, title, publisher_name, year, link, status, target_game_id, provisional_game_id, result_seen_at, created_at, resolved_at`, sortiert nach `created_at desc`, seitenweise (20). Eingebettet: `provisional:games!provisional_game_id(id, merged_into_id, hidden_at, game_variants(id))` und `target:games!target_game_id(id, title, year)`. Die Fremdschlüsselnamen für das Embedding prüft flutter-dev gegen die 2c-Migration. +- **Hinweis zu einem Spiel** (5.4): `game_reports` mit `or=(provisional_game_id.eq.,target_game_id.eq.)`; der Index aus 3.2 deckt beide Fälle. +- **Edition (E7):** `collection_items` des Nutzers zum Zielspiel (`variant_id`) und `provisional.game_variants` aus der eingebetteten Abfrage. + +### 4.7 Doku + +- `docs/datenmodell-r1.md`: `game_reports.result_seen_at`, Indizes, Spaltenrechte, Policies, Trigger `game_reports_daily_limit`, die vier Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes. +- `docs/katalog-pflege.md`, Abschnitt „Meldungen bearbeiten“ (aus 2c) ergänzen: Nutzer-Meldungen haben immer einen vorläufigen Eintrag; „abgelehnt mit Verweis“ lässt dem Nutzer die Wahl (`adopt_report_target`), der Admin hängt dabei nichts um; vor `approved` die Edition berücksichtigen (Runbook aus 2c); Meldungen nie von Hand auf einen anderen Status setzen (nur über `admin_resolve_report`), weil die App den Hinweis aus Status und `merged_into_id` ableitet. +- `supabase/README.md`: neue RPCs und Rechte. + +## 5. Frontend + +### 5.1 Struktur + +Neues Feature `app/lib/features/game_reports/` (Feature-first, Abhängigkeiten nach innen): + +``` +features/game_reports/ + domain/ + game_report.dart freezed: GameReport + game_report_status.dart enum GameReportStatus { open, approved, rejected, merged } + game_report_origin.dart enum GameReportOrigin { user, import } + report_outcome.dart sealed ReportOutcome (5.4) + reine Funktion outcomeOf(...) + game_report_draft.dart freezed: Eingaben + Validierung (Titel Pflicht, Längen, Jahr, Link) + similar_game.dart freezed: SimilarGame (gameId, title, year, publishers, matchKind) + game_report_failure.dart freezed/sealed: dailyLimit, similarExists, validation(field, code), + notAdoptable, alreadyAdopted, targetNotListed, offline, unknown + game_report_repository.dart abstrakte Schnittstelle + data/ + supabase_game_report_repository.dart RPC-Aufrufe und Abfragen (4.2–4.6), explizite Parameter-Maps + game_report_dto.dart Mapping aus Zeilen (nur fromJson; kein toJson für Schreibzugriffe) + game_report_failure_mapper.dart P0001-Codes → GameReportFailure + presentation/ + report_form/ Screen, Controller (Notifier), State + similar_games/ Abschnitt/Dialog mit Kandidaten und ausdrücklicher Bestätigung + my_reports/ Liste „Meine Meldungen“, Controller + report_detail/ Detail einer Meldung inkl. Hinweis und Aktionen + widgets/report_outcome_notice.dart wiederverwendbarer Hinweis (auch für #4/#6/#11) + providers/ Repository-, Listen- und Hinweis-Provider (Riverpod, generiert) + game_report_routes.dart +``` + +Die Domäne importiert weder Flutter noch Supabase. Die Validierung der Eingaben liegt in `GameReportDraft` (Unit-Tests), nicht im Widget. + +### 5.2 Routen (go_router) + +| Route | Screen | Hinweis | +|---|---|---| +| `/reports/new?title=` | `ReportFormScreen` | Einstieg aus der leeren Trefferliste von KAT-01 (#3 Krit. 6) mit vorbelegtem Titel; Anmeldung nötig (Redirect auf `/login?from=…` wie in Spec #1) | +| `/reports` | `MyReportsScreen` | Liste der eigenen Meldungen | +| `/reports/:reportId` | `ReportDetailScreen` | Status, Angaben, Hinweis, Aktionen; Deep Link und Web-URL möglich, fremde/unbekannte ID → „nicht gefunden“ (RLS liefert keine Zeile) | + +Konstanten in `AppRoutes`. Einstieg zu `/reports`: aus der Suche (#3) und aus jedem Hinweis; ob zusätzlich in den Einstellungen (KTO-11 #28) oder im Dashboard (QUE-09), entscheidet ui-designer mit Chris (kein Kriterium verlangt es). + +### 5.3 Melden (Krit. 1, 2, 3, 7, 8) + +**Ablauf im `ReportFormController`** (Riverpod-Notifier, Zustände als freezed-Union): + +| Zustand | Bedeutung | UI | +|---|---|---| +| `editing(draft, fieldErrors)` | Eingabe | Felder Titel (Pflicht), Verlag, Erscheinungsjahr, Link (optional), Auswahl „Gleich aufnehmen in“: Sammlung / Wunschliste / Einkaufsliste / nicht aufnehmen (Standard: Sammlung, Spec-F9) | +| `checking` | `find_similar_catalog_games` läuft | Button mit Ladeanzeige, Felder gesperrt | +| `similarFound(draft, candidates)` | Treffer nach 3.3 | Kandidatenliste (Titel, Jahr, Verlage, `GameCover`). Je Kandidat „Das ist mein Spiel“ → Detailseite des Katalogspiels (#4), keine Meldung. Darunter ausdrückliche Bestätigung: Checkbox „Mein Spiel ist keines davon“ + Button „Trotzdem melden“ (erst aktiv mit Haken). | +| `submitting` | `report_missing_game` läuft | Ladeanzeige | +| `submitted(result)` | gespeichert | Bestätigung (Krit. 1): „Danke, deine Meldung ist eingegangen. Wir prüfen sie von Hand; bis dahin kannst du das Spiel als vorläufigen Eintrag nutzen.“ Ggf. „Steht jetzt in deiner Sammlung/Wunschliste/Einkaufsliste“; Aktionen „Zur Meldung“, „Zur Sammlung“ (falls aufgenommen) | +| `failure(draft, failure)` | Fehler | Eingaben bleiben erhalten (Zustände Krit. 3). `dailyLimit` → Hinweis „Du hast heute schon 10 Spiele gemeldet. Morgen geht es wieder.“ (Krit. 7); `similarExists` (Rennen: Katalog hat sich geändert) → zurück zu `similarFound`; Feldfehler am Feld; offline → „Melden braucht eine Verbindung“ | + +Gibt es keine Treffer, geht `checking` direkt in `submitting` über (`p_confirmed_distinct = false`). Bei Treffern wird mit `true` gesendet. + +Das Formular prüft vor dem Senden lokal dieselben Regeln wie 4.2 (Titel Pflicht, Längen, Jahr, Link-Format), damit Fehler sofort am Feld stehen. Das Tageslimit prüft nur der Server. + +**Krit. 3 nachträglich:** Wurde „nicht aufnehmen“ gewählt, kann der Nutzer den vorläufigen Eintrag später auf der Detailseite der Meldung zu einer Liste hinzufügen. 13b nutzt dafür die Funktion „zur Sammlung/Liste hinzufügen“ aus SAM-01/SAM-04/SAM-05 (#6/#8/#9), falls vorhanden; sonst ein einfacher Insert in `collection_items` über das Repository (Spalten `owner_id`, `game_id`, `list`) mit Hinweis an #6, ihn später zu ersetzen (Spec-F8). + +### 5.4 Ergebnishinweise (Krit. 4, 5, 6, 8) + +`ReportOutcome` wird rein aus den Daten abgeleitet (`outcomeOf(report, provisional, collectionItemOnTarget)`, Unit-Tests je Fall): + +| Fall | Erkennung | Hinweis (Kurzform, Texte über ARB) | Aktionen | Wo sichtbar | +|---|---|---|---|---| +| `pending` (Krit. 8) | `status = open` | „Gemeldet am …, wird geprüft“ | – | Meldung; vorläufiger Eintrag (Badge „vorläufig“) | +| `approved` (Krit. 4) | `status = approved`, vorläufiger Eintrag `merged_into_id = target` | „Dein gemeldetes Spiel ist jetzt im Katalog. Deine Angaben wurden übernommen.“ | „Zum Spiel“, „Verstanden“ | Meldung; **Eintrag am Zielspiel** (Sammlung/Liste, Detailseite) | +| `merged` (Krit. 5) | `status = merged`, vorläufiger Eintrag `merged_into_id = target` | „Dein Spiel gab es schon im Katalog als „{title}“. Dein Eintrag zeigt jetzt dorthin.“ | „Zum Spiel“, „Verstanden“ | wie `approved` | +| `rejectedWithTarget` (Krit. 5) | `status = rejected`, `target_game_id` gesetzt, vorläufiger Eintrag nicht zusammengeführt | „Dein Spiel gibt es bereits als „{title}“.“ | „Zu „{title}“ wechseln“ (→ `adopt_report_target`, mit Bestätigungsdialog: „Deine Angaben werden auf den Katalogeintrag übertragen.“), „Vorläufig behalten“ | Meldung; vorläufiger Eintrag | +| `rejectedAdopted` | `status = rejected`, `target_game_id` gesetzt, vorläufiger Eintrag `merged_into_id = target` | wie `merged` | „Zum Spiel“ | wie `approved` | +| `rejected` (Krit. 6) | `status = rejected`, kein Ziel | Dauerhafte Markierung „Abgelehnt“ + Text „Dieses Spiel wird nicht in den Katalog aufgenommen. Dein Eintrag bleibt privat für dich.“ | „Verstanden“ (blendet nur den Text aus, die Markierung bleibt) | Meldung; vorläufiger Eintrag | + +**Zusatz Edition (E7, N15):** Bei `approved`, `merged` und `rejectedAdopted` zusätzlich „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“, wenn der vorläufige Eintrag mindestens eine eigene Variante hat **und** der Sammlungseintrag des Nutzers am Zielspiel `variant_id = null` hat. Aktion „Edition wählen“ führt zur Bearbeitung des Sammlungseintrags (KAT-03 #12 / SAM-02 #7), sobald vorhanden. Bei Partien wird nicht einzeln geprüft (Spec-F6). + +**„Verstanden“** setzt `result_seen_at` (4.5). Danach erscheint der Hinweis nicht mehr am Eintrag; in „Meine Meldungen“ bleibt der Status immer sichtbar. + +**Hinweis „am Eintrag“:** Provider `reportNoticeForGameProvider(gameId)` liefert den passenden `ReportOutcome` (oder keinen) für ein Spiel, an dem der Nutzer einen Eintrag hat: Meldungen mit `provisional_game_id = gameId` (vorläufiger Eintrag) oder `target_game_id = gameId` und `result_seen_at is null` (ersetzter Eintrag). Das Widget `ReportOutcomeNotice` (kompakt für Listenzeilen, ausführlich für Detailseiten) wird eingebaut + +- in 13b: in „Meine Meldungen“ und die Meldungsdetailseite, +- in die Detailseite eines Spiels (#4) und die Sammlungs-/Listenansicht (#6/#11), soweit diese bei der Umsetzung von 13b gemergt sind; sonst übernehmen #4/#6/#11 den Einbau (Spec-F8, Hinweis 9.4). + +Vorläufige Einträge werden in Listen mit einem Badge „vorläufig“ (Text, nicht nur Farbe) und dem `GameCover`-Platzhalter gezeigt. + +**Aktualität:** Kein Realtime in R1. Die Hinweise werden beim Öffnen der Liste/Detailseite und per Pull-to-Refresh neu geladen; das reicht, weil der Admin Meldungen selten und manuell bearbeitet. + +### 5.5 Meine Meldungen + +`MyReportsController` (AsyncNotifier mit Paging, 20 je Seite): Zustände Laden, Leer („Du hast noch keine Spiele gemeldet“ + Weg zur Suche), Fehler mit „Erneut versuchen“, Daten. Je Zeile: Titel, Jahr, Datum, Status-Badge (Text), ggf. kompakter Hinweis. Import-Meldungen (`origin = import`, SAM-08) erscheinen mit Kennzeichen „aus BGG-Import“ (Text liefert #25; 13b zeigt `origin` nur an, wenn vorhanden). + +### 5.6 l10n (Auszug, de/en) + +Neue ARB-Schlüssel mit Präfix `gameReport…`: Formularfelder und Fehler (`gameReportTitleRequired`, `gameReportTitleTooLong`, `gameReportPublisherTooLong`, `gameReportYearInvalid`, `gameReportLinkInvalid`), Ähnlichkeit (`gameReportSimilarHeading`, `gameReportSimilarIsMine`, `gameReportSimilarConfirmDistinct`, `gameReportSubmitAnyway`), Listenwahl, Bestätigung, Tageslimit (`gameReportDailyLimit`), Offline, Status-Badges (`gameReportStatusOpen` … `gameReportStatusRejected`, `gameReportProvisionalBadge`), Hinweise aus 5.4 inkl. Edition, Aktionen, Leer-/Fehlerzustand, Semantics-Labels. Platzhalter `{title}`, Datum über `intl`. + +## 6. Plattform-Besonderheiten + +- **iOS, Android, Web:** gleiches Verhalten. Link-Feld mit `TextInputType.url`, Jahr mit Ziffern-Tastatur; der Link wird in der App nur als Text angezeigt, nicht geöffnet (Datenschutz, 2). +- **Web:** Routen sind teilbare URLs; der Bestätigungsschritt bei ähnlichen Spielen ist per Tastatur bedienbar (Checkbox, Fokus-Reihenfolge); breites Layout: Formular und Kandidaten nebeneinander ab Breite ≥ 840 dp (ui-designer). +- **Offline:** Melden braucht eine Verbindung (wie SAM-01 Krit. 3); Hinweis statt Fehler, Eingaben bleiben im Formular. Keine Offline-Warteschlange für Meldungen. +- **Push, Kamera, Dateien:** nicht betroffen (kein Bild-Upload, keine Push in R1). + +## 7. Teststrategie + +Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (Web-E2E). Testnamen enthalten „KAT-04 Kriterium n“. + +**Neue pgTAP-Datei `supabase/tests/database/09_game_reports_user.test.sql`** (Nummer nach `08_game_reports.test.sql` aus 2c). Fixtures mit festen Test-UUIDs, zwei Nutzer A und B, unabhängig vom Startbestand. + +### 7.1 Abdeckungsmatrix + +| AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task | +|---|---|---|---|---|---| +| Krit. 1 | `GameReportDraft`: Titel Pflicht (auch nur Leerzeichen), Längen, Jahr, Link-Format; Fehlercode-Mapping | Formular: Feldfehler am Feld, Bestätigung nach Erfolg, Listenwahl | Playwright: leere Suche → „Spiel melden“ → Titel vorbelegt → absenden → Bestätigung | `report_missing_game` legt Meldung (`status = open`, `origin = user`) und vorläufiges Spiel (`private`, `owner_id = A`, nur Titel/Jahr) an; Trimmen; alle Validierungsfehler (`report_title_required` … `report_list_invalid`); `anon` → 42501 | B1, B2, F2, T1, T2 | +| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` beim Senden → zurück zu Kandidaten | Kandidatenliste, Bestätigungs-Checkbox aktiviert Button, Tastatur im Web, Semantics | Playwright: Meldung zu „Catan“ zeigt vorhandenes „CATAN“ | `normalize_game_title`: Beispiele aus 3.3 (Artikel de/en, Sonderzeichen, Umlaute bleiben, `ß`, nur Artikel); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr trifft; Trigramm ≥ 0,8 bei Jahr ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; Jahr fehlt → Regel nach Spec-F2; ausgeblendete, zusammengeführte und private Spiele (auch eigene) werden nicht geliefert; höchstens 5, Sortierung; `report_missing_game` ohne Bestätigung bei Treffer → `report_similar_exists`, mit Bestätigung gespeichert | B1, B2, F2, U1, T1, T2 | +| Krit. 3 | – | Bestätigung zeigt Liste; Badge „vorläufig“ | Playwright: gemeldetes Spiel steht in der Wunschliste (sofern #6/#8 vorhanden) | `p_list` legt `collection_items` zum vorläufigen Spiel an; B sieht weder vorläufiges Spiel noch Listeneintrag noch Meldung von A; `anon` sieht nichts; Rollback: schlägt der Listeneintrag fehl, gibt es weder Spiel noch Meldung | B1, B2, F2, F3 | +| Krit. 4 | `outcomeOf`: `approved` mit/ohne Edition-Hinweis | `ReportOutcomeNotice` für `approved` (de/en), „Verstanden“ blendet aus | integration_test/Playwright: Meldung per SQL-Hilfsfunktion im Testsetup mit `admin_resolve_report(…, 'approved', …)` bearbeiten → Hinweis am Eintrag | nach `admin_resolve_report` (2c) ist der Sammlungseintrag von A am Zielspiel mit Liste/Notiz/Kaufdatum; vorläufige Variante → `variant_id` null, private Variante bleibt am vorläufigen Eintrag und ist für A lesbar (E7); `result_seen_at` setzbar | B2, F3, T1, T2 | +| Krit. 5 | `outcomeOf`: `merged`, `rejectedWithTarget`, `rejectedAdopted` | Hinweise und Aktion „wechseln“ mit Bestätigungsdialog | Playwright: abgelehnt mit Verweis → wechseln → Eintrag zeigt Zielspiel | `adopt_report_target`: Erfolg hängt Sammlung und Partien von A um, setzt `merged_into_id`/`hidden_at`, Status bleibt `rejected`, `result_seen_at` gesetzt; Fehler `report_not_found` (fremde Meldung von B, unbekannte ID), `report_not_adoptable` (`open`, `approved`, `rejected` ohne Ziel), `report_already_adopted` (zweiter Aufruf), `report_target_not_listed` (Ziel inzwischen ausgeblendet), `report_foreign_references` (Fixture als `postgres`); `anon` → 42501 | B1, B2, F3, T1, T2 | +| Krit. 6 | `outcomeOf`: `rejected` ohne Ziel | dauerhafte Markierung „Abgelehnt“ bleibt nach „Verstanden“ | – | nach `admin_resolve_report(…, 'rejected')` bleibt das vorläufige Spiel unverändert privat und für A lesbar | B2, F3, T2 | +| Krit. 7 | Mapping `report_daily_limit` → `dailyLimit` | Limit-Hinweis, Eingaben bleiben erhalten | – | 10 Meldungen am selben Berliner Kalendertag ok, 11. → `report_daily_limit` und **kein** vorläufiges Spiel übrig (Rollback); Meldungen von gestern (Berliner Zeit, Fixture mit `created_at` als `postgres` gesetzt) zählen nicht; Grenzfall 23:30 UTC (= nächster Tag in Berlin im Winter); `origin = 'import'` als `postgres` eingefügt zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; direkter Insert mit `created_at`, `origin` oder `status` → 42501 | B1, B2, F2, T1 | +| Krit. 8 | – | Status „offen“ in Liste und Detail | – | Nutzer kann `status`, `target_game_id`, `resolved_at` weder per Insert noch per Update setzen (42501); Update von `result_seen_at` bei `status = open` → 0 Zeilen; nach dem Insert ist der Status `open`; kein Trigger/Job ändert ihn | B2, T1 | +| Sicherheit Krit. 1, 3 | – | – | – | B liest A's Meldungen und vorläufige Spiele nicht; B kann A's Meldung nicht wegklicken (0 Zeilen) und nicht übernehmen; Insert mit `owner_id = A` als B → Policy-Fehler; `provisional_game_id` auf ein fremdes, ein Katalog- oder ein ausgeblendetes Spiel → Policy-Fehler; Spaltenrechte per `has_column_privilege` | B2 | +| Sicherheit Krit. 2 | – | – | – | `anon`: kein `select`, `insert`, `update` auf `game_reports`, keine RPC ausführbar | B2 | +| Datenschutz | – | – | – | Konto von A löschen → Meldungen, vorläufige Spiele, Listeneinträge weg; Meldungen und Spiele von B unverändert | B2 | +| QUE-05/06, Zustände | – | Alle Screens de/en, `meetsGuideline` (Tap-Ziele, Kontrast, Labels), `textScaler` 2.0 ohne Überlauf, Lade-/Leer-/Fehler-/Datenzustand „Meine Meldungen“, Offline-Hinweis | Playwright: Tastaturbedienung des Bestätigungsschritts | – | F2, F3, T2 | + +**Nicht automatisiert:** echte Parallelität des Limits (die Sperre wird per Code-Review und einem dokumentierten manuellen Test mit zwei `psql`-Sitzungen geprüft); Bearbeitung echter Meldungen im Studio auf Staging (Chris). + +**Bestehende Tests:** `08_game_reports.test.sql` aus 2c erwartet „kein Insert für `authenticated`“; 13a passt diesen Test auf die neuen Spaltenrechte an (B2). `00_schema.test.sql` bleibt bei 21 Tabellen. + +## 8. Aufgaben + +### 8.1 PR 13a – Backend (`Refs #13`), Branch `feature/13a-meldungen-backend` + +| # | Agent | Aufgabe | abhängig von | +|---|---|---|---| +| B1 | backend-dev | Migration `_game_reports_user.sql` nach 3.2–4.5: `pg_trgm`, `normalize_game_title` (oder Wiederverwendung aus #3, 3.3), Spalte `result_seen_at` mit Check, Indizes, Spaltenrechte, Policies `game_reports_insert_own` und `game_reports_update_seen`, Trigger `game_reports_daily_limit` (Berliner Kalendertag, Sperre je Nutzer, nur `origin = 'user'`), RPCs `find_similar_catalog_games`, `report_missing_game`, `adopt_report_target`; Rechte nach 4.1 | #2 PR 2c gemergt | +| B2 | backend-dev | pgTAP `09_game_reports_user.test.sql` nach 7.1; `08_game_reports.test.sql` an die neuen Insert-Rechte anpassen | B1 | +| B3 | backend-dev | Doku nach 4.7 (`datenmodell-r1.md`, `katalog-pflege.md` Abschnitt Meldungen, `supabase/README.md`) | B1 | +| T1 | test-engineer | Review der pgTAP-Abdeckung gegen 7.1; manueller Paralleltest des Limits (zwei Sitzungen) dokumentieren | B2 | + +### 8.2 PR 13b – Flutter (`Closes #13`), Branch `feature/13b-meldungen-app` + +| # | Agent | Aufgabe | abhängig von | +|---|---|---|---| +| U1 | ui-designer | Entwurf Formular, Kandidatenschritt mit ausdrücklicher Bestätigung, Bestätigung, „Meine Meldungen“, Meldungsdetail, `ReportOutcomeNotice` (kompakt/ausführlich), Badge „vorläufig“, breites Web-Layout; Texte de/en; Einstieg zu `/reports` mit Chris abstimmen | Spec freigegeben | +| F1 | flutter-dev | Domäne (5.1): Modelle, `ReportOutcome` + `outcomeOf`, `GameReportDraft`-Validierung, Fehler, Repository-Schnittstelle; Unit-Tests | Spec freigegeben (parallel zu B1) | +| F2 | flutter-dev | Data-Schicht (RPC-Aufrufe mit expliziten Parametern, kein `toJson()` an Schreibzugriffe; Fehler-Mapper) und Melden-Ablauf (5.3) inkl. Routen, Einstieg aus der leeren Suche (#3), l10n | F1, U1; gegen echtes Backend: B1 | +| F3 | flutter-dev | „Meine Meldungen“, Meldungsdetail, Hinweise (5.4) inkl. „Verstanden“, „wechseln“, Edition-Hinweis; Einbau von `ReportOutcomeNotice` in #4/#6/#11, soweit gemergt (sonst Hinweis-Kommentar in den Issues) | F1, F2 | +| T2 | test-engineer | Widget-Tests nach 7.1 (alle Zustände, alle Hinweisfälle, de/en, a11y, `textScaler` 2.0); Playwright-Ablauf Krit. 1–5 gegen lokales Supabase, Bearbeitung der Meldung im Testsetup per SQL als `postgres` | F2, F3, B1 | + +### 8.3 Reihenfolge und Parallelität + +``` +Vorbedingung: #2 PR 2b → 2c gemergt; #3 KAT-01 gemergt (Einstieg, ggf. Normalisierung) +13a: B1 → B2 → B3, T1 +13b: U1 ─┐ + F1 ─┴→ F2 (Mock-Repository, danach gegen B1) → F3 → T2 +13b wird erst nach 13a gemergt (RPC-Vertrag muss in main sein). +``` + +F1 und U1 starten sofort nach Freigabe der Spec, parallel zu B1. 13b schließt #13. Merge nur durch Chris. + +## 9. Risiken & offene Fragen + +### 9.1 Risiken + +| # | Risiko | Auswirkung | Gegenmaßnahme | +|---|---|---|---| +| R1 | 2b/2c weichen bei der Umsetzung von den in Spec #2 beschriebenen Namen oder Rückgaben ab | 13a passt nicht | B1 liest die gemergten Migrationen zuerst; Abweichungen im PR nennen | +| R2 | `adopt_report_target` ist `security definer` und erreicht `repoint_game_references`, das RLS umgeht | Fehler würden fremde Daten berühren | Strenge Prüfungen (4.4) inkl. Prüfung auf fremde Verweise, `for update`, nur eigene private, nicht zusammengeführte Spiele; pgTAP-Negativfälle; Code-Review mit Fokus Sicherheit | +| R3 | Trigramm-Schwelle 0,8 ist bei kurzen Titeln streng oder bei Reihen („Catan“, „Catan: Seefahrer“) großzügig | Ähnliche werden nicht gezeigt bzw. zu viele | Klärung 15 ist verbindlich; Kandidatenschritt blockiert nicht, er verlangt nur eine Bestätigung; Erfahrungen nach R1 sammeln | +| R4 | `[:alnum:]` hängt von der Datenbank-Locale ab | Umlaute würden entfernt, Normalisierung falsch | Test mit Umlauten in pgTAP; Rückfall auf explizite Zeichenklasse (3.3) | +| R5 | Edition-Hinweis per Ableitung (E7) zeigt auch dann „Edition neu wählen“, wenn der Nutzer nie eine Edition gewählt hatte, aber eine private Variante existiert | Unnötiger Hinweis | Selten (vorläufige Einträge haben anfangs keine Varianten); wegklickbar; Alternative in Spec-F6 | +| R6 | Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11, die evtl. noch fehlen | Krit. 4–6 nur in „Meine Meldungen“ sichtbar | Spec-F8: Abhängigkeiten setzen oder Einbau dort nachziehen | +| R7 | Nutzer melden das gleiche Spiel mehrfach (eigene offene Meldungen werden nicht geprüft, Spec-F7) | Mehr Arbeit für Chris, doppelte vorläufige Einträge | Tageslimit; Chris lehnt Duplikate mit Verweis ab | +| R8 | Kein Realtime | Ergebnis erscheint erst beim nächsten Laden | Akzeptiert (manuelle Bearbeitung, kein Zeitdruck) | + +### 9.2 Offene Fragen an Chris (Spec-F1 bis Spec-F9) + +Keine Frage blockiert die Umsetzung: Für jede gibt es eine umsetzbare Empfehlung, die mit der Freigabe der Spec gilt, falls Chris nichts anderes entscheidet. + +| # | Frage | Empfehlung | Begründung | +|---|---|---|---| +| Spec-F1 | **Krit. 1:** Sind Verlag und Erscheinungsjahr Pflicht oder optional? Der Text nennt nur den Titel als Pflicht. | **Optional**, nur der Titel ist Pflicht. | Nutzer kennen das Jahr oft nicht; eine Pflicht würde zu geratenen Werten führen. 2c hat beide Spalten nullable. | +| Spec-F2 | **Krit. 2:** Wie gilt die Trigramm-Regel, wenn das Jahr in der Meldung oder im Katalogeintrag fehlt? | Trigramm ≥ 0,8 **ohne Jahresbedingung**. | Lieber einmal mehr nachfragen als ein Duplikat; der Schritt verlangt nur eine Bestätigung. | +| Spec-F3 | **Krit. 2 „Artikel und Sonderzeichen ignoriert“:** Welche Artikel, wo, und zählen Umlaute/Akzente als Sonderzeichen? | Nur **führende** Artikel auf Deutsch und Englisch (Liste in 3.3); Umlaute und Akzente **bleiben** Buchstaben, `ß` = `ss`; alle anderen Zeichen werden ignoriert. | Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“). Akzent-Faltung (`unaccent`) ist nicht `immutable` und damit für Index und gemeinsame Funktion ungeeignet; der Trigramm-Vergleich fängt „Molkky/Mölkky“ meist trotzdem. | +| Spec-F4 | **Krit. 7 „heute“:** Kalendertag in welcher Zeitzone, oder gleitende 24 Stunden? | **Kalendertag in Europe/Berlin.** | Entspricht „heute“ im Kriterium und ist für Nutzer verständlich („morgen geht es wieder“). Eine Profil-Zeitzone gibt es nicht. | +| Spec-F5 | **Krit. 4–6:** Wie lange bleibt der Hinweis am Eintrag sichtbar? | **Bis der Nutzer „Verstanden“ wählt** (`result_seen_at`). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft; in „Meine Meldungen“ bleibt der Status immer sichtbar. | Ein dauerhafter Hinweis an einem längst ersetzten Eintrag stört; ein automatisches Ausblenden nach Zeit könnte übersehen werden. | +| Spec-F6 | **N15, Edition:** Woran erkennt die App, dass die Edition geleert wurde? | **Ableitung** (E7): vorläufiger Eintrag hat eine eigene Variante und der Eintrag am Ziel hat keine Edition. Alternative: Spalte `game_reports.edition_cleared`, gesetzt von `admin_resolve_report`/`adopt_report_target`; dafür müsste `repoint_game_references` (2b) zusätzlich zählen, wie viele **Verweise** geleert wurden, und 2c bzw. 13a die Funktionen anpassen. | Die Ableitung braucht keine Änderung an den bereits freigegebenen Teilen 2b/2c. Die Alternative ist genauer und lohnt sich, wenn 2b/2c ohnehin noch nicht gebaut sind; dann sollte die Änderung in Spec #2 nachgetragen werden, nicht in 13a per `create or replace`. | +| Spec-F7 | **Krit. 2:** Sollen auch eigene offene Meldungen bzw. eigene vorläufige Einträge mit ähnlichem Titel angezeigt werden („Du hast dieses Spiel schon gemeldet“)? | **Nein in R1.** | Kriterium nennt nur Katalogeinträge; Duplikate begrenzt das Tageslimit, Chris lehnt sie mit Verweis ab. | +| Spec-F8 | **Abhängigkeiten:** Krit. 3 („in Sammlung oder Listen“) und der Hinweis „am Eintrag“ (Krit. 4–6) leben in Screens von SAM-01 (#6), KAT-02 (#4) und SAM-03 (#11). Sollen #4 und #6 als Forgejo-Abhängigkeit von #13 gesetzt werden? | **Ja, #13 hängt zusätzlich von #4 und #6 ab** (beide liegen in der Reihenfolge ohnehin vor #13). #11 nicht: Den Einbau des Hinweises in die durchsuchbare Sammlung übernimmt #11, falls es später kommt. | Sonst müsste #13 eigene Zwischenlösungen für Listeneintrag und Detailseite bauen, die später ersetzt werden. | +| Spec-F9 | **Krit. 3:** Welche Liste ist im Formular vorausgewählt? | **Sammlung**, änderbar auf Wunsch-/Einkaufsliste oder „nicht aufnehmen“. | Häufigster Fall: Nutzer will ein Spiel erfassen, das er besitzt (Story: „Katalogloch blockiert beim Erfassen“). | + +### 9.3 Entscheidungen innerhalb der Architektur (zur Kenntnis, kein ADR nötig) + +- RPC als Bündelung mit `security invoker`, Rechte in der Datenbank (E2, E3); nur `adopt_report_target` ist `security definer` (E5). +- Kein Realtime, keine Edge Function, keine neuen Pakete (E9, R8). +- Kein ADR: Die Entscheidungen bleiben innerhalb von ADR-0001/0002 und sind ohne großen Aufwand änderbar. + +### 9.4 Hinweise für andere Stories + +- **KAT-01 (#3):** Einstieg „Spiel melden“ aus der leeren Trefferliste mit `/reports/new?title=` (Krit. 6); Normalisierung `normalize_game_title` für die Suche mitnutzen (3.3); legt #3 alternative Titel an, auch in `find_similar_catalog_games` berücksichtigen. +- **KAT-02 (#4), SAM-01 (#6), SAM-03 (#11):** `ReportOutcomeNotice` und `reportNoticeForGameProvider` am Eintrag einbauen; vorläufige Einträge mit Badge „vorläufig“; eine Detailansicht für ein eigenes privates Spiel (nicht in `catalog_games`) lesen sie direkt aus `games`. +- **KAT-03 (#12):** Aktion „Edition wählen“ aus dem Edition-Hinweis anbinden. +- **KTO-02 (#14):** Die Policy `favorite_games_own` erlaubt derzeit jedes sichtbare Spiel, also auch eigene vorläufige; Klärung 23 verlangt nur Katalogspiele. #14 muss die Policy (neue Migration) auf `visibility = 'catalog'` einschränken. +- **PAR-01 (#15):** vorläufige Spiele als wählbare Spiele anbieten (Krit. 3, Klärung 36), mit Badge. +- **KTO-05 (#23):** Export enthält `game_reports` (inkl. Status und Link) und vorläufige Spiele; Löschen per Kaskade ist durch pgTAP in 13a abgedeckt. +- **SAM-08 (#25):** Import-Meldungen mit `origin = 'import'` und `bgg_id`, einem vorläufigen Eintrag je Meldung (E1); der Limit-Trigger ignoriert sie (N12); die Hinweise aus 5.4 gelten unverändert; Kennzeichen „aus BGG-Import“ in „Meine Meldungen“ (5.5). + +### 9.5 Ausstehende Prozessschritte + +- Freigabe der Spec und Antworten zu Spec-F1 bis Spec-F9 durch Chris (Label `status/spec-review`). +- Bei Spec-F6 Alternative: Nachtrag in Spec #2 (2b/2c) vor deren Umsetzung. +- Bei Spec-F8 Ja: Forgejo-Abhängigkeiten #13 → #4, #6 setzen (Chris oder Orchestrator). + +## 10. Änderungsprotokoll + +### 2026-11-03 – Entwurf + +Erste Fassung durch architect. Berücksichtigt den Hinweis aus der KAT-05-Spec (Issue-Kommentar vom 2026-11-03, Klärung N15) zur eingeschränkten Erhaltung der Edition bei Krit. 4. From 2c2546ab472d796e2253fcf5f08302801a8e600f Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:49:42 +0100 Subject: [PATCH 06/30] docs(db): runbook section merging, data model and spec notes (KAT-05, 2b) Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/datenmodell-r1.md | 2 +- docs/specs/2-katalog-daten.md | 15 +++++++++++++++ supabase/README.md | 1 + supabase/tests/database/07_catalog_merge.test.sql | 4 ++-- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/datenmodell-r1.md b/docs/datenmodell-r1.md index 2e0d958..d4bba07 100644 --- a/docs/datenmodell-r1.md +++ b/docs/datenmodell-r1.md @@ -342,7 +342,7 @@ Drei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Av Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle `postgres` (Klärungen 25/44, Ablauf: `docs/katalog-pflege.md`). Spec: `docs/specs/2-katalog-daten.md`. - **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf). -- **Zusammenführen:** `games.merged_into_id` zeigt auf das Zielspiel (Spalte seit 2a; die Funktion `admin_merge_games` kommt mit PR 2b). Ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). +- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). Gesetzt wird die Spalte nur von der Funktion `public.admin_merge_games(p_source, p_target) returns jsonb` (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu. - **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder. - **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import). - **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`. diff --git a/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index b2e0134..db2e5c0 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -703,3 +703,18 @@ Umgesetzt: B1, B2, B3a, B3, B4 und die beiden CI-Schritte aus D1 (Deno-Tests und - **Ergänzung Testdaten:** `supabase/catalog/testdata/` (fiktives Vokabular und drei Testspiele) erzeugt `supabase/tests/support/*.inc`, die `06_catalog_foundation.test.sql` per `\ir` einbindet; so laufen Idempotenz und `checks.sql` im pgTAP-Test mit den echten erzeugten Skripten. - **`game_links_mark_manual`:** prüft zuerst per `select`, ob das Spiel ein Katalogspiel ist, und schreibt nur dann `manual_fields`. Ohne diese Prüfung scheiterte jede Zuordnung zu einem privaten Spiel mit 42501, weil Nutzer kein Schreibrecht auf `manual_fields` haben. - **Werkzeug:** Deno wird für das Skript gebraucht (lokal 2.9.8, in CI gepinnt über `DENO_VERSION`). Keine externen Importe. + +### 2026-11-03 – Umsetzung PR 2b (backend-dev) + +Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_catalog_merge.test.sql`, 89 Tests), B7 (Runbook Abschnitt 5). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR: + +- **Branch:** `feature/2b-katalog-zusammenfuehren` statt `feature/2-katalog-pflege` (Vorgabe des Auftrags). +- **Keine neue Tabelle:** `00_schema.test.sql` bleibt unverändert (20 Tabellen). +- **Ergebnis-JSON (festgelegt):** `collection_items` (ohne Kollision umgehängt), `collection_conflicts`, `favorite_games`, `favorite_conflicts`, `plays`, `score_sheets`, `variants_copied`, `variants_mapped`, `variants_cleared`; `admin_merge_games` ergänzt `source_id`, `target_id`, `bgg_id_transferred`, `source_transferred`, `chain_flattened` und bei Konflikt `bgg_id_conflict` / `source_conflict`. **`variants_cleared` zählt Verweise** (Sammlungseinträge und Partien, deren Edition geleert wurde), nicht Varianten; so erkennt der Admin und später #13, ob bei einem Nutzer eine Edition verloren ging. Beim Zusammenführen zweier Katalogspiele ist er immer 0. `source_conflict` enthält zusätzlich `source_synced_at` (alle vier Werte der Gruppe bleiben erhalten). +- **Zusätzliche Prüfung:** `admin_merge_games(null, …)` / `(…, null)` meldet `merge_game_not_found`. `admin_merge_games` sperrt beide Zeilen in fester Reihenfolge (`for update`), damit parallele Aufrufe nicht verklemmen. +- **Variantenzuordnung** ist ein jsonb-Wert in der Funktion, keine temporäre Tabelle (mehrere Aufrufe in einer Transaktion bleiben möglich). Der Namensabgleich läuft nur gegen die Zielvarianten, die vor dem Aufruf existierten; jede Quellvariante wird einzeln kopiert, auch wenn zwei Quellvarianten gleich heißen (wörtliche Auslegung von 3.3). +- **Kollisionsregel Spec-F1** füllt nur `note`, `acquired_on` und `variant_id`; die Felder aus SAM-02/SAM-04 gibt es noch nicht (Hinweis 9.5 bleibt). Eine leere Notiz (`null` oder leerer Text) am Ziel gilt als leer. +- **`repoint_game_references` prüft nicht, ob `p_from` existiert** (nur die beiden Fehler aus 3.3); `admin_merge_games` prüft es. Der Fall „privater Eintrag auf Katalogspiel“ ist in 2b nur direkt gegen `repoint_game_references` getestet; `admin_resolve_report` kommt mit 2c. +- **Schritt 4 aus 3.3** (`game_reports.target_game_id` umhängen) entfällt bis 2c, wie vorgesehen. +- **Hinweis für 2c (B9):** Sobald `game_reports` zwei Fremdschlüssel auf `games` bekommt, wird der Wächter in `07` rot. Die erwartete Liste in `07` muss dort um `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (bewusst ausgelassen) erweitert werden, nicht nur der Wächter in `08`. +- **Runbook:** Alle SQL-Bausteine aus Abschnitt 5 laufen wörtlich in `07` (einmalige Prüfung per Skript; keine CI-Prüfung). Abschnitt 5 enthält die Duplikatsuche, eine Vorschau (Verweise, Doppelbesitz), den Aufruf, die Nachprüfung, die Regeln, die Schlüssel des Ergebnis-JSON und die Fehlertexte. diff --git a/supabase/README.md b/supabase/README.md index 3a4af99..6f67b9d 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -32,6 +32,7 @@ supabase test db # alle pgTAP-Tests ausführen ## Katalog (KAT-05) - Pflege in R1 nur über Studio bzw. `psql` (Rolle `postgres`), Ablauf und Regeln: [`docs/katalog-pflege.md`](../docs/katalog-pflege.md). Spec: `docs/specs/2-katalog-daten.md`. - Suche und Entdecken lesen nur die View `catalog_games` (ohne ausgeblendete und zusammengeführte Spiele). Geänderte Fakten trägt ein Trigger in `games.manual_fields` ein. +- Zusammenführen von Duplikaten nur über die Funktion `public.admin_merge_games(quelle, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`, nicht über die API aufrufbar); Ablauf in `docs/katalog-pflege.md`, Abschnitt 5. Jede neue Tabelle mit Verweis auf `games` muss dort behandelt werden (Wächter in `tests/database/07_catalog_merge.test.sql`). - `db reset` spielt `seed.sql`, `catalog/vocabulary.sql` und `catalog/startbestand.sql` ein (`[db.seed] sql_paths` in `config.toml`). Die SQL-Dateien in `catalog/` sind **erzeugt**; Quellen sind die CSV-Dateien. Neu erzeugen und testen (Deno, `brew install deno`): ```bash deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql index e157d3e..28f690f 100644 --- a/supabase/tests/database/07_catalog_merge.test.sql +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -202,9 +202,9 @@ select set_eq($$select owner_id, liste_quelle, liste_ziel from rb_dbl$$, -- Runbook 5, Schritt 3: Zusammenführen create temp table rb_merge as -select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003') as ergebnis; +select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); -select is((select ergebnis from rb_merge), +select is((select admin_merge_games from rb_merge), jsonb_build_object( 'source_id', '7a1b2c3d-0000-4000-8000-000000000002', 'target_id', '7a1b2c3d-0000-4000-8000-000000000003', 'collection_items', 1, 'collection_conflicts', 3, 'favorite_games', 1, 'favorite_conflicts', 1, From 8b52a410626fe287adeb00ac37f17d249d82d105 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 19:50:56 +0000 Subject: [PATCH 07/30] =?UTF-8?q?docs(catalog):=20Spec=20f=C3=BCr=20KAT-01?= =?UTF-8?q?=20Spiele=20suchen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/3-katalog-suche.md | 532 ++++++++++++++++++++++++++++++++++ 1 file changed, 532 insertions(+) create mode 100644 docs/specs/3-katalog-suche.md diff --git a/docs/specs/3-katalog-suche.md b/docs/specs/3-katalog-suche.md new file mode 100644 index 0000000..065e626 --- /dev/null +++ b/docs/specs/3-katalog-suche.md @@ -0,0 +1,532 @@ +# Spec #3 – KAT-01 Spiele suchen + +| | | +|---|---| +| Issue | #3 (KAT-01) | +| Status | Entwurf, wartet auf Freigabe durch Chris (Spec-F1 bis Spec-F7 in § 9.2 offen, keine blockiert) | +| Autor | architect | +| Stand | 2026-11-03 | +| Etappe | R1.1 Fundament | +| Abhängigkeiten | #2 KAT-05 Teil 2a (gemergt: `catalog_games`, `publishers`, `game_publishers`, `manual_fields`, `GameCover`, Startbestand-Werkzeug). Teilabhängigkeit: Krit. 6 verweist auf #13 KAT-04 (später). Von #3 hängen ab: #4 KAT-02 (Einstieg in die Detailseite), #13 KAT-04 (Melden aus dem Leerzustand), #29 QUE-09 (Startseite ohne Anmeldung zeigt die Suche), #14 KTO-02 (Lieblingsspiele per Katalogsuche). | +| Quellen | Issue #3 inkl. Kommentare, `requirements/20-r1-akzeptanzkriterien.md` § KAT-01 und § Querschnitt, `requirements/r1-klaerungen.md` (3, 4, 13, 14, 15, 36, O6, O7), `requirements/backlog.md` (Datenmodell-Änderungen 3, 4), `docs/specs/2-katalog-daten.md` (E3, E4, E6, 3.2, § 5, 9.5), `docs/datenmodell-r1.md`, `supabase/migrations/20261102100200_catalog.sql`, `…/20261103142204_catalog_foundation.sql`, `supabase/catalog/build_startbestand.ts`, `app/lib/core/widgets/game_cover.dart`, `app/lib/core/routing/*`, `app/lib/features/auth/presentation/providers/auth_redirect.dart`, `docs/design-system.md`, `e2e/README.md`, ADR-0001, ADR-0002 | + +**Namenskonvention:** „Spec-F1“ bis „Spec-F7“ sind die Fragen an Chris aus § 9.2. „E1“ usw. sind Kernentscheidungen dieser Spec. „Spec #2 E3“ verweist auf Entscheidungen der Katalog-Spec. „B1“, „F1“, „U1“, „T1“, „D1“, „C1“ sind Aufgaben (§ 8). + +## 1. Ziel + +Jeder, auch ohne Anmeldung (O6), findet ein Spiel im Katalog schnell über Titel, alternativen Titel oder Verlag und kann den Katalog über Filter (Spielerzahl, Spieldauer, Mindestalter, Genre, Mechanik) auch ohne Suchbegriff entdecken. Die Suche ist die Grundlage für spätere Einstiege: Detailseite (#4), Melden (#13), Startseite ohne Anmeldung (#29), Lieblingsspiele (#14), Sammlung und Partien. + +Nicht im Scope (Issue): Barcode-Suche, Offline-Katalog (O7), Empfehlungen. Ebenfalls nicht hier: Detailseite (#4), Melden-Formular (#13), Startseite (#29), Tippfehler-Toleranz („Catna“ → „Catan“), Suche in Beschreibungstexten. + +### Kernentscheidungen (Kurzfassung) + +| # | Entscheidung | Begründung | +|---|---|---| +| E1 | **Eine RPC `search_catalog_games`** (PostgreSQL-Funktion, `security invoker`, `stable`) liefert Treffer, Gesamtzahl und Verlage in **einer** Anfrage. Sie liest über die View `catalog_games` (Spec #2 E3), damit ausgeblendete und zusammengeführte Spiele nie erscheinen und die Anzeige-Maskierung (Spec #2 E6) gilt. | Relevanz über Titel, alternative Titel und Verlage, UND-Filter über Zuordnungstabellen und Normalisierung lassen sich mit PostgREST-Filtern auf der View nicht sauber ausdrücken. Eine Anfrage je Seite hält Krit. 7 (100 ms RTT) ein. `security invoker` heißt: RLS gilt für den Aufrufer, die Funktion umgeht nichts. | +| E2 | **Alternative Titel als eigene Tabelle `game_alt_titles`** (Titel + Sprache `de`/`en`), nur lesbar für Clients, Pflege über Studio und das Startbestand-Werkzeug (Spalte `alt_titles` der CSV, Spec #2 4.3). Neuer `manual_fields`-Schlüssel `alt_titles`. | Klärung 3, Backlog „Datenmodell-Änderungen“ Nr. 3. Nutzer haben in R1 keinen Anwendungsfall zum Schreiben; ohne Schreibrecht entfällt die Angriffsfläche. | +| E3 | **Normalisierung in der Datenbank:** eine Funktion `public.search_normalize(text)` (Kleinschreibung, Akzente und Umlaute entfernt, alles außer `a–z`/`0–9` wird zu einem Leerzeichen). Ergebnis als **gespeicherte generierte Spalten** `games.title_search`, `game_alt_titles.title_search`, `publishers.name_search` mit Trigramm-Index. | Krit. 1 „unabhängig von Groß- und Kleinschreibung und Sonderzeichen“. Eine zentrale Funktion statt Logik im Client; #13 nutzt sie für „gleicher normalisierter Titel“ (Klärung 15) wieder. Generierte Spalten können nicht vergessen werden und brauchen keine Trigger. | +| E4 | **Treffer-Regel:** Der Suchbegriff wird in Wörter zerlegt; **jedes Wort** muss als Teilzeichenkette im Titel, in einem alternativen Titel oder in einem Verlagsnamen des Spiels vorkommen. Zusätzlich trifft der Begriff ohne Leerzeichen („7wonders“) den Titel ohne Leerzeichen. | Krit. 1 („ein Titel oder ein Teil davon“), Krit. 2, Krit. 3 in einem Suchfeld. „siedler catan“ findet „Die Siedler von Catan“, „catan kosmos“ findet Catan von Kosmos. | +| E5 | **Relevanz als feste Stufen** (genau gleich → beginnt mit → Wortanfang → enthält → nur über Verlag), danach Trigramm-Ähnlichkeit, Titel, ID. | Krit. 1 „nach Relevanz sortiert“ wird damit testbar und deterministisch; die eindeutige Reihenfolge ist Voraussetzung für stabiles Nachladen (Krit. 5). | +| E6 | **Nachladen über `limit`/`offset`**, Seitengröße 30, Gesamtzahl über `count(*) over ()`. | Der Katalog ändert sich selten (Studio-Pflege), die Reihenfolge ist eindeutig (E5). Keyset-Paging über eine zusammengesetzte Relevanz wäre deutlich komplexer ohne spürbaren Nutzen bei einigen hundert bis tausend Spielen. | +| E7 | **Filter sind UND-verknüpft**, auch mehrere gewählte Genres oder Mechaniken untereinander (Spec-F1). Filter auf Spielerzahl, Dauer und Alter schließen Spiele mit fehlender Angabe aus (Spec-F2). | Wörtliche Lesart von Krit. 4/8 „zu allen gewählten Filtern passen“ (Klärung 13). | +| E8 | **Ohne Suchbegriff und ohne Filter zeigt die Suche den ganzen Katalog alphabetisch** (Spec-F3). | Krit. 8 (Entdecken) ohne Sonderfall: „keine Filter“ ist der leere Filter. Bei ~200 Spielen nützlich zum Stöbern. | +| E9 | **Neues Feature `features/catalog/`** mit Domäne (`CatalogGameSummary`, `CatalogSearchQuery`, `CatalogTerm`, `CatalogFailure`), Repository und einem Notifier mit eigenem Seitenzustand. **Alle Datenklassen sind nur lesend** (`createToJson: false`); das Feature schreibt nichts in `games`. | Merkpunkt aus Spec #2 § 10: Kein ungefiltertes `toJson()` darf an `insert`/`update` gehen. Ohne `toJson` ist das hier nicht nur dokumentiert, sondern unmöglich. | +| E10 | **Öffentliche Route `/catalog`** mit Suchbegriff und Filtern als Query-Parameter (Web-Deep-Links). Die Startseite bekommt nur einen Einstieg; die Startseite ohne Anmeldung baut #29. | Web-URLs (ADR-0001, go_router); `privateRoutePaths` bleibt leer (Spec #1). | +| E11 | **Krit. 6 in #3 als Leerzustand mit Hinweis und Einstiegspunkt**; die Aktion „Spiel melden“ wird mit #13 freigeschaltet (Spec-F4). | Issue: „Teilabhängigkeit: Kriterium 6 verweist auf KAT-04 (#13), das später umgesetzt wird“. | +| E12 | **Krit. 7 wird mit einem eigenen, nur lesenden Playwright-Projekt `perf` gegen Staging gemessen** (Chrome-Drosselung 100 ms / 10 Mbit/s, mindestens 100 Suchen, Messung vom letzten Tastendruck bis zum ersten sichtbaren Treffer). Datenbank-Budget: p95 ≤ 150 ms bei 10 000 Spielen. | Klärung 14. Die bestehenden E2E-Helfer (`db.ts`, `E2E_ALLOWED_HOSTS`) sind bewusst nur lokal; Staging wird nur über die Web-App angesprochen. | +| E13 | **Drei PRs:** 3a Backend, 3b App (parallel gegen den Vertrag aus § 4.2), 3c E2E und Messung. Nur 3c schließt das Issue. | Wie bei #2 (PR #52/#53); 3b kann mit gemocktem Repository sofort beginnen. | +| E14 | **Keine neuen Dart-Pakete, keine Edge Function.** Neu sind nur die PostgreSQL-Erweiterungen `pg_trgm` und `unaccent` (Bestandteil des Supabase-Postgres-Images, auch self-hosted; Prüfung in B5). | CLAUDE.md, ADR-0002. Kein ADR nötig (§ 9.4). | + +## 2. Akzeptanzkriterien + +Übernommen aus Issue #3 / `20-r1-akzeptanzkriterien.md` § KAT-01 (Stand 2026-11-02 inkl. Klärungen). Referenz in Tests und PRs: „KAT-01 Kriterium n“. + +1. Gegeben der Katalog enthält Spiele, wenn ich einen Titel oder einen Teil davon eingebe, dann erhalte ich passende Treffer nach Relevanz sortiert, unabhängig von Groß- und Kleinschreibung und Sonderzeichen. +2. Gegeben ein Spiel hat neben dem Haupttitel alternative Titel (je mit Sprache, zum Beispiel einen deutschen und einen englischen), wenn ich nach einem davon suche, dann finde ich dasselbe Spiel (Klärung 3). +3. Gegeben ich suche nach einem Verlag, dann erhalte ich dessen Spiele; ein Spiel kann mehreren Verlagen zugeordnet sein (Klärung 4). +4. Gegeben eine Trefferliste, wenn ich nach Spielerzahl, Spieldauer, Mindestalter, Genre oder Mechanik filtere, dann enthält sie nur Spiele, die zu allen gewählten Filtern passen (Klärung 13). +5. Gegeben mehr Treffer als auf eine Seite passen, wenn ich bis zum Ende scrolle, dann werden weitere nachgeladen. +6. Gegeben ich suche ohne Treffer, dann sehe ich einen Hinweis und die Möglichkeit, das Spiel zu melden (KAT-04). +7. Gegeben ein mobiles 4G-Profil (mindestens 10 Mbit/s, etwa 100 ms Round-Trip-Zeit), dann liefern 95 Prozent der Suchen gegen Staging die ersten Treffer in höchstens 2 Sekunden (Klärung 14). +8. Gegeben ich habe keinen Suchbegriff eingegeben, wenn ich einen oder mehrere Filter (Genre, Mechanik, Spielerzahl, Spieldauer, Mindestalter) wähle, dann sehe ich alle Katalogspiele, die zu allen gewählten Filtern passen, und kann so neue Spiele entdecken (Klärung 13). + +**Konkretisierungen dieser Spec (zur Freigabe):** + +- **Krit. 1 „Sonderzeichen“** umfasst Satz- und Sonderzeichen (`:`, `-`, `®`, `'` usw.) und diakritische Zeichen (`é` → `e`, `ä` → `a`, `ß` → `ss`). Die Schreibweise „ae“ für „ä“ findet in R1 **nicht** automatisch „ä“ (Spec-F5). +- **Krit. 1 „Teil davon“:** Die Suche beginnt ab **2 Zeichen** (nach Entfernen von Leerzeichen am Rand). Ein einzelnes Zeichen wirkt wie „kein Suchbegriff“ (§ 5.4). +- **Krit. 4 Filtersemantik:** siehe § 4.2 und Spec-F2. +- **Krit. 5:** Seitengröße 30. Nachgeladen wird automatisch kurz vor dem Listenende **und** über eine Schaltfläche „Weitere laden“ am Listenende (Tastatur und Screenreader, QUE-06 Krit. 4). +- **Krit. 6:** siehe E11 und Spec-F4. +- **Krit. 7 Messpunkt:** vom letzten Tastendruck (inklusive Entprellung von 300 ms) bis der erste Treffer sichtbar ist; Staging mit eingespieltem Startbestand (§ 7.3). + +**Zuordnung zu den PRs** + +| PR | Kriterien | +|---|---| +| 3a Backend | 1, 2, 3, 4, 5, 8 (Datenbankanteil, pgTAP); 7 (Datenbank-Budget, B5) | +| 3b App | 1–6, 8 (App-Anteil, Unit/Widget); KAT-05 Krit. 10 (Platzhalter-Cover in der Trefferliste) | +| 3c E2E/Messung | 1–6, 8 (Web-E2E); 7 (Messung gegen Staging, Abnahme durch Chris) | + +**Relevante Querschnittskriterien** + +- **Sicherheit der Daten Krit. 2:** Suche ohne Anmeldung; die RPC ist für `anon` ausführbar und liefert nur Katalogspiele, nie private Spiele (auch nicht die eigenen). Nichts ist schreibbar. +- **Datenschutz (QUE-04):** Suchbegriffe werden nicht gespeichert und nicht protokolliert (weder App-Logger noch Datenbank). Die RPC wird per `POST` aufgerufen (Standard von `supabase_flutter`), der Suchbegriff steht damit nicht in Zugriffsprotokollen von URLs. Keine Such-Analytik. +- **Sprachen (QUE-05) Krit. 2, 3:** alle Texte aus ARB (de/en); Genre- und Mechaniknamen nach Oberflächensprache (`name_de`/`name_en`); Trefferzahl mit Pluralform. +- **Barrierefreiheit (QUE-06) Krit. 1–4:** Suchfeld und Filter beschriftet, Trefferzahl wird angesagt, Touch-Ziele ≥ 48 dp, 200 % Schrift, Tastaturbedienung im Web inkl. „Weitere laden“. +- **Zustände, Fehler und Netz Krit. 1–3:** Lade-, Leer-, Fehler- (mit „Erneut versuchen“) und Normalzustand; Offline-Hinweis; Suchbegriff und Filter bleiben bei Fehlern erhalten. +- **Plattformen Krit. 1, 2:** gleiche Kriterien auf iOS, Android, Web; ab 1024 dp Filter als feste Seitenleiste. + +## 3. Datenmodell + +### 3.1 Bestand gegen die Kriterien + +| Bedarf | Bestand nach 2a | Handlung | +|---|---|---| +| Nur sichtbare Katalogspiele (KAT-05 Krit. 4) | View `catalog_games` | RPC liest darüber (E1) | +| Normalisierter Titel (Krit. 1) | Index `games_catalog_listed_idx` auf `lower(title)` | `search_normalize`, generierte Spalte `games.title_search` + Trigramm-Index (bestehender Index bleibt unverändert) | +| Alternative Titel mit Sprache (Krit. 2) | – (CSV-Spalte `alt_titles` reserviert, Spec #2 4.3) | Tabelle `game_alt_titles` | +| Verlage (Krit. 3) | `publishers`, `game_publishers` | generierte Spalte `publishers.name_search` + Trigramm-Index | +| Spielerzahl, Dauer, Alter (Krit. 4) | Spalten an `games` | keine | +| Genre, Mechanik (Krit. 4, 8) | `genres`, `mechanics`, `game_genres`, `game_mechanics` mit Index auf `genre_id`/`mechanic_id` und `unique (game_id, …)` | keine | +| Kennzeichnung manuell gepflegt | `manual_fields`, Trigger `game_links_mark_manual(tg_argv)` | Schlüssel `alt_titles` ergänzen, Trigger an `game_alt_titles` | + +### 3.2 Migration 3a `supabase/migrations/_catalog_search.sql` + +Nur additiv. Es gilt die Rechte-Regel aus Spec #2 3.2: Jede neue Tabelle zuerst `revoke all … from anon, authenticated`, jede neue Funktion zuerst `revoke execute … from public, anon, authenticated, service_role`, danach nur die Grants aus der Tabelle unten. + +**Grants je Objekt (verbindlich)** + +| Objekt | `anon` | `authenticated` | Hinweis | +|---|---|---|---| +| `game_alt_titles` | `select` | `select` | kein Schreibrecht für API-Rollen; Policy nur für `select` | +| `search_normalize(text)` | – | – | intern (generierte Spalten, RPC, später #13) | +| `search_catalog_games(…)` | **`execute`** | **`execute`** | **erste Funktion im Projekt, die API-Rollen ausdrücklich ausführen dürfen**; `service_role` und `public` bleiben entzogen | +| `games`, `publishers` | unverändert | unverändert | neue generierte Spalten sind über das bestehende Tabellenrecht `select` lesbar; die Spaltenrechte für `insert`/`update` (Spec #2 3.2) bleiben unverändert und enthalten die neuen Spalten nicht | + +**1. Erweiterungen** + +``` +create extension if not exists pg_trgm with schema extensions; +create extension if not exists unaccent with schema extensions; +``` + +Beide gehören zum Supabase-Postgres-Image und zu PostgreSQL-contrib. **Unverifizierte Annahme (B5 prüft lokal und auf Staging mit `select * from pg_available_extensions where name in ('pg_trgm','unaccent')`):** Beide sind im self-hosted Image verfügbar und im Schema `extensions` anlegbar. Rückfall siehe R1. + +**2. Funktion `public.search_normalize(p text) returns text`** + +- `language sql`, `immutable`, `strict`, `parallel safe`, `set search_path = ''`. +- Ablauf: `extensions.unaccent('extensions.unaccent'::regdictionary, p)` (die Variante mit ausdrücklichem Wörterbuch, damit die Funktion als `immutable` gelten darf) → `lower(…)` → `regexp_replace(…, '[^a-z0-9]+', ' ', 'g')` → `btrim(…)` → `nullif(…, '')`. +- Beispiele (Pflichtfälle im Test): `'Die Siedler von Catan'` → `'die siedler von catan'`; `'CATAN®'` → `'catan'`; `'7 Wonders: Duel'` → `'7 wonders duel'`; `'Café Größe'` → `'cafe grosse'`; `'Ticket to Ride – Europe'` → `'ticket to ride europe'`; `'!!!'` und `''` → `null`. +- Zeichen außerhalb des lateinischen Alphabets (z. B. japanische Titel) werden entfernt; in R1 gibt es nur deutsche und englische Titel (Spec-F7). +- **Regel für spätere Änderungen:** Ändert sich die Funktion, müssen die generierten Spalten neu berechnet werden (Spalte in einer neuen Migration entfernen und neu anlegen). Steht als Kommentar an der Funktion. + +**3. Generierte Spalten und Indizes** + +| Tabelle | Spalte | Definition | Index | +|---|---|---|---| +| `games` | `title_search` | `text generated always as (public.search_normalize(title)) stored` | `games_title_search_trgm_idx using gin (title_search extensions.gin_trgm_ops) where visibility = 'catalog' and hidden_at is null and merged_into_id is null` | +| `publishers` | `name_search` | `text generated always as (public.search_normalize(name)) stored` | `publishers_name_search_trgm_idx using gin (name_search extensions.gin_trgm_ops)` | +| `game_alt_titles` | `title_search` | wie `games.title_search` | `game_alt_titles_title_search_trgm_idx using gin (title_search extensions.gin_trgm_ops)` | + +Hinweise: Das Anlegen einer gespeicherten Spalte schreibt `games` einmal neu (unkritisch bei der Größe). Generierte Spalten kann kein Client setzen. Der Trigger `games_mark_manual_fields` arbeitet mit einer festen Spaltenliste und ist nicht betroffen. Die View `catalog_games` bleibt unverändert; die RPC verbindet sie per Primärschlüssel mit `games`, um `title_search` zu lesen. + +**4. Neue Tabelle `game_alt_titles`** (Klärung 3, E2) + +| Spalte | Typ | Hinweis | +|---|---|---| +| `id` | uuid | PK, `gen_random_uuid()` | +| `game_id` | uuid | not null, FK → `games (id)` `on delete cascade` | +| `title` | text | not null, 1–200 Zeichen (wie `games.title`) | +| `locale` | text | not null, Check `in ('de','en')` (wie `games.description_locale`; Spec-F7) | +| `title_search` | text | generiert (oben); Check `title_search is not null` (ein Titel nur aus Sonderzeichen ist nicht zulässig) | +| `created_at`, `updated_at` | timestamptz | Standard, Trigger `set_updated_at` | + +- `unique (game_id, locale, title_search)` (Konfliktziel für das Startbestand-Werkzeug; derselbe Titel in derselben Sprache nur einmal, unabhängig von Schreibweise). Mehrere Titel je Sprache sind erlaubt (z. B. alter und neuer deutscher Titel). +- Der Index der Unique-Constraint deckt Abfragen nach `game_id` ab; kein zusätzlicher Index nötig. +- Trigger `game_links_mark_manual after insert or update or delete … execute function public.game_links_mark_manual('alt_titles')` (Funktion aus 2a, generisch über `tg_argv`, braucht nur `game_id`). + +**RLS in Klartext** + +- `enable row level security`; `revoke all … from anon, authenticated`; `grant select … to anon, authenticated`. +- Lesen (`game_alt_titles_select`, `anon`, `authenticated`): wer das Spiel sehen darf (`exists (select 1 from public.games g where g.id = game_id)`, Muster `game_publishers_select`). Alternative Titel eines fremden privaten Spiels sind damit unsichtbar. +- Schreiben: **keine Policy, kein Recht** für API-Rollen. Pflege nur über Studio (`postgres`) und das Startbestand-Werkzeug. QUE-08 (R4) ergänzt bei Bedarf eine Admin-Policy (Spec #2 R8). + +**5. `manual_fields`-Schlüssel ergänzen** + +`alter table public.games drop constraint games_manual_fields_check, add constraint games_manual_fields_check check (manual_fields <@ array[…]::text[])` mit der **vollständigen** Liste aus 2a plus `'alt_titles'`. + +**Regel für alle Stories, die Schlüssel ergänzen (#3, #12, 2b, Import):** Jede Migration nennt die vollständige Liste aller bis dahin bekannten Schlüssel. Wer später merged, gleicht die Liste mit `main` ab (sonst entfernt die spätere Migration einen Schlüssel, und bestehende Zeilen verletzen den Check). + +**6. RPC `public.search_catalog_games`** – Vertrag in § 4.2. + +### 3.3 Doku zum Datenmodell + +`docs/datenmodell-r1.md`: neue Tabelle `game_alt_titles`, generierte Spalten, Funktion `search_normalize`, RPC `search_catalog_games`, Erweiterungen, neuer `manual_fields`-Schlüssel; Abschnitt „API für das Frontend“ um die RPC ergänzen. `supabase/README.md`: Tabellenzahl, Hinweis „erste RPC mit `execute` für `anon`“. `docs/katalog-pflege.md`: Abschnitt „Alternative Titel pflegen“ (Studio-SQL und CSV-Spalte `alt_titles`). + +## 4. Backend + +### 4.1 Überblick + +| Objekt | Art | PR | Aufrufbar von | +|---|---|---|---| +| `pg_trgm`, `unaccent` | Erweiterungen (Schema `extensions`) | 3a | – | +| `search_normalize` | interne Funktion | 3a | nur Eigentümer (generierte Spalten, RPC) | +| `game_alt_titles` | Tabelle | 3a | Lesen: alle; Schreiben: Studio | +| `title_search`, `name_search` | generierte Spalten | 3a | Lesen über bestehende Rechte | +| `search_catalog_games` | RPC | 3a | `anon`, `authenticated` | +| Startbestand-Werkzeug | Deno-Skript (Erweiterung) | 3a | Chris lokal, CI | + +Keine Edge Function. + +### 4.2 RPC `public.search_catalog_games` (Vertrag zwischen 3a und 3b) + +`language plpgsql`, `stable`, `security invoker`, `set search_path = ''`. Alle Objekte schema-qualifiziert (`public.…`, `extensions.similarity(…)`). Aufruf über PostgREST `POST /rest/v1/rpc/search_catalog_games` (in Dart `client.rpc('search_catalog_games', params: {...})`). + +**Eingabe** (alle optional, `null` = nicht gesetzt) + +| Parameter | Typ | Zulässig | Bedeutung | +|---|---|---|---| +| `p_query` | text | ≤ 100 Zeichen | Suchbegriff. `null` oder nur Leerzeichen = kein Suchbegriff. Ergibt die Normalisierung `null` (z. B. `'!!!'`), gibt es **keine Treffer**. | +| `p_players` | smallint | 1–99 | Spielerzahl n: passt, wenn `min_players <= n <= max_players` (beide bekannt) | +| `p_max_minutes` | smallint | 1–1440 | „höchstens X Minuten“: passt, wenn `coalesce(max_minutes, min_minutes) <= X` (mindestens einer bekannt) | +| `p_age` | smallint | 0–99 | „für Spielende ab X Jahren geeignet“: passt, wenn `min_age <= X` (bekannt) | +| `p_genre_ids` | uuid[] | ≤ 20 Einträge | Spiel hat **jedes** dieser Genres (Spec-F1; Duplikate werden entfernt; unbekannte IDs ergeben 0 Treffer) | +| `p_mechanic_ids` | uuid[] | ≤ 20 Einträge | wie Genres | +| `p_limit` | integer | 1–50, Standard 30 | Seitengröße | +| `p_offset` | integer | 0–10 000, Standard 0 | Versatz | + +Die Semantik von Spielerzahl, Dauer und Alter steht unter Vorbehalt von Spec-F2. + +**Ausgabe** (`returns table`) + +| Spalte | Typ | Inhalt | +|---|---|---| +| `id` | uuid | Shuffle-ID | +| `title` | text | Haupttitel | +| `year` | smallint | nullable | +| `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age` | smallint | nullable | +| `image_path` | text | aus `catalog_games` (maskiert nach Spec #2 E6); im Startbestand immer `null` | +| `publisher_names` | text[] | alle Verlage, sortiert nach `name_search`, leer statt `null` | +| `matched_alt_title` | text | der alternative Titel, über den das Spiel besser getroffen wurde als über den Haupttitel; sonst `null` (Anzeige „auch: …“) | +| `match_rank` | smallint | Relevanzstufe 0–4 (unten), `null` ohne Suchbegriff; für Tests und Fehlersuche, die App zeigt sie nicht an | +| `total_count` | bigint | Gesamtzahl der Treffer (gleich in jeder Zeile); `hasMore = offset + Zeilen < total_count` | + +Beschreibung, Genres und Mechaniken sind nicht enthalten (Listenansicht, kleine Antwort). Die Detailseite (#4) liest sie selbst. + +**Treffer-Regel** (E4), mit `q := search_normalize(p_query)`, Wörter `w := string_to_array(q, ' ')` (höchstens die ersten 10), `qc := replace(q, ' ', '')`: + +- Kandidaten-Titel eines Spiels: `games.title_search` und alle `game_alt_titles.title_search`. +- Ein Spiel trifft, wenn **jedes** Wort aus `w` als Teilzeichenkette (`like '%' || wort || '%'`; Wörter enthalten nur `a–z0–9`, ein Escapen entfällt) in einem Kandidaten-Titel **oder** in einem `publishers.name_search` des Spiels vorkommt, **oder** wenn `qc` Teilzeichenkette eines Kandidaten-Titels ohne Leerzeichen ist. +- Ein Spiel erscheint immer genau einmal, auch wenn mehrere Titel treffen. + +**Relevanz** (E5): `match_rank` ist die beste Stufe über alle Kandidaten-Titel. + +| Stufe | Bedingung | +|---|---| +| 0 | ein Kandidaten-Titel ist gleich `q` (oder ohne Leerzeichen gleich `qc`) | +| 1 | ein Kandidaten-Titel beginnt mit `q` | +| 2 | `q` steht in einem Kandidaten-Titel am Wortanfang (`like '% ' || q || '%'`) | +| 3 | alle Wörter kommen in einem Kandidaten-Titel vor (oder Treffer über `qc`) | +| 4 | sonst (mindestens ein Wort trifft nur über einen Verlag) | + +Sortierung: `match_rank` aufsteigend, dann `extensions.similarity(title_search, q)` absteigend, dann `title_search`, dann `id`. Ohne Suchbegriff: `title_search`, dann `id`. Bei gleicher Stufe von Haupttitel und alternativem Titel gilt der Haupttitel (`matched_alt_title = null`). + +**Filter** (E7): alle gesetzten Filter UND-verknüpft, zusätzlich zum Suchbegriff. Genres: `(select count(*) from public.game_genres gg where gg.game_id = g.id and gg.genre_id = any(p_genre_ids)) = cardinality()`; Mechaniken entsprechend. + +**Fehler** (`raise exception … using errcode = 'P0001'`; PostgREST liefert HTTP 400 mit `code = 'P0001'` und der Meldung als `message`) + +| Fall | Meldung | +|---|---| +| `p_query` länger als 100 Zeichen | `search_query_too_long` | +| `p_players`, `p_max_minutes` oder `p_age` außerhalb des Bereichs; mehr als 20 IDs in einem Array | `search_filter_invalid` | +| `p_limit` oder `p_offset` außerhalb des Bereichs | `search_paging_invalid` | + +Die App verhindert diese Fälle schon bei der Eingabe; tritt einer trotzdem auf, ist das ein Programmfehler (Log, allgemeiner Fehlerzustand). + +**Sicherheit:** `security invoker` – für `anon` gelten die Policies von `games`, `catalog_sources`, `game_publishers`, `publishers`, `game_alt_titles`, `game_genres`, `game_mechanics`. Die Eingabegrenzen begrenzen die Last je Aufruf; zusätzlich greift das `statement_timeout` der Rollen `anon`/`authenticated` (Supabase-Standard; B5 prüft den Wert im Self-Hosting, Ergebnis in `docs/katalog-pflege.md` bzw. `supabase/README.md`). Eine Mengenbegrenzung je Client gibt es in R1 nicht (R4). + +**Leistung (E12):** Ziel für die Ausführung in der Datenbank: p95 ≤ 150 ms bei 10 000 sichtbaren Katalogspielen, 20 000 alternativen Titeln, 2 000 Verlagen und je Spiel zwei Genres und drei Mechaniken. B5 misst lokal mit synthetischen Daten (Skript `supabase/tests/perf/catalog_search_perf.sql`, nicht Teil von `supabase test db`, läuft nie gegen Staging/Produktion) und dokumentiert `explain (analyze, buffers)` für typische Fälle im PR. Wird das Ziel verfehlt: Rückfall in R2. + +### 4.3 Startbestand-Werkzeug: Spalte `alt_titles` auswerten + +Erweiterung von `supabase/catalog/build_startbestand.ts` (Spec #2 4.3, E9). Die Spalte ist schon reserviert. + +- **Format:** `de:Titel;en:Title`. Einträge durch `;` getrennt; je Eintrag Sprache und Titel am **ersten** `:` getrennt (Titel dürfen `:` enthalten, z. B. `de:Catan: Das Duell`). Leerzeichen am Rand werden entfernt. +- **Prüfungen** (Abbruch mit Zeilennummer und Grund): Sprache nicht `de`/`en`; leerer Titel; Titel länger als 200 Zeichen; derselbe Titel in derselben Sprache doppelt; alternativer Titel gleich dem Haupttitel. „Gleich“ heißt im Skript: Kleinbuchstaben, diakritische Zeichen entfernt (Unicode NFD), alles außer `a–z0–9` zu Leerzeichen – eine Annäherung an `search_normalize`; maßgeblich bleibt die Unique-Constraint in der Datenbank. +- **Erzeugte SQL je Startbestandsspiel** (Abgleich wie bei den Zuordnungen, Spec #2 4.3 Idempotenz Regel 3): + 1. `delete from public.game_alt_titles a where a.game_id = and (a.locale, a.title_search) not in (select locale, public.search_normalize(title) from (values …) v(locale, title))`, + 2. `insert into public.game_alt_titles (game_id, locale, title) values … on conflict (game_id, locale, title_search) do update set title = excluded.title where game_alt_titles.title is distinct from excluded.title`. + Unveränderte Zeilen bleiben unberührt (`updated_at` und `manual_fields` stabil). Spiele ohne `alt_titles` bekommen nur Schritt 1 (entfernt also früher gesetzte Titel). +- Alternative Titel bekommen **keine** abgeleitete ID (wie Verlage); Konfliktziel ist die Unique-Constraint. +- `checks.sql`: keine neue Pflichtregel (alternative Titel sind optional); eine Informationszeile „Startbestandsspiele mit alternativen Titeln: n“. +- Die erzeugten Dateien (`startbestand.sql`, Testdaten unter `supabase/catalog/testdata/`, `supabase/tests/support/*.inc`) werden neu erzeugt; der CI-Schritt „erzeugte Dateien aktuell“ verlangt das. Die Testdaten-CSV bekommt bei einem Testspiel zwei alternative Titel (de und en). +- **Bestehender Deno-Test bricht absichtlich:** `"facts_source, alt_titles und base_game_key landen nicht in der SQL"` (`build_startbestand_test.ts`, Z. 253) wird zu „`facts_source` und `base_game_key` landen nicht in der SQL“ geändert; neue Tests für `alt_titles` (§ 7.1). +- Das Einspielen markiert `alt_titles` in `manual_fields` (Trigger, Spec #2 SB8). Startbestand ab Go-live eingefroren (Spec #2 Spec-F6): Pflegt Chris alternative Titel vorher, kommen sie über die CSV; danach nur über Studio. + +## 5. Frontend + +### 5.1 Struktur + +``` +app/lib/features/catalog/ + domain/ + catalog_game_summary.dart CatalogGameSummary (freezed, ohne JSON) + catalog_search_query.dart CatalogSearchQuery + CatalogFilters (freezed), Grenzen als Konstanten + catalog_search_page.dart CatalogSearchPage(items, totalCount) (freezed) + catalog_term.dart CatalogTerm(id, nameDe, nameEn) + localizedName(String languageCode) + catalog_failure.dart CatalogFailure (sealed: network, invalidInput, unexpected) + catalog_repository.dart abstract interface class CatalogRepository + data/ + supabase_catalog_repository.dart + catalog_game_summary_dto.dart @JsonSerializable(createToJson: false, fieldRename: snake) + catalog_term_dto.dart dito + catalog_failure_mapper.dart + presentation/ + catalog_routes.dart + providers/catalog_providers.dart Repository, Genres, Mechaniken + search/catalog_search_controller.dart (+ state) + search/catalog_search_screen.dart + search/widgets/… Suchfeld, Filterbereich, Treffer-Kachel, Listenfuß, Leerzustand +``` + +Domäne ohne Flutter- und Supabase-Importe (`freezed_annotation` ist erlaubt). Datenklassen **ohne `toJson`** (E9); das Feature schreibt nichts in die Datenbank. + +### 5.2 Domänenmodelle und Repository + +- **`CatalogGameSummary`:** `id`, `title`, `year?`, `minPlayers?`, `maxPlayers?`, `minMinutes?`, `maxMinutes?`, `minAge?`, `imagePath?`, `publisherNames` (`List`, nie `null`), `matchedAltTitle?`. **Abweichung von Spec #2 § 5:** Dort heißt es, KAT-01 lege `CatalogGame` an. Die Suche braucht nur die Listenansicht; das vollständige Modell `CatalogGame` (mit Beschreibung, Genres, Quellenangabe) legt KAT-02 (#4) an. +- **`CatalogSearchQuery`:** `text` (getrimmt, ≤ 100 Zeichen), `filters` (`players?`, `maxMinutes?`, `minAgeSuitable?`, `genreIds` `Set`, `mechanicIds` `Set`). Hilfen: `hasText` (≥ 2 Zeichen nach Trim), `hasFilters`, `activeFilterCount`, `isEmpty`. Zulässige Werte als Konstanten (Spielerzahl 1–12, Dauer-Stufen 30/45/60/90/120/180, Alters-Stufen 6/8/10/12/14/16/18; Spec-F2). +- **`CatalogTerm`:** Genre bzw. Mechanik; `localizedName('de')` → `nameDe`, sonst `nameEn` (QUE-05 Krit. 4: Englisch als Rückfall). +- **`CatalogFailure`:** `network` (keine Verbindung, Zeitüberschreitung), `invalidInput` (P0001 aus § 4.2, Programmfehler), `unexpected`. +- **`CatalogRepository`:** + - `Future> search(CatalogSearchQuery query, {required int offset, int limit = 30})` + - `Future, CatalogFailure>> fetchGenres()`, `fetchMechanics()` (PostgREST `select id, name_de, name_en` auf `genres`/`mechanics`; Sortierung im Client nach lokalisiertem Namen) +- **`SupabaseCatalogRepository`:** baut die RPC-Parameter **nur aus gesetzten Werten** (kein `null` für leere Mengen, kein Suchbegriff unter 2 Zeichen), Zeitlimit 10 s je Aufruf. Fehlerabbildung in `catalog_failure_mapper.dart`: `isIoNetworkError` (`core/platform/io_error.dart`), `http.ClientException`, `TimeoutException` → `network`; `PostgrestException` mit `code == 'P0001'` → `invalidInput`; sonst `unexpected`. Geloggt wird über den zentralen Logger **nur die Fehlerart, nie der Suchbegriff** (Datenschutz). Kein Connectivity-Paket; Offline wird am Aufruf erkannt (wie Spec #1). + +### 5.3 Provider + +- `catalogRepositoryProvider` (keepAlive) auf Basis von `supabaseClientProvider`. +- `catalogGenresProvider`, `catalogMechanicsProvider` (`FutureProvider`, keepAlive; Vokabular ändert sich selten). Fehler beim Laden der Begriffe: Filterbereich zeigt für Genre/Mechanik einen Hinweis mit „Erneut versuchen“; die übrigen Filter und die Suche bleiben nutzbar. +- `CatalogSearchController` (`@riverpod class`, autoDispose) mit `CatalogSearchState`. + +### 5.4 Zustand und Verhalten + +**`CatalogSearchState`** (freezed): `query`, `status` (`loading` | `data` | `empty` | `error`), `items`, `totalCount`, `failure?`, `refreshing` (neue Suche läuft, alte Treffer bleiben sichtbar), `loadingMore`, `loadMoreFailure?`. Abgeleitet: `hasMore = items.length < totalCount`. + +**Methoden des Controllers** + +| Methode | Verhalten | +|---|---| +| `init(CatalogSearchQuery initial)` | einmalig beim Öffnen (Startwerte aus der URL), startet die erste Suche sofort | +| `setText(String)` | Entprellung **300 ms**, dann Suche ab Versatz 0. Unter 2 Zeichen gilt „kein Suchbegriff“ (Liste nach Filtern bzw. ganzer Katalog, E8). | +| `setFilters(CatalogFilters)` / `clearFilters()` | sofort Suche ab Versatz 0 (laufende Entprellung wird mit ausgeführt) | +| `loadMore()` | nur wenn `hasMore` und nicht schon `loadingMore`; Versatz = `items.length`; Ergebnis wird an `items` angehängt, IDs, die schon vorhanden sind, werden verworfen | +| `retry()` | wiederholt die zuletzt fehlgeschlagene Aktion (erste Seite oder Nachladen) | + +- **Nur die letzte Anfrage zählt:** Jede neue Suche erhöht einen Zähler; Antworten älterer Anfragen werden verworfen (kein Flackern, keine falsche Reihenfolge). +- Suchbegriff und Filter bleiben bei jedem Fehler erhalten (Zustände Krit. 3). +- Die Entprellung nutzt einen `Timer`; Tests mit `fakeAsync`. + +### 5.5 Route und Startseite + +- **`AppRoutes.catalog = '/catalog'`**, öffentlich (nicht in `privateRoutePaths`). Registrierung über `catalogRoutes` in `app_router.dart` (Muster `authRoutes`). +- **Query-Parameter:** `q` (Suchbegriff), `players`, `maxMinutes`, `age`, `genre` und `mechanic` (IDs, durch Komma getrennt). Ungültige oder unbekannte Werte werden still ignoriert (geparst in `presentation`, geprüft gegen die Konstanten der Domäne). +- **URL aktualisieren (Web):** Nach jeder ausgeführten Suche wird die URL ohne neuen Verlaufseintrag ersetzt, sodass Neuladen und Teilen den Stand erhalten. flutter-dev prüft mit go_router 18, dass dabei weder Seite noch Controller neu entstehen (Zustand, Scrollposition, Fokus im Suchfeld). **Falls doch:** Das Schreiben der URL entfällt, nur das Lesen beim Öffnen bleibt; Abweichung im PR begründen. +- **Startseite:** Der bestehende Platzhalter `HomeScreen` bekommt eine Schaltfläche „Spiele suchen“ → `/catalog`. Die Startseite ohne Anmeldung (Suche direkt auf `/`) baut #29 (QUE-09 Krit. 13); dafür ist der Inhalt des Screens als eigenes Widget `CatalogSearchView` gekapselt. +- **Antippen eines Treffers:** Die Kachel hat einen `onTap`-Parameter; in #3 ist er `null` (Kachel nicht interaktiv, kein toter Link). KAT-02 (#4) verdrahtet die Navigation zur Detailseite. + +### 5.6 Screen `CatalogSearchScreen` + +Aufbau (Details und Texte liefert ui-designer in U1, Komponenten ins Design-System): + +- **Suchfeld** oben: Beschriftung „Spiele suchen“ (Semantics-Label + sichtbarer Hinweistext „Titel oder Verlag“), `textInputAction.search`, `maxLength` 100 ohne Zähler, Löschen-Schaltfläche (≥ 48 dp, Label „Suchbegriff löschen“). Kein Autofokus auf Mobilgeräten (Tastatur verdeckt sonst die Liste); im Web Autofokus. +- **Filter:** + - kompakt (< 600 dp) und mittel (600–1023 dp): Schaltfläche „Filter“ mit Anzahl aktiver Filter; öffnet ein Bottom Sheet. Änderungen wirken sofort (Liste dahinter aktualisiert sich), „Fertig“ schließt. + - groß (≥ 1024 dp, `AppWidthClass` aus `app_spacing.dart`): feste Seitenleiste links (ca. 320 dp), Trefferliste rechts mit begrenzter Breite (Plattformen Krit. 2). + - Inhalte: Spielerzahl (Auswahl „beliebig“, 1–12), Spieldauer (Einfachauswahl „bis 30/45/60/90/120/180 Min.“), Alter (Einfachauswahl „ab 6 … ab 18 Jahren geeignet“), Genre und Mechanik (Mehrfachauswahl als Filter-Chips, nach lokalisiertem Namen sortiert), „Filter zurücksetzen“. Die genaue Beschriftung von Alter und Dauer folgt Spec-F2. +- **Trefferzahl** über der Liste („23 Spiele“, ARB-Plural) und **Ansage** für Screenreader nach jeder abgeschlossenen Suche (Live-Region bzw. aktuelle Flutter-API für Ansagen). +- **Treffer-Kachel:** `GameCover` klein mit `titleVisibleNearby: true` (Platzhalter, KAT-05 Krit. 10), Titel (max. 2 Zeilen), Jahr, Verlage (bis zu zwei, sonst „+n“), Kurzangaben Spielerzahl („2–4 Spieler“), Dauer („30–60 Min.“), Alter („ab 10“); fehlende Angaben werden weggelassen. Bei `matchedAltTitle` eine Zeile „auch: {Titel}“. Ein zusammengefasstes Semantics-Label je Kachel. +- **Listenfuß:** während des Nachladens eine Ladeanzeige; bei `hasMore` eine Schaltfläche „Weitere laden“; bei Fehler beim Nachladen Hinweis + „Erneut versuchen“ (bereits geladene Treffer bleiben). Automatisches Nachladen, sobald die letzten 5 Kacheln in Sicht kommen. + +**Zustände** (Zustände Krit. 1, 2) + +| Zustand | Anzeige | +|---|---| +| Laden (erste Seite, noch keine Treffer) | Platzhalter-Kacheln oder Ladeanzeige; Suchfeld und Filter bedienbar | +| Neue Suche mit sichtbaren alten Treffern | alte Treffer bleiben, schmale Fortschrittsanzeige oben | +| Daten | Trefferzahl + Liste | +| Leer mit Suchbegriff (Krit. 6) | „Keine Spiele zu „{q}“ gefunden.“ + Hinweis „Prüfe die Schreibweise oder entferne Filter.“; „Filter zurücksetzen“ (nur wenn Filter aktiv); **Einstiegspunkt „Spiel melden“**, sichtbar erst mit #13 (E11, Spec-F4) | +| Leer nur mit Filtern (Krit. 8) | „Keine Spiele passen zu deinen Filtern.“ + „Filter zurücksetzen“ | +| Leer ohne Suchbegriff und Filter | „Der Katalog ist noch leer.“ (nur lokal/Staging ohne Startbestand denkbar) | +| Fehler: keine Verbindung | „Keine Verbindung. Für die Suche brauchst du Internet.“ + „Erneut versuchen“ | +| Fehler: sonstige | „Die Suche ist gerade nicht möglich.“ + „Erneut versuchen“ | + +Der Einstiegspunkt für #13 ist ein optionaler Callback am Leerzustand-Widget (`onReportGame`, erhält den Suchbegriff); #3 übergibt `null`, #13 übergibt die Navigation zum Melden (mit dem Suchbegriff als vorbelegtem Titel, Anmeldung über die bestehende `from`-Weiterleitung). + +**Neue gemeinsame Komponenten** (ui-designer, `core/widgets/`): Zustandsanzeige für Leer/Fehler mit optionaler Aktion (`AppStateMessage`) und Listenfuß für Nachladen. Begründung: Zustände Krit. 1 gilt für jede Liste in R1; Sammlung, Partien und Listen nutzen sie als nächste. + +**ARB-Schlüssel** (de/en, Präfix `catalogSearch…`): Titel, Suchfeld-Label und -Hinweis, Löschen, Filter (mit Anzahl), alle Filterbezeichnungen und -werte, Zurücksetzen, Fertig, Trefferzahl (Plural), Ansage, „auch: {title}“, Kurzangaben Spielerzahl/Dauer/Alter (Plural bzw. Bereich), Weitere laden, alle Leer- und Fehlertexte, Startseiten-Schaltfläche. Kein hartkodierter Text. + +## 6. Plattform-Besonderheiten + +- **iOS, Android, Web:** gleiche Funktion. Unterschied nur beim Autofokus des Suchfelds (Web ja, Mobil nein) und beim URL-Abgleich (wirkt sichtbar nur im Web; Deep Links auf `/catalog?q=…` funktionieren auch mobil über go_router). +- **Web, Tastatur (QUE-06 Krit. 4):** Tab-Reihenfolge Suchfeld → Filter → Liste → „Weitere laden“; Enter im Suchfeld löst die Suche sofort aus (ohne Entprellung); sichtbarer Fokus. +- **Offline:** Die Suche braucht eine Verbindung (O7, kein Offline-Katalog). Hinweis statt technischem Fehler; Eingaben bleiben. +- **Bilder:** Der Startbestand hat keine Cover; `GameCover` zeigt den Platzhalter. Kommen später Cover aus dem eigenen Storage, gelten die Hinweise im Doc-Kommentar von `GameCover` (CORS im Self-Hosting, signierte URLs). +- **Kamera, Dateien, Push:** nicht betroffen (Barcode-Suche nicht im Scope). + +## 7. Teststrategie + +Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flutter_test` + `mocktail` (Unit/Widget), Playwright (Web-E2E und Messung). Testnamen enthalten die Referenz („KAT-01 Kriterium n“). + +**pgTAP-Datei `supabase/tests/database/09_catalog_search.test.sql`** (3a). Die Nummern `07` und `08` sind für Spec #2 (2b, 2c) reserviert. Die Tests laufen mit eingespieltem Startbestand: Fixtures mit festen Test-UUIDs und einem eindeutigen Kunstwort in allen Titeln und Verlagen (z. B. `qxz`), sodass Suchanfragen nie Startbestandsspiele treffen; bei Abfragen ohne Suchbegriff wird nur auf die Fixture-IDs geprüft. + +**`00_schema.test.sql`:** Tabellenzahl + 1 gegenüber dem Stand von `main` beim Merge (2c erhöht ebenfalls; wer später merged, gleicht ab). + +### 7.1 Abdeckungsmatrix + +| AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task | +|---|---|---|---|---|---| +| Krit. 1 | Repository: Parameter nur aus gesetzten Werten, Suchbegriff < 2 Zeichen wird nicht gesendet; DTO-Parsing (alle Felder nullable, `publisher_names` leer); Controller: Entprellung 300 ms, Enter sofort, nur letzte Antwort zählt | Suchfeld: Eingabe → Liste; Löschen-Schaltfläche; Treffer-Kachel mit Kurzangaben, fehlende Angaben weggelassen | Web: Teil eines Titels in anderer Schreibweise und mit Sonderzeichen findet das Spiel | `search_normalize`-Pflichtfälle (3.2); Teilzeichenkette, Groß/Klein, Sonderzeichen, Umlaut/ß; mehrere Wörter (UND); Schreibweise ohne Leerzeichen (`qc`); Relevanzstufen 0–4 in erwarteter Reihenfolge; gleiche Stufe → Ähnlichkeit, Titel, ID; `'!!!'` → 0 Treffer; `null`/Leerzeichen → kein Suchbegriff | B1, B2, F1, F2, T1, T2 | +| Krit. 2 | DTO: `matched_alt_title` | Kachel zeigt „auch: …“ | Web: Suche nach deutschem und englischem alternativen Titel findet dasselbe Spiel | Treffer über `de`- und `en`-Titel; Spiel nur einmal, auch wenn Haupt- und alternativer Titel treffen; `matched_alt_title` nur bei besserer Stufe; Tabelle: Checks (Sprache, Länge, nur Sonderzeichen → 23514), Unique (Schreibweise egal → 23505); RLS: `anon` liest Titel von Katalogspielen, nicht die eines fremden privaten Spiels; `anon`/`authenticated` können nicht schreiben (42501); Einfügen als `postgres` markiert `alt_titles` in `manual_fields`; Check akzeptiert `alt_titles`, lehnt unbekannte Schlüssel ab. Deno: Format, Sprache, Länge, Duplikat, gleich Haupttitel, `:` im Titel; erzeugte SQL; Idempotenz (zweites Einspielen ändert nichts, `updated_at` stabil; entfernter alternativer Titel wird gelöscht) | B1, B2, B3, T1, T2 | +| Krit. 3 | – | Kachel: Verlage (bis zwei, „+n“) | Web: Verlagsname findet dessen Spiele | Verlag findet alle seine Spiele; Spiel mit zwei Verlagen über beide; Wort aus Titel + Wort aus Verlag (UND); `publisher_names` sortiert, vollständig; Stufe 4 hinter Titeltreffern | B1, B2, T2 | +| Krit. 4 | `CatalogSearchQuery`: Grenzen, `activeFilterCount`; URL-Parameter lesen/schreiben (Rundreise, ungültige Werte ignoriert); Controller: Filteränderung startet ab Versatz 0 | Filterbereich: jede Filterart setzt den Wert; Zurücksetzen; Bottom Sheet (kompakt) und Seitenleiste (≥ 1024 dp); Begriffe nach Sprache sortiert und benannt; Fehler beim Laden der Begriffe | Web: Filter Genre + Spielerzahl mit Suchbegriff | je Filter allein; Kombination aller Filter (UND); zwei Genres → nur Spiele mit beiden (Spec-F1); fehlende Angaben werden ausgeschlossen (Spec-F2); unbekannte Genre-ID → 0 Treffer; Duplikate im Array zählen einfach | B1, B2, F1, F2, U1, T1, T2 | +| Krit. 5 | Controller: `loadMore` hängt an, verwirft doppelte IDs, nicht doppelt gleichzeitig; Fehler beim Nachladen behält Treffer; `retry` | Listenfuß: Ladeanzeige, „Weitere laden“, Fehler + „Erneut versuchen“; automatisches Nachladen beim Scrollen | Web: ≥ 31 Fixture-Treffer, Scrollen lädt die zweite Seite; „Weitere laden“ per Tastatur | Seiten ohne Überschneidung, Vereinigung = Gesamtmenge, Reihenfolge stabil über Aufrufe; `total_count` gleich in allen Zeilen und Seiten | B2, F2, T1, T2 | +| Krit. 6 | – | Leerzustand mit Suchbegriff: Hinweis, Text mit Suchbegriff, „Filter zurücksetzen“ nur mit Filtern, Melden-Einstieg nur bei gesetztem Callback | Web: Suche ohne Treffer zeigt Hinweis | – | F3, U1, T2 | +| Krit. 7 | – | – | Projekt `perf` gegen Staging (7.3); lokal: Datenbank-Budget mit synthetischen Daten | – (Messung, nicht pgTAP) | B5, T3, D1, C1 | +| Krit. 8 | Controller: ohne Suchbegriff mit Filtern; Startzustand ohne beides | Leerzustand nur mit Filtern | Web: nur Filter, kein Suchbegriff → passende Spiele | ohne Suchbegriff: Filter wirken, Sortierung nach Titel, `match_rank` `null`; ohne beides: alle sichtbaren Katalogspiele (Fixtures vollständig enthalten) | B2, F2, T1, T2 | +| KAT-05 Krit. 4 | – | – | – | ausgeblendete und zusammengeführte Fixture-Spiele erscheinen nie, auch nicht über alternative Titel oder Verlag | B2 | +| KAT-05 Krit. 10 | – | Kachel zeigt `GameCover`-Platzhalter, aus dem Semantics-Baum ausgeblendet | – | `image_path` maskiert bei Quelle ohne Erlaubnis (über die View) | B2, T2 | +| Sicherheit Krit. 2 | – | – | Web: Suche ohne Anmeldung | `anon` und `authenticated` dürfen `search_catalog_games` ausführen, `public`/`service_role` nicht (`has_function_privilege`); `search_normalize` für keine API-Rolle ausführbar; eigene und fremde private Spiele erscheinen nie (auch mit gleichem Titel); keine Schreibrechte auf `game_alt_titles` | B2 | +| Eingabegrenzen | `CatalogSearchQuery` lässt nur zulässige Werte zu | Suchfeld begrenzt auf 100 Zeichen | – | `search_query_too_long`, `search_filter_invalid` (je Parameter, Array > 20), `search_paging_invalid` (`limit` 0/51, `offset` −1/10 001) | B2, T1 | +| Zustände Krit. 1–3 | Fehlerabbildung: Netzwerkfehler, Zeitlimit, P0001, sonstige; Suchbegriff/Filter bleiben nach Fehler | alle Zustände aus 5.6; „Erneut versuchen“ | Web: `context.setOffline(true)` → Offline-Hinweis, danach online → „Erneut versuchen“ liefert Treffer | – | F1, F2, T1, T2 | +| QUE-04 | Logger erhält bei Fehlern keinen Suchbegriff | – | – | – | F1, T1 | +| QUE-05 Krit. 2–4 | `CatalogTerm.localizedName` (Rückfall Englisch) | Screen in de und en; Trefferzahl Plural | – | – | F1, T2 | +| QUE-06 Krit. 1–4 | – | a11y-Guidelines (`labeledTapTargetGuideline`, `androidTapTargetGuideline`, `iOSTapTargetGuideline`, `textContrastGuideline`); `textScaler` 2.0 ohne Überlauf (kompakt und groß); Ansage der Trefferzahl | Web: Bedienung nur per Tastatur | – | U1, T2, T4 | +| Plattformen Krit. 2 | – | Layout ≥ 1024 dp mit Seitenleiste | – | – | U1, T2 | + +### 7.2 E2E (Projekt `web`, lokal und CI) + +- Neue Datei `e2e/tests/web/kat01-suche.spec.ts`. Testdaten legt ein Helfer über `e2e/support/db.ts` (nur lokal, bestehende Host-Prüfung) als `postgres` an: 40 Katalogspiele mit dem Kunstwort im Titel, zwei Verlagen, alternativen Titeln, Genres und Mechaniken aus dem Vokabular; Aufräumen nach dem Lauf über die festen IDs. Tests laufen ohne Anmeldung. +- Fälle: Krit. 1, 2, 3, 4, 5, 6, 8, Offline (siehe Matrix), Tastaturbedienung. +- `supabase/catalog/testdata` reicht nicht (3 Spiele); der echte Startbestand ist für E2E nicht verlässlich (Inhalt wächst). + +### 7.3 Messung Krit. 7 (Projekt `perf`) + +- Eigenes Playwright-Projekt `perf` in `e2e/playwright.config.ts`, nur registriert, wenn `PERF_WEB_URL` gesetzt ist (Muster `throttle`/`spike`). **Nur lesend**, kein Zugriff auf `db.ts`, keine Anmeldung, keine Schlüssel außer dem öffentlichen Anon-Key im Web-Build. +- Drosselung über CDP `Network.emulateNetworkConditions`: `latency` 100 ms, Download und Upload je 10 Mbit/s (1 250 000 Byte/s). +- Ablauf: Web-App von Staging öffnen (Startbestand eingespielt), Semantics einschalten; aus einer festen Liste von mindestens 100 Suchen (vollständige Titel, Teile, alternative Titel, Verlage, Wortkombinationen, nur Filter) je Suche das Feld leeren, den Begriff tippen, **Zeit ab dem letzten Tastendruck bis zur Sichtbarkeit der ersten Treffer-Kachel** (bzw. des Leerzustands bei absichtlich leeren Suchen) messen. Die erste Suche nach dem Laden der App zählt mit (Verbindungsaufbau). +- Ergebnis: p50, p95, Maximum als Testausgabe und Anhang; **bestanden, wenn p95 ≤ 2000 ms**. +- Mobile Apps: Stichprobe von 20 Suchen auf einem Android-Gerät oder Emulator mit 4G-Profil (Emulator-Netzwerkprofil bzw. Network Link Conditioner auf iOS), manuell durch Chris oder test-engineer, Ergebnis im PR 3c. Die Web-Messung gilt als maßgeblicher Nachweis; Abweichung im PR begründet. +- Ausführung manuell (`workflow_dispatch`) bzw. lokal gegen Staging, nicht bei jedem PR. + +**Nicht automatisiert testbar, manuelle Prüfung:** Krit. 7 auf Mobilgeräten (Stichprobe), Screenreader-Durchgang (TalkBack, VoiceOver, NVDA/VoiceOver im Web), inhaltliche Plausibilität der Relevanz mit dem echten Startbestand (Chris). + +## 8. Aufgaben + +### 8.1 PR 3a – Backend (R1.1), Branch `feature/3a-katalog-suche-backend`, `Refs #3` + +| # | Agent | Aufgabe | abhängig von | +|---|---|---|---| +| B1 | backend-dev | Migration `_catalog_search.sql` nach 3.2: Erweiterungen, `search_normalize`, generierte Spalten + Trigramm-Indizes, `game_alt_titles` (Checks, Unique, Trigger `set_updated_at` und `game_links_mark_manual('alt_titles')`, RLS, Grants), `manual_fields`-Check mit vollständiger Liste + `alt_titles`, RPC `search_catalog_games` nach 4.2 (Prüfungen, Treffer-Regel, Relevanz, Filter, Sortierung, Rechte: `revoke` von allen, dann `grant execute` an `anon`, `authenticated`) | – | +| B2 | backend-dev | pgTAP `09_catalog_search.test.sql` nach 7.1; `00_schema.test.sql` Tabellenzahl | B1 | +| B3 | backend-dev | Startbestand-Werkzeug nach 4.3: `alt_titles` parsen, prüfen, SQL erzeugen; Deno-Tests (neu und geänderter Test Z. 253); Testdaten-CSV um alternative Titel ergänzen; alle erzeugten Dateien neu erzeugen | B1 | +| B4 | backend-dev | Doku nach 3.3: `docs/datenmodell-r1.md`, `supabase/README.md`, `docs/katalog-pflege.md` (Abschnitt „Alternative Titel pflegen“, SQL-Bausteine wie in pgTAP ausgeführt) | B1, B3 | +| B5 | backend-dev | Annahmen prüfen und messen: Erweiterungen lokal verfügbar (und mit devops auf Staging, D1); `statement_timeout` von `anon`/`authenticated`; Skript `supabase/tests/perf/catalog_search_perf.sql` mit synthetischen Daten (nur lokal, in einer zurückgerollten Transaktion), `explain (analyze, buffers)` und p95 im PR dokumentieren | B1 | +| T1a | test-engineer | Review der pgTAP- und Deno-Abdeckung gegen 7.1, Lücken ergänzen | B2, B3 | + +### 8.2 PR 3b – App (R1.1), Branch `feature/3b-katalog-suche-app`, `Refs #3` + +| # | Agent | Aufgabe | abhängig von | +|---|---|---|---| +| U1 | ui-designer | Entwurf Suche (kompakt, mittel, groß), Treffer-Kachel, Filterbereich (Sheet und Seitenleiste), alle Zustände; gemeinsame Komponenten `AppStateMessage` und Listenfuß in `core/widgets/`; ARB-Texte de/en; Eintrag in `docs/design-system.md` | – (Vertrag § 4.2 und 5.6) | +| F1 | flutter-dev | Domäne und Daten nach 5.1–5.3: Modelle ohne `toJson`, `CatalogRepository`, `SupabaseCatalogRepository` (Parameterbau, Zeitlimit, Fehlerabbildung, Logging ohne Suchbegriff), DTOs, Provider | – (gegen den Vertrag § 4.2; Integration gegen echte RPC nach 3a) | +| F2 | flutter-dev | `CatalogSearchController` und `CatalogSearchState` nach 5.4 (Entprellung, letzte Anfrage zählt, Nachladen, Wiederholen) | F1 | +| F3 | flutter-dev | Route `/catalog` mit Query-Parametern, URL-Abgleich (5.5, mit Rückfall), `CatalogSearchScreen`/`CatalogSearchView` nach 5.6 mit den Komponenten aus U1, Einstieg auf der Startseite, `onTap = null`, Melden-Callback `null` | F2, U1 | +| T1 | test-engineer | Unit-Tests (Repository mit gemocktem Client, DTO, Fehlerabbildung, Query, URL-Rundreise, Controller mit `fakeAsync`) | F1, F2 | +| T2 | test-engineer | Widget-Tests nach 7.1 (Zustände, Filter in beiden Layouts, Listenfuß, a11y-Guidelines, `textScaler` 2.0, de/en) | F3 | + +### 8.3 PR 3c – E2E und Messung (R1.1), Branch `feature/3c-katalog-suche-e2e`, `Closes #3` (siehe Spec-F4) + +| # | Agent | Aufgabe | abhängig von | +|---|---|---|---| +| T4 | test-engineer | Web-E2E `kat01-suche.spec.ts` nach 7.2 inkl. Fixture-Helfer und Aufräumen | 3a, 3b gemergt | +| T3 | test-engineer | Projekt `perf` nach 7.3 (Drosselung, Suchliste, Auswertung p50/p95) | 3b gemergt | +| D1 | devops | Staging bereitstellen bzw. aktualisieren: Migrationen, Web-Build, Erweiterungen prüfen (B5); CI-Job für das E2E-Projekt `web` um die neue Datei ergänzen (falls nicht automatisch); `workflow_dispatch`-Job für `perf` mit `PERF_WEB_URL` | 3a gemergt | +| C1 | Chris | Startbestand auf Staging einspielen (Spec #2 Runbook), Messung auslösen bzw. abnehmen; mobile Stichprobe; Relevanz mit echten Daten plausibilisieren | D1, T3 | + +### 8.4 Reihenfolge und Parallelität + +``` +3a B1 → B2, B3, B5 (parallel) → B4 → T1a → Merge (Chris) +3b U1 ‖ F1 → F2 → F3 → T1, T2 (startet parallel zu 3a, gegen den Vertrag § 4.2) + └─ Integration gegen die echte RPC, sobald 3a gemergt ist → Merge (Chris) +3c D1 (nach 3a) → T4, T3 (nach 3b) → C1 → Merge (Chris), Issue #3 geschlossen +``` + +- Der Vertrag in § 4.2 ist verbindlich für 3a und 3b; Änderungen daran nur über diese Spec. +- 3b wird erst nach 3a gemergt (sonst ruft die App eine fehlende RPC auf). +- Merge nur durch Chris. Kein ADR (9.4). + +## 9. Risiken & offene Fragen + +### 9.1 Risiken + +| # | Risiko | Auswirkung | Gegenmaßnahme | +|---|---|---|---| +| R1 | `pg_trgm` oder `unaccent` sind im self-hosted Image nicht verfügbar (unverifiziert) | Migration schlägt auf Staging/Produktion fehl | B5 prüft vor dem Merge von 3a. Rückfall ohne `unaccent`: `search_normalize` mit `translate()` und `replace()` für die gängigen Zeichen (äöüàáâéèêíóúñç…, ß → ss), ebenfalls `immutable`. Rückfall ohne `pg_trgm`: keine Trigramm-Indizes, Sortierung ohne Ähnlichkeit (Titel, ID); bei R1-Größe ausreichend schnell. | +| R2 | Abfrage mit mehreren `exists`/`or` über drei Tabellen wird bei wachsendem Katalog langsam | Krit. 7 verfehlt | Budget 150 ms bei 10 000 Spielen (B5); Rückfall: zusammengefasster Suchtext je Spiel in einer eigenen Spalte, gepflegt per Trigger an `games`, `game_alt_titles`, `game_publishers`, `publishers` | +| R3 | Staging existiert noch nicht oder hat keinen Startbestand (ADR-0002: ein Server für Staging/Produktion) | Krit. 7 nicht abnehmbar, #3 bleibt offen | D1 früh einplanen; Startbestand-Fixtures für die Messung reichen nicht, es braucht die echte Größenordnung | +| R4 | `search_catalog_games` ist ohne Anmeldung aufrufbar | Last durch automatisierte Aufrufe | Eingabegrenzen, `statement_timeout` (B5), nur lesend; Mengenbegrenzung am Gateway erst bei Bedarf (nicht in R1) | +| R5 | Nachladen per `offset` bei gleichzeitiger Katalogänderung | Ein Spiel doppelt oder übersprungen | Doppelte IDs werden verworfen; Katalog ändert sich selten; akzeptiert | +| R6 | Änderung von `search_normalize` später | Generierte Spalten veralten nicht automatisch | Kommentar an der Funktion; Neuanlage der Spalten per Migration | +| R7 | Parallelarbeit mit 2b/2c und #12 | Konflikte bei `manual_fields`-Check, Tabellenzahl in `00_schema`, Fremdschlüssel-Wächter | Regel „vollständige Liste“ (3.2 Nr. 5); Tabellenzahl beim Merge abgleichen; Wächter-Einordnung von `game_alt_titles` (9.5) | +| R8 | Relevanz wirkt mit echten Daten unpassend | Nutzer finden das erwartete Spiel weiter unten | Stufen sind einfach anpassbar (nur RPC); Plausibilisierung durch Chris (C1) | +| R9 | Schreibweise „ae“/„oe“/„ue“ findet Umlaut-Titel nicht | „Kaese“ findet „Käse“ nicht | Spec-F5; spätere Erweiterung ohne Datenmigration der Clients möglich (nur Funktion + Spalten) | +| R10 | Krit. 7 wird im Web gemessen, gilt aber für mobile Apps | Abweichung der Messbedingung | Web als maßgeblicher Nachweis, mobile Stichprobe (7.3), Begründung im PR | + +### 9.2 Offene Fragen an Chris (keine blockiert die Umsetzung) + +| # | Frage | Optionen | Empfehlung | Wirkung | +|---|---|---|---|---| +| Spec-F1 | Mehrere Genres (bzw. Mechaniken) gewählt: muss ein Spiel **alle** haben oder **eines davon**? | A: alle (UND); B: eines davon (ODER innerhalb einer Filterart, UND zwischen Filterarten) | **A**, wörtliche Lesart von Krit. 4/8 („zu allen gewählten Filtern“, Klärung 13). B liefert beim Entdecken mehr Treffer; Umstellung später nur in der RPC. | E7, 4.2, 7.1 Krit. 4 | +| Spec-F2 | Bedeutung von Spielerzahl, Dauer und Alter als Filter; Umgang mit fehlenden Angaben | Spielerzahl: Zahl n, passt wenn n im Bereich min–max. Dauer: „bis X Minuten“ (30/45/60/90/120/180), passt wenn die längste Angabe ≤ X; alternativ Bereiche („kurz/mittel/lang“). Alter: „ab X Jahren geeignet“, passt wenn Mindestalter ≤ X. Fehlende Angabe: ausschließen oder einschließen. | **Wie links beschrieben** (n im Bereich; „bis X Minuten“; Mindestalter ≤ X) und **fehlende Angaben ausschließen** (ein Spiel ohne Spielerzahl passt nicht nachweislich). Im Startbestand sind alle Angaben Pflicht (Spec #2 SB2), die Regel betrifft nur spätere Einträge. | 4.2, 5.2, 5.6, 7.1 | +| Spec-F3 | Was zeigt die Suche ohne Suchbegriff und ohne Filter? | A: den ganzen Katalog alphabetisch; B: nur einen Hinweis „Suchbegriff eingeben oder Filter wählen“ | **A** (Entdecken ohne Sonderfall, E8) | E8, 4.2, 5.6 | +| Spec-F4 | Krit. 6 („Möglichkeit, das Spiel zu melden“) hängt an KAT-04 (#13). Wann gilt #3 als erledigt? | A: #3 liefert Hinweis + Einstiegspunkt; Krit. 6 wird im PR als „Hinweis erfüllt, Melden mit #13“ abgehakt, #3 wird mit 3c geschlossen, #13 übernimmt die Aktion als eigenes Kriterium; B: #3 bleibt offen, bis #13 fertig ist | **A** (entspricht der Teilabhängigkeit im Issue; bei B hinge der Abschluss von #3, einer R1.1-Story, an #13 aus R1.2) | E11, 5.6, 8.3 | +| Spec-F5 | Soll „ae/oe/ue/ss“ als Schreibweise für „ä/ö/ü/ß“ in R1 treffen (z. B. „Kaese“ → „Käse“)? | A: nein in R1 (nur Akzente/Umlaute entfernen); B: ja (zweite normalisierte Form je Titel) | **A**; Krit. 1 verlangt Groß/Klein und Sonderzeichen. B ist später ohne App-Änderung nachrüstbar (R9). | 2 (Konkretisierung), 3.2 Nr. 2 | +| Spec-F6 | Alternative Titel beim Zusammenführen zweier Katalogspiele (2b, `admin_merge_games`) | A: bleiben am ausgeblendeten Quell-Eintrag (wie Genres/Verlage); der Admin ergänzt am Ziel bei Bedarf Titel per SQL (Runbook-Schritt vor dem Zusammenführen); B: werden automatisch auf das Ziel übertragen (ohne Duplikate) | **A** für R1: einfach, kein Eingriff in die freigegebene Spec #2; der Haupttitel der Quelle hat keine Sprache und ließe sich ohnehin nicht automatisch als alternativer Titel übernehmen | 9.5 (2b), Fremdschlüssel-Wächter | +| Spec-F7 | Sprachen alternativer Titel | A: nur `de` und `en` (wie `description_locale`); B: beliebige Sprache (ISO 639-1, z. B. Originaltitel auf Französisch) | **A** (Klärung 3 nennt Deutsch und Englisch; erweiterbar per Migration) | 3.2 Nr. 4, 4.3 | + +### 9.3 Designwahlen (zur Bestätigung mit der Freigabe) + +| # | Wahl | Begründung | Verworfene Alternative | +|---|---|---|---| +| W1 | RPC statt PostgREST-Filter auf `catalog_games` (E1) | Relevanz, UND-Filter über Zuordnungen und Verlagssuche in einer Anfrage | mehrere Anfragen bzw. Filter im Client | +| W2 | Generierte Spalten statt Ausdrucksindex (E3) | einfache Abfragen, Normalisierung einmal beim Schreiben | Ausdrucksindex `search_normalize(title)`; Suchtext-Spalte per Trigger (Rückfall R2) | +| W3 | `limit`/`offset` mit `total_count` (E6) | einfach, Trefferzahl für Anzeige und Ansage | Keyset-Paging | +| W4 | Suche ab 2 Zeichen | ein Zeichen trifft fast alles und belastet die Datenbank ohne Nutzen | ab 1 Zeichen; ab 3 Zeichen (Trigramm-optimal, aber „Go“ wäre nicht suchbar) | +| W5 | Alternative Titel ohne Schreibrecht für Nutzer (E2) | kein Anwendungsfall in R1, keine Angriffsfläche | Schreiben für Ersteller privater Spiele (Muster `game_genres_write`) | +| W6 | Datenklassen ohne `toJson` (E9) | macht den Merkpunkt aus Spec #2 technisch unverletzbar | Dokumentation allein | +| W7 | Kachel in #3 nicht antippbar, #4 verdrahtet | kein toter Link auf eine fehlende Detailseite | Platzhalter-Detailseite in #3 | +| W8 | Keine Suchbegriffe in Logs, Aufruf per `POST` | Datensparsamkeit (QUE-04) | Suchanalytik | + +### 9.4 Ausstehende Prozessschritte + +- Spec-Review durch Chris; Label `status/spec-review` ist gesetzt, die Fragen stehen zusätzlich als Kommentar im Issue #3. `needs-clarification` wird nicht gesetzt, weil keine Frage die Umsetzung blockiert (jede hat eine Empfehlung, die Umstellung betrifft jeweils nur die RPC bzw. einen Text). +- **Kein ADR nötig:** `pg_trgm` und `unaccent` sind Standard-Erweiterungen von PostgreSQL-contrib im Supabase-Image, ohne neue Dienste, Pakete oder Lizenzen; die Entscheidung lässt sich per Migration zurücknehmen. Die übrigen Entscheidungen bleiben innerhalb von ADR-0001/0002. +- Nach Freigabe: B1 (backend-dev) und parallel U1, F1 (ui-designer, flutter-dev); D1 (devops) früh wegen R3. + +### 9.5 Hinweise für andere Stories, nicht Teil von #3 + +- **KAT-05 2b (#2):** Der Fremdschlüssel-Wächter (Spec #2 § 7) muss `game_alt_titles.game_id` einordnen: Empfehlung „bewusst ausgelassen“ (bleibt am Quell-Eintrag, Spec-F6 A); Runbook-Schritt „vor dem Zusammenführen relevante Titel der Quelle als alternative Titel am Ziel anlegen“. Der `manual_fields`-Check in 2b/2c muss `alt_titles` enthalten (Regel 3.2 Nr. 5). Tabellenzahl in `00_schema` abgleichen. +- **KAT-02 (#4):** Route `/catalog/games/:id`, `onTap` der Treffer-Kachel verdrahten; Domänenmodell `CatalogGame` (Detail) neu, `CatalogGameSummary` bleibt für Listen. Alternative Titel sind in KAT-02 Krit. 1 nicht gefordert. +- **KAT-04 (#13):** `onReportGame`-Callback im Leerzustand setzen (Suchbegriff als vorbelegter Titel, Anmeldung über `from`). „Gleicher normalisierter Titel“ (Klärung 15) auf Basis von `search_normalize` plus Entfernen von Artikeln (eigene Funktion, nicht in `search_normalize`, sonst ändert sich die Suche); Trigramm-Ähnlichkeit über `extensions.similarity` auf `title_search`. Krit. 6 von KAT-01 wird mit #13 vollständig (Spec-F4). +- **QUE-09 (#29):** Startseite ohne Anmeldung bettet `CatalogSearchView` ein. +- **KTO-02 (#14), SAM-01, PAR-01 (#15):** Katalogsuche über `CatalogRepository.search` wiederverwenden. Wo vorläufige private Spiele wählbar sein sollen (Klärung 36), ist das eine eigene Abfrage auf eigene private Spiele, nicht Teil von `search_catalog_games` (die RPC liefert bewusst nur den Katalog). +- **KAT-03 (#12):** neuer `manual_fields`-Schlüssel mit vollständiger Liste inkl. `alt_titles`. +- **Import (SAM-08 #25, Import-Spec):** alternative Titel aus der Quelle schreibt der Import als `catalog_importer` (Ausnahme im Trigger greift bereits). + +## 10. Änderungsprotokoll + +### 2026-11-03 – Entwurf + +Erste Fassung durch architect, wartet auf Freigabe und Antworten zu Spec-F1 bis Spec-F7. From ece8e3eef25da467ed3838a209eb633aace93035 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 19:55:11 +0000 Subject: [PATCH 08/30] =?UTF-8?q?docs(catalog):=20Spec=20KAT-04=20pr=C3=A4?= =?UTF-8?q?zisiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalisierung mit Akzent-Faltung, Trigramm-Index gestrichen, Abweichung von Spec #2 3.4 vermerkt, testbare Tagesgrenze. Refs #13 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/13-fehlendes-spiel-melden.md | 41 +++++++++++++++---------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/docs/specs/13-fehlendes-spiel-melden.md b/docs/specs/13-fehlendes-spiel-melden.md index e4ed896..ee72fbd 100644 --- a/docs/specs/13-fehlendes-spiel-melden.md +++ b/docs/specs/13-fehlendes-spiel-melden.md @@ -98,7 +98,8 @@ Falls #3 die Erweiterung schon anlegt, ist die Zeile ein No-op. Kein `unaccent` |---|---| | `unique (provisional_game_id) where provisional_game_id is not null` | 1:1 Meldung ↔ vorläufiger Eintrag (E1). Falls 2c ihn schon anlegt: entfällt. | | `(target_game_id) where target_game_id is not null` | Hinweis am Eintrag: „gibt es eine Meldung von mir mit diesem Ziel?“ (5.4) | -| GIN `(extensions.gin_trgm_ops)` auf `public.normalize_game_title(title)` in `games`, `where visibility = 'catalog' and hidden_at is null and merged_into_id is null` | Ähnlichkeitssuche (4.3). Bei ~200–2000 Katalogspielen nicht zwingend, aber billig; KAT-01 kann ihn mitnutzen. | + +**Kein Trigramm-Index:** Ein GIN-Index mit `gin_trgm_ops` würde nur über den Operator `%` genutzt, nicht über `similarity(...) >= 0.8` (3.3). Bei ~200–2000 Katalogspielen ist ein sequenzieller Durchlauf schnell genug (einfach vor clever). Braucht KAT-01 einen Trigramm-Index für die Suche, legt #3 ihn an. **Rechte auf `game_reports`** (E3) @@ -108,6 +109,8 @@ grant insert (owner_id, title, publisher_name, year, link, provisional_game_id) grant update (result_seen_at) on public.game_reports to authenticated; ``` +**Bewusste Änderung gegenüber Spec #2 § 3.4:** Dort haben Nutzer kein Update-Recht auf `game_reports`, und `08_game_reports.test.sql` prüft, dass nur `postgres` Meldungen ändert. 13a erlaubt Nutzern genau eine Spalte (`result_seen_at`) an **eigenen**, bereits bearbeiteten Meldungen. Das bleibt mit KAT-05 Krit. 9 vereinbar (verboten ist das Ändern **fremder** Meldungen); B2 passt den Test aus `08` an. + `select` bleibt wie in 2c. **Nicht** schreibbar für Nutzer: `id`, `origin`, `status`, `bgg_id`, `target_game_id`, `resolved_at`, `created_at`, `updated_at` (Fehler 42501). Damit setzt die Datenbank `origin = 'user'`, `status = 'open'` (Krit. 8) und `created_at = now()` (Tageslimit) selbst. `delete` bekommt der Nutzer nicht. **Merkpunkt für den Client (aus #2 PR 2a):** Clients schreiben `games` und `game_reports` nie mit `id`, `created_at` oder `updated_at`, und ein `toJson()` eines Modells geht nie ungefiltert an `insert`/`update`. In #13 schreibt die App ohnehin nur über die RPCs (4.2, 4.4) und das eine Update von `result_seen_at` (4.5) mit einer ausdrücklichen Spaltenliste. @@ -124,7 +127,8 @@ grant update (result_seen_at) on public.game_reports to authenticated; - `before insert on public.game_reports for each row`, nur wenn `new.origin = 'user'`. - Sperre gegen gleichzeitige Inserts: `perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0))`. Ohne Sperre könnten zwei parallele Meldungen beide die Zählung „9“ sehen. -- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `created_at` am **selben Kalendertag in Europe/Berlin** (Spec-F4): `(created_at at time zone 'Europe/Berlin')::date = (now() at time zone 'Europe/Berlin')::date`. Bei ≥ 10 Fehler `report_daily_limit` (P0001). +- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `created_at` am **selben Kalendertag in Europe/Berlin** (Spec-F4): `public.report_day(created_at) = public.report_day(now())`. Bei ≥ 10 Fehler `report_daily_limit` (P0001). +- Hilfsfunktion `public.report_day(p_at timestamptz) returns date` = `(p_at at time zone 'Europe/Berlin')::date`, `language sql`, `immutable`, `search_path = ''`, `execute` nur für den Eigentümer. Sie ist eigens herausgezogen, damit pgTAP die Tagesgrenze mit festen Zeitstempeln prüfen kann (`now()` lässt sich im Test nicht steuern). - Import-Meldungen (`origin = 'import'`, SAM-08) werden weder gezählt noch geprüft (N12). - Funktion `security invoker` mit `search_path = ''`. Der Nutzer sieht per RLS alle eigenen Meldungen, mehr braucht die Zählung nicht; Studio (`postgres`) umgeht RLS ohnehin. `execute` wie oben entzogen (Trigger-Funktionen brauchen kein `execute`-Recht des Aufrufers). - Die Zahl 10 steht als Konstante in der Funktion (kein Konfigurationswert); eine Änderung ist eine neue Migration. @@ -135,13 +139,14 @@ grant update (result_seen_at) on public.game_reports to authenticated; **Funktion `public.normalize_game_title(p_title text) returns text`**, `language sql`, `immutable`, `strict`, `parallel safe`, `search_path = ''`. Schritte in dieser Reihenfolge: -1. `lower()`; `ß` → `ss`. -2. Alles, was kein Buchstabe und keine Ziffer ist (Satz- und Sonderzeichen, Bindestriche, Doppelpunkte, Apostrophe, Emojis), wird zu einem Leerzeichen. Umlaute und andere Buchstaben mit Akzent **bleiben** (Spec-F3). Umsetzung mit `regexp_replace(…, '[^[:alnum:]]+', ' ', 'g')`; backend-dev prüft per Test, dass `[:alnum:]` in der Datenbank-Locale Umlaute als Buchstaben erkennt (sonst explizite Zeichenklasse `[^a-z0-9äöüàáâèéêìíîòóôùúûñçåæøœ]`). -3. **Führende Artikel** entfernen, einmal, nur am Anfang (Spec-F3): Deutsch `der`, `die`, `das`, `den`, `dem`, `des`, `ein`, `eine`, `einen`, `einem`, `einer`, `eines`; Englisch `the`, `a`, `an`. -4. Mehrfache Leerzeichen zu einem, `trim`. -5. Ist das Ergebnis leer (Titel bestand nur aus einem Artikel oder Sonderzeichen), wird der Wert aus Schritt 2 (ohne Artikelentfernung) verwendet. +1. `lower()`; `ß` → `ss`, `æ` → `ae`, `œ` → `oe`. +2. **Akzente falten** (Spec-F3) mit einer festen, handgeschriebenen Zuordnung über `translate()` (z. B. `äàáâãå` → `a`, `ëèéê` → `e`, `ïìíî` → `i`, `öòóôõø` → `o`, `üùúû` → `u`, `ñ` → `n`, `ç` → `c`, `ÿý` → `y`). `translate()` ist `immutable`; `unaccent` wird nicht verwendet (nur `stable`). +3. Alles, was danach kein Zeichen aus `a–z` oder `0–9` ist (Satz- und Sonderzeichen, Bindestriche, Doppelpunkte, Apostrophe, Emojis, übrige Schriftzeichen), wird zu einem Leerzeichen: `regexp_replace(…, '[^a-z0-9]+', ' ', 'g')`. Die explizite Zeichenklasse hängt nicht von der Datenbank-Locale ab. +4. **Führende Artikel** entfernen, einmal, nur am Anfang (Spec-F3): Deutsch `der`, `die`, `das`, `den`, `dem`, `des`, `ein`, `eine`, `einen`, `einem`, `einer`, `eines`; Englisch `the`, `a`, `an`. +5. Mehrfache Leerzeichen zu einem, `trim`. +6. Ist das Ergebnis leer, wird der Wert aus Schritt 3 (ohne Artikelentfernung) verwendet; ist auch der leer (Titel nur aus Sonderzeichen oder nicht-lateinischen Schriftzeichen), `lower(btrim(p_title))`. -Beispiele (werden pgTAP-Fälle): „Die Siedler von Catan“ → `siedler von catan`; „CATAN!“ → `catan`; „7 Wonders: Duel“ → `7 wonders duel`; „The Crew“ → `crew`; „Café International“ → `café international`; „Das“ → `das`. +Beispiele (werden pgTAP-Fälle): „Die Siedler von Catan“ → `siedler von catan`; „CATAN!“ → `catan`; „7 Wonders: Duel“ → `7 wonders duel`; „The Crew“ → `crew`; „Café International“ → `cafe international`; „Mölkky“ und „Molkky“ → `molkky`; „Das“ → `das`. **Regel „sehr ähnlich“** zwischen Meldung (Titel `t`, Jahr `y`) und Katalogspiel `g`: @@ -159,7 +164,7 @@ Beispiele (werden pgTAP-Fälle): „Die Siedler von Catan“ → `siedler von ca | Objekt | Art | `execute`/Recht | Zweck | |---|---|---|---| -| `normalize_game_title(text)` | Funktion, sql, immutable | `anon`, `authenticated` (harmlos, rein rechnend; nötig für Index und Abfragen als Aufrufer) | Normalisierung (3.3) | +| `normalize_game_title(text)` | Funktion, sql, immutable | `anon`, `authenticated` (harmlos, rein rechnend; nötig für Abfragen als Aufrufer, später auch für die Suche in #3) | Normalisierung (3.3) | | `find_similar_catalog_games(p_title text, p_year smallint)` | RPC, security invoker | `authenticated` | Ähnliche Katalogspiele anzeigen (Krit. 2) | | `report_missing_game(...)` | RPC, security invoker | `authenticated` | Meldung + vorläufiger Eintrag + optional Listeneintrag (Krit. 1, 2, 3, 7, 8) | | `adopt_report_target(p_report uuid)` | RPC, **security definer** (Eigentümer `postgres`) | `authenticated` | Wechsel auf den Zieleintrag bei „abgelehnt mit Verweis“ (Krit. 5) | @@ -269,7 +274,7 @@ Kein RPC nötig; PostgREST-Abfragen als angemeldeter Nutzer: ### 4.7 Doku -- `docs/datenmodell-r1.md`: `game_reports.result_seen_at`, Indizes, Spaltenrechte, Policies, Trigger `game_reports_daily_limit`, die vier Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes. +- `docs/datenmodell-r1.md`: `game_reports.result_seen_at`, Indizes, Spaltenrechte, Policies, Trigger `game_reports_daily_limit`, die Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes. - `docs/katalog-pflege.md`, Abschnitt „Meldungen bearbeiten“ (aus 2c) ergänzen: Nutzer-Meldungen haben immer einen vorläufigen Eintrag; „abgelehnt mit Verweis“ lässt dem Nutzer die Wahl (`adopt_report_target`), der Admin hängt dabei nichts um; vor `approved` die Edition berücksichtigen (Runbook aus 2c); Meldungen nie von Hand auf einen anderen Status setzen (nur über `admin_resolve_report`), weil die App den Hinweis aus Status und `merged_into_id` ableitet. - `supabase/README.md`: neue RPCs und Rechte. @@ -388,12 +393,12 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We | AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task | |---|---|---|---|---|---| | Krit. 1 | `GameReportDraft`: Titel Pflicht (auch nur Leerzeichen), Längen, Jahr, Link-Format; Fehlercode-Mapping | Formular: Feldfehler am Feld, Bestätigung nach Erfolg, Listenwahl | Playwright: leere Suche → „Spiel melden“ → Titel vorbelegt → absenden → Bestätigung | `report_missing_game` legt Meldung (`status = open`, `origin = user`) und vorläufiges Spiel (`private`, `owner_id = A`, nur Titel/Jahr) an; Trimmen; alle Validierungsfehler (`report_title_required` … `report_list_invalid`); `anon` → 42501 | B1, B2, F2, T1, T2 | -| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` beim Senden → zurück zu Kandidaten | Kandidatenliste, Bestätigungs-Checkbox aktiviert Button, Tastatur im Web, Semantics | Playwright: Meldung zu „Catan“ zeigt vorhandenes „CATAN“ | `normalize_game_title`: Beispiele aus 3.3 (Artikel de/en, Sonderzeichen, Umlaute bleiben, `ß`, nur Artikel); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr trifft; Trigramm ≥ 0,8 bei Jahr ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; Jahr fehlt → Regel nach Spec-F2; ausgeblendete, zusammengeführte und private Spiele (auch eigene) werden nicht geliefert; höchstens 5, Sortierung; `report_missing_game` ohne Bestätigung bei Treffer → `report_similar_exists`, mit Bestätigung gespeichert | B1, B2, F2, U1, T1, T2 | +| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` beim Senden → zurück zu Kandidaten | Kandidatenliste, Bestätigungs-Checkbox aktiviert Button, Tastatur im Web, Semantics | Playwright: Meldung zu „Catan“ zeigt vorhandenes „CATAN“ | `normalize_game_title`: Beispiele aus 3.3 (Artikel de/en, Sonderzeichen, Akzente und Umlaute gefaltet, `ß`, nur Artikel, nur nicht-lateinische Zeichen); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr trifft; Trigramm ≥ 0,8 bei Jahr ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; Jahr fehlt → Regel nach Spec-F2; ausgeblendete, zusammengeführte und private Spiele (auch eigene) werden nicht geliefert; höchstens 5, Sortierung; `report_missing_game` ohne Bestätigung bei Treffer → `report_similar_exists`, mit Bestätigung gespeichert | B1, B2, F2, U1, T1, T2 | | Krit. 3 | – | Bestätigung zeigt Liste; Badge „vorläufig“ | Playwright: gemeldetes Spiel steht in der Wunschliste (sofern #6/#8 vorhanden) | `p_list` legt `collection_items` zum vorläufigen Spiel an; B sieht weder vorläufiges Spiel noch Listeneintrag noch Meldung von A; `anon` sieht nichts; Rollback: schlägt der Listeneintrag fehl, gibt es weder Spiel noch Meldung | B1, B2, F2, F3 | | Krit. 4 | `outcomeOf`: `approved` mit/ohne Edition-Hinweis | `ReportOutcomeNotice` für `approved` (de/en), „Verstanden“ blendet aus | integration_test/Playwright: Meldung per SQL-Hilfsfunktion im Testsetup mit `admin_resolve_report(…, 'approved', …)` bearbeiten → Hinweis am Eintrag | nach `admin_resolve_report` (2c) ist der Sammlungseintrag von A am Zielspiel mit Liste/Notiz/Kaufdatum; vorläufige Variante → `variant_id` null, private Variante bleibt am vorläufigen Eintrag und ist für A lesbar (E7); `result_seen_at` setzbar | B2, F3, T1, T2 | | Krit. 5 | `outcomeOf`: `merged`, `rejectedWithTarget`, `rejectedAdopted` | Hinweise und Aktion „wechseln“ mit Bestätigungsdialog | Playwright: abgelehnt mit Verweis → wechseln → Eintrag zeigt Zielspiel | `adopt_report_target`: Erfolg hängt Sammlung und Partien von A um, setzt `merged_into_id`/`hidden_at`, Status bleibt `rejected`, `result_seen_at` gesetzt; Fehler `report_not_found` (fremde Meldung von B, unbekannte ID), `report_not_adoptable` (`open`, `approved`, `rejected` ohne Ziel), `report_already_adopted` (zweiter Aufruf), `report_target_not_listed` (Ziel inzwischen ausgeblendet), `report_foreign_references` (Fixture als `postgres`); `anon` → 42501 | B1, B2, F3, T1, T2 | | Krit. 6 | `outcomeOf`: `rejected` ohne Ziel | dauerhafte Markierung „Abgelehnt“ bleibt nach „Verstanden“ | – | nach `admin_resolve_report(…, 'rejected')` bleibt das vorläufige Spiel unverändert privat und für A lesbar | B2, F3, T2 | -| Krit. 7 | Mapping `report_daily_limit` → `dailyLimit` | Limit-Hinweis, Eingaben bleiben erhalten | – | 10 Meldungen am selben Berliner Kalendertag ok, 11. → `report_daily_limit` und **kein** vorläufiges Spiel übrig (Rollback); Meldungen von gestern (Berliner Zeit, Fixture mit `created_at` als `postgres` gesetzt) zählen nicht; Grenzfall 23:30 UTC (= nächster Tag in Berlin im Winter); `origin = 'import'` als `postgres` eingefügt zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; direkter Insert mit `created_at`, `origin` oder `status` → 42501 | B1, B2, F2, T1 | +| Krit. 7 | Mapping `report_daily_limit` → `dailyLimit` | Limit-Hinweis, Eingaben bleiben erhalten | – | 10 Meldungen am selben Berliner Kalendertag ok, 11. → `report_daily_limit` und **kein** vorläufiges Spiel übrig (Rollback); Meldungen von gestern (Fixture mit `created_at = now() - interval '1 day'` als `postgres` gesetzt) zählen nicht; Tagesgrenze über `report_day` mit festen Zeitstempeln (`2026-01-15 23:30 UTC` → 16.01., `2026-07-15 21:59 UTC` → 15.07., `22:00 UTC` → 16.07.); `origin = 'import'` als `postgres` eingefügt zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; direkter Insert mit `created_at`, `origin` oder `status` → 42501 | B1, B2, F2, T1 | | Krit. 8 | – | Status „offen“ in Liste und Detail | – | Nutzer kann `status`, `target_game_id`, `resolved_at` weder per Insert noch per Update setzen (42501); Update von `result_seen_at` bei `status = open` → 0 Zeilen; nach dem Insert ist der Status `open`; kein Trigger/Job ändert ihn | B2, T1 | | Sicherheit Krit. 1, 3 | – | – | – | B liest A's Meldungen und vorläufige Spiele nicht; B kann A's Meldung nicht wegklicken (0 Zeilen) und nicht übernehmen; Insert mit `owner_id = A` als B → Policy-Fehler; `provisional_game_id` auf ein fremdes, ein Katalog- oder ein ausgeblendetes Spiel → Policy-Fehler; Spaltenrechte per `has_column_privilege` | B2 | | Sicherheit Krit. 2 | – | – | – | `anon`: kein `select`, `insert`, `update` auf `game_reports`, keine RPC ausführbar | B2 | @@ -402,7 +407,7 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We **Nicht automatisiert:** echte Parallelität des Limits (die Sperre wird per Code-Review und einem dokumentierten manuellen Test mit zwei `psql`-Sitzungen geprüft); Bearbeitung echter Meldungen im Studio auf Staging (Chris). -**Bestehende Tests:** `08_game_reports.test.sql` aus 2c erwartet „kein Insert für `authenticated`“; 13a passt diesen Test auf die neuen Spaltenrechte an (B2). `00_schema.test.sql` bleibt bei 21 Tabellen. +**Bestehende Tests:** `08_game_reports.test.sql` aus 2c erwartet „kein Insert und kein Update für `authenticated`“ bzw. „nur `postgres` ändert Meldungen“; 13a passt beide Erwartungen auf die neuen Spaltenrechte an (Insert der erlaubten Spalten, Update nur `result_seen_at` an eigenen bearbeiteten Meldungen; alles andere weiter 42501) (B2). `00_schema.test.sql` bleibt bei 21 Tabellen. ## 8. Aufgaben @@ -410,8 +415,8 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We | # | Agent | Aufgabe | abhängig von | |---|---|---|---| -| B1 | backend-dev | Migration `_game_reports_user.sql` nach 3.2–4.5: `pg_trgm`, `normalize_game_title` (oder Wiederverwendung aus #3, 3.3), Spalte `result_seen_at` mit Check, Indizes, Spaltenrechte, Policies `game_reports_insert_own` und `game_reports_update_seen`, Trigger `game_reports_daily_limit` (Berliner Kalendertag, Sperre je Nutzer, nur `origin = 'user'`), RPCs `find_similar_catalog_games`, `report_missing_game`, `adopt_report_target`; Rechte nach 4.1 | #2 PR 2c gemergt | -| B2 | backend-dev | pgTAP `09_game_reports_user.test.sql` nach 7.1; `08_game_reports.test.sql` an die neuen Insert-Rechte anpassen | B1 | +| B1 | backend-dev | Migration `_game_reports_user.sql` nach 3.2–4.5: `pg_trgm`, `normalize_game_title` (oder Wiederverwendung aus #3, 3.3), Spalte `result_seen_at` mit Check, Indizes, Spaltenrechte, Policies `game_reports_insert_own` und `game_reports_update_seen`, Trigger `game_reports_daily_limit` mit Hilfsfunktion `report_day` (Berliner Kalendertag, Sperre je Nutzer, nur `origin = 'user'`), RPCs `find_similar_catalog_games`, `report_missing_game`, `adopt_report_target`; Rechte nach 4.1 | #2 PR 2c gemergt | +| B2 | backend-dev | pgTAP `09_game_reports_user.test.sql` nach 7.1; `08_game_reports.test.sql` an die neuen Insert- und Update-Rechte anpassen | B1 | | B3 | backend-dev | Doku nach 4.7 (`datenmodell-r1.md`, `katalog-pflege.md` Abschnitt Meldungen, `supabase/README.md`) | B1 | | T1 | test-engineer | Review der pgTAP-Abdeckung gegen 7.1; manueller Paralleltest des Limits (zwei Sitzungen) dokumentieren | B2 | @@ -446,7 +451,7 @@ F1 und U1 starten sofort nach Freigabe der Spec, parallel zu B1. 13b schließt # | R1 | 2b/2c weichen bei der Umsetzung von den in Spec #2 beschriebenen Namen oder Rückgaben ab | 13a passt nicht | B1 liest die gemergten Migrationen zuerst; Abweichungen im PR nennen | | R2 | `adopt_report_target` ist `security definer` und erreicht `repoint_game_references`, das RLS umgeht | Fehler würden fremde Daten berühren | Strenge Prüfungen (4.4) inkl. Prüfung auf fremde Verweise, `for update`, nur eigene private, nicht zusammengeführte Spiele; pgTAP-Negativfälle; Code-Review mit Fokus Sicherheit | | R3 | Trigramm-Schwelle 0,8 ist bei kurzen Titeln streng oder bei Reihen („Catan“, „Catan: Seefahrer“) großzügig | Ähnliche werden nicht gezeigt bzw. zu viele | Klärung 15 ist verbindlich; Kandidatenschritt blockiert nicht, er verlangt nur eine Bestätigung; Erfahrungen nach R1 sammeln | -| R4 | `[:alnum:]` hängt von der Datenbank-Locale ab | Umlaute würden entfernt, Normalisierung falsch | Test mit Umlauten in pgTAP; Rückfall auf explizite Zeichenklasse (3.3) | +| R4 | `pg_trgm` erkennt Wortzeichen abhängig von der Datenbank-Locale | Ähnlichkeitswerte weichen zwischen lokal und Produktion ab | Normalisierung liefert nur `a–z`, `0–9` und Leerzeichen (3.3); damit ist die Trigrammbildung locale-unabhängig; pgTAP-Fälle mit Umlauten und Akzenten | | R5 | Edition-Hinweis per Ableitung (E7) zeigt auch dann „Edition neu wählen“, wenn der Nutzer nie eine Edition gewählt hatte, aber eine private Variante existiert | Unnötiger Hinweis | Selten (vorläufige Einträge haben anfangs keine Varianten); wegklickbar; Alternative in Spec-F6 | | R6 | Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11, die evtl. noch fehlen | Krit. 4–6 nur in „Meine Meldungen“ sichtbar | Spec-F8: Abhängigkeiten setzen oder Einbau dort nachziehen | | R7 | Nutzer melden das gleiche Spiel mehrfach (eigene offene Meldungen werden nicht geprüft, Spec-F7) | Mehr Arbeit für Chris, doppelte vorläufige Einträge | Tageslimit; Chris lehnt Duplikate mit Verweis ab | @@ -460,7 +465,7 @@ Keine Frage blockiert die Umsetzung: Für jede gibt es eine umsetzbare Empfehlun |---|---|---|---| | Spec-F1 | **Krit. 1:** Sind Verlag und Erscheinungsjahr Pflicht oder optional? Der Text nennt nur den Titel als Pflicht. | **Optional**, nur der Titel ist Pflicht. | Nutzer kennen das Jahr oft nicht; eine Pflicht würde zu geratenen Werten führen. 2c hat beide Spalten nullable. | | Spec-F2 | **Krit. 2:** Wie gilt die Trigramm-Regel, wenn das Jahr in der Meldung oder im Katalogeintrag fehlt? | Trigramm ≥ 0,8 **ohne Jahresbedingung**. | Lieber einmal mehr nachfragen als ein Duplikat; der Schritt verlangt nur eine Bestätigung. | -| Spec-F3 | **Krit. 2 „Artikel und Sonderzeichen ignoriert“:** Welche Artikel, wo, und zählen Umlaute/Akzente als Sonderzeichen? | Nur **führende** Artikel auf Deutsch und Englisch (Liste in 3.3); Umlaute und Akzente **bleiben** Buchstaben, `ß` = `ss`; alle anderen Zeichen werden ignoriert. | Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“). Akzent-Faltung (`unaccent`) ist nicht `immutable` und damit für Index und gemeinsame Funktion ungeeignet; der Trigramm-Vergleich fängt „Molkky/Mölkky“ meist trotzdem. | +| Spec-F3 | **Krit. 2 „Artikel und Sonderzeichen ignoriert“:** Welche Artikel, wo, und zählen Umlaute/Akzente als Sonderzeichen? | Nur **führende** Artikel auf Deutsch und Englisch (Liste in 3.3); Umlaute und Akzente werden **gefaltet** (`ö` = `o`, `é` = `e`, `ß` = `ss`) über eine feste `translate()`-Zuordnung; alle anderen Zeichen werden ignoriert. | Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“). Ohne Faltung würden „Mölkky“ und „Molkky“ **nicht** als ähnlich erkannt: Die Trigramm-Ähnlichkeit liegt bei etwa 0,4, weit unter 0,8. `unaccent` scheidet aus, weil es nicht `immutable` ist; `translate()` ist es und braucht keine Erweiterung. Nachteil: „ae“-Schreibweisen („Raeuber“) werden nicht auf „ä“ abgebildet. Alternative: Umlaute als eigene Buchstaben behalten (strenger, dann werden Schreibvarianten mit und ohne Umlaut nicht erkannt). | | Spec-F4 | **Krit. 7 „heute“:** Kalendertag in welcher Zeitzone, oder gleitende 24 Stunden? | **Kalendertag in Europe/Berlin.** | Entspricht „heute“ im Kriterium und ist für Nutzer verständlich („morgen geht es wieder“). Eine Profil-Zeitzone gibt es nicht. | | Spec-F5 | **Krit. 4–6:** Wie lange bleibt der Hinweis am Eintrag sichtbar? | **Bis der Nutzer „Verstanden“ wählt** (`result_seen_at`). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft; in „Meine Meldungen“ bleibt der Status immer sichtbar. | Ein dauerhafter Hinweis an einem längst ersetzten Eintrag stört; ein automatisches Ausblenden nach Zeit könnte übersehen werden. | | Spec-F6 | **N15, Edition:** Woran erkennt die App, dass die Edition geleert wurde? | **Ableitung** (E7): vorläufiger Eintrag hat eine eigene Variante und der Eintrag am Ziel hat keine Edition. Alternative: Spalte `game_reports.edition_cleared`, gesetzt von `admin_resolve_report`/`adopt_report_target`; dafür müsste `repoint_game_references` (2b) zusätzlich zählen, wie viele **Verweise** geleert wurden, und 2c bzw. 13a die Funktionen anpassen. | Die Ableitung braucht keine Änderung an den bereits freigegebenen Teilen 2b/2c. Die Alternative ist genauer und lohnt sich, wenn 2b/2c ohnehin noch nicht gebaut sind; dann sollte die Änderung in Spec #2 nachgetragen werden, nicht in 13a per `create or replace`. | @@ -495,3 +500,5 @@ Keine Frage blockiert die Umsetzung: Für jede gibt es eine umsetzbare Empfehlun ### 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. From a1895f4c5f3592a798ebf1511f781fd1342b087f Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 19:59:02 +0000 Subject: [PATCH 09/30] docs(catalog): Korrekturen an der KAT-01-Spec (Tests, Reihenfolge 2b/2c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idempotenz der alternativen Titel in pgTAP statt Deno, Auswirkung auf 06/00_schema, alternative Titel an Testspiel B, Merge-Reihenfolge 2b -> 2c -> 3a und Erweiterung des Fremdschlüssel-Wächters in 07. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/3-katalog-suche.md | 40 +++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/specs/3-katalog-suche.md b/docs/specs/3-katalog-suche.md index 065e626..cee1d5f 100644 --- a/docs/specs/3-katalog-suche.md +++ b/docs/specs/3-katalog-suche.md @@ -159,7 +159,17 @@ Hinweise: Das Anlegen einer gespeicherten Spalte schreibt `games` einmal neu (un **6. RPC `public.search_catalog_games`** – Vertrag in § 4.2. -### 3.3 Doku zum Datenmodell +### 3.3 Reihenfolge zu 2b und 2c (Spec #2) + +**Merge-Reihenfolge: 2b → 2c → 3a.** 2b (`admin_merge_games`, `repoint_game_references`, Branch `feature/2b-katalog-zusammenfuehren`) und 2c (`game_reports`, Erweiterung des Fremdschlüssel-Wächters) sind in Arbeit; 3a wird danach gemergt. Folgen für 3a: + +- **Migration:** Zeitstempel von `_catalog_search.sql` liegt nach den Migrationen von 2b und 2c. B1 erstellt bzw. aktualisiert den Branch von `main` nach dem Merge von 2c (bei früherem Start: vor dem PR auf `main` aktualisieren und den Zeitstempel bei Bedarf neu vergeben, solange 3a nicht gemergt ist). +- **`manual_fields`-Check:** vollständige Liste von `main` übernehmen, `alt_titles` ergänzen (3.2 Nr. 5). +- **Fremdschlüssel-Wächter in `07_catalog_merge.test.sql`:** Die neue FK `game_alt_titles.game_id` macht den Wächter rot. 3a ergänzt sie im `set_eq` in der Gruppe **„bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag“** (neben `game_genres`, `game_mechanics`, `game_categories`, `game_publishers`), gemäß Spec-F6 A. `admin_merge_games`/`repoint_game_references` bleiben unverändert. Die Einträge von 2c (`game_reports.target_game_id` behandelt, `game_reports.provisional_game_id` ausgelassen) bleiben erhalten. +- **`00_schema.test.sql`:** Tabellenzahl von `main` (nach 2c) + 1. +- **pgTAP-Nummer:** `09_catalog_search.test.sql` (07 = 2b, 08 = 2c). + +### 3.4 Doku zum Datenmodell `docs/datenmodell-r1.md`: neue Tabelle `game_alt_titles`, generierte Spalten, Funktion `search_normalize`, RPC `search_catalog_games`, Erweiterungen, neuer `manual_fields`-Schlüssel; Abschnitt „API für das Frontend“ um die RPC ergänzen. `supabase/README.md`: Tabellenzahl, Hinweis „erste RPC mit `execute` für `anon`“. `docs/katalog-pflege.md`: Abschnitt „Alternative Titel pflegen“ (Studio-SQL und CSV-Spalte `alt_titles`). @@ -380,14 +390,18 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut **pgTAP-Datei `supabase/tests/database/09_catalog_search.test.sql`** (3a). Die Nummern `07` und `08` sind für Spec #2 (2b, 2c) reserviert. Die Tests laufen mit eingespieltem Startbestand: Fixtures mit festen Test-UUIDs und einem eindeutigen Kunstwort in allen Titeln und Verlagen (z. B. `qxz`), sodass Suchanfragen nie Startbestandsspiele treffen; bei Abfragen ohne Suchbegriff wird nur auf die Fixture-IDs geprüft. -**`00_schema.test.sql`:** Tabellenzahl + 1 gegenüber dem Stand von `main` beim Merge (2c erhöht ebenfalls; wer später merged, gleicht ab). +**Auswirkung auf bestehende Tests** (geprüft gegen `main` und `feature/2b-katalog-zusammenfuehren`): + +- **`00_schema.test.sql`:** nur die Tabellenzahl (+ 1 gegenüber dem Stand von `main` beim Merge von 3a, also nach 2b und 2c, siehe 3.3). Es gibt keine `columns_are`-Prüfungen; die neuen generierten Spalten `title_search`/`name_search` brechen dort nichts. +- **`06_catalog_foundation.test.sql`:** Die Spaltenrechte-Prüfungen (Z. 293–302) arbeiten mit festen Spaltenlisten und sind von den generierten Spalten nicht betroffen. **Aber Z. 532–535 prüft `manual_fields` von „Testspiel A“ exakt.** Bekäme Testspiel A alternative Titel, käme `alt_titles` hinzu und der Test würde rot. Vorgabe für B3: die alternativen Titel in `supabase/catalog/testdata/startbestand.csv` an **Testspiel B** hängen (Testspiel A bleibt unverändert); nur falls das nicht geht, passt B2 die erwartete Liste in Z. 532–535 an. Testspiel B hat bereits zwei Verlage und eignet sich auch für Krit. 3. +- **`07_catalog_merge.test.sql`** (2b): Fremdschlüssel-Wächter um `game_alt_titles.game_id` erweitern (3.3). ### 7.1 Abdeckungsmatrix | AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task | |---|---|---|---|---|---| | Krit. 1 | Repository: Parameter nur aus gesetzten Werten, Suchbegriff < 2 Zeichen wird nicht gesendet; DTO-Parsing (alle Felder nullable, `publisher_names` leer); Controller: Entprellung 300 ms, Enter sofort, nur letzte Antwort zählt | Suchfeld: Eingabe → Liste; Löschen-Schaltfläche; Treffer-Kachel mit Kurzangaben, fehlende Angaben weggelassen | Web: Teil eines Titels in anderer Schreibweise und mit Sonderzeichen findet das Spiel | `search_normalize`-Pflichtfälle (3.2); Teilzeichenkette, Groß/Klein, Sonderzeichen, Umlaut/ß; mehrere Wörter (UND); Schreibweise ohne Leerzeichen (`qc`); Relevanzstufen 0–4 in erwarteter Reihenfolge; gleiche Stufe → Ähnlichkeit, Titel, ID; `'!!!'` → 0 Treffer; `null`/Leerzeichen → kein Suchbegriff | B1, B2, F1, F2, T1, T2 | -| Krit. 2 | DTO: `matched_alt_title` | Kachel zeigt „auch: …“ | Web: Suche nach deutschem und englischem alternativen Titel findet dasselbe Spiel | Treffer über `de`- und `en`-Titel; Spiel nur einmal, auch wenn Haupt- und alternativer Titel treffen; `matched_alt_title` nur bei besserer Stufe; Tabelle: Checks (Sprache, Länge, nur Sonderzeichen → 23514), Unique (Schreibweise egal → 23505); RLS: `anon` liest Titel von Katalogspielen, nicht die eines fremden privaten Spiels; `anon`/`authenticated` können nicht schreiben (42501); Einfügen als `postgres` markiert `alt_titles` in `manual_fields`; Check akzeptiert `alt_titles`, lehnt unbekannte Schlüssel ab. Deno: Format, Sprache, Länge, Duplikat, gleich Haupttitel, `:` im Titel; erzeugte SQL; Idempotenz (zweites Einspielen ändert nichts, `updated_at` stabil; entfernter alternativer Titel wird gelöscht) | B1, B2, B3, T1, T2 | +| Krit. 2 | DTO: `matched_alt_title` | Kachel zeigt „auch: …“ | Web: Suche nach deutschem und englischem alternativen Titel findet dasselbe Spiel | Treffer über `de`- und `en`-Titel; Spiel nur einmal, auch wenn Haupt- und alternativer Titel treffen; `matched_alt_title` nur bei besserer Stufe; Tabelle: Checks (Sprache, Länge, nur Sonderzeichen → 23514), Unique (Schreibweise egal → 23505); RLS: `anon` liest Titel von Katalogspielen, nicht die eines fremden privaten Spiels; `anon`/`authenticated` können nicht schreiben (42501); Einfügen als `postgres` markiert `alt_titles` in `manual_fields`; Check akzeptiert `alt_titles`, lehnt unbekannte Schlüssel ab. **Idempotenz in pgTAP** über die neu erzeugten `.inc`-Fixtures (`startbestand_fixture.inc` zweimal einspielen, wie in `06`): zweites Einspielen ändert keine Zeile von `game_alt_titles` (Vergleich ohne `updated_at`, dazu `updated_at` unverändert); ein in der CSV entfernter alternativer Titel wird gelöscht; geänderte Schreibweise aktualisiert nur `title`. Deno (nur das Skript, ohne Datenbank): Format, Sprache, Länge, Duplikat, gleich Haupttitel, `:` im Titel; erzeugte SQL (Snapshot) | B1, B2, B3, T1, T2 | | Krit. 3 | – | Kachel: Verlage (bis zwei, „+n“) | Web: Verlagsname findet dessen Spiele | Verlag findet alle seine Spiele; Spiel mit zwei Verlagen über beide; Wort aus Titel + Wort aus Verlag (UND); `publisher_names` sortiert, vollständig; Stufe 4 hinter Titeltreffern | B1, B2, T2 | | Krit. 4 | `CatalogSearchQuery`: Grenzen, `activeFilterCount`; URL-Parameter lesen/schreiben (Rundreise, ungültige Werte ignoriert); Controller: Filteränderung startet ab Versatz 0 | Filterbereich: jede Filterart setzt den Wert; Zurücksetzen; Bottom Sheet (kompakt) und Seitenleiste (≥ 1024 dp); Begriffe nach Sprache sortiert und benannt; Fehler beim Laden der Begriffe | Web: Filter Genre + Spielerzahl mit Suchbegriff | je Filter allein; Kombination aller Filter (UND); zwei Genres → nur Spiele mit beiden (Spec-F1); fehlende Angaben werden ausgeschlossen (Spec-F2); unbekannte Genre-ID → 0 Treffer; Duplikate im Array zählen einfach | B1, B2, F1, F2, U1, T1, T2 | | Krit. 5 | Controller: `loadMore` hängt an, verwirft doppelte IDs, nicht doppelt gleichzeitig; Fehler beim Nachladen behält Treffer; `retry` | Listenfuß: Ladeanzeige, „Weitere laden“, Fehler + „Erneut versuchen“; automatisches Nachladen beim Scrollen | Web: ≥ 31 Fixture-Treffer, Scrollen lädt die zweite Seite; „Weitere laden“ per Tastatur | Seiten ohne Überschneidung, Vereinigung = Gesamtmenge, Reihenfolge stabil über Aufrufe; `total_count` gleich in allen Zeilen und Seiten | B2, F2, T1, T2 | @@ -428,9 +442,9 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut | # | Agent | Aufgabe | abhängig von | |---|---|---|---| | B1 | backend-dev | Migration `_catalog_search.sql` nach 3.2: Erweiterungen, `search_normalize`, generierte Spalten + Trigramm-Indizes, `game_alt_titles` (Checks, Unique, Trigger `set_updated_at` und `game_links_mark_manual('alt_titles')`, RLS, Grants), `manual_fields`-Check mit vollständiger Liste + `alt_titles`, RPC `search_catalog_games` nach 4.2 (Prüfungen, Treffer-Regel, Relevanz, Filter, Sortierung, Rechte: `revoke` von allen, dann `grant execute` an `anon`, `authenticated`) | – | -| B2 | backend-dev | pgTAP `09_catalog_search.test.sql` nach 7.1; `00_schema.test.sql` Tabellenzahl | B1 | -| B3 | backend-dev | Startbestand-Werkzeug nach 4.3: `alt_titles` parsen, prüfen, SQL erzeugen; Deno-Tests (neu und geänderter Test Z. 253); Testdaten-CSV um alternative Titel ergänzen; alle erzeugten Dateien neu erzeugen | B1 | -| B4 | backend-dev | Doku nach 3.3: `docs/datenmodell-r1.md`, `supabase/README.md`, `docs/katalog-pflege.md` (Abschnitt „Alternative Titel pflegen“, SQL-Bausteine wie in pgTAP ausgeführt) | B1, B3 | +| B2 | backend-dev | pgTAP `09_catalog_search.test.sql` nach 7.1 (inkl. Idempotenz der alternativen Titel über die `.inc`-Fixtures); `00_schema.test.sql` Tabellenzahl; Fremdschlüssel-Wächter in `07_catalog_merge.test.sql` um `game_alt_titles.game_id` („bewusst ausgelassen“) ergänzen (3.3); `06` Z. 532–535 nur anpassen, falls B3 Testspiel A ändern muss | B1, B3 | +| B3 | backend-dev | Startbestand-Werkzeug nach 4.3: `alt_titles` parsen, prüfen, SQL erzeugen; Deno-Tests (neu und geänderter Test Z. 253); Testdaten-CSV: alternative Titel (de und en) an **Testspiel B**, nicht an Testspiel A (§ 7, Auswirkung auf `06`); alle erzeugten Dateien neu erzeugen | B1 | +| B4 | backend-dev | Doku nach 3.4: `docs/datenmodell-r1.md`, `supabase/README.md`, `docs/katalog-pflege.md` (Abschnitt „Alternative Titel pflegen“, SQL-Bausteine wie in pgTAP ausgeführt) | B1, B3 | | B5 | backend-dev | Annahmen prüfen und messen: Erweiterungen lokal verfügbar (und mit devops auf Staging, D1); `statement_timeout` von `anon`/`authenticated`; Skript `supabase/tests/perf/catalog_search_perf.sql` mit synthetischen Daten (nur lokal, in einer zurückgerollten Transaktion), `explain (analyze, buffers)` und p95 im PR dokumentieren | B1 | | T1a | test-engineer | Review der pgTAP- und Deno-Abdeckung gegen 7.1, Lücken ergänzen | B2, B3 | @@ -457,7 +471,8 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut ### 8.4 Reihenfolge und Parallelität ``` -3a B1 → B2, B3, B5 (parallel) → B4 → T1a → Merge (Chris) +#2 2b gemergt → 2c gemergt (Voraussetzung für den Merge von 3a, 3.3) +3a B1 → B3, B5 (parallel) → B2 → B4 → T1a → Merge (Chris) 3b U1 ‖ F1 → F2 → F3 → T1, T2 (startet parallel zu 3a, gegen den Vertrag § 4.2) └─ Integration gegen die echte RPC, sobald 3a gemergt ist → Merge (Chris) 3c D1 (nach 3a) → T4, T3 (nach 3b) → C1 → Merge (Chris), Issue #3 geschlossen @@ -465,6 +480,7 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut - Der Vertrag in § 4.2 ist verbindlich für 3a und 3b; Änderungen daran nur über diese Spec. - 3b wird erst nach 3a gemergt (sonst ruft die App eine fehlende RPC auf). +- Merge-Reihenfolge im Backend: **2b → 2c → 3a** (3.3). Arbeiten an 3a und 3b dürfen vorher beginnen; vor dem PR von 3a wird auf `main` aktualisiert. - Merge nur durch Chris. Kein ADR (9.4). ## 9. Risiken & offene Fragen @@ -479,7 +495,7 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut | R4 | `search_catalog_games` ist ohne Anmeldung aufrufbar | Last durch automatisierte Aufrufe | Eingabegrenzen, `statement_timeout` (B5), nur lesend; Mengenbegrenzung am Gateway erst bei Bedarf (nicht in R1) | | R5 | Nachladen per `offset` bei gleichzeitiger Katalogänderung | Ein Spiel doppelt oder übersprungen | Doppelte IDs werden verworfen; Katalog ändert sich selten; akzeptiert | | R6 | Änderung von `search_normalize` später | Generierte Spalten veralten nicht automatisch | Kommentar an der Funktion; Neuanlage der Spalten per Migration | -| R7 | Parallelarbeit mit 2b/2c und #12 | Konflikte bei `manual_fields`-Check, Tabellenzahl in `00_schema`, Fremdschlüssel-Wächter | Regel „vollständige Liste“ (3.2 Nr. 5); Tabellenzahl beim Merge abgleichen; Wächter-Einordnung von `game_alt_titles` (9.5) | +| R7 | Parallelarbeit mit 2b/2c und #12 | Konflikte bei `manual_fields`-Check, Tabellenzahl in `00_schema`, Fremdschlüssel-Wächter | Merge-Reihenfolge 2b → 2c → 3a (3.3); Regel „vollständige Liste“ (3.2 Nr. 5); Tabellenzahl beim Merge abgleichen; Wächter-Einordnung von `game_alt_titles` durch 3a (3.3) | | R8 | Relevanz wirkt mit echten Daten unpassend | Nutzer finden das erwartete Spiel weiter unten | Stufen sind einfach anpassbar (nur RPC); Plausibilisierung durch Chris (C1) | | R9 | Schreibweise „ae“/„oe“/„ue“ findet Umlaut-Titel nicht | „Kaese“ findet „Käse“ nicht | Spec-F5; spätere Erweiterung ohne Datenmigration der Clients möglich (nur Funktion + Spalten) | | R10 | Krit. 7 wird im Web gemessen, gilt aber für mobile Apps | Abweichung der Messbedingung | Web als maßgeblicher Nachweis, mobile Stichprobe (7.3), Begründung im PR | @@ -517,7 +533,7 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut ### 9.5 Hinweise für andere Stories, nicht Teil von #3 -- **KAT-05 2b (#2):** Der Fremdschlüssel-Wächter (Spec #2 § 7) muss `game_alt_titles.game_id` einordnen: Empfehlung „bewusst ausgelassen“ (bleibt am Quell-Eintrag, Spec-F6 A); Runbook-Schritt „vor dem Zusammenführen relevante Titel der Quelle als alternative Titel am Ziel anlegen“. Der `manual_fields`-Check in 2b/2c muss `alt_titles` enthalten (Regel 3.2 Nr. 5). Tabellenzahl in `00_schema` abgleichen. +- **KAT-05 2b/2c (#2):** Keine Änderung an 2b oder 2c nötig, weil beide vor 3a gemergt werden (3.3). 3a ordnet `game_alt_titles.game_id` im Fremdschlüssel-Wächter von `07_catalog_merge.test.sql` als „bewusst ausgelassen“ ein (bleibt am Quell-Eintrag, passt zu `admin_merge_games`, das die Zuordnungen ebenfalls nicht umhängt; Spec-F6 A). Für das Runbook (Abschnitt Zusammenführen) ergänzt 3a (B4) den Schritt „vor dem Zusammenführen relevante Titel der Quelle als alternative Titel am Ziel anlegen“. - **KAT-02 (#4):** Route `/catalog/games/:id`, `onTap` der Treffer-Kachel verdrahten; Domänenmodell `CatalogGame` (Detail) neu, `CatalogGameSummary` bleibt für Listen. Alternative Titel sind in KAT-02 Krit. 1 nicht gefordert. - **KAT-04 (#13):** `onReportGame`-Callback im Leerzustand setzen (Suchbegriff als vorbelegter Titel, Anmeldung über `from`). „Gleicher normalisierter Titel“ (Klärung 15) auf Basis von `search_normalize` plus Entfernen von Artikeln (eigene Funktion, nicht in `search_normalize`, sonst ändert sich die Suche); Trigramm-Ähnlichkeit über `extensions.similarity` auf `title_search`. Krit. 6 von KAT-01 wird mit #13 vollständig (Spec-F4). - **QUE-09 (#29):** Startseite ohne Anmeldung bettet `CatalogSearchView` ein. @@ -530,3 +546,9 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut ### 2026-11-03 – Entwurf Erste Fassung durch architect, wartet auf Freigabe und Antworten zu Spec-F1 bis Spec-F7. + +### 2026-11-03 – Korrekturen am Entwurf + +- § 7.1 Krit. 2: Idempotenz der alternativen Titel nach pgTAP verschoben (über die erzeugten `.inc`-Fixtures); Deno prüft nur das Skript. +- § 7: Auswirkung auf bestehende Tests: `00_schema` nur Tabellenzahl; `06` Z. 532–535 prüft `manual_fields` von Testspiel A exakt → alternative Titel an Testspiel B (B3), sonst Anpassung in B2. +- Neuer Abschnitt 3.3: Merge-Reihenfolge 2b → 2c → 3a; 3a erweitert den Fremdschlüssel-Wächter in `07_catalog_merge.test.sql` um `game_alt_titles.game_id` („bewusst ausgelassen“, Spec-F6 A). Abschnitt „Doku zum Datenmodell“ wird 3.4. B2, B3, § 8.4, R7, 9.5 angepasst. From 34bba3439b6e030e31af29424274d61125d994ae Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:00:45 +0000 Subject: [PATCH 10/30] docs(auth): address review findings on spec 49 Existing team KSX6GJY9C5 (C1), defer CODE_SIGN_ENTITLEMENTS until the team is confirmed, local mobile builds mandatory, email_change needs KTO-11, verified facts separated from spike assumptions, QUE-06 keyboard criterion, T2 depends on PR A, limited interception protection on Android < 12 (R13, F11), login CSRF accepted (R14), supersession of spec 1 E4 recorded as task W2. Refs #49 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/49-mobil-anmeldung-app-links.md | 66 +++++++++++++--------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/docs/specs/49-mobil-anmeldung-app-links.md b/docs/specs/49-mobil-anmeldung-app-links.md index 9dd9b64..710fa69 100644 --- a/docs/specs/49-mobil-anmeldung-app-links.md +++ b/docs/specs/49-mobil-anmeldung-app-links.md @@ -11,7 +11,9 @@ ## 1. Ziel -Auf iOS und Android sollen Nutzer (a) aus der App heraus zur Anmeldung und Registrierung kommen und (b) mit den Links aus der Bestätigungs- und der Reset-Mail **in der App** landen, wenn sie installiert ist. Ohne App und auf anderen Geräten bleibt die Web-App der Zielort (Spec #1 E3, E4). Das Verhalten im Web ändert sich bei den Mail-Links nicht. +Auf iOS und Android sollen Nutzer (a) aus der App heraus zur Anmeldung und Registrierung kommen und (b) mit den Links aus der Bestätigungs- und der Reset-Mail **in der App** landen, wenn sie installiert ist. Ohne App und auf anderen Geräten bleibt die Web-App der Zielort; der geräteübergreifende Ablauf aus Spec #1 E3 bleibt erhalten. Spec #1 E4 („Mail-Links landen in R1 immer in der Web-App“) wird durch diese Spec abgelöst (E2; Verweis in Spec #1 über Aufgabe W2). Das Verhalten im Web ändert sich bei den Mail-Links nicht. + +**Abweichung vom Vorschlag im Issue:** Das Issue schlägt Universal/App Links für `/auth/confirm` **und** `/reset-password` vor. Diese Spec beansprucht nur `/auth/confirm` (E2), weil `/reset-password` nie Ziel eines Mail-Links ist. Befund (Issue #49, iOS-Simulator, main @ 455b3bf): @@ -33,7 +35,7 @@ Befund (Issue #49, iOS-Simulator, main @ 455b3bf): | Option | Beschreibung | Vorteile | Nachteile | |---|---|---|---| -| L-a | **Universal Links (iOS) und App Links (Android) auf der bestehenden https-Adresse `/auth/confirm`** | Ein Link für alle Geräte; ohne App öffnet derselbe Link die Web-App (Fallback ohne Zusatzaufwand); Mailvorlagen, GoTrue und Web bleiben unverändert; kein Fremdanbieter; Links sind an Domain, Team-ID und Signatur gebunden, eine fremde App kann sie nicht abfangen | Braucht Apple-Developer-Konto (Team-ID), Play-Signaturschlüssel, endgültige Domain und IDs sowie öffentlich erreichbare Zuordnungsdateien; lokal kaum Ende-zu-Ende testbar; manche Mail-Programme (z. B. Outlook Safe Links, In-App-Browser) lösen Universal Links nicht aus | +| L-a | **Universal Links (iOS) und App Links (Android) auf der bestehenden https-Adresse `/auth/confirm`** | Ein Link für alle Geräte; ohne App öffnet derselbe Link die Web-App (Fallback ohne Zusatzaufwand); Mailvorlagen, GoTrue und Web bleiben unverändert; kein Fremdanbieter; Links sind an Domain, Team-ID und Signatur gebunden; eine fremde App kann sie nach erfolgreicher Verifizierung nicht abfangen (Android: verlässlich erst ab Android 12, siehe R13) | Braucht Apple-Developer-Konto (Team-ID), Play-Signaturschlüssel, endgültige Domain und IDs sowie öffentlich erreichbare Zuordnungsdateien; lokal kaum Ende-zu-Ende testbar; manche Mail-Programme lösen Universal Links voraussichtlich nicht aus (z. B. Outlook Safe Links, In-App-Browser) | | L-b | Eigenes URL-Schema (`shuffle://auth/confirm?…`) direkt in der Mail | Lokal sofort testbar, keine Konten nötig | **Abgelehnt.** Ohne App öffnet der Link nichts, das widerspricht dem Fallback im Web (Kriterium 4) und dem geräteübergreifenden Ablauf (E3, Registrierung am Handy, Mail am PC); viele Mail-Programme machen Nicht-http-Links nicht klickbar; jede App kann dasselbe Schema registrieren und den `token_hash` abfangen, beim Reset-Link wäre das eine Kontoübernahme | | L-c | Eigenes Schema als `redirect_to` über `{{ .ConfirmationURL }}` (GoTrue `/verify` leitet zur App weiter) | Supabase-Standardweg | **Abgelehnt.** Hebt E3 auf: Der Link würde beim ersten Aufruf eingelöst, Mail-Scanner verbrauchen ihn; geräteübergreifend nur mit PKCE-Verifier; Abfang-Risiko wie L-b; Allow-List müsste das Schema aufnehmen | | L-d | Code statt Link (6-stelliges OTP `{{ .Token }}` in der Mail, Eingabe in der App) | Keine Domain-Dateien, lokal testbar | Widerspricht KTO-01 Kriterium 8 („Link“); neue UX und neue Mailvorlagen; 6 Ziffern sind ohne Drosselung je Konto ratbar (Drosselung nur je IP, AUTH-CFG-12) | @@ -44,11 +46,11 @@ Befund (Issue #49, iOS-Simulator, main @ 455b3bf): | # | Entscheidung (Vorschlag) | Begründung | |---|---|---| | E1 | **Einstieg über eine Hinweis-Karte auf der Startseite (E-a)**, auf allen Plattformen inklusive Web, nur im Status `signedOut`. Die Karte ist ein eigenes Widget `SignInPrompt` in `features/auth/presentation/widgets/`, das QUE-09 später auf der neuen Startseite wiederverwendet. Keine AppBar-Aktion, keine Navigationsleiste. | Deckt Kriterium 1 und vorab den Anmelde-Teil von QUE-09 Kriterium 13 ab. Eine Shell oder Navigationsleiste ist eine eigene Entscheidung mit QUE-09/KTO-11. Gleiches Verhalten auf allen Plattformen ist einfacher als eine Weiche nur für Mobile. | -| E2 | **Universal Links und App Links (L-a), beschränkt auf genau den Pfad `/auth/confirm`.** `/reset-password` gehört **nicht** dazu: Es ist nie Ziel eines Mail-Links, die App erreicht es per Redirect nach `verifyOTP` (Spec #1 § 5.3). Alle anderen Web-Adressen öffnen weiter den Browser. | Kleinster nötiger Umfang. Spätere öffentliche Web-Adressen (Katalog, Spieldetail) sollen nicht ungeplant in der App landen. Eine Erweiterung ist eine eigene Entscheidung (F1). KTO-11 (E-Mail-Wechsel, `type=email_change`) nutzt denselben Pfad und profitiert ohne Änderung. | +| E2 | **Universal Links und App Links (L-a), beschränkt auf genau den Pfad `/auth/confirm`.** `/reset-password` gehört **nicht** dazu: Es ist nie Ziel eines Mail-Links, die App erreicht es per Redirect nach `verifyOTP` (Spec #1 § 5.3). Alle anderen Web-Adressen öffnen weiter den Browser. | Kleinster nötiger Umfang. Spätere öffentliche Web-Adressen (Katalog, Spieldetail) sollen nicht ungeplant in der App landen. Eine Erweiterung ist eine eigene Entscheidung (F1). KTO-11 (E-Mail-Wechsel, `type=email_change`) nutzt denselben Pfad und profitiert bei der **Link-Zuordnung** ohne Änderung. `EmailLinkType.tryParse` (`app/lib/features/auth/domain/email_link_type.dart`) kennt heute nur `email`, `signup` und `recovery`; `email_change` ergäbe „ungültig“. `EmailLinkType` und die Mailvorlage ergänzt KTO-11 (#28), Hinweis dort. | | E3 | **Kein eigenes URL-Schema**, weder in Mails noch als Fallback (L-b, L-c abgelehnt). | Sicherheit (Abfangen des Reset-Tokens), Fallback ohne App, E3 aus Spec #1. | | E4 | **Keine Backend-Änderung.** Keine Migration, keine Edge Function, Mailvorlagen und `GOTRUE_URI_ALLOW_LIST` (AUTH-CFG-02) bleiben unverändert. Einzige Bedingung: Der Host von `SITE_URL` (AUTH-CFG-01) ist genau die Domain, die die Zuordnungsdateien ausliefert und in den App-Projekten eingetragen ist. | Die Vorlagen bauen den Link aus `{{ .SiteURL }}` und `{{ .TokenHash }}`, nicht aus GoTrue `/verify?redirect_to=…`. Die Allow-List greift dabei nicht. | -| E5 | **Deep Links über das eingebaute Flutter-Deep-Linking an go_router**, kein zusätzliches Paket. `FlutterDeepLinkingEnabled` (iOS) und `flutter_deeplinking_enabled` (Android) werden ausdrücklich auf `true` gesetzt. `EmailLinkScreen` bleibt unverändert der Zielort, auch der Knopfdruck vor `verifyOTP` (Spec #1 E3) bleibt. | go_router verarbeitet Pfad und Query bereits (Route `/auth/confirm` existiert). `supabase_flutter` hört über `app_links` (transitiv vorhanden) mit, reagiert aber nur auf `access_token`, `code`, `error*` (Quelltext `SupabaseAuth._defaultIsAuthCallbackDeeplink`); unsere Links mit `token_hash` ignoriert es. Das iOS-Plugin von `app_links` gibt Links standardmäßig an andere Empfänger weiter (`defaultUrlHandling = .never`). Beides bestätigt der Spike S1 am Gerät bzw. Simulator. | -| E6 | **Ein Issue, zwei PRs:** PR A „Einstieg“ (sofort umsetzbar, Kriterium 1), PR B „App Links“ (Kriterien 2–4). PR B ist ohne Chris-Aufgaben mergebar (Dateien mit Platzhaltern, geprüft durch Tests), die Gerätebestätigung der Kriterien 2 und 3 hängt an den Chris-Aufgaben C1–C6 (§ 8.3). | PR A soll nicht auf Konten und Domain warten. | +| E5 | **Deep Links über das eingebaute Flutter-Deep-Linking an go_router**, kein zusätzliches Paket. `FlutterDeepLinkingEnabled` (iOS) und `flutter_deeplinking_enabled` (Android) werden ausdrücklich auf `true` gesetzt. `EmailLinkScreen` bleibt unverändert der Zielort, auch der Knopfdruck vor `verifyOTP` (Spec #1 E3) bleibt. | go_router verarbeitet Pfad und Query bereits (Route `/auth/confirm` existiert). Belegt im Quelltext (Review, 2026-11-03): `supabase_flutter` 2.18.3 hört über `app_links` (transitiv vorhanden) mit, reagiert aber nur auf `access_token`, `code`, `error*` (`SupabaseAuth._defaultIsAuthCallbackDeeplink`) und ignoriert unsere Links mit `token_hash`; das iOS-Plugin von `app_links` 7.2.2 gibt Links standardmäßig an andere Empfänger weiter (`defaultUrlHandling = .never`); die Flutter-Engine 3.47.9 verarbeitet Deep Links selbst (iOS: `FlutterDeepLinkingEnabled` ohne Eintrag `YES`; Android: `onNewIntent` pusht die Route); go_router loggt Routen nur mit `debugLogDiagnostics`. Das Zusammenspiel am Gerät bzw. Simulator (UIScene, Kalt- und Warmstart) prüft S1. | +| E6 | **Ein Issue, zwei PRs:** PR A „Einstieg“ (sofort umsetzbar, Kriterium 1), PR B „App Links“ (Kriterien 2–4). PR B ist für Android und die Vorlagen ohne Chris-Aufgaben mergebar (Dateien mit Platzhaltern, geprüft durch Tests). **Einschränkung iOS:** Das Xcode-Projekt nutzt bereits `DEVELOPMENT_TEAM = KSX6GJY9C5`. Ob das ein bezahltes Team ist, klärt C1. Bis dahin legt PR B `Runner.entitlements` an, verdrahtet `CODE_SIGN_ENTITLEMENTS` aber noch nicht (oder nur über eine xcconfig-Weiche, die standardmäßig aus ist), damit Gerätebuilds mit einem kostenlosen Personal Team nicht brechen. Die CI baut nur Web und würde den Bruch nicht bemerken. Die Gerätebestätigung der Kriterien 2 und 3 hängt an C1–C6 (§ 8.3). | PR A soll nicht auf Konten und Domain warten; ein iOS-Gerätebuild darf nicht unbemerkt brechen. | | E7 | **Zuordnungsdateien liegen als Vorlage im Repo** (Team-ID und Zertifikat-Fingerabdrücke sind nicht geheim); ein Test prüft sie gegen Bundle-ID, Application-ID, Pfad und Host. Wo sie ausgeliefert werden (Web-Build oder Gateway), klärt S1. | Repo und Betrieb bleiben deckungsgleich (ADR-0002: Einstellungen im Repo festhalten). | | E8 | **Host je Plattform an genau einer Stelle konfiguriert** (Android: Gradle-Property, iOS: xcconfig-Variable), Standard ist die Produktions-Domain (Platzhalter `shuffle.cshomelabs.work`, F8 aus Spec #1). | Domain ist noch Platzhalter; eine Änderung soll eine Zeile je Plattform sein. Staging-Builds sind offen (F5). | @@ -97,7 +99,7 @@ Neuer Abschnitt „App Links / Universal Links“ mit eigenen IDs, damit Tests u |---|---|---|---| | APPLINK-01 | Associated Domain | Host von `SITE_URL` (Prod), Platzhalter `shuffle.cshomelabs.work` | Android: Gradle-Property `shuffleAppLinkHost` (Standard in `android/gradle.properties`), über `manifestPlaceholders["appLinkHost"]`; iOS: `APP_LINK_HOST` in `ios/Flutter/AppLinks.xcconfig`, eingebunden in `Debug.xcconfig` und `Release.xcconfig` | | APPLINK-02 | Beanspruchter Pfad | genau `/auth/confirm` (mit beliebiger Query) | Android-Intent-Filter, AASA `components`, assetlinks (Android verifiziert je Host, der Pfad steht im Intent-Filter) | -| APPLINK-03 | iOS-App-ID | `.work.cshomelabs.shuffle` (Bundle-ID Platzhalter, R15 aus Spec #1) | `apple-app-site-association` | +| APPLINK-03 | iOS-App-ID | `.work.cshomelabs.shuffle` (Bundle-ID Platzhalter, R15 aus Spec #1). Im Xcode-Projekt steht bereits `DEVELOPMENT_TEAM = KSX6GJY9C5`; bestätigt Chris es als bezahltes Team (C1), wird `` durch `KSX6GJY9C5` ersetzt | `apple-app-site-association` | | APPLINK-04 | Android-Paket und Signatur | `work.cshomelabs.shuffle`, SHA-256 des Upload-Schlüssels **und** des Play-App-Signing-Schlüssels | `assetlinks.json` | | APPLINK-05 | Auslieferung | `https:///.well-known/apple-app-site-association` und `/.well-known/assetlinks.json`: HTTPS mit gültigem Zertifikat, Status 200 **ohne Weiterleitung**, `Content-Type: application/json`, öffentlich erreichbar (Apple lädt über sein CDN), **vom SPA-Rewrite ausgenommen** | Gateway bzw. Web-Hosting (D1, C5) | | APPLINK-06 | Flutter-Deep-Linking | `FlutterDeepLinkingEnabled = true` (Info.plist), `flutter_deeplinking_enabled = true` (Manifest) | App-Projekte | @@ -159,13 +161,14 @@ Keine neuen Server-Fehler. Alle Fehler beim Einlösen laufen wie bisher über da **`HomeScreen`** wird `ConsumerWidget`: -- Liest `authStatusProvider`. Zustände: +- Liest **nur** `showSignInPromptProvider` (siehe unten), nicht `authStatusProvider` direkt. Der Provider bildet die Zustände so ab: - `signedOut` → Platzhaltertext und darunter `SignInPrompt`. - `signedIn` → Platzhaltertext wie bisher, **kein** Prompt (keine Abmelden-Funktion, siehe F4). - `passwordRecovery` → tritt auf `/` nicht auf (Redirect nach `/reset-password`). - Laden (Stream ohne ersten Wert) → Platzhaltertext ohne Prompt, damit beim Start mit gespeicherter Sitzung der Prompt nicht kurz aufblinkt. Der Wert kommt praktisch sofort (`initialSession`). - Fehler im Stream → wie `signedOut` behandeln (Prompt anzeigen), Fehler nur ins Log. -- Die Entscheidung „Prompt ja/nein“ trifft ein kleiner Provider `showSignInPromptProvider` (bool) in `features/auth/presentation/providers/`, nicht das Widget. Damit bleibt der HomeScreen ohne Logik und QUE-09 übernimmt Provider und Widget. +- Die Entscheidung „Prompt ja/nein“ trifft ein kleiner Provider `showSignInPromptProvider` (bool) in `features/auth/presentation/providers/`, der `authStatusProvider` liest; das Widget entscheidet nichts. Damit bleibt der HomeScreen ohne Logik und QUE-09 übernimmt Provider und Widget. +- **Import über Feature-Grenzen (`home` → `auth/presentation`):** bewusst. Der Anmelde-Hinweis gehört fachlich zu `auth` (Routen, Status); `home` ist nur Gastgeber. Die Abhängigkeit geht in eine Richtung (`auth` kennt `home` nicht), und QUE-09 ersetzt `home` später durch die Startseite, die denselben Import nutzt. Ein Verschieben nach `core/widgets` lohnt erst mit einem zweiten Gastgeber. - Breite Bildschirme: Karte max. 480 dp breit, zentriert (wie `AppFormScaffold`). **Texte (ARB, de/en):** `signInPromptTitle`, `signInPromptBody`, `signInPromptSignIn`, `signInPromptRegister`. Bestehende Schlüssel für „Anmelden“/„Konto erstellen“ dürfen wiederverwendet werden, wenn der Wortlaut passt. @@ -178,8 +181,8 @@ Abläufe in der App: | Situation | Verhalten | |---|---| -| Kaltstart über den Link | Flutter übergibt `/auth/confirm?token_hash=…&type=…` als Startroute; go_router nutzt sie statt `initialLocation` (S1 bestätigt; sonst `overridePlatformDefaultLocation` prüfen). Redirect-Regeln wie bisher: `signedOut`/`signedIn` → bleibt; `passwordRecovery` → `/reset-password`. | -| App läuft (warm) | Flutter meldet die neue Route, go_router ersetzt den Stack (`go`). Nicht gespeicherte Eingaben auf dem vorherigen Screen gehen verloren (R10). | +| Kaltstart über den Link | Flutter übergibt `/auth/confirm?token_hash=…&type=…` als Startroute; erwartet: go_router nutzt sie statt `initialLocation` (zu bestätigen in S1 (a); sonst `overridePlatformDefaultLocation` prüfen). Redirect-Regeln wie bisher: `signedOut`/`signedIn` → bleibt; `passwordRecovery` → `/reset-password`. | +| App läuft (warm) | Flutter meldet die neue Route (Android: `onNewIntent` pusht sie, belegt); erwartet: go_router ersetzt den Stack (`go`), zu bestätigen in S1 (f). Nicht gespeicherte Eingaben auf dem vorherigen Screen gehen dann verloren (R10). | | Bestätigung erfolgreich | Hinweis „E-Mail bestätigt“, Knopf „Weiter“ → `/`. Nutzer ist in der App angemeldet, die Startseite zeigt keinen Prompt mehr. Der Zusatz „auch in der App anmelden“ erscheint in der App nicht (`isMobileWebProvider` ist dort `false`). | | Reset erfolgreich | Status `passwordRecovery` → `/reset-password` (Spec #1 § 5.4, 5.5 unverändert, Guard nur im Prozess). | | Link ungültig/abgelaufen | Fehlerzustand mit „Neuen Link anfordern“ wie bisher. | @@ -190,7 +193,7 @@ Abläufe in der App: - `android/app/src/main/AndroidManifest.xml`: in `MainActivity` zusätzlicher Intent-Filter mit `android:autoVerify="true"`, `action VIEW`, `category DEFAULT` und `BROWSABLE`, `data android:scheme="https" android:host="${appLinkHost}" android:path="/auth/confirm"`. Dazu `meta-data flutter_deeplinking_enabled = true`. `launchMode="singleTop"` bleibt (warme Links kommen über `onNewIntent`). - `android/app/build.gradle.kts`: `manifestPlaceholders["appLinkHost"]` aus der Gradle-Property `shuffleAppLinkHost` (APPLINK-01). -- `ios/Runner/Runner.entitlements` (neu): `com.apple.developer.associated-domains` = `applinks:$(APP_LINK_HOST)` und (F2) `webcredentials:$(APP_LINK_HOST)`; `CODE_SIGN_ENTITLEMENTS` im Xcode-Projekt für alle Build-Konfigurationen setzen. Für Tests auf Geräten im Entwicklermodus optional `applinks:$(APP_LINK_HOST)?mode=developer` (umgeht den Apple-CDN-Cache), **nur** in Debug. +- `ios/Runner/Runner.entitlements` (neu): `com.apple.developer.associated-domains` = `applinks:$(APP_LINK_HOST)` und (F2) `webcredentials:$(APP_LINK_HOST)`. **Verdrahtung (E6):** `CODE_SIGN_ENTITLEMENTS` wird erst gesetzt, wenn C1 bestätigt, dass `KSX6GJY9C5` ein bezahltes Team ist. Bis dahin bleibt die Einstellung leer, oder sie kommt über eine xcconfig-Variable (z. B. `SHUFFLE_ENTITLEMENTS` in `ios/Flutter/AppLinks.xcconfig`, Standard leer), die nach C1 auf `Runner/Runner.entitlements` gesetzt wird. Für Tests auf Geräten im Entwicklermodus optional `applinks:$(APP_LINK_HOST)?mode=developer` (umgeht den Apple-CDN-Cache), **nur in Debug**. Mechanismus: der Suffix kommt aus einer Variablen `APP_LINK_MODE_SUFFIX`, die nur `Debug.xcconfig` auf `?mode=developer` setzt (`Release.xcconfig` lässt sie leer); der Eintrag lautet dann `applinks:$(APP_LINK_HOST)$(APP_LINK_MODE_SUFFIX)`. Alternative: eigene `Runner-Debug.entitlements` nur für Debug. - `ios/Runner/Info.plist`: `FlutterDeepLinkingEnabled = true`. - `ios/Runner/SceneDelegate.swift`: unverändert, sofern S1 zeigt, dass `FlutterSceneDelegate` Universal Links (`scene(_:willConnectTo:options:)` und `scene(_:continue:)`) an die Engine weitergibt. @@ -214,9 +217,10 @@ Abläufe in der App: | Einstieg (Krit. 1) | Hinweis-Karte auf `/` | Hinweis-Karte auf `/` | Hinweis-Karte auf `/` (auch hier, QUE-09 Krit. 13); URLs `/login`, `/register` weiter direkt aufrufbar | | Link-Zuordnung | Universal Links: Entitlement `applinks:`, AASA wird bei Installation und regelmäßig über Apples CDN geladen | App Links: Intent-Filter mit `autoVerify`, `assetlinks.json` wird bei Installation geprüft (Android 12+: ohne erfolgreiche Prüfung öffnet der Browser) | keine Änderung; `/auth/confirm` wie bisher (SPA-Rewrite, D1) | | Voraussetzungen (Konten) | Bezahltes Apple-Developer-Programm (Team-ID, Capability „Associated Domains“; mit einem kostenlosen Personal Team nach unserem Kenntnisstand nicht möglich, C1 bestätigt) | Play Console und Play App Signing (Fingerabdruck), eigener Upload-Schlüssel statt Debug-Signatur (heute signiert `release` mit dem Debug-Schlüssel) | – | -| Wann öffnet die App nicht? | Link in Safari-Adresszeile eingegeben; Link über Weiterleitungsdienste (Outlook Safe Links, Tracking-Redirects); manche In-App-Browser von Mail-Apps; Nutzer hat „In Safari öffnen“ gewählt (iOS merkt sich das je Domain) | App-Link-Prüfung fehlgeschlagen; Nutzer hat in den Einstellungen „Unterstützte Links öffnen“ deaktiviert; manche In-App-Browser | – | +| Wann öffnet die App nicht? | Link in Safari-Adresszeile eingegeben; Link über Weiterleitungsdienste (voraussichtlich Outlook Safe Links, Tracking-Redirects); manche In-App-Browser von Mail-Apps; Nutzer hat „In Safari öffnen“ gewählt (iOS merkt sich das je Domain) | App-Link-Prüfung fehlgeschlagen; Nutzer hat in den Einstellungen „Unterstützte Links öffnen“ deaktiviert; manche In-App-Browser | – | +| Android-Versionen | – | `minSdk = flutter.minSdkVersion`, also auch Android 7–11. Dort zeigt Android bei fehlender oder fehlgeschlagener Verifizierung einen Auswahldialog; eine andere App mit passendem Intent-Filter kann den Link dann empfangen. Verlässlicher Abfangschutz erst ab Android 12 mit erfolgreicher Verifizierung (R13) | – | | Fallback | Web-App öffnet `/auth/confirm` und schließt den Schritt ab; der Nutzer ist dann im Browser angemeldet, nicht in der App; Hinweis „auch in der App anmelden“ (bestehend) | gleich | ist der Normalfall | -| Debug-Builds | Kaltstart einer Debug-Build per Link funktioniert nicht ohne Flutter-Tooling (bekannte Flutter-Einschränkung); Gerätetests mit Profile/Release | Debug funktioniert, solange der Debug-Fingerabdruck in `assetlinks.json` steht (nur Staging, F5) | – | +| Debug-Builds | Kaltstart einer Debug-Build per Link funktioniert nicht ohne Flutter-Tooling (bekannte Flutter-Einschränkung; Quelle: Hinweis im iOS-Plugin von `app_links` 7.2.2 mit Verweis auf flutter/flutter#149214 und llfbandit/app_links#211); Gerätetests mit Profile/Release | Debug funktioniert, solange der Debug-Fingerabdruck in `assetlinks.json` steht (nur Staging, F5) | – | | Kamera, Dateien, Push, Offline | nicht betroffen; Einlösen braucht Netz (Offline-Hinweis aus Spec #1) | gleich | gleich | ### 6.2 Lokale Entwicklung @@ -233,12 +237,12 @@ Werkzeuge: `flutter_test` + `mocktail` (Unit/Widget), bestehende Playwright-Test | AK | Unit | Widget | Integration/E2E | RLS | Task | |---|---|---|---|---|---| -| #49 Krit. 1 | `showSignInPromptProvider`: `signedOut` → true, `signedIn` → false, Laden → false, Fehler → true | HomeScreen abgemeldet: Prompt mit zwei Knöpfen sichtbar; Tipp „Anmelden“ → LoginScreen, „Konto erstellen“ → RegisterScreen; angemeldet: kein Prompt; a11y-Guidelines (`labeledTapTargetGuideline`, `androidTapTargetGuideline`, `iOSTapTargetGuideline`, `textContrastGuideline`), `textScaler` 2.0 ohne Overflow, de/en | Playwright `web`: Startseite abgemeldet zeigt den Prompt, Klick führt zu `/login` und `/register` (Web gehört dazu, E1) | – | U1, A1, T1 | -| #49 Krit. 2 | – | Kaltstart: `tester.binding.platformDispatcher.defaultRouteNameTestValue = '/auth/confirm?token_hash=x&type=email'` → `EmailLinkScreen` mit Typ Bestätigung; warm: `tester.binding.handlePushRoute(...)` bzw. `SystemChannels.navigation` `pushRouteInformation` → `EmailLinkScreen`; Knopfdruck ruft `verifyEmailLink` (Mock) genau einmal; Erfolg → „Weiter“ → `/`, Prompt verschwunden | manuell M1–M4 (7.2) | – | A2, T2 | +| #49 Krit. 1 | `showSignInPromptProvider`: `signedOut` → true, `signedIn` → false, Laden → false, Fehler → true | HomeScreen abgemeldet: Prompt mit zwei Knöpfen sichtbar; Tipp „Anmelden“ → LoginScreen, „Konto erstellen“ → RegisterScreen; angemeldet: kein Prompt; a11y-Guidelines (`labeledTapTargetGuideline`, `androidTapTargetGuideline`, `iOSTapTargetGuideline`, `textContrastGuideline`), `textScaler` 2.0 ohne Overflow, de/en | Playwright `web`: Startseite abgemeldet zeigt den Prompt, Klick führt zu `/login` und `/register` (Web gehört dazu, E1); **QUE-06 Kriterium 4:** per Tab erreichbar in Lesereihenfolge (Überschrift/Text → „Anmelden“ → „Konto erstellen“), sichtbarer Fokus, Enter bzw. Leertaste löst den fokussierten Knopf aus | – | U1, A1, T1 | +| #49 Krit. 2 | – | Kaltstart: `tester.binding.platformDispatcher.defaultRouteNameTestValue = '/auth/confirm?token_hash=x&type=email'` → `EmailLinkScreen` mit Typ Bestätigung; warm: `tester.binding.handlePushRoute(...)` bzw. `SystemChannels.navigation` `pushRouteInformation` → `EmailLinkScreen`; Knopfdruck ruft `verifyEmailLink` (Mock) genau einmal; Erfolg → „Weiter“ → `/`; die Prüfung „Prompt verschwunden“ setzt PR A voraus (T2 hängt deshalb von A1 ab, § 8.2) | manuell M1–M4 (7.2) | – | A2, T2 | | #49 Krit. 3 | – | wie Krit. 2 mit `type=recovery`: nach Erfolg und Status `passwordRecovery` → ResetPasswordScreen; Deep Link während `passwordRecovery` → bleibt auf `/reset-password` | manuell M5–M6 | – | A2, T2 | | #49 Krit. 4 | – | – | Playwright `web` (bestehend, unverändert): kompletter Bestätigungs- und Reset-Ablauf im Browser; manuell M7–M9 (App nicht installiert, PC, Outlook) | – | T2 | | Vorschlag 5 (Web unverändert) | bestehende Redirect-Tests (`authRedirectFor`) unverändert grün | – | alle Playwright-Projekte `api`, `web`, `throttle` unverändert grün | – | T2 | -| APPLINK-01 bis -06 | **Konfigurations-Test** `app/test/platform/app_links_config_test.dart` (liest Dateien relativ zu `app/`): Manifest enthält Intent-Filter mit `autoVerify="true"`, `scheme=https`, `host=${appLinkHost}`, `path=/auth/confirm` und `flutter_deeplinking_enabled=true`; Gradle-Property gesetzt; Entitlements enthalten `applinks:$(APP_LINK_HOST)`; `CODE_SIGN_ENTITLEMENTS` in allen Build-Konfigurationen; Info.plist `FlutterDeepLinkingEnabled=true`; AASA und assetlinks sind gültiges JSON, Bundle-/Package-ID stimmen mit Xcode-Projekt bzw. `applicationId` überein, Pfad ist genau `/auth/confirm`; xcconfig-Host = Gradle-Host | – | Release-Checkliste (7.3) | – | A2, T2 | +| APPLINK-01 bis -06 | **Konfigurations-Test** `app/test/platform/app_links_config_test.dart` (liest Dateien relativ zu `app/`): Manifest enthält Intent-Filter mit `autoVerify="true"`, `scheme=https`, `host=${appLinkHost}`, `path=/auth/confirm` und `flutter_deeplinking_enabled=true`; Gradle-Property gesetzt; Entitlements-Datei existiert und enthält `applinks:$(APP_LINK_HOST)`; `CODE_SIGN_ENTITLEMENTS` vor C1 leer bzw. nur über die Weiche gesetzt, nach C1 in allen Build-Konfigurationen (der Test prüft den jeweils gültigen Stand); Info.plist `FlutterDeepLinkingEnabled=true`; AASA und assetlinks sind gültiges JSON, Bundle-/Package-ID stimmen mit Xcode-Projekt bzw. `applicationId` überein, Pfad ist genau `/auth/confirm`; xcconfig-Host = Gradle-Host | – | Release-Checkliste (7.3) | – | A2, T2 | | Logging | Test: Router-Fehler- und Logpfade erhalten bei einem `/auth/confirm`-URI keinen `token_hash` (z. B. `AppLogger` mit Test-Handler, Route mit Token aufrufen, Logausgabe prüfen) | – | – | – | T2 | ### 7.2 Manuelle Gerätematrix (Profile- oder Release-Build, nach C1–C6) @@ -249,13 +253,14 @@ Je Plattform (iOS-Gerät, Android-Gerät bzw. Emulator mit Play-Services) und je |---|---|---| | M1 | Bestätigungslink in Apple Mail (iOS) bzw. Gmail (Android) | App öffnet `/auth/confirm`, Knopf, angemeldet in der App | | M2 | Bestätigungslink in Gmail-App (iOS) | App öffnet oder Fallback Web (dokumentieren, welches) | -| M3 | Bestätigungslink in Outlook (beide) | voraussichtlich Fallback Web wegen Safe Links (dokumentieren) | +| M3 | Bestätigungslink in Outlook (beide) | voraussichtlich Fallback Web wegen Safe Links (dokumentieren, welches) | | M4 | App läuft im Hintergrund auf einer anderen Seite | App kommt nach vorn, `/auth/confirm` | | M5 | Reset-Link | App, Knopf, „Neues Passwort“, Erfolg, andere Sitzung wird abgemeldet (KTO-01 Krit. 9) | | M6 | Reset-Link zweimal benutzt | zweites Mal „Link abgelaufen oder schon benutzt“ | | M7 | App nicht installiert | Web-App schließt den Schritt ab | | M8 | Link am PC geöffnet, Registrierung war am Handy | Web-App am PC bestätigt, Anmeldung in der App danach möglich | | M9 | iOS: Nutzer wählt „In Safari öffnen“ | Web-App; beim nächsten Link bleibt iOS beim Browser (bekanntes iOS-Verhalten, dokumentieren) | +| M10 | Android 7–11 (falls `minSdk` < 31 bleibt, F11) | App öffnet nach Verifizierung bzw. Auswahldialog; Verhalten dokumentieren | Ergebnis als Tabelle im PR B bzw. im Issue #49; Abweichungen (z. B. M2, M3) werden beschrieben, nicht als Fehler gewertet, solange der Fallback greift. @@ -282,33 +287,34 @@ Ergebnis als Tabelle im PR B bzw. im Issue #49; Abweichungen (z. B. M2, M3) werd | # | Agent | Aufgabe | abhängig von | |---|---|---|---| | S1 | flutter-dev | **Spike (max. ½ Tag), Ergebnis als Änderungsprotokoll in dieser Spec.** Zu klären: (a) Flutter 3.47: Ist Deep-Linking ohne Flag aktiv, und übergibt die Engine Pfad **und** Query an go_router (Kaltstart, warm)? (b) iOS mit UIScene (`SceneDelegate.swift`, `FlutterSceneDelegate`): kommen `scene(_:willConnectTo:)` und `scene(_:continue:)` mit `NSUserActivityTypeBrowsingWeb` bei go_router an, während das `app_links`-Plugin registriert ist? (c) Android mit `singleTop`: `onNewIntent` erreicht go_router? (d) Verarbeitet `supabase_flutter` die Links tatsächlich nicht (kein `getSessionFromUrl`)? (e) Kopiert `flutter build web` `web/.well-known/` in `build/web/`, und liefert `serve -s` bzw. das geplante Hosting die AASA mit `application/json` aus? (f) Ersetzt go_router bei warmem Link den Stack (`go`) oder legt er ihn oben drauf? Prüfweg: Android-Emulator per `adb … am start` (6.2), iOS-Simulator mit `xcrun simctl openurl` **mit einem eigenen Test-Schema nur im lokalen Spike-Branch** (nicht committen), um den Weg bis go_router zu sehen; Universal-Link-Zuordnung selbst ist ohne C1 nicht prüfbar. | – | -| A2 | flutter-dev | Plattform-Dateien nach 5.2 (Manifest-Intent-Filter, Gradle-Property, Entitlements, xcconfig, `CODE_SIGN_ENTITLEMENTS`, Info.plist-Flag); Logging-Prüfung (5.2); falls S1 negativ: Rückfall-Adapter nach 5.4 (vorher Spec-Ergänzung) | S1 | -| D1a | devops | AASA- und assetlinks-Vorlagen nach 4.3 am Ort aus S1 (e); Doku-Abschnitt APPLINK-01 bis -06 in `docs/auth-konfiguration.md`; Release-Checkliste 7.3; Gateway-Vorgaben (Auslieferung ohne Redirect, `application/json`, `/.well-known/` vom SPA-Rewrite ausgenommen, öffentlich erreichbar, nicht hinter VPN/Basic-Auth) als Ergänzung zu D1 aus Spec #1; Android-Release-Signatur vorbereiten (Upload-Keystore außerhalb des Repos, `key.properties` in `.gitignore`, Signing-Config in `build.gradle.kts`), ohne Schlüssel selbst anzulegen | S1 | -| T2 | test-engineer | Konfigurations-Test (7.1 APPLINK), Widget-Tests Deep Link kalt/warm (Krit. 2, 3), Logging-Test, Regression Playwright; Vorlage der Gerätematrix 7.2 im PR | A2, D1a | +| A2 | flutter-dev | Plattform-Dateien nach 5.2 (Manifest-Intent-Filter, Gradle-Property, Entitlements, xcconfig, Info.plist-Flag; `CODE_SIGN_ENTITLEMENTS` erst nach C1 bzw. über die xcconfig-Weiche, E6); Logging-Prüfung (5.2); falls S1 negativ: Rückfall-Adapter nach 5.4 (vorher Spec-Ergänzung). **Pflicht vor dem PR (lokal, die CI baut nur Web):** `flutter build apk --release` und `flutter build ios --no-codesign`, wenn möglich zusätzlich ein Gerätebuild mit Signatur; Ergebnis im PR vermerken | S1 | +| D1a | devops | AASA- und assetlinks-Vorlagen nach 4.3 am Ort aus S1 (e); Doku-Abschnitt APPLINK-01 bis -06 in `docs/auth-konfiguration.md`; Release-Checkliste 7.3; Gateway-Vorgaben (Auslieferung ohne Redirect, `application/json`, `/.well-known/` vom SPA-Rewrite ausgenommen, öffentlich erreichbar, nicht hinter VPN/Basic-Auth) als Ergänzung zu D1 aus Spec #1; Android-Release-Signatur vorbereiten (Upload-Keystore außerhalb des Repos; prüfen, dass `key.properties`, `*.jks` und `*.keystore` weiter ignoriert werden, das steht bereits in `.gitignore` und `app/android/.gitignore`; Signing-Config in `build.gradle.kts`), ohne Schlüssel selbst anzulegen | S1 | +| T2 | test-engineer | Konfigurations-Test (7.1 APPLINK), Widget-Tests Deep Link kalt/warm (Krit. 2, 3), Logging-Test, Regression Playwright; lokale Builds wie bei A2 nachvollziehen; Vorlage der Gerätematrix 7.2 im PR | A2, D1a, A1 (PR A gemergt, wegen „Prompt verschwunden“ in 7.1 Krit. 2) | | W1 | docs-writer | Outline-Seite KTO-01 bzw. neue Seite „App Links“ (Ablauf, Fallback, Grenzen) nach Merge | A2, D1a | +| W2 | architect / docs-writer | Ablösung in Spec #1 dokumentieren: § 10 Eintrag „E4 und § 6 (Mail-Links, Autofill) durch Spec #49 E2/F2 abgelöst“, Verweise an E4 (Zeile „E4“ in § 1) und § 6 (Zeilen „Mail-Links“ und „Autofill / Passwortmanager“), jeweils „Abgelöst durch Spec #49 E2“ bzw. „siehe Spec #49 F2“. Nur Verweise und Änderungsprotokoll, keine Inhaltsänderung. **Noch offen:** Im Review-Commit nicht enthalten, weil der Commit über die Forgejo-API läuft und Spec #1 dafür vollständig neu übertragen werden müsste (rund 50.000 Tokens, fehleranfällig). Erledigt der nächste Agent mit Shell-Zugriff auf diesem Branch, spätestens mit PR A. | – | ### 8.3 Aufgaben, die nur Chris erledigen kann | # | Aufgabe | Warum Chris | blockiert | |---|---|---|---| -| C1 | **Apple Developer Program** beitreten (kostenpflichtig) und Team-ID mitteilen; bestätigen, dass „Associated Domains“ für das App-ID-Profil aktiviert ist | Vertrag, Zahlung, Identität | iOS-Teil von Krit. 2/3, M1–M9 iOS | +| C1 | **Team bestätigen:** Das Xcode-Projekt nutzt bereits `DEVELOPMENT_TEAM = KSX6GJY9C5` (Debug, Release, Profile). Chris bestätigt, ob das ein bezahltes Apple-Developer-Program-Team ist. Wenn ja: `KSX6GJY9C5` wird in APPLINK-03 und der AASA-Vorlage eingetragen, „Associated Domains“ für die App-ID aktiviert und `CODE_SIGN_ENTITLEMENTS` verdrahtet (E6). Wenn nein (kostenloses Personal Team): Beitritt zum Programm (kostenpflichtig) oder iOS-Teil zurückstellen; bis dahin bleiben die Entitlements unverdrahtet | Vertrag, Zahlung, Identität | iOS-Teil von Krit. 2/3, M1–M9 iOS, Verdrahtung `CODE_SIGN_ENTITLEMENTS` | | C2 | **Google Play Console** einrichten, Play App Signing aktivieren, SHA-256 des App-Signing-Schlüssels mitteilen; Upload-Keystore erzeugen und sicher verwahren (nicht im Repo), SHA-256 mitteilen | Konto, Schlüsselhoheit | Android-Teil von Krit. 2/3 in Release-Builds | | C3 | **Bundle-ID / Application-ID endgültig festlegen** (heute Platzhalter `work.cshomelabs.shuffle`, R15 aus Spec #1) | Nach Store-Veröffentlichung nicht mehr änderbar; AASA und assetlinks hängen daran | C1, C2, finale Dateien | | C4 | **Produktions-Domain endgültig festlegen** (heute Platzhalter `shuffle.cshomelabs.work`, F8 aus Spec #1) und sicherstellen, dass sie öffentlich per HTTPS erreichbar ist (kein VPN, keine Zugangssperre vor `/.well-known/`) | Domain und DNS gehören Chris | APPLINK-01, -05 | | C5 | **Auf Prod:** Zuordnungsdateien ausliefern (Werte aus C1–C3), `GOTRUE_SITE_URL` auf diese Domain setzen, Release-Checkliste 7.3 abhaken | Zugriff auf Server und Prod-Konfiguration nur Chris (kein Agent greift auf Server zu) | Krit. 2/3 Ende-zu-Ende | | C6 | Gerätematrix 7.2 auf echten Geräten durchführen oder abnehmen (iPhone, Android) | Physische Geräte, Apple-ID | Abschluss #49 | -| C7 | Offene Fragen F1–F10 entscheiden, Spec freigeben | Freigabe | Umsetzung | +| C7 | Offene Fragen F1–F11 entscheiden, Spec freigeben | Freigabe | Umsetzung | ### 8.4 Reihenfolge ``` sofort PR A: U1 → A1 → T1 (unabhängig von allem anderen) -parallel S1 (Spike) → A2, D1a → T2 → PR B (mergebar mit Platzhaltern) -Chris C3, C4 → C1, C2 → C5 → C6 → #49 schließen (F7) -danach W1 +parallel S1 (Spike) → A2, D1a → (PR A gemergt) → T2 → PR B (mergebar mit Platzhaltern, iOS-Entitlements unverdrahtet bis C1) +Chris C1 (Team bestätigen), C3, C4 → C2 → C5 → C6 → #49 schließen (F7) +danach W1; nach C1: Verdrahtung CODE_SIGN_ENTITLEMENTS (kleiner Folge-PR, flutter-dev) ``` -PR A und PR B sind unabhängig; PR B ändert `HomeScreen` nicht. Kein Backend-PR nötig (backend-dev hat in #49 keine Aufgabe). +PR B ändert `HomeScreen` nicht. Die Umsetzung von A2 ist unabhängig von PR A; der Widget-Test zu Krit. 2 („Prompt verschwunden“) braucht PR A, deshalb wird PR B nach PR A gemergt. Kein Backend-PR nötig (backend-dev hat in #49 keine Aufgabe). ## 9. Risiken & offene Fragen @@ -316,7 +322,7 @@ PR A und PR B sind unabhängig; PR B ändert `HomeScreen` nicht. Kein Backend-PR | # | Risiko | Auswirkung | Gegenmaßnahme | |---|---|---|---| -| R1 | Mail-Programme und Weiterleitungsdienste (Outlook Safe Links, Tracking-Redirects, In-App-Browser) lösen Universal/App Links nicht aus | Link öffnet die Web-App statt der App | Fallback Web ist vollwertig (Krit. 4); Gerätematrix dokumentiert das Verhalten; kein Tracking-Redirect in eigenen Mails (bereits so, Spec #1 § 4.1) | +| R1 | Mail-Programme und Weiterleitungsdienste (voraussichtlich Outlook Safe Links, Tracking-Redirects, In-App-Browser) lösen Universal/App Links nicht aus | Link öffnet die Web-App statt der App | Fallback Web ist vollwertig (Krit. 4); Gerätematrix dokumentiert das Verhalten; kein Tracking-Redirect in eigenen Mails (bereits so, Spec #1 § 4.1) | | R2 | SPA-Rewrite liefert `index.html` statt der AASA aus, oder es gibt eine Weiterleitung (z. B. http→https, `www`) | Universal/App Links fallen still aus | APPLINK-05, Release-Checkliste 7.3 mit `curl`; `/.well-known/` ausdrücklich vom Rewrite ausnehmen (D1a) | | R3 | Apple lädt die AASA über sein CDN und cacht sie (Stunden bis Tage); Android prüft `assetlinks.json` vor allem bei der Installation | Änderungen wirken verzögert; eine bei Installation fehlende Datei lässt die App-Link-Prüfung scheitern | Dateien **vor** der ersten Store-Installation ausliefern; iOS-Entwicklermodus (`?mode=developer`) für Tests; Android: `pm verify-app-links --re-verify` | | R4 | Signatur passt nicht zu `assetlinks.json` (heute `release` mit Debug-Schlüssel, Play App Signing signiert neu) | App Links auf Android ohne Wirkung | Beide Fingerabdrücke eintragen (C2); Release-Signatur vorbereiten (D1a); Prüfung über die Digital Asset Links API | @@ -328,6 +334,8 @@ PR A und PR B sind unabhängig; PR B ändert `HomeScreen` nicht. Kein Backend-PR | R10 | Warmer Deep Link ersetzt den Navigationsstack | Nicht gespeicherte Eingaben auf dem vorherigen Screen gehen verloren | Akzeptiert: Mail-Links sind ein bewusster Wechsel; S1 (f) bestätigt das Verhalten | | R11 | iOS-Debug-Builds starten nicht kalt über einen Link | Fehlalarm beim Testen | Gerätetests mit Profile/Release (6.1) | | R12 | Bestätigung im mobilen Browser (Fallback) meldet den Nutzer im Browser an, nicht in der App | Nutzer muss sich in der App zusätzlich anmelden | Bestehender Hinweis „Du kannst dich jetzt auch in der App anmelden.“; die Startseite der App bietet den Einstieg (Krit. 1) | +| R13 | **Abfangschutz nur eingeschränkt:** App Links schützen vor fremden Apps nur bei erfolgreicher Verifizierung und verlässlich erst ab Android 12. `minSdk = flutter.minSdkVersion` schließt Android 7–11 ein; dort kann bei fehlender Verifizierung eine andere App mit passendem Intent-Filter den Link (und damit den `token_hash`) empfangen. Auf iOS greift die Zuordnung nur nach erfolgreichem Laden der AASA | Bei einem Reset-Link wäre das eine Kontoübernahme bis zum Ablauf | E3 aus Spec #1 begrenzt den Schaden: Der Token wird erst per Knopfdruck eingelöst, ist einmalig und läuft nach 1 h ab (AUTH-CFG-07); eine neue Anforderung ersetzt ihn. Verifizierung in der Release-Checkliste (7.3). Option: `minSdk ≥ 31` (F11) | +| R14 | **Login-CSRF:** Wer einem Opfer einen eigenen, gültigen Bestätigungs- oder Reset-Link unterschiebt, kann es nach dem Knopfdruck im Angreifer-Konto anmelden (gilt in App und Web gleichermaßen, besteht schon heute im Web) | Opfer erfasst Daten im falschen Konto | **Bewusst akzeptiert** (F3): Der Knopfdruck auf einer Seite mit klarem Titel ist eine bewusste Handlung; der Schaden ist gering (Angreifer sieht, was das Opfer im fremden Konto anlegt). Kein zusätzlicher Dialog ohne zweiten Anwendungsfall | ### 9.2 Offene Fragen (Entscheidung Chris) @@ -335,7 +343,7 @@ PR A und PR B sind unabhängig; PR B ändert `HomeScreen` nicht. Kein Backend-PR |---|---|---|---| | F1 | Welche Pfade beansprucht die App? Nur `/auth/confirm` oder alle Web-Adressen? | **Nur `/auth/confirm`.** Erweiterung (z. B. Spieldetail-Links) erst, wenn es teilbare Inhalte gibt und die App die Route kennt. | E2, APPLINK-02, R6 | | F2 | Passwortspeicher von App und Web verbinden (`webcredentials` iOS, `get_login_creds` Android)? | **Ja für iOS** (`webcredentials`, gleiche Datei, kein Mehraufwand außer Entitlement-Zeile; Passwort-Vorschläge der Web-Anmeldung erscheinen in der App). Android `get_login_creds` optional, nein in #49. | 4.3, 5.2 | -| F3 | Was passiert, wenn ein Link geöffnet wird, während in der App ein anderes Konto angemeldet ist? | **Wie im Web:** Nach Knopfdruck gilt die neue Sitzung, kein zusätzlicher Dialog. Der Knopfdruck ist bereits eine bewusste Handlung; ein Dialog wäre neue UX ohne zweiten Anwendungsfall. | 5.2 | +| F3 | Was passiert, wenn ein Link geöffnet wird, während in der App ein anderes Konto angemeldet ist? | **Wie im Web:** Nach Knopfdruck gilt die neue Sitzung, kein zusätzlicher Dialog. Der Knopfdruck ist bereits eine bewusste Handlung; ein Dialog wäre neue UX ohne zweiten Anwendungsfall. Das damit verbundene Login-CSRF-Restrisiko (R14) wird bewusst akzeptiert; es besteht schon heute im Web. | 5.2, R14 | | F4 | Braucht die App vor KTO-11 ein vorläufiges „Abmelden“ (z. B. für Gerätetests)? | **Nein.** Abmelden ist KTO-11 Kriterium 3; Tester installieren neu oder nutzen den Reset-Abbruch. | 5.1 | | F5 | Eigene Staging-App-Builds mit Staging-Host (und Debug-Fingerabdruck in der Staging-`assetlinks.json`)? | **In #49 nein.** Ein Host je Build über die zentrale Property (E8); Staging-Builds entscheidet devops mit dem Hosting-Setup. Bis dahin Android lokal per `adb` (6.2) und iOS erst gegen Prod/Staging-Domain mit Entwicklermodus. | E8, APPLINK-01 | | F6 | Erscheint der Hinweis zur Anmeldung auch im Web? | **Ja.** QUE-09 Kriterium 13 verlangt ihn für App und Web-Adresse; „Web unverändert“ gilt nur für die Mail-Links. | E1 | @@ -343,9 +351,11 @@ PR A und PR B sind unabhängig; PR B ändert `HomeScreen` nicht. Kein Backend-PR | F8 | Wo liegen die Zuordnungsdateien im Repo? | **`app/web/.well-known/`**, wenn S1 (e) zeigt, dass Build und Hosting sie korrekt ausliefern; sonst `deploy/well-known/` mit Gateway-Regel. | E7, D1a | | F9 | Für das Produktions-Setup (D1 aus Spec #1, ADR-0002) gibt es kein Forgejo-Issue. Sollen C4/C5 und D1a dort hinein? | **Ja:** Issue „Produktions-Setup nach ADR-0002“ anlegen (requirements-analyst oder devops), Chris-Aufgaben C4/C5 und die Gateway-Vorgaben dort bündeln; #49 verweist darauf. | 8.3 | | F10 | Übernimmst du die Präzisierungen der Akzeptanzkriterien (§ 2, Vorschläge zu 1–4 und Kriterium 5)? | **Ja**, sie machen Kriterien 2–4 testbar und schreiben das Web-Verhalten fest. Danach Issue-Text anpassen. | § 2 | +| F11 | `minSdk` auf 31 (Android 12) anheben, damit App Links nur mit verlässlichem Abfangschutz laufen? | **Nein in #49.** Android 7–11 schließt sonst Nutzer aus; E3 (Knopfdruck, einmalig, 1 h) begrenzt den Schaden (R13). Neu bewerten, wenn die Store-Statistik kaum Geräte unter Android 12 zeigt. | R13, M10 | ## 10. Änderungsprotokoll | Datum | Änderung | |---|---| | 2026-11-03 | Entwurf (architect) | +| 2026-11-03 | Review code-reviewer eingearbeitet: vorhandenes Team `KSX6GJY9C5` (C1, APPLINK-03), `CODE_SIGN_ENTITLEMENTS` erst nach C1 bzw. über xcconfig-Weiche, E6 eingeschränkt, lokale Builds als Pflicht (A2, T2); Ablösung von Spec #1 E4/§ 6 in § 1 und als Aufgabe W2 dokumentiert (Verweise in Spec #1 selbst noch offen), Abweichung vom Issue-Vorschlag (`/reset-password`) festgehalten; `email_change` braucht Ergänzung in KTO-11 (E2); belegte Fakten aus dem Quelltext von Spike-Annahmen getrennt (E5, § 5.2); Outlook einheitlich „voraussichtlich“; QUE-06 Krit. 4 in der Matrix, T2 abhängig von A1; Abfangschutz eingeschränkt (R13, F11, M10), Login-CSRF akzeptiert (R14); `?mode=developer`-Mechanismus, HomeScreen liest nur den Provider, Import `home` → `auth` begründet, `key.properties` nur prüfen, Quelle Debug-Kaltstart | From e3532210eab3aec6fb32fe55ab434343985585f0 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 21:01:55 +0100 Subject: [PATCH 11/30] docs(auth): mark E4 and mail-link rows superseded by spec 49 Refs #49 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/1-auth-registrierung-anmeldung.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/specs/1-auth-registrierung-anmeldung.md b/docs/specs/1-auth-registrierung-anmeldung.md index 4966f24..c3a1a47 100644 --- a/docs/specs/1-auth-registrierung-anmeldung.md +++ b/docs/specs/1-auth-registrierung-anmeldung.md @@ -22,7 +22,7 @@ Gleichzeitig ist #1 die **erste Story mit App-Code**. Das Flutter-Gerüst existi | E1 | **App-Fundament als eigenes Chore-Issue #31** vor #1, nicht als Teil von #1 (entschieden, F10) | KAT-01/KAT-02 (R1.1) brauchen das Gerüst auch ohne Anmeldung; kleinere, prüfbare PRs; CLAUDE.md verlangt ein Issue je Änderung. `datenmodell-r1.md` Schritt 4 bündelt Gerüst und Anmeldung. Wir weichen davon nur im Zuschnitt ab, die Reihenfolge bleibt gleich. | | E2 | **Ein Issue #1, drei PRs** (01a Registrierung, 01b Anmelden, 01c Passwort zurücksetzen), davor ein Backend-PR (Migration + Auth-Konfiguration) | Die Kriterien teilen Backend-Konfiguration und Domänenschicht. Getrennte Issues würden Abhängigkeiten nur verschieben. Die PRs referenzieren `Refs #1`, der letzte `Closes #1`. **Abweichung vom Issue-Vorschlag:** Krit. 6 liegt in 01b statt 01a, weil der Hinweis auf die ausstehende Bestätigung beim *Anmelden* erscheint und den LoginScreen braucht. Die Aktion „E-Mail erneut senden“ entsteht schon in 01a (CheckEmailScreen) und wird in 01b wiederverwendet. | | E3 | **Links in Mails zeigen auf eine App-Seite `/auth/confirm?token_hash=…&type=…`**, nicht auf `{{ .ConfirmationURL }}`. Die Seite löst `verifyOTP` erst per Knopfdruck aus. | Funktioniert geräteübergreifend: kein PKCE-Verifier nötig, z. B. Registrierung am Handy und Mail am PC. Mail-Scanner (z. B. Safe Links) verbrauchen den Einmal-Link nicht. Die Einmaligkeit bleibt serverseitig erhalten (Kriterium 8). | -| E4 | **In R1 landen Mail-Links immer in der Web-App.** Universal Links/App Links (iOS/Android) sind nicht Teil von #1. | Die Web-App kann alle Schritte ausführen. Universal Links brauchen AASA/assetlinks-Hosting und Store-Signaturen; das ist eine eigene Aufgabe und sollte ein Folge-Issue werden. | +| E4 | *(Abgelöst durch Spec #49 E2.)* **In R1 landen Mail-Links immer in der Web-App.** Universal Links/App Links (iOS/Android) sind nicht Teil von #1. | Die Web-App kann alle Schritte ausführen. Universal Links brauchen AASA/assetlinks-Hosting und Store-Signaturen; das ist eine eigene Aufgabe und sollte ein Folge-Issue werden. | | E5 | **Mailsprache über `user_metadata.locale`**: Je Mailtyp gibt es *eine* Vorlage mit Go-Template-Verzweigung de/en (Standard Englisch, F6). Ein neuer DB-Trigger hält `auth.users.raw_user_meta_data.locale` synchron zu `profiles.locale`. | GoTrue kennt nur eine Vorlage je Typ und stellt `.Data` (= `user_metadata`) bereit. Die Profilsprache ist maßgeblich (Kriterium 11, KTO-11 Kriterium 2). Ein Send-Email-Hook (Edge Function + eigener SMTP-Versand) wäre mehr bewegliche Teile ohne zweiten Anwendungsfall. Spike B0 hat bestätigt, dass Betreff und Text `.Data.locale` auswerten (Spike-Dokument (a)); ein Rückfall ist nicht nötig. | | E6 | **Validierung zweistufig**: Feldfehler im Client (Kriterien 2, 3, 10). Der Server erzwingt dieselben Regeln als Schutz (Trigger, `minimum_password_length = 10`). | Der Client gibt sofort Rückmeldung am Feld. Der Server vertraut nie dem Client. GoTrue meldet Trigger-Fehler als HTTP 500; der Body enthält zwar die Meldung des Triggers (z. B. `display_name_invalid`, Spike A4; gilt auch für U+200B im Anzeigenamen), das Format ist aber nicht vertraglich zugesichert. Der Client bleibt deshalb die nutzersichtbare Stelle. | | E7 | **„Liste häufiger Passwörter“** wird nur im Client als gebündelte Liste geprüft; der Server erzwingt die Mindestlänge 10. **Kein Have I Been Pwned** (entschieden, F2): `GOTRUE_PASSWORD_HIBP_ENABLED=false`. | HIBP würde einen SHA-1-Präfix an einen US-Dienst (Cloudflare/HIBP) übertragen (Klärung 21/47). Außerdem gibt es HIBP in der lokalen Supabase CLI nicht. Restrisiko: Über die rohe API lassen sich häufige Passwörter mit mindestens 10 Zeichen setzen (R14). | @@ -328,8 +328,8 @@ Alle Pakete unterstützen iOS, Android und Web. `flutter-dev` pinnt die Versione | Thema | Web | iOS / Android | |---|---|---| | Sitzung (Krit. 5) | `localStorage`; bleibt nach Reload und Browser-Neustart erhalten. Kein Abgleich zwischen Tabs: ein zweiter Tab merkt die Abmeldung erst beim nächsten Refresh. | SharedPreferences (App-Sandbox); bleibt nach Neustart erhalten. Bei Neuinstallation ist die Sitzung weg. | -| Mail-Links (E3, E4) | `/auth/confirm` braucht `usePathUrlStrategy` **und** eine SPA-Rewrite-Regel im Webserver (alle Pfade → `index.html`, Aufgabe D1). | Links öffnen in R1 den Browser (Web-App). Universal/App Links kommen in einem Folge-Issue. | -| Autofill / Passwortmanager | `AutofillHints.email`, `newPassword`, `password`; Flutter Web unterstützt Passwortmanager nur eingeschränkt (bekannt, im PR vermerken) | Autofill iOS/Android inkl. „Starkes Passwort vorschlagen“; Associated Domains erst mit Universal Links | +| Mail-Links (E3, E4; E4 abgelöst durch Spec #49 E2) | `/auth/confirm` braucht `usePathUrlStrategy` **und** eine SPA-Rewrite-Regel im Webserver (alle Pfade → `index.html`, Aufgabe D1). | Links öffnen in R1 den Browser (Web-App). Universal/App Links kommen in einem Folge-Issue. | +| Autofill / Passwortmanager | `AutofillHints.email`, `newPassword`, `password`; Flutter Web unterstützt Passwortmanager nur eingeschränkt (bekannt, im PR vermerken) | Autofill iOS/Android inkl. „Starkes Passwort vorschlagen“; Associated Domains erst mit Universal Links (siehe Spec #49 F2) | | Tastatur | Tab-Reihenfolge = Lesereihenfolge, Enter sendet ab, sichtbarer Fokus (QUE-06 Krit. 4) | `TextInputType.emailAddress`, `TextInputAction.next/done` | | Offline | Login/Registrierung/Reset brauchen Verbindung → Hinweis statt technischem Fehler (Querschnitt Zustände 2). Kein Connectivity-Paket; der Fehler wird beim Aufruf erkannt. | gleich | | Layout (Plattformen Krit. 2) | ab 600 dp zentrierte Karte, max. 480 dp breit | Vollbild, scrollbar (200 % Schrift) | @@ -533,3 +533,7 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien ### 2026-11-03 – A11y-Fix Semantics-Split (Issue #41, Branch `fix/41-semantics-split`) 1. **Ursache und Fix:** Die `Card` im Layout ab 600 dp faltete Titel, Text und Knopf in einen Semantics-Knoten. `AppFormScaffold` setzt `Semantics(explicitChildNodes: true)` um den Karteninhalt (Regel in `docs/design/auth-screens.md`). Der Befund aus T3 (A11y-Befund QUE-06) ist damit behoben; `pressFirstButton` ist entfernt. + +### 2026-11-03 – Verweis auf Spec #49 (Issue #49) + +E4 und § 6 (Mail-Links, Autofill) sind durch Spec #49 E2/F2 abgelöst: Universal Links (iOS) und App Links (Android) für `/auth/confirm` werden dort spezifiziert. Der Inhalt dieser Spec ist sonst unverändert. From 127f765d856e3094cefc45851ca01347bc63728e Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 21:04:12 +0100 Subject: [PATCH 12/30] test(db): close surviving mutants in merge tests, extend runbook (KAT-05, 2b) Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/datenmodell-r1.md | 2 +- docs/katalog-pflege.md | 28 ++++- docs/specs/2-katalog-daten.md | 2 + .../tests/database/07_catalog_merge.test.sql | 100 ++++++++++++++++-- 4 files changed, 120 insertions(+), 12 deletions(-) diff --git a/docs/datenmodell-r1.md b/docs/datenmodell-r1.md index d4bba07..cdfb50b 100644 --- a/docs/datenmodell-r1.md +++ b/docs/datenmodell-r1.md @@ -342,7 +342,7 @@ Drei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Av Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle `postgres` (Klärungen 25/44, Ablauf: `docs/katalog-pflege.md`). Spec: `docs/specs/2-katalog-daten.md`. - **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf). -- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). Gesetzt wird die Spalte nur von der Funktion `public.admin_merge_games(p_source, p_target) returns jsonb` (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu. +- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). **Regel: `merged_into_id` wird nur über `public.admin_merge_games(p_source, p_target) returns jsonb` gesetzt, nie von Hand.** (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu. - **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder. - **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import). - **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`. diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index 5d0b200..0da7bcb 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -233,12 +233,22 @@ join public.collection_items t on t.owner_id = s.owner_id and t.game_id = '7a1b2 where s.game_id = '7a1b2c3d-0000-4000-8000-000000000002'; ``` -**Schritt 3: Zusammenführen.** In einer Transaktion ausführen (`begin;` davor, nach dem Lesen des Ergebnisses `commit;`, bei Zweifel `rollback;`; die Bausteine stehen wie überall ohne `begin`/`commit`). Bei einem Fehler wird nichts geändert: +**Schritt 3: Zusammenführen.** Der Baustein steht wie überall ohne `begin`/`commit`; er wird in einer Transaktion ausgeführt. Bei einem Fehler wird nichts geändert: ```sql select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); ``` +**Trockenlauf (empfohlen vor dem ersten echten Merge).** Das Ergebnis lesen und alles zurückrollen, **in einem einzigen Lauf**: + +```sql +begin; +select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); +rollback; +``` + +Danach ist nichts verändert; mit `commit;` statt `rollback;` wird der Lauf endgültig. Achtung: `begin;` und `commit;`/`rollback;` in getrennten Läufen funktionieren nur in `psql` (eine Sitzung). Der SQL-Editor in Studio führt jeden Lauf womöglich in einer eigenen Verbindung bzw. Transaktion aus; dort immer alles in einem Lauf ausführen. (Dieser Trockenlauf enthält Transaktionssteuerung und kann deshalb nicht wörtlich in pgTAP laufen; der enthaltene Aufruf ist identisch zu Schritt 3 und dort getestet.) + **Schritt 4: Nachprüfen.** Der Vorschau-Baustein aus Schritt 2 (mit der Quelle) muss jetzt überall `0` liefern. Zustand beider Einträge: ```sql @@ -246,7 +256,11 @@ select id, title, hidden_at is not null as ausgeblendet, merged_into_id, bgg_id, from public.games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); ``` -Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert sichtbar. +Die Quelle ist ausgeblendet und verweist auf das Ziel. Das Ziel bleibt sichtbar; Fakten, Kurztext und Zuordnungen sind unverändert. **Ändern können sich `bgg_id` und die Quelle (`catalog_source_id`, `source_*`)**, wenn das Ziel sie von der Quelle übernommen hat (siehe `bgg_id_transferred` und `source_transferred` im Ergebnis). Außerdem darf kein Spiel mehr auf die Quelle verweisen; diese Abfrage muss leer sein (Ketten sind geglättet): + +```sql +select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002'; +``` **Was die Funktion macht** @@ -255,7 +269,7 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert | Sammlung (`collection_items`) | Hat der Nutzer nur die Quelle, wechselt der Eintrag auf das Ziel. Hat er beide (Doppelbesitz, Spec-F1): Der Zieleintrag bleibt; leere Felder (Notiz, Erwerbsdatum, Edition) füllt der Quell-Eintrag; bei unterschiedlicher Liste gilt Sammlung vor Wunschliste vor Einkaufsliste; der Quell-Eintrag wird gelöscht. Eine nicht leere Notiz am Ziel wird nie überschrieben. | | Favoriten | Hat der Nutzer das Ziel auch als Favorit, entfällt der Favorit der Quelle (die Lücke in der Reihenfolge ist zulässig); sonst wechselt er auf das Ziel. | | Partien, Wertungsbögen | wechseln auf das Ziel; die Edition einer Partie wird über die Variantenzuordnung übertragen. | -| Varianten (Editionen) | Für jede Variante der Quelle: Hat das Ziel eine gleichnamige Variante (Vergleich ohne Groß-/Kleinschreibung und ohne Leerzeichen am Rand), wird darauf abgebildet; gibt es mehrere gleichnamige, gilt die älteste (bei Gleichstand die kleinste ID). Sonst wird die Variante am Ziel neu angelegt (Name, Jahr, Sprache, BGG-Kennung). Danach werden die Varianten der Quelle gelöscht. | +| Varianten (Editionen) | Für jede Variante der Quelle: Hat das Ziel eine gleichnamige Variante (Vergleich ohne Groß-/Kleinschreibung und ohne **Leerzeichen** am Rand; Tabulatoren und Zeilenumbrüche am Rand zählen als Teil des Namens), wird darauf abgebildet; gibt es mehrere gleichnamige, gilt die älteste (bei Gleichstand die kleinste ID). Sonst wird die Variante am Ziel neu angelegt (Name, Jahr, Sprache, BGG-Kennung). Danach werden die Varianten der Quelle gelöscht. | | BGG-Kennung und Quelle | Hat das Ziel keine, aber die Quelle, übernimmt das Ziel sie (`bgg_id`; ebenso Quelle, `source_ref`, `source_url`, `source_synced_at` als Gruppe). An der Quelle werden sie in jedem Fall geleert, damit der BGG-Abgleich später den ausgeblendeten Eintrag nicht trifft. | | Ketten | Spiele, die bisher auf die Quelle verwiesen, verweisen jetzt auf das Ziel. | @@ -289,9 +303,15 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert | `merge_same_game` | Quelle und Ziel sind dieselbe ID | | `merge_game_not_found` | Quelle oder Ziel gibt es nicht (ID vertippt?) | | `merge_not_catalog` | Quelle oder Ziel ist ein privater Eintrag; Zusammenführen gilt nur für Katalogspiele | -| `merge_target_not_listed` | Das Ziel ist ausgeblendet oder selbst schon zusammengeführt; das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) | +| `merge_target_not_listed` | Das Ziel ist ausgeblendet oder selbst schon zusammengeführt. Ist es zusammengeführt, das endgültige Ziel über `merged_into_id` nachschlagen (Baustein unten, hier mit der Quelle aus dem Beispiel; Ketten sind geglättet, ein Schritt genügt) oder ein ausgeblendetes Ziel zuerst wieder einblenden (siehe 4) | | `merge_source_already_merged` | Die Quelle ist bereits zusammengeführt | +Nachschlagen, wohin ein zusammengeführtes Spiel verweist (`merged_into_id` ist das endgültige Ziel): + +```sql +select id, title, hidden_at, merged_into_id from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002'; +``` + Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Bei Zeitüberschreitung oder anderem Fehler `rollback;` und erneut prüfen. Löscht man ein Zielspiel, auf das etwas verweist, schlägt das mit `23503` fehl (gewollt: ausblenden statt löschen). ## 6. Meldungen bearbeiten diff --git a/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index db2e5c0..6fb2d58 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -718,3 +718,5 @@ Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_cata - **Schritt 4 aus 3.3** (`game_reports.target_game_id` umhängen) entfällt bis 2c, wie vorgesehen. - **Hinweis für 2c (B9):** Sobald `game_reports` zwei Fremdschlüssel auf `games` bekommt, wird der Wächter in `07` rot. Die erwartete Liste in `07` muss dort um `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (bewusst ausgelassen) erweitert werden, nicht nur der Wächter in `08`. - **Runbook:** Alle SQL-Bausteine aus Abschnitt 5 laufen wörtlich in `07` (einmalige Prüfung per Skript; keine CI-Prüfung). Abschnitt 5 enthält die Duplikatsuche, eine Vorschau (Verweise, Doppelbesitz), den Aufruf, die Nachprüfung, die Regeln, die Schlüssel des Ergebnis-JSON und die Fehlertexte. + +- **Nachtrag Review 2b:** Tests ergänzt (Spec-F1-Edition am Ziel, `variants_cleared` im Kollisionszweig, gleichnamige Quellvarianten, zweiter Fremdschlüssel-Wächter auf `game_variants`, unbeteiligte Spiele, Ziel `null`); Runbook um Trockenlauf (`begin; …; rollback;` in einem Lauf, nur dieser Baustein läuft nicht wörtlich in pgTAP), Ketten-Nachprüfung und Nachschlagen von `merged_into_id` ergänzt. Der Null-Check in `admin_merge_games` ist ein äquivalenter Mutant (das spätere `not found` liefert denselben Fehler) und bleibt als ausdrückliche Absicherung. Offen für Chris: Verweise, die nach dem Merge noch auf die Quelle entstehen (W3). diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql index 28f690f..c70e81e 100644 --- a/supabase/tests/database/07_catalog_merge.test.sql +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -5,7 +5,7 @@ -- Hinweis für 2c: Der Fremdschlüssel-Wächter am Ende muss um game_reports.target_game_id (behandelt) und -- game_reports.provisional_game_id (bewusst ausgelassen) erweitert werden, sonst wird er rot. begin; -select plan(89); +select plan(107); insert into auth.users (id, email, raw_user_meta_data) values ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), @@ -156,6 +156,16 @@ insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_i insert into public.score_sheets (id, owner_id, game_id, name) values ('f7000000-0000-0000-0000-0000000000b5', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 'Bogen'); +-- Unbeteiligtes Spiel mit Verweisen: darf vom Zusammenführen nicht berührt werden +insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000501', 'Unbeteiligt', 'catalog'); +insert into public.game_variants (id, game_id, name) values ('f7000000-0000-0000-0000-0000000005e1', 'f7000000-0000-0000-0000-000000000501', 'Deluxe'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list, note) values + ('f7000000-0000-0000-0000-0000000005c1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 'f7000000-0000-0000-0000-0000000005e1', 'wishlist', 'bleibt'); +insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values + ('f7000000-0000-0000-0000-0000000005b1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 'f7000000-0000-0000-0000-0000000005e1', '2026-04-01', 'f7000000-0000-0000-0000-0000000005b1'); +insert into public.favorite_games (id, profile_id, game_id, position) values + ('f7000000-0000-0000-0000-0000000005d1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 1); + -- Runbook 5, Schritt 1: Duplikate finden (global; geprüft wird nur die Fixture-Zeile) create temp table rb_dup as select lower(btrim(title)) as titel_normalisiert, year, @@ -233,6 +243,17 @@ select set_eq($$select id, ausgeblendet, merged_into_id, bgg_id, catalog_source_ ('7a1b2c3d-0000-4000-8000-000000000003', false, null, 13, true)$$, 'KAT-05 Kriterium 4: Quelle ausgeblendet und verwiesen, Ziel sichtbar und mit BGG-Kennung und Quelle der Quelle'); +-- Runbook 5, Schritt 4: Nachprüfung auf Ketten (muss leer sein) +create temp table rb_ketten as +select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002'; +select is_empty($$select * from rb_ketten$$, 'KAT-05 Kriterium 8: Runbook 5, kein Spiel verweist mehr auf die Quelle'); + +-- Runbook 5, Fehlertext merge_target_not_listed: wohin verweist ein zusammengeführtes Spiel? +create temp table rb_verweis as +select id, title, hidden_at, merged_into_id from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002'; +select results_eq($$select merged_into_id from rb_verweis$$, $$values ('7a1b2c3d-0000-4000-8000-000000000003'::uuid)$$, + 'KAT-05 Kriterium 8: Runbook 5, Nachschlagen des endgültigen Ziels über merged_into_id'); + -- Weitere Prüfungen am Ergebnis des Hauptfalls select is((select count(*) from public.catalog_games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003')), 1::bigint, 'KAT-05 Kriterium 4: catalog_games enthält nur noch das Ziel'); @@ -268,8 +289,16 @@ select is((select row(list, note, acquired_on)::text from public.collection_item select is((select row(list, note, acquired_on)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c7'), '(owned,"Doras Quellnotiz",)', 'Spec-F1: Dora: owned (Quelle) vor shopping (Ziel); eine leere Notiz am Ziel wird aus der Quelle gefüllt'); -select is((select count(*) from public.collection_items where owner_id = '44444444-4444-4444-4444-444444444444'), 1::bigint, +select is((select count(*) from public.collection_items where owner_id = '44444444-4444-4444-4444-444444444444' + and game_id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003')), 1::bigint, 'Spec-F1: Doras Quell-Eintrag ist gelöscht'); +select is((select row(c.game_id, c.variant_id, c.list, c.note)::text || '|' || p.game_id::text || p.variant_id::text || '|' || f.game_id::text || f.position::text + from public.collection_items c, public.plays p, public.favorite_games f + where c.id = 'f7000000-0000-0000-0000-0000000005c1' and p.id = 'f7000000-0000-0000-0000-0000000005b1' and f.id = 'f7000000-0000-0000-0000-0000000005d1'), + '(f7000000-0000-0000-0000-000000000501,f7000000-0000-0000-0000-0000000005e1,wishlist,bleibt)|f7000000-0000-0000-0000-000000000501f7000000-0000-0000-0000-0000000005e1|f7000000-0000-0000-0000-0000000005011', + 'Daten unbeteiligter Spiele bleiben unverändert (Sammlung, Partie, Favorit, Variante)'); +select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000501'), 1::bigint, + 'Varianten unbeteiligter Spiele bleiben erhalten'); select is((select count(*) from public.collection_items where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 4::bigint, 'Jeder der vier Nutzer hat genau einen Eintrag am Ziel'); @@ -308,7 +337,7 @@ select is((select count(distinct variant_id) from ( -- Löschen eines Ziels mit Verweis (nur der Verweis merged_into_id, ohne andere Verweise) insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-0000000000b8', 'Löschziel', 'catalog'), ('f7000000-0000-0000-0000-0000000000b9', 'Löschquelle', 'catalog'); -select public.admin_merge_games('f7000000-0000-0000-0000-0000000000b9', 'f7000000-0000-0000-0000-0000000000b8'); +select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000000b9', 'f7000000-0000-0000-0000-0000000000b8')$$, 'Zusammenführen läuft ohne Fehler (0000b8)'); select throws_ok($$delete from public.games where id = 'f7000000-0000-0000-0000-0000000000b8'$$, '23503', null, 'KAT-05 Kriterium 4: Ein Ziel mit Verweis lässt sich nicht löschen (on delete restrict)'); @@ -356,7 +385,7 @@ select is((select r ? 'bgg_id_conflict' from rb_tgt), false, 'Kein Konflikt, wen insert into public.games (id, title, visibility, hidden_at) values ('f7000000-0000-0000-0000-000000000131', 'Versteckte Quelle', 'catalog', '2020-05-05 00:00:00+00'), ('f7000000-0000-0000-0000-000000000132', 'Ziel zu Versteckt', 'catalog', null); -select public.admin_merge_games('f7000000-0000-0000-0000-000000000131', 'f7000000-0000-0000-0000-000000000132'); +select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000131', 'f7000000-0000-0000-0000-000000000132')$$, 'Zusammenführen läuft ohne Fehler (000132)'); select is((select hidden_at from public.games where id = 'f7000000-0000-0000-0000-000000000131'), '2020-05-05 00:00:00+00'::timestamptz, 'Eine bereits ausgeblendete Quelle behält ihr hidden_at'); select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000131'), 'f7000000-0000-0000-0000-000000000132'::uuid, @@ -369,7 +398,7 @@ insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000141', 'Kette A', 'catalog'), ('f7000000-0000-0000-0000-000000000142', 'Kette B', 'catalog'), ('f7000000-0000-0000-0000-000000000143', 'Kette C', 'catalog'); -select public.admin_merge_games('f7000000-0000-0000-0000-000000000143', 'f7000000-0000-0000-0000-000000000141'); +select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000143', 'f7000000-0000-0000-0000-000000000141')$$, 'Zusammenführen läuft ohne Fehler (000141)'); select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000143'), 'f7000000-0000-0000-0000-000000000141'::uuid, 'Kette: C zeigt zuerst auf A'); create temp table rb_chain as select public.admin_merge_games('f7000000-0000-0000-0000-000000000141', 'f7000000-0000-0000-0000-000000000142') as r; select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000143'), 'f7000000-0000-0000-0000-000000000142'::uuid, @@ -392,7 +421,7 @@ insert into public.game_variants (id, game_id, name, created_at) values insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values ('f7000000-0000-0000-0000-0000000001c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000151', 'f7000000-0000-0000-0000-0000000001b1', '2026-02-01', 'f7000000-0000-0000-0000-0000000001c1'); -select public.admin_merge_games('f7000000-0000-0000-0000-000000000151', 'f7000000-0000-0000-0000-000000000152'); +select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000151', 'f7000000-0000-0000-0000-000000000152')$$, 'Zusammenführen läuft ohne Fehler (000152)'); select is((select variant_id from public.plays where id = 'f7000000-0000-0000-0000-0000000001c1'), 'f7000000-0000-0000-0000-0000000001a1'::uuid, 'KAT-05 Kriterium 4: bei Gleichstand von created_at gilt die kleinste id'); select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000152'), 3::bigint, @@ -456,11 +485,59 @@ insert into public.game_variants (id, game_id, name) values insert into public.collection_items (id, owner_id, game_id, variant_id, list) values ('f7000000-0000-0000-0000-0000000003c1', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003e1', 'owned'), ('f7000000-0000-0000-0000-0000000003c2', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-0000000003b1', null, 'owned'); -select public.admin_merge_games('f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003b1'); +select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003b1')$$, 'Zusammenführen läuft ohne Fehler (0003b1)'); select is((select v.name || '/' || v.game_id::text from public.collection_items c join public.game_variants v on v.id = c.variant_id where c.id = 'f7000000-0000-0000-0000-0000000003c2'), 'Jubiläum/f7000000-0000-0000-0000-0000000003b1', 'Spec-F1: Kollision: die Edition der Quelle kommt über die Zuordnung (Kopie am Ziel) in den Zieleintrag'); +-- Spec-F1 Kernregel (W1): Doppelbesitz, beide Einträge haben eine eigene Edition -> der Zieleintrag behält seine +insert into public.games (id, title, visibility) values + ('f7000000-0000-0000-0000-000000000401', 'Edition Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000402', 'Edition Ziel', 'catalog'); +insert into public.game_variants (id, game_id, name) values + ('f7000000-0000-0000-0000-0000000004e1', 'f7000000-0000-0000-0000-000000000401', 'Y-Edition'), + ('f7000000-0000-0000-0000-0000000004e2', 'f7000000-0000-0000-0000-000000000402', 'X-Edition'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list) values + ('f7000000-0000-0000-0000-0000000004c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000401', 'f7000000-0000-0000-0000-0000000004e1', 'owned'), + ('f7000000-0000-0000-0000-0000000004c2', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000402', 'f7000000-0000-0000-0000-0000000004e2', 'owned'); +select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000401', 'f7000000-0000-0000-0000-000000000402')$$, + 'Zusammenführen mit Doppelbesitz und zwei Editionen läuft'); +select is((select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000004c2'), + 'f7000000-0000-0000-0000-0000000004e2'::uuid, 'Spec-F1: Doppelbesitz: der Zieleintrag behält seine eigene Edition (X), die der Quelle (Y) verdrängt sie nicht'); + +-- N15 (W2): privater Eintrag -> Katalog, Doppelbesitz, Ziel ohne Edition, Quelle mit Edition ohne Gegenstück -> geleert und gezählt +insert into public.games (id, title, visibility, owner_id) values + ('f7000000-0000-0000-0000-000000000411', 'Vorläufig Cara', 'private', '33333333-3333-3333-3333-333333333333'); +insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000412', 'Ziel Cara', 'catalog'); +insert into public.game_variants (id, game_id, name) values + ('f7000000-0000-0000-0000-0000000004e3', 'f7000000-0000-0000-0000-000000000411', 'Nur bei Cara'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list) values + ('f7000000-0000-0000-0000-0000000004c3', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-000000000411', 'f7000000-0000-0000-0000-0000000004e3', 'owned'), + ('f7000000-0000-0000-0000-0000000004c4', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-000000000412', null, 'owned'); +create temp table rb_n15 as select public.repoint_game_references('f7000000-0000-0000-0000-000000000411', 'f7000000-0000-0000-0000-000000000412') as r; +select is((select r from rb_n15), + '{"collection_items":0,"collection_conflicts":1,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":1}'::jsonb, + 'KAT-05 Kriterium 4 (N15): Doppelbesitz mit Edition ohne Gegenstück: variants_cleared = 1'); +select is((select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000004c4'), null::uuid, + 'KAT-05 Kriterium 4 (N15): die Edition am Zieleintrag bleibt leer (nichts kopiert)'); +select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000412'), 0::bigint, + 'KAT-05 Kriterium 4 (N15): das Katalogspiel bekommt keine Variante'); + +-- Zwei gleichnamige Quellvarianten werden einzeln kopiert (nur gegen vorher vorhandene Zielvarianten abgeglichen) +insert into public.games (id, title, visibility) values + ('f7000000-0000-0000-0000-000000000421', 'Zwilling Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000422', 'Zwilling Ziel', 'catalog'); +insert into public.game_variants (id, game_id, name, created_at) values + ('f7000000-0000-0000-0000-0000000004f1', 'f7000000-0000-0000-0000-000000000421', 'Twin', '2020-01-01'), + ('f7000000-0000-0000-0000-0000000004f2', 'f7000000-0000-0000-0000-000000000421', 'twin ', '2021-01-01'); +create temp table rb_twin as select public.admin_merge_games('f7000000-0000-0000-0000-000000000421', 'f7000000-0000-0000-0000-000000000422') as r; +select is((select (r ->> 'variants_copied') || '/' || (r ->> 'variants_mapped') from rb_twin), '2/0', + 'Zwei gleichnamige Quellvarianten werden beide kopiert, keine auf die eben kopierte abgebildet'); +select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000422'), 2::bigint, + 'Das Ziel hat danach beide Kopien'); + +-- Ziel null +select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', null)$$, + 'P0001', 'merge_game_not_found', 'KAT-05 Kriterium 4: fehlendes Ziel (null) ist merge_game_not_found'); + -- --------------------------------------------------------------------------------------------- -- Fremdschlüssel-Wächter: jeder Verweis auf games ist entweder behandelt oder bewusst ausgelassen -- --------------------------------------------------------------------------------------------- @@ -480,5 +557,14 @@ select set_eq($$select fk from fk_found$$, ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id')$$, 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); +-- Zweiter Wächter: Verweise auf game_variants. Sie werden über die Variantenzuordnung umgesetzt; +-- ein neuer Verweis (z. B. aus SAM-02/SAM-04) erzwingt eine Entscheidung in repoint_game_references. +select set_eq($$select (select relname from pg_class where oid = c.conrelid) || '(' || + (select string_agg(a.attname, ', ' order by k.ord) from unnest(c.conkey) with ordinality as k(attnum, ord) + join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k.attnum) || ')' + from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.game_variants'::regclass$$, + $$values ('collection_items(variant_id, game_id)'), ('plays(variant_id, game_id)')$$, + 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: Verweise auf game_variants sind genau Sammlung und Partien (beide über die Variantenzuordnung)'); + select * from finish(); rollback; From 147dd1870efde9fafad9304b428c71557e7b812a Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 21:05:02 +0100 Subject: [PATCH 13/30] feat(db): game reports table, admin_resolve_report and visibility trigger (KAT-05 2c) Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/katalog-pflege.md | 151 +++- .../20261103195151_game_reports.sql | 280 +++++++ supabase/tests/database/00_schema.test.sql | 4 +- .../tests/database/07_catalog_merge.test.sql | 14 +- .../tests/database/08_game_reports.test.sql | 741 ++++++++++++++++++ 5 files changed, 1176 insertions(+), 14 deletions(-) create mode 100644 supabase/migrations/20261103195151_game_reports.sql create mode 100644 supabase/tests/database/08_game_reports.test.sql diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index 5d0b200..c6934dd 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -2,9 +2,9 @@ Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`. -Stand: PR 2b (Datenmodell, Startbestand, Ausblenden, Zusammenführen). Der Abschnitt **Meldungen bearbeiten** (PR 2c) kommt mit dem jeweiligen PR dazu. +Stand: PR 2c (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten). -**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). +**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`, Abschnitt 6 in `08_game_reports.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). ## 1. Zugang @@ -190,7 +190,7 @@ Zwei Katalogspiele, die dasselbe Spiel beschreiben (Duplikat), führt man mit de - **Quelle** ist das Duplikat, das verschwindet. Sie wird **nicht gelöscht**, sondern ausgeblendet und auf das Ziel verwiesen (`hidden_at`, `merged_into_id`); ihre ID bleibt gültig, die App leitet alte Links auf das Ziel um (KAT-02). - **Ziel** ist der Eintrag, der bleibt. Er muss sichtbar sein (nicht ausgeblendet, nicht selbst zusammengeführt). Das Ziel behält seine Fakten, Kurztexte und Zuordnungen (Verlag, Genre, Mechanik, Kategorie); die Zuordnungen der Quelle bleiben am ausgeblendeten Quell-Eintrag. Was am Ziel fehlt, ergänzt man vorher oder nachher mit den Bausteinen aus 3. -- Beide müssen **Katalogspiele** sein. Einen vorläufigen privaten Eintrag eines Nutzers führt man nicht hiermit zusammen (dafür gibt es ab PR 2c die Meldungs-Funktion). +- Beide müssen **Katalogspiele** sein. Einen vorläufigen privaten Eintrag eines Nutzers führt man nicht hiermit zusammen (dafür gibt es die Meldungs-Funktion, siehe 6). - **Es gibt kein Rückgängigmachen.** Vor dem Zusammenführen den Stand des Backups prüfen; im Zweifel zuerst die Vorschau (unten) lesen. **Schritt 1: Duplikate finden** (normalisierter Titel + Jahr, nur sichtbare Katalogspiele; die älteste ID steht zuerst): @@ -265,7 +265,7 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert {"plays": 7, "target_id": "…", "source_id": "…", "score_sheets": 1, "favorite_games": 1, "collection_items": 3, "variants_copied": 2, "variants_mapped": 1, "variants_cleared": 0, "chain_flattened": 0, "favorite_conflicts": 0, "bgg_id_transferred": true, "source_transferred": false, - "collection_conflicts": 1} + "collection_conflicts": 1, "reports_repointed": 0} ``` | Schlüssel | Bedeutung | @@ -275,11 +275,12 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert | `favorite_games`, `favorite_conflicts` | Favoriten umgehängt bzw. wegen vorhandenem Favoriten am Ziel entfallen | | `plays`, `score_sheets` | umgehängte Partien und Wertungsbögen | | `variants_copied`, `variants_mapped` | Varianten, die am Ziel neu angelegt bzw. auf eine gleichnamige abgebildet wurden | -| `variants_cleared` | Sammlungseinträge und Partien, deren Edition geleert wurde. Beim Zusammenführen zweier Katalogspiele bleibt das `0`; es kommt nur beim Umhängen privater Einträge vor (PR 2c) | +| `variants_cleared` | Sammlungseinträge und Partien, deren Edition geleert wurde. Beim Zusammenführen zweier Katalogspiele bleibt das `0`; es kommt nur beim Umhängen privater Einträge vor (Abschnitt 6) | | `bgg_id_transferred`, `source_transferred` | `true`, wenn das Ziel die BGG-Kennung bzw. die Quelle der Quelle übernommen hat | | `bgg_id_conflict` | nur vorhanden, wenn beide eine **unterschiedliche** BGG-Kennung hatten: `{"source": 13, "target": 42}`. Das Ziel behält seinen Wert, der Wert der Quelle ist geleert und steht **nur noch hier**. Bei Bedarf notieren und prüfen, ob das Ziel das richtige Spiel ist | | `source_conflict` | wie `bgg_id_conflict` für die Quelle (`source` und `target` mit `catalog_source_id`, `source_ref`, `source_url`, `source_synced_at`) | | `chain_flattened` | Anzahl früher zusammengeführter Spiele, die jetzt direkt auf das Ziel zeigen | +| `reports_repointed` | Meldungen (`game_reports`), deren Zieleintrag die Quelle war und die jetzt auf das Ziel zeigen (Abschnitt 6) | **Fehlermeldungen** (alle ohne Änderung, `P0001`): @@ -296,7 +297,145 @@ Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mi ## 6. Meldungen bearbeiten -Kommt mit PR 2c. +Nutzer melden fehlende Spiele (KAT-04, #13); Meldungen, die ein Import anlegt, gibt es ab SAM-08 (`origin = 'import'`). In der Tabelle `game_reports` steht, was der Nutzer angegeben hat (Titel, Verlag, Jahr, Link, BGG-Kennung) und, falls vorhanden, sein **vorläufiger privater Eintrag** (`provisional_game_id`, nur für ihn sichtbar). Der Admin entscheidet in drei Wegen, immer mit der Funktion `public.admin_resolve_report(meldung, status, ziel)`: + +| Entscheidung | Aufruf | Wirkung | +|---|---|---| +| **freigeben** | `'approved'` + Ziel | Der Eintrag ist wegen dieser Meldung **neu** entstanden. Der vorläufige Eintrag des Nutzers wird auf das Ziel umgehängt (Sammlung, Favoriten, Partien, Wertungsbögen) und ausgeblendet. | +| **zusammenführen** | `'merged'` + Ziel | Der Eintrag war **schon vorhanden**. Wirkung wie bei `'approved'`. | +| **ablehnen** | `'rejected'`, optional mit Ziel | Mit Ziel wird nur der Verweis gespeichert (die App kann dem Nutzer den Zieleintrag anbieten); der vorläufige Eintrag bleibt unverändert. Ohne Ziel ändert sich nichts außer Status und Zeitpunkt. | + +**Regeln** + +- **Nie einen privaten Eintrag öffentlich schalten.** `visibility` und `owner_id` eines Spiels lassen sich nicht ändern (der Trigger `games_visibility_immutable` lehnt es für jede Rolle ab, auch für `postgres`, Fehler `visibility_immutable`). Freigeben heißt immer: **neuen** Katalogeintrag anlegen. +- **Beim Anlegen nur Fakten aus den Meldungsfeldern übernehmen:** `title`, `publisher_name`, `year`; `link` dient nur als Recherchehilfe. **Nie** `description` oder `image_path` des vorläufigen Eintrags übernehmen (das sind private Angaben des Nutzers). Den Kurztext schreibt der Admin selbst (SB4, deutsch). +- **Datenschutz:** vom vorläufigen Eintrag nur ansehen, was die Entscheidung braucht (Anzahl Verweise, Namen der Editionen). Die Meldung selbst gehört zum Konto des Nutzers und wird mit ihm gelöscht. +- **Editionen werden nicht kopiert** (RB1, Klärung N15). Hat der Katalogeintrag eine gleichnamige Edition (Vergleich ohne Groß-/Kleinschreibung und ohne Leerzeichen am Rand), bleibt die Edition des Nutzers erhalten, sonst wird sie an Sammlungseintrag und Partien geleert (`variants_cleared` im Ergebnis). Soll die Edition erhalten bleiben, **vorher** am Katalogeintrag eine Variante anlegen, und zwar nur aus belegbaren Fakten (Name und Jahr der Edition aus Verlags- oder Schachtelangaben), nie durch Übernahme der privaten Variante. Die private Variante bleibt am ausgeblendeten vorläufigen Eintrag und wird mit dem Konto gelöscht. +- Das Ziel muss ein sichtbares Katalogspiel sein (nicht ausgeblendet, nicht zusammengeführt). Es gibt kein Rückgängigmachen; in einer Transaktion arbeiten (`begin;` davor, nach dem Lesen des Ergebnisses `commit;`, bei Zweifel `rollback;`; die Bausteine stehen wie überall ohne `begin`/`commit`). +- Wird ein Zieleintrag später mit einem anderen Katalogspiel zusammengeführt (Abschnitt 5), zeigen die Meldungen automatisch auf das neue Ziel (`reports_repointed` im Ergebnis). + +In den Bausteinen stehen Beispiel-IDs: `7c1d2c3d-0000-4000-8000-000000000001` ist die Meldung, `…0002` das neu angelegte Katalogspiel, `…0004` ein vorhandenes Katalogspiel. Immer durch die echten IDs ersetzen (neue ID erzeugen: `select gen_random_uuid();`). + +**Schritt 1: Offene Meldungen** (älteste zuerst): + +```sql +select r.id, r.created_at, r.origin, r.title, r.publisher_name, r.year, r.link, r.bgg_id, r.provisional_game_id +from public.game_reports r +where r.status = 'open' +order by r.created_at, r.id; +``` + +**Schritt 2: Ähnliche Spiele im Katalog suchen** (gleicher Titel ohne Groß-/Kleinschreibung oder gleiche BGG-Kennung wie die Meldung). Gibt es schon einen Eintrag, entscheidet man sich für `'merged'`: + +```sql +select g.id, g.title, g.year, g.bgg_id +from public.game_reports r +join public.catalog_games g + on lower(btrim(g.title)) = lower(btrim(r.title)) + or (r.bgg_id is not null and g.bgg_id = r.bgg_id) +where r.id = '7c1d2c3d-0000-4000-8000-000000000001' +order by g.title, g.id; +``` + +Was hängt am vorläufigen Eintrag des Nutzers? Nur Anzahlen: + +```sql +select g.id, g.title, + (select count(*) from public.collection_items c where c.game_id = g.id) as sammlung, + (select count(*) from public.favorite_games f where f.game_id = g.id) as favoriten, + (select count(*) from public.plays p where p.game_id = g.id) as partien, + (select count(*) from public.score_sheets s where s.game_id = g.id) as wertungsboegen +from public.game_reports r +join public.games g on g.id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; +``` + +Und die Namen seiner Editionen (für die Entscheidung, ob am Katalogeintrag eine gleichnamige Variante angelegt wird): + +```sql +select v.id, v.name, v.year, v.language +from public.game_reports r +join public.game_variants v on v.game_id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001' +order by v.name, v.id; +``` + +**Schritt 3a: Freigeben** (neuer Eintrag). Zuerst den Katalogeintrag mit den Fakten aus der Meldung und einem eigenen Kurztext anlegen (jedes gesetzte Feld wird automatisch in `manual_fields` vermerkt): + +```sql +insert into public.games (id, title, year, description, description_locale, visibility) +values ('7c1d2c3d-0000-4000-8000-000000000002', 'Meldungsspiel', 2024, 'Eigener Kurztext des Admins.', 'de', 'catalog'); +``` + +Verlag zuordnen (ein vorhandener Verlag wird wiederverwendet, die Schreibweise entscheidet nicht): + +```sql +insert into public.publishers (name) values ('Beispielverlag') on conflict ((lower(name))) do nothing; +insert into public.game_publishers (game_id, publisher_id) +select '7c1d2c3d-0000-4000-8000-000000000002', p.id from public.publishers p where lower(p.name) = lower('Beispielverlag'); +``` + +Optional, wenn die Edition des Nutzers erhalten bleiben soll (nur belegbare Fakten, siehe Regeln): + +```sql +insert into public.game_variants (game_id, name, year) values ('7c1d2c3d-0000-4000-8000-000000000002', 'Deluxe', 2024); +``` + +Dann die Meldung abschließen und das Ergebnis lesen: + +```sql +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', '7c1d2c3d-0000-4000-8000-000000000002'); +``` + +**Schritt 3b: Zusammenführen** (der Eintrag war schon da, Ziel ist das vorhandene Katalogspiel): + +```sql +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004'); +``` + +**Schritt 3c: Ablehnen**, ohne Verweis oder mit Verweis auf ein vorhandenes Katalogspiel: + +```sql +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected'); +``` + +```sql +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004'); +``` + +**Schritt 4: Nachprüfen.** Status der Meldung und Zustand des vorläufigen Eintrags (bei `approved`/`merged`: ausgeblendet und verwiesen; bei `rejected`: unverändert): + +```sql +select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, + g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id +from public.game_reports r +left join public.games g on g.id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; +``` + +**Ergebnis lesen** (Beispiel, `approved` mit vorläufigem Eintrag): + +``` +{"plays": 2, "status": "approved", "repointed": true, "report_id": "…", "target_id": "…", "score_sheets": 1, + "favorite_games": 1, "collection_items": 1, "variants_copied": 0, "variants_mapped": 1, "variants_cleared": 1, + "favorite_conflicts": 0, "collection_conflicts": 0, "provisional_game_id": "…"} +``` + +`repointed` ist `false`, wenn nichts umgehängt wurde (`rejected` oder Meldung ohne vorläufigen Eintrag); dann stehen die Zähler nicht im Ergebnis. Die Zähler haben dieselbe Bedeutung wie bei der Zusammenführung (Abschnitt 5). Wichtig hier: **`variants_cleared`** zählt die Sammlungseinträge und Partien, deren Edition geleert wurde, weil der Katalogeintrag keine gleichnamige Variante hatte. Das ist der Hinweis, dass der Nutzer die Edition neu wählen muss. Eine Kollision (`collection_conflicts`) bedeutet: Der Nutzer hatte das Ziel schon in seiner Sammlung; der Zieleintrag bleibt, leere Felder füllt der Eintrag aus dem vorläufigen Spiel. + +**Fehlermeldungen** (alle ohne Änderung, `P0001`): + +| Meldung | Bedeutung | +|---|---| +| `admin_only` | Die Sitzung läuft nicht als `postgres` bzw. `supabase_admin` (`select current_user;` prüfen) | +| `report_not_found` | Die Meldung gibt es nicht (ID vertippt?) | +| `report_not_open` | Die Meldung ist schon bearbeitet (`approved`, `merged` oder `rejected`) | +| `report_status_invalid` | Der Status ist nicht `approved`, `merged` oder `rejected` | +| `report_target_required` | `approved` und `merged` brauchen ein Ziel | +| `report_target_not_listed` | Das Ziel ist kein sichtbares Katalogspiel (privat, ausgeblendet, zusammengeführt oder unbekannt); das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) | +| `report_provisional_not_private` | Der vorläufige Eintrag der Meldung ist kein privates Spiel (Datenfehler); nicht umhängen, sondern die Meldung prüfen | + +Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Meldungen anlegen, ändern oder löschen können Nutzer in dieser Etappe nicht (das Anlegen kommt mit KAT-04); sie lesen nur ihre eigenen. Das Löschen eines Zielspiels, auf das eine Meldung verweist, schlägt mit `23503` fehl (gewollt: ausblenden statt löschen). ## 7. Admin-Rolle in der App diff --git a/supabase/migrations/20261103195151_game_reports.sql b/supabase/migrations/20261103195151_game_reports.sql new file mode 100644 index 0000000..494a86c --- /dev/null +++ b/supabase/migrations/20261103195151_game_reports.sql @@ -0,0 +1,280 @@ +-- KAT-05 / PR 2c: Meldungen (Spec docs/specs/2-katalog-daten.md, Abschnitt 3.4). +-- Nur additiv. Enthält: +-- games_visibility_immutable Trigger: Sichtbarkeit und Eigentümer eines Spiels sind unveränderlich (E14) +-- game_reports Tabelle mit RLS (Nutzer lesen nur ihre eigenen Meldungen) +-- admin_resolve_report Admin-Funktion: Meldung freigeben, zusammenführen oder ablehnen +-- admin_merge_games per create or replace erweitert: hängt auch game_reports.target_game_id um +-- Anlegen von Meldungen durch Nutzer (Insert-Recht, Insert-Policy, Tageslimit) liefert #13 (KAT-04). + +-- --------------------------------------------------------------------------------------------- +-- E14: visibility und owner_id einer bestehenden games-Zeile ändern sich nie, für keine Rolle +-- (auch nicht für postgres). Ein privater Eintrag wird nie öffentlich geschaltet; freigegebene +-- Meldungen bekommen immer einen neuen Katalogeintrag. Das Konto-Löschen ist nicht betroffen +-- (private Spiele werden per on delete cascade gelöscht, nicht geändert). +-- Hinweis: Ein BEFORE-Trigger läuft vor der RLS-Prüfung (with check). Ein Nutzer, der visibility +-- ändert, bekommt deshalb P0001 visibility_immutable statt 42501. +-- --------------------------------------------------------------------------------------------- +create function public.games_visibility_immutable() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + if new.visibility is distinct from old.visibility or new.owner_id is distinct from old.owner_id then + raise exception 'visibility_immutable' using errcode = 'P0001'; + end if; + return new; +end; +$$; + +create trigger games_visibility_immutable before update on public.games + for each row execute function public.games_visibility_immutable(); + +-- --------------------------------------------------------------------------------------------- +-- Tabelle game_reports (Klärungen 6, 10, 26, 27, N12, N12a; E12) +-- --------------------------------------------------------------------------------------------- +create table public.game_reports ( + id uuid primary key default gen_random_uuid(), + -- meldender Nutzer; Klärung 10: beim Konto-Löschen hart gelöscht + owner_id uuid not null references public.profiles (id) on delete cascade, + origin text not null default 'user' check (origin in ('user', 'import')), + title text not null check (char_length(title) between 1 and 200), + publisher_name text check (char_length(publisher_name) <= 200), + year smallint check (year between 1800 and 2100), + link text check (char_length(link) <= 500 and link ~ '^https?://'), + bgg_id integer, + -- vorläufiger privater Eintrag des Meldenden (KAT-04 Krit. 3); verschwindet er, bleibt die Meldung + provisional_game_id uuid references public.games (id) on delete set null, + status text not null default 'open' check (status in ('open', 'approved', 'rejected', 'merged')), + -- Zieleintrag: wie merged_into_id on delete restrict (Katalogspiele werden ausgeblendet, nicht gelöscht) + target_game_id uuid references public.games (id) on delete restrict, + resolved_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint game_reports_resolved_matches_status check ((status = 'open') = (resolved_at is null)), + constraint game_reports_open_has_no_target check (status <> 'open' or target_game_id is null), + constraint game_reports_target_required check (status not in ('approved', 'merged') or target_game_id is not null) +); + +create trigger set_updated_at before update on public.game_reports + for each row execute function public.set_updated_at(); + +-- Tageslimit (#13): (owner_id, created_at) +create index game_reports_owner_created_idx on public.game_reports (owner_id, created_at); +-- N12a: je Nutzer höchstens eine offene Import-Meldung je BGG-Kennung +create unique index game_reports_import_open_key on public.game_reports (owner_id, bgg_id) + where origin = 'import' and status = 'open' and bgg_id is not null; +-- Admin-Liste: offene Meldungen, älteste zuerst +create index game_reports_open_idx on public.game_reports (created_at) where status = 'open'; +-- Fremdschlüssel (Ergänzung zur Spec: Index auf jeden Fremdschlüssel). Ein vorläufiger Eintrag +-- gehört zu höchstens einer Meldung (1:1), deshalb eindeutig; mehrere Meldungen können dasselbe +-- Ziel haben, daher dort ein einfacher Index. +create unique index game_reports_provisional_game_id_key on public.game_reports (provisional_game_id) + where provisional_game_id is not null; +create index game_reports_target_game_id_idx on public.game_reports (target_game_id); + +-- RLS: Der Meldende liest seine eigenen Meldungen (Ergebnishinweis, KAT-04 Krit. 4-6). Die +-- Admin-Policy (is_admin) ist für R4 vorbereitet, in R1 wird die Rolle niemandem vergeben (E10). +-- Schreibrechte gibt es für anon und authenticated nicht; #13 ergänzt Insert-Recht und -Policy. +alter table public.game_reports enable row level security; +revoke all on public.game_reports from anon, authenticated; +grant select on public.game_reports to authenticated; + +create policy game_reports_select_own on public.game_reports + for select to authenticated + using (owner_id = (select auth.uid())); +create policy game_reports_select_admin on public.game_reports + for select to authenticated + using (public.is_admin()); + +-- --------------------------------------------------------------------------------------------- +-- Admin-Funktion: Meldung bearbeiten (Krit. 5). security invoker, nur postgres/supabase_admin. +-- --------------------------------------------------------------------------------------------- +create function public.admin_resolve_report(p_report uuid, p_status text, p_target uuid default null) +returns jsonb +language plpgsql +security invoker +set search_path = '' +as $$ +declare + rep public.game_reports; + prov public.games; + result jsonb := '{}'::jsonb; + repointed boolean := false; +begin + if current_user not in ('postgres', 'supabase_admin') then + raise exception 'admin_only' using errcode = 'P0001'; + end if; + + select * into rep from public.game_reports r where r.id = p_report for update; + if not found then + raise exception 'report_not_found' using errcode = 'P0001'; + end if; + if rep.status <> 'open' then + raise exception 'report_not_open' using errcode = 'P0001'; + end if; + if p_status is null or p_status not in ('approved', 'merged', 'rejected') then + raise exception 'report_status_invalid' using errcode = 'P0001'; + end if; + if p_status in ('approved', 'merged') and p_target is null then + raise exception 'report_target_required' using errcode = 'P0001'; + end if; + + -- Beteiligte Spiele in fester Reihenfolge sperren (wie admin_merge_games). + perform 1 from public.games g + where g.id in (p_target, rep.provisional_game_id) order by g.id for update; + + if p_target is not null and not exists ( + select 1 from public.games g + where g.id = p_target and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null + ) then + raise exception 'report_target_not_listed' using errcode = 'P0001'; + end if; + + if p_status in ('approved', 'merged') and rep.provisional_game_id is not null then + select * into prov from public.games g where g.id = rep.provisional_game_id; + -- Zusatzprüfung (nicht in der Spec): Nur ein privater Eintrag darf als vorläufiger Eintrag + -- umgehängt werden; ein Katalogspiel würde sonst über den Variantenfall "Katalog" kopiert. + if prov.visibility <> 'private' then + raise exception 'report_provisional_not_private' using errcode = 'P0001'; + end if; + -- privat -> Katalog: nichts wird kopiert (RB1, Klärung N15) + result := public.repoint_game_references(prov.id, p_target); + repointed := true; + -- merged_into_id verlangt hidden_at (Check), deshalb in einem Statement. + update public.games g + set merged_into_id = p_target, hidden_at = coalesce(g.hidden_at, now()) + where g.id = prov.id; + end if; + + -- Bei "rejected" mit Ziel wird nur der Verweis gespeichert (Spec-F3); der vorläufige Eintrag + -- bleibt unverändert, ob der Nutzer wechselt, entscheidet er in der App. + update public.game_reports r + set status = p_status, target_game_id = p_target, resolved_at = now() + where r.id = rep.id; + + return result || jsonb_build_object( + 'report_id', rep.id, + 'status', p_status, + 'target_id', p_target, + 'provisional_game_id', rep.provisional_game_id, + 'repointed', repointed + ); +end; +$$; + +-- --------------------------------------------------------------------------------------------- +-- admin_merge_games: Rumpf aus PR 2b unverändert, nur Schritt 4 neu (game_reports.target_game_id +-- von der Quelle auf das Ziel umhängen) und der Zähler reports_repointed im Ergebnis. +-- --------------------------------------------------------------------------------------------- +create or replace function public.admin_merge_games(p_source uuid, p_target uuid) +returns jsonb +language plpgsql +security invoker +set search_path = '' +as $$ +declare + s public.games; + t public.games; + result jsonb; + n_chain integer; + n_reports integer; + bgg_transferred boolean := false; + source_transferred boolean := false; +begin + if current_user not in ('postgres', 'supabase_admin') then + raise exception 'admin_only' using errcode = 'P0001'; + end if; + if p_source = p_target then + raise exception 'merge_same_game' using errcode = 'P0001'; + end if; + if p_source is null or p_target is null then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + + -- Beide Zeilen in fester Reihenfolge sperren, damit zwei gleichzeitige Aufrufe nicht verklemmen. + perform 1 from public.games g where g.id in (p_source, p_target) order by g.id for update; + select * into s from public.games g where g.id = p_source; + if not found then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + select * into t from public.games g where g.id = p_target; + if not found then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + if s.visibility <> 'catalog' or t.visibility <> 'catalog' then + raise exception 'merge_not_catalog' using errcode = 'P0001'; + end if; + if t.hidden_at is not null or t.merged_into_id is not null then + raise exception 'merge_target_not_listed' using errcode = 'P0001'; + end if; + if s.merged_into_id is not null then + raise exception 'merge_source_already_merged' using errcode = 'P0001'; + end if; + + -- 1. Verweise umhängen (beide Katalog -> Varianten werden kopiert) + result := public.repoint_game_references(p_source, p_target); + + -- 2. BGG-Kennung und Quelle übernehmen (W6). Wegen der eindeutigen Indizes (bgg_id im Katalog, + -- (Quelle, source_ref) im Katalog) zuerst an der Quelle leeren, dann am Ziel setzen. + -- Gleichzeitig wird die Quelle als zusammengeführt und ausgeblendet markiert (ein Statement: + -- merged_into_id verlangt hidden_at). Bereits ausgeblendete Quellen behalten ihr hidden_at. + update public.games g + set bgg_id = null, + catalog_source_id = null, source_ref = null, source_url = null, source_synced_at = null, + merged_into_id = p_target, + hidden_at = coalesce(g.hidden_at, now()) + where g.id = p_source; + + if s.bgg_id is not null then + if t.bgg_id is null then + update public.games g set bgg_id = s.bgg_id where g.id = p_target; + bgg_transferred := true; + elsif t.bgg_id <> s.bgg_id then + -- Das Ziel behält seinen Wert; der geleerte Wert der Quelle bleibt hier erhalten. + result := result || jsonb_build_object('bgg_id_conflict', + jsonb_build_object('source', s.bgg_id, 'target', t.bgg_id)); + end if; + end if; + + if s.catalog_source_id is not null then + if t.catalog_source_id is null then + update public.games g + set catalog_source_id = s.catalog_source_id, source_ref = s.source_ref, + source_url = s.source_url, source_synced_at = s.source_synced_at + where g.id = p_target; + source_transferred := true; + elsif (t.catalog_source_id, t.source_ref) is distinct from (s.catalog_source_id, s.source_ref) then + result := result || jsonb_build_object('source_conflict', jsonb_build_object( + 'source', jsonb_build_object('catalog_source_id', s.catalog_source_id, 'source_ref', s.source_ref, + 'source_url', s.source_url, 'source_synced_at', s.source_synced_at), + 'target', jsonb_build_object('catalog_source_id', t.catalog_source_id, 'source_ref', t.source_ref, + 'source_url', t.source_url, 'source_synced_at', t.source_synced_at))); + end if; + end if; + + -- 3. Ketten glätten: Spiele, die bisher auf die Quelle zeigten, zeigen jetzt auf das Ziel. + update public.games g set merged_into_id = p_target where g.merged_into_id = p_source; + get diagnostics n_chain = row_count; + + -- 4. (neu in 2c) Meldungen, die auf die Quelle als Zieleintrag zeigen, zeigen jetzt auf das Ziel. + update public.game_reports r set target_game_id = p_target where r.target_game_id = p_source; + get diagnostics n_reports = row_count; + + return result || jsonb_build_object( + 'source_id', p_source, + 'target_id', p_target, + 'bgg_id_transferred', bgg_transferred, + 'source_transferred', source_transferred, + 'chain_flattened', n_chain, + 'reports_repointed', n_reports + ); +end; +$$; + +-- Rechte-Regel: nur der Eigentümer darf ausführen. Nach dem create or replace wird der revoke +-- für admin_merge_games ausdrücklich wiederholt (create or replace behält zwar die bisherigen +-- Rechte, aber das soll nicht still vorausgesetzt werden). +revoke execute on function public.games_visibility_immutable() from public, anon, authenticated, service_role; +revoke execute on function public.admin_resolve_report(uuid, text, uuid) from public, anon, authenticated, service_role; +revoke execute on function public.admin_merge_games(uuid, uuid) from public, anon, authenticated, service_role; diff --git a/supabase/tests/database/00_schema.test.sql b/supabase/tests/database/00_schema.test.sql index 359840d..c6ee175 100644 --- a/supabase/tests/database/00_schema.test.sql +++ b/supabase/tests/database/00_schema.test.sql @@ -2,8 +2,8 @@ begin; select plan(5); -select is((select count(*) from pg_tables where schemaname = 'public'), 20::bigint, - 'R1 hat genau 20 Tabellen (17 + catalog_sources, publishers, game_publishers)'); +select is((select count(*) from pg_tables where schemaname = 'public'), 21::bigint, + 'R1 hat genau 21 Tabellen (17 + catalog_sources, publishers, game_publishers, game_reports)'); select is_empty($$ select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql index 28f690f..1831bdc 100644 --- a/supabase/tests/database/07_catalog_merge.test.sql +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -2,8 +2,8 @@ -- Fremdschlüssel-Wächter und die SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 5 -- (unverändert, ohne begin/commit; Auswahl immer per id). -- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix f7/7a1b), gezählt wird nur darüber. --- Hinweis für 2c: Der Fremdschlüssel-Wächter am Ende muss um game_reports.target_game_id (behandelt) und --- game_reports.provisional_game_id (bewusst ausgelassen) erweitert werden, sonst wird er rot. +-- Ab 2c kennt der Fremdschlüssel-Wächter am Ende auch game_reports.target_game_id (behandelt) und +-- game_reports.provisional_game_id (bewusst ausgelassen); admin_merge_games meldet reports_repointed. begin; select plan(89); @@ -209,7 +209,7 @@ select is((select admin_merge_games from rb_merge), 'source_id', '7a1b2c3d-0000-4000-8000-000000000002', 'target_id', '7a1b2c3d-0000-4000-8000-000000000003', 'collection_items', 1, 'collection_conflicts', 3, 'favorite_games', 1, 'favorite_conflicts', 1, 'plays', 3, 'score_sheets', 1, 'variants_copied', 1, 'variants_mapped', 1, 'variants_cleared', 0, - 'bgg_id_transferred', true, 'source_transferred', true, 'chain_flattened', 0), + 'bgg_id_transferred', true, 'source_transferred', true, 'chain_flattened', 0, 'reports_repointed', 0), 'KAT-05 Kriterium 8: Runbook 5, das Ergebnis-JSON zählt alles richtig'); -- Runbook 5, Schritt 4: Nachprüfen (der Vorschau-Baustein aus Schritt 2 liefert überall 0) @@ -402,7 +402,7 @@ select is((select count(*) from public.game_variants where game_id = 'f7000000-0 insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000161', 'Leer Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000162', 'Leer Ziel', 'catalog'); select is(public.admin_merge_games('f7000000-0000-0000-0000-000000000161', 'f7000000-0000-0000-0000-000000000162') - 'source_id' - 'target_id', - '{"collection_items":0,"collection_conflicts":0,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":0,"bgg_id_transferred":false,"source_transferred":false,"chain_flattened":0}'::jsonb, + '{"collection_items":0,"collection_conflicts":0,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":0,"bgg_id_transferred":false,"source_transferred":false,"chain_flattened":0,"reports_repointed":0}'::jsonb, 'Zusammenführen ohne Verweise liefert lauter Nullen'); -- --------------------------------------------------------------------------------------------- @@ -475,9 +475,11 @@ select set_eq($$select fk from fk_found$$, $$values -- behandelt von repoint_game_references / admin_merge_games ('collection_items.game_id'), ('favorite_games.game_id'), ('plays.game_id'), ('score_sheets.game_id'), - ('game_variants.game_id'), ('games.merged_into_id'), + ('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'), -- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag - ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id')$$, + ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'), + -- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht + ('game_reports.provisional_game_id')$$, 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); select * from finish(); diff --git a/supabase/tests/database/08_game_reports.test.sql b/supabase/tests/database/08_game_reports.test.sql new file mode 100644 index 0000000..ab4629b --- /dev/null +++ b/supabase/tests/database/08_game_reports.test.sql @@ -0,0 +1,741 @@ +-- KAT-05 (PR 2c): Meldungen. Tabelle game_reports (RLS, Checks, Indizes, Fremdschlüssel), +-- Trigger games_visibility_immutable, admin_resolve_report, admin_merge_games nach dem +-- create or replace (Rechte, Schritt 4), Konto-Löschen nach Freigabe, Fremdschlüssel-Wächter und die +-- SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 6 (unverändert, ohne begin/commit; Auswahl per id). +-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix 7c1d/f8), gezählt wird nur darüber. +-- Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall, sonst würde "alles verweigern" bestehen. +begin; +select plan(152); + +insert into auth.users (id, email, raw_user_meta_data) values + ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), + ('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'), + ('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Cara"}'); + +-- --------------------------------------------------------------------------------------------- +-- Tabellenrechte (KAT-05 Kriterium 9) +-- --------------------------------------------------------------------------------------------- +select ok((select relrowsecurity from pg_class where oid = 'public.game_reports'::regclass), + 'Sicherheit Kriterium 1: game_reports hat Row Level Security'); +select ok(has_table_privilege('authenticated', 'public.game_reports', 'select'), + 'KAT-05 Kriterium 5: authenticated darf game_reports lesen (eigene Zeilen, Policy)'); +-- Formuliert stabil gegenüber späteren Spaltenrechten (#13 ergänzt Insert-Recht und update (result_seen_at)): +-- geprüft wird je Spalte, nicht "kein Insert/Update auf der Tabelle". +select is_empty($$select c, p from unnest(array['status', 'target_game_id', 'resolved_at', 'origin', 'owner_id', 'bgg_id', 'created_at']) c, + unnest(array['insert', 'update']) p + where has_column_privilege('authenticated', 'public.game_reports', c, p)$$, + 'KAT-05 Kriterium 9: authenticated hat kein Schreibrecht auf status, target_game_id, resolved_at, origin, owner_id, bgg_id, created_at'); +select is_empty($$select c, p from (select attname as c from pg_attribute where attrelid = 'public.game_reports'::regclass and attnum > 0 and not attisdropped) a, + unnest(array['insert', 'update']) p + where has_column_privilege('anon', 'public.game_reports', c, p)$$, + 'KAT-05 Kriterium 9: anon hat auf keine Spalte von game_reports ein Schreibrecht'); +select is_empty($$select r, p from unnest(array['anon', 'authenticated']) r, unnest(array['delete', 'truncate', 'references', 'trigger']) p + where has_table_privilege(r, 'public.game_reports', p)$$, + 'KAT-05 Kriterium 9: kein Löschen, Leeren oder Trigger-Recht für anon und authenticated'); +select is_empty($$select 1 from pg_policies where tablename = 'game_reports' and cmd in ('DELETE', 'ALL')$$, + 'KAT-05 Kriterium 9: game_reports hat keine Delete- oder All-Policy'); +select ok(not has_table_privilege('anon', 'public.game_reports', 'select'), + 'Sicherheit Kriterium 3: anon darf game_reports nicht einmal lesen'); + +-- --------------------------------------------------------------------------------------------- +-- Fixtures (als postgres) +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility) values + ('7c1d2c3d-0000-4000-8000-000000000004', 'Bestehendes Spiel', 'catalog'), + ('f8000000-0000-0000-0000-00000000000c', 'MELDUNGSSPIEL', 'catalog'), + ('f8000000-0000-0000-0000-00000000000e', 'Unbeteiligt', 'catalog'); +update public.games set year = 2023 where id = 'f8000000-0000-0000-0000-00000000000c'; +insert into public.games (id, title, year, bgg_id, visibility) values + ('f8000000-0000-0000-0000-00000000000d', 'Anderer Titel', 2020, 4711, 'catalog'); +insert into public.game_variants (id, game_id, name) values + ('f8000000-0000-0000-0000-0000000000a4', '7c1d2c3d-0000-4000-8000-000000000004', 'Standard'); + +-- vorläufige private Einträge: P1 (Anna, freigeben), P2 (Anna, zusammenführen), P3 und P4 (Cara, ablehnen) +insert into public.games (id, title, year, description, image_path, visibility, owner_id) values + ('7c1d2c3d-0000-4000-8000-000000000003', 'Meldungsspiel', 2024, 'Annas privater Text', 'private/anna.png', 'private', '11111111-1111-1111-1111-111111111111'), + ('7c1d2c3d-0000-4000-8000-000000000008', 'Bestehendes Spiel', 2019, null, null, 'private', '11111111-1111-1111-1111-111111111111'), + ('7c1d2c3d-0000-4000-8000-000000000009', 'Caras Prototyp', 2025, null, null, 'private', '33333333-3333-3333-3333-333333333333'), + ('7c1d2c3d-0000-4000-8000-00000000000a', 'Caras Wunsch', 2025, null, null, 'private', '33333333-3333-3333-3333-333333333333'); + +insert into public.game_reports (id, owner_id, title, publisher_name, year, link, bgg_id, provisional_game_id, created_at) values + ('7c1d2c3d-0000-4000-8000-000000000001', '11111111-1111-1111-1111-111111111111', 'Meldungsspiel', 'Beispielverlag', 2024, 'https://example.org/spiel', 4711, '7c1d2c3d-0000-4000-8000-000000000003', '2026-01-01'), + ('7c1d2c3d-0000-4000-8000-000000000005', '11111111-1111-1111-1111-111111111111', 'Bestehendes Spiel', null, null, null, null, '7c1d2c3d-0000-4000-8000-000000000008', '2026-01-03'), + ('7c1d2c3d-0000-4000-8000-000000000006', '33333333-3333-3333-3333-333333333333', 'Caras Prototyp', null, null, null, null, '7c1d2c3d-0000-4000-8000-000000000009', '2026-01-02'), + ('7c1d2c3d-0000-4000-8000-000000000007', '33333333-3333-3333-3333-333333333333', 'Caras Wunsch', null, null, null, null, '7c1d2c3d-0000-4000-8000-00000000000a', '2026-01-04'), + ('7c1d2c3d-0000-4000-8000-00000000000b', '22222222-2222-2222-2222-222222222222', 'Bens Meldung ohne Eintrag', null, null, null, null, null, '2026-01-05'); + +-- Verweise an den vorläufigen Einträgen +insert into public.game_variants (id, game_id, name) values + ('f8000000-0000-0000-0000-0000000000e1', '7c1d2c3d-0000-4000-8000-000000000003', 'Deluxe'), + ('f8000000-0000-0000-0000-0000000000e2', '7c1d2c3d-0000-4000-8000-000000000003', 'Prototyp'), + ('f8000000-0000-0000-0000-0000000000e3', '7c1d2c3d-0000-4000-8000-000000000008', ' standard'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list, note) values + ('f8000000-0000-0000-0000-0000000000c1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'f8000000-0000-0000-0000-0000000000e2', 'owned', 'Annas Notiz'), + ('f8000000-0000-0000-0000-0000000000c2', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000008', 'f8000000-0000-0000-0000-0000000000e3', 'wishlist', 'Annas Wunsch'), + ('f8000000-0000-0000-0000-0000000000c3', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000004', null, 'owned', null), + ('f8000000-0000-0000-0000-0000000000c4', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000004', 'f8000000-0000-0000-0000-0000000000a4', 'owned', 'Bens Notiz'), + ('f8000000-0000-0000-0000-0000000000c5', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-000000000009', null, 'owned', 'Caras Notiz'), + ('f8000000-0000-0000-0000-0000000000c6', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-00000000000a', null, 'wishlist', 'Caras Wunschnotiz'); +insert into public.favorite_games (id, profile_id, game_id, position) values + ('f8000000-0000-0000-0000-0000000000d1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 1); +insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values + ('f8000000-0000-0000-0000-0000000000b1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'f8000000-0000-0000-0000-0000000000e1', '2026-01-01', 'f8000000-0000-0000-0000-0000000000b1'), + ('f8000000-0000-0000-0000-0000000000b2', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', null, '2026-01-02', 'f8000000-0000-0000-0000-0000000000b2'), + ('f8000000-0000-0000-0000-0000000000b3', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000004', 'f8000000-0000-0000-0000-0000000000a4', '2026-01-03', 'f8000000-0000-0000-0000-0000000000b3'), + ('f8000000-0000-0000-0000-0000000000b4', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-000000000009', null, '2026-01-04', 'f8000000-0000-0000-0000-0000000000b4'); +insert into public.score_sheets (id, owner_id, game_id, name) values + ('f8000000-0000-0000-0000-0000000000b5', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'Bogen'); + +-- --------------------------------------------------------------------------------------------- +-- Lesen, RLS (Sicherheit Kriterium 1 und 3, KAT-05 Kriterium 5 und 9) +-- --------------------------------------------------------------------------------------------- +select cmp_ok((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), '>', 0::bigint, + 'Vorbedingung: es gibt Meldungen anderer Nutzer'); + +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select is((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), 2::bigint, + 'Sicherheit Kriterium 3: Anna liest ihre beiden eigenen Meldungen'); +select is((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'Sicherheit Kriterium 3: Anna liest keine fremden Meldungen'); +select is_empty($$select 1 from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000006'$$, + 'Sicherheit Kriterium 3: Anna findet Caras Meldung auch per ID nicht'); +select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', 'Im Namen von Ben')$$, '42501', null, + 'KAT-05 Kriterium 9: Anna kann keine Meldung für einen anderen Nutzer anlegen'); +select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at, target_game_id) + values ('11111111-1111-1111-1111-111111111111', 'Selbst freigegeben', 'approved', now(), '7c1d2c3d-0000-4000-8000-000000000004')$$, '42501', null, + 'KAT-05 Kriterium 9: Anna kann keine bereits freigegebene Meldung anlegen'); +select throws_ok($$update public.game_reports set target_game_id = '7c1d2c3d-0000-4000-8000-000000000004' where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: Anna kann das Ziel ihrer Meldung nicht setzen'); +select throws_ok($$update public.game_reports set status = 'approved' where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: Anna kann ihre Meldung nicht selbst freigeben'); +select throws_ok($$delete from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: Anna kann ihre Meldung nicht löschen'); +reset role; + +select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true); +set local role authenticated; +select is((select count(*) from public.game_reports where owner_id = '22222222-2222-2222-2222-222222222222'), 1::bigint, + 'Sicherheit Kriterium 3: Ben liest seine eigene Meldung'); +select is((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), 0::bigint, + 'Sicherheit Kriterium 3: Ben liest Annas und Caras Meldungen nicht'); +reset role; + +-- Gefälschte Rolle im user_metadata (vom Client änderbar) öffnet nichts +select set_config('request.jwt.claims', + '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated","user_metadata":{"role":"admin"}}', true); +set local role authenticated; +select is((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), 0::bigint, + 'Sicherheit Kriterium 3: user_metadata.role = admin öffnet keine fremden Meldungen'); +reset role; + +-- Die Admin-Policy (R4) ist vorbereitet: nur app_metadata (serverseitig gesetzt) öffnet das Lesen, schreiben kann auch der Admin nicht +select set_config('request.jwt.claims', + '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated","app_metadata":{"role":"admin"}}', true); +set local role authenticated; +select cmp_ok((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), '>=', 4::bigint, + 'Admin-Policy für R4: app_metadata.role = admin liest alle Meldungen'); +select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now() where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: auch mit app_metadata.role = admin kein Ändern des Status über die Schnittstelle'); +select throws_ok($$delete from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: auch mit app_metadata.role = admin kein Löschen über die Schnittstelle'); +reset role; + +set local role anon; +select throws_ok($$select count(*) from public.game_reports$$, '42501', null, + 'Sicherheit Kriterium 3: anon liest keine Meldungen'); +select throws_ok($$insert into public.game_reports (owner_id, title) values ('11111111-1111-1111-1111-111111111111', 'Neu')$$, '42501', null, + 'KAT-05 Kriterium 9: anon kann keine Meldung anlegen'); +select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now() where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: anon kann keine Meldung ändern'); +reset role; + +-- --------------------------------------------------------------------------------------------- +-- Checks (jeweils mit dem Namen der verletzten Regel, damit nicht ein anderer Fehler besteht) +-- --------------------------------------------------------------------------------------------- +select lives_ok($$insert into public.game_reports (id, owner_id, title, link, year, publisher_name) values + ('f8000000-0000-0000-0000-000000000a01', '22222222-2222-2222-2222-222222222222', 'Gültig https', 'https://example.org/x', 1800, repeat('v', 200)), + ('f8000000-0000-0000-0000-000000000a02', '22222222-2222-2222-2222-222222222222', repeat('t', 200), 'http://example.org/x', 2100, null)$$, + 'Gültige Meldungen (https/http, Jahresgrenzen, Längengrenzen) lassen sich anlegen'); +select throws_ok($$insert into public.game_reports (owner_id, title, origin) values ('22222222-2222-2222-2222-222222222222', 'X', 'bot')$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_origin_check"', 'Check: origin nur user/import'); +select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', '')$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_title_check"', 'Check: Titel nicht leer'); +select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', repeat('t', 201))$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_title_check"', 'Check: Titel höchstens 200 Zeichen'); +select throws_ok($$insert into public.game_reports (owner_id, title, publisher_name) values ('22222222-2222-2222-2222-222222222222', 'X', repeat('v', 201))$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_publisher_name_check"', 'Check: Verlag höchstens 200 Zeichen'); +select throws_ok($$insert into public.game_reports (owner_id, title, year) values ('22222222-2222-2222-2222-222222222222', 'X', 1799)$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_year_check"', 'Check: Jahr mindestens 1800'); +select throws_ok($$insert into public.game_reports (owner_id, title, year) values ('22222222-2222-2222-2222-222222222222', 'X', 2101)$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_year_check"', 'Check: Jahr höchstens 2100'); +select throws_ok($$insert into public.game_reports (owner_id, title, link) values ('22222222-2222-2222-2222-222222222222', 'X', 'ftp://example.org')$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_link_check"', 'Check: Link nur http(s)'); +select throws_ok($$insert into public.game_reports (owner_id, title, link) values ('22222222-2222-2222-2222-222222222222', 'X', 'https://' || repeat('a', 493))$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_link_check"', 'Check: Link höchstens 500 Zeichen'); +select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'done', now())$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_status_check"', 'Check: Status nur open/approved/rejected/merged'); +select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'open', now())$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_resolved_matches_status"', 'Check: offene Meldung hat kein resolved_at'); +select throws_ok($$insert into public.game_reports (owner_id, title, status) values ('22222222-2222-2222-2222-222222222222', 'X', 'rejected')$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_resolved_matches_status"', 'Check: erledigte Meldung braucht resolved_at'); +select throws_ok($$insert into public.game_reports (owner_id, title, target_game_id) values ('22222222-2222-2222-2222-222222222222', 'X', '7c1d2c3d-0000-4000-8000-000000000004')$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_open_has_no_target"', 'Check: offene Meldung hat kein Ziel'); +select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'approved', now())$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: approved braucht ein Ziel'); +select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'merged', now())$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: merged braucht ein Ziel'); +select lives_ok($$insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values + ('f8000000-0000-0000-0000-000000000a03', '22222222-2222-2222-2222-222222222222', 'rejected mit Ziel', 'rejected', now(), '7c1d2c3d-0000-4000-8000-000000000004'), + ('f8000000-0000-0000-0000-000000000a04', '22222222-2222-2222-2222-222222222222', 'rejected ohne Ziel', 'rejected', now(), null)$$, + 'Positiv: rejected ist mit und ohne Ziel zulässig'); +select is((select origin || ':' || status from public.game_reports where id = 'f8000000-0000-0000-0000-000000000a01'), 'user:open', + 'Standardwerte: origin = user, status = open'); + +-- --------------------------------------------------------------------------------------------- +-- N12a: eine offene Import-Meldung je Nutzer und BGG-Kennung +-- --------------------------------------------------------------------------------------------- +insert into public.game_reports (id, owner_id, title, origin, bgg_id) values + ('f8000000-0000-0000-0000-000000000b01', '22222222-2222-2222-2222-222222222222', 'Import 1', 'import', 9001); +select throws_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) + values ('22222222-2222-2222-2222-222222222222', 'Import 1 doppelt', 'import', 9001)$$, '23505', null, + 'N12a: zweite offene Import-Meldung desselben Nutzers mit derselben BGG-Kennung wird abgelehnt'); +select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) values + ('33333333-3333-3333-3333-333333333333', 'Import anderer Nutzer', 'import', 9001), + ('22222222-2222-2222-2222-222222222222', 'Import andere Kennung', 'import', 9002), + ('22222222-2222-2222-2222-222222222222', 'Von Hand dieselbe Kennung', 'user', 9001), + ('22222222-2222-2222-2222-222222222222', 'Von Hand dieselbe Kennung 2', 'user', 9001), + ('22222222-2222-2222-2222-222222222222', 'Import ohne Kennung', 'import', null), + ('22222222-2222-2222-2222-222222222222', 'Import ohne Kennung 2', 'import', null)$$, + 'N12a: anderer Nutzer, andere Kennung, origin = user und fehlende Kennung bleiben zulässig'); +insert into public.game_reports (id, owner_id, title, origin, bgg_id, status, resolved_at) values + ('f8000000-0000-0000-0000-000000000b02', '22222222-2222-2222-2222-222222222222', 'Import 1 erledigt', 'import', 9001, 'rejected', now()); +select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) + values ('22222222-2222-2222-2222-222222222222', 'Import 1 neu', 'import', 9003)$$, + 'N12a: Vorbedingung für die nächste Prüfung'); +update public.game_reports set status = 'rejected', resolved_at = now() where id = 'f8000000-0000-0000-0000-000000000b01'; +select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) + values ('22222222-2222-2222-2222-222222222222', 'Import 1 nach Erledigung', 'import', 9001)$$, + 'N12a: ist die frühere Meldung erledigt, darf eine neue offene für dieselbe Kennung angelegt werden'); +select is((select indexdef from pg_indexes where indexname = 'game_reports_open_idx'), + 'CREATE INDEX game_reports_open_idx ON public.game_reports USING btree (created_at) WHERE (status = ''open''::text)', + 'Index für die Admin-Liste (offene Meldungen nach Alter)'); +select is((select count(*) from pg_indexes where tablename = 'game_reports' and indexname in + ('game_reports_owner_created_idx', 'game_reports_provisional_game_id_key', 'game_reports_target_game_id_idx')), 3::bigint, + 'Indizes für Tageslimit und beide Fremdschlüssel auf games vorhanden'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) + values ('11111111-1111-1111-1111-111111111111', 'Zweite Meldung zum selben Eintrag', '7c1d2c3d-0000-4000-8000-000000000003')$$, '23505', null, + 'Ein vorläufiger Eintrag gehört zu höchstens einer Meldung (1:1)'); +select lives_ok($$insert into public.game_reports (owner_id, title, target_game_id, status, resolved_at) values + ('22222222-2222-2222-2222-222222222222', 'Gleiches Ziel 1', '7c1d2c3d-0000-4000-8000-000000000004', 'merged', now()), + ('22222222-2222-2222-2222-222222222222', 'Gleiches Ziel 2', '7c1d2c3d-0000-4000-8000-000000000004', 'merged', now())$$, + 'Mehrere Meldungen dürfen dasselbe Ziel haben, und mehrere dürfen ohne vorläufigen Eintrag sein'); + +-- --------------------------------------------------------------------------------------------- +-- Fremdschlüssel-Regeln +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility, owner_id) values + ('f8000000-0000-0000-0000-000000000c01', 'Wird gelöscht', 'private', '22222222-2222-2222-2222-222222222222'); +insert into public.game_reports (id, owner_id, title, provisional_game_id) values + ('f8000000-0000-0000-0000-000000000c02', '22222222-2222-2222-2222-222222222222', 'Vorläufiger Eintrag verschwindet', 'f8000000-0000-0000-0000-000000000c01'); +delete from public.games where id = 'f8000000-0000-0000-0000-000000000c01'; +select is((select count(*) || ':' || count(provisional_game_id) from public.game_reports where id = 'f8000000-0000-0000-0000-000000000c02'), '1:0', + 'Fremdschlüssel: wird der vorläufige Eintrag gelöscht, bleibt die Meldung und der Verweis wird leer (on delete set null)'); +select throws_ok($$delete from public.games where id = '7c1d2c3d-0000-4000-8000-000000000004'$$, '23503', null, + 'Fremdschlüssel: ein Ziel mit Verweis aus einer Meldung lässt sich nicht löschen (on delete restrict)'); +select throws_ok($$insert into public.game_reports (owner_id, title) values (gen_random_uuid(), 'Ohne Konto')$$, '23503', null, + 'Fremdschlüssel: Meldung braucht ein vorhandenes Profil'); + +-- --------------------------------------------------------------------------------------------- +-- Trigger games_visibility_immutable (E14), KAT-05 Kriterium 9 +-- --------------------------------------------------------------------------------------------- +select is_empty($$select p.proname, r from pg_proc p join pg_namespace n on n.oid = p.pronamespace, + unnest(array['anon', 'authenticated', 'service_role']) as r + where n.nspname = 'public' and p.proname in ('games_visibility_immutable', 'admin_resolve_report') + and has_function_privilege(r, p.oid, 'execute')$$, + 'KAT-05 Kriterium 9: games_visibility_immutable und admin_resolve_report sind für anon, authenticated und service_role nicht ausführbar'); +select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname in ('games_visibility_immutable', 'admin_resolve_report', 'admin_merge_games') + and not p.prosecdef and p.proconfig = array['search_path=""']), 3::bigint, + 'Alle drei Funktionen sind security invoker mit leerem search_path'); + +-- als postgres: private -> Katalog (mit allem, was der Check games_owner_matches_visibility verlangt) +select throws_ok($$update public.games set visibility = 'catalog', owner_id = null where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch postgres kann einen privaten Eintrag nicht öffentlich schalten'); +select throws_ok($$update public.games set visibility = 'catalog' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch nur visibility allein wird abgelehnt'); +select throws_ok($$update public.games set visibility = 'private', owner_id = '11111111-1111-1111-1111-111111111111' where id = '7c1d2c3d-0000-4000-8000-000000000004'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch postgres kann ein Katalogspiel nicht privat schalten'); +select throws_ok($$update public.games set owner_id = '22222222-2222-2222-2222-222222222222' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: der Eigentümer eines privaten Spiels ist unveränderlich'); +select throws_ok($$update public.games set owner_id = null where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch ein leerer Eigentümer wird abgelehnt'); +select is((select visibility || ':' || owner_id::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), + 'private:11111111-1111-1111-1111-111111111111', 'Nach den Fehlern ist das Spiel unverändert'); +select lives_ok($$update public.games set title = 'Meldungsspiel', visibility = 'private' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'Positiv: dieselbe Sichtbarkeit nochmals zu setzen ist keine Änderung'); +select lives_ok($$update public.games set year = 2022 where id = '7c1d2c3d-0000-4000-8000-000000000004'$$, + 'Positiv: andere Felder eines Katalogspiels bleiben als postgres änderbar'); +update public.games set year = null where id = '7c1d2c3d-0000-4000-8000-000000000004'; + +-- als Nutzer: der Trigger läuft vor der RLS-Prüfung, deshalb P0001 statt 42501 +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select throws_ok($$update public.games set visibility = 'catalog' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: Anna kann ihren privaten Eintrag nicht öffentlich schalten (Trigger vor RLS)'); +select lives_ok($$update public.games set title = 'Meldungsspiel (neu)' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'Positiv: Anna ändert den Titel ihres privaten Eintrags weiterhin'); +update public.games set title = 'Meldungsspiel' where id = '7c1d2c3d-0000-4000-8000-000000000003'; +reset role; +select is((select visibility from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 'private', + 'Annas Eintrag ist weiterhin privat'); + +-- --------------------------------------------------------------------------------------------- +-- admin_resolve_report: Rechte (KAT-05 Kriterium 9) +-- --------------------------------------------------------------------------------------------- +select set_config('request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"role":"admin"}}', true); +set local role authenticated; +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null, + 'KAT-05 Kriterium 9: authenticated (auch mit app_metadata.role = admin) kann admin_resolve_report nicht aufrufen'); +reset role; +set local role anon; +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null, + 'KAT-05 Kriterium 9: anon kann admin_resolve_report nicht aufrufen'); +reset role; +set local role service_role; +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null, + 'KAT-05 Kriterium 9: service_role kann admin_resolve_report nicht aufrufen'); +reset role; +select is((select status from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), 'open', + 'Die verweigerten Aufrufe haben nichts verändert'); + +-- Die Rollenprüfung im Funktionskörper: eine Rolle MIT execute-Recht, aber ohne postgres/supabase_admin +create role report_tester nologin; +grant report_tester to postgres with set true; +grant usage on schema public to report_tester; +grant execute on function public.admin_resolve_report(uuid, text, uuid) to report_tester; +set local role report_tester; +do $$ +begin + perform public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected'); + perform set_config('t.resolve_err', 'kein Fehler', true); +exception when others then + perform set_config('t.resolve_err', sqlstate || ':' || sqlerrm, true); +end +$$; +reset role; +select is(current_setting('t.resolve_err'), 'P0001:admin_only', + 'KAT-05 Kriterium 9: admin_resolve_report prüft die Datenbankrolle (admin_only)'); +select is((select status from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), 'open', + 'admin_only ändert nichts'); + +-- --------------------------------------------------------------------------------------------- +-- admin_resolve_report: Fehlerfälle (alle ohne Änderung) +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility, hidden_at) values + ('f8000000-0000-0000-0000-000000000d01', 'Ziel versteckt', 'catalog', '2020-01-01'); +insert into public.games (id, title, visibility, hidden_at, merged_into_id) values + ('f8000000-0000-0000-0000-000000000d02', 'Ziel zusammengeführt', 'catalog', '2020-01-01', '7c1d2c3d-0000-4000-8000-000000000004'); +insert into public.games (id, title, visibility, owner_id) values + ('f8000000-0000-0000-0000-000000000d03', 'Ziel privat', 'private', '22222222-2222-2222-2222-222222222222'); +create temp table rb_before as +select (select count(*) from public.game_reports where status <> 'open') as erledigt, + (select count(*) from public.games where hidden_at is not null) as ausgeblendet, + (select string_agg(id::text || target_game_id::text || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%') as meldungen; + +select throws_ok($$select public.admin_resolve_report(gen_random_uuid(), 'rejected')$$, 'P0001', 'report_not_found', + 'KAT-05 Kriterium 5: unbekannte Meldung (report_not_found)'); +select throws_ok($$select public.admin_resolve_report(null, 'rejected')$$, 'P0001', 'report_not_found', + 'KAT-05 Kriterium 5: fehlende Meldung (null) ist report_not_found'); +select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000a04', 'rejected')$$, 'P0001', 'report_not_open', + 'KAT-05 Kriterium 5: bereits abgelehnte Meldung (report_not_open)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'open')$$, 'P0001', 'report_status_invalid', + 'KAT-05 Kriterium 5: Status open ist kein Ergebnis (report_status_invalid)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'fertig')$$, 'P0001', 'report_status_invalid', + 'KAT-05 Kriterium 5: unbekannter Status (report_status_invalid)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', null)$$, 'P0001', 'report_status_invalid', + 'KAT-05 Kriterium 5: fehlender Status (null) ist report_status_invalid'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'fertig', null)$$, 'P0001', 'report_status_invalid', + 'KAT-05 Kriterium 5: ungültiger Status geht vor fehlendem Ziel'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved')$$, 'P0001', 'report_target_required', + 'KAT-05 Kriterium 5: approved ohne Ziel (report_target_required)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'merged', null)$$, 'P0001', 'report_target_required', + 'KAT-05 Kriterium 5: merged ohne Ziel (report_target_required)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-000000000d01')$$, + 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: ausgeblendetes Ziel (report_target_not_listed)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'merged', 'f8000000-0000-0000-0000-000000000d02')$$, + 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: zusammengeführtes Ziel (report_target_not_listed)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-000000000d03')$$, + 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: privates Ziel (report_target_not_listed)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-0000000000ff')$$, + 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: unbekanntes Ziel (report_target_not_listed)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected', 'f8000000-0000-0000-0000-000000000d01')$$, + 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: auch bei rejected wird das Ziel geprüft'); + +-- Datenfehler: vorläufiger Eintrag ist ein Katalogspiel (Zusatzprüfung gegenüber der Spec) +insert into public.game_reports (id, owner_id, title, provisional_game_id) values + ('f8000000-0000-0000-0000-000000000e01', '22222222-2222-2222-2222-222222222222', 'Vorläufig ist Katalog', 'f8000000-0000-0000-0000-00000000000e'); +select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000e01', 'merged', '7c1d2c3d-0000-4000-8000-000000000004')$$, + 'P0001', 'report_provisional_not_private', 'Zusatzprüfung: ein Katalogspiel als vorläufiger Eintrag wird nicht umgehängt'); +select is((select status from public.game_reports where id = 'f8000000-0000-0000-0000-000000000e01'), 'open', + 'Nach report_provisional_not_private ist die Meldung weiterhin offen'); +select is((select hidden_at is null and merged_into_id is null from public.games where id = 'f8000000-0000-0000-0000-00000000000e'), true, + 'Das Katalogspiel wurde nicht verändert'); +select is((select row(erledigt, ausgeblendet, meldungen)::text from rb_before), + (select row((select count(*) from public.game_reports where status <> 'open'), + (select count(*) from public.games where hidden_at is not null), + (select string_agg(id::text || target_game_id::text || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%'))::text), + 'Nach allen Fehlerfällen ist nichts verändert (Meldungen, Ausblendungen)'); + +-- --------------------------------------------------------------------------------------------- +-- Runbook Abschnitt 6, Schritt 1 und 2 (SQL-Bausteine unverändert) +-- --------------------------------------------------------------------------------------------- +-- Vorbereitung für Schritt 2 (ähnliche Spiele): Titel gleich bzw. BGG-Kennung 4711 am Katalogspiel +-- (Fixtures oben: MELDUNGSSPIEL, Anderer Titel) + +-- Schritt 1: Offene Meldungen +create temp table rb_open as +select r.id, r.created_at, r.origin, r.title, r.publisher_name, r.year, r.link, r.bgg_id, r.provisional_game_id +from public.game_reports r +where r.status = 'open' +order by r.created_at, r.id; +select results_eq($$select id from rb_open where id::text like '7c1d2c3d%' order by created_at, id$$, + $$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid), ('7c1d2c3d-0000-4000-8000-000000000006'), ('7c1d2c3d-0000-4000-8000-000000000005'), + ('7c1d2c3d-0000-4000-8000-000000000007'), ('7c1d2c3d-0000-4000-8000-00000000000b')$$, + 'KAT-05 Kriterium 8: Runbook 6, offene Meldungen (älteste zuerst)'); +select is((select row(origin, title, publisher_name, year, link, bgg_id, provisional_game_id)::text from rb_open where id = '7c1d2c3d-0000-4000-8000-000000000001'), + '(user,Meldungsspiel,Beispielverlag,2024,https://example.org/spiel,4711,7c1d2c3d-0000-4000-8000-000000000003)', + 'KAT-05 Kriterium 8: Runbook 6, die Spalten der Meldung stehen in der Liste'); + +-- Schritt 2: Ähnliche Spiele im Katalog +create temp table rb_similar as +select g.id, g.title, g.year, g.bgg_id +from public.game_reports r +join public.catalog_games g + on lower(btrim(g.title)) = lower(btrim(r.title)) + or (r.bgg_id is not null and g.bgg_id = r.bgg_id) +where r.id = '7c1d2c3d-0000-4000-8000-000000000001' +order by g.title, g.id; +select results_eq($$select id, title, year, bgg_id from rb_similar$$, + $$values ('f8000000-0000-0000-0000-00000000000d'::uuid, 'Anderer Titel'::text, 2020::smallint, 4711::integer), + ('f8000000-0000-0000-0000-00000000000c', 'MELDUNGSSPIEL', 2023::smallint, null::integer)$$, + 'KAT-05 Kriterium 8: Runbook 6, ähnliche Spiele (gleicher Titel ohne Groß-/Kleinschreibung oder gleiche BGG-Kennung)'); + +-- Schritt 2: Was hängt am vorläufigen Eintrag? +create temp table rb_counts as +select g.id, g.title, + (select count(*) from public.collection_items c where c.game_id = g.id) as sammlung, + (select count(*) from public.favorite_games f where f.game_id = g.id) as favoriten, + (select count(*) from public.plays p where p.game_id = g.id) as partien, + (select count(*) from public.score_sheets s where s.game_id = g.id) as wertungsboegen +from public.game_reports r +join public.games g on g.id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; +select results_eq($$select id, title, sammlung, favoriten, partien, wertungsboegen from rb_counts$$, + $$values ('7c1d2c3d-0000-4000-8000-000000000003'::uuid, 'Meldungsspiel'::text, 1::bigint, 1::bigint, 2::bigint, 1::bigint)$$, + 'KAT-05 Kriterium 8: Runbook 6, Anzahlen am vorläufigen Eintrag'); + +-- Schritt 2: Editionen des vorläufigen Eintrags +create temp table rb_editions as +select v.id, v.name, v.year, v.language +from public.game_reports r +join public.game_variants v on v.game_id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001' +order by v.name, v.id; +select results_eq($$select id, name from rb_editions$$, + $$values ('f8000000-0000-0000-0000-0000000000e1'::uuid, 'Deluxe'::text), ('f8000000-0000-0000-0000-0000000000e2', 'Prototyp')$$, + 'KAT-05 Kriterium 8: Runbook 6, Namen der Editionen des vorläufigen Eintrags'); + +-- --------------------------------------------------------------------------------------------- +-- Runbook Abschnitt 6, Schritt 3a: Freigeben (neuer Katalogeintrag, nur Fakten aus der Meldung) +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, year, description, description_locale, visibility) +values ('7c1d2c3d-0000-4000-8000-000000000002', 'Meldungsspiel', 2024, 'Eigener Kurztext des Admins.', 'de', 'catalog'); + +insert into public.publishers (name) values ('Beispielverlag') on conflict ((lower(name))) do nothing; +insert into public.game_publishers (game_id, publisher_id) +select '7c1d2c3d-0000-4000-8000-000000000002', p.id from public.publishers p where lower(p.name) = lower('Beispielverlag'); + +insert into public.game_variants (game_id, name, year) values ('7c1d2c3d-0000-4000-8000-000000000002', 'Deluxe', 2024); + +select is((select manual_fields from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'), + array['description', 'description_locale', 'publishers', 'title', 'year'], + 'KAT-05 Kriterium 8: Runbook 6, der neue Katalogeintrag vermerkt seine Felder in manual_fields'); + +-- Ben hat das neue Spiel nicht; Bens Daten am vorhandenen Ziel (…04) merken, sie dürfen sich nicht ändern +create temp table ben_before as +select c.id, c.game_id, c.variant_id, c.list, c.note from public.collection_items c where c.owner_id = '22222222-2222-2222-2222-222222222222' +union all +select p.id, p.game_id, p.variant_id, 'play', null from public.plays p where p.owner_id = '22222222-2222-2222-2222-222222222222'; + +create temp table rb_approve as +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', '7c1d2c3d-0000-4000-8000-000000000002') as r; +select is((select r - 'report_id' from rb_approve), + jsonb_build_object( + 'status', 'approved', 'target_id', '7c1d2c3d-0000-4000-8000-000000000002', + 'provisional_game_id', '7c1d2c3d-0000-4000-8000-000000000003', 'repointed', true, + 'collection_items', 1, 'collection_conflicts', 0, 'favorite_games', 1, 'favorite_conflicts', 0, + 'plays', 2, 'score_sheets', 1, 'variants_copied', 0, 'variants_mapped', 1, 'variants_cleared', 1), + 'KAT-05 Kriterium 5: Runbook 6, approved: Ergebnis-JSON zählt alles richtig (nichts kopiert, eine Edition abgebildet, eine geleert)'); +select is((select r ->> 'report_id' from rb_approve), '7c1d2c3d-0000-4000-8000-000000000001', 'Das Ergebnis nennt die Meldung'); + +-- Schritt 4: Nachprüfen +create temp table rb_check as +select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, + g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id +from public.game_reports r +left join public.games g on g.id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; +select results_eq($$select id, status, target_game_id, erledigt, vorlaeufig_ausgeblendet, merged_into_id from rb_check$$, + $$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid, 'approved'::text, '7c1d2c3d-0000-4000-8000-000000000002'::uuid, true, true, '7c1d2c3d-0000-4000-8000-000000000002'::uuid)$$, + 'KAT-05 Kriterium 8: Runbook 6, Nachprüfen: Meldung freigegeben, vorläufiger Eintrag ausgeblendet und verwiesen'); + +select is((select row(visibility, owner_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), + '(private,11111111-1111-1111-1111-111111111111)', 'KAT-05 Kriterium 5: der vorläufige Eintrag bleibt privat und gehört weiter Anna (E14)'); +select is((select row(description, image_path)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'), + '("Eigener Kurztext des Admins.",)', 'Beschreibung und Bild des vorläufigen Eintrags werden nie übernommen'); +select is((select row(visibility, owner_id, hidden_at is null, merged_into_id is null)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'), + '(catalog,,t,t)', 'Das neue Katalogspiel ist sichtbar und gehört niemandem'); +select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4000-8000-000000000002'), 1::bigint, + 'Das neue Katalogspiel erscheint in catalog_games'); +select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 0::bigint, + 'Der vorläufige Eintrag erscheint nie in catalog_games'); +select is((select resolved_at is not null and updated_at >= created_at from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'), true, + 'resolved_at ist gesetzt'); + +-- Verweise +select is((select row(game_id, variant_id, list, note)::text from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c1'), + '(7c1d2c3d-0000-4000-8000-000000000002,,owned,"Annas Notiz")', + 'RB1: Sammlungseintrag am neuen Spiel, Edition "Prototyp" ohne Gegenstück ist geleert'); +select is((select row(p.game_id, v.name, v.game_id)::text from public.plays p join public.game_variants v on v.id = p.variant_id + where p.id = 'f8000000-0000-0000-0000-0000000000b1'), + '(7c1d2c3d-0000-4000-8000-000000000002,Deluxe,7c1d2c3d-0000-4000-8000-000000000002)', + 'RB1: die Edition "Deluxe" der Partie zeigt auf die vom Admin angelegte gleichnamige Variante am Katalogeintrag'); +select is((select row(game_id, variant_id)::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b2'), + '(7c1d2c3d-0000-4000-8000-000000000002,)', 'Partie ohne Edition wechselt auf das neue Spiel'); +select is((select game_id from public.favorite_games where id = 'f8000000-0000-0000-0000-0000000000d1'), '7c1d2c3d-0000-4000-8000-000000000002'::uuid, + 'Favorit wechselt auf das neue Spiel'); +select is((select game_id from public.score_sheets where id = 'f8000000-0000-0000-0000-0000000000b5'), '7c1d2c3d-0000-4000-8000-000000000002'::uuid, + 'Wertungsbogen wechselt auf das neue Spiel'); +select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000002'), 1::bigint, + 'RB1: am Katalogeintrag gibt es nur die vom Admin angelegte Variante, die private wurde nicht kopiert'); +select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000003'), 2::bigint, + 'RB1: die privaten Varianten bleiben am vorläufigen Eintrag'); +select is((select count(*) from public.collection_items where game_id = '7c1d2c3d-0000-4000-8000-000000000003'), 0::bigint, + 'Nichts hängt mehr am vorläufigen Eintrag'); + +-- --------------------------------------------------------------------------------------------- +-- Runbook Abschnitt 6, Schritt 3b: Zusammenführen mit einem vorhandenen Eintrag (Kollision) +-- --------------------------------------------------------------------------------------------- +create temp table rb_merged as +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004') as r; +select is((select r - 'report_id' from rb_merged), + jsonb_build_object( + 'status', 'merged', 'target_id', '7c1d2c3d-0000-4000-8000-000000000004', + 'provisional_game_id', '7c1d2c3d-0000-4000-8000-000000000008', 'repointed', true, + 'collection_items', 0, 'collection_conflicts', 1, 'favorite_games', 0, 'favorite_conflicts', 0, + 'plays', 0, 'score_sheets', 0, 'variants_copied', 0, 'variants_mapped', 1, 'variants_cleared', 0), + 'KAT-05 Kriterium 5: Runbook 6, merged: Kollision (Anna hatte das Ziel schon), gleichnamige Variante abgebildet'); +select is((select row(status, target_game_id)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000005'), + '(merged,7c1d2c3d-0000-4000-8000-000000000004)', 'merged: Status und Ziel gespeichert'); +select is((select row(hidden_at is not null, merged_into_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000008'), + '(t,7c1d2c3d-0000-4000-8000-000000000004)', 'merged: der vorläufige Eintrag ist ausgeblendet und verwiesen'); +select is((select row(list, note, variant_id)::text from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c3'), + '(owned,"Annas Wunsch",f8000000-0000-0000-0000-0000000000a4)', + 'Spec-F1: Anna: der Zieleintrag gewinnt (owned vor wishlist), die leere Notiz und Edition füllt der vorläufige Eintrag (Edition über die gleichnamige Variante)'); +select is((select count(*) from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c2'), 0::bigint, + 'Spec-F1: der Quell-Eintrag der Kollision ist gelöscht'); +select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000004'), 1::bigint, + 'RB1: das vorhandene Katalogspiel bekommt keine Variante'); +select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000008'), 1::bigint, + 'RB1: die private Variante bleibt am vorläufigen Eintrag'); +select results_eq($$select c.id, c.game_id, c.variant_id, c.list, c.note from public.collection_items c + where c.owner_id = '22222222-2222-2222-2222-222222222222' + union all select p.id, p.game_id, p.variant_id, 'play', null from public.plays p where p.owner_id = '22222222-2222-2222-2222-222222222222'$$, + $$select id, game_id, variant_id, list, note from ben_before$$, + 'Daten anderer Nutzer am Ziel (Bens Sammlung und Partie) sind unverändert'); + +-- --------------------------------------------------------------------------------------------- +-- Runbook Abschnitt 6, Schritt 3c: Ablehnen +-- --------------------------------------------------------------------------------------------- +create temp table rb_rej_snapshot as +select 'c5' as k, game_id::text || coalesce(variant_id::text, '') || list as v from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c5' +union all select 'c6', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c6' +union all select 'b4', game_id::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b4' +union all select 'g9', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-000000000009' +union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-00000000000a'; + +create temp table rb_rej1 as +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected') as r; +create temp table rb_rej2 as +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004') as r; +select is((select r - 'report_id' from rb_rej1), + '{"status":"rejected","target_id":null,"provisional_game_id":"7c1d2c3d-0000-4000-8000-000000000009","repointed":false}'::jsonb, + 'KAT-05 Kriterium 5: Runbook 6, rejected ohne Ziel: nichts umgehängt, keine Zähler'); +select is((select r - 'report_id' from rb_rej2), + '{"status":"rejected","target_id":"7c1d2c3d-0000-4000-8000-000000000004","provisional_game_id":"7c1d2c3d-0000-4000-8000-00000000000a","repointed":false}'::jsonb, + 'KAT-05 Kriterium 5: Runbook 6, rejected mit Ziel: nur der Verweis, nichts umgehängt'); +select is((select row(status, target_game_id, resolved_at is not null)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000006'), + '(rejected,,t)', 'rejected ohne Ziel: Status und Zeitpunkt gespeichert, kein Ziel'); +select is((select row(status, target_game_id, resolved_at is not null)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000007'), + '(rejected,7c1d2c3d-0000-4000-8000-000000000004,t)', 'rejected mit Ziel: der Verweis auf den Zieleintrag ist gespeichert (Spec-F3)'); +select results_eq($$select 'c5', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c5' + union all select 'c6', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c6' + union all select 'b4', game_id::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b4' + union all select 'g9', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-000000000009' + union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-00000000000a'$$, + $$select k, v from rb_rej_snapshot$$, + 'KAT-05 Kriterium 5: bei rejected (mit und ohne Ziel) bleiben vorläufige Einträge, Sammlung und Partien unverändert'); + +-- approved ohne vorläufigen Eintrag: nur Status und Ziel +create temp table rb_noprov as +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', '7c1d2c3d-0000-4000-8000-000000000004') as r; +select is((select r - 'report_id' from rb_noprov), + '{"status":"approved","target_id":"7c1d2c3d-0000-4000-8000-000000000004","provisional_game_id":null,"repointed":false}'::jsonb, + 'approved ohne vorläufigen Eintrag: nichts umzuhängen (repointed = false)'); +select is((select row(status, target_game_id)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), + '(approved,7c1d2c3d-0000-4000-8000-000000000004)', 'approved ohne vorläufigen Eintrag: Status und Ziel gespeichert'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'rejected')$$, 'P0001', 'report_not_open', + 'KAT-05 Kriterium 5: eine freigegebene Meldung lässt sich nicht erneut bearbeiten'); + +-- Der Nutzer sieht das Ergebnis seiner Meldung (KAT-04 Krit. 4-6), aber keine fremden +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select is((select status || ':' || target_game_id::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'), + 'approved:7c1d2c3d-0000-4000-8000-000000000002', 'Anna sieht das Ergebnis ihrer Meldung'); +select is((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'Anna sieht weiterhin keine fremden Meldungen'); +select cmp_ok((select count(*) from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), '=', 1::bigint, + 'Anna sieht ihren ausgeblendeten vorläufigen Eintrag weiterhin'); +reset role; + +-- --------------------------------------------------------------------------------------------- +-- admin_merge_games nach dem create or replace (Schritt 4: Meldungen umhängen) und Rechte +-- --------------------------------------------------------------------------------------------- +select is_empty($$select p.proname, r from pg_proc p join pg_namespace n on n.oid = p.pronamespace, + unnest(array['anon', 'authenticated', 'service_role']) as r + where n.nspname = 'public' and p.proname in ('admin_merge_games', 'repoint_game_references') + and has_function_privilege(r, p.oid, 'execute')$$, + 'KAT-05 Kriterium 9: nach dem create or replace sind admin_merge_games und repoint_game_references für anon, authenticated und service_role nicht ausführbar'); +select set_config('request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"role":"admin"}}', true); +set local role authenticated; +select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, + 'KAT-05 Kriterium 9: authenticated (auch mit app_metadata.role = admin) kann admin_merge_games weiterhin nicht aufrufen'); +reset role; +set local role anon; +select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, + 'KAT-05 Kriterium 9: anon kann admin_merge_games weiterhin nicht aufrufen'); +reset role; +set local role service_role; +select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, + 'KAT-05 Kriterium 9: service_role kann admin_merge_games weiterhin nicht aufrufen'); +reset role; +create role merge_tester nologin; +grant merge_tester to postgres with set true; +grant usage on schema public to merge_tester; +grant execute on function public.admin_merge_games(uuid, uuid) to merge_tester; +set local role merge_tester; +do $$ +begin + perform public.admin_merge_games('f8000000-0000-0000-0000-000000000f01', 'f8000000-0000-0000-0000-000000000f02'); + perform set_config('t.merge_err', 'kein Fehler', true); +exception when others then + perform set_config('t.merge_err', sqlstate || ':' || sqlerrm, true); +end +$$; +reset role; +select is(current_setting('t.merge_err'), 'P0001:admin_only', + 'KAT-05 Kriterium 9: admin_merge_games prüft nach dem create or replace weiterhin die Datenbankrolle'); + +-- Schritt 4: Quelle (…f01) -> Ziel (…f02); Meldungen mit Ziel = Quelle ziehen mit +insert into public.games (id, title, visibility) values + ('f8000000-0000-0000-0000-000000000f01', 'Merge Quelle', 'catalog'), + ('f8000000-0000-0000-0000-000000000f02', 'Merge Ziel', 'catalog'), + ('f8000000-0000-0000-0000-000000000f03', 'Anderes Ziel', 'catalog'); +insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values + ('f8000000-0000-0000-0000-000000000f11', '22222222-2222-2222-2222-222222222222', 'Freigegeben auf Quelle', 'approved', now(), 'f8000000-0000-0000-0000-000000000f01'), + ('f8000000-0000-0000-0000-000000000f12', '22222222-2222-2222-2222-222222222222', 'Abgelehnt mit Verweis auf Quelle', 'rejected', now(), 'f8000000-0000-0000-0000-000000000f01'), + ('f8000000-0000-0000-0000-000000000f13', '22222222-2222-2222-2222-222222222222', 'Zusammengeführt mit anderem', 'merged', now(), 'f8000000-0000-0000-0000-000000000f03'); +insert into public.game_reports (id, owner_id, title) values + ('f8000000-0000-0000-0000-000000000f14', '22222222-2222-2222-2222-222222222222', 'Offen'); +create temp table rb_mg as select public.admin_merge_games('f8000000-0000-0000-0000-000000000f01', 'f8000000-0000-0000-0000-000000000f02') as r; +select is((select r -> 'reports_repointed' from rb_mg), '2'::jsonb, 'KAT-05 Kriterium 4: admin_merge_games meldet 2 umgehängte Meldungen'); +select results_eq($$select id, target_game_id from public.game_reports where id::text like 'f8000000-0000-0000-0000-000000000f1%' order by id$$, + $$values ('f8000000-0000-0000-0000-000000000f11'::uuid, 'f8000000-0000-0000-0000-000000000f02'::uuid), + ('f8000000-0000-0000-0000-000000000f12', 'f8000000-0000-0000-0000-000000000f02'), + ('f8000000-0000-0000-0000-000000000f13', 'f8000000-0000-0000-0000-000000000f03'), + ('f8000000-0000-0000-0000-000000000f14', null)$$, + 'KAT-05 Kriterium 4: Meldungen mit Ziel = Quelle zeigen auf das Ziel, andere Meldungen bleiben unverändert'); +select is((select row(status, resolved_at is not null)::text from public.game_reports where id = 'f8000000-0000-0000-0000-000000000f11'), '(approved,t)', + 'Das Umhängen ändert Status und Zeitpunkt der Meldung nicht'); +select is(public.admin_merge_games('f8000000-0000-0000-0000-000000000f03', 'f8000000-0000-0000-0000-000000000f02') -> 'reports_repointed', '1'::jsonb, + 'Ein weiteres Zusammenführen hängt die nächste Meldung um'); +select lives_ok($$delete from public.games where id = 'f8000000-0000-0000-0000-000000000f01'$$, + 'Positiv: Die Quelle ist nach dem Umhängen nicht mehr Ziel einer Meldung und lässt sich löschen (keine verwaiste Restrict-Sperre)'); + +-- --------------------------------------------------------------------------------------------- +-- Fremdschlüssel-Wächter (wie in 07): jeder Verweis auf games ist behandelt oder bewusst ausgelassen +-- --------------------------------------------------------------------------------------------- +create temp table fk_found as +select (select relname from pg_class where oid = c.conrelid) || '.' || + (select attname from pg_attribute where attrelid = c.conrelid and attnum = c.conkey[1]) as fk +from pg_constraint c +where c.contype = 'f' and c.confrelid = 'public.games'::regclass; +select is((select count(*) from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.games'::regclass and cardinality(c.conkey) > 1), 0::bigint, + 'Fremdschlüssel-Wächter: alle Verweise auf games sind einspaltig'); +select set_eq($$select fk from fk_found$$, + $$values + -- behandelt von repoint_game_references / admin_merge_games + ('collection_items.game_id'), ('favorite_games.game_id'), ('plays.game_id'), ('score_sheets.game_id'), + ('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'), + -- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag + ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'), + -- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht + ('game_reports.provisional_game_id')$$, + 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); + +-- --------------------------------------------------------------------------------------------- +-- Konto löschen nach Freigabe (QUE-04, Klärung 10; Spec 9.5 KTO-05) +-- Anna: freigegebene Meldung (neu angelegter Katalogeintrag), zusammengeführte Meldung, ausgeblendete +-- vorläufige Einträge mit merged_into_id auf Katalogspiele. +-- --------------------------------------------------------------------------------------------- +insert into public.collection_items (id, owner_id, game_id, list, note) values + ('f8000000-0000-0000-0000-0000000000c7', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000002', 'owned', 'Bens Notiz am neuen Spiel'); +create temp table other_before as +select 'game2' as k, row(g.title, g.year, g.description, g.hidden_at, g.merged_into_id, g.visibility, g.manual_fields)::text as v + from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000002' +union all select 'game4', row(g.title, g.year, g.hidden_at, g.merged_into_id, g.visibility)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000004' +union all select 'ben_c7', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c7' +union all select 'ben_c4', row(c.game_id, c.variant_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c4' +union all select 'cara_report', row(r.status, r.target_game_id)::text from public.game_reports r where r.id = '7c1d2c3d-0000-4000-8000-000000000007' +union all select 'cara_c5', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c5' +union all select 'variants', (select count(*) from public.game_variants where game_id in ('7c1d2c3d-0000-4000-8000-000000000002', '7c1d2c3d-0000-4000-8000-000000000004'))::text; + +select cmp_ok((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), '=', 2::bigint, + 'Vorbedingung: Anna hat zwei bearbeitete Meldungen'); +select cmp_ok((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111' and merged_into_id is not null), '=', 2::bigint, + 'Vorbedingung: Anna hat zwei ausgeblendete vorläufige Einträge mit merged_into_id'); +select lives_ok($$delete from auth.users where id = '11111111-1111-1111-1111-111111111111'$$, + 'KAT-05 Kriterium 5 / QUE-04: das Konto der Meldenden lässt sich nach der Freigabe löschen'); +select is((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'QUE-04: die Meldungen sind mit dem Konto gelöscht'); +select is((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'QUE-04: die vorläufigen Einträge sind mit dem Konto gelöscht'); +select is((select count(*) from public.collection_items where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'QUE-04: Annas Sammlung (auch die umgehängten Einträge) ist gelöscht'); +select results_eq($$select 'game2', row(g.title, g.year, g.description, g.hidden_at, g.merged_into_id, g.visibility, g.manual_fields)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000002' + union all select 'game4', row(g.title, g.year, g.hidden_at, g.merged_into_id, g.visibility)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000004' + union all select 'ben_c7', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c7' + union all select 'ben_c4', row(c.game_id, c.variant_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c4' + union all select 'cara_report', row(r.status, r.target_game_id)::text from public.game_reports r where r.id = '7c1d2c3d-0000-4000-8000-000000000007' + union all select 'cara_c5', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c5' + union all select 'variants', (select count(*) from public.game_variants where game_id in ('7c1d2c3d-0000-4000-8000-000000000002', '7c1d2c3d-0000-4000-8000-000000000004'))::text$$, + $$select k, v from other_before$$, + 'QUE-04: Katalogspiele (neu angelegt und vorhanden), ihre Varianten und die Daten und Meldungen anderer Nutzer bleiben unverändert'); +select is((select count(*) from public.game_reports where owner_id = '33333333-3333-3333-3333-333333333333'), 3::bigint, + 'Caras Meldungen (zwei aus dem Runbook, eine Import-Meldung) sind nach Annas Löschung vorhanden'); + +select * from finish(); +rollback; From e665a71f13e71001263f606ac2d2e658c1e5ed2d Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 21:07:19 +0100 Subject: [PATCH 14/30] feat(db): reports tests, runbook section 6 and docs for KAT-05 2c Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/datenmodell-r1.md | 27 +++++++++++++++++-- docs/specs/2-katalog-daten.md | 19 +++++++++++++ supabase/README.md | 5 ++-- .../tests/database/08_game_reports.test.sql | 10 +++++-- 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/docs/datenmodell-r1.md b/docs/datenmodell-r1.md index cdfb50b..3da8a51 100644 --- a/docs/datenmodell-r1.md +++ b/docs/datenmodell-r1.md @@ -28,7 +28,7 @@ Die Pfeile zeigen, welche Tabelle auf welche verweist. Jede Partie (`plays`) hä ## Tabellen -R1 braucht 20 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers` und `game_publishers` aus KAT-05; mit `game_reports` in KAT-05 PR 2c werden es 21). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag. +R1 braucht 21 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers`, `game_publishers` und `game_reports` aus KAT-05). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag. ### Konto und Profil @@ -43,6 +43,7 @@ R1 braucht 20 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog | Tabelle | Spalten | Zweck | | --- | --- | --- | | `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at` | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die letzten sieben Spalten gehören der Katalogpflege (KAT-05), siehe unten | +| `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at` | Meldungen fehlender Spiele (KAT-05 PR 2c, Anlegen durch Nutzer kommt mit KAT-04); siehe Katalogpflege | | `catalog_sources` | `key`, `name`, `attribution_text`, `attribution_url`, `images_allowed`, `texts_allowed`, `terms_url`, `terms_checked_at` | Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt `bgg` ohne Erlaubnis an | | `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) | | `game_variants` | `game_id` (FK → `games`), `name`, `year`, `language`, `bgg_id` (optional) | Editionen und Varianten eines Spiels (O4) | @@ -76,6 +77,7 @@ Jede Tabelle hat Row Level Security (RLS) und eine Policy-Test-Suite (pgTAP). De | `profiles` | Eigenes Profil; fremde nur `display_name`, `avatar_path`, `city` | Nur eigenes | | `favorite_games`, `bgg_links` | Nur eigene | Nur eigene | | `games`, `game_variants`, Begriffe | Katalog für alle (O6); private Spiele nur der Ersteller | Katalog nur Admin (in R1 nur über Studio/SQL, E10); private Spiele nur der Ersteller, und nur die Spalten laut Spaltenrechten | +| `game_reports` | nur der Meldende liest seine Zeilen (Admin-Policy `is_admin()` für R4); Schreiben kein Nutzerrecht | Anlegen ergänzt KAT-04 (#13); Bearbeiten nur über `admin_resolve_report` (Studio) | | `catalog_sources`, `publishers`, `game_publishers`, View `catalog_games` | für alle, auch ohne Anmeldung (`game_publishers` folgt dem Spiel) | Quellen und Verlage nur Admin; `game_publishers` Admin oder Ersteller des privaten Spiels; die View ist nicht beschreibbar | | `collection_items`, `score_sheets`, `score_categories` | Nur eigene | Nur eigene | | `plays` | Ersteller und eingeladene Mitspieler (offen oder bestätigt) | Nur Ersteller | @@ -233,6 +235,26 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel | `created_at` | timestamptz | ja | `now()` | | | `updated_at` | timestamptz | ja | `now()` | | +**`game_reports`** (KAT-05, Migration `game_reports`): + +| Spalte | Typ | Pflicht | Standard | Schlüssel | +| --- | --- | --- | --- | --- | +| `id` | uuid | ja | `gen_random_uuid()` | PK | +| `owner_id` | uuid | ja | | FK → profiles (`on delete cascade`) | +| `origin` | text | ja | `'user'` | `user` oder `import` | +| `title` | text | ja | | 1 bis 200 Zeichen | +| `publisher_name` | text | nein | | höchstens 200 | +| `year` | smallint | nein | | 1800 bis 2100 | +| `link` | text | nein | | `http(s)://`, höchstens 500 | +| `bgg_id` | integer | nein | | | +| `provisional_game_id` | uuid | nein | | FK → games (`on delete set null`), eindeutig (1:1) | +| `status` | text | ja | `'open'` | `open`, `approved`, `rejected`, `merged` | +| `target_game_id` | uuid | nein | | FK → games (`on delete restrict`) | +| `resolved_at` | timestamptz | nein | | gesetzt genau dann, wenn `status <> 'open'` | +| `created_at`, `updated_at` | timestamptz | ja | `now()` | | + +Checks: offen hat kein Ziel; `approved`/`merged` brauchen ein Ziel. Indizes: `(owner_id, created_at)`, eindeutig `(owner_id, bgg_id)` für offene Import-Meldungen (N12a), `(created_at)` für offene Meldungen, eindeutig `provisional_game_id`, `target_game_id`. + **`genres`**, **`mechanics`** und **`categories`** sind gleich aufgebaut: | Spalte | Typ | Pflicht | Standard | Schlüssel | @@ -343,9 +365,10 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle - **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf). - **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). **Regel: `merged_into_id` wird nur über `public.admin_merge_games(p_source, p_target) returns jsonb` gesetzt, nie von Hand.** (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu. +- **Meldungen** (Migration `game_reports`, PR 2c): Tabelle `game_reports` (siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy auf `owner_id`; die Policy für `is_admin()` ist für R4 vorbereitet); Schreibrechte haben `anon` und `authenticated` nicht, #13 ergänzt Insert-Recht und -Policy. Bearbeitet wird in R1 nur über `public.admin_resolve_report(p_report, p_status, p_target) returns jsonb` (Ablauf, Ergebnis und Fehler: `docs/katalog-pflege.md`, Abschnitt 6): `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`), für `anon`, `authenticated` und `service_role` nicht ausführbar. `approved` und `merged` hängen den vorläufigen privaten Eintrag über `repoint_game_references` auf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert, `variants_cleared`) und blenden ihn aus (`hidden_at`, `merged_into_id`); `rejected` speichert höchstens den Verweis. `admin_merge_games` hängt zusätzlich `game_reports.target_game_id` von der Quelle auf das Ziel (`reports_repointed`). Der Trigger `games_visibility_immutable` lehnt jede Änderung von `visibility` und `owner_id` an bestehenden Spielen ab (Fehler `visibility_immutable`, für alle Rollen auch `postgres`; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501). Beim Konto-Löschen werden Meldungen und vorläufige Einträge (auch nach der Freigabe) hart gelöscht, Katalogspiele und Daten anderer Nutzer bleiben. Fremdschlüssel-Wächter: `07` und `08`. - **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder. - **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import). - **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`. - **Startbestand:** `supabase/catalog/` (CSV, Werkzeug, erzeugte SQL-Dateien); `supabase db reset` spielt Vokabular und Startbestand ein. Der Startbestand enthält nur Fakten und einen eigenen deutschen Kurztext, kein Cover, keine Quelle, keine BGG-Kennung. - **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht. -- **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an. +- **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`; neu für den Meldenden lesbar: `game_reports`. Das Ändern von `visibility` an einem Spiel ist nicht mehr möglich (P0001 `visibility_immutable`); wer eine Sichtbarkeit mitsendet, muss den alten Wert senden oder die Spalte weglassen. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an. diff --git a/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index 6fb2d58..5c4fd50 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -720,3 +720,22 @@ Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_cata - **Runbook:** Alle SQL-Bausteine aus Abschnitt 5 laufen wörtlich in `07` (einmalige Prüfung per Skript; keine CI-Prüfung). Abschnitt 5 enthält die Duplikatsuche, eine Vorschau (Verweise, Doppelbesitz), den Aufruf, die Nachprüfung, die Regeln, die Schlüssel des Ergebnis-JSON und die Fehlertexte. - **Nachtrag Review 2b:** Tests ergänzt (Spec-F1-Edition am Ziel, `variants_cleared` im Kollisionszweig, gleichnamige Quellvarianten, zweiter Fremdschlüssel-Wächter auf `game_variants`, unbeteiligte Spiele, Ziel `null`); Runbook um Trockenlauf (`begin; …; rollback;` in einem Lauf, nur dieser Baustein läuft nicht wörtlich in pgTAP), Ketten-Nachprüfung und Nachschlagen von `merged_into_id` ergänzt. Der Null-Check in `admin_merge_games` ist ein äquivalenter Mutant (das spätere `not found` liefert denselben Fehler) und bleibt als ausdrückliche Absicherung. Offen für Chris: Verweise, die nach dem Merge noch auf die Quelle entstehen (W3). + +### 2026-11-03 – Umsetzung PR 2c (backend-dev) + +Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_reports.test.sql`, 153 Tests; `07` und `00` angepasst), B10 (Runbook Abschnitt 6). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR: + +- **Branch:** `feature/2c-katalog-meldungen` statt `feature/2-katalog-meldungen` (Vorgabe des Auftrags), gestapelt auf `feature/2b-katalog-zusammenfuehren`. +- **Zusätzliche Indizes** auf beide Fremdschlüssel nach `games` (Regel: Index auf jeden Fremdschlüssel). `provisional_game_id` ist **eindeutig** (`where provisional_game_id is not null`, 1:1: ein vorläufiger Eintrag gehört zu höchstens einer Meldung), `target_game_id` ein einfacher Index. +- **Zusatzfehler `report_provisional_not_private`** in `admin_resolve_report`: Ist der vorläufige Eintrag kein privates Spiel (Datenfehler per Studio), wird nichts umgehängt; sonst würde `repoint_game_references` ein Katalogspiel wie beim Zusammenführen behandeln (Varianten kopieren und löschen). +- **Ergebnis-JSON `admin_resolve_report`:** `report_id`, `status`, `target_id`, `provisional_game_id`, `repointed` (bool). Nur bei `repointed = true` zusätzlich die Zähler aus `repoint_game_references`. `rejected` und Meldungen ohne vorläufigen Eintrag liefern `repointed = false` und keine Zähler. +- **Ergebnis-JSON `admin_merge_games`:** neuer Schlüssel `reports_repointed` (Anzahl umgehängter Meldungen, immer vorhanden). Dadurch mussten zwei Assertions in `07` (exaktes JSON) und das Beispiel im Runbook Abschnitt 5 angepasst werden. Rumpf von `admin_merge_games` sonst unverändert (per `diff` gegen 2b geprüft); `revoke execute … from public, anon, authenticated, service_role` nach dem `create or replace` ausdrücklich wiederholt. +- **Sperren:** `admin_resolve_report` sperrt die Meldung und danach Ziel und vorläufigen Eintrag in `id`-Reihenfolge (`for update`), wie `admin_merge_games`. Dass der vorläufige Eintrag existiert, sichert der Fremdschlüssel (`on delete set null`); `repoint_game_references` selbst prüft die Existenz von `p_from` weiterhin nicht. +- **Ziel wird auch bei `rejected` geprüft** (`report_target_not_listed`), wenn eines angegeben ist. Reihenfolge der Prüfungen wie in 3.4; `p_report = null` ist `report_not_found`, `p_status = null` ist `report_status_invalid`. +- **Trigger `games_visibility_immutable`** läuft vor der RLS-Prüfung: Ein Nutzer, der `visibility` ändert, bekommt `P0001 visibility_immutable` statt `42501` (API-Änderung für Clients; bestehende Tests in `02` bleiben grün, weil dort `owner_id` ohne Spaltenrecht vorher mit 42501 scheitert). Es gibt weiterhin keine generierten Datenbanktypen im Repo. +- **RLS:** zwei Lese-Policies (eigene Zeilen; `is_admin()` für R4). Bewusst kein Test mit `is_admin()` als Zugang in R1, aber `08` dokumentiert, dass `app_metadata.role = 'admin'` lesen, nicht schreiben kann, und `user_metadata` nichts öffnet. +- **Tests stabil gegenüber #13:** `08` prüft Schreibrechte je Spalte (`has_column_privilege` auf `status`, `target_game_id`, `resolved_at`, `origin`, `owner_id`, `bgg_id`, `created_at`), „Delete verboten“ und „fremde Meldung nicht sichtbar/änderbar“, nicht „kein Insert/Update auf der Tabelle“. +- **Bewusst nicht gebaut (offene Entscheidung Chris, Spec #13):** `game_reports.result_seen_at` und `editions_cleared`. #13 ergänzt bei Freigabe das Spaltenrecht `update (result_seen_at)` und ggf. `editions_cleared` sowie Insert-Recht und -Policy; die Tests in `08` sind darauf vorbereitet. +- **Fremdschlüssel-Wächter:** erweiterte Liste in `07` und in `08` (`game_reports.target_game_id` behandelt, `game_reports.provisional_game_id` bewusst ausgelassen). `00_schema.test.sql`: 21 Tabellen. +- **Runbook Abschnitt 6:** Alle SQL-Bausteine laufen wörtlich in `08` (einmalige Prüfung per Skript, keine CI-Prüfung). Der Trockenlauf-Baustein aus 2b gilt entsprechend (`begin; …; rollback;` in einem Lauf). +- **Mutationsprüfung:** 25 Mutanten (u. a. Trigger entfernt/ohne `owner_id`, Grants, Policies, RLS aus, Fremdschlüssel-Aktionen, N12a-Index, Funktionsrumpf-Änderungen) gegen `08`; alle werden erkannt. Bei der Prüfung fiel ein Scheintest auf (Löschen eines Ziels scheiterte an anderen Verweisen), jetzt mit eigenem Ziel. Nicht erkennbar: das Weglassen des wiederholten `revoke` allein (Postgres behält die Rechte bei `create or replace`); erkannt wird stattdessen ein ausdrückliches `grant execute`. diff --git a/supabase/README.md b/supabase/README.md index 6f67b9d..6dc0427 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -3,7 +3,7 @@ ## Inhalt | Pfad | Zweck | |---|---| -| `migrations/` | Schema, Trigger und RLS-Policies für R1 (20 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. | +| `migrations/` | Schema, Trigger und RLS-Policies für R1 (21 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. | | `tests/database/` | pgTAP-Tests: Struktur-Regeln und Zugriffsrechte je Bereich. | | `templates/` | Auth-Mailvorlagen (HTML, Go-Template) für Bestätigung und Passwort-Reset. | | `seed.sql` | Beispieldaten nur für die lokale Entwicklung. | @@ -32,7 +32,8 @@ supabase test db # alle pgTAP-Tests ausführen ## Katalog (KAT-05) - Pflege in R1 nur über Studio bzw. `psql` (Rolle `postgres`), Ablauf und Regeln: [`docs/katalog-pflege.md`](../docs/katalog-pflege.md). Spec: `docs/specs/2-katalog-daten.md`. - Suche und Entdecken lesen nur die View `catalog_games` (ohne ausgeblendete und zusammengeführte Spiele). Geänderte Fakten trägt ein Trigger in `games.manual_fields` ein. -- Zusammenführen von Duplikaten nur über die Funktion `public.admin_merge_games(quelle, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`, nicht über die API aufrufbar); Ablauf in `docs/katalog-pflege.md`, Abschnitt 5. Jede neue Tabelle mit Verweis auf `games` muss dort behandelt werden (Wächter in `tests/database/07_catalog_merge.test.sql`). +- Zusammenführen von Duplikaten nur über die Funktion `public.admin_merge_games(quelle, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`, nicht über die API aufrufbar); Ablauf in `docs/katalog-pflege.md`, Abschnitt 5. Jede neue Tabelle mit Verweis auf `games` muss dort behandelt werden (Wächter in `tests/database/07_catalog_merge.test.sql` und `08_game_reports.test.sql`). +- Meldungen fehlender Spiele (`game_reports`) bearbeitet der Admin nur über `public.admin_resolve_report(meldung, status, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`); Ablauf in `docs/katalog-pflege.md`, Abschnitt 6. `visibility` und `owner_id` eines Spiels sind unveränderlich (Trigger `games_visibility_immutable`). - `db reset` spielt `seed.sql`, `catalog/vocabulary.sql` und `catalog/startbestand.sql` ein (`[db.seed] sql_paths` in `config.toml`). Die SQL-Dateien in `catalog/` sind **erzeugt**; Quellen sind die CSV-Dateien. Neu erzeugen und testen (Deno, `brew install deno`): ```bash deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts diff --git a/supabase/tests/database/08_game_reports.test.sql b/supabase/tests/database/08_game_reports.test.sql index ab4629b..8b6013a 100644 --- a/supabase/tests/database/08_game_reports.test.sql +++ b/supabase/tests/database/08_game_reports.test.sql @@ -5,7 +5,7 @@ -- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix 7c1d/f8), gezählt wird nur darüber. -- Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall, sonst würde "alles verweigern" bestehen. begin; -select plan(152); +select plan(153); insert into auth.users (id, email, raw_user_meta_data) values ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), @@ -241,8 +241,14 @@ insert into public.game_reports (id, owner_id, title, provisional_game_id) value delete from public.games where id = 'f8000000-0000-0000-0000-000000000c01'; select is((select count(*) || ':' || count(provisional_game_id) from public.game_reports where id = 'f8000000-0000-0000-0000-000000000c02'), '1:0', 'Fremdschlüssel: wird der vorläufige Eintrag gelöscht, bleibt die Meldung und der Verweis wird leer (on delete set null)'); -select throws_ok($$delete from public.games where id = '7c1d2c3d-0000-4000-8000-000000000004'$$, '23503', null, +-- eigenes Ziel, auf das nur eine Meldung verweist (sonst würde ein anderer Verweis die Prüfung vortäuschen) +insert into public.games (id, title, visibility) values ('f8000000-0000-0000-0000-000000000c03', 'Ziel nur für eine Meldung', 'catalog'); +insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values + ('f8000000-0000-0000-0000-000000000c04', '22222222-2222-2222-2222-222222222222', 'Verweist auf Ziel', 'rejected', now(), 'f8000000-0000-0000-0000-000000000c03'); +select throws_ok($$delete from public.games where id = 'f8000000-0000-0000-0000-000000000c03'$$, '23503', null, 'Fremdschlüssel: ein Ziel mit Verweis aus einer Meldung lässt sich nicht löschen (on delete restrict)'); +select is((select target_game_id from public.game_reports where id = 'f8000000-0000-0000-0000-000000000c04'), 'f8000000-0000-0000-0000-000000000c03'::uuid, + 'Der Verweis der Meldung auf das Ziel bleibt bestehen'); select throws_ok($$insert into public.game_reports (owner_id, title) values (gen_random_uuid(), 'Ohne Konto')$$, '23503', null, 'Fremdschlüssel: Meldung braucht ein vorhandenes Profil'); From f7a7c1e54c68a1eacdb6745b63562d97e58361ef Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:07:44 +0000 Subject: [PATCH 15/30] docs(catalog): Review-Befunde zur Spec KAT-04 eingearbeitet Tageslimit-Rechte (report_day), Normalisierung als Wrapper um search_normalize aus Spec #3, Sperrreihenfolge und Rechte von adopt_report_target, Indizes aus 2c, Testdatei 10, Edition-Hinweis mit editions_cleared als Empfehlung. Refs #13 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/13-fehlendes-spiel-melden.md | 445 ++++++++++++------------ 1 file changed, 218 insertions(+), 227 deletions(-) diff --git a/docs/specs/13-fehlendes-spiel-melden.md b/docs/specs/13-fehlendes-spiel-melden.md index ee72fbd..bae1af4 100644 --- a/docs/specs/13-fehlendes-spiel-melden.md +++ b/docs/specs/13-fehlendes-spiel-melden.md @@ -3,12 +3,12 @@ | | | |---|---| | Issue | #13 (KAT-04) | -| Status | Entwurf, wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2) | +| 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 | **#2 PR 2b und 2c müssen gemergt sein**, bevor 13a beginnt (Spec #2 E12): `game_reports`, `admin_resolve_report`, `repoint_game_references`, Trigger `games_visibility_immutable`. #1 (Anmeldung), #3 KAT-01 (Einstieg aus der leeren Suche, Krit. 6). Fachlich zusätzlich #6 SAM-01 und #4 KAT-02 (Orte für „in die Sammlung“ und den Hinweis „am Eintrag“), in Forgejo bisher **nicht** als Abhängigkeit gesetzt (Spec-F8). | -| Quellen | Issue #13 inkl. Kommentare (u. a. Hinweis aus PR #51 vom 2026-11-03), `requirements/20-r1-akzeptanzkriterien.md` § KAT-04 und § Querschnitt, `requirements/r1-klaerungen.md` (6, 10, 12, 15, 20, 23, 26, 27, 28, 36, 44/25, 45, N12, N12a, N15), `requirements/backlog.md`, `docs/specs/2-katalog-daten.md` (E10–E14, 3.2–3.4, 4.4, 9.5), `docs/datenmodell-r1.md`, `supabase/migrations/20261102100200_catalog.sql`, `…100300_collection.sql`, `…100400_plays.sql`, `…20261103142204_catalog_foundation.sql`, CLAUDE.md | +| 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). @@ -16,24 +16,24 @@ Ein Nutzer, der ein Spiel im Katalog nicht findet, meldet es mit wenigen Angaben und kann es **sofort** als vorläufigen privaten Eintrag nutzen (Entscheidung O5). Der Admin (Chris) prüft jede Meldung manuell im Studio (Klärungen 27, 44; Werkzeug aus #2 PR 2c). Das Ergebnis sieht der Nutzer als Hinweis in der App, nie als E-Mail (Klärungen 26, 45). -Diese Spec baut auf der Meldungstabelle `game_reports` und der Funktion `admin_resolve_report` aus Spec #2 § 3.4 (PR 2c) auf. Sie ergänzt nur, was der **Nutzer** braucht: Anlegen mit Tageslimit, Ähnlichkeitsprüfung, vorläufigen Eintrag, Ergebnishinweise und das Wechseln auf den Zieleintrag. +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 in dieser Spec: Bearbeiten weiterer Felder des vorläufigen Eintrags (Spielerzahl, Dauer usw.), Zurückziehen einer Meldung, Auswahl vorläufiger Spiele in Partien (gehört zu PAR-01 #15 Krit. 3). +**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, eindeutiger Index auf `game_reports.provisional_game_id`). Er bekommt nur `title` und `year` aus der Meldung. | Der Hinweis zum Ergebnis braucht einen festen Ort, und Krit. 3 verlangt die sofortige Nutzung. Das entspricht dem Backlog („eine Meldung je vorläufigem Eintrag“, N12) und dem Import (SAM-08 Krit. 4). Mit nur Titel und Jahr gibt es außer der Edition nichts, was beim Ersetzen verloren gehen kann (Krit. 4, N15). | -| E2 | **Anlegen über eine RPC `report_missing_game` (security invoker)**, die in einer Transaktion Ähnlichkeit prüft, den vorläufigen Eintrag, die Meldung und optional den Listeneintrag anlegt. | Getrennte Client-Aufrufe ließen verwaiste vorläufige Spiele zurück, wenn das Tageslimit die Meldung ablehnt. `security invoker` heißt: RLS, Spaltenrechte und Trigger gelten weiter, die RPC ist nur Bündelung, keine Rechte-Erweiterung. | -| E3 | **Schutz liegt in der Datenbank, nicht in der RPC:** Spaltenrechte auf `game_reports` (kein `id`, `created_at`, `origin`, `status`, `target_game_id`, `resolved_at`, `bgg_id`), Insert-Policy und ein Limit-Trigger mit Sperre je Nutzer. | Ein direkter REST-Insert umgeht nur den UX-Schritt der Ähnlichkeitsprüfung, nicht Limit oder Rechte. Weil `created_at` nicht schreibbar ist, lässt sich das Tageslimit nicht durch ein falsches Datum umgehen. | -| E4 | **Ähnlichkeitsprüfung serverseitig** über `find_similar_catalog_games` mit `pg_trgm` und einer unveränderlichen (`immutable`) Normalisierungsfunktion `normalize_game_title`. Nur sichtbare Katalogspiele (`catalog_games`). | Klärung 15 ist eine Datenbankregel (Trigramm). Dieselbe Regel prüft die RPC beim Absenden noch einmal; ohne ausdrückliche Bestätigung (`p_confirmed_distinct`) wird nicht gespeichert (Krit. 2). | -| E5 | **„Abgelehnt mit Verweis“: Der Nutzer entscheidet selbst**, ob er auf den Zieleintrag wechselt (Spec #2 Spec-F3, entschieden). Dafür gibt es die RPC `adopt_report_target` (security definer) mit strenger Prüfung von Eigentümer und Status; sie nutzt `repoint_game_references` aus 2b. | Krit. 5 verlangt, dass der vorläufige Eintrag „auf den richtigen Eintrag verweist“. Der Verweis steht in `target_game_id`; das Umhängen der eigenen Daten bleibt eine Entscheidung des Nutzers. Das ist der **erste Weg, auf dem ein Nutzer `repoint_game_references` indirekt auslöst**; die Prüfungen sind deshalb ausführlich (4.4). | -| E6 | **Ergebnishinweise werden aus vorhandenen Daten abgeleitet** (`status`, `target_game_id`, `merged_into_id` des vorläufigen Eintrags) und lassen sich wegklicken (`game_reports.result_seen_at`, Spec-F5). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft. | Kein Benachrichtigungssystem in R1 (QUE-01 erst R2). Eine Spalte reicht. | -| E7 | **Edition (N15) ohne Änderung an 2c:** Die App zeigt „Edition neu wählen“, wenn der vorläufige Eintrag mindestens eine eigene Variante hatte und der Eintrag des Nutzers am Zielspiel keine Edition mehr hat (Spec-F6). | Die privaten Varianten bleiben am ausgeblendeten vorläufigen Eintrag erhalten und sind für den Eigentümer weiter lesbar (Spec #2 § 3.3). Alternative mit einer Spalte `edition_cleared` steht in Spec-F6. | -| E8 | **Verlag bleibt Freitext** in `game_reports.publisher_name`; der vorläufige Eintrag bekommt keinen Verlag. | `publishers` ist nur für den Admin schreibbar (Spec #2 3.2). Der Admin legt den Verlag beim Freigeben selbst an (Runbook). | -| E9 | **Keine neuen Pakete, keine Edge Function.** `pg_trgm` ist Teil des Supabase-Postgres-Images und self-hosted verfügbar (ADR-0002). | CLAUDE.md, ADR-0002. | -| E10 | **Zwei PRs:** 13a Backend (`Refs #13`), 13b Flutter (`Closes #13`). 13b kann gegen ein gemocktes Repository parallel beginnen, sobald der RPC-Vertrag (4.2–4.5) freigegeben ist. | Der Vertrag steht vollständig in dieser Spec. | +| 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 @@ -48,58 +48,51 @@ Diese Spec baut auf der Meldungstabelle `game_reports` und der Funktion `admin_r 7. Gegeben ich habe heute schon 10 Meldungen abgeschickt, wenn ich eine weitere melden will, dann wird sie nicht gespeichert und ich sehe einen Hinweis auf das Tageslimit (Klärung 27). Meldungen, die der BGG-Import automatisch anlegt (SAM-08 Kriterium 4), zählen nicht auf dieses Limit (N12). 8. Gegeben ich habe eine Meldung abgeschickt, dann bleibt sie im Status „offen“, bis der Admin sie manuell bearbeitet; eine automatische Freigabe gibt es nicht (Klärungen 6, 27, 44). -**Abweichung bei Krit. 4 (Klärung N15, entschieden von Chris am 2026-11-03):** Beim Ersetzen bleibt die **Edition** nur erhalten, wenn der Katalogeintrag eine gleichnamige Variante hat; sonst wird sie an Sammlungseintrag und Partien geleert, und die App fordert auf, sie neu zu wählen (E7). Alle anderen eigenen Angaben (Liste, Notiz, Kaufdatum, Angaben aus SAM-02/SAM-04, Partien) bleiben erhalten; das leistet `repoint_game_references` aus 2b. +**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):** Die Bedingung „Erscheinungsjahr ± 1“ gehört nur zur Trigramm-Regel. Gleicher normalisierter Titel gilt unabhängig vom Jahr als „sehr ähnlich“. Fehlt das Jahr auf einer Seite, siehe Spec-F2. +**Lesart von Krit. 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`, auf vorläufige Spiele und auf die neuen RPCs. +- **Sicherheit der Daten Krit. 1 und 3:** Meldungen und vorläufige Einträge sieht nur der Meldende (und der Admin im Studio). pgTAP prüft fremden Zugriff auf `game_reports`, vorläufige Spiele und die neuen RPCs. - **Sicherheit der Daten Krit. 2:** `anon` kann nichts melden und keine Meldungen lesen. -- **Datenschutz (QUE-04):** Meldungen und vorläufige Einträge werden mit dem Konto hart gelöscht (`on delete cascade`, Klärung 10) und gehören in den Export (KTO-05 #23, Klärung 20). Freigegebene Katalogeinträge übernehmen nur Fakten aus den Meldungsfeldern (Runbook aus 2c). Der Link wird in der App nicht geöffnet und nicht vorgeladen (keine Verbindung zu Fremdseiten). -- **Sprachen (QUE-05) Krit. 2, 4:** alle Texte, Fehler- und Statushinweise aus ARB (de/en). -- **Barrierefreiheit (QUE-06) Krit. 1–4:** Formular mit Feldbeschriftungen, Statushinweise mit Text (nicht nur Farbe), Touch-Ziele ≥ 48 dp, `textScaler` 2.0, Tastaturbedienung im Web inkl. Bestätigungsdialog. -- **Zustände, Fehler und Netz Krit. 1–3:** Liste „Meine Meldungen“ mit Lade-, Leer-, Fehler- und Datenzustand; offline Hinweis statt technischem Fehler; bei Limit- oder Netzfehler bleiben die Eingaben im Formular erhalten (Issue: „besonders relevant“). +- **Datenschutz (QUE-04):** Meldungen und vorläufige Einträge werden mit dem Konto hart gelöscht (Klärung 10) und gehören in den Export (KTO-05 #23, Klärung 20). Freigegebene Katalogeinträge übernehmen nur Fakten aus den Meldungsfeldern (Runbook aus 2c). Der Link wird in der App nicht geöffnet und nicht vorgeladen. +- **Sprachen (QUE-05) Krit. 2, 4:** alle Texte aus ARB (de/en). +- **Barrierefreiheit (QUE-06) Krit. 1–4:** Feldbeschriftungen, Status mit Text (nicht nur Farbe), Touch-Ziele ≥ 48 dp, `textScaler` 2.0, Tastaturbedienung im Web inkl. Bestätigungsschritt. +- **Zustände, Fehler und Netz Krit. 1–3:** „Meine Meldungen“ mit Lade-, Leer-, Fehler- und Datenzustand; offline Hinweis statt Fehler; bei Limit- oder Netzfehler bleiben die Eingaben erhalten. - **Plattformen Krit. 1, 2:** identisch auf iOS, Android und Web; breites Layout im Web. ## 3. Datenmodell -### 3.1 Ausgangslage (nach #2 PR 2c) +### 3.1 Ausgangslage (nach #2 PR 2c und #3) -Spec #2 § 3.4 legt an (Spaltennamen werden hier so vorausgesetzt; weicht 2c davon ab, passt B1 die Namen an): +Aus Spec #2 § 3.4 (Spaltennamen werden so vorausgesetzt; weicht 2c ab, passt B1 die Namen an): -- `game_reports` mit `id`, `owner_id` (FK → `profiles`, `on delete cascade`), `origin` (`user`/`import`, Standard `user`), `title` (1–200), `publisher_name` (≤ 200), `year` (1800–2100), `link` (≤ 500, `^https?://`), `bgg_id`, `provisional_game_id` (FK → `games`, `on delete set null`), `status` (`open`/`approved`/`rejected`/`merged`, Standard `open`), `target_game_id` (FK → `games`, `on delete restrict`), `resolved_at`, `created_at`, `updated_at`; Checks zu Status, Ziel und `resolved_at`; Index `(owner_id, created_at)`; RLS: Lesen nur eigene, **kein** Insert/Update/Delete für `authenticated`. -- `admin_resolve_report(p_report, p_status, p_target)`: bei `approved`/`merged` hängt sie den vorläufigen Eintrag per `repoint_game_references` auf das Ziel um und setzt am vorläufigen Eintrag `merged_into_id` und `hidden_at`; bei `rejected` speichert sie nur den Verweis (oder nichts). +- `game_reports` mit `id`, `owner_id` (FK → `profiles`, `on delete cascade`), `origin` (`user`/`import`, Standard `user`), `title` (1–200), `publisher_name` (≤ 200), `year` (1800–2100), `link` (≤ 500, `^https?://`), `bgg_id`, `provisional_game_id` (FK → `games`, `on delete set null`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`, `on delete restrict`), `resolved_at`, `created_at`, `updated_at`; Checks zu Status, Ziel und `resolved_at`. +- **Indizes aus 2c:** `(owner_id, created_at)` (Tageslimit), `game_reports_target_game_id_idx` (Hinweis am Zieleintrag), eindeutiger Teilindex auf `provisional_game_id` (E1). **13a legt keine eigenen Indizes an.** +- RLS: Lesen nur eigene, kein Insert/Update/Delete für `authenticated`. +- `admin_resolve_report(p_report, p_status, p_target)`: bei `approved`/`merged` Umhängen per `repoint_game_references`, am vorläufigen Eintrag `merged_into_id` und `hidden_at`; bei `rejected` nur der Verweis (oder nichts). - Trigger `games_visibility_immutable`: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14). +Aus Spec #3: `search_normalize(text)` und die Tabelle `game_alt_titles` (alternative Titel je Spiel mit Sprache). Der Fremdschlüssel `game_alt_titles.game_id` und seine Einordnung im Fremdschlüssel-Wächter gehören zu #3. + ### 3.2 Änderungen in 13a (Migration `supabase/migrations/_game_reports_user.sql`) -Nur additiv; der Zeitstempel liegt nach der Migration aus 2c. Rechte-Regel aus Spec #2 § 3.2 gilt: neue Funktionen zuerst `revoke execute … from public, anon, authenticated, service_role`, danach nur die Grants aus 4.1. - -**Erweiterungen** +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; ``` -Falls #3 die Erweiterung schon anlegt, ist die Zeile ein No-op. Kein `unaccent` (Spec-F3). - **Neue Spalte in `game_reports`** | Spalte | Typ | Hinweis | |---|---|---| | `result_seen_at` | timestamptz | nullable; der Nutzer hat den Ergebnishinweis weggeklickt (E6, Spec-F5). Check `result_seen_at is null or status <> 'open'`. | -**Neue Indizes** - -| Index | Zweck | -|---|---| -| `unique (provisional_game_id) where provisional_game_id is not null` | 1:1 Meldung ↔ vorläufiger Eintrag (E1). Falls 2c ihn schon anlegt: entfällt. | -| `(target_game_id) where target_game_id is not null` | Hinweis am Eintrag: „gibt es eine Meldung von mir mit diesem Ziel?“ (5.4) | - -**Kein Trigramm-Index:** Ein GIN-Index mit `gin_trgm_ops` würde nur über den Operator `%` genutzt, nicht über `similarity(...) >= 0.8` (3.3). Bei ~200–2000 Katalogspielen ist ein sequenzieller Durchlauf schnell genug (einfach vor clever). Braucht KAT-01 einen Trigramm-Index für die Suche, legt #3 ihn an. +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) @@ -109,91 +102,88 @@ grant insert (owner_id, title, publisher_name, year, link, provisional_game_id) grant update (result_seen_at) on public.game_reports to authenticated; ``` -**Bewusste Änderung gegenüber Spec #2 § 3.4:** Dort haben Nutzer kein Update-Recht auf `game_reports`, und `08_game_reports.test.sql` prüft, dass nur `postgres` Meldungen ändert. 13a erlaubt Nutzern genau eine Spalte (`result_seen_at`) an **eigenen**, bereits bearbeiteten Meldungen. Das bleibt mit KAT-05 Krit. 9 vereinbar (verboten ist das Ändern **fremder** Meldungen); B2 passt den Test aus `08` an. +**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` (Fehler 42501). Damit setzt die Datenbank `origin = 'user'`, `status = 'open'` (Krit. 8) und `created_at = now()` (Tageslimit) selbst. `delete` bekommt der Nutzer nicht. +`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`. In #13 schreibt die App ohnehin nur über die RPCs (4.2, 4.4) und das eine Update von `result_seen_at` (4.5) mit einer ausdrücklichen Spaltenliste. +**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()` und, falls `provisional_game_id` gesetzt ist, zeigt er auf ein **eigenes privates**, nicht ausgeblendetes und nicht zusammengeführtes Spiel (`exists (select 1 from games g where g.id = provisional_game_id and g.visibility = 'private' and g.owner_id = auth.uid() and g.hidden_at is null and g.merged_into_id is null)`). `origin`, `status` usw. sind über die Spaltenrechte festgelegt; die Policy prüft zusätzlich `origin = 'user' and status = 'open' and target_game_id is null` (zweite Sicherung). +- **Anlegen (`game_reports_insert_own`, `for insert to authenticated`):** `owner_id = auth.uid()`, `origin = 'user'`, `status = 'open'`, `target_game_id is null` und **`provisional_game_id is not null`**, und dieser zeigt auf ein eigenes privates, nicht ausgeblendetes und nicht zusammengeführtes Spiel (`exists (select 1 from games g where g.id = provisional_game_id and g.visibility = 'private' and g.owner_id = auth.uid() and g.hidden_at is null and g.merged_into_id is null)`). Ein zweiter Insert mit demselben vorläufigen Eintrag scheitert am Teilindex aus 2c (23505); `report_missing_game` bildet das auf `report_provisional_in_use` ab, die App auf „unbekannter Fehler“. - **Ändern (`game_reports_update_seen`, `for update to authenticated`):** `using (owner_id = auth.uid() and status <> 'open')`, `with check` gleich. Über die Spaltenrechte ist nur `result_seen_at` änderbar. -- **Lesen:** unverändert aus 2c (nur eigene). -- **Löschen:** kein Recht (Meldungen verschwinden nur mit dem Konto). -- **Vorläufige Spiele:** unverändert; `games_select` zeigt private Spiele nur dem Ersteller, auch wenn sie ausgeblendet oder zusammengeführt sind. So bleiben die privaten Varianten für den Hinweis zur Edition lesbar (E7). +- **Lesen:** unverändert aus 2c (nur eigene). **Löschen:** kein Recht. +- **Vorläufige Spiele:** unverändert; der Ersteller sieht sie auch ausgeblendet oder zusammengeführt (wichtig für die Ableitung in E7). Er kann sie über `games_delete_private` löschen, solange nichts darauf verweist; dann wird `provisional_game_id` per `on delete set null` leer (E6, 5.4). **Trigger `game_reports_daily_limit`** (Krit. 7) -- `before insert on public.game_reports for each row`, nur wenn `new.origin = 'user'`. -- Sperre gegen gleichzeitige Inserts: `perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0))`. Ohne Sperre könnten zwei parallele Meldungen beide die Zählung „9“ sehen. -- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `created_at` am **selben Kalendertag in Europe/Berlin** (Spec-F4): `public.report_day(created_at) = public.report_day(now())`. Bei ≥ 10 Fehler `report_daily_limit` (P0001). -- Hilfsfunktion `public.report_day(p_at timestamptz) returns date` = `(p_at at time zone 'Europe/Berlin')::date`, `language sql`, `immutable`, `search_path = ''`, `execute` nur für den Eigentümer. Sie ist eigens herausgezogen, damit pgTAP die Tagesgrenze mit festen Zeitstempeln prüfen kann (`now()` lässt sich im Test nicht steuern). +- `create trigger … before insert on public.game_reports for each row when (new.origin = 'user') execute function public.game_reports_daily_limit()`. +- Sperre gegen gleichzeitige Inserts: `perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0))`. **Setzt die Isolationsstufe READ COMMITTED voraus** (Standard bei PostgREST und Supabase): Die Zählung nach der Sperre sieht dann die inzwischen bestätigten Meldungen der anderen Transaktion. Unter REPEATABLE READ oder SERIALIZABLE wäre die Sperre wirkungslos, weil der Snapshot vor der Sperre feststeht. +- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `public.report_day(created_at) = public.report_day(now())`. Bei ≥ 10 Fehler `report_daily_limit` (P0001). Die Zahl 10 ist eine Konstante in der Funktion; Änderung = neue Migration. +- Hilfsfunktion `public.report_day(p_at timestamptz) returns date` = `(p_at at time zone 'Europe/Berlin')::date`, `language sql`, `immutable`, `search_path = ''`. **`grant execute … to authenticated`** (rechnet nur): Der Trigger läuft als `security invoker` mit der Rolle des Einfügenden; ohne dieses Recht scheitert jede Meldung mit 42501. Herausgezogen, damit pgTAP die Tagesgrenze mit festen Zeitstempeln prüfen kann. +- Trigger-Funktion `security invoker`, `search_path = ''`: Der Nutzer sieht per RLS alle eigenen Meldungen; Studio (`postgres`) umgeht RLS ohnehin. - Import-Meldungen (`origin = 'import'`, SAM-08) werden weder gezählt noch geprüft (N12). -- Funktion `security invoker` mit `search_path = ''`. Der Nutzer sieht per RLS alle eigenen Meldungen, mehr braucht die Zählung nicht; Studio (`postgres`) umgeht RLS ohnehin. `execute` wie oben entzogen (Trigger-Funktionen brauchen kein `execute`-Recht des Aufrufers). -- Die Zahl 10 steht als Konstante in der Funktion (kein Konfigurationswert); eine Änderung ist eine neue Migration. -**Keine neue Tabelle, kein neuer Fremdschlüssel auf `games`.** Der Fremdschlüssel-Wächter aus #2 (7) und `00_schema.test.sql` (21 Tabellen) bleiben unverändert. +**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) -**Funktion `public.normalize_game_title(p_title text) returns text`**, `language sql`, `immutable`, `strict`, `parallel safe`, `search_path = ''`. Schritte in dieser Reihenfolge: +**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. -1. `lower()`; `ß` → `ss`, `æ` → `ae`, `œ` → `oe`. -2. **Akzente falten** (Spec-F3) mit einer festen, handgeschriebenen Zuordnung über `translate()` (z. B. `äàáâãå` → `a`, `ëèéê` → `e`, `ïìíî` → `i`, `öòóôõø` → `o`, `üùúû` → `u`, `ñ` → `n`, `ç` → `c`, `ÿý` → `y`). `translate()` ist `immutable`; `unaccent` wird nicht verwendet (nur `stable`). -3. Alles, was danach kein Zeichen aus `a–z` oder `0–9` ist (Satz- und Sonderzeichen, Bindestriche, Doppelpunkte, Apostrophe, Emojis, übrige Schriftzeichen), wird zu einem Leerzeichen: `regexp_replace(…, '[^a-z0-9]+', ' ', 'g')`. Die explizite Zeichenklasse hängt nicht von der Datenbank-Locale ab. -4. **Führende Artikel** entfernen, einmal, nur am Anfang (Spec-F3): Deutsch `der`, `die`, `das`, `den`, `dem`, `des`, `ein`, `eine`, `einen`, `einem`, `einer`, `eines`; Englisch `the`, `a`, `an`. -5. Mehrfache Leerzeichen zu einem, `trim`. -6. Ist das Ergebnis leer, wird der Wert aus Schritt 3 (ohne Artikelentfernung) verwendet; ist auch der leer (Titel nur aus Sonderzeichen oder nicht-lateinischen Schriftzeichen), `lower(btrim(p_title))`. +**In 13a neu** (beide `language sql`, `immutable`, `parallel safe`, `search_path = ''`, `grant execute … to anon, authenticated`): -Beispiele (werden pgTAP-Fälle): „Die Siedler von Catan“ → `siedler von catan`; „CATAN!“ → `catan`; „7 Wonders: Duel“ → `7 wonders duel`; „The Crew“ → `crew`; „Café International“ → `cafe international`; „Mölkky“ und „Molkky“ → `molkky`; „Das“ → `das`. +- `public.strip_leading_article(t text) returns text`: entfernt **einmal ein führendes** Artikelwort (Deutsch `der`, `die`, `das`, `den`, `dem`, `des`, `ein`, `eine`, `einen`, `einem`, `einer`, `eines`; Englisch `the`, `a`, `an`) samt folgendem Leerzeichen; erwartet bereits normalisierten Text (Kleinbuchstaben, ein Leerzeichen als Trenner). +- `public.normalize_game_title(t text) returns text` = + `coalesce(nullif(public.strip_leading_article(public.search_normalize(t)), ''), nullif(public.search_normalize(t), ''), lower(btrim(t)))`. + Der letzte Rückfall greift bei Titeln, die nach `search_normalize` leer sind (z. B. nur nicht-lateinische Schriftzeichen oder nur Sonderzeichen). -**Regel „sehr ähnlich“** zwischen Meldung (Titel `t`, Jahr `y`) und Katalogspiel `g`: +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))`. -- **A (gleicher Titel):** `normalize_game_title(g.title) = normalize_game_title(t)`, unabhängig vom Jahr. -- **B (Trigramm):** `extensions.similarity(normalize_game_title(g.title), normalize_game_title(t)) >= 0.8` **und** `abs(g.year - y) <= 1`. Fehlt `y` oder `g.year`, gilt B ohne Jahresbedingung (Spec-F2). -- Grundlage sind nur sichtbare Katalogspiele (`catalog_games`), keine ausgeblendeten, zusammengeführten oder privaten Spiele (auch nicht eigene vorläufige, Spec-F7). -- **Wichtig für die Umsetzung:** In Funktionen mit `search_path = ''` muss `extensions.similarity(...) >= 0.8` ausdrücklich verwendet werden, **nicht** der Operator `%`; dieser nutzt die Einstellung `pg_trgm.similarity_threshold` (Standard 0,3). -- **Alternative Titel (Klärung 3, #3):** Hat #3 bei der Umsetzung von 13a eine Tabelle für alternative Titel, werden diese in A und B mitgeprüft (beste Übereinstimmung je Spiel). Sonst nur der Haupttitel; #3 ergänzt die Prüfung dann in seiner Migration (Hinweis 9.4). +**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**): -**Zusammenspiel mit #3:** Die Suche in KAT-01 (Krit. 1: „unabhängig von Groß- und Kleinschreibung und Sonderzeichen“) soll dieselbe Funktion nutzen. Wird #3 vor 13a umgesetzt und legt eine Normalisierungsfunktion an, die die Regeln oben erfüllt, verwendet 13a sie statt einer zweiten; sonst legt 13a `normalize_game_title` an und #3 nutzt sie später mit. Eine zweite, abweichende Normalisierung ist zu vermeiden (zweiter Anwendungsfall vorhanden, deshalb eine gemeinsame Funktion). +- **A (gleicher Titel):** `normalize_game_title(c) = normalize_game_title(t)` für mindestens ein `c`, unabhängig vom Jahr. +- **B (Trigramm):** `extensions.similarity(normalize_game_title(c), normalize_game_title(t)) >= 0.8` für mindestens ein `c` **und** `abs(g.year - y) <= 1`. Fehlt `y` oder `g.year`, gilt B ohne Jahresbedingung (Spec-F2). +- Je Spiel zählt die beste Übereinstimmung über alle Kandidatentitel. +- Nur sichtbare Katalogspiele (`catalog_games`): keine ausgeblendeten, zusammengeführten oder privaten (auch nicht eigene vorläufige, Spec-F7). +- In Funktionen mit `search_path = ''` ausdrücklich `extensions.similarity(...) >= 0.8` verwenden, **nicht** den Operator `%` (nutzt `pg_trgm.similarity_threshold`, Standard 0,3). Deshalb kein Trigramm-Index in 13a; bei ~200–2000 Katalogspielen reicht ein sequenzieller Durchlauf. ## 4. Backend -### 4.1 Überblick +### 4.1 Überblick und Rechte -| Objekt | Art | `execute`/Recht | Zweck | +| Objekt | Art | `execute` | Zweck | |---|---|---|---| -| `normalize_game_title(text)` | Funktion, sql, immutable | `anon`, `authenticated` (harmlos, rein rechnend; nötig für Abfragen als Aufrufer, später auch für die Suche in #3) | Normalisierung (3.3) | -| `find_similar_catalog_games(p_title text, p_year smallint)` | RPC, security invoker | `authenticated` | Ähnliche Katalogspiele anzeigen (Krit. 2) | +| `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 bei „abgelehnt mit Verweis“ (Krit. 5) | +| `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 | -`anon` darf keine der RPCs ausführen (42501). `service_role` bekommt nichts (keine Edge Function; der Import ergänzt später eigene Rechte, Spec #2 9.5). +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 werden wie in den bestehenden Migrationen als `raise exception '' using errcode = 'P0001'` gemeldet; die App bildet den Code auf einen Domänenfehler ab (5.3). +Fehler: `raise exception '' 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 smallint default null, - p_link text default null, - p_list text default null, -- 'owned' | 'wishlist' | 'shopping' | null - p_confirmed_distinct boolean default false + 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 = ''`, `volatile`. +`language plpgsql`, `security invoker`, `search_path = ''`. `p_year` ist bewusst `integer` und wird selbst geprüft (ein `smallint`-Parameter würde bei großen Werten schon beim Aufruf mit 22003 scheitern, ohne verständlichen Code). Ablauf (eine Transaktion; jeder Fehler rollt alles zurück): 1. `auth.uid()` ist null → `not_authenticated`. -2. Eingaben bereinigen: `btrim` auf Titel, Verlag, Link; leere Zeichenketten werden `null`. +2. `btrim` auf Titel, Verlag, Link; leere Zeichenketten werden `null`. 3. Prüfungen in dieser Reihenfolge: | Prüfung | Fehler | @@ -204,78 +194,73 @@ Ablauf (eine Transaktion; jeder Fehler rollt alles zurück): | 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 Prüfungen spiegeln die Checks aus 2c, damit die App gezielte Feldfehler zeigen kann; die Checks der Tabelle bleiben die eigentliche Grenze (23514 als Rückfall → „unbekannter Fehler“). -4. `insert into public.games (owner_id, title, year, visibility) values (auth.uid(), title, year, 'private') returning id` (nur erlaubte Spalten, keine `id`). -5. `insert into public.game_reports (owner_id, title, publisher_name, year, link, provisional_game_id) … returning id`. Hier greift der Limit-Trigger (`report_daily_limit`); weil Schritt 4 in derselben Transaktion liegt, bleibt kein verwaistes Spiel zurück. -6. Wenn `p_list` gesetzt: `insert into public.collection_items (owner_id, game_id, list) values (auth.uid(), provisional_id, p_list) returning id`. Keine Edition (der vorläufige Eintrag hat keine Varianten; KAT-03 Krit. 7 für Listen). + 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` (4.3) zeigen, dann `report_missing_game` mit `p_confirmed_distinct = true`, wenn der Nutzer bestätigt hat. Ruft ein Client direkt mit `true` auf, ohne die Liste gezeigt zu haben, umgeht er nur den UX-Schritt; Rechte und Limit sind nicht betroffen (E3). +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 smallint default null) +find_similar_catalog_games(p_title text, p_year integer default null) returns table ( game_id uuid, - title text, + title text, -- Haupttitel + matched_title text, -- Titel, der getroffen hat (Haupt- oder alternativer Titel) year smallint, - publishers text[], -- Namen aus game_publishers/publishers, sortiert - match_kind text, -- 'same_title' | 'similar_title' - similarity real -- 1.0 bei 'same_title' + 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 `public.catalog_games` (plus `game_publishers`/`publishers`). Regel nach 3.3. Sortierung: `same_title` zuerst, dann `similarity` absteigend, dann `title`. Höchstens **5** Treffer. Leerer oder nur aus Leerzeichen bestehender Titel → leere Menge (kein Fehler). - -Wird für `authenticated` freigegeben, nicht für `anon` (Melden setzt Anmeldung voraus). +`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, z. B. {"collection_items": 1, "plays": 0, "variants_mapped": 0, "variants_cleared": 1, …} +adopt_report_target(p_report uuid) returns jsonb -- Zähler aus repoint_game_references ``` -`language plpgsql`, `security definer`, Eigentümer `postgres`, `search_path = ''`. Nötig, weil Nutzer `merged_into_id`/`hidden_at` nicht schreiben und `repoint_game_references` nicht ausführen dürfen (Spec #2 3.2, 3.3). +`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=""`. -Prüfungen (alle mit `for update` auf Meldung und vorläufigem Eintrag, damit ein gleichzeitiges `admin_resolve_report` nicht dazwischenkommt): +**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, verrät keine fremden IDs) | `report_not_found` | +| 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` | `report_no_provisional` | +| `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 (`merged_into_id is not null`) | `report_already_adopted` | -| Ziel ist kein sichtbares Katalogspiel (`catalog_games`) | `report_target_not_listed` | -| Es gibt Verweise auf den vorläufigen Eintrag von **anderen** Nutzern (`collection_items`, `favorite_games`, `plays`, `score_sheets` mit `owner_id`/`profile_id` ≠ `auth.uid()`) | `report_foreign_references` | +| 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` | -Die letzte Prüfung ist eine Schutzschicht: Über die API können andere Nutzer ein fremdes privates Spiel nicht referenzieren (Policies verlangen ein sichtbares Spiel), aber `security definer` umgeht RLS, und ein Studio-Handgriff wäre denkbar. +Ablauf: `repoint_game_references(provisional, target)` (privat → Katalog, keine Varianten kopiert, gleichnamige abgebildet, sonst `null`; Spec #2 RB1/N15), danach am vorläufigen Eintrag `merged_into_id = target`, `hidden_at = now()`. Die Meldung bleibt `rejected`. **`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`. -Ablauf: `repoint_game_references(provisional, target)` (privat → Katalog, keine Varianten kopiert, gleichnamige abgebildet, sonst `null`; Spec #2 RB1/N15), danach am vorläufigen Eintrag `merged_into_id = target`, `hidden_at = now()`. Die Meldung bleibt `rejected` (das ist das Ergebnis der Prüfung durch den Admin); `result_seen_at` wird auf `now()` gesetzt, weil der Nutzer gehandelt hat. Rückgabe: Zähler aus `repoint_game_references`. - -`favorite_games` sind bei vorläufigen Spielen fachlich ausgeschlossen (Klärung 23), technisch aber derzeit erlaubt (Hinweis an #14, 9.4); `repoint_game_references` behandelt sie ohnehin. +**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 eigener RPC. Die App sendet per PostgREST `PATCH /rest/v1/game_reports?id=eq.` genau die Map `{'result_seen_at': }` (PostgREST setzt Werte, keine SQL-Ausdrücke wie `now()`). Dass die Zeit vom Client kommt, ist unkritisch: Der Wert steuert nur die Anzeige des eigenen Hinweises. Policy und Spaltenrecht (3.2) begrenzen das Update auf eigene, bereits bearbeitete Meldungen und genau diese Spalte; bei einer offenen oder fremden Meldung ändert sich keine Zeile. +Kein RPC. Die App sendet `PATCH /rest/v1/game_reports?id=eq.` mit genau `{'result_seen_at': }` (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 -Kein RPC nötig; PostgREST-Abfragen als angemeldeter Nutzer: - -- **Meine Meldungen:** `game_reports` (eigene, RLS) mit Spalten `id, origin, title, publisher_name, year, link, status, target_game_id, provisional_game_id, result_seen_at, created_at, resolved_at`, sortiert nach `created_at desc`, seitenweise (20). Eingebettet: `provisional:games!provisional_game_id(id, merged_into_id, hidden_at, game_variants(id))` und `target:games!target_game_id(id, title, year)`. Die Fremdschlüsselnamen für das Embedding prüft flutter-dev gegen die 2c-Migration. -- **Hinweis zu einem Spiel** (5.4): `game_reports` mit `or=(provisional_game_id.eq.,target_game_id.eq.)`; der Index aus 3.2 deckt beide Fälle. -- **Edition (E7):** `collection_items` des Nutzers zum Zielspiel (`variant_id`) und `provisional.game_variants` aus der eingebetteten Abfrage. +- **Meine Meldungen:** `game_reports` mit `id, origin, title, publisher_name, year, link, status, target_game_id, provisional_game_id, result_seen_at, created_at, resolved_at` (bei Spec-F6 = Zähler zusätzlich `editions_cleared`), `created_at desc`, je 20. Eingebettet: `provisional:games!provisional_game_id(id, merged_into_id, hidden_at, game_variants(id))`, `target:games!target_game_id(id, title, year)`. Fremdschlüsselnamen prüft flutter-dev gegen die 2c-Migration. +- **Hinweis zu einem Spiel** (5.4): `game_reports` mit `or=(provisional_game_id.eq.,target_game_id.eq.)` (Indizes aus 2c) plus eingebettetem `provisional`. +- **Edition, nur bei Rückfall Ableitung:** `collection_items` des Nutzers zum Zielspiel mit `list = 'owned'` (`variant_id`) und `provisional.game_variants`. ### 4.7 Doku -- `docs/datenmodell-r1.md`: `game_reports.result_seen_at`, Indizes, Spaltenrechte, Policies, Trigger `game_reports_daily_limit`, die Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes. -- `docs/katalog-pflege.md`, Abschnitt „Meldungen bearbeiten“ (aus 2c) ergänzen: Nutzer-Meldungen haben immer einen vorläufigen Eintrag; „abgelehnt mit Verweis“ lässt dem Nutzer die Wahl (`adopt_report_target`), der Admin hängt dabei nichts um; vor `approved` die Edition berücksichtigen (Runbook aus 2c); Meldungen nie von Hand auf einen anderen Status setzen (nur über `admin_resolve_report`), weil die App den Hinweis aus Status und `merged_into_id` ableitet. +- `docs/datenmodell-r1.md`: `result_seen_at`, Spaltenrechte, Policies, Trigger, Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes; Satz zur Zuständigkeit für `search_normalize` (3.3). +- `docs/katalog-pflege.md`, Abschnitt „Meldungen bearbeiten“ (2c) ergänzen: Nutzer-Meldungen haben einen vorläufigen Eintrag (sofern nicht gelöscht); „abgelehnt mit Verweis“ lässt dem Nutzer die Wahl (`adopt_report_target`); Status nie von Hand setzen, nur über `admin_resolve_report`, weil die App den Hinweis aus Status und `merged_into_id` ableitet. - `supabase/README.md`: neue RPCs und Rechte. ## 5. Frontend @@ -291,123 +276,118 @@ features/game_reports/ game_report_status.dart enum GameReportStatus { open, approved, rejected, merged } game_report_origin.dart enum GameReportOrigin { user, import } report_outcome.dart sealed ReportOutcome (5.4) + reine Funktion outcomeOf(...) - game_report_draft.dart freezed: Eingaben + Validierung (Titel Pflicht, Längen, Jahr, Link) - similar_game.dart freezed: SimilarGame (gameId, title, year, publishers, matchKind) - game_report_failure.dart freezed/sealed: dailyLimit, similarExists, validation(field, code), - notAdoptable, alreadyAdopted, targetNotListed, offline, unknown + game_report_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 Mapping aus Zeilen (nur fromJson; kein toJson für Schreibzugriffe) + game_report_dto.dart nur fromJson; kein toJson für Schreibzugriffe game_report_failure_mapper.dart P0001-Codes → GameReportFailure presentation/ - report_form/ Screen, Controller (Notifier), State - similar_games/ Abschnitt/Dialog mit Kandidaten und ausdrücklicher Bestätigung - my_reports/ Liste „Meine Meldungen“, Controller - report_detail/ Detail einer Meldung inkl. Hinweis und Aktionen - widgets/report_outcome_notice.dart wiederverwendbarer Hinweis (auch für #4/#6/#11) - providers/ Repository-, Listen- und Hinweis-Provider (Riverpod, generiert) + 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. Die Validierung der Eingaben liegt in `GameReportDraft` (Unit-Tests), nicht im Widget. +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=` | `ReportFormScreen` | Einstieg aus der leeren Trefferliste von KAT-01 (#3 Krit. 6) mit vorbelegtem Titel; Anmeldung nötig (Redirect auf `/login?from=…` wie in Spec #1) | -| `/reports` | `MyReportsScreen` | Liste der eigenen Meldungen | -| `/reports/:reportId` | `ReportDetailScreen` | Status, Angaben, Hinweis, Aktionen; Deep Link und Web-URL möglich, fremde/unbekannte ID → „nicht gefunden“ (RLS liefert keine Zeile) | +| `/reports/new?title=` | `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`. Einstieg zu `/reports`: aus der Suche (#3) und aus jedem Hinweis; ob zusätzlich in den Einstellungen (KTO-11 #28) oder im Dashboard (QUE-09), entscheidet ui-designer mit Chris (kein Kriterium verlangt es). +Konstanten in `AppRoutes`. Weitere Einstiege zu `/reports` (Einstellungen, Dashboard) stimmt ui-designer mit Chris ab. ### 5.3 Melden (Krit. 1, 2, 3, 7, 8) -**Ablauf im `ReportFormController`** (Riverpod-Notifier, Zustände als freezed-Union): +`ReportFormController` (Riverpod-Notifier, Zustände als freezed-Union): -| Zustand | Bedeutung | UI | -|---|---|---| -| `editing(draft, fieldErrors)` | Eingabe | Felder Titel (Pflicht), Verlag, Erscheinungsjahr, Link (optional), Auswahl „Gleich aufnehmen in“: Sammlung / Wunschliste / Einkaufsliste / nicht aufnehmen (Standard: Sammlung, Spec-F9) | -| `checking` | `find_similar_catalog_games` läuft | Button mit Ladeanzeige, Felder gesperrt | -| `similarFound(draft, candidates)` | Treffer nach 3.3 | Kandidatenliste (Titel, Jahr, Verlage, `GameCover`). Je Kandidat „Das ist mein Spiel“ → Detailseite des Katalogspiels (#4), keine Meldung. Darunter ausdrückliche Bestätigung: Checkbox „Mein Spiel ist keines davon“ + Button „Trotzdem melden“ (erst aktiv mit Haken). | -| `submitting` | `report_missing_game` läuft | Ladeanzeige | -| `submitted(result)` | gespeichert | Bestätigung (Krit. 1): „Danke, deine Meldung ist eingegangen. Wir prüfen sie von Hand; bis dahin kannst du das Spiel als vorläufigen Eintrag nutzen.“ Ggf. „Steht jetzt in deiner Sammlung/Wunschliste/Einkaufsliste“; Aktionen „Zur Meldung“, „Zur Sammlung“ (falls aufgenommen) | -| `failure(draft, failure)` | Fehler | Eingaben bleiben erhalten (Zustände Krit. 3). `dailyLimit` → Hinweis „Du hast heute schon 10 Spiele gemeldet. Morgen geht es wieder.“ (Krit. 7); `similarExists` (Rennen: Katalog hat sich geändert) → zurück zu `similarFound`; Feldfehler am Feld; offline → „Melden braucht eine Verbindung“ | +| 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“ | -Gibt es keine Treffer, geht `checking` direkt in `submitting` über (`p_confirmed_distinct = false`). Bei Treffern wird mit `true` gesendet. +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). -Das Formular prüft vor dem Senden lokal dieselben Regeln wie 4.2 (Titel Pflicht, Längen, Jahr, Link-Format), damit Fehler sofort am Feld stehen. Das Tageslimit prüft nur der Server. - -**Krit. 3 nachträglich:** Wurde „nicht aufnehmen“ gewählt, kann der Nutzer den vorläufigen Eintrag später auf der Detailseite der Meldung zu einer Liste hinzufügen. 13b nutzt dafür die Funktion „zur Sammlung/Liste hinzufügen“ aus SAM-01/SAM-04/SAM-05 (#6/#8/#9), falls vorhanden; sonst ein einfacher Insert in `collection_items` über das Repository (Spalten `owner_id`, `game_id`, `list`) mit Hinweis an #6, ihn später zu ersetzen (Spec-F8). +**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) -`ReportOutcome` wird rein aus den Daten abgeleitet (`outcomeOf(report, provisional, collectionItemOnTarget)`, Unit-Tests je Fall): +`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, Texte über ARB) | Aktionen | Wo sichtbar | +| 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`, vorläufiger Eintrag `merged_into_id = target` | „Dein gemeldetes Spiel ist jetzt im Katalog. Deine Angaben wurden übernommen.“ | „Zum Spiel“, „Verstanden“ | Meldung; **Eintrag am Zielspiel** (Sammlung/Liste, Detailseite) | -| `merged` (Krit. 5) | `status = merged`, vorläufiger Eintrag `merged_into_id = target` | „Dein Spiel gab es schon im Katalog als „{title}“. Dein Eintrag zeigt jetzt dorthin.“ | „Zum Spiel“, „Verstanden“ | wie `approved` | -| `rejectedWithTarget` (Krit. 5) | `status = rejected`, `target_game_id` gesetzt, vorläufiger Eintrag nicht zusammengeführt | „Dein Spiel gibt es bereits als „{title}“.“ | „Zu „{title}“ wechseln“ (→ `adopt_report_target`, mit Bestätigungsdialog: „Deine Angaben werden auf den Katalogeintrag übertragen.“), „Vorläufig behalten“ | Meldung; vorläufiger Eintrag | -| `rejectedAdopted` | `status = rejected`, `target_game_id` gesetzt, vorläufiger Eintrag `merged_into_id = target` | wie `merged` | „Zum Spiel“ | wie `approved` | -| `rejected` (Krit. 6) | `status = rejected`, kein Ziel | Dauerhafte Markierung „Abgelehnt“ + Text „Dieses Spiel wird nicht in den Katalog aufgenommen. Dein Eintrag bleibt privat für dich.“ | „Verstanden“ (blendet nur den Text aus, die Markierung bleibt) | Meldung; vorläufiger Eintrag | +| `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` und `rejectedAdopted` zusätzlich „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“, wenn der vorläufige Eintrag mindestens eine eigene Variante hat **und** der Sammlungseintrag des Nutzers am Zielspiel `variant_id = null` hat. Aktion „Edition wählen“ führt zur Bearbeitung des Sammlungseintrags (KAT-03 #12 / SAM-02 #7), sobald vorhanden. Bei Partien wird nicht einzeln geprüft (Spec-F6). +**Zusatz Edition (E7, N15)** bei `approved`, `merged`, `rejectedAdopted`: „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“ -**„Verstanden“** setzt `result_seen_at` (4.5). Danach erscheint der Hinweis nicht mehr am Eintrag; in „Meine Meldungen“ bleibt der Status immer sichtbar. +- Bei Spec-F6 = Zähler (Empfehlung): wenn `editions_cleared > 0`. Der Text nennt Sammlung **und** Partien („in deiner Sammlung oder deinen Partien“). +- Rückfall Ableitung: wenn der vorläufige Eintrag mindestens eine eigene Variante hat **und** der Sammlungseintrag am Ziel mit `list = 'owned'` `variant_id = null` hat. Wunsch- und Einkaufsliste haben nach Klärung 28 nie eine Edition und werden nicht geprüft; Partien werden nicht einzeln geprüft (Grenze der Ableitung). +- Aktion „Edition wählen“ → Bearbeitung des Sammlungseintrags (KAT-03 #12 / SAM-02 #7), sobald vorhanden. -**Hinweis „am Eintrag“:** Provider `reportNoticeForGameProvider(gameId)` liefert den passenden `ReportOutcome` (oder keinen) für ein Spiel, an dem der Nutzer einen Eintrag hat: Meldungen mit `provisional_game_id = gameId` (vorläufiger Eintrag) oder `target_game_id = gameId` und `result_seen_at is null` (ersetzter Eintrag). Das Widget `ReportOutcomeNotice` (kompakt für Listenzeilen, ausführlich für Detailseiten) wird eingebaut +**„Verstanden“** setzt `result_seen_at` (4.5); danach kein Hinweis mehr am Eintrag, in „Meine Meldungen“ bleibt der Status sichtbar. -- in 13b: in „Meine Meldungen“ und die Meldungsdetailseite, -- in die Detailseite eines Spiels (#4) und die Sammlungs-/Listenansicht (#6/#11), soweit diese bei der Umsetzung von 13b gemergt sind; sonst übernehmen #4/#6/#11 den Einbau (Spec-F8, Hinweis 9.4). +**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. -Vorläufige Einträge werden in Listen mit einem Badge „vorläufig“ (Text, nicht nur Farbe) und dem `GameCover`-Platzhalter gezeigt. - -**Aktualität:** Kein Realtime in R1. Die Hinweise werden beim Öffnen der Liste/Detailseite und per Pull-to-Refresh neu geladen; das reicht, weil der Admin Meldungen selten und manuell bearbeitet. +**Aktualität:** kein Realtime; Neuladen beim Öffnen und per Pull-to-Refresh. ### 5.5 Meine Meldungen -`MyReportsController` (AsyncNotifier mit Paging, 20 je Seite): Zustände Laden, Leer („Du hast noch keine Spiele gemeldet“ + Weg zur Suche), Fehler mit „Erneut versuchen“, Daten. Je Zeile: Titel, Jahr, Datum, Status-Badge (Text), ggf. kompakter Hinweis. Import-Meldungen (`origin = import`, SAM-08) erscheinen mit Kennzeichen „aus BGG-Import“ (Text liefert #25; 13b zeigt `origin` nur an, wenn vorhanden). +`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) -Neue ARB-Schlüssel mit Präfix `gameReport…`: Formularfelder und Fehler (`gameReportTitleRequired`, `gameReportTitleTooLong`, `gameReportPublisherTooLong`, `gameReportYearInvalid`, `gameReportLinkInvalid`), Ähnlichkeit (`gameReportSimilarHeading`, `gameReportSimilarIsMine`, `gameReportSimilarConfirmDistinct`, `gameReportSubmitAnyway`), Listenwahl, Bestätigung, Tageslimit (`gameReportDailyLimit`), Offline, Status-Badges (`gameReportStatusOpen` … `gameReportStatusRejected`, `gameReportProvisionalBadge`), Hinweise aus 5.4 inkl. Edition, Aktionen, Leer-/Fehlerzustand, Semantics-Labels. Platzhalter `{title}`, Datum über `intl`. +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 mit `TextInputType.url`, Jahr mit Ziffern-Tastatur; der Link wird in der App nur als Text angezeigt, nicht geöffnet (Datenschutz, 2). -- **Web:** Routen sind teilbare URLs; der Bestätigungsschritt bei ähnlichen Spielen ist per Tastatur bedienbar (Checkbox, Fokus-Reihenfolge); breites Layout: Formular und Kandidaten nebeneinander ab Breite ≥ 840 dp (ui-designer). -- **Offline:** Melden braucht eine Verbindung (wie SAM-01 Krit. 3); Hinweis statt Fehler, Eingaben bleiben im Formular. Keine Offline-Warteschlange für Meldungen. -- **Push, Kamera, Dateien:** nicht betroffen (kein Bild-Upload, keine Push in R1). +- **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/09_game_reports_user.test.sql`** (Nummer nach `08_game_reports.test.sql` aus 2c). Fixtures mit festen Test-UUIDs, zwei Nutzer A und B, unabhängig vom Startbestand. +**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-Format; Fehlercode-Mapping | Formular: Feldfehler am Feld, Bestätigung nach Erfolg, Listenwahl | Playwright: leere Suche → „Spiel melden“ → Titel vorbelegt → absenden → Bestätigung | `report_missing_game` legt Meldung (`status = open`, `origin = user`) und vorläufiges Spiel (`private`, `owner_id = A`, nur Titel/Jahr) an; Trimmen; alle Validierungsfehler (`report_title_required` … `report_list_invalid`); `anon` → 42501 | B1, B2, F2, T1, T2 | -| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` beim Senden → zurück zu Kandidaten | Kandidatenliste, Bestätigungs-Checkbox aktiviert Button, Tastatur im Web, Semantics | Playwright: Meldung zu „Catan“ zeigt vorhandenes „CATAN“ | `normalize_game_title`: Beispiele aus 3.3 (Artikel de/en, Sonderzeichen, Akzente und Umlaute gefaltet, `ß`, nur Artikel, nur nicht-lateinische Zeichen); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr trifft; Trigramm ≥ 0,8 bei Jahr ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; Jahr fehlt → Regel nach Spec-F2; ausgeblendete, zusammengeführte und private Spiele (auch eigene) werden nicht geliefert; höchstens 5, Sortierung; `report_missing_game` ohne Bestätigung bei Treffer → `report_similar_exists`, mit Bestätigung gespeichert | B1, B2, F2, U1, T1, T2 | -| Krit. 3 | – | Bestätigung zeigt Liste; Badge „vorläufig“ | Playwright: gemeldetes Spiel steht in der Wunschliste (sofern #6/#8 vorhanden) | `p_list` legt `collection_items` zum vorläufigen Spiel an; B sieht weder vorläufiges Spiel noch Listeneintrag noch Meldung von A; `anon` sieht nichts; Rollback: schlägt der Listeneintrag fehl, gibt es weder Spiel noch Meldung | B1, B2, F2, F3 | -| Krit. 4 | `outcomeOf`: `approved` mit/ohne Edition-Hinweis | `ReportOutcomeNotice` für `approved` (de/en), „Verstanden“ blendet aus | integration_test/Playwright: Meldung per SQL-Hilfsfunktion im Testsetup mit `admin_resolve_report(…, 'approved', …)` bearbeiten → Hinweis am Eintrag | nach `admin_resolve_report` (2c) ist der Sammlungseintrag von A am Zielspiel mit Liste/Notiz/Kaufdatum; vorläufige Variante → `variant_id` null, private Variante bleibt am vorläufigen Eintrag und ist für A lesbar (E7); `result_seen_at` setzbar | B2, F3, T1, T2 | -| Krit. 5 | `outcomeOf`: `merged`, `rejectedWithTarget`, `rejectedAdopted` | Hinweise und Aktion „wechseln“ mit Bestätigungsdialog | Playwright: abgelehnt mit Verweis → wechseln → Eintrag zeigt Zielspiel | `adopt_report_target`: Erfolg hängt Sammlung und Partien von A um, setzt `merged_into_id`/`hidden_at`, Status bleibt `rejected`, `result_seen_at` gesetzt; Fehler `report_not_found` (fremde Meldung von B, unbekannte ID), `report_not_adoptable` (`open`, `approved`, `rejected` ohne Ziel), `report_already_adopted` (zweiter Aufruf), `report_target_not_listed` (Ziel inzwischen ausgeblendet), `report_foreign_references` (Fixture als `postgres`); `anon` → 42501 | B1, B2, F3, T1, T2 | -| Krit. 6 | `outcomeOf`: `rejected` ohne Ziel | dauerhafte Markierung „Abgelehnt“ bleibt nach „Verstanden“ | – | nach `admin_resolve_report(…, 'rejected')` bleibt das vorläufige Spiel unverändert privat und für A lesbar | B2, F3, T2 | -| Krit. 7 | Mapping `report_daily_limit` → `dailyLimit` | Limit-Hinweis, Eingaben bleiben erhalten | – | 10 Meldungen am selben Berliner Kalendertag ok, 11. → `report_daily_limit` und **kein** vorläufiges Spiel übrig (Rollback); Meldungen von gestern (Fixture mit `created_at = now() - interval '1 day'` als `postgres` gesetzt) zählen nicht; Tagesgrenze über `report_day` mit festen Zeitstempeln (`2026-01-15 23:30 UTC` → 16.01., `2026-07-15 21:59 UTC` → 15.07., `22:00 UTC` → 16.07.); `origin = 'import'` als `postgres` eingefügt zählt nicht und wird nicht begrenzt; Meldungen von B zählen nicht für A; direkter Insert mit `created_at`, `origin` oder `status` → 42501 | B1, B2, F2, T1 | -| Krit. 8 | – | Status „offen“ in Liste und Detail | – | Nutzer kann `status`, `target_game_id`, `resolved_at` weder per Insert noch per Update setzen (42501); Update von `result_seen_at` bei `status = open` → 0 Zeilen; nach dem Insert ist der Status `open`; kein Trigger/Job ändert ihn | B2, T1 | -| Sicherheit Krit. 1, 3 | – | – | – | B liest A's Meldungen und vorläufige Spiele nicht; B kann A's Meldung nicht wegklicken (0 Zeilen) und nicht übernehmen; Insert mit `owner_id = A` als B → Policy-Fehler; `provisional_game_id` auf ein fremdes, ein Katalog- oder ein ausgeblendetes Spiel → Policy-Fehler; Spaltenrechte per `has_column_privilege` | B2 | -| Sicherheit Krit. 2 | – | – | – | `anon`: kein `select`, `insert`, `update` auf `game_reports`, keine RPC ausführbar | B2 | -| Datenschutz | – | – | – | Konto von A löschen → Meldungen, vorläufige Spiele, Listeneinträge weg; Meldungen und Spiele von B unverändert | B2 | -| QUE-05/06, Zustände | – | Alle Screens de/en, `meetsGuideline` (Tap-Ziele, Kontrast, Labels), `textScaler` 2.0 ohne Überlauf, Lade-/Leer-/Fehler-/Datenzustand „Meine Meldungen“, Offline-Hinweis | Playwright: Tastaturbedienung des Bestätigungsschritts | – | F2, F3, T2 | +| 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 (die Sperre wird per Code-Review und einem dokumentierten manuellen Test mit zwei `psql`-Sitzungen geprüft); Bearbeitung echter Meldungen im Studio auf Staging (Chris). +**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` aus 2c erwartet „kein Insert und kein Update für `authenticated`“ bzw. „nur `postgres` ändert Meldungen“; 13a passt beide Erwartungen auf die neuen Spaltenrechte an (Insert der erlaubten Spalten, Update nur `result_seen_at` an eigenen bearbeiteten Meldungen; alles andere weiter 42501) (B2). `00_schema.test.sql` bleibt bei 21 Tabellen. +**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 @@ -415,32 +395,32 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We | # | Agent | Aufgabe | abhängig von | |---|---|---|---| -| B1 | backend-dev | Migration `_game_reports_user.sql` nach 3.2–4.5: `pg_trgm`, `normalize_game_title` (oder Wiederverwendung aus #3, 3.3), Spalte `result_seen_at` mit Check, Indizes, Spaltenrechte, Policies `game_reports_insert_own` und `game_reports_update_seen`, Trigger `game_reports_daily_limit` mit Hilfsfunktion `report_day` (Berliner Kalendertag, Sperre je Nutzer, nur `origin = 'user'`), RPCs `find_similar_catalog_games`, `report_missing_game`, `adopt_report_target`; Rechte nach 4.1 | #2 PR 2c gemergt | -| B2 | backend-dev | pgTAP `09_game_reports_user.test.sql` nach 7.1; `08_game_reports.test.sql` an die neuen Insert- und Update-Rechte anpassen | B1 | -| B3 | backend-dev | Doku nach 4.7 (`datenmodell-r1.md`, `katalog-pflege.md` Abschnitt Meldungen, `supabase/README.md`) | B1 | -| T1 | test-engineer | Review der pgTAP-Abdeckung gegen 7.1; manueller Paralleltest des Limits (zwei Sitzungen) dokumentieren | B2 | +| 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 | Entwurf Formular, Kandidatenschritt mit ausdrücklicher Bestätigung, Bestätigung, „Meine Meldungen“, Meldungsdetail, `ReportOutcomeNotice` (kompakt/ausführlich), Badge „vorläufig“, breites Web-Layout; Texte de/en; Einstieg zu `/reports` mit Chris abstimmen | Spec freigegeben | -| F1 | flutter-dev | Domäne (5.1): Modelle, `ReportOutcome` + `outcomeOf`, `GameReportDraft`-Validierung, Fehler, Repository-Schnittstelle; Unit-Tests | Spec freigegeben (parallel zu B1) | -| F2 | flutter-dev | Data-Schicht (RPC-Aufrufe mit expliziten Parametern, kein `toJson()` an Schreibzugriffe; Fehler-Mapper) und Melden-Ablauf (5.3) inkl. Routen, Einstieg aus der leeren Suche (#3), l10n | F1, U1; gegen echtes Backend: B1 | -| F3 | flutter-dev | „Meine Meldungen“, Meldungsdetail, Hinweise (5.4) inkl. „Verstanden“, „wechseln“, Edition-Hinweis; Einbau von `ReportOutcomeNotice` in #4/#6/#11, soweit gemergt (sonst Hinweis-Kommentar in den Issues) | F1, F2 | -| T2 | test-engineer | Widget-Tests nach 7.1 (alle Zustände, alle Hinweisfälle, de/en, a11y, `textScaler` 2.0); Playwright-Ablauf Krit. 1–5 gegen lokales Supabase, Bearbeitung der Meldung im Testsetup per SQL als `postgres` | F2, F3, B1 | +| 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 (Einstieg, ggf. Normalisierung) +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 erst nach 13a gemergt (RPC-Vertrag muss in main sein). +13b wird nach 13a gemergt. ``` -F1 und U1 starten sofort nach Freigabe der Spec, parallel zu B1. 13b schließt #13. Merge nur durch Chris. +F1 und U1 starten nach Freigabe der Spec, parallel zu B1. Merge nur durch Chris. ## 9. Risiken & offene Fragen @@ -448,57 +428,68 @@ F1 und U1 starten sofort nach Freigabe der Spec, parallel zu B1. 13b schließt # | # | Risiko | Auswirkung | Gegenmaßnahme | |---|---|---|---| -| R1 | 2b/2c weichen bei der Umsetzung von den in Spec #2 beschriebenen Namen oder Rückgaben ab | 13a passt nicht | B1 liest die gemergten Migrationen zuerst; Abweichungen im PR nennen | -| R2 | `adopt_report_target` ist `security definer` und erreicht `repoint_game_references`, das RLS umgeht | Fehler würden fremde Daten berühren | Strenge Prüfungen (4.4) inkl. Prüfung auf fremde Verweise, `for update`, nur eigene private, nicht zusammengeführte Spiele; pgTAP-Negativfälle; Code-Review mit Fokus Sicherheit | -| R3 | Trigramm-Schwelle 0,8 ist bei kurzen Titeln streng oder bei Reihen („Catan“, „Catan: Seefahrer“) großzügig | Ähnliche werden nicht gezeigt bzw. zu viele | Klärung 15 ist verbindlich; Kandidatenschritt blockiert nicht, er verlangt nur eine Bestätigung; Erfahrungen nach R1 sammeln | -| R4 | `pg_trgm` erkennt Wortzeichen abhängig von der Datenbank-Locale | Ähnlichkeitswerte weichen zwischen lokal und Produktion ab | Normalisierung liefert nur `a–z`, `0–9` und Leerzeichen (3.3); damit ist die Trigrammbildung locale-unabhängig; pgTAP-Fälle mit Umlauten und Akzenten | -| R5 | Edition-Hinweis per Ableitung (E7) zeigt auch dann „Edition neu wählen“, wenn der Nutzer nie eine Edition gewählt hatte, aber eine private Variante existiert | Unnötiger Hinweis | Selten (vorläufige Einträge haben anfangs keine Varianten); wegklickbar; Alternative in Spec-F6 | -| R6 | Hinweis „am Eintrag“ braucht Screens aus #4/#6/#11, die evtl. noch fehlen | Krit. 4–6 nur in „Meine Meldungen“ sichtbar | Spec-F8: Abhängigkeiten setzen oder Einbau dort nachziehen | -| R7 | Nutzer melden das gleiche Spiel mehrfach (eigene offene Meldungen werden nicht geprüft, Spec-F7) | Mehr Arbeit für Chris, doppelte vorläufige Einträge | Tageslimit; Chris lehnt Duplikate mit Verweis ab | -| R8 | Kein Realtime | Ergebnis erscheint erst beim nächsten Laden | Akzeptiert (manuelle Bearbeitung, kein Zeitdruck) | +| 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: Für jede gibt es eine umsetzbare Empfehlung, die mit der Freigabe der Spec gilt, falls Chris nichts anderes entscheidet. +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 Erscheinungsjahr Pflicht oder optional? Der Text nennt nur den Titel als Pflicht. | **Optional**, nur der Titel ist Pflicht. | Nutzer kennen das Jahr oft nicht; eine Pflicht würde zu geratenen Werten führen. 2c hat beide Spalten nullable. | -| Spec-F2 | **Krit. 2:** Wie gilt die Trigramm-Regel, wenn das Jahr in der Meldung oder im Katalogeintrag fehlt? | Trigramm ≥ 0,8 **ohne Jahresbedingung**. | Lieber einmal mehr nachfragen als ein Duplikat; der Schritt verlangt nur eine Bestätigung. | -| Spec-F3 | **Krit. 2 „Artikel und Sonderzeichen ignoriert“:** Welche Artikel, wo, und zählen Umlaute/Akzente als Sonderzeichen? | Nur **führende** Artikel auf Deutsch und Englisch (Liste in 3.3); Umlaute und Akzente werden **gefaltet** (`ö` = `o`, `é` = `e`, `ß` = `ss`) über eine feste `translate()`-Zuordnung; alle anderen Zeichen werden ignoriert. | Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“). Ohne Faltung würden „Mölkky“ und „Molkky“ **nicht** als ähnlich erkannt: Die Trigramm-Ähnlichkeit liegt bei etwa 0,4, weit unter 0,8. `unaccent` scheidet aus, weil es nicht `immutable` ist; `translate()` ist es und braucht keine Erweiterung. Nachteil: „ae“-Schreibweisen („Raeuber“) werden nicht auf „ä“ abgebildet. Alternative: Umlaute als eigene Buchstaben behalten (strenger, dann werden Schreibvarianten mit und ohne Umlaut nicht erkannt). | -| Spec-F4 | **Krit. 7 „heute“:** Kalendertag in welcher Zeitzone, oder gleitende 24 Stunden? | **Kalendertag in Europe/Berlin.** | Entspricht „heute“ im Kriterium und ist für Nutzer verständlich („morgen geht es wieder“). Eine Profil-Zeitzone gibt es nicht. | -| Spec-F5 | **Krit. 4–6:** Wie lange bleibt der Hinweis am Eintrag sichtbar? | **Bis der Nutzer „Verstanden“ wählt** (`result_seen_at`). Die Markierung „abgelehnt“ (Krit. 6) bleibt dauerhaft; in „Meine Meldungen“ bleibt der Status immer sichtbar. | Ein dauerhafter Hinweis an einem längst ersetzten Eintrag stört; ein automatisches Ausblenden nach Zeit könnte übersehen werden. | -| Spec-F6 | **N15, Edition:** Woran erkennt die App, dass die Edition geleert wurde? | **Ableitung** (E7): vorläufiger Eintrag hat eine eigene Variante und der Eintrag am Ziel hat keine Edition. Alternative: Spalte `game_reports.edition_cleared`, gesetzt von `admin_resolve_report`/`adopt_report_target`; dafür müsste `repoint_game_references` (2b) zusätzlich zählen, wie viele **Verweise** geleert wurden, und 2c bzw. 13a die Funktionen anpassen. | Die Ableitung braucht keine Änderung an den bereits freigegebenen Teilen 2b/2c. Die Alternative ist genauer und lohnt sich, wenn 2b/2c ohnehin noch nicht gebaut sind; dann sollte die Änderung in Spec #2 nachgetragen werden, nicht in 13a per `create or replace`. | -| Spec-F7 | **Krit. 2:** Sollen auch eigene offene Meldungen bzw. eigene vorläufige Einträge mit ähnlichem Titel angezeigt werden („Du hast dieses Spiel schon gemeldet“)? | **Nein in R1.** | Kriterium nennt nur Katalogeinträge; Duplikate begrenzt das Tageslimit, Chris lehnt sie mit Verweis ab. | -| Spec-F8 | **Abhängigkeiten:** Krit. 3 („in Sammlung oder Listen“) und der Hinweis „am Eintrag“ (Krit. 4–6) leben in Screens von SAM-01 (#6), KAT-02 (#4) und SAM-03 (#11). Sollen #4 und #6 als Forgejo-Abhängigkeit von #13 gesetzt werden? | **Ja, #13 hängt zusätzlich von #4 und #6 ab** (beide liegen in der Reihenfolge ohnehin vor #13). #11 nicht: Den Einbau des Hinweises in die durchsuchbare Sammlung übernimmt #11, falls es später kommt. | Sonst müsste #13 eigene Zwischenlösungen für Listeneintrag und Detailseite bauen, die später ersetzt werden. | -| Spec-F9 | **Krit. 3:** Welche Liste ist im Formular vorausgewählt? | **Sammlung**, änderbar auf Wunsch-/Einkaufsliste oder „nicht aufnehmen“. | Häufigster Fall: Nutzer will ein Spiel erfassen, das er besitzt (Story: „Katalogloch blockiert beim Erfassen“). | +| 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 (zur Kenntnis, kein ADR nötig) +### 9.3 Entscheidungen innerhalb der Architektur (kein ADR nötig) -- RPC als Bündelung mit `security invoker`, Rechte in der Datenbank (E2, E3); nur `adopt_report_target` ist `security definer` (E5). -- Kein Realtime, keine Edge Function, keine neuen Pakete (E9, R8). -- Kein ADR: Die Entscheidungen bleiben innerhalb von ADR-0001/0002 und sind ohne großen Aufwand änderbar. +- RPC als Bündelung mit `security invoker`, Rechte in der Datenbank; nur `adopt_report_target` ist `security definer` (E2, E3, E5). +- Normalisierung gehört #3; 13a ergänzt nur den Artikel-Wrapper (E4). +- Kein Realtime, keine Edge Function, keine neuen Pakete. ### 9.4 Hinweise für andere Stories -- **KAT-01 (#3):** Einstieg „Spiel melden“ aus der leeren Trefferliste mit `/reports/new?title=` (Krit. 6); Normalisierung `normalize_game_title` für die Suche mitnutzen (3.3); legt #3 alternative Titel an, auch in `find_similar_catalog_games` berücksichtigen. -- **KAT-02 (#4), SAM-01 (#6), SAM-03 (#11):** `ReportOutcomeNotice` und `reportNoticeForGameProvider` am Eintrag einbauen; vorläufige Einträge mit Badge „vorläufig“; eine Detailansicht für ein eigenes privates Spiel (nicht in `catalog_games`) lesen sie direkt aus `games`. -- **KAT-03 (#12):** Aktion „Edition wählen“ aus dem Edition-Hinweis anbinden. -- **KTO-02 (#14):** Die Policy `favorite_games_own` erlaubt derzeit jedes sichtbare Spiel, also auch eigene vorläufige; Klärung 23 verlangt nur Katalogspiele. #14 muss die Policy (neue Migration) auf `visibility = 'catalog'` einschränken. -- **PAR-01 (#15):** vorläufige Spiele als wählbare Spiele anbieten (Krit. 3, Klärung 36), mit Badge. -- **KTO-05 (#23):** Export enthält `game_reports` (inkl. Status und Link) und vorläufige Spiele; Löschen per Kaskade ist durch pgTAP in 13a abgedeckt. -- **SAM-08 (#25):** Import-Meldungen mit `origin = 'import'` und `bgg_id`, einem vorläufigen Eintrag je Meldung (E1); der Limit-Trigger ignoriert sie (N12); die Hinweise aus 5.4 gelten unverändert; Kennzeichen „aus BGG-Import“ in „Meine Meldungen“ (5.5). +- **Spec #2 (2b/2c), falls Spec-F6 = Zähler:** Nachtrag vor dem Bau (Spalte `editions_cleared`, Zähler der geleerten Verweise, Setzen in `admin_resolve_report`). +- **KAT-01 (#3):** derselbe Satz zu Zuständigkeit und Rechten von `search_normalize` (3.3); Einstieg `/reports/new?title=` (Krit. 6); `game_alt_titles` wird von `find_similar_catalog_games` gelesen. +- **KAT-02 (#4), SAM-01 (#6), SAM-03 (#11):** `ReportOutcomeNotice` und `reportNoticeForGameProvider` einbauen; Badge „vorläufig“; eigene private Spiele direkt aus `games` lesen. +- **KAT-03 (#12):** Aktion „Edition wählen“ anbinden. +- **KTO-02 (#14):** `favorite_games_own` erlaubt derzeit jedes sichtbare Spiel, auch eigene vorläufige; Klärung 23 verlangt Katalogspiele. #14 schränkt die Policy (neue Migration) auf ein **sichtbares Katalogspiel** ein (Bedingung wie `catalog_games`: `visibility = 'catalog' and hidden_at is null and merged_into_id is null`). +- **PAR-01 (#15):** vorläufige Spiele wählbar (Klärung 36), mit Badge; nach Freigabe/Wechsel zeigen Partien das Katalogspiel, auch für Mitspieler. +- **KTO-05 (#23):** Export enthält `game_reports` und vorläufige Spiele. +- **SAM-08 (#25):** Import-Meldungen mit `origin = 'import'`, `bgg_id` und vorläufigem Eintrag; Limit-Trigger greift nicht (N12); Hinweise aus 5.4 gelten. ### 9.5 Ausstehende Prozessschritte -- Freigabe der Spec und Antworten zu Spec-F1 bis Spec-F9 durch Chris (Label `status/spec-review`). -- Bei Spec-F6 Alternative: Nachtrag in Spec #2 (2b/2c) vor deren Umsetzung. -- Bei Spec-F8 Ja: Forgejo-Abhängigkeiten #13 → #4, #6 setzen (Chris oder Orchestrator). +- 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. 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. +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. -Nachtrag am selben Tag: Normalisierung faltet Akzente per `translate()` (Spec-F3 neu begründet: ohne Faltung läge „Mölkky/Molkky“ bei etwa 0,4); Trigramm-Index gestrichen (wäre mit `similarity() >= 0.8` nicht nutzbar); Abweichung von Spec #2 § 3.4 (Update-Recht auf `result_seen_at`) ausdrücklich vermerkt und Testanpassung in B2 ergänzt; Hilfsfunktion `report_day` für testbare Tagesgrenze. +### 2026-11-03 – Review (code-reviewer) + +- **B1:** `report_day` mit `execute` für `authenticated` (sonst 42501 im Trigger); Trigger mit `when (new.origin = 'user')`; Voraussetzung READ COMMITTED; pgTAP fügt als `authenticated` ein. +- **B2/B3:** Normalisierung gehört Spec #3 (`search_normalize`); 13a nur `strip_leading_article` und Wrapper `normalize_game_title`; `translate()`-Faltung gestrichen; Regeln A und B auf `normalize_game_title`; alternative Titel verpflichtend; alle „falls #3“-Bedingungen entfernt (#3 vor 13a); Satz zur Zuständigkeit/Rechten; `unaccent`-Immutability als Konvention. +- `adopt_report_target`: Sperrreihenfolge, `is distinct from`, Attribut- und Rechte-Tests, kein `admin_*`-Aufruf; `result_seen_at` bleibt null (Hinweis am Ziel nach Wechsel). +- Insert-Policy verlangt `provisional_game_id`; 23505 → `report_provisional_in_use`. +- Gelöschter vorläufiger Eintrag in `outcomeOf` (`rejectedReference`, nur Status); Hinweis am Ziel nur bei `merged_into_id = target`. +- Indizes aus 2c übernommen; Testdatei `10_…`; Tabellenzahl unverändert gegenüber `main`. +- Edition: Ableitung nur `list = 'owned'`; Spec-F6 neu bewertet, Empfehlung Zähler `editions_cleared`. +- `p_year integer`, Titel > 200 → leere Menge, Limit-Vorprüfung vor der Ähnlichkeit, Großbuchstaben-Testfall, Partien/Mitspieler, Hinweis #14 präzisiert. From b65ec0efe77bd9129e300e101347acd0156e9eff Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:16:26 +0000 Subject: [PATCH 16/30] docs(catalog): Review-Befunde in die KAT-01-Spec einarbeiten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_normalize für alle API-Rollen ausführbar, Symbole vor unaccent entfernen, eine Basisfunktion gemeinsam mit #13, Reihenfolge 3a nach 2b, Spec-F4 mit vorläufiger Messung ohne Staging, Indizes nicht als Begründung, Grenzen und Hinweise aus dem Review. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/3-katalog-suche.md | 281 +++++++++++++++++++++------------- 1 file changed, 171 insertions(+), 110 deletions(-) diff --git a/docs/specs/3-katalog-suche.md b/docs/specs/3-katalog-suche.md index cee1d5f..d946bb7 100644 --- a/docs/specs/3-katalog-suche.md +++ b/docs/specs/3-katalog-suche.md @@ -3,12 +3,12 @@ | | | |---|---| | Issue | #3 (KAT-01) | -| Status | Entwurf, wartet auf Freigabe durch Chris (Spec-F1 bis Spec-F7 in § 9.2 offen, keine blockiert) | +| Status | Entwurf, wartet auf Freigabe durch Chris (Spec-F1 bis Spec-F7 in § 9.2 offen, keine blockiert); Review (code-reviewer) eingearbeitet, § 10 | | Autor | architect | | Stand | 2026-11-03 | | Etappe | R1.1 Fundament | -| Abhängigkeiten | #2 KAT-05 Teil 2a (gemergt: `catalog_games`, `publishers`, `game_publishers`, `manual_fields`, `GameCover`, Startbestand-Werkzeug). Teilabhängigkeit: Krit. 6 verweist auf #13 KAT-04 (später). Von #3 hängen ab: #4 KAT-02 (Einstieg in die Detailseite), #13 KAT-04 (Melden aus dem Leerzustand), #29 QUE-09 (Startseite ohne Anmeldung zeigt die Suche), #14 KTO-02 (Lieblingsspiele per Katalogsuche). | -| Quellen | Issue #3 inkl. Kommentare, `requirements/20-r1-akzeptanzkriterien.md` § KAT-01 und § Querschnitt, `requirements/r1-klaerungen.md` (3, 4, 13, 14, 15, 36, O6, O7), `requirements/backlog.md` (Datenmodell-Änderungen 3, 4), `docs/specs/2-katalog-daten.md` (E3, E4, E6, 3.2, § 5, 9.5), `docs/datenmodell-r1.md`, `supabase/migrations/20261102100200_catalog.sql`, `…/20261103142204_catalog_foundation.sql`, `supabase/catalog/build_startbestand.ts`, `app/lib/core/widgets/game_cover.dart`, `app/lib/core/routing/*`, `app/lib/features/auth/presentation/providers/auth_redirect.dart`, `docs/design-system.md`, `e2e/README.md`, ADR-0001, ADR-0002 | +| Abhängigkeiten | #2 KAT-05 Teil 2a (gemergt: `catalog_games`, `publishers`, `game_publishers`, `manual_fields`, `GameCover`, Startbestand-Werkzeug); Teil 2b (`feature/2b-katalog-zusammenfuehren`) wird vor 3a gemergt (3.3). Teilabhängigkeit: Krit. 6 verweist auf #13 KAT-04 (später). Von #3 hängen ab: #4 KAT-02 (Einstieg in die Detailseite), #13 KAT-04 (Melden aus dem Leerzustand, `search_normalize`), #29 QUE-09 (Startseite ohne Anmeldung zeigt die Suche), #14 KTO-02 (Lieblingsspiele per Katalogsuche). | +| Quellen | Issue #3 inkl. Kommentare, `requirements/20-r1-akzeptanzkriterien.md` § KAT-01 und § Querschnitt, `requirements/r1-klaerungen.md` (3, 4, 13, 14, 15, 36, O6, O7), `requirements/backlog.md` (Datenmodell-Änderungen 3, 4), `docs/specs/2-katalog-daten.md` (E3, E4, E6, 3.2, § 5, 9.5), `docs/specs/13-fehlendes-spiel-melden.md` (Branch `docs/13-kat04-spec`), `docs/datenmodell-r1.md`, `supabase/migrations/20261102100200_catalog.sql`, `…/20261103142204_catalog_foundation.sql`, `supabase/catalog/build_startbestand.ts`, `supabase/tests/database/06_catalog_foundation.test.sql`, `…/07_catalog_merge.test.sql` (2b), `app/lib/core/widgets/game_cover.dart`, `app/lib/core/routing/*`, `app/lib/features/auth/presentation/providers/auth_redirect.dart`, `docs/design-system.md`, `e2e/README.md`, ADR-0001, ADR-0002 | **Namenskonvention:** „Spec-F1“ bis „Spec-F7“ sind die Fragen an Chris aus § 9.2. „E1“ usw. sind Kernentscheidungen dieser Spec. „Spec #2 E3“ verweist auf Entscheidungen der Katalog-Spec. „B1“, „F1“, „U1“, „T1“, „D1“, „C1“ sind Aufgaben (§ 8). @@ -24,17 +24,17 @@ Nicht im Scope (Issue): Barcode-Suche, Offline-Katalog (O7), Empfehlungen. Ebenf |---|---|---| | E1 | **Eine RPC `search_catalog_games`** (PostgreSQL-Funktion, `security invoker`, `stable`) liefert Treffer, Gesamtzahl und Verlage in **einer** Anfrage. Sie liest über die View `catalog_games` (Spec #2 E3), damit ausgeblendete und zusammengeführte Spiele nie erscheinen und die Anzeige-Maskierung (Spec #2 E6) gilt. | Relevanz über Titel, alternative Titel und Verlage, UND-Filter über Zuordnungstabellen und Normalisierung lassen sich mit PostgREST-Filtern auf der View nicht sauber ausdrücken. Eine Anfrage je Seite hält Krit. 7 (100 ms RTT) ein. `security invoker` heißt: RLS gilt für den Aufrufer, die Funktion umgeht nichts. | | E2 | **Alternative Titel als eigene Tabelle `game_alt_titles`** (Titel + Sprache `de`/`en`), nur lesbar für Clients, Pflege über Studio und das Startbestand-Werkzeug (Spalte `alt_titles` der CSV, Spec #2 4.3). Neuer `manual_fields`-Schlüssel `alt_titles`. | Klärung 3, Backlog „Datenmodell-Änderungen“ Nr. 3. Nutzer haben in R1 keinen Anwendungsfall zum Schreiben; ohne Schreibrecht entfällt die Angriffsfläche. | -| E3 | **Normalisierung in der Datenbank:** eine Funktion `public.search_normalize(text)` (Kleinschreibung, Akzente und Umlaute entfernt, alles außer `a–z`/`0–9` wird zu einem Leerzeichen). Ergebnis als **gespeicherte generierte Spalten** `games.title_search`, `game_alt_titles.title_search`, `publishers.name_search` mit Trigramm-Index. | Krit. 1 „unabhängig von Groß- und Kleinschreibung und Sonderzeichen“. Eine zentrale Funktion statt Logik im Client; #13 nutzt sie für „gleicher normalisierter Titel“ (Klärung 15) wieder. Generierte Spalten können nicht vergessen werden und brauchen keine Trigger. | -| E4 | **Treffer-Regel:** Der Suchbegriff wird in Wörter zerlegt; **jedes Wort** muss als Teilzeichenkette im Titel, in einem alternativen Titel oder in einem Verlagsnamen des Spiels vorkommen. Zusätzlich trifft der Begriff ohne Leerzeichen („7wonders“) den Titel ohne Leerzeichen. | Krit. 1 („ein Titel oder ein Teil davon“), Krit. 2, Krit. 3 in einem Suchfeld. „siedler catan“ findet „Die Siedler von Catan“, „catan kosmos“ findet Catan von Kosmos. | +| E3 | **Eine Basisfunktion `public.search_normalize(text)`** für alle normalisierten Titel (Symbole entfernen, Akzente/Umlaute entfernen, Kleinschreibung, alles außer `a–z`/`0–9` zu einem Leerzeichen), **angelegt von #3**, ausführbar für alle API-Rollen. Ergebnis als **gespeicherte generierte Spalten** `games.title_search`, `game_alt_titles.title_search`, `publishers.name_search`. #13 baut `normalize_game_title` darauf auf (3.2 Nr. 2). | Krit. 1 „unabhängig von Groß- und Kleinschreibung und Sonderzeichen“. Eine zentrale Funktion statt Logik im Client und statt zweier abweichender Normalisierungen in #3 und #13. Generierte Spalten können nicht vergessen werden und brauchen keine Trigger. | +| E4 | **Treffer-Regel:** Der Suchbegriff wird in Wörter zerlegt; **jedes Wort** (ab 2 Zeichen) muss als Teilzeichenkette im Titel, in einem alternativen Titel oder in einem Verlagsnamen des Spiels vorkommen. Zusätzlich trifft der Begriff ohne Leerzeichen („7wonders“, „aeons end“) den Titel ohne Leerzeichen. | Krit. 1 („ein Titel oder ein Teil davon“), Krit. 2, Krit. 3 in einem Suchfeld. „siedler catan“ findet „Die Siedler von Catan“, „catan kosmos“ findet Catan von Kosmos. | | E5 | **Relevanz als feste Stufen** (genau gleich → beginnt mit → Wortanfang → enthält → nur über Verlag), danach Trigramm-Ähnlichkeit, Titel, ID. | Krit. 1 „nach Relevanz sortiert“ wird damit testbar und deterministisch; die eindeutige Reihenfolge ist Voraussetzung für stabiles Nachladen (Krit. 5). | | E6 | **Nachladen über `limit`/`offset`**, Seitengröße 30, Gesamtzahl über `count(*) over ()`. | Der Katalog ändert sich selten (Studio-Pflege), die Reihenfolge ist eindeutig (E5). Keyset-Paging über eine zusammengesetzte Relevanz wäre deutlich komplexer ohne spürbaren Nutzen bei einigen hundert bis tausend Spielen. | | E7 | **Filter sind UND-verknüpft**, auch mehrere gewählte Genres oder Mechaniken untereinander (Spec-F1). Filter auf Spielerzahl, Dauer und Alter schließen Spiele mit fehlender Angabe aus (Spec-F2). | Wörtliche Lesart von Krit. 4/8 „zu allen gewählten Filtern passen“ (Klärung 13). | -| E8 | **Ohne Suchbegriff und ohne Filter zeigt die Suche den ganzen Katalog alphabetisch** (Spec-F3). | Krit. 8 (Entdecken) ohne Sonderfall: „keine Filter“ ist der leere Filter. Bei ~200 Spielen nützlich zum Stöbern. | +| E8 | **Ohne Suchbegriff und ohne Filter zeigt die Suche den ganzen Katalog alphabetisch** (Spec-F3). | Krit. 8 (Entdecken) ohne Sonderfall: „keine Filter“ ist der leere Filter. Bei ~200 Spielen nützlich zum Stöbern. Der Katalog ist öffentlich; dass er sich vollständig durchblättern lässt, ist gewollt. | | E9 | **Neues Feature `features/catalog/`** mit Domäne (`CatalogGameSummary`, `CatalogSearchQuery`, `CatalogTerm`, `CatalogFailure`), Repository und einem Notifier mit eigenem Seitenzustand. **Alle Datenklassen sind nur lesend** (`createToJson: false`); das Feature schreibt nichts in `games`. | Merkpunkt aus Spec #2 § 10: Kein ungefiltertes `toJson()` darf an `insert`/`update` gehen. Ohne `toJson` ist das hier nicht nur dokumentiert, sondern unmöglich. | | E10 | **Öffentliche Route `/catalog`** mit Suchbegriff und Filtern als Query-Parameter (Web-Deep-Links). Die Startseite bekommt nur einen Einstieg; die Startseite ohne Anmeldung baut #29. | Web-URLs (ADR-0001, go_router); `privateRoutePaths` bleibt leer (Spec #1). | | E11 | **Krit. 6 in #3 als Leerzustand mit Hinweis und Einstiegspunkt**; die Aktion „Spiel melden“ wird mit #13 freigeschaltet (Spec-F4). | Issue: „Teilabhängigkeit: Kriterium 6 verweist auf KAT-04 (#13), das später umgesetzt wird“. | -| E12 | **Krit. 7 wird mit einem eigenen, nur lesenden Playwright-Projekt `perf` gegen Staging gemessen** (Chrome-Drosselung 100 ms / 10 Mbit/s, mindestens 100 Suchen, Messung vom letzten Tastendruck bis zum ersten sichtbaren Treffer). Datenbank-Budget: p95 ≤ 150 ms bei 10 000 Spielen. | Klärung 14. Die bestehenden E2E-Helfer (`db.ts`, `E2E_ALLOWED_HOSTS`) sind bewusst nur lokal; Staging wird nur über die Web-App angesprochen. | -| E13 | **Drei PRs:** 3a Backend, 3b App (parallel gegen den Vertrag aus § 4.2), 3c E2E und Messung. Nur 3c schließt das Issue. | Wie bei #2 (PR #52/#53); 3b kann mit gemocktem Repository sofort beginnen. | +| E12 | **Krit. 7 wird mit einem eigenen, nur lesenden Playwright-Projekt `perf` gemessen** (Chrome-Drosselung 100 ms / 10 Mbit/s, mindestens 100 Suchen, Messung vom letzten Tastendruck bis zum ersten sichtbaren Treffer). **Staging gibt es noch nicht** (ADR-0002: ein Hetzner-Server); bis dahin vorläufige Messung gegen den lokalen bzw. CI-Stack mit Startbestand und synthetischem Volumen, die Staging-Messung wird Freigabebedingung in der Release-Checkliste (Spec-F4). Datenbank-Budget: p95 ≤ 150 ms bei 10 000 Spielen, gemessen als `anon` mit RLS. | Klärung 14. Die bestehenden E2E-Helfer (`db.ts`, `E2E_ALLOWED_HOSTS`) sind bewusst nur lokal; Staging wird nur über die Web-App angesprochen. | +| E13 | **Drei PRs:** 3a Backend, 3b App (parallel gegen den Vertrag aus § 4.2), 3c E2E und Messung. Nur 3c schließt das Issue (unter Vorbehalt von Spec-F4). | Wie bei #2 (PR #52/#53); 3b kann mit gemocktem Repository sofort beginnen. | | E14 | **Keine neuen Dart-Pakete, keine Edge Function.** Neu sind nur die PostgreSQL-Erweiterungen `pg_trgm` und `unaccent` (Bestandteil des Supabase-Postgres-Images, auch self-hosted; Prüfung in B5). | CLAUDE.md, ADR-0002. Kein ADR nötig (§ 9.4). | ## 2. Akzeptanzkriterien @@ -52,12 +52,12 @@ Nicht im Scope (Issue): Barcode-Suche, Offline-Katalog (O7), Empfehlungen. Ebenf **Konkretisierungen dieser Spec (zur Freigabe):** -- **Krit. 1 „Sonderzeichen“** umfasst Satz- und Sonderzeichen (`:`, `-`, `®`, `'` usw.) und diakritische Zeichen (`é` → `e`, `ä` → `a`, `ß` → `ss`). Die Schreibweise „ae“ für „ä“ findet in R1 **nicht** automatisch „ä“ (Spec-F5). -- **Krit. 1 „Teil davon“:** Die Suche beginnt ab **2 Zeichen** (nach Entfernen von Leerzeichen am Rand). Ein einzelnes Zeichen wirkt wie „kein Suchbegriff“ (§ 5.4). +- **Krit. 1 „Sonderzeichen“** umfasst Satz- und Sonderzeichen (`:`, `-`, `'`, `’` usw.), Marken- und Schutzzeichen (`®`, `©`, `™`, `℗`, `℠`, werden ersatzlos entfernt) und diakritische Zeichen (`é` → `e`, `ä` → `a`, `ß` → `ss`, `Æ` → `ae`). Die Schreibweise „ae“ für „ä“ findet in R1 **nicht** automatisch „ä“ (Spec-F5). +- **Krit. 1 „Teil davon“:** Die Suche beginnt ab **2 Zeichen** (App: nach Entfernen von Leerzeichen am Rand; RPC: nach Normalisierung). Weniger wirkt wie „kein Suchbegriff“ (§ 4.2, § 5.4). - **Krit. 4 Filtersemantik:** siehe § 4.2 und Spec-F2. -- **Krit. 5:** Seitengröße 30. Nachgeladen wird automatisch kurz vor dem Listenende **und** über eine Schaltfläche „Weitere laden“ am Listenende (Tastatur und Screenreader, QUE-06 Krit. 4). +- **Krit. 5:** Seitengröße 30. Nachgeladen wird automatisch kurz vor dem Listenende **und** über eine Schaltfläche „Weitere laden“ am Listenende (Tastatur und Screenreader, QUE-06 Krit. 4). Nachgeladen wird höchstens bis Versatz 10 000 (Grenze der RPC); bei der Katalogsgröße von R1 nicht erreichbar. - **Krit. 6:** siehe E11 und Spec-F4. -- **Krit. 7 Messpunkt:** vom letzten Tastendruck (inklusive Entprellung von 300 ms) bis der erste Treffer sichtbar ist; Staging mit eingespieltem Startbestand (§ 7.3). +- **Krit. 7 Messpunkt:** vom letzten Tastendruck (inklusive Entprellung von 300 ms) bis der erste Treffer sichtbar ist; Umgebung und Abnahme nach Spec-F4 (§ 7.3). **Zuordnung zu den PRs** @@ -65,12 +65,12 @@ Nicht im Scope (Issue): Barcode-Suche, Offline-Katalog (O7), Empfehlungen. Ebenf |---|---| | 3a Backend | 1, 2, 3, 4, 5, 8 (Datenbankanteil, pgTAP); 7 (Datenbank-Budget, B5) | | 3b App | 1–6, 8 (App-Anteil, Unit/Widget); KAT-05 Krit. 10 (Platzhalter-Cover in der Trefferliste) | -| 3c E2E/Messung | 1–6, 8 (Web-E2E); 7 (Messung gegen Staging, Abnahme durch Chris) | +| 3c E2E/Messung | 1–6, 8 (Web-E2E); 7 (Messung nach Spec-F4) | **Relevante Querschnittskriterien** - **Sicherheit der Daten Krit. 2:** Suche ohne Anmeldung; die RPC ist für `anon` ausführbar und liefert nur Katalogspiele, nie private Spiele (auch nicht die eigenen). Nichts ist schreibbar. -- **Datenschutz (QUE-04):** Suchbegriffe werden nicht gespeichert und nicht protokolliert (weder App-Logger noch Datenbank). Die RPC wird per `POST` aufgerufen (Standard von `supabase_flutter`), der Suchbegriff steht damit nicht in Zugriffsprotokollen von URLs. Keine Such-Analytik. +- **Datenschutz (QUE-04):** Suchbegriffe werden nicht gespeichert und nicht protokolliert (weder App-Logger noch Datenbank; B5 prüft die Protokoll-Einstellungen der Datenbank). Die RPC wird per `POST` aufgerufen (Standard von `supabase_flutter`), der Suchbegriff steht damit nicht in Zugriffsprotokollen von URLs. Keine Such-Analytik. - **Sprachen (QUE-05) Krit. 2, 3:** alle Texte aus ARB (de/en); Genre- und Mechaniknamen nach Oberflächensprache (`name_de`/`name_en`); Trefferzahl mit Pluralform. - **Barrierefreiheit (QUE-06) Krit. 1–4:** Suchfeld und Filter beschriftet, Trefferzahl wird angesagt, Touch-Ziele ≥ 48 dp, 200 % Schrift, Tastaturbedienung im Web inkl. „Weitere laden“. - **Zustände, Fehler und Netz Krit. 1–3:** Lade-, Leer-, Fehler- (mit „Erneut versuchen“) und Normalzustand; Offline-Hinweis; Suchbegriff und Filter bleiben bei Fehlern erhalten. @@ -83,9 +83,9 @@ Nicht im Scope (Issue): Barcode-Suche, Offline-Katalog (O7), Empfehlungen. Ebenf | Bedarf | Bestand nach 2a | Handlung | |---|---|---| | Nur sichtbare Katalogspiele (KAT-05 Krit. 4) | View `catalog_games` | RPC liest darüber (E1) | -| Normalisierter Titel (Krit. 1) | Index `games_catalog_listed_idx` auf `lower(title)` | `search_normalize`, generierte Spalte `games.title_search` + Trigramm-Index (bestehender Index bleibt unverändert) | +| Normalisierter Titel (Krit. 1) | Index `games_catalog_listed_idx` auf `lower(title)` | `search_normalize`, generierte Spalte `games.title_search` (bestehender Index bleibt unverändert) | | Alternative Titel mit Sprache (Krit. 2) | – (CSV-Spalte `alt_titles` reserviert, Spec #2 4.3) | Tabelle `game_alt_titles` | -| Verlage (Krit. 3) | `publishers`, `game_publishers` | generierte Spalte `publishers.name_search` + Trigramm-Index | +| Verlage (Krit. 3) | `publishers`, `game_publishers` | generierte Spalte `publishers.name_search` | | Spielerzahl, Dauer, Alter (Krit. 4) | Spalten an `games` | keine | | Genre, Mechanik (Krit. 4, 8) | `genres`, `mechanics`, `game_genres`, `game_mechanics` mit Index auf `genre_id`/`mechanic_id` und `unique (game_id, …)` | keine | | Kennzeichnung manuell gepflegt | `manual_fields`, Trigger `game_links_mark_manual(tg_argv)` | Schlüssel `alt_titles` ergänzen, Trigger an `game_alt_titles` | @@ -96,12 +96,14 @@ Nur additiv. Es gilt die Rechte-Regel aus Spec #2 3.2: Jede neue Tabelle zuerst **Grants je Objekt (verbindlich)** -| Objekt | `anon` | `authenticated` | Hinweis | -|---|---|---|---| -| `game_alt_titles` | `select` | `select` | kein Schreibrecht für API-Rollen; Policy nur für `select` | -| `search_normalize(text)` | – | – | intern (generierte Spalten, RPC, später #13) | -| `search_catalog_games(…)` | **`execute`** | **`execute`** | **erste Funktion im Projekt, die API-Rollen ausdrücklich ausführen dürfen**; `service_role` und `public` bleiben entzogen | -| `games`, `publishers` | unverändert | unverändert | neue generierte Spalten sind über das bestehende Tabellenrecht `select` lesbar; die Spaltenrechte für `insert`/`update` (Spec #2 3.2) bleiben unverändert und enthalten die neuen Spalten nicht | +| Objekt | `anon` | `authenticated` | `service_role` | Hinweis | +|---|---|---|---|---| +| `game_alt_titles` | `select` | `select` | – | kein Schreibrecht für API-Rollen; Policy nur für `select` | +| `search_normalize(text)` | **`execute`** | **`execute`** | **`execute`** | rein rechnend, ohne Tabellenzugriff, deshalb auch als RPC harmlos. **Pflicht:** Generierte Spalten werden mit den Rechten des Schreibenden berechnet, und die RPC läuft als `security invoker`. Ohne `execute` scheitern Anlegen und Ändern privater Spiele durch Nutzer (`title_search`) und jede Suche als `anon`. | +| `search_catalog_games(…)` | **`execute`** | **`execute`** | – | erste Abfrage-RPC des Projekts für API-Rollen; `public` und `service_role` bleiben entzogen | +| `games`, `publishers` | unverändert | unverändert | unverändert | neue generierte Spalten sind über das bestehende Tabellenrecht `select` lesbar; die Spaltenrechte für `insert`/`update` (Spec #2 3.2) bleiben unverändert und enthalten die neuen Spalten nicht | + +Rechte auf `extensions.unaccent` und `extensions.similarity` bestehen für die API-Rollen bereits (Supabase-Standard für das Schema `extensions`); B5 bestätigt das im Test. **1. Erweiterungen** @@ -110,15 +112,38 @@ create extension if not exists pg_trgm with schema extensions; create extension if not exists unaccent with schema extensions; ``` -Beide gehören zum Supabase-Postgres-Image und zu PostgreSQL-contrib. **Unverifizierte Annahme (B5 prüft lokal und auf Staging mit `select * from pg_available_extensions where name in ('pg_trgm','unaccent')`):** Beide sind im self-hosted Image verfügbar und im Schema `extensions` anlegbar. Rückfall siehe R1. +Beide gehören zum Supabase-Postgres-Image und zu PostgreSQL-contrib. **Unverifizierte Annahme (B5 prüft lokal und – sobald es Staging gibt – dort mit `select * from pg_available_extensions where name in ('pg_trgm','unaccent')`):** Beide sind im self-hosted Image verfügbar und im Schema `extensions` anlegbar. Rückfall siehe R1. -**2. Funktion `public.search_normalize(p text) returns text`** +**2. Basisfunktion `public.search_normalize(p text) returns text`** (E3) + +> **Gemeinsame Normalisierung (gleichlautend in Spec #3 und Spec #13):** `public.search_normalize(text)` ist die einzige Basisfunktion für normalisierte Titel. Sie wird von #3 (PR 3a) angelegt, ist rein rechnend und für `anon`, `authenticated` und `service_role` ausführbar, weil generierte Spalten und `security invoker`-Funktionen sie mit den Rechten des Aufrufers berechnen. #13 legt `public.normalize_game_title(text)` darüber an und entfernt nur dort Artikel; `search_normalize` entfernt nie Artikel. - `language sql`, `immutable`, `strict`, `parallel safe`, `set search_path = ''`. -- Ablauf: `extensions.unaccent('extensions.unaccent'::regdictionary, p)` (die Variante mit ausdrücklichem Wörterbuch, damit die Funktion als `immutable` gelten darf) → `lower(…)` → `regexp_replace(…, '[^a-z0-9]+', ' ', 'g')` → `btrim(…)` → `nullif(…, '')`. -- Beispiele (Pflichtfälle im Test): `'Die Siedler von Catan'` → `'die siedler von catan'`; `'CATAN®'` → `'catan'`; `'7 Wonders: Duel'` → `'7 wonders duel'`; `'Café Größe'` → `'cafe grosse'`; `'Ticket to Ride – Europe'` → `'ticket to ride europe'`; `'!!!'` und `''` → `null`. +- Ablauf, in dieser Reihenfolge: + 1. Marken- und Schutzzeichen entfernen: `regexp_replace(p, '[®©™℗℠]', '', 'g')` (sonst macht `unaccent` daraus `(R)`, `(C)`, `TM` und „CATAN®“ würde zu `catan r`), + 2. `extensions.unaccent('extensions.unaccent'::regdictionary, …)` mit festem Wörterbuch, + 3. `lower(…)`, + 4. `regexp_replace(…, '[^a-z0-9]+', ' ', 'g')`, + 5. `btrim(…)`, + 6. `nullif(…, '')`. +- **Zu `immutable`:** `unaccent` selbst ist nur `stable`, weil das Wörterbuch geändert werden könnte. Der Wrapper mit ausdrücklich angegebenem Wörterbuch als `immutable` ist das übliche Vorgehen und funktioniert für generierte Spalten. Das verbleibende Risiko ist eine geänderte `unaccent.rules`-Datei nach einem Image-Upgrade; dafür gibt es eine Prüfabfrage (R6). +- **Brüche** (`½`, `¼`): bewusst so, wie `unaccent` sie umschreibt (`½` → `1/2` → `1 2`). Eine Suche nach „1/2“ oder „1 2“ findet sie; eine Sonderbehandlung gibt es nicht. +- **Apostroph** (`'`, `’`): wird wie jedes Sonderzeichen zu einem Leerzeichen (`Æon’s End` → `aeon s end`). Die Suche „aeons end“ findet den Titel trotzdem über die Regel ohne Leerzeichen (`qc`, § 4.2). +- **Pflichtfälle im Test:** + + | Eingabe | Ergebnis | + |---|---| + | `'Die Siedler von Catan'` | `'die siedler von catan'` | + | `'CATAN®'` | `'catan'` | + | `'Spiel™ ½'` | `'spiel 1 2'` | + | `'Æon’s End'` | `'aeon s end'` (Suche `'aeons end'` trifft über `qc`) | + | `'7 Wonders: Duel'` | `'7 wonders duel'` | + | `'Café Größe'` | `'cafe grosse'` | + | `'Ticket to Ride – Europe'` | `'ticket to ride europe'` | + | `'!!!'`, `''` | `null` | + - Zeichen außerhalb des lateinischen Alphabets (z. B. japanische Titel) werden entfernt; in R1 gibt es nur deutsche und englische Titel (Spec-F7). -- **Regel für spätere Änderungen:** Ändert sich die Funktion, müssen die generierten Spalten neu berechnet werden (Spalte in einer neuen Migration entfernen und neu anlegen). Steht als Kommentar an der Funktion. +- **Regel für spätere Änderungen:** Ändert sich die Funktion (oder das Wörterbuch, R6), müssen die generierten Spalten neu berechnet werden (Spalte in einer neuen Migration entfernen und neu anlegen). Steht als Kommentar an der Funktion. **3. Generierte Spalten und Indizes** @@ -128,7 +153,10 @@ Beide gehören zum Supabase-Postgres-Image und zu PostgreSQL-contrib. **Unverifi | `publishers` | `name_search` | `text generated always as (public.search_normalize(name)) stored` | `publishers_name_search_trgm_idx using gin (name_search extensions.gin_trgm_ops)` | | `game_alt_titles` | `title_search` | wie `games.title_search` | `game_alt_titles_title_search_trgm_idx using gin (title_search extensions.gin_trgm_ops)` | -Hinweise: Das Anlegen einer gespeicherten Spalte schreibt `games` einmal neu (unkritisch bei der Größe). Generierte Spalten kann kein Client setzen. Der Trigger `games_mark_manual_fields` arbeitet mit einer festen Spaltenliste und ist nicht betroffen. Die View `catalog_games` bleibt unverändert; die RPC verbindet sie per Primärschlüssel mit `games`, um `title_search` zu lesen. +Hinweise: + +- **Die Indizes sind keine Begründung für die Geschwindigkeit.** Bei Suchwörtern mit 2 Zeichen, bei der Regel ohne Leerzeichen (`replace(…)`), bei `or` über `exists`-Unterabfragen und mit den RLS-Prüfungen für `anon` nutzt der Planer sie weitgehend nicht. Bei der Größe von R1 reicht ein sequenzieller Scan; maßgeblich ist die Messung in B5 (§ 4.2 Leistung). Der Partial-Index auf `games` passt nur zu Abfragen mit den Bedingungen der View `catalog_games` (Kommentar in der RPC). +- Das Anlegen einer gespeicherten Spalte schreibt `games` einmal neu (unkritisch bei der Größe). Generierte Spalten kann kein Client setzen. Der Trigger `games_mark_manual_fields` arbeitet mit einer festen Spaltenliste und ist nicht betroffen. Die View `catalog_games` bleibt unverändert; die RPC verbindet sie per Primärschlüssel mit `games`, um `title_search` zu lesen. **4. Neue Tabelle `game_alt_titles`** (Klärung 3, E2) @@ -144,6 +172,7 @@ Hinweise: Das Anlegen einer gespeicherten Spalte schreibt `games` einmal neu (un - `unique (game_id, locale, title_search)` (Konfliktziel für das Startbestand-Werkzeug; derselbe Titel in derselben Sprache nur einmal, unabhängig von Schreibweise). Mehrere Titel je Sprache sind erlaubt (z. B. alter und neuer deutscher Titel). - Der Index der Unique-Constraint deckt Abfragen nach `game_id` ab; kein zusätzlicher Index nötig. - Trigger `game_links_mark_manual after insert or update or delete … execute function public.game_links_mark_manual('alt_titles')` (Funktion aus 2a, generisch über `tg_argv`, braucht nur `game_id`). +- `game_alt_titles.title_search` ist auch die verbindliche Quelle für alternative Titel in der Ähnlichkeitsprüfung von #13 (`find_similar_catalog_games`, 9.5). **RLS in Klartext** @@ -153,25 +182,25 @@ Hinweise: Das Anlegen einer gespeicherten Spalte schreibt `games` einmal neu (un **5. `manual_fields`-Schlüssel ergänzen** -`alter table public.games drop constraint games_manual_fields_check, add constraint games_manual_fields_check check (manual_fields <@ array[…]::text[])` mit der **vollständigen** Liste aus 2a plus `'alt_titles'`. +`alter table public.games drop constraint games_manual_fields_check, add constraint games_manual_fields_check check (manual_fields <@ array[…]::text[])` mit der **vollständigen** Liste von `main` plus `'alt_titles'`. -**Regel für alle Stories, die Schlüssel ergänzen (#3, #12, 2b, Import):** Jede Migration nennt die vollständige Liste aller bis dahin bekannten Schlüssel. Wer später merged, gleicht die Liste mit `main` ab (sonst entfernt die spätere Migration einen Schlüssel, und bestehende Zeilen verletzen den Check). +**Regel für alle Stories, die Schlüssel ergänzen (#3, #12, 2b, 2c, Import):** Jede Migration nennt die vollständige Liste aller bis dahin bekannten Schlüssel. Wer später merged, gleicht die Liste mit `main` ab (sonst entfernt die spätere Migration einen Schlüssel, und bestehende Zeilen verletzen den Check). **6. RPC `public.search_catalog_games`** – Vertrag in § 4.2. ### 3.3 Reihenfolge zu 2b und 2c (Spec #2) -**Merge-Reihenfolge: 2b → 2c → 3a.** 2b (`admin_merge_games`, `repoint_game_references`, Branch `feature/2b-katalog-zusammenfuehren`) und 2c (`game_reports`, Erweiterung des Fremdschlüssel-Wächters) sind in Arbeit; 3a wird danach gemergt. Folgen für 3a: +**3a wird nach 2b gemergt.** 2b (`admin_merge_games`, `repoint_game_references`, Fremdschlüssel-Wächter in `07_catalog_merge.test.sql`, Branch `feature/2b-katalog-zusammenfuehren`) ist in Arbeit; 2c (`game_reports`) existiert noch nicht auf `origin`. Ob 2c vor oder nach 3a gemergt wird, ist offen. **Wer von 2c und 3a später merged, gleicht ab:** -- **Migration:** Zeitstempel von `_catalog_search.sql` liegt nach den Migrationen von 2b und 2c. B1 erstellt bzw. aktualisiert den Branch von `main` nach dem Merge von 2c (bei früherem Start: vor dem PR auf `main` aktualisieren und den Zeitstempel bei Bedarf neu vergeben, solange 3a nicht gemergt ist). -- **`manual_fields`-Check:** vollständige Liste von `main` übernehmen, `alt_titles` ergänzen (3.2 Nr. 5). -- **Fremdschlüssel-Wächter in `07_catalog_merge.test.sql`:** Die neue FK `game_alt_titles.game_id` macht den Wächter rot. 3a ergänzt sie im `set_eq` in der Gruppe **„bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag“** (neben `game_genres`, `game_mechanics`, `game_categories`, `game_publishers`), gemäß Spec-F6 A. `admin_merge_games`/`repoint_game_references` bleiben unverändert. Die Einträge von 2c (`game_reports.target_game_id` behandelt, `game_reports.provisional_game_id` ausgelassen) bleiben erhalten. -- **`00_schema.test.sql`:** Tabellenzahl von `main` (nach 2c) + 1. -- **pgTAP-Nummer:** `09_catalog_search.test.sql` (07 = 2b, 08 = 2c). +- **Fremdschlüssel-Wächter in `07_catalog_merge.test.sql`:** 3a trägt `('game_alt_titles.game_id')` im `set_eq` in die Gruppe **„bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag“** ein (neben `game_genres`, `game_mechanics`, `game_categories`, `game_publishers`), gemäß Spec-F6 A. 2c ergänzt `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (ausgelassen). Der spätere PR übernimmt die Einträge des früheren. `admin_merge_games`/`repoint_game_references` bleiben für 3a unverändert. +- **`00_schema.test.sql`:** Tabellenzahl von `main` beim Merge + 1 (3a: `game_alt_titles`; 2c: `game_reports`). +- **`manual_fields`-Check:** vollständige Liste von `main` übernehmen (3.2 Nr. 5). +- **Migration:** Zeitstempel von `_catalog_search.sql` liegt nach den Migrationen, die beim Merge auf `main` sind; vor dem PR auf `main` aktualisieren und den Zeitstempel bei Bedarf neu vergeben, solange 3a nicht gemergt ist. +- **pgTAP-Nummern:** `07` = 2b, `08` = 2c, `09` = #3 (`09_catalog_search.test.sql`), `10` = #13. ### 3.4 Doku zum Datenmodell -`docs/datenmodell-r1.md`: neue Tabelle `game_alt_titles`, generierte Spalten, Funktion `search_normalize`, RPC `search_catalog_games`, Erweiterungen, neuer `manual_fields`-Schlüssel; Abschnitt „API für das Frontend“ um die RPC ergänzen. `supabase/README.md`: Tabellenzahl, Hinweis „erste RPC mit `execute` für `anon`“. `docs/katalog-pflege.md`: Abschnitt „Alternative Titel pflegen“ (Studio-SQL und CSV-Spalte `alt_titles`). +`docs/datenmodell-r1.md`: neue Tabelle `game_alt_titles`, generierte Spalten, Funktion `search_normalize` (mit dem gemeinsamen Satz aus 3.2 Nr. 2), RPC `search_catalog_games`, Erweiterungen, neuer `manual_fields`-Schlüssel; Abschnitt „API für das Frontend“ um die RPC ergänzen. `supabase/README.md`: Tabellenzahl, Hinweis „`search_normalize` und `search_catalog_games` sind für API-Rollen ausführbar“. `docs/katalog-pflege.md`: Abschnitte „Alternative Titel pflegen“ (Studio-SQL und CSV-Spalte `alt_titles`), „Nach einem Image-Upgrade: Normalisierung prüfen“ (R6) und im Abschnitt Zusammenführen der Schritt aus 9.5. ## 4. Backend @@ -180,7 +209,7 @@ Hinweise: Das Anlegen einer gespeicherten Spalte schreibt `games` einmal neu (un | Objekt | Art | PR | Aufrufbar von | |---|---|---|---| | `pg_trgm`, `unaccent` | Erweiterungen (Schema `extensions`) | 3a | – | -| `search_normalize` | interne Funktion | 3a | nur Eigentümer (generierte Spalten, RPC) | +| `search_normalize` | Basisfunktion | 3a | `anon`, `authenticated`, `service_role` (generierte Spalten, RPC, #13) | | `game_alt_titles` | Tabelle | 3a | Lesen: alle; Schreiben: Studio | | `title_search`, `name_search` | generierte Spalten | 3a | Lesen über bestehende Rechte | | `search_catalog_games` | RPC | 3a | `anon`, `authenticated` | @@ -190,20 +219,20 @@ Keine Edge Function. ### 4.2 RPC `public.search_catalog_games` (Vertrag zwischen 3a und 3b) -`language plpgsql`, `stable`, `security invoker`, `set search_path = ''`. Alle Objekte schema-qualifiziert (`public.…`, `extensions.similarity(…)`). Aufruf über PostgREST `POST /rest/v1/rpc/search_catalog_games` (in Dart `client.rpc('search_catalog_games', params: {...})`). +`language plpgsql`, `stable`, `security invoker`, `set search_path = ''`. Alle Objekte schema-qualifiziert (`public.…`, `extensions.similarity(…)`). Aufruf über PostgREST `POST /rest/v1/rpc/search_catalog_games` (in Dart `client.rpc('search_catalog_games', params: {...})`). Kommentar in der Funktion: Der Partial-Index `games_title_search_trgm_idx` ist nur über die Bedingungen der View nutzbar. **Eingabe** (alle optional, `null` = nicht gesetzt) | Parameter | Typ | Zulässig | Bedeutung | |---|---|---|---| -| `p_query` | text | ≤ 100 Zeichen | Suchbegriff. `null` oder nur Leerzeichen = kein Suchbegriff. Ergibt die Normalisierung `null` (z. B. `'!!!'`), gibt es **keine Treffer**. | +| `p_query` | text | ≤ 100 Zeichen, ≤ 10 Wörter nach Normalisierung | Suchbegriff. `null` oder nur Leerzeichen = kein Suchbegriff. Ergibt die Normalisierung `null` (z. B. `'!!!'`), gibt es **keine Treffer**. Hat der normalisierte Begriff weniger als 2 Zeichen, gilt er als **kein Suchbegriff**. | | `p_players` | smallint | 1–99 | Spielerzahl n: passt, wenn `min_players <= n <= max_players` (beide bekannt) | -| `p_max_minutes` | smallint | 1–1440 | „höchstens X Minuten“: passt, wenn `coalesce(max_minutes, min_minutes) <= X` (mindestens einer bekannt) | +| `p_max_minutes` | smallint | 1–1440 | „höchstens X Minuten“: passt, wenn `coalesce(max_minutes, min_minutes) <= X`; es genügt eine der beiden Angaben (nur `max_minutes` oder nur `min_minutes` bekannt zählt) | | `p_age` | smallint | 0–99 | „für Spielende ab X Jahren geeignet“: passt, wenn `min_age <= X` (bekannt) | | `p_genre_ids` | uuid[] | ≤ 20 Einträge | Spiel hat **jedes** dieser Genres (Spec-F1; Duplikate werden entfernt; unbekannte IDs ergeben 0 Treffer) | | `p_mechanic_ids` | uuid[] | ≤ 20 Einträge | wie Genres | | `p_limit` | integer | 1–50, Standard 30 | Seitengröße | -| `p_offset` | integer | 0–10 000, Standard 0 | Versatz | +| `p_offset` | integer | 0–10 000, Standard 0 | Versatz. Die App lädt nicht über 10 000 hinaus nach (`hasMore` wird dort `false`). | Die Semantik von Spielerzahl, Dauer und Alter steht unter Vorbehalt von Spec-F2. @@ -215,7 +244,7 @@ Die Semantik von Spielerzahl, Dauer und Alter steht unter Vorbehalt von Spec-F2. | `title` | text | Haupttitel | | `year` | smallint | nullable | | `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age` | smallint | nullable | -| `image_path` | text | aus `catalog_games` (maskiert nach Spec #2 E6); im Startbestand immer `null` | +| `image_path` | text | **nur aus `catalog_games`** (maskiert nach Spec #2 E6), nie aus `games`; im Startbestand immer `null`. Ein Speicherpfad, keine URL; die Umwandlung in eine URL kommt mit der Dateizugriffsschicht (Storage), in #3 bleibt `GameCover.imageUrl` immer `null`. | | `publisher_names` | text[] | alle Verlage, sortiert nach `name_search`, leer statt `null` | | `matched_alt_title` | text | der alternative Titel, über den das Spiel besser getroffen wurde als über den Haupttitel; sonst `null` (Anzeige „auch: …“) | | `match_rank` | smallint | Relevanzstufe 0–4 (unten), `null` ohne Suchbegriff; für Tests und Fehlersuche, die App zeigt sie nicht an | @@ -223,10 +252,12 @@ Die Semantik von Spielerzahl, Dauer und Alter steht unter Vorbehalt von Spec-F2. Beschreibung, Genres und Mechaniken sind nicht enthalten (Listenansicht, kleine Antwort). Die Detailseite (#4) liest sie selbst. -**Treffer-Regel** (E4), mit `q := search_normalize(p_query)`, Wörter `w := string_to_array(q, ' ')` (höchstens die ersten 10), `qc := replace(q, ' ', '')`: +**Treffer-Regel** (E4), mit `q := search_normalize(p_query)`, `qc := replace(q, ' ', '')` und den Wörtern `w :=` alle Wörter aus `string_to_array(q, ' ')` **mit mindestens 2 Zeichen** (Wörter mit 1 Zeichen werden verworfen): +- `char_length(q) < 2` → kein Suchbegriff (wie `null`). +- Mehr als 10 Wörter in `q` → Fehler `search_query_too_long` (kein stilles Abschneiden). - Kandidaten-Titel eines Spiels: `games.title_search` und alle `game_alt_titles.title_search`. -- Ein Spiel trifft, wenn **jedes** Wort aus `w` als Teilzeichenkette (`like '%' || wort || '%'`; Wörter enthalten nur `a–z0–9`, ein Escapen entfällt) in einem Kandidaten-Titel **oder** in einem `publishers.name_search` des Spiels vorkommt, **oder** wenn `qc` Teilzeichenkette eines Kandidaten-Titels ohne Leerzeichen ist. +- Ein Spiel trifft, wenn **jedes** Wort aus `w` als Teilzeichenkette (`like '%' || wort || '%'`; Wörter enthalten nur `a–z0–9`, ein Escapen entfällt) in einem Kandidaten-Titel **oder** in einem `publishers.name_search` des Spiels vorkommt, **oder** wenn `qc` Teilzeichenkette eines Kandidaten-Titels ohne Leerzeichen ist. Ist `w` nach dem Verwerfen leer (z. B. „a b“), trifft nur die Regel mit `qc`. - Ein Spiel erscheint immer genau einmal, auch wenn mehrere Titel treffen. **Relevanz** (E5): `match_rank` ist die beste Stufe über alle Kandidaten-Titel. @@ -247,29 +278,33 @@ Sortierung: `match_rank` aufsteigend, dann `extensions.similarity(title_search, | Fall | Meldung | |---|---| -| `p_query` länger als 100 Zeichen | `search_query_too_long` | +| `p_query` länger als 100 Zeichen oder mehr als 10 Wörter | `search_query_too_long` | | `p_players`, `p_max_minutes` oder `p_age` außerhalb des Bereichs; mehr als 20 IDs in einem Array | `search_filter_invalid` | | `p_limit` oder `p_offset` außerhalb des Bereichs | `search_paging_invalid` | -Die App verhindert diese Fälle schon bei der Eingabe; tritt einer trotzdem auf, ist das ein Programmfehler (Log, allgemeiner Fehlerzustand). +Die App verhindert diese Fälle schon bei der Eingabe (Feld begrenzt auf 100 Zeichen; mehr als 10 Wörter sind in der Praxis nicht zu erwarten und landen als Programmfehler im allgemeinen Fehlerzustand); tritt einer trotzdem auf, wird er geloggt (ohne Suchbegriff). -**Sicherheit:** `security invoker` – für `anon` gelten die Policies von `games`, `catalog_sources`, `game_publishers`, `publishers`, `game_alt_titles`, `game_genres`, `game_mechanics`. Die Eingabegrenzen begrenzen die Last je Aufruf; zusätzlich greift das `statement_timeout` der Rollen `anon`/`authenticated` (Supabase-Standard; B5 prüft den Wert im Self-Hosting, Ergebnis in `docs/katalog-pflege.md` bzw. `supabase/README.md`). Eine Mengenbegrenzung je Client gibt es in R1 nicht (R4). +**Sicherheit:** `security invoker` – für `anon` gelten die Policies von `games`, `catalog_sources`, `game_publishers`, `publishers`, `game_alt_titles`, `game_genres`, `game_mechanics`. Die Eingabegrenzen begrenzen die Last je Aufruf. Zusätzlich gilt das `statement_timeout` der Rollen (lokal: `anon` 3 s, `authenticated` 8 s; B5 prüft die Werte im Self-Hosting). Ein `set statement_timeout` an der Funktion selbst wirkt **nicht** (die Einstellung gilt nur für die Anweisung, die vor dem Funktionsaufruf startet) und wird deshalb nicht verwendet. Das eigentliche Risiko sind viele parallele Aufrufe ohne Anmeldung (R4): Eine Mengenbegrenzung je IP (Kong-Plugin `rate-limiting` auf `/rest/v1/rpc/search_catalog_games` oder `limit_req` im vorgelagerten Proxy) wird **vor dem Go-live** in D1 bzw. im Hosting-Runbook entschieden; für R1.1 ist sie kein Muss. -**Leistung (E12):** Ziel für die Ausführung in der Datenbank: p95 ≤ 150 ms bei 10 000 sichtbaren Katalogspielen, 20 000 alternativen Titeln, 2 000 Verlagen und je Spiel zwei Genres und drei Mechaniken. B5 misst lokal mit synthetischen Daten (Skript `supabase/tests/perf/catalog_search_perf.sql`, nicht Teil von `supabase test db`, läuft nie gegen Staging/Produktion) und dokumentiert `explain (analyze, buffers)` für typische Fälle im PR. Wird das Ziel verfehlt: Rückfall in R2. +**Leistung (E12):** Ziel für die Ausführung in der Datenbank: p95 ≤ 150 ms bei 10 000 sichtbaren Katalogspielen, 20 000 alternativen Titeln, 2 000 Verlagen und je Spiel zwei Genres und drei Mechaniken. B5 misst **als `anon` mit RLS** (`set local role anon`) lokal mit synthetischen Daten (Skript `supabase/tests/perf/catalog_search_perf.sql`, nicht Teil von `supabase test db`, läuft nie gegen Staging/Produktion) und dokumentiert `explain (analyze, buffers)` für typische Fälle im PR (Titelteil, 2-Zeichen-Begriff, Begriff ohne Leerzeichen, Verlag, nur Filter, ohne alles). Wird das Ziel verfehlt, in dieser Reihenfolge: + +1. **Kandidaten-CTE:** Vorauswahl der Spiel-IDs per `union` über die GIN-Indizes (nur Wörter ≥ 3 Zeichen, sonst ohne Vorauswahl), danach Treffer-Regel und Relevanz nur auf den Kandidaten. +2. **Generierte Spalte `title_compact`** (`replace(search_normalize(title), ' ', '')`) an `games` und `game_alt_titles` mit Trigramm-Index, damit die Regel mit `qc` indexierbar wird. +3. Rückfall R2 (zusammengefasster Suchtext je Spiel). ### 4.3 Startbestand-Werkzeug: Spalte `alt_titles` auswerten Erweiterung von `supabase/catalog/build_startbestand.ts` (Spec #2 4.3, E9). Die Spalte ist schon reserviert. -- **Format:** `de:Titel;en:Title`. Einträge durch `;` getrennt; je Eintrag Sprache und Titel am **ersten** `:` getrennt (Titel dürfen `:` enthalten, z. B. `de:Catan: Das Duell`). Leerzeichen am Rand werden entfernt. -- **Prüfungen** (Abbruch mit Zeilennummer und Grund): Sprache nicht `de`/`en`; leerer Titel; Titel länger als 200 Zeichen; derselbe Titel in derselben Sprache doppelt; alternativer Titel gleich dem Haupttitel. „Gleich“ heißt im Skript: Kleinbuchstaben, diakritische Zeichen entfernt (Unicode NFD), alles außer `a–z0–9` zu Leerzeichen – eine Annäherung an `search_normalize`; maßgeblich bleibt die Unique-Constraint in der Datenbank. +- **Format:** `de:Titel;en:Title`. Einträge durch `;` getrennt; je Eintrag Sprache und Titel am **ersten** `:` getrennt (Titel dürfen `:` enthalten, z. B. `de:Catan: Das Duell`). Leerzeichen am Rand werden entfernt. **`;` ist in alternativen Titeln nicht erlaubt** (kein Escaping); betroffene Titel pflegt Chris im Studio. +- **Prüfungen** (Abbruch mit Zeilennummer und Grund): Sprache nicht `de`/`en`; Eintrag ohne `:`; leerer Titel; Titel länger als 200 Zeichen; derselbe Titel in derselben Sprache doppelt; alternativer Titel gleich dem Haupttitel. „Gleich“ heißt im Skript: Marken- und Schutzzeichen entfernt, Kleinbuchstaben, diakritische Zeichen entfernt (Unicode NFD), alles außer `a–z0–9` zu Leerzeichen – eine Annäherung an `search_normalize`; maßgeblich bleibt die Unique-Constraint in der Datenbank. - **Erzeugte SQL je Startbestandsspiel** (Abgleich wie bei den Zuordnungen, Spec #2 4.3 Idempotenz Regel 3): 1. `delete from public.game_alt_titles a where a.game_id = and (a.locale, a.title_search) not in (select locale, public.search_normalize(title) from (values …) v(locale, title))`, 2. `insert into public.game_alt_titles (game_id, locale, title) values … on conflict (game_id, locale, title_search) do update set title = excluded.title where game_alt_titles.title is distinct from excluded.title`. Unveränderte Zeilen bleiben unberührt (`updated_at` und `manual_fields` stabil). Spiele ohne `alt_titles` bekommen nur Schritt 1 (entfernt also früher gesetzte Titel). - Alternative Titel bekommen **keine** abgeleitete ID (wie Verlage); Konfliktziel ist die Unique-Constraint. - `checks.sql`: keine neue Pflichtregel (alternative Titel sind optional); eine Informationszeile „Startbestandsspiele mit alternativen Titeln: n“. -- Die erzeugten Dateien (`startbestand.sql`, Testdaten unter `supabase/catalog/testdata/`, `supabase/tests/support/*.inc`) werden neu erzeugt; der CI-Schritt „erzeugte Dateien aktuell“ verlangt das. Die Testdaten-CSV bekommt bei einem Testspiel zwei alternative Titel (de und en). +- Die erzeugten Dateien (`startbestand.sql`, Testdaten unter `supabase/catalog/testdata/`, `supabase/tests/support/*.inc`) werden neu erzeugt; der CI-Schritt „erzeugte Dateien aktuell“ verlangt das. Die Testdaten-CSV bekommt bei **Testspiel B** zwei alternative Titel (de und en), nicht bei Testspiel A (§ 7, Auswirkung auf `06`). - **Bestehender Deno-Test bricht absichtlich:** `"facts_source, alt_titles und base_game_key landen nicht in der SQL"` (`build_startbestand_test.ts`, Z. 253) wird zu „`facts_source` und `base_game_key` landen nicht in der SQL“ geändert; neue Tests für `alt_titles` (§ 7.1). - Das Einspielen markiert `alt_titles` in `manual_fields` (Trigger, Spec #2 SB8). Startbestand ab Go-live eingefroren (Spec #2 Spec-F6): Pflegt Chris alternative Titel vorher, kommen sie über die CSV; danach nur über Studio. @@ -299,12 +334,12 @@ app/lib/features/catalog/ search/widgets/… Suchfeld, Filterbereich, Treffer-Kachel, Listenfuß, Leerzustand ``` -Domäne ohne Flutter- und Supabase-Importe (`freezed_annotation` ist erlaubt). Datenklassen **ohne `toJson`** (E9); das Feature schreibt nichts in die Datenbank. +Domäne ohne Flutter- und Supabase-Importe (`freezed_annotation` ist erlaubt), Domänenmodelle ohne JSON. **DTOs nur mit `json_serializable`** (`createToJson: false`, kein freezed-JSON); das Feature schreibt nichts in die Datenbank (E9). ### 5.2 Domänenmodelle und Repository -- **`CatalogGameSummary`:** `id`, `title`, `year?`, `minPlayers?`, `maxPlayers?`, `minMinutes?`, `maxMinutes?`, `minAge?`, `imagePath?`, `publisherNames` (`List`, nie `null`), `matchedAltTitle?`. **Abweichung von Spec #2 § 5:** Dort heißt es, KAT-01 lege `CatalogGame` an. Die Suche braucht nur die Listenansicht; das vollständige Modell `CatalogGame` (mit Beschreibung, Genres, Quellenangabe) legt KAT-02 (#4) an. -- **`CatalogSearchQuery`:** `text` (getrimmt, ≤ 100 Zeichen), `filters` (`players?`, `maxMinutes?`, `minAgeSuitable?`, `genreIds` `Set`, `mechanicIds` `Set`). Hilfen: `hasText` (≥ 2 Zeichen nach Trim), `hasFilters`, `activeFilterCount`, `isEmpty`. Zulässige Werte als Konstanten (Spielerzahl 1–12, Dauer-Stufen 30/45/60/90/120/180, Alters-Stufen 6/8/10/12/14/16/18; Spec-F2). +- **`CatalogGameSummary`:** `id`, `title`, `year?`, `minPlayers?`, `maxPlayers?`, `minMinutes?`, `maxMinutes?`, `minAge?`, `imagePath?`, `publisherNames` (`List`, nie `null`), `matchedAltTitle?`. `imagePath` ist ein Speicherpfad aus `catalog_games`; in #3 wird daraus keine URL erzeugt (`GameCover.imageUrl` bleibt `null`, Platzhalter). **Abweichung von Spec #2 § 5:** Dort heißt es, KAT-01 lege `CatalogGame` an. Die Suche braucht nur die Listenansicht; das vollständige Modell `CatalogGame` (mit Beschreibung, Genres, Quellenangabe) legt KAT-02 (#4) an. +- **`CatalogSearchQuery`:** `text` (getrimmt, ≤ 100 Zeichen), `filters` (`players?`, `maxMinutes?`, `minAgeSuitable?`, `genreIds` `Set`, `mechanicIds` `Set`). Hilfen: `hasText` (≥ 2 Zeichen nach Trim), `hasFilters`, `activeFilterCount`, `isEmpty`. Zulässige Werte als Konstanten (Spielerzahl 1–12, Dauer-Stufen 30/45/60/90/120/180, Alters-Stufen 6/8/10/12/14/16/18; Spec-F2; höchstens 20 Genres bzw. Mechaniken). - **`CatalogTerm`:** Genre bzw. Mechanik; `localizedName('de')` → `nameDe`, sonst `nameEn` (QUE-05 Krit. 4: Englisch als Rückfall). - **`CatalogFailure`:** `network` (keine Verbindung, Zeitüberschreitung), `invalidInput` (P0001 aus § 4.2, Programmfehler), `unexpected`. - **`CatalogRepository`:** @@ -320,7 +355,7 @@ Domäne ohne Flutter- und Supabase-Importe (`freezed_annotation` ist erlaubt). D ### 5.4 Zustand und Verhalten -**`CatalogSearchState`** (freezed): `query`, `status` (`loading` | `data` | `empty` | `error`), `items`, `totalCount`, `failure?`, `refreshing` (neue Suche läuft, alte Treffer bleiben sichtbar), `loadingMore`, `loadMoreFailure?`. Abgeleitet: `hasMore = items.length < totalCount`. +**`CatalogSearchState`** (freezed): `query`, `status` (`loading` | `data` | `empty` | `error`), `items`, `totalCount`, `failure?`, `refreshing` (neue Suche läuft, alte Treffer bleiben sichtbar), `loadingMore`, `loadMoreFailure?`. Abgeleitet: `hasMore = items.length < totalCount && items.length < 10000`. **Methoden des Controllers** @@ -340,7 +375,7 @@ Domäne ohne Flutter- und Supabase-Importe (`freezed_annotation` ist erlaubt). D - **`AppRoutes.catalog = '/catalog'`**, öffentlich (nicht in `privateRoutePaths`). Registrierung über `catalogRoutes` in `app_router.dart` (Muster `authRoutes`). - **Query-Parameter:** `q` (Suchbegriff), `players`, `maxMinutes`, `age`, `genre` und `mechanic` (IDs, durch Komma getrennt). Ungültige oder unbekannte Werte werden still ignoriert (geparst in `presentation`, geprüft gegen die Konstanten der Domäne). -- **URL aktualisieren (Web):** Nach jeder ausgeführten Suche wird die URL ohne neuen Verlaufseintrag ersetzt, sodass Neuladen und Teilen den Stand erhalten. flutter-dev prüft mit go_router 18, dass dabei weder Seite noch Controller neu entstehen (Zustand, Scrollposition, Fokus im Suchfeld). **Falls doch:** Das Schreiben der URL entfällt, nur das Lesen beim Öffnen bleibt; Abweichung im PR begründen. +- **URL aktualisieren (Web):** Nach jeder ausgeführten Suche wird die URL ohne neuen Verlaufseintrag und ohne neue Navigation ersetzt, sodass Neuladen und Teilen den Stand erhalten. Eingeplant ist `Router.neglect` zusammen mit `SystemNavigator.routeInformationUpdated(uri: …, replace: true)` statt eines `context.go`/`replace`, damit weder Seite noch Controller neu entstehen (Zustand, Scrollposition, Fokus im Suchfeld). flutter-dev prüft das mit go_router 18. **Falls es nicht zuverlässig funktioniert:** Das Schreiben der URL entfällt, nur das Lesen beim Öffnen bleibt; Abweichung im PR begründen. - **Startseite:** Der bestehende Platzhalter `HomeScreen` bekommt eine Schaltfläche „Spiele suchen“ → `/catalog`. Die Startseite ohne Anmeldung (Suche direkt auf `/`) baut #29 (QUE-09 Krit. 13); dafür ist der Inhalt des Screens als eigenes Widget `CatalogSearchView` gekapselt. - **Antippen eines Treffers:** Die Kachel hat einen `onTap`-Parameter; in #3 ist er `null` (Kachel nicht interaktiv, kein toter Link). KAT-02 (#4) verdrahtet die Navigation zur Detailseite. @@ -353,9 +388,9 @@ Aufbau (Details und Texte liefert ui-designer in U1, Komponenten ins Design-Syst - kompakt (< 600 dp) und mittel (600–1023 dp): Schaltfläche „Filter“ mit Anzahl aktiver Filter; öffnet ein Bottom Sheet. Änderungen wirken sofort (Liste dahinter aktualisiert sich), „Fertig“ schließt. - groß (≥ 1024 dp, `AppWidthClass` aus `app_spacing.dart`): feste Seitenleiste links (ca. 320 dp), Trefferliste rechts mit begrenzter Breite (Plattformen Krit. 2). - Inhalte: Spielerzahl (Auswahl „beliebig“, 1–12), Spieldauer (Einfachauswahl „bis 30/45/60/90/120/180 Min.“), Alter (Einfachauswahl „ab 6 … ab 18 Jahren geeignet“), Genre und Mechanik (Mehrfachauswahl als Filter-Chips, nach lokalisiertem Namen sortiert), „Filter zurücksetzen“. Die genaue Beschriftung von Alter und Dauer folgt Spec-F2. -- **Trefferzahl** über der Liste („23 Spiele“, ARB-Plural) und **Ansage** für Screenreader nach jeder abgeschlossenen Suche (Live-Region bzw. aktuelle Flutter-API für Ansagen). -- **Treffer-Kachel:** `GameCover` klein mit `titleVisibleNearby: true` (Platzhalter, KAT-05 Krit. 10), Titel (max. 2 Zeilen), Jahr, Verlage (bis zu zwei, sonst „+n“), Kurzangaben Spielerzahl („2–4 Spieler“), Dauer („30–60 Min.“), Alter („ab 10“); fehlende Angaben werden weggelassen. Bei `matchedAltTitle` eine Zeile „auch: {Titel}“. Ein zusammengefasstes Semantics-Label je Kachel. -- **Listenfuß:** während des Nachladens eine Ladeanzeige; bei `hasMore` eine Schaltfläche „Weitere laden“; bei Fehler beim Nachladen Hinweis + „Erneut versuchen“ (bereits geladene Treffer bleiben). Automatisches Nachladen, sobald die letzten 5 Kacheln in Sicht kommen. +- **Trefferzahl** über der Liste („23 Spiele“, ARB-Plural) und **Ansage** für Screenreader: über `SemanticsService.sendAnnouncement` bzw. eine Live-Region, **nur nach einer abgeschlossenen Suche** (nicht während des Tippens, nicht bei verworfenen Antworten) und entprellt, damit schnelle Filterwechsel keine Ansagenkette erzeugen. +- **Treffer-Kachel:** `GameCover` klein mit `titleVisibleNearby: true` und `imageUrl: null` (Platzhalter, KAT-05 Krit. 10), Titel (max. 2 Zeilen), Jahr, Verlage (bis zu zwei, sonst „+n“), Kurzangaben Spielerzahl („2–4 Spieler“), Dauer („30–60 Min.“), Alter („ab 10“); fehlende Angaben werden weggelassen. Bei `matchedAltTitle` eine Zeile „auch: {Titel}“. Ein zusammengefasstes Semantics-Label je Kachel. +- **Listenfuß:** während des Nachladens eine Ladeanzeige; bei `hasMore` eine Schaltfläche „Weitere laden“; bei Fehler beim Nachladen Hinweis + „Erneut versuchen“ (bereits geladene Treffer bleiben). Automatisches Nachladen, sobald die letzten 5 Kacheln in Sicht kommen. **Fokus:** Nach „Weitere laden“ per Tastatur oder Screenreader springt der Fokus auf die erste neu geladene Kachel (nicht zurück an den Listenanfang). **Zustände** (Zustände Krit. 1, 2) @@ -366,7 +401,7 @@ Aufbau (Details und Texte liefert ui-designer in U1, Komponenten ins Design-Syst | Daten | Trefferzahl + Liste | | Leer mit Suchbegriff (Krit. 6) | „Keine Spiele zu „{q}“ gefunden.“ + Hinweis „Prüfe die Schreibweise oder entferne Filter.“; „Filter zurücksetzen“ (nur wenn Filter aktiv); **Einstiegspunkt „Spiel melden“**, sichtbar erst mit #13 (E11, Spec-F4) | | Leer nur mit Filtern (Krit. 8) | „Keine Spiele passen zu deinen Filtern.“ + „Filter zurücksetzen“ | -| Leer ohne Suchbegriff und Filter | „Der Katalog ist noch leer.“ (nur lokal/Staging ohne Startbestand denkbar) | +| Leer ohne Suchbegriff und Filter | „Der Katalog ist noch leer.“ (nur lokal ohne Startbestand denkbar) | | Fehler: keine Verbindung | „Keine Verbindung. Für die Suche brauchst du Internet.“ + „Erneut versuchen“ | | Fehler: sonstige | „Die Suche ist gerade nicht möglich.“ + „Erneut versuchen“ | @@ -388,11 +423,11 @@ Der Einstiegspunkt für #13 ist ein optionaler Callback am Leerzustand-Widget (` Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flutter_test` + `mocktail` (Unit/Widget), Playwright (Web-E2E und Messung). Testnamen enthalten die Referenz („KAT-01 Kriterium n“). -**pgTAP-Datei `supabase/tests/database/09_catalog_search.test.sql`** (3a). Die Nummern `07` und `08` sind für Spec #2 (2b, 2c) reserviert. Die Tests laufen mit eingespieltem Startbestand: Fixtures mit festen Test-UUIDs und einem eindeutigen Kunstwort in allen Titeln und Verlagen (z. B. `qxz`), sodass Suchanfragen nie Startbestandsspiele treffen; bei Abfragen ohne Suchbegriff wird nur auf die Fixture-IDs geprüft. +**pgTAP-Datei `supabase/tests/database/09_catalog_search.test.sql`** (3a; `07` = 2b, `08` = 2c, `10` = #13). Die Tests laufen mit eingespieltem Startbestand: Fixtures mit festen Test-UUIDs und einem eindeutigen Kunstwort in allen Titeln und Verlagen (z. B. `qxz`), sodass Suchanfragen nie Startbestandsspiele treffen; bei Abfragen ohne Suchbegriff wird nur auf die Fixture-IDs geprüft. **Auswirkung auf bestehende Tests** (geprüft gegen `main` und `feature/2b-katalog-zusammenfuehren`): -- **`00_schema.test.sql`:** nur die Tabellenzahl (+ 1 gegenüber dem Stand von `main` beim Merge von 3a, also nach 2b und 2c, siehe 3.3). Es gibt keine `columns_are`-Prüfungen; die neuen generierten Spalten `title_search`/`name_search` brechen dort nichts. +- **`00_schema.test.sql`:** nur die Tabellenzahl (+ 1 gegenüber dem Stand von `main` beim Merge von 3a, siehe 3.3). Es gibt keine `columns_are`-Prüfungen; die neuen generierten Spalten `title_search`/`name_search` brechen dort nichts. - **`06_catalog_foundation.test.sql`:** Die Spaltenrechte-Prüfungen (Z. 293–302) arbeiten mit festen Spaltenlisten und sind von den generierten Spalten nicht betroffen. **Aber Z. 532–535 prüft `manual_fields` von „Testspiel A“ exakt.** Bekäme Testspiel A alternative Titel, käme `alt_titles` hinzu und der Test würde rot. Vorgabe für B3: die alternativen Titel in `supabase/catalog/testdata/startbestand.csv` an **Testspiel B** hängen (Testspiel A bleibt unverändert); nur falls das nicht geht, passt B2 die erwartete Liste in Z. 532–535 an. Testspiel B hat bereits zwei Verlage und eignet sich auch für Krit. 3. - **`07_catalog_merge.test.sql`** (2b): Fremdschlüssel-Wächter um `game_alt_titles.game_id` erweitern (3.3). @@ -400,38 +435,42 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut | AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task | |---|---|---|---|---|---| -| Krit. 1 | Repository: Parameter nur aus gesetzten Werten, Suchbegriff < 2 Zeichen wird nicht gesendet; DTO-Parsing (alle Felder nullable, `publisher_names` leer); Controller: Entprellung 300 ms, Enter sofort, nur letzte Antwort zählt | Suchfeld: Eingabe → Liste; Löschen-Schaltfläche; Treffer-Kachel mit Kurzangaben, fehlende Angaben weggelassen | Web: Teil eines Titels in anderer Schreibweise und mit Sonderzeichen findet das Spiel | `search_normalize`-Pflichtfälle (3.2); Teilzeichenkette, Groß/Klein, Sonderzeichen, Umlaut/ß; mehrere Wörter (UND); Schreibweise ohne Leerzeichen (`qc`); Relevanzstufen 0–4 in erwarteter Reihenfolge; gleiche Stufe → Ähnlichkeit, Titel, ID; `'!!!'` → 0 Treffer; `null`/Leerzeichen → kein Suchbegriff | B1, B2, F1, F2, T1, T2 | -| Krit. 2 | DTO: `matched_alt_title` | Kachel zeigt „auch: …“ | Web: Suche nach deutschem und englischem alternativen Titel findet dasselbe Spiel | Treffer über `de`- und `en`-Titel; Spiel nur einmal, auch wenn Haupt- und alternativer Titel treffen; `matched_alt_title` nur bei besserer Stufe; Tabelle: Checks (Sprache, Länge, nur Sonderzeichen → 23514), Unique (Schreibweise egal → 23505); RLS: `anon` liest Titel von Katalogspielen, nicht die eines fremden privaten Spiels; `anon`/`authenticated` können nicht schreiben (42501); Einfügen als `postgres` markiert `alt_titles` in `manual_fields`; Check akzeptiert `alt_titles`, lehnt unbekannte Schlüssel ab. **Idempotenz in pgTAP** über die neu erzeugten `.inc`-Fixtures (`startbestand_fixture.inc` zweimal einspielen, wie in `06`): zweites Einspielen ändert keine Zeile von `game_alt_titles` (Vergleich ohne `updated_at`, dazu `updated_at` unverändert); ein in der CSV entfernter alternativer Titel wird gelöscht; geänderte Schreibweise aktualisiert nur `title`. Deno (nur das Skript, ohne Datenbank): Format, Sprache, Länge, Duplikat, gleich Haupttitel, `:` im Titel; erzeugte SQL (Snapshot) | B1, B2, B3, T1, T2 | +| Krit. 1 | Repository: Parameter nur aus gesetzten Werten, Suchbegriff < 2 Zeichen wird nicht gesendet; DTO-Parsing (alle Felder nullable, `publisher_names` leer); Controller: Entprellung 300 ms, Enter sofort, nur letzte Antwort zählt | Suchfeld: Eingabe → Liste; Löschen-Schaltfläche; Treffer-Kachel mit Kurzangaben, fehlende Angaben weggelassen | Web: Teil eines Titels in anderer Schreibweise und mit Sonderzeichen findet das Spiel | `search_normalize`-Pflichtfälle (3.2 Nr. 2, inkl. `®`, `™`, `½`, `Æon’s End`); Teilzeichenkette, Groß/Klein, Sonderzeichen, Umlaut/ß; mehrere Wörter (UND); Wörter mit 1 Zeichen werden verworfen; normalisierter Begriff < 2 Zeichen = kein Suchbegriff; Schreibweise ohne Leerzeichen (`qc`, „aeons end“ findet „Æon’s End“); Relevanzstufen 0–4 in erwarteter Reihenfolge; gleiche Stufe → Ähnlichkeit, Titel, ID; `'!!!'` → 0 Treffer; `null`/Leerzeichen → kein Suchbegriff | B1, B2, F1, F2, T1, T2 | +| Krit. 2 | DTO: `matched_alt_title` | Kachel zeigt „auch: …“ | Web: Suche nach deutschem und englischem alternativen Titel findet dasselbe Spiel | Treffer über `de`- und `en`-Titel; Spiel nur einmal, auch wenn Haupt- und alternativer Titel treffen; `matched_alt_title` nur bei besserer Stufe; Tabelle: Checks (Sprache, Länge, nur Sonderzeichen → 23514), Unique (Schreibweise egal → 23505); RLS: `anon` liest Titel von Katalogspielen, nicht die eines fremden privaten Spiels; `anon`/`authenticated` können nicht schreiben (42501); Einfügen als `postgres` markiert `alt_titles` in `manual_fields`; Check akzeptiert `alt_titles`, lehnt unbekannte Schlüssel ab. **Idempotenz in pgTAP** über die neu erzeugten `.inc`-Fixtures (`startbestand_fixture.inc` zweimal einspielen, wie in `06`): zweites Einspielen ändert keine Zeile von `game_alt_titles` (Vergleich ohne `updated_at`, dazu `updated_at` unverändert); ein in der CSV entfernter alternativer Titel wird gelöscht; geänderte Schreibweise aktualisiert nur `title`. Deno (nur das Skript, ohne Datenbank): Format, Sprache, Länge, Duplikat, gleich Haupttitel, `:` im Titel erlaubt, `;` im Titel → Fehler mit Zeilennummer; erzeugte SQL (Snapshot) | B1, B2, B3, T1, T2 | | Krit. 3 | – | Kachel: Verlage (bis zwei, „+n“) | Web: Verlagsname findet dessen Spiele | Verlag findet alle seine Spiele; Spiel mit zwei Verlagen über beide; Wort aus Titel + Wort aus Verlag (UND); `publisher_names` sortiert, vollständig; Stufe 4 hinter Titeltreffern | B1, B2, T2 | -| Krit. 4 | `CatalogSearchQuery`: Grenzen, `activeFilterCount`; URL-Parameter lesen/schreiben (Rundreise, ungültige Werte ignoriert); Controller: Filteränderung startet ab Versatz 0 | Filterbereich: jede Filterart setzt den Wert; Zurücksetzen; Bottom Sheet (kompakt) und Seitenleiste (≥ 1024 dp); Begriffe nach Sprache sortiert und benannt; Fehler beim Laden der Begriffe | Web: Filter Genre + Spielerzahl mit Suchbegriff | je Filter allein; Kombination aller Filter (UND); zwei Genres → nur Spiele mit beiden (Spec-F1); fehlende Angaben werden ausgeschlossen (Spec-F2); unbekannte Genre-ID → 0 Treffer; Duplikate im Array zählen einfach | B1, B2, F1, F2, U1, T1, T2 | -| Krit. 5 | Controller: `loadMore` hängt an, verwirft doppelte IDs, nicht doppelt gleichzeitig; Fehler beim Nachladen behält Treffer; `retry` | Listenfuß: Ladeanzeige, „Weitere laden“, Fehler + „Erneut versuchen“; automatisches Nachladen beim Scrollen | Web: ≥ 31 Fixture-Treffer, Scrollen lädt die zweite Seite; „Weitere laden“ per Tastatur | Seiten ohne Überschneidung, Vereinigung = Gesamtmenge, Reihenfolge stabil über Aufrufe; `total_count` gleich in allen Zeilen und Seiten | B2, F2, T1, T2 | +| Krit. 4 | `CatalogSearchQuery`: Grenzen, `activeFilterCount`; URL-Parameter lesen/schreiben (Rundreise, ungültige Werte ignoriert); Controller: Filteränderung startet ab Versatz 0 | Filterbereich: jede Filterart setzt den Wert; Zurücksetzen; Bottom Sheet (kompakt) und Seitenleiste (≥ 1024 dp); Begriffe nach Sprache sortiert und benannt; Fehler beim Laden der Begriffe | Web: Filter Genre + Spielerzahl mit Suchbegriff | je Filter allein; Kombination aller Filter (UND); zwei Genres → nur Spiele mit beiden (Spec-F1); fehlende Angaben werden ausgeschlossen, Dauer mit nur `max_minutes` bzw. nur `min_minutes` zählt (Spec-F2); unbekannte Genre-ID → 0 Treffer; Duplikate im Array zählen einfach | B1, B2, F1, F2, U1, T1, T2 | +| Krit. 5 | Controller: `loadMore` hängt an, verwirft doppelte IDs, nicht doppelt gleichzeitig, kein Nachladen über Versatz 10 000; Fehler beim Nachladen behält Treffer; `retry` | Listenfuß: Ladeanzeige, „Weitere laden“, Fehler + „Erneut versuchen“; automatisches Nachladen beim Scrollen; Fokus auf die erste neue Kachel | Web: ≥ 31 Fixture-Treffer, Scrollen lädt die zweite Seite; „Weitere laden“ per Tastatur | Seiten ohne Überschneidung, Vereinigung = Gesamtmenge, Reihenfolge stabil über Aufrufe; `total_count` gleich in allen Zeilen und Seiten | B2, F2, T1, T2 | | Krit. 6 | – | Leerzustand mit Suchbegriff: Hinweis, Text mit Suchbegriff, „Filter zurücksetzen“ nur mit Filtern, Melden-Einstieg nur bei gesetztem Callback | Web: Suche ohne Treffer zeigt Hinweis | – | F3, U1, T2 | -| Krit. 7 | – | – | Projekt `perf` gegen Staging (7.3); lokal: Datenbank-Budget mit synthetischen Daten | – (Messung, nicht pgTAP) | B5, T3, D1, C1 | +| Krit. 7 | – | – | Projekt `perf` (7.3) nach Spec-F4; Datenbank-Budget lokal als `anon` mit synthetischen Daten | – (Messung, nicht pgTAP) | B5, T3, D1, C1 | | Krit. 8 | Controller: ohne Suchbegriff mit Filtern; Startzustand ohne beides | Leerzustand nur mit Filtern | Web: nur Filter, kein Suchbegriff → passende Spiele | ohne Suchbegriff: Filter wirken, Sortierung nach Titel, `match_rank` `null`; ohne beides: alle sichtbaren Katalogspiele (Fixtures vollständig enthalten) | B2, F2, T1, T2 | | KAT-05 Krit. 4 | – | – | – | ausgeblendete und zusammengeführte Fixture-Spiele erscheinen nie, auch nicht über alternative Titel oder Verlag | B2 | -| KAT-05 Krit. 10 | – | Kachel zeigt `GameCover`-Platzhalter, aus dem Semantics-Baum ausgeblendet | – | `image_path` maskiert bei Quelle ohne Erlaubnis (über die View) | B2, T2 | -| Sicherheit Krit. 2 | – | – | Web: Suche ohne Anmeldung | `anon` und `authenticated` dürfen `search_catalog_games` ausführen, `public`/`service_role` nicht (`has_function_privilege`); `search_normalize` für keine API-Rolle ausführbar; eigene und fremde private Spiele erscheinen nie (auch mit gleichem Titel); keine Schreibrechte auf `game_alt_titles` | B2 | -| Eingabegrenzen | `CatalogSearchQuery` lässt nur zulässige Werte zu | Suchfeld begrenzt auf 100 Zeichen | – | `search_query_too_long`, `search_filter_invalid` (je Parameter, Array > 20), `search_paging_invalid` (`limit` 0/51, `offset` −1/10 001) | B2, T1 | +| KAT-05 Krit. 10 | – | Kachel zeigt `GameCover`-Platzhalter (`imageUrl` `null`), aus dem Semantics-Baum ausgeblendet | – | `image_path` maskiert bei Quelle ohne Erlaubnis (über die View) | B2, T2 | +| Sicherheit Krit. 2 | – | – | Web: Suche ohne Anmeldung | `anon` und `authenticated` dürfen `search_catalog_games` ausführen, `public`/`service_role` nicht (`has_function_privilege`); **`search_normalize` ist für `anon`, `authenticated` und `service_role` ausführbar**; **`authenticated` legt ein privates Spiel an und ändert den Titel, `title_search` ist danach gefüllt bzw. aktualisiert (kein 42501)**; **Suche als `anon` (`set local role anon`) läuft ohne Rechtefehler**; eigene und fremde private Spiele erscheinen nie (auch mit gleichem Titel); keine Schreibrechte auf `game_alt_titles` | B2 | +| Eingabegrenzen | `CatalogSearchQuery` lässt nur zulässige Werte zu | Suchfeld begrenzt auf 100 Zeichen | – | `search_query_too_long` (101 Zeichen, 11 Wörter), `search_filter_invalid` (je Parameter, Array > 20), `search_paging_invalid` (`limit` 0/51, `offset` −1/10 001) | B2, T1 | | Zustände Krit. 1–3 | Fehlerabbildung: Netzwerkfehler, Zeitlimit, P0001, sonstige; Suchbegriff/Filter bleiben nach Fehler | alle Zustände aus 5.6; „Erneut versuchen“ | Web: `context.setOffline(true)` → Offline-Hinweis, danach online → „Erneut versuchen“ liefert Treffer | – | F1, F2, T1, T2 | -| QUE-04 | Logger erhält bei Fehlern keinen Suchbegriff | – | – | – | F1, T1 | +| QUE-04 | Logger erhält bei Fehlern keinen Suchbegriff | – | – | – (B5 prüft Protokoll-Einstellungen der Datenbank) | F1, T1, B5 | | QUE-05 Krit. 2–4 | `CatalogTerm.localizedName` (Rückfall Englisch) | Screen in de und en; Trefferzahl Plural | – | – | F1, T2 | -| QUE-06 Krit. 1–4 | – | a11y-Guidelines (`labeledTapTargetGuideline`, `androidTapTargetGuideline`, `iOSTapTargetGuideline`, `textContrastGuideline`); `textScaler` 2.0 ohne Überlauf (kompakt und groß); Ansage der Trefferzahl | Web: Bedienung nur per Tastatur | – | U1, T2, T4 | +| QUE-06 Krit. 1–4 | – | a11y-Guidelines (`labeledTapTargetGuideline`, `androidTapTargetGuideline`, `iOSTapTargetGuideline`, `textContrastGuideline`); `textScaler` 2.0 ohne Überlauf (kompakt und groß); Ansage der Trefferzahl nur nach abgeschlossener Suche, entprellt | Web: Bedienung nur per Tastatur | – | U1, T2, T4 | | Plattformen Krit. 2 | – | Layout ≥ 1024 dp mit Seitenleiste | – | – | U1, T2 | ### 7.2 E2E (Projekt `web`, lokal und CI) - Neue Datei `e2e/tests/web/kat01-suche.spec.ts`. Testdaten legt ein Helfer über `e2e/support/db.ts` (nur lokal, bestehende Host-Prüfung) als `postgres` an: 40 Katalogspiele mit dem Kunstwort im Titel, zwei Verlagen, alternativen Titeln, Genres und Mechaniken aus dem Vokabular; Aufräumen nach dem Lauf über die festen IDs. Tests laufen ohne Anmeldung. +- **Parallelität:** Die Konfiguration läuft mit `fullyParallel`. Die Suite nutzt gemeinsame Fixtures; sie läuft deshalb mit `test.describe.configure({ mode: 'serial' })` oder legt die Fixtures in einem eigenen Setup-Projekt einmal an (Wahl durch test-engineer). Andere Suites bleiben parallel. - Fälle: Krit. 1, 2, 3, 4, 5, 6, 8, Offline (siehe Matrix), Tastaturbedienung. - `supabase/catalog/testdata` reicht nicht (3 Spiele); der echte Startbestand ist für E2E nicht verlässlich (Inhalt wächst). ### 7.3 Messung Krit. 7 (Projekt `perf`) - Eigenes Playwright-Projekt `perf` in `e2e/playwright.config.ts`, nur registriert, wenn `PERF_WEB_URL` gesetzt ist (Muster `throttle`/`spike`). **Nur lesend**, kein Zugriff auf `db.ts`, keine Anmeldung, keine Schlüssel außer dem öffentlichen Anon-Key im Web-Build. +- **Konfiguration:** Der `webServer` in `e2e/playwright.config.ts` und die Standardwerte in `e2e/support/auth-config.ts` werden bedingt, sodass ein Lauf nur mit `perf` keinen lokalen Web-Server startet und keine lokalen Auth-Konstanten voraussetzt. `e2e-run.sh` listet weiterhin nur `api`, `web`, `throttle`; `perf` läuft ausschließlich per `workflow_dispatch` bzw. manuell. - Drosselung über CDP `Network.emulateNetworkConditions`: `latency` 100 ms, Download und Upload je 10 Mbit/s (1 250 000 Byte/s). -- Ablauf: Web-App von Staging öffnen (Startbestand eingespielt), Semantics einschalten; aus einer festen Liste von mindestens 100 Suchen (vollständige Titel, Teile, alternative Titel, Verlage, Wortkombinationen, nur Filter) je Suche das Feld leeren, den Begriff tippen, **Zeit ab dem letzten Tastendruck bis zur Sichtbarkeit der ersten Treffer-Kachel** (bzw. des Leerzustands bei absichtlich leeren Suchen) messen. Die erste Suche nach dem Laden der App zählt mit (Verbindungsaufbau). +- Ablauf: Web-App öffnen, Semantics einschalten; aus einer festen Liste von mindestens 100 Suchen (vollständige Titel, Teile, alternative Titel, Verlage, Wortkombinationen, nur Filter) je Suche das Feld leeren, den Begriff tippen, **Zeit ab dem letzten Tastendruck bis zur Sichtbarkeit der ersten Treffer-Kachel** (bzw. des Leerzustands bei absichtlich leeren Suchen) messen. Die erste Suche nach dem Laden der App zählt mit (Verbindungsaufbau). +- **Umgebung (Spec-F4):** + - **Vorläufig (solange es kein Staging gibt):** lokaler bzw. CI-Stack (`supabase start`, Web-Build) mit Startbestand und zusätzlich synthetischem Volumen in der Größenordnung von B5 (Ladeskript nur lokal), Drosselung wie oben. Ergebnis im PR 3c. + - **Maßgeblich:** dieselbe Messung gegen Staging, sobald es existiert (D1). Sie wird als Freigabebedingung in die Release-Checkliste `docs/release-checkliste.md` (PR #54) aufgenommen. - Ergebnis: p50, p95, Maximum als Testausgabe und Anhang; **bestanden, wenn p95 ≤ 2000 ms**. - Mobile Apps: Stichprobe von 20 Suchen auf einem Android-Gerät oder Emulator mit 4G-Profil (Emulator-Netzwerkprofil bzw. Network Link Conditioner auf iOS), manuell durch Chris oder test-engineer, Ergebnis im PR 3c. Die Web-Messung gilt als maßgeblicher Nachweis; Abweichung im PR begründet. -- Ausführung manuell (`workflow_dispatch`) bzw. lokal gegen Staging, nicht bei jedem PR. **Nicht automatisiert testbar, manuelle Prüfung:** Krit. 7 auf Mobilgeräten (Stichprobe), Screenreader-Durchgang (TalkBack, VoiceOver, NVDA/VoiceOver im Web), inhaltliche Plausibilität der Relevanz mit dem echten Startbestand (Chris). @@ -441,46 +480,48 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut | # | Agent | Aufgabe | abhängig von | |---|---|---|---| -| B1 | backend-dev | Migration `_catalog_search.sql` nach 3.2: Erweiterungen, `search_normalize`, generierte Spalten + Trigramm-Indizes, `game_alt_titles` (Checks, Unique, Trigger `set_updated_at` und `game_links_mark_manual('alt_titles')`, RLS, Grants), `manual_fields`-Check mit vollständiger Liste + `alt_titles`, RPC `search_catalog_games` nach 4.2 (Prüfungen, Treffer-Regel, Relevanz, Filter, Sortierung, Rechte: `revoke` von allen, dann `grant execute` an `anon`, `authenticated`) | – | -| B2 | backend-dev | pgTAP `09_catalog_search.test.sql` nach 7.1 (inkl. Idempotenz der alternativen Titel über die `.inc`-Fixtures); `00_schema.test.sql` Tabellenzahl; Fremdschlüssel-Wächter in `07_catalog_merge.test.sql` um `game_alt_titles.game_id` („bewusst ausgelassen“) ergänzen (3.3); `06` Z. 532–535 nur anpassen, falls B3 Testspiel A ändern muss | B1, B3 | -| B3 | backend-dev | Startbestand-Werkzeug nach 4.3: `alt_titles` parsen, prüfen, SQL erzeugen; Deno-Tests (neu und geänderter Test Z. 253); Testdaten-CSV: alternative Titel (de und en) an **Testspiel B**, nicht an Testspiel A (§ 7, Auswirkung auf `06`); alle erzeugten Dateien neu erzeugen | B1 | -| B4 | backend-dev | Doku nach 3.4: `docs/datenmodell-r1.md`, `supabase/README.md`, `docs/katalog-pflege.md` (Abschnitt „Alternative Titel pflegen“, SQL-Bausteine wie in pgTAP ausgeführt) | B1, B3 | -| B5 | backend-dev | Annahmen prüfen und messen: Erweiterungen lokal verfügbar (und mit devops auf Staging, D1); `statement_timeout` von `anon`/`authenticated`; Skript `supabase/tests/perf/catalog_search_perf.sql` mit synthetischen Daten (nur lokal, in einer zurückgerollten Transaktion), `explain (analyze, buffers)` und p95 im PR dokumentieren | B1 | +| B1 | backend-dev | Migration `_catalog_search.sql` nach 3.2: Erweiterungen, `search_normalize` (Schritte und Rechte nach 3.2 Nr. 2, `execute` für `anon`, `authenticated`, `service_role`), generierte Spalten + Trigramm-Indizes, `game_alt_titles` (Checks, Unique, Trigger `set_updated_at` und `game_links_mark_manual('alt_titles')`, RLS, Grants), `manual_fields`-Check mit vollständiger Liste + `alt_titles`, RPC `search_catalog_games` nach 4.2 (Prüfungen, Treffer-Regel, Relevanz, Filter, Sortierung, Kommentar zum Partial-Index, Rechte: `revoke` von allen, dann `grant execute` an `anon`, `authenticated`) | 2b gemergt (3.3) | +| B2 | backend-dev | pgTAP `09_catalog_search.test.sql` nach 7.1 (inkl. Rechte von `search_normalize`, privates Spiel als `authenticated`, Suche als `anon`, Idempotenz der alternativen Titel über die `.inc`-Fixtures); `00_schema.test.sql` Tabellenzahl; Fremdschlüssel-Wächter in `07_catalog_merge.test.sql` um `game_alt_titles.game_id` („bewusst ausgelassen“) ergänzen (3.3); `06` Z. 532–535 nur anpassen, falls B3 Testspiel A ändern muss | B1, B3 | +| B3 | backend-dev | Startbestand-Werkzeug nach 4.3: `alt_titles` parsen, prüfen (inkl. `;` verboten), SQL erzeugen; Deno-Tests (neu und geänderter Test Z. 253); Testdaten-CSV: alternative Titel (de und en) an **Testspiel B**, nicht an Testspiel A; alle erzeugten Dateien neu erzeugen | B1 | +| B4 | backend-dev | Doku nach 3.4: `docs/datenmodell-r1.md`, `supabase/README.md`, `docs/katalog-pflege.md` (Abschnitte „Alternative Titel pflegen“, „Nach einem Image-Upgrade: Normalisierung prüfen“ mit der Prüfabfrage aus R6, Schritt im Abschnitt Zusammenführen aus 9.5; SQL-Bausteine wie in pgTAP ausgeführt) | B1, B3 | +| B5 | backend-dev | Annahmen prüfen und messen: Erweiterungen lokal verfügbar (Staging, sobald vorhanden, mit devops, D1); Rechte der API-Rollen auf `extensions.unaccent`/`similarity`; `statement_timeout` von `anon`/`authenticated`; Protokoll-Einstellungen (`log_min_duration_statement`, `log_statement`, `log_parameter_max_length`, `log_parameter_max_length_on_error`) – Suchbegriffe dürfen nicht in Datenbank-Logs landen, sonst Einstellung bzw. Abweichung dokumentieren; Skript `supabase/tests/perf/catalog_search_perf.sql` mit synthetischen Daten (nur lokal, zurückgerollte Transaktion), Messung **als `anon` mit RLS**, `explain (analyze, buffers)` und p95 im PR dokumentieren; bei Verfehlen die Stufen aus 4.2 Leistung | B1 | | T1a | test-engineer | Review der pgTAP- und Deno-Abdeckung gegen 7.1, Lücken ergänzen | B2, B3 | ### 8.2 PR 3b – App (R1.1), Branch `feature/3b-katalog-suche-app`, `Refs #3` | # | Agent | Aufgabe | abhängig von | |---|---|---|---| -| U1 | ui-designer | Entwurf Suche (kompakt, mittel, groß), Treffer-Kachel, Filterbereich (Sheet und Seitenleiste), alle Zustände; gemeinsame Komponenten `AppStateMessage` und Listenfuß in `core/widgets/`; ARB-Texte de/en; Eintrag in `docs/design-system.md` | – (Vertrag § 4.2 und 5.6) | -| F1 | flutter-dev | Domäne und Daten nach 5.1–5.3: Modelle ohne `toJson`, `CatalogRepository`, `SupabaseCatalogRepository` (Parameterbau, Zeitlimit, Fehlerabbildung, Logging ohne Suchbegriff), DTOs, Provider | – (gegen den Vertrag § 4.2; Integration gegen echte RPC nach 3a) | -| F2 | flutter-dev | `CatalogSearchController` und `CatalogSearchState` nach 5.4 (Entprellung, letzte Anfrage zählt, Nachladen, Wiederholen) | F1 | -| F3 | flutter-dev | Route `/catalog` mit Query-Parametern, URL-Abgleich (5.5, mit Rückfall), `CatalogSearchScreen`/`CatalogSearchView` nach 5.6 mit den Komponenten aus U1, Einstieg auf der Startseite, `onTap = null`, Melden-Callback `null` | F2, U1 | +| U1 | ui-designer | Entwurf Suche (kompakt, mittel, groß), Treffer-Kachel, Filterbereich (Sheet und Seitenleiste), alle Zustände; gemeinsame Komponenten `AppStateMessage` und Listenfuß in `core/widgets/`; Ansage- und Fokusverhalten (5.6); ARB-Texte de/en; Eintrag in `docs/design-system.md` | – (Vertrag § 4.2 und 5.6) | +| F1 | flutter-dev | Domäne und Daten nach 5.1–5.3: Modelle ohne `toJson`, DTOs nur `json_serializable`, `CatalogRepository`, `SupabaseCatalogRepository` (Parameterbau, Zeitlimit, Fehlerabbildung, Logging ohne Suchbegriff), Provider | – (gegen den Vertrag § 4.2; Integration gegen echte RPC nach 3a) | +| F2 | flutter-dev | `CatalogSearchController` und `CatalogSearchState` nach 5.4 (Entprellung, letzte Anfrage zählt, Nachladen bis Versatz 10 000, Wiederholen) | F1 | +| F3 | flutter-dev | Route `/catalog` mit Query-Parametern, URL-Abgleich (5.5, `Router.neglect` + `SystemNavigator.routeInformationUpdated(replace: true)`, mit Rückfall), `CatalogSearchScreen`/`CatalogSearchView` nach 5.6 mit den Komponenten aus U1, Einstieg auf der Startseite, `onTap = null`, `imageUrl = null`, Melden-Callback `null` | F2, U1 | | T1 | test-engineer | Unit-Tests (Repository mit gemocktem Client, DTO, Fehlerabbildung, Query, URL-Rundreise, Controller mit `fakeAsync`) | F1, F2 | -| T2 | test-engineer | Widget-Tests nach 7.1 (Zustände, Filter in beiden Layouts, Listenfuß, a11y-Guidelines, `textScaler` 2.0, de/en) | F3 | +| T2 | test-engineer | Widget-Tests nach 7.1 (Zustände, Filter in beiden Layouts, Listenfuß inkl. Fokus, Ansage, a11y-Guidelines, `textScaler` 2.0, de/en) | F3 | -### 8.3 PR 3c – E2E und Messung (R1.1), Branch `feature/3c-katalog-suche-e2e`, `Closes #3` (siehe Spec-F4) +### 8.3 PR 3c – E2E und Messung (R1.1), Branch `feature/3c-katalog-suche-e2e`, `Closes #3` nur bei Spec-F4 A, sonst `Refs #3` | # | Agent | Aufgabe | abhängig von | |---|---|---|---| -| T4 | test-engineer | Web-E2E `kat01-suche.spec.ts` nach 7.2 inkl. Fixture-Helfer und Aufräumen | 3a, 3b gemergt | -| T3 | test-engineer | Projekt `perf` nach 7.3 (Drosselung, Suchliste, Auswertung p50/p95) | 3b gemergt | -| D1 | devops | Staging bereitstellen bzw. aktualisieren: Migrationen, Web-Build, Erweiterungen prüfen (B5); CI-Job für das E2E-Projekt `web` um die neue Datei ergänzen (falls nicht automatisch); `workflow_dispatch`-Job für `perf` mit `PERF_WEB_URL` | 3a gemergt | -| C1 | Chris | Startbestand auf Staging einspielen (Spec #2 Runbook), Messung auslösen bzw. abnehmen; mobile Stichprobe; Relevanz mit echten Daten plausibilisieren | D1, T3 | +| T4 | test-engineer | Web-E2E `kat01-suche.spec.ts` nach 7.2 inkl. Fixture-Helfer, Aufräumen und serieller Ausführung bzw. Setup-Projekt | 3a, 3b gemergt | +| T3 | test-engineer | Projekt `perf` nach 7.3 (bedingter `webServer`/`auth-config.ts`, Drosselung, Suchliste, Ladeskript für synthetisches Volumen lokal, Auswertung p50/p95); vorläufige Messung gegen den lokalen bzw. CI-Stack | 3b gemergt | +| D1 | devops | CI-Job für das E2E-Projekt `web` um die neue Datei ergänzen (falls nicht automatisch); `workflow_dispatch`-Job für `perf` mit `PERF_WEB_URL`; Eintrag „Krit. 7 gegen Staging messen“ in `docs/release-checkliste.md` (PR #54); Entscheidung zur Mengenbegrenzung der RPC je IP (4.2 Sicherheit) im Hosting-Runbook vor dem Go-live; sobald Staging existiert: Migrationen, Web-Build, Erweiterungen prüfen (B5) | 3a gemergt | +| C1 | Chris | Vorläufige Messung abnehmen (Spec-F4 A); später Startbestand auf Staging einspielen (Spec #2 Runbook) und Staging-Messung vor dem Release abnehmen; mobile Stichprobe; Relevanz mit echten Daten plausibilisieren | T3 (Staging: D1) | ### 8.4 Reihenfolge und Parallelität ``` -#2 2b gemergt → 2c gemergt (Voraussetzung für den Merge von 3a, 3.3) +#2 2b gemergt (Voraussetzung für den Merge von 3a; 2c davor oder danach, 3.3) 3a B1 → B3, B5 (parallel) → B2 → B4 → T1a → Merge (Chris) 3b U1 ‖ F1 → F2 → F3 → T1, T2 (startet parallel zu 3a, gegen den Vertrag § 4.2) └─ Integration gegen die echte RPC, sobald 3a gemergt ist → Merge (Chris) -3c D1 (nach 3a) → T4, T3 (nach 3b) → C1 → Merge (Chris), Issue #3 geschlossen +3c D1 (nach 3a) → T4, T3 (nach 3b) → C1 (vorläufige Messung) → Merge (Chris), #3 geschlossen bei Spec-F4 A + später: Staging (D1) → Messung als Release-Freigabebedingung (C1) ``` - Der Vertrag in § 4.2 ist verbindlich für 3a und 3b; Änderungen daran nur über diese Spec. - 3b wird erst nach 3a gemergt (sonst ruft die App eine fehlende RPC auf). -- Merge-Reihenfolge im Backend: **2b → 2c → 3a** (3.3). Arbeiten an 3a und 3b dürfen vorher beginnen; vor dem PR von 3a wird auf `main` aktualisiert. +- Backend: **3a nach 2b**; wer von 2c und 3a später merged, gleicht Fremdschlüssel-Wächter, Tabellenzahl und `manual_fields`-Check ab (3.3). Arbeiten an 3a und 3b dürfen vorher beginnen; vor dem PR von 3a wird auf `main` aktualisiert. +- #13 (Spec auf `docs/13-kat04-spec`) setzt `search_normalize` aus 3a voraus; 13a wird nach 3a gemergt. - Merge nur durch Chris. Kein ADR (9.4). ## 9. Risiken & offene Fragen @@ -489,27 +530,28 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut | # | Risiko | Auswirkung | Gegenmaßnahme | |---|---|---|---| -| R1 | `pg_trgm` oder `unaccent` sind im self-hosted Image nicht verfügbar (unverifiziert) | Migration schlägt auf Staging/Produktion fehl | B5 prüft vor dem Merge von 3a. Rückfall ohne `unaccent`: `search_normalize` mit `translate()` und `replace()` für die gängigen Zeichen (äöüàáâéèêíóúñç…, ß → ss), ebenfalls `immutable`. Rückfall ohne `pg_trgm`: keine Trigramm-Indizes, Sortierung ohne Ähnlichkeit (Titel, ID); bei R1-Größe ausreichend schnell. | -| R2 | Abfrage mit mehreren `exists`/`or` über drei Tabellen wird bei wachsendem Katalog langsam | Krit. 7 verfehlt | Budget 150 ms bei 10 000 Spielen (B5); Rückfall: zusammengefasster Suchtext je Spiel in einer eigenen Spalte, gepflegt per Trigger an `games`, `game_alt_titles`, `game_publishers`, `publishers` | -| R3 | Staging existiert noch nicht oder hat keinen Startbestand (ADR-0002: ein Server für Staging/Produktion) | Krit. 7 nicht abnehmbar, #3 bleibt offen | D1 früh einplanen; Startbestand-Fixtures für die Messung reichen nicht, es braucht die echte Größenordnung | -| R4 | `search_catalog_games` ist ohne Anmeldung aufrufbar | Last durch automatisierte Aufrufe | Eingabegrenzen, `statement_timeout` (B5), nur lesend; Mengenbegrenzung am Gateway erst bei Bedarf (nicht in R1) | +| R1 | `pg_trgm` oder `unaccent` sind im self-hosted Image nicht verfügbar (unverifiziert) | Migration schlägt auf Staging/Produktion fehl | B5 prüft vor dem Merge von 3a. Rückfall ohne `unaccent`: `search_normalize` mit `translate()` und `replace()` für die gängigen Zeichen (äöüàáâéèêíóúñç…, ß → ss, æ → ae), ebenfalls `immutable`. Rückfall ohne `pg_trgm`: keine Trigramm-Indizes, Sortierung ohne Ähnlichkeit (Titel, ID); bei R1-Größe ausreichend schnell. | +| R2 | Die Abfrage (2-Zeichen-`like`, Regel ohne Leerzeichen, `or` über `exists`, RLS für `anon`) nutzt die Trigramm-Indizes kaum und wird bei wachsendem Katalog langsam | Krit. 7 verfehlt | Messung als `anon` (B5); Stufen aus 4.2 Leistung (Kandidaten-CTE, `title_compact`); letzter Rückfall: zusammengefasster Suchtext je Spiel in einer eigenen Spalte, gepflegt per Trigger an `games`, `game_alt_titles`, `game_publishers`, `publishers` | +| R3 | **Staging existiert noch nicht** (ADR-0002: ein Hetzner-Server für Staging/Produktion) | Krit. 7 lässt sich nicht wie formuliert („gegen Staging“) abnehmen | Spec-F4: vorläufige Messung lokal/CI, Staging-Messung als Freigabebedingung in der Release-Checkliste; D1 früh einplanen | +| R4 | `search_catalog_games` ist ohne Anmeldung aufrufbar; viele parallele Aufrufe | Last auf der Datenbank | Eingabegrenzen, `statement_timeout` der Rollen (B5; `set statement_timeout` an der Funktion wirkt nicht), nur lesend; Mengenbegrenzung je IP (Kong `rate-limiting` oder `limit_req` im Proxy) als Entscheidung vor dem Go-live (D1), kein Muss für R1.1. Das vollständige Durchblättern des öffentlichen Katalogs ist gewollt. | | R5 | Nachladen per `offset` bei gleichzeitiger Katalogänderung | Ein Spiel doppelt oder übersprungen | Doppelte IDs werden verworfen; Katalog ändert sich selten; akzeptiert | -| R6 | Änderung von `search_normalize` später | Generierte Spalten veralten nicht automatisch | Kommentar an der Funktion; Neuanlage der Spalten per Migration | -| R7 | Parallelarbeit mit 2b/2c und #12 | Konflikte bei `manual_fields`-Check, Tabellenzahl in `00_schema`, Fremdschlüssel-Wächter | Merge-Reihenfolge 2b → 2c → 3a (3.3); Regel „vollständige Liste“ (3.2 Nr. 5); Tabellenzahl beim Merge abgleichen; Wächter-Einordnung von `game_alt_titles` durch 3a (3.3) | +| R6 | `search_normalize` ändert sich, oder ein Image-Upgrade ändert `unaccent.rules` | Generierte Spalten weichen von der Funktion ab, Suchen treffen nicht mehr | Kommentar an der Funktion; **Runbook-Schritt nach jedem Image-Upgrade:** `select count(*) from public.games where title_search is distinct from public.search_normalize(title)` (ebenso für `game_alt_titles.title_search` und `publishers.name_search`); bei Abweichung die Spalten per Migration neu anlegen (B4) | +| R7 | Parallelarbeit mit 2b, 2c, #12 und #13 | Konflikte bei `manual_fields`-Check, Tabellenzahl in `00_schema`, Fremdschlüssel-Wächter, Normalisierung | 3a nach 2b; wer von 2c und 3a später merged, gleicht ab (3.3); Regel „vollständige Liste“ (3.2 Nr. 5); eine Basisfunktion `search_normalize` mit gleichlautendem Satz in #3 und #13 (3.2 Nr. 2) | | R8 | Relevanz wirkt mit echten Daten unpassend | Nutzer finden das erwartete Spiel weiter unten | Stufen sind einfach anpassbar (nur RPC); Plausibilisierung durch Chris (C1) | | R9 | Schreibweise „ae“/„oe“/„ue“ findet Umlaut-Titel nicht | „Kaese“ findet „Käse“ nicht | Spec-F5; spätere Erweiterung ohne Datenmigration der Clients möglich (nur Funktion + Spalten) | | R10 | Krit. 7 wird im Web gemessen, gilt aber für mobile Apps | Abweichung der Messbedingung | Web als maßgeblicher Nachweis, mobile Stichprobe (7.3), Begründung im PR | +| R11 | Suchbegriffe landen in Datenbank-Logs (langsame Anweisungen, Parameter bei Fehlern) | Verstoß gegen „nicht protokolliert“ (QUE-04) | B5 prüft `log_min_duration_statement` und `log_parameter_max_length*`; Ergebnis bzw. nötige Einstellung im PR und im Hosting-Runbook | ### 9.2 Offene Fragen an Chris (keine blockiert die Umsetzung) | # | Frage | Optionen | Empfehlung | Wirkung | |---|---|---|---|---| | Spec-F1 | Mehrere Genres (bzw. Mechaniken) gewählt: muss ein Spiel **alle** haben oder **eines davon**? | A: alle (UND); B: eines davon (ODER innerhalb einer Filterart, UND zwischen Filterarten) | **A**, wörtliche Lesart von Krit. 4/8 („zu allen gewählten Filtern“, Klärung 13). B liefert beim Entdecken mehr Treffer; Umstellung später nur in der RPC. | E7, 4.2, 7.1 Krit. 4 | -| Spec-F2 | Bedeutung von Spielerzahl, Dauer und Alter als Filter; Umgang mit fehlenden Angaben | Spielerzahl: Zahl n, passt wenn n im Bereich min–max. Dauer: „bis X Minuten“ (30/45/60/90/120/180), passt wenn die längste Angabe ≤ X; alternativ Bereiche („kurz/mittel/lang“). Alter: „ab X Jahren geeignet“, passt wenn Mindestalter ≤ X. Fehlende Angabe: ausschließen oder einschließen. | **Wie links beschrieben** (n im Bereich; „bis X Minuten“; Mindestalter ≤ X) und **fehlende Angaben ausschließen** (ein Spiel ohne Spielerzahl passt nicht nachweislich). Im Startbestand sind alle Angaben Pflicht (Spec #2 SB2), die Regel betrifft nur spätere Einträge. | 4.2, 5.2, 5.6, 7.1 | +| Spec-F2 | Bedeutung von Spielerzahl, Dauer und Alter als Filter; Umgang mit fehlenden Angaben | Spielerzahl: Zahl n, passt wenn n im Bereich min–max. Dauer: „bis X Minuten“ (30/45/60/90/120/180), passt wenn die längste bekannte Angabe ≤ X (auch wenn nur `max_minutes` oder nur `min_minutes` gepflegt ist); alternativ Bereiche („kurz/mittel/lang“). Alter: „ab X Jahren geeignet“, passt wenn Mindestalter ≤ X. Fehlende Angabe: ausschließen oder einschließen. | **Wie links beschrieben** (n im Bereich; „bis X Minuten“; Mindestalter ≤ X) und **fehlende Angaben ausschließen** (ein Spiel ohne Spielerzahl passt nicht nachweislich). Im Startbestand sind alle Angaben Pflicht (Spec #2 SB2), die Regel betrifft nur spätere Einträge. | 4.2, 5.2, 5.6, 7.1 | | Spec-F3 | Was zeigt die Suche ohne Suchbegriff und ohne Filter? | A: den ganzen Katalog alphabetisch; B: nur einen Hinweis „Suchbegriff eingeben oder Filter wählen“ | **A** (Entdecken ohne Sonderfall, E8) | E8, 4.2, 5.6 | -| Spec-F4 | Krit. 6 („Möglichkeit, das Spiel zu melden“) hängt an KAT-04 (#13). Wann gilt #3 als erledigt? | A: #3 liefert Hinweis + Einstiegspunkt; Krit. 6 wird im PR als „Hinweis erfüllt, Melden mit #13“ abgehakt, #3 wird mit 3c geschlossen, #13 übernimmt die Aktion als eigenes Kriterium; B: #3 bleibt offen, bis #13 fertig ist | **A** (entspricht der Teilabhängigkeit im Issue; bei B hinge der Abschluss von #3, einer R1.1-Story, an #13 aus R1.2) | E11, 5.6, 8.3 | +| Spec-F4 | **Wann gilt #3 als erledigt?** Zwei Teilabhängigkeiten: Krit. 6 hängt an #13 (Melden), Krit. 7 verlangt eine Messung „gegen Staging“, **Staging existiert aber noch nicht** (ADR-0002: ein Hetzner-Server). | **A:** PR 3c schließt #3. Krit. 6: Hinweis + Einstiegspunkt in #3, im PR als „Hinweis erfüllt, Melden mit #13“ abgehakt, #13 übernimmt die Aktion. Krit. 7: vorläufige Messung mit dem Projekt `perf` gegen den lokalen bzw. CI-Stack mit Startbestand und synthetischem Volumen, Drosselung wie in 7.3; die Messung gegen Staging wird **Freigabebedingung in der Release-Checkliste** (`docs/release-checkliste.md`, PR #54). **B:** #3 bleibt offen, bis #13 umgesetzt und die Messung gegen Staging erfolgt ist (beide Teile auch einzeln wählbar). | **A** (entspricht der Teilabhängigkeit im Issue; das Release kann ohne Staging-Messung trotzdem nicht freigegeben werden; bei B hinge der Abschluss einer R1.1-Story an #13 aus R1.2 und an der Infrastruktur) | E11, E12, 5.6, 7.3, 8.3 | | Spec-F5 | Soll „ae/oe/ue/ss“ als Schreibweise für „ä/ö/ü/ß“ in R1 treffen (z. B. „Kaese“ → „Käse“)? | A: nein in R1 (nur Akzente/Umlaute entfernen); B: ja (zweite normalisierte Form je Titel) | **A**; Krit. 1 verlangt Groß/Klein und Sonderzeichen. B ist später ohne App-Änderung nachrüstbar (R9). | 2 (Konkretisierung), 3.2 Nr. 2 | -| Spec-F6 | Alternative Titel beim Zusammenführen zweier Katalogspiele (2b, `admin_merge_games`) | A: bleiben am ausgeblendeten Quell-Eintrag (wie Genres/Verlage); der Admin ergänzt am Ziel bei Bedarf Titel per SQL (Runbook-Schritt vor dem Zusammenführen); B: werden automatisch auf das Ziel übertragen (ohne Duplikate) | **A** für R1: einfach, kein Eingriff in die freigegebene Spec #2; der Haupttitel der Quelle hat keine Sprache und ließe sich ohnehin nicht automatisch als alternativer Titel übernehmen | 9.5 (2b), Fremdschlüssel-Wächter | +| Spec-F6 | Alternative Titel beim Zusammenführen zweier Katalogspiele (2b, `admin_merge_games`) | A: bleiben am ausgeblendeten Quell-Eintrag (wie Genres/Verlage); der Admin ergänzt am Ziel bei Bedarf Titel per SQL (Runbook-Schritt vor dem Zusammenführen); B: werden automatisch auf das Ziel übertragen (ohne Duplikate) | **A** für R1: einfach, kein Eingriff in die freigegebene Spec #2 und in 2b; der Haupttitel der Quelle hat keine Sprache und ließe sich ohnehin nicht automatisch als alternativer Titel übernehmen | 3.3, 9.5, Fremdschlüssel-Wächter | | Spec-F7 | Sprachen alternativer Titel | A: nur `de` und `en` (wie `description_locale`); B: beliebige Sprache (ISO 639-1, z. B. Originaltitel auf Französisch) | **A** (Klärung 3 nennt Deutsch und Englisch; erweiterbar per Migration) | 3.2 Nr. 4, 4.3 | ### 9.3 Designwahlen (zur Bestätigung mit der Freigabe) @@ -517,29 +559,35 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut | # | Wahl | Begründung | Verworfene Alternative | |---|---|---|---| | W1 | RPC statt PostgREST-Filter auf `catalog_games` (E1) | Relevanz, UND-Filter über Zuordnungen und Verlagssuche in einer Anfrage | mehrere Anfragen bzw. Filter im Client | -| W2 | Generierte Spalten statt Ausdrucksindex (E3) | einfache Abfragen, Normalisierung einmal beim Schreiben | Ausdrucksindex `search_normalize(title)`; Suchtext-Spalte per Trigger (Rückfall R2) | +| W2 | Generierte Spalten statt Ausdrucksindex (E3) | einfache Abfragen, Normalisierung einmal beim Schreiben, gemeinsame Grundlage für #3 und #13 | Ausdrucksindex `search_normalize(title)`; Suchtext-Spalte per Trigger (Rückfall R2) | | W3 | `limit`/`offset` mit `total_count` (E6) | einfach, Trefferzahl für Anzeige und Ansage | Keyset-Paging | -| W4 | Suche ab 2 Zeichen | ein Zeichen trifft fast alles und belastet die Datenbank ohne Nutzen | ab 1 Zeichen; ab 3 Zeichen (Trigramm-optimal, aber „Go“ wäre nicht suchbar) | +| W4 | Suche ab 2 Zeichen, Wörter ab 2 Zeichen | ein Zeichen trifft fast alles und belastet die Datenbank ohne Nutzen | ab 1 Zeichen; ab 3 Zeichen (Trigramm-optimal, aber „Go“ wäre nicht suchbar) | | W5 | Alternative Titel ohne Schreibrecht für Nutzer (E2) | kein Anwendungsfall in R1, keine Angriffsfläche | Schreiben für Ersteller privater Spiele (Muster `game_genres_write`) | | W6 | Datenklassen ohne `toJson` (E9) | macht den Merkpunkt aus Spec #2 technisch unverletzbar | Dokumentation allein | | W7 | Kachel in #3 nicht antippbar, #4 verdrahtet | kein toter Link auf eine fehlende Detailseite | Platzhalter-Detailseite in #3 | | W8 | Keine Suchbegriffe in Logs, Aufruf per `POST` | Datensparsamkeit (QUE-04) | Suchanalytik | +| W9 | `search_normalize` für alle API-Rollen ausführbar | generierte Spalten und `security invoker`-RPCs rechnen mit den Rechten des Aufrufers; die Funktion liest keine Daten | `execute` entzogen (bricht Schreiben privater Spiele und die Suche als `anon`) | ### 9.4 Ausstehende Prozessschritte -- Spec-Review durch Chris; Label `status/spec-review` ist gesetzt, die Fragen stehen zusätzlich als Kommentar im Issue #3. `needs-clarification` wird nicht gesetzt, weil keine Frage die Umsetzung blockiert (jede hat eine Empfehlung, die Umstellung betrifft jeweils nur die RPC bzw. einen Text). +- Spec-Review durch Chris; Label `status/spec-review` ist gesetzt, die Fragen stehen zusätzlich als Kommentar im Issue #3. `needs-clarification` wird nicht gesetzt, weil keine Frage die Umsetzung blockiert (jede hat eine Empfehlung, die Umstellung betrifft jeweils nur die RPC, einen Text oder den Abschluss des Issues). - **Kein ADR nötig:** `pg_trgm` und `unaccent` sind Standard-Erweiterungen von PostgreSQL-contrib im Supabase-Image, ohne neue Dienste, Pakete oder Lizenzen; die Entscheidung lässt sich per Migration zurücknehmen. Die übrigen Entscheidungen bleiben innerhalb von ADR-0001/0002. -- Nach Freigabe: B1 (backend-dev) und parallel U1, F1 (ui-designer, flutter-dev); D1 (devops) früh wegen R3. +- Nach Freigabe: B1 (backend-dev, nach Merge von 2b) und parallel U1, F1 (ui-designer, flutter-dev); D1 (devops) früh wegen R3/R4. ### 9.5 Hinweise für andere Stories, nicht Teil von #3 -- **KAT-05 2b/2c (#2):** Keine Änderung an 2b oder 2c nötig, weil beide vor 3a gemergt werden (3.3). 3a ordnet `game_alt_titles.game_id` im Fremdschlüssel-Wächter von `07_catalog_merge.test.sql` als „bewusst ausgelassen“ ein (bleibt am Quell-Eintrag, passt zu `admin_merge_games`, das die Zuordnungen ebenfalls nicht umhängt; Spec-F6 A). Für das Runbook (Abschnitt Zusammenführen) ergänzt 3a (B4) den Schritt „vor dem Zusammenführen relevante Titel der Quelle als alternative Titel am Ziel anlegen“. -- **KAT-02 (#4):** Route `/catalog/games/:id`, `onTap` der Treffer-Kachel verdrahten; Domänenmodell `CatalogGame` (Detail) neu, `CatalogGameSummary` bleibt für Listen. Alternative Titel sind in KAT-02 Krit. 1 nicht gefordert. -- **KAT-04 (#13):** `onReportGame`-Callback im Leerzustand setzen (Suchbegriff als vorbelegter Titel, Anmeldung über `from`). „Gleicher normalisierter Titel“ (Klärung 15) auf Basis von `search_normalize` plus Entfernen von Artikeln (eigene Funktion, nicht in `search_normalize`, sonst ändert sich die Suche); Trigramm-Ähnlichkeit über `extensions.similarity` auf `title_search`. Krit. 6 von KAT-01 wird mit #13 vollständig (Spec-F4). +- **KAT-05 2b/2c (#2):** Keine Änderung an 2b nötig. 3a ordnet `game_alt_titles.game_id` im Fremdschlüssel-Wächter von `07_catalog_merge.test.sql` als „bewusst ausgelassen“ ein (bleibt am Quell-Eintrag, passt zu `admin_merge_games`, das die Zuordnungen ebenfalls nicht umhängt; Spec-F6 A). Wird 2c nach 3a gemergt, übernimmt 2c diesen Eintrag, die Tabellenzahl und die `manual_fields`-Liste von `main` (3.3). Für das Runbook (Abschnitt Zusammenführen) ergänzt 3a (B4) den Schritt „vor dem Zusammenführen relevante Titel der Quelle als alternative Titel am Ziel anlegen“. +- **KAT-02 (#4):** Route `/catalog/games/:id`, `onTap` der Treffer-Kachel verdrahten; Domänenmodell `CatalogGame` (Detail) neu, `CatalogGameSummary` bleibt für Listen; Umwandlung von `image_path` in eine URL über die Dateizugriffsschicht. Alternative Titel sind in KAT-02 Krit. 1 nicht gefordert. +- **KAT-04 (#13):** + - `onReportGame`-Callback im Leerzustand setzen (Suchbegriff als vorbelegter Titel, Anmeldung über `from`). Krit. 6 von KAT-01 wird mit #13 vollständig (Spec-F4). + - **Normalisierung:** `public.normalize_game_title(t) = coalesce((search_normalize(t)), search_normalize(t), lower(btrim(t)))`, angelegt von #13 über der Basisfunktion aus 3a; Artikel werden nur dort entfernt (gemeinsamer Satz in 3.2 Nr. 2). + - **Alternative Titel in `find_similar_catalog_games` (13a):** Die Ähnlichkeitsprüfung („gleicher normalisierter Titel“ bzw. Trigramm-Ähnlichkeit, Klärung 15) bezieht alternative Titel verbindlich über `game_alt_titles.title_search` ein, Trigramm-Ähnlichkeit über `extensions.similarity`. + - pgTAP-Datei `10_…` (09 ist #3); 13a wird nach 3a gemergt. - **QUE-09 (#29):** Startseite ohne Anmeldung bettet `CatalogSearchView` ein. - **KTO-02 (#14), SAM-01, PAR-01 (#15):** Katalogsuche über `CatalogRepository.search` wiederverwenden. Wo vorläufige private Spiele wählbar sein sollen (Klärung 36), ist das eine eigene Abfrage auf eigene private Spiele, nicht Teil von `search_catalog_games` (die RPC liefert bewusst nur den Katalog). - **KAT-03 (#12):** neuer `manual_fields`-Schlüssel mit vollständiger Liste inkl. `alt_titles`. - **Import (SAM-08 #25, Import-Spec):** alternative Titel aus der Quelle schreibt der Import als `catalog_importer` (Ausnahme im Trigger greift bereits). +- **Hosting/Release (D1):** Mengenbegrenzung für `search_catalog_games` vor dem Go-live entscheiden; Krit. 7 gegen Staging als Freigabebedingung in `docs/release-checkliste.md`; Prüfabfrage aus R6 nach Image-Upgrades. ## 10. Änderungsprotokoll @@ -551,4 +599,17 @@ Erste Fassung durch architect, wartet auf Freigabe und Antworten zu Spec-F1 bis - § 7.1 Krit. 2: Idempotenz der alternativen Titel nach pgTAP verschoben (über die erzeugten `.inc`-Fixtures); Deno prüft nur das Skript. - § 7: Auswirkung auf bestehende Tests: `00_schema` nur Tabellenzahl; `06` Z. 532–535 prüft `manual_fields` von Testspiel A exakt → alternative Titel an Testspiel B (B3), sonst Anpassung in B2. -- Neuer Abschnitt 3.3: Merge-Reihenfolge 2b → 2c → 3a; 3a erweitert den Fremdschlüssel-Wächter in `07_catalog_merge.test.sql` um `game_alt_titles.game_id` („bewusst ausgelassen“, Spec-F6 A). Abschnitt „Doku zum Datenmodell“ wird 3.4. B2, B3, § 8.4, R7, 9.5 angepasst. +- Neuer Abschnitt 3.3 zur Reihenfolge mit 2b/2c; 3a erweitert den Fremdschlüssel-Wächter in `07_catalog_merge.test.sql` um `game_alt_titles.game_id` („bewusst ausgelassen“, Spec-F6 A). Abschnitt „Doku zum Datenmodell“ wird 3.4. + +### 2026-11-03 – Review (code-reviewer) + +- **B-1:** `search_normalize` ist für `anon`, `authenticated` und `service_role` ausführbar (generierte Spalten und `security invoker`); pgTAP-Fall umgedreht, neue Fälle „privates Spiel als `authenticated`“ und „Suche als `anon`“ (3.2, 4.1, 7.1, W9). +- **B-2:** Marken- und Schutzzeichen werden vor `unaccent` entfernt; Brüche und Apostroph festgelegt; Pflichtfälle `™`, `½`, `Æon’s End` (3.2 Nr. 2, 7.1). +- **B-3:** Eine Basisfunktion `search_normalize` (von #3), `normalize_game_title` in #13 darüber; gleichlautender Satz in beiden Specs; alternative Titel in `find_similar_catalog_games` (13a) über `game_alt_titles.title_search`; Testnummern 09/10; Begründung zu `immutable` richtiggestellt (3.2 Nr. 2, 9.5). +- **W-1:** 3a nach 2b; wer von 2c und 3a später merged, gleicht ab (3.3, R7, 8.4). +- **W-2:** Spec-F4 neu: Abschluss von #3 mit vorläufiger Messung, Staging-Messung als Release-Freigabebedingung (E12, 7.3, 8.3, R3). +- **W-3:** Indizes nicht mehr als Begründung für Geschwindigkeit; Messung als `anon` mit RLS; Stufen Kandidaten-CTE und `title_compact` (3.2 Nr. 3, 4.2, R2, B5). +- **W-4:** Begriff < 2 Zeichen = kein Suchbegriff, Wörter mit 1 Zeichen verworfen (4.2). +- **W-5:** `statement_timeout` an der Funktion wirkt nicht; Mengenbegrenzung je IP als Entscheidung vor dem Go-live (4.2, R4, D1). +- **W-6:** Prüfabfrage nach Image-Upgrades als Runbook-Schritt (R6, B4). +- Hinweise: Versatzgrenze und gewollte Durchblätterbarkeit (E8, 4.2, 5.4); `image_path` nur aus der View, `imageUrl` in #3 immer `null` (4.2, 5.2, 5.6); mehr als 10 Wörter → `search_query_too_long`; Dauer mit nur einer Angabe (Spec-F2); `perf`-Konfiguration (7.3); serielle E2E-Fixtures (7.2); Protokoll-Einstellungen (B5, R11); Ansage und Fokus (5.6); URL-Ersetzung mit `Router.neglect` (5.5); `;` in alternativen Titeln verboten (4.3); DTOs nur `json_serializable` (5.1); Kommentar zum Partial-Index (4.2). From 119267bb26fca5c5c4a7e410d1759187f4cbe93d Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 21:23:13 +0100 Subject: [PATCH 17/30] fix(db): check owner of provisional game in admin_resolve_report, harden report tests (KAT-05 2c review) Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/katalog-pflege.md | 6 ++- docs/specs/2-katalog-daten.md | 5 ++- .../20261103195151_game_reports.sql | 8 ++-- .../tests/database/08_game_reports.test.sql | 43 ++++++++++++++----- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index e55f5d2..2fea088 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -453,9 +453,11 @@ where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; | `report_status_invalid` | Der Status ist nicht `approved`, `merged` oder `rejected` | | `report_target_required` | `approved` und `merged` brauchen ein Ziel | | `report_target_not_listed` | Das Ziel ist kein sichtbares Katalogspiel (privat, ausgeblendet, zusammengeführt oder unbekannt); das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) | -| `report_provisional_not_private` | Der vorläufige Eintrag der Meldung ist kein privates Spiel (Datenfehler); nicht umhängen, sondern die Meldung prüfen | +| `report_provisional_not_private` | Der vorläufige Eintrag der Meldung ist kein privates Spiel **des Meldenden** (Datenfehler, z. B. ein Katalogspiel oder das Spiel eines anderen Nutzers); nicht umhängen, sondern die Meldung prüfen | -Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Meldungen anlegen, ändern oder löschen können Nutzer in dieser Etappe nicht (das Anlegen kommt mit KAT-04); sie lesen nur ihre eigenen. Das Löschen eines Zielspiels, auf das eine Meldung verweist, schlägt mit `23503` fehl (gewollt: ausblenden statt löschen). +**Falsch gesetzte `visibility` oder `owner_id`** lassen sich nicht mit `update` korrigieren (Trigger `games_visibility_immutable`, Fehler `visibility_immutable`). Korrigieren geht nur durch Löschen und Neuanlegen des Spiels oder, bewusst und nur als Tabelleneigentümer in einer Transaktion, mit `alter table public.games disable trigger games_visibility_immutable;` … `enable trigger …`. Für Clients: Der Fehler `P0001` kommt über PostgREST als HTTP 400 an (Hinweis für die Fehlerabbildung in #3 und #13). + +Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Hinweis: Die Rolle `service_role` hat (Supabase-Standard) volle Tabellenrechte und umgeht RLS; die Zugriffsgrenzen in Krit. 9 gelten für die API-Rollen `anon` und `authenticated`, den Funktionen ist `service_role` ausdrücklich entzogen. Meldungen anlegen, ändern oder löschen können Nutzer in dieser Etappe nicht (das Anlegen kommt mit KAT-04); sie lesen nur ihre eigenen. Das Löschen eines Zielspiels, auf das eine Meldung verweist, schlägt mit `23503` fehl (gewollt: ausblenden statt löschen). ## 7. Admin-Rolle in der App diff --git a/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index 5c4fd50..709f79f 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -723,7 +723,7 @@ Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_cata ### 2026-11-03 – Umsetzung PR 2c (backend-dev) -Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_reports.test.sql`, 153 Tests; `07` und `00` angepasst), B10 (Runbook Abschnitt 6). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR: +Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_reports.test.sql`, 157 Tests; `07` und `00` angepasst), B10 (Runbook Abschnitt 6). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR: - **Branch:** `feature/2c-katalog-meldungen` statt `feature/2-katalog-meldungen` (Vorgabe des Auftrags), gestapelt auf `feature/2b-katalog-zusammenfuehren`. - **Zusätzliche Indizes** auf beide Fremdschlüssel nach `games` (Regel: Index auf jeden Fremdschlüssel). `provisional_game_id` ist **eindeutig** (`where provisional_game_id is not null`, 1:1: ein vorläufiger Eintrag gehört zu höchstens einer Meldung), `target_game_id` ein einfacher Index. @@ -734,7 +734,8 @@ Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_ - **Ziel wird auch bei `rejected` geprüft** (`report_target_not_listed`), wenn eines angegeben ist. Reihenfolge der Prüfungen wie in 3.4; `p_report = null` ist `report_not_found`, `p_status = null` ist `report_status_invalid`. - **Trigger `games_visibility_immutable`** läuft vor der RLS-Prüfung: Ein Nutzer, der `visibility` ändert, bekommt `P0001 visibility_immutable` statt `42501` (API-Änderung für Clients; bestehende Tests in `02` bleiben grün, weil dort `owner_id` ohne Spaltenrecht vorher mit 42501 scheitert). Es gibt weiterhin keine generierten Datenbanktypen im Repo. - **RLS:** zwei Lese-Policies (eigene Zeilen; `is_admin()` für R4). Bewusst kein Test mit `is_admin()` als Zugang in R1, aber `08` dokumentiert, dass `app_metadata.role = 'admin'` lesen, nicht schreiben kann, und `user_metadata` nichts öffnet. -- **Tests stabil gegenüber #13:** `08` prüft Schreibrechte je Spalte (`has_column_privilege` auf `status`, `target_game_id`, `resolved_at`, `origin`, `owner_id`, `bgg_id`, `created_at`), „Delete verboten“ und „fremde Meldung nicht sichtbar/änderbar“, nicht „kein Insert/Update auf der Tabelle“. +- **Tests und #13:** `08` prüft Schreibrechte je Spalte: beim Anlegen sind `status`, `target_game_id`, `resolved_at`, `origin`, `created_at`, `updated_at` nicht setzbar (nicht pauschal „kein Insert“), ein Update-Recht gibt es auf keine Spalte außer (ab #13) `result_seen_at`. Dazu „Delete verboten“ und „fremde Meldung nicht sichtbar/änderbar“. Einzelne Insert-/Update-Assertions im Abschnitt „Lesen, RLS“ (z. B. Anna legt eine Meldung für Ben an) gehen von fehlenden Insert-Rechten aus; 13a muss diese anpassen, `08` ändert sich mit #13 ohnehin. +- **Review 2c:** `admin_resolve_report` prüft zusätzlich, dass der vorläufige Eintrag existiert und dem Meldenden gehört (`report_provisional_not_private`, deckt Katalogspiel, fremdes Spiel und fehlende Zeile ab); Test mit fremdem Spiel in `08`. Hinweise: `service_role` hat Supabase-Standardrechte auf Tabellen (Krit. 9 gilt für `anon`/`authenticated`); `P0001` → HTTP 400 in PostgREST; **der Fremdschlüssel-Wächter existiert in `07` und `08`: #3 (`game_alt_titles.game_id`) und jede weitere Tabelle mit Verweis auf `games` muss beide anpassen**. Optionaler Merkpunkt (nicht gebaut): ein Check-Trigger, dass `target_game_id` auf ein Katalogspiel zeigt (analog `games_merged_into_catalog`); heute stellt das nur `admin_resolve_report` sicher. - **Bewusst nicht gebaut (offene Entscheidung Chris, Spec #13):** `game_reports.result_seen_at` und `editions_cleared`. #13 ergänzt bei Freigabe das Spaltenrecht `update (result_seen_at)` und ggf. `editions_cleared` sowie Insert-Recht und -Policy; die Tests in `08` sind darauf vorbereitet. - **Fremdschlüssel-Wächter:** erweiterte Liste in `07` und in `08` (`game_reports.target_game_id` behandelt, `game_reports.provisional_game_id` bewusst ausgelassen). `00_schema.test.sql`: 21 Tabellen. - **Runbook Abschnitt 6:** Alle SQL-Bausteine laufen wörtlich in `08` (einmalige Prüfung per Skript, keine CI-Prüfung). Der Trockenlauf-Baustein aus 2b gilt entsprechend (`begin; …; rollback;` in einem Lauf). diff --git a/supabase/migrations/20261103195151_game_reports.sql b/supabase/migrations/20261103195151_game_reports.sql index 494a86c..c744f0b 100644 --- a/supabase/migrations/20261103195151_game_reports.sql +++ b/supabase/migrations/20261103195151_game_reports.sql @@ -133,9 +133,11 @@ begin if p_status in ('approved', 'merged') and rep.provisional_game_id is not null then select * into prov from public.games g where g.id = rep.provisional_game_id; - -- Zusatzprüfung (nicht in der Spec): Nur ein privater Eintrag darf als vorläufiger Eintrag - -- umgehängt werden; ein Katalogspiel würde sonst über den Variantenfall "Katalog" kopiert. - if prov.visibility <> 'private' then + -- Zusatzprüfung (nicht in der Spec): Nur ein privater Eintrag des Meldenden darf umgehängt + -- werden. Ein Katalogspiel würde sonst über den Variantenfall "Katalog" kopiert, und ein + -- fremdes privates Spiel (Datenfehler im Studio) würde Daten eines anderen Nutzers umhängen. + -- "not found" deckt eine fehlende Zeile ab (null <> 'private' wäre sonst nicht wahr). + if not found or prov.visibility <> 'private' or prov.owner_id is distinct from rep.owner_id then raise exception 'report_provisional_not_private' using errcode = 'P0001'; end if; -- privat -> Katalog: nichts wird kopiert (RB1, Klärung N15) diff --git a/supabase/tests/database/08_game_reports.test.sql b/supabase/tests/database/08_game_reports.test.sql index 8b6013a..85da731 100644 --- a/supabase/tests/database/08_game_reports.test.sql +++ b/supabase/tests/database/08_game_reports.test.sql @@ -5,7 +5,7 @@ -- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix 7c1d/f8), gezählt wird nur darüber. -- Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall, sonst würde "alles verweigern" bestehen. begin; -select plan(153); +select plan(157); insert into auth.users (id, email, raw_user_meta_data) values ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), @@ -19,12 +19,17 @@ select ok((select relrowsecurity from pg_class where oid = 'public.game_reports' 'Sicherheit Kriterium 1: game_reports hat Row Level Security'); select ok(has_table_privilege('authenticated', 'public.game_reports', 'select'), 'KAT-05 Kriterium 5: authenticated darf game_reports lesen (eigene Zeilen, Policy)'); --- Formuliert stabil gegenüber späteren Spaltenrechten (#13 ergänzt Insert-Recht und update (result_seen_at)): --- geprüft wird je Spalte, nicht "kein Insert/Update auf der Tabelle". -select is_empty($$select c, p from unnest(array['status', 'target_game_id', 'resolved_at', 'origin', 'owner_id', 'bgg_id', 'created_at']) c, - unnest(array['insert', 'update']) p - where has_column_privilege('authenticated', 'public.game_reports', c, p)$$, - 'KAT-05 Kriterium 9: authenticated hat kein Schreibrecht auf status, target_game_id, resolved_at, origin, owner_id, bgg_id, created_at'); +-- Formuliert stabil gegenüber späteren Spaltenrechten (#13 ergänzt Insert-Recht, u. a. auf owner_id und +-- provisional_game_id, und update (result_seen_at)): geprüft wird je Spalte. +-- Insert: nur Spalten, die der Nutzer nie setzen darf (Ergebnis und Herkunft der Meldung). +select is_empty($$select c from unnest(array['status', 'target_game_id', 'resolved_at', 'origin', 'created_at', 'updated_at']) c + where has_column_privilege('authenticated', 'public.game_reports', c, 'insert')$$, + 'KAT-05 Kriterium 9: authenticated kann beim Anlegen status, target_game_id, resolved_at, origin, created_at, updated_at nicht setzen'); +-- Update: auf keine Spalte außer (ab #13) result_seen_at +select is_empty($$select a.attname from pg_attribute a + where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped and a.attname <> 'result_seen_at' + and has_column_privilege('authenticated', 'public.game_reports', a.attname, 'update')$$, + 'KAT-05 Kriterium 9: authenticated hat auf keine Spalte von game_reports ein Update-Recht (ausgenommen ab #13 result_seen_at)'); select is_empty($$select c, p from (select attname as c from pg_attribute where attrelid = 'public.game_reports'::regclass and attnum > 0 and not attisdropped) a, unnest(array['insert', 'update']) p where has_column_privilege('anon', 'public.game_reports', c, p)$$, @@ -348,7 +353,7 @@ insert into public.games (id, title, visibility, owner_id) values create temp table rb_before as select (select count(*) from public.game_reports where status <> 'open') as erledigt, (select count(*) from public.games where hidden_at is not null) as ausgeblendet, - (select string_agg(id::text || target_game_id::text || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%') as meldungen; + (select string_agg(id::text || coalesce(target_game_id::text, '-') || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%') as meldungen; select throws_ok($$select public.admin_resolve_report(gen_random_uuid(), 'rejected')$$, 'P0001', 'report_not_found', 'KAT-05 Kriterium 5: unbekannte Meldung (report_not_found)'); @@ -379,6 +384,24 @@ select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-0 select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected', 'f8000000-0000-0000-0000-000000000d01')$$, 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: auch bei rejected wird das Ziel geprüft'); +-- Meldung MIT vorläufigem Eintrag und ungültigem Ziel: die Zielprüfung der Funktion greift zuerst +-- (sonst käme repoint_target_not_listed aus der internen Funktion) +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', 'f8000000-0000-0000-0000-000000000d01')$$, + 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: Meldung mit vorläufigem Eintrag und ausgeblendetem Ziel (report_target_not_listed)'); + +-- Datenfehler: der vorläufige Eintrag gehört einem anderen Nutzer (Zusatzprüfung gegenüber der Spec) +insert into public.games (id, title, visibility, owner_id) values + ('f8000000-0000-0000-0000-000000000e11', 'Annas fremdes Spiel', 'private', '11111111-1111-1111-1111-111111111111'); +insert into public.collection_items (id, owner_id, game_id, list) values + ('f8000000-0000-0000-0000-000000000e12', '11111111-1111-1111-1111-111111111111', 'f8000000-0000-0000-0000-000000000e11', 'owned'); +insert into public.game_reports (id, owner_id, title, provisional_game_id) values + ('f8000000-0000-0000-0000-000000000e02', '22222222-2222-2222-2222-222222222222', 'Bens Meldung auf Annas Spiel', 'f8000000-0000-0000-0000-000000000e11'); +select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000e02', 'merged', '7c1d2c3d-0000-4000-8000-000000000004')$$, + 'P0001', 'report_provisional_not_private', 'Zusatzprüfung: ein privates Spiel eines anderen Nutzers wird nicht umgehängt'); +select is((select game_id::text || ':' || (select hidden_at is null and merged_into_id is null from public.games where id = 'f8000000-0000-0000-0000-000000000e11')::text + from public.collection_items where id = 'f8000000-0000-0000-0000-000000000e12'), + 'f8000000-0000-0000-0000-000000000e11:true', 'Annas Daten und ihr Spiel bleiben unverändert'); + -- Datenfehler: vorläufiger Eintrag ist ein Katalogspiel (Zusatzprüfung gegenüber der Spec) insert into public.game_reports (id, owner_id, title, provisional_game_id) values ('f8000000-0000-0000-0000-000000000e01', '22222222-2222-2222-2222-222222222222', 'Vorläufig ist Katalog', 'f8000000-0000-0000-0000-00000000000e'); @@ -391,7 +414,7 @@ select is((select hidden_at is null and merged_into_id is null from public.games select is((select row(erledigt, ausgeblendet, meldungen)::text from rb_before), (select row((select count(*) from public.game_reports where status <> 'open'), (select count(*) from public.games where hidden_at is not null), - (select string_agg(id::text || target_game_id::text || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%'))::text), + (select string_agg(id::text || coalesce(target_game_id::text, '-') || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%'))::text), 'Nach allen Fehlerfällen ist nichts verändert (Meldungen, Ausblendungen)'); -- --------------------------------------------------------------------------------------------- @@ -507,7 +530,7 @@ select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4 'Das neue Katalogspiel erscheint in catalog_games'); select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 0::bigint, 'Der vorläufige Eintrag erscheint nie in catalog_games'); -select is((select resolved_at is not null and updated_at >= created_at from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'), true, +select is((select resolved_at is not null and updated_at > '2026-01-01' from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'), true, 'resolved_at ist gesetzt'); -- Verweise From a4c7b47073afc9df3da02f1a6efe45f675ca3e23 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:30:15 +0000 Subject: [PATCH 18/30] docs(catalog): Spec KAT-04 an Spec #3 und PR 2c angeglichen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kandidatenschlüssel über title_search, gemeinsame Normalisierung wörtlich aus Spec #3, pg_trgm aus 3a, Abhängigkeiten 3a/3b, Spec-F6 korrigiert (nur 2c-Nachtrag, vor Merge von PR #57). Refs #13 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/13-fehlendes-spiel-melden.md | 106 ++++++++++++++---------- 1 file changed, 61 insertions(+), 45 deletions(-) diff --git a/docs/specs/13-fehlendes-spiel-melden.md b/docs/specs/13-fehlendes-spiel-melden.md index bae1af4..14558d2 100644 --- a/docs/specs/13-fehlendes-spiel-melden.md +++ b/docs/specs/13-fehlendes-spiel-melden.md @@ -3,12 +3,12 @@ | | | |---|---| | Issue | #13 (KAT-04) | -| Status | Entwurf, Review-Befunde eingearbeitet; wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2) | +| Status | Entwurf, Review-Befunde eingearbeitet; wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2). **Spec-F6 muss vor dem Merge von PR #57 (2c) entschieden werden.** | | 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 | +| 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). @@ -27,12 +27,12 @@ Diese Spec baut auf `game_reports` und `admin_resolve_report` aus Spec #2 § 3.4 | 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). | +| 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 | **„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. | +| 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; 13a setzt ihn in `adopt_report_target` genauso. 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` gehört zum Supabase-Postgres-Image (ADR-0002). | CLAUDE.md, ADR-0002. | +| 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 @@ -66,25 +66,21 @@ Diese Spec baut auf `game_reports` und `admin_resolve_report` aus Spec #2 § 3.4 ## 3. Datenmodell -### 3.1 Ausgangslage (nach #2 PR 2c und #3) +### 3.1 Ausgangslage (nach #2 PR 2c und #3 PR 3a) Aus Spec #2 § 3.4 (Spaltennamen werden so vorausgesetzt; weicht 2c ab, passt B1 die Namen an): - `game_reports` mit `id`, `owner_id` (FK → `profiles`, `on delete cascade`), `origin` (`user`/`import`, Standard `user`), `title` (1–200), `publisher_name` (≤ 200), `year` (1800–2100), `link` (≤ 500, `^https?://`), `bgg_id`, `provisional_game_id` (FK → `games`, `on delete set null`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`, `on delete restrict`), `resolved_at`, `created_at`, `updated_at`; Checks zu Status, Ziel und `resolved_at`. - **Indizes aus 2c:** `(owner_id, created_at)` (Tageslimit), `game_reports_target_game_id_idx` (Hinweis am Zieleintrag), eindeutiger Teilindex auf `provisional_game_id` (E1). **13a legt keine eigenen Indizes an.** - RLS: Lesen nur eigene, kein Insert/Update/Delete für `authenticated`. -- `admin_resolve_report(p_report, p_status, p_target)`: bei `approved`/`merged` Umhängen per `repoint_game_references`, am vorläufigen Eintrag `merged_into_id` und `hidden_at`; bei `rejected` nur der Verweis (oder nichts). +- `admin_resolve_report(p_report, p_status, p_target)`: bei `approved`/`merged` Umhängen per `repoint_game_references`, am vorläufigen Eintrag `merged_into_id` und `hidden_at`; bei `rejected` nur der Verweis (oder nichts). Gibt die Zähler aus `repoint_game_references` zurück, darunter `variants_cleared` (geleerte Verweise in `collection_items` inkl. Kollisionsfall und in `plays`), speichert sie aber nicht (Stand PR #57). - Trigger `games_visibility_immutable`: ein vorläufiger Eintrag kann nie öffentlich werden (#2 E14). -Aus Spec #3: `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. +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/_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; -``` +**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`** @@ -92,7 +88,7 @@ create extension if not exists pg_trgm with schema extensions; |---|---|---| | `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. +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) @@ -102,7 +98,7 @@ grant insert (owner_id, title, publisher_name, year, link, provisional_game_id) 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. +**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. @@ -119,7 +115,7 @@ grant update (result_seen_at) on public.game_reports to authenticated; - `create trigger … before insert on public.game_reports for each row when (new.origin = 'user') execute function public.game_reports_daily_limit()`. - Sperre gegen gleichzeitige Inserts: `perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0))`. **Setzt die Isolationsstufe READ COMMITTED voraus** (Standard bei PostgREST und Supabase): Die Zählung nach der Sperre sieht dann die inzwischen bestätigten Meldungen der anderen Transaktion. Unter REPEATABLE READ oder SERIALIZABLE wäre die Sperre wirkungslos, weil der Snapshot vor der Sperre feststeht. -- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `public.report_day(created_at) = public.report_day(now())`. Bei ≥ 10 Fehler `report_daily_limit` (P0001). Die Zahl 10 ist eine Konstante in der Funktion; Änderung = neue Migration. +- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `public.report_day(created_at) = public.report_day(now())`. Bei ≥ 10 Fehler `report_daily_limit` (P0001). Die Zahl 10 ist eine Konstante in der Funktion; Änderung = neue Migration. Optional (backend-dev): als Bereich `created_at >= (date_trunc('day', now() at time zone 'Europe/Berlin') at time zone 'Europe/Berlin')` formulieren, damit der Index `(owner_id, created_at)` greift; die Bedeutung bleibt gleich. - Hilfsfunktion `public.report_day(p_at timestamptz) returns date` = `(p_at at time zone 'Europe/Berlin')::date`, `language sql`, `immutable`, `search_path = ''`. **`grant execute … to authenticated`** (rechnet nur): Der Trigger läuft als `security invoker` mit der Rolle des Einfügenden; ohne dieses Recht scheitert jede Meldung mit 42501. Herausgezogen, damit pgTAP die Tagesgrenze mit festen Zeitstempeln prüfen kann. - Trigger-Funktion `security invoker`, `search_path = ''`: Der Nutzer sieht per RLS alle eigenen Meldungen; Studio (`postgres`) umgeht RLS ohnehin. - Import-Meldungen (`origin = 'import'`, SAM-08) werden weder gezählt noch geprüft (N12). @@ -128,21 +124,29 @@ grant update (result_seen_at) on public.game_reports to authenticated; ### 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. +> **Gemeinsame Normalisierung (gleichlautend in Spec #3 und Spec #13):** `public.search_normalize(text)` ist die einzige Basisfunktion für normalisierte Titel. Sie wird von #3 (PR 3a) angelegt, ist rein rechnend und für `anon`, `authenticated` und `service_role` ausführbar, weil generierte Spalten und `security invoker`-Funktionen sie mit den Rechten des Aufrufers berechnen. #13 legt `public.normalize_game_title(text)` darüber an und entfernt nur dort Artikel; `search_normalize` entfernt nie Artikel. -**In 13a neu** (beide `language sql`, `immutable`, `parallel safe`, `search_path = ''`, `grant execute … to anon, authenticated`): +`unaccent` mit festem Wörterbuch als `immutable` zu deklarieren ist eine verbreitete Konvention, keine Garantie von PostgreSQL. + +**In 13a neu** (beide `language sql`, `immutable`, `parallel safe`, `search_path = ''`, `grant execute … to authenticated`; `anon` braucht sie nicht): - `public.strip_leading_article(t text) returns text`: entfernt **einmal ein führendes** Artikelwort (Deutsch `der`, `die`, `das`, `den`, `dem`, `des`, `ein`, `eine`, `einen`, `einem`, `einer`, `eines`; Englisch `the`, `a`, `an`) samt folgendem Leerzeichen; erwartet bereits normalisierten Text (Kleinbuchstaben, ein Leerzeichen als Trenner). - `public.normalize_game_title(t text) returns text` = `coalesce(nullif(public.strip_leading_article(public.search_normalize(t)), ''), nullif(public.search_normalize(t), ''), lower(btrim(t)))`. - Der letzte Rückfall greift bei Titeln, die nach `search_normalize` leer sind (z. B. nur nicht-lateinische Schriftzeichen oder nur Sonderzeichen). + Normalisiert den **gemeldeten** Titel. Der letzte Rückfall greift bei Titeln, die nach `search_normalize` leer sind (z. B. nur nicht-lateinische Schriftzeichen oder nur Sonderzeichen). + +**Kandidatenschlüssel** je Kandidatentitel `c` eines Katalogspiels `g` (verbindlich, Spec #3 3.2 Nr. 4): Haupttitel über `games.title_search`, alternative Titel über `game_alt_titles.title_search` (nicht neu aus dem Rohtitel berechnet): + +`key(c) = coalesce(nullif(public.strip_leading_article(c.title_search), ''), c.title_search, lower(btrim(c.title)))` + +Der letzte Rückfall `lower(btrim(c.title))` gilt nur beim Haupttitel (für alternative Titel gibt es keinen Rohtitel-Rückfall). Beispiele (pgTAP; das genaue Ergebnis von `search_normalize` legt #3 fest): „Die Siedler von Catan“ und „siedler von catan“ ergeben denselben Wert; „CATAN!“ = „Catan“; „The Crew“ = „Crew“; „Das“ bleibt nicht leer; „Mölkky“ = „Molkky“ (Faltung durch `unaccent`); ein Titel nur aus japanischen Schriftzeichen → `lower(btrim(t))`. -**Regel „sehr ähnlich“** zwischen Meldung (Titel `t`, Jahr `y`) und Katalogspiel `g` mit Haupttitel und allen alternativen Titeln aus `game_alt_titles` (Kandidatentitel `c`, **verpflichtend**): +**Regel „sehr ähnlich“** zwischen Meldung (Titel `t`, Jahr `y`) und Katalogspiel `g` mit Haupttitel und allen alternativen Titeln (Kandidatentitel `c`, **verpflichtend**): -- **A (gleicher Titel):** `normalize_game_title(c) = normalize_game_title(t)` für mindestens ein `c`, unabhängig vom Jahr. -- **B (Trigramm):** `extensions.similarity(normalize_game_title(c), normalize_game_title(t)) >= 0.8` für mindestens ein `c` **und** `abs(g.year - y) <= 1`. Fehlt `y` oder `g.year`, gilt B ohne Jahresbedingung (Spec-F2). +- **A (gleicher Titel):** `key(c) = normalize_game_title(t)` für mindestens ein `c`, unabhängig vom Jahr. +- **B (Trigramm):** `extensions.similarity(key(c), normalize_game_title(t)) >= 0.8` für mindestens ein `c` **und** `abs(g.year - y) <= 1`. Fehlt `y` oder `g.year`, gilt B ohne Jahresbedingung (Spec-F2). - Je Spiel zählt die beste Übereinstimmung über alle Kandidatentitel. - Nur sichtbare Katalogspiele (`catalog_games`): keine ausgeblendeten, zusammengeführten oder privaten (auch nicht eigene vorläufige, Spec-F7). - In Funktionen mit `search_path = ''` ausdrücklich `extensions.similarity(...) >= 0.8` verwenden, **nicht** den Operator `%` (nutzt `pg_trgm.similarity_threshold`, Standard 0,3). Deshalb kein Trigramm-Index in 13a; bei ~200–2000 Katalogspielen reicht ein sequenzieller Durchlauf. @@ -153,7 +157,7 @@ Beispiele (pgTAP; das genaue Ergebnis von `search_normalize` legt #3 fest): „D | Objekt | Art | `execute` | Zweck | |---|---|---|---| -| `strip_leading_article(text)`, `normalize_game_title(text)` | Funktion, sql, immutable | `anon`, `authenticated` | Normalisierung (3.3) | +| `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) | @@ -220,7 +224,7 @@ returns table ( ) ``` -`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`. +`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. 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` @@ -243,7 +247,7 @@ adopt_report_target(p_report uuid) returns jsonb -- Zähler aus repoint_game_r | 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`. +Ablauf: `result := 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 = coalesce(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 = coalesce((result->>'variants_cleared')::int, 0)`, genau wie `admin_resolve_report`. Rückgabe: `result`. **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`. @@ -259,8 +263,8 @@ Kein RPC. Die App sendet `PATCH /rest/v1/game_reports?id=eq.` mit genau `{'r ### 4.7 Doku -- `docs/datenmodell-r1.md`: `result_seen_at`, Spaltenrechte, Policies, Trigger, Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes; Satz zur Zuständigkeit für `search_normalize` (3.3). -- `docs/katalog-pflege.md`, Abschnitt „Meldungen bearbeiten“ (2c) ergänzen: Nutzer-Meldungen haben einen vorläufigen Eintrag (sofern nicht gelöscht); „abgelehnt mit Verweis“ lässt dem Nutzer die Wahl (`adopt_report_target`); Status nie von Hand setzen, nur über `admin_resolve_report`, weil die App den Hinweis aus Status und `merged_into_id` ableitet. +- `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); bei „abgelehnt mit Verweis“ hängt der Admin nichts um, der Nutzer entscheidet selbst (`adopt_report_target`); vor `approved` die Edition berücksichtigen (Runbook 2c); Status nie von Hand setzen, nur über `admin_resolve_report`, weil die App den Hinweis aus Status und `merged_into_id` ableitet. - `supabase/README.md`: neue RPCs und Rechte. ## 5. Frontend @@ -298,7 +302,7 @@ Die Domäne importiert weder Flutter noch Supabase; Validierung in `GameReportDr | Route | Screen | Hinweis | |---|---|---| -| `/reports/new?title=` | `ReportFormScreen` | Einstieg aus der leeren Trefferliste von KAT-01 (#3 Krit. 6); Anmeldung nötig (Redirect `/login?from=…`, Spec #1) | +| `/reports/new?title=` | `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“ | @@ -373,10 +377,10 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We | 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. 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`, `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. 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, bei Zähler `editions_cleared` gesetzt; 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 | @@ -387,7 +391,7 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We **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`. +**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 @@ -395,8 +399,8 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We | # | 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 | +| B1 | backend-dev | Migration nach 3.2–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`), `report_missing_game`, `adopt_report_target` (Sperrreihenfolge, `is distinct from`); Rechte nach 4.1 | #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 | @@ -406,14 +410,15 @@ Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (We |---|---|---|---| | 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 | +| 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: #2 PR 2b → 2c gemergt; #3 KAT-01 gemergt +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 @@ -429,17 +434,18 @@ F1 und U1 starten nach Freigabe der Spec, parallel zu B1. Merge nur durch Chris. | # | 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 | +| 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. Zur Sperrreihenfolge: `adopt_report_target` und `admin_resolve_report` sperren erst die Meldung, dann die Spiele; `admin_merge_games` sperrt erst Spiele, dann Meldungen. Eine Verklemmung ist deshalb möglich, PostgreSQL erkennt sie und bricht eine Transaktion ab; Daten sind nicht gefährdet. | | 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 | +| 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) -Keine Frage blockiert die Umsetzung: Ohne andere Entscheidung gilt mit der Freigabe die Empfehlung. +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 | |---|---|---|---| @@ -448,7 +454,7 @@ Keine Frage blockiert die Umsetzung: Ohne andere Entscheidung gilt mit der Freig | 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-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. | @@ -456,13 +462,13 @@ Keine Frage blockiert die Umsetzung: Ohne andere Entscheidung gilt mit der Freig ### 9.3 Entscheidungen innerhalb der Architektur (kein ADR nötig) - RPC als Bündelung mit `security invoker`, Rechte in der Datenbank; nur `adopt_report_target` ist `security definer` (E2, E3, E5). -- Normalisierung gehört #3; 13a ergänzt nur den Artikel-Wrapper (E4). +- 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 (2b/2c), falls Spec-F6 = Zähler:** Nachtrag vor dem Bau (Spalte `editions_cleared`, Zähler der geleerten Verweise, Setzen in `admin_resolve_report`). -- **KAT-01 (#3):** derselbe Satz zu Zuständigkeit und Rechten von `search_normalize` (3.3); Einstieg `/reports/new?title=` (Krit. 6); `game_alt_titles` wird von `find_similar_catalog_games` gelesen. +- **Spec #2 / PR 2c (PR #57), falls Spec-F6 = Zähler:** vor dem Merge Spalte `editions_cleared` anlegen und in `admin_resolve_report` setzen; 2b unverändert. +- **KAT-01 (#3):** Einstieg `/reports/new?title=` über `onReportGame` (PR 3b, Krit. 6); `find_similar_catalog_games` liest `games.title_search` und `game_alt_titles.title_search`. - **KAT-02 (#4), SAM-01 (#6), SAM-03 (#11):** `ReportOutcomeNotice` und `reportNoticeForGameProvider` einbauen; Badge „vorläufig“; eigene private Spiele direkt aus `games` lesen. - **KAT-03 (#12):** Aktion „Edition wählen“ anbinden. - **KTO-02 (#14):** `favorite_games_own` erlaubt derzeit jedes sichtbare Spiel, auch eigene vorläufige; Klärung 23 verlangt Katalogspiele. #14 schränkt die Policy (neue Migration) auf ein **sichtbares Katalogspiel** ein (Bedingung wie `catalog_games`: `visibility = 'catalog' and hidden_at is null and merged_into_id is null`). @@ -473,15 +479,16 @@ Keine Frage blockiert die Umsetzung: Ohne andere Entscheidung gilt mit der Freig ### 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. +- **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. -- 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. +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) @@ -493,3 +500,12 @@ Erste Fassung durch architect, inkl. Hinweis aus PR #51 (Klärung N15). Nachtrag - Indizes aus 2c übernommen; Testdatei `10_…`; Tabellenzahl unverändert gegenüber `main`. - Edition: Ableitung nur `list = 'owned'`; Spec-F6 neu bewertet, Empfehlung Zähler `editions_cleared`. - `p_year integer`, Titel > 200 → leere Menge, Limit-Vorprüfung vor der Ähnlichkeit, Großbuchstaben-Testfall, Partien/Mitspieler, Hinweis #14 präzisiert. + +### 2026-11-03 – Konsistenz-Review mit Spec #3 + +- **B-1:** Kandidatenschlüssel `key(c)` über `games.title_search` und `game_alt_titles.title_search` (Spec #3 3.2 Nr. 4); Regeln A und B darauf; `find_similar_catalog_games` verbindet `catalog_games` per Primärschlüssel mit `games`; Testfall über `title_search` des alternativen Titels. +- **B-2:** Spec-F6 korrigiert: 2b braucht keinen Nachtrag (`variants_cleared` gibt es schon); Nachtrag nur in 2c (PR #57), Entscheidung vor dessen Merge; E7, 9.2, 9.4, 9.5 angepasst. +- **W-1:** `pg_trgm` und `unaccent` kommen aus 3a; Codeblock in 3.2 und `pg_trgm` in B1 gestrichen; E9 angepasst. +- **W-2:** gemeinsamer Absatz zur Normalisierung wörtlich aus Spec #3 übernommen; veraltete Hinweise in 9.4/9.5 gestrichen. +- **W-3:** Vorbedingungen 13a (2b, 2c, 3a) und 13b (3b) präzisiert. +- Hinweise: R9 (Locale) aufgenommen; Runbook-Satz in 4.7; `hidden_at = coalesce(hidden_at, now())`; Sperrreihenfolge gegenüber `admin_merge_games` in R2; optionale Bereichsform des Limits; Testbeschreibungen in `08` statt Erwartungen; Wrapper-Funktionen nur für `authenticated`. From 2513ed8ef2134b4c195666d6ddda1f988fd1e211 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:32:09 +0000 Subject: [PATCH 19/30] docs(catalog): Hinweise aus dem Konsistenz-Review in die KAT-01-Spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand von 2c (PR #57) und Fremdschlüssel-Wächter in 07 und 08, normalize_game_title ausgeschrieben, Release-Checkliste als offener PR #54 gekennzeichnet, Änderungsprotokoll nur ergänzt. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/3-katalog-suche.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/specs/3-katalog-suche.md b/docs/specs/3-katalog-suche.md index d946bb7..e419f1a 100644 --- a/docs/specs/3-katalog-suche.md +++ b/docs/specs/3-katalog-suche.md @@ -190,9 +190,9 @@ Hinweise: ### 3.3 Reihenfolge zu 2b und 2c (Spec #2) -**3a wird nach 2b gemergt.** 2b (`admin_merge_games`, `repoint_game_references`, Fremdschlüssel-Wächter in `07_catalog_merge.test.sql`, Branch `feature/2b-katalog-zusammenfuehren`) ist in Arbeit; 2c (`game_reports`) existiert noch nicht auf `origin`. Ob 2c vor oder nach 3a gemergt wird, ist offen. **Wer von 2c und 3a später merged, gleicht ab:** +**3a wird nach 2b gemergt.** 2b (`admin_merge_games`, `repoint_game_references`, Fremdschlüssel-Wächter in `07_catalog_merge.test.sql`, Branch `feature/2b-katalog-zusammenfuehren`) ist in Arbeit; 2c (`feature/2c-katalog-meldungen`, PR #57, gestapelt auf PR #56) ist im Review; sein `07` enthält bereits `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (ausgelassen), und `08_game_reports.test.sql` enthält einen zweiten Fremdschlüssel-Wächter. Ob 2c vor oder nach 3a gemergt wird, ist offen. **Wer von 2c und 3a später merged, gleicht ab:** -- **Fremdschlüssel-Wächter in `07_catalog_merge.test.sql`:** 3a trägt `('game_alt_titles.game_id')` im `set_eq` in die Gruppe **„bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag“** ein (neben `game_genres`, `game_mechanics`, `game_categories`, `game_publishers`), gemäß Spec-F6 A. 2c ergänzt `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (ausgelassen). Der spätere PR übernimmt die Einträge des früheren. `admin_merge_games`/`repoint_game_references` bleiben für 3a unverändert. +- **Fremdschlüssel-Wächter in `07_catalog_merge.test.sql` und `08_game_reports.test.sql`:** 3a trägt in beiden Dateien `('game_alt_titles.game_id')` im `set_eq` in die Gruppe **„bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag“** ein (neben `game_genres`, `game_mechanics`, `game_categories`, `game_publishers`), gemäß Spec-F6 A. 2c ergänzt `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (ausgelassen). Der spätere PR übernimmt die Einträge des früheren. `admin_merge_games`/`repoint_game_references` bleiben für 3a unverändert. - **`00_schema.test.sql`:** Tabellenzahl von `main` beim Merge + 1 (3a: `game_alt_titles`; 2c: `game_reports`). - **`manual_fields`-Check:** vollständige Liste von `main` übernehmen (3.2 Nr. 5). - **Migration:** Zeitstempel von `_catalog_search.sql` liegt nach den Migrationen, die beim Merge auf `main` sind; vor dem PR auf `main` aktualisieren und den Zeitstempel bei Bedarf neu vergeben, solange 3a nicht gemergt ist. @@ -429,7 +429,7 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut - **`00_schema.test.sql`:** nur die Tabellenzahl (+ 1 gegenüber dem Stand von `main` beim Merge von 3a, siehe 3.3). Es gibt keine `columns_are`-Prüfungen; die neuen generierten Spalten `title_search`/`name_search` brechen dort nichts. - **`06_catalog_foundation.test.sql`:** Die Spaltenrechte-Prüfungen (Z. 293–302) arbeiten mit festen Spaltenlisten und sind von den generierten Spalten nicht betroffen. **Aber Z. 532–535 prüft `manual_fields` von „Testspiel A“ exakt.** Bekäme Testspiel A alternative Titel, käme `alt_titles` hinzu und der Test würde rot. Vorgabe für B3: die alternativen Titel in `supabase/catalog/testdata/startbestand.csv` an **Testspiel B** hängen (Testspiel A bleibt unverändert); nur falls das nicht geht, passt B2 die erwartete Liste in Z. 532–535 an. Testspiel B hat bereits zwei Verlage und eignet sich auch für Krit. 3. -- **`07_catalog_merge.test.sql`** (2b): Fremdschlüssel-Wächter um `game_alt_titles.game_id` erweitern (3.3). +- **`07_catalog_merge.test.sql`** (2b) **und `08_game_reports.test.sql`** (2c, PR #57): Der Fremdschlüssel-Wächter existiert in beiden Dateien; 3a trägt `game_alt_titles.game_id` („bewusst ausgelassen“) in **beiden** ein (3.3). Ist 2c beim Merge von 3a noch nicht auf `main`, übernimmt 2c den Eintrag in `08`. ### 7.1 Abdeckungsmatrix @@ -468,7 +468,7 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut - Ablauf: Web-App öffnen, Semantics einschalten; aus einer festen Liste von mindestens 100 Suchen (vollständige Titel, Teile, alternative Titel, Verlage, Wortkombinationen, nur Filter) je Suche das Feld leeren, den Begriff tippen, **Zeit ab dem letzten Tastendruck bis zur Sichtbarkeit der ersten Treffer-Kachel** (bzw. des Leerzustands bei absichtlich leeren Suchen) messen. Die erste Suche nach dem Laden der App zählt mit (Verbindungsaufbau). - **Umgebung (Spec-F4):** - **Vorläufig (solange es kein Staging gibt):** lokaler bzw. CI-Stack (`supabase start`, Web-Build) mit Startbestand und zusätzlich synthetischem Volumen in der Größenordnung von B5 (Ladeskript nur lokal), Drosselung wie oben. Ergebnis im PR 3c. - - **Maßgeblich:** dieselbe Messung gegen Staging, sobald es existiert (D1). Sie wird als Freigabebedingung in die Release-Checkliste `docs/release-checkliste.md` (PR #54) aufgenommen. + - **Maßgeblich:** dieselbe Messung gegen Staging, sobald es existiert (D1). Sie wird als Freigabebedingung in die Release-Checkliste `docs/release-checkliste.md` (PR #54, noch offen) aufgenommen. - Ergebnis: p50, p95, Maximum als Testausgabe und Anhang; **bestanden, wenn p95 ≤ 2000 ms**. - Mobile Apps: Stichprobe von 20 Suchen auf einem Android-Gerät oder Emulator mit 4G-Profil (Emulator-Netzwerkprofil bzw. Network Link Conditioner auf iOS), manuell durch Chris oder test-engineer, Ergebnis im PR 3c. Die Web-Messung gilt als maßgeblicher Nachweis; Abweichung im PR begründet. @@ -481,7 +481,7 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut | # | Agent | Aufgabe | abhängig von | |---|---|---|---| | B1 | backend-dev | Migration `_catalog_search.sql` nach 3.2: Erweiterungen, `search_normalize` (Schritte und Rechte nach 3.2 Nr. 2, `execute` für `anon`, `authenticated`, `service_role`), generierte Spalten + Trigramm-Indizes, `game_alt_titles` (Checks, Unique, Trigger `set_updated_at` und `game_links_mark_manual('alt_titles')`, RLS, Grants), `manual_fields`-Check mit vollständiger Liste + `alt_titles`, RPC `search_catalog_games` nach 4.2 (Prüfungen, Treffer-Regel, Relevanz, Filter, Sortierung, Kommentar zum Partial-Index, Rechte: `revoke` von allen, dann `grant execute` an `anon`, `authenticated`) | 2b gemergt (3.3) | -| B2 | backend-dev | pgTAP `09_catalog_search.test.sql` nach 7.1 (inkl. Rechte von `search_normalize`, privates Spiel als `authenticated`, Suche als `anon`, Idempotenz der alternativen Titel über die `.inc`-Fixtures); `00_schema.test.sql` Tabellenzahl; Fremdschlüssel-Wächter in `07_catalog_merge.test.sql` um `game_alt_titles.game_id` („bewusst ausgelassen“) ergänzen (3.3); `06` Z. 532–535 nur anpassen, falls B3 Testspiel A ändern muss | B1, B3 | +| B2 | backend-dev | pgTAP `09_catalog_search.test.sql` nach 7.1 (inkl. Rechte von `search_normalize`, privates Spiel als `authenticated`, Suche als `anon`, Idempotenz der alternativen Titel über die `.inc`-Fixtures); `00_schema.test.sql` Tabellenzahl; Fremdschlüssel-Wächter in `07_catalog_merge.test.sql` und `08_game_reports.test.sql` um `game_alt_titles.game_id` („bewusst ausgelassen“) ergänzen (3.3); `06` Z. 532–535 nur anpassen, falls B3 Testspiel A ändern muss | B1, B3 | | B3 | backend-dev | Startbestand-Werkzeug nach 4.3: `alt_titles` parsen, prüfen (inkl. `;` verboten), SQL erzeugen; Deno-Tests (neu und geänderter Test Z. 253); Testdaten-CSV: alternative Titel (de und en) an **Testspiel B**, nicht an Testspiel A; alle erzeugten Dateien neu erzeugen | B1 | | B4 | backend-dev | Doku nach 3.4: `docs/datenmodell-r1.md`, `supabase/README.md`, `docs/katalog-pflege.md` (Abschnitte „Alternative Titel pflegen“, „Nach einem Image-Upgrade: Normalisierung prüfen“ mit der Prüfabfrage aus R6, Schritt im Abschnitt Zusammenführen aus 9.5; SQL-Bausteine wie in pgTAP ausgeführt) | B1, B3 | | B5 | backend-dev | Annahmen prüfen und messen: Erweiterungen lokal verfügbar (Staging, sobald vorhanden, mit devops, D1); Rechte der API-Rollen auf `extensions.unaccent`/`similarity`; `statement_timeout` von `anon`/`authenticated`; Protokoll-Einstellungen (`log_min_duration_statement`, `log_statement`, `log_parameter_max_length`, `log_parameter_max_length_on_error`) – Suchbegriffe dürfen nicht in Datenbank-Logs landen, sonst Einstellung bzw. Abweichung dokumentieren; Skript `supabase/tests/perf/catalog_search_perf.sql` mit synthetischen Daten (nur lokal, zurückgerollte Transaktion), Messung **als `anon` mit RLS**, `explain (analyze, buffers)` und p95 im PR dokumentieren; bei Verfehlen die Stufen aus 4.2 Leistung | B1 | @@ -504,7 +504,7 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut |---|---|---|---| | T4 | test-engineer | Web-E2E `kat01-suche.spec.ts` nach 7.2 inkl. Fixture-Helfer, Aufräumen und serieller Ausführung bzw. Setup-Projekt | 3a, 3b gemergt | | T3 | test-engineer | Projekt `perf` nach 7.3 (bedingter `webServer`/`auth-config.ts`, Drosselung, Suchliste, Ladeskript für synthetisches Volumen lokal, Auswertung p50/p95); vorläufige Messung gegen den lokalen bzw. CI-Stack | 3b gemergt | -| D1 | devops | CI-Job für das E2E-Projekt `web` um die neue Datei ergänzen (falls nicht automatisch); `workflow_dispatch`-Job für `perf` mit `PERF_WEB_URL`; Eintrag „Krit. 7 gegen Staging messen“ in `docs/release-checkliste.md` (PR #54); Entscheidung zur Mengenbegrenzung der RPC je IP (4.2 Sicherheit) im Hosting-Runbook vor dem Go-live; sobald Staging existiert: Migrationen, Web-Build, Erweiterungen prüfen (B5) | 3a gemergt | +| D1 | devops | CI-Job für das E2E-Projekt `web` um die neue Datei ergänzen (falls nicht automatisch); `workflow_dispatch`-Job für `perf` mit `PERF_WEB_URL`; Eintrag „Krit. 7 gegen Staging messen“ in `docs/release-checkliste.md` (PR #54, noch offen); Entscheidung zur Mengenbegrenzung der RPC je IP (4.2 Sicherheit) im Hosting-Runbook vor dem Go-live; sobald Staging existiert: Migrationen, Web-Build, Erweiterungen prüfen (B5) | 3a gemergt | | C1 | Chris | Vorläufige Messung abnehmen (Spec-F4 A); später Startbestand auf Staging einspielen (Spec #2 Runbook) und Staging-Messung vor dem Release abnehmen; mobile Stichprobe; Relevanz mit echten Daten plausibilisieren | T3 (Staging: D1) | ### 8.4 Reihenfolge und Parallelität @@ -549,7 +549,7 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut | Spec-F1 | Mehrere Genres (bzw. Mechaniken) gewählt: muss ein Spiel **alle** haben oder **eines davon**? | A: alle (UND); B: eines davon (ODER innerhalb einer Filterart, UND zwischen Filterarten) | **A**, wörtliche Lesart von Krit. 4/8 („zu allen gewählten Filtern“, Klärung 13). B liefert beim Entdecken mehr Treffer; Umstellung später nur in der RPC. | E7, 4.2, 7.1 Krit. 4 | | Spec-F2 | Bedeutung von Spielerzahl, Dauer und Alter als Filter; Umgang mit fehlenden Angaben | Spielerzahl: Zahl n, passt wenn n im Bereich min–max. Dauer: „bis X Minuten“ (30/45/60/90/120/180), passt wenn die längste bekannte Angabe ≤ X (auch wenn nur `max_minutes` oder nur `min_minutes` gepflegt ist); alternativ Bereiche („kurz/mittel/lang“). Alter: „ab X Jahren geeignet“, passt wenn Mindestalter ≤ X. Fehlende Angabe: ausschließen oder einschließen. | **Wie links beschrieben** (n im Bereich; „bis X Minuten“; Mindestalter ≤ X) und **fehlende Angaben ausschließen** (ein Spiel ohne Spielerzahl passt nicht nachweislich). Im Startbestand sind alle Angaben Pflicht (Spec #2 SB2), die Regel betrifft nur spätere Einträge. | 4.2, 5.2, 5.6, 7.1 | | Spec-F3 | Was zeigt die Suche ohne Suchbegriff und ohne Filter? | A: den ganzen Katalog alphabetisch; B: nur einen Hinweis „Suchbegriff eingeben oder Filter wählen“ | **A** (Entdecken ohne Sonderfall, E8) | E8, 4.2, 5.6 | -| Spec-F4 | **Wann gilt #3 als erledigt?** Zwei Teilabhängigkeiten: Krit. 6 hängt an #13 (Melden), Krit. 7 verlangt eine Messung „gegen Staging“, **Staging existiert aber noch nicht** (ADR-0002: ein Hetzner-Server). | **A:** PR 3c schließt #3. Krit. 6: Hinweis + Einstiegspunkt in #3, im PR als „Hinweis erfüllt, Melden mit #13“ abgehakt, #13 übernimmt die Aktion. Krit. 7: vorläufige Messung mit dem Projekt `perf` gegen den lokalen bzw. CI-Stack mit Startbestand und synthetischem Volumen, Drosselung wie in 7.3; die Messung gegen Staging wird **Freigabebedingung in der Release-Checkliste** (`docs/release-checkliste.md`, PR #54). **B:** #3 bleibt offen, bis #13 umgesetzt und die Messung gegen Staging erfolgt ist (beide Teile auch einzeln wählbar). | **A** (entspricht der Teilabhängigkeit im Issue; das Release kann ohne Staging-Messung trotzdem nicht freigegeben werden; bei B hinge der Abschluss einer R1.1-Story an #13 aus R1.2 und an der Infrastruktur) | E11, E12, 5.6, 7.3, 8.3 | +| Spec-F4 | **Wann gilt #3 als erledigt?** Zwei Teilabhängigkeiten: Krit. 6 hängt an #13 (Melden), Krit. 7 verlangt eine Messung „gegen Staging“, **Staging existiert aber noch nicht** (ADR-0002: ein Hetzner-Server). | **A:** PR 3c schließt #3. Krit. 6: Hinweis + Einstiegspunkt in #3, im PR als „Hinweis erfüllt, Melden mit #13“ abgehakt, #13 übernimmt die Aktion. Krit. 7: vorläufige Messung mit dem Projekt `perf` gegen den lokalen bzw. CI-Stack mit Startbestand und synthetischem Volumen, Drosselung wie in 7.3; die Messung gegen Staging wird **Freigabebedingung in der Release-Checkliste** (`docs/release-checkliste.md`, PR #54, noch offen). **B:** #3 bleibt offen, bis #13 umgesetzt und die Messung gegen Staging erfolgt ist (beide Teile auch einzeln wählbar). | **A** (entspricht der Teilabhängigkeit im Issue; das Release kann ohne Staging-Messung trotzdem nicht freigegeben werden; bei B hinge der Abschluss einer R1.1-Story an #13 aus R1.2 und an der Infrastruktur) | E11, E12, 5.6, 7.3, 8.3 | | Spec-F5 | Soll „ae/oe/ue/ss“ als Schreibweise für „ä/ö/ü/ß“ in R1 treffen (z. B. „Kaese“ → „Käse“)? | A: nein in R1 (nur Akzente/Umlaute entfernen); B: ja (zweite normalisierte Form je Titel) | **A**; Krit. 1 verlangt Groß/Klein und Sonderzeichen. B ist später ohne App-Änderung nachrüstbar (R9). | 2 (Konkretisierung), 3.2 Nr. 2 | | Spec-F6 | Alternative Titel beim Zusammenführen zweier Katalogspiele (2b, `admin_merge_games`) | A: bleiben am ausgeblendeten Quell-Eintrag (wie Genres/Verlage); der Admin ergänzt am Ziel bei Bedarf Titel per SQL (Runbook-Schritt vor dem Zusammenführen); B: werden automatisch auf das Ziel übertragen (ohne Duplikate) | **A** für R1: einfach, kein Eingriff in die freigegebene Spec #2 und in 2b; der Haupttitel der Quelle hat keine Sprache und ließe sich ohnehin nicht automatisch als alternativer Titel übernehmen | 3.3, 9.5, Fremdschlüssel-Wächter | | Spec-F7 | Sprachen alternativer Titel | A: nur `de` und `en` (wie `description_locale`); B: beliebige Sprache (ISO 639-1, z. B. Originaltitel auf Französisch) | **A** (Klärung 3 nennt Deutsch und Englisch; erweiterbar per Migration) | 3.2 Nr. 4, 4.3 | @@ -576,11 +576,11 @@ Werkzeuge: pgTAP (`supabase test db`), Deno-Tests (Startbestand-Werkzeug), `flut ### 9.5 Hinweise für andere Stories, nicht Teil von #3 -- **KAT-05 2b/2c (#2):** Keine Änderung an 2b nötig. 3a ordnet `game_alt_titles.game_id` im Fremdschlüssel-Wächter von `07_catalog_merge.test.sql` als „bewusst ausgelassen“ ein (bleibt am Quell-Eintrag, passt zu `admin_merge_games`, das die Zuordnungen ebenfalls nicht umhängt; Spec-F6 A). Wird 2c nach 3a gemergt, übernimmt 2c diesen Eintrag, die Tabellenzahl und die `manual_fields`-Liste von `main` (3.3). Für das Runbook (Abschnitt Zusammenführen) ergänzt 3a (B4) den Schritt „vor dem Zusammenführen relevante Titel der Quelle als alternative Titel am Ziel anlegen“. +- **KAT-05 2b/2c (#2):** Keine Änderung an 2b nötig. 3a ordnet `game_alt_titles.game_id` im Fremdschlüssel-Wächter von `07_catalog_merge.test.sql` und `08_game_reports.test.sql` als „bewusst ausgelassen“ ein (bleibt am Quell-Eintrag, passt zu `admin_merge_games`, das die Zuordnungen ebenfalls nicht umhängt; Spec-F6 A). Wird 2c nach 3a gemergt, übernimmt 2c diesen Eintrag, die Tabellenzahl und die `manual_fields`-Liste von `main` (3.3). Für das Runbook (Abschnitt Zusammenführen) ergänzt 3a (B4) den Schritt „vor dem Zusammenführen relevante Titel der Quelle als alternative Titel am Ziel anlegen“. - **KAT-02 (#4):** Route `/catalog/games/:id`, `onTap` der Treffer-Kachel verdrahten; Domänenmodell `CatalogGame` (Detail) neu, `CatalogGameSummary` bleibt für Listen; Umwandlung von `image_path` in eine URL über die Dateizugriffsschicht. Alternative Titel sind in KAT-02 Krit. 1 nicht gefordert. - **KAT-04 (#13):** - `onReportGame`-Callback im Leerzustand setzen (Suchbegriff als vorbelegter Titel, Anmeldung über `from`). Krit. 6 von KAT-01 wird mit #13 vollständig (Spec-F4). - - **Normalisierung:** `public.normalize_game_title(t) = coalesce((search_normalize(t)), search_normalize(t), lower(btrim(t)))`, angelegt von #13 über der Basisfunktion aus 3a; Artikel werden nur dort entfernt (gemeinsamer Satz in 3.2 Nr. 2). + - **Normalisierung:** `public.normalize_game_title(t) = coalesce(nullif(public.strip_leading_article(search_normalize(t)), ''), search_normalize(t), lower(btrim(t)))`, angelegt von #13 über der Basisfunktion aus 3a; Artikel werden nur dort entfernt (gemeinsamer Satz in 3.2 Nr. 2). - **Alternative Titel in `find_similar_catalog_games` (13a):** Die Ähnlichkeitsprüfung („gleicher normalisierter Titel“ bzw. Trigramm-Ähnlichkeit, Klärung 15) bezieht alternative Titel verbindlich über `game_alt_titles.title_search` ein, Trigramm-Ähnlichkeit über `extensions.similarity`. - pgTAP-Datei `10_…` (09 ist #3); 13a wird nach 3a gemergt. - **QUE-09 (#29):** Startseite ohne Anmeldung bettet `CatalogSearchView` ein. @@ -599,7 +599,7 @@ Erste Fassung durch architect, wartet auf Freigabe und Antworten zu Spec-F1 bis - § 7.1 Krit. 2: Idempotenz der alternativen Titel nach pgTAP verschoben (über die erzeugten `.inc`-Fixtures); Deno prüft nur das Skript. - § 7: Auswirkung auf bestehende Tests: `00_schema` nur Tabellenzahl; `06` Z. 532–535 prüft `manual_fields` von Testspiel A exakt → alternative Titel an Testspiel B (B3), sonst Anpassung in B2. -- Neuer Abschnitt 3.3 zur Reihenfolge mit 2b/2c; 3a erweitert den Fremdschlüssel-Wächter in `07_catalog_merge.test.sql` um `game_alt_titles.game_id` („bewusst ausgelassen“, Spec-F6 A). Abschnitt „Doku zum Datenmodell“ wird 3.4. +- Neuer Abschnitt 3.3: Merge-Reihenfolge 2b → 2c → 3a; 3a erweitert den Fremdschlüssel-Wächter in `07_catalog_merge.test.sql` um `game_alt_titles.game_id` („bewusst ausgelassen“, Spec-F6 A). Abschnitt „Doku zum Datenmodell“ wird 3.4. B2, B3, § 8.4, R7, 9.5 angepasst. ### 2026-11-03 – Review (code-reviewer) @@ -613,3 +613,10 @@ Erste Fassung durch architect, wartet auf Freigabe und Antworten zu Spec-F1 bis - **W-5:** `statement_timeout` an der Funktion wirkt nicht; Mengenbegrenzung je IP als Entscheidung vor dem Go-live (4.2, R4, D1). - **W-6:** Prüfabfrage nach Image-Upgrades als Runbook-Schritt (R6, B4). - Hinweise: Versatzgrenze und gewollte Durchblätterbarkeit (E8, 4.2, 5.4); `image_path` nur aus der View, `imageUrl` in #3 immer `null` (4.2, 5.2, 5.6); mehr als 10 Wörter → `search_query_too_long`; Dauer mit nur einer Angabe (Spec-F2); `perf`-Konfiguration (7.3); serielle E2E-Fixtures (7.2); Protokoll-Einstellungen (B5, R11); Ansage und Fokus (5.6); URL-Ersetzung mit `Router.neglect` (5.5); `;` in alternativen Titeln verboten (4.3); DTOs nur `json_serializable` (5.1); Kommentar zum Partial-Index (4.2). + +### 2026-11-03 – Konsistenz-Review + +- Die Reihenfolge aus „Korrekturen am Entwurf“ (2b → 2c → 3a) ist durch W-1 abgelöst (3a nach 2b; wer später merged, gleicht ab). Der Eintrag oben bleibt als Verlauf unverändert. +- 3.3, § 7, B2, 9.5: 2c ist `feature/2c-katalog-meldungen` (PR #57, gestapelt auf PR #56) im Review; sein `07` enthält die Einträge zu `game_reports` bereits. Der Fremdschlüssel-Wächter steht in `07` **und** `08`; 3a trägt `game_alt_titles.game_id` in beiden ein. +- 9.5 KAT-04: Formel für `normalize_game_title` ausgeschrieben (`strip_leading_article`). +- Spec-F4, 7.3, D1: `docs/release-checkliste.md` als „PR #54, noch offen“ gekennzeichnet. From de830a71487eeb17ce327d057044921966ddee88 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 00:15:46 +0100 Subject: [PATCH 20/30] feat(db): store cleared editions count in game_reports.editions_cleared (KAT-05 2c, Spec-F6) Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/datenmodell-r1.md | 5 ++-- docs/katalog-pflege.md | 4 +-- docs/specs/2-katalog-daten.md | 7 ++--- .../20261103195151_game_reports.sql | 7 ++++- .../tests/database/08_game_reports.test.sql | 26 +++++++++++++------ 5 files changed, 33 insertions(+), 16 deletions(-) diff --git a/docs/datenmodell-r1.md b/docs/datenmodell-r1.md index 3da8a51..01a5a31 100644 --- a/docs/datenmodell-r1.md +++ b/docs/datenmodell-r1.md @@ -43,7 +43,7 @@ R1 braucht 21 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog | Tabelle | Spalten | Zweck | | --- | --- | --- | | `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at` | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die letzten sieben Spalten gehören der Katalogpflege (KAT-05), siehe unten | -| `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at` | Meldungen fehlender Spiele (KAT-05 PR 2c, Anlegen durch Nutzer kommt mit KAT-04); siehe Katalogpflege | +| `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at`, `editions_cleared` | Meldungen fehlender Spiele (KAT-05 PR 2c, Anlegen durch Nutzer kommt mit KAT-04); siehe Katalogpflege | | `catalog_sources` | `key`, `name`, `attribution_text`, `attribution_url`, `images_allowed`, `texts_allowed`, `terms_url`, `terms_checked_at` | Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt `bgg` ohne Erlaubnis an | | `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) | | `game_variants` | `game_id` (FK → `games`), `name`, `year`, `language`, `bgg_id` (optional) | Editionen und Varianten eines Spiels (O4) | @@ -251,6 +251,7 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel | `status` | text | ja | `'open'` | `open`, `approved`, `rejected`, `merged` | | `target_game_id` | uuid | nein | | FK → games (`on delete restrict`) | | `resolved_at` | timestamptz | nein | | gesetzt genau dann, wenn `status <> 'open'` | +| `editions_cleared` | integer | ja | `0` | Anzahl der Verweise (Sammlung, Partien), deren Edition beim Umhängen geleert wurde (`variants_cleared`); Check `>= 0`; setzt nur `admin_resolve_report` (Spec-F6) | | `created_at`, `updated_at` | timestamptz | ja | `now()` | | Checks: offen hat kein Ziel; `approved`/`merged` brauchen ein Ziel. Indizes: `(owner_id, created_at)`, eindeutig `(owner_id, bgg_id)` für offene Import-Meldungen (N12a), `(created_at)` für offene Meldungen, eindeutig `provisional_game_id`, `target_game_id`. @@ -365,7 +366,7 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle - **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf). - **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). **Regel: `merged_into_id` wird nur über `public.admin_merge_games(p_source, p_target) returns jsonb` gesetzt, nie von Hand.** (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu. -- **Meldungen** (Migration `game_reports`, PR 2c): Tabelle `game_reports` (siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy auf `owner_id`; die Policy für `is_admin()` ist für R4 vorbereitet); Schreibrechte haben `anon` und `authenticated` nicht, #13 ergänzt Insert-Recht und -Policy. Bearbeitet wird in R1 nur über `public.admin_resolve_report(p_report, p_status, p_target) returns jsonb` (Ablauf, Ergebnis und Fehler: `docs/katalog-pflege.md`, Abschnitt 6): `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`), für `anon`, `authenticated` und `service_role` nicht ausführbar. `approved` und `merged` hängen den vorläufigen privaten Eintrag über `repoint_game_references` auf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert, `variants_cleared`) und blenden ihn aus (`hidden_at`, `merged_into_id`); `rejected` speichert höchstens den Verweis. `admin_merge_games` hängt zusätzlich `game_reports.target_game_id` von der Quelle auf das Ziel (`reports_repointed`). Der Trigger `games_visibility_immutable` lehnt jede Änderung von `visibility` und `owner_id` an bestehenden Spielen ab (Fehler `visibility_immutable`, für alle Rollen auch `postgres`; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501). Beim Konto-Löschen werden Meldungen und vorläufige Einträge (auch nach der Freigabe) hart gelöscht, Katalogspiele und Daten anderer Nutzer bleiben. Fremdschlüssel-Wächter: `07` und `08`. +- **Meldungen** (Migration `game_reports`, PR 2c): Tabelle `game_reports` (siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy auf `owner_id`; die Policy für `is_admin()` ist für R4 vorbereitet); Schreibrechte haben `anon` und `authenticated` nicht, #13 ergänzt Insert-Recht und -Policy. Bearbeitet wird in R1 nur über `public.admin_resolve_report(p_report, p_status, p_target) returns jsonb` (Ablauf, Ergebnis und Fehler: `docs/katalog-pflege.md`, Abschnitt 6): `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`), für `anon`, `authenticated` und `service_role` nicht ausführbar. `approved` und `merged` hängen den vorläufigen privaten Eintrag über `repoint_game_references` auf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert, `variants_cleared`) und blenden ihn aus (`hidden_at`, `merged_into_id`); `rejected` speichert höchstens den Verweis. `admin_merge_games` hängt zusätzlich `game_reports.target_game_id` von der Quelle auf das Ziel (`reports_repointed`). Der Trigger `games_visibility_immutable` lehnt jede Änderung von `visibility` und `owner_id` an bestehenden Spielen ab (Fehler `visibility_immutable`, für alle Rollen auch `postgres`; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501). `admin_resolve_report` schreibt außerdem `editions_cleared` (Anzahl geleerter Editionen, 0 bei `rejected` und ohne Umhängen), damit die App den Nutzer zum Neuwählen auffordern kann (Spec-F6). Beim Konto-Löschen werden Meldungen und vorläufige Einträge (auch nach der Freigabe) hart gelöscht, Katalogspiele und Daten anderer Nutzer bleiben. Fremdschlüssel-Wächter: `07` und `08`. - **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder. - **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import). - **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`. diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index 2fea088..dc608dd 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -426,7 +426,7 @@ select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'reje **Schritt 4: Nachprüfen.** Status der Meldung und Zustand des vorläufigen Eintrags (bei `approved`/`merged`: ausgeblendet und verwiesen; bei `rejected`: unverändert): ```sql -select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, +select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, r.editions_cleared, g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id from public.game_reports r left join public.games g on g.id = r.provisional_game_id @@ -441,7 +441,7 @@ where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; "favorite_conflicts": 0, "collection_conflicts": 0, "provisional_game_id": "…"} ``` -`repointed` ist `false`, wenn nichts umgehängt wurde (`rejected` oder Meldung ohne vorläufigen Eintrag); dann stehen die Zähler nicht im Ergebnis. Die Zähler haben dieselbe Bedeutung wie bei der Zusammenführung (Abschnitt 5). Wichtig hier: **`variants_cleared`** zählt die Sammlungseinträge und Partien, deren Edition geleert wurde, weil der Katalogeintrag keine gleichnamige Variante hatte. Das ist der Hinweis, dass der Nutzer die Edition neu wählen muss. Eine Kollision (`collection_conflicts`) bedeutet: Der Nutzer hatte das Ziel schon in seiner Sammlung; der Zieleintrag bleibt, leere Felder füllt der Eintrag aus dem vorläufigen Spiel. +`repointed` ist `false`, wenn nichts umgehängt wurde (`rejected` oder Meldung ohne vorläufigen Eintrag); dann stehen die Zähler nicht im Ergebnis. Die Zähler haben dieselbe Bedeutung wie bei der Zusammenführung (Abschnitt 5). Wichtig hier: **`variants_cleared`** zählt die Sammlungseinträge und Partien, deren Edition geleert wurde, weil der Katalogeintrag keine gleichnamige Variante hatte. Das ist der Hinweis, dass der Nutzer die Edition neu wählen muss. Derselbe Wert steht danach in der Meldung (`game_reports.editions_cleared`, Spec-F6); die App (#13) liest ihn, um den Nutzer zum Neuwählen aufzufordern. Er ist 0 bei `rejected` und bei Meldungen ohne Umhängen. Eine Kollision (`collection_conflicts`) bedeutet: Der Nutzer hatte das Ziel schon in seiner Sammlung; der Zieleintrag bleibt, leere Felder füllt der Eintrag aus dem vorläufigen Spiel. **Fehlermeldungen** (alle ohne Änderung, `P0001`): diff --git a/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index 709f79f..cbc9620 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -287,6 +287,7 @@ Die Quelle wird **nicht** gelöscht: Ihre Shuffle-ID bleibt gültig, die App lei | `status` | text | not null, default `'open'`, Check `in ('open','approved','rejected','merged')` | | `target_game_id` | uuid | nullable, FK → `games` **`on delete restrict`** (wie `merged_into_id`) | | `resolved_at` | timestamptz | nullable | +| `editions_cleared` | integer | not null, default 0, Check `>= 0` (Spec-F6, Option A, ergänzt bei der Umsetzung von 2c): Anzahl der Sammlungseinträge und Partien, deren Edition beim Umhängen geleert wurde | | `created_at`, `updated_at` | timestamptz | Standard, Trigger `set_updated_at` | Checks: `(status = 'open') = (resolved_at is null)`; `status <> 'open' or target_game_id is null`; `status not in ('approved','merged') or target_game_id is not null`. @@ -319,7 +320,7 @@ Ablauf: `status`, `target_game_id`, `resolved_at = now()` setzen. Hat die Meldun - bei `rejected` mit Ziel: nur `target_game_id` wird gespeichert, der vorläufige Eintrag bleibt unverändert; ob der Nutzer auf den Zieleintrag wechselt, entscheidet er in der App (KAT-04 Krit. 5, #13), - bei `rejected` ohne Ziel: keine Änderung am vorläufigen Eintrag (KAT-04 Krit. 6). -Die Rückgabe enthält die Zähler aus `repoint_game_references` (u. a. `variants_cleared`). +Die Rückgabe enthält die Zähler aus `repoint_game_references` (u. a. `variants_cleared`). Dasselbe `variants_cleared` speichert die Funktion in `game_reports.editions_cleared` (0 bei `rejected` und bei Meldungen ohne Umhängen), damit die App (#13) den Nutzer zum Neuwählen der Edition auffordern kann. „Freigeben“ heißt in R1: Der Admin legt im Studio zuerst einen **neuen** Katalogeintrag an (bzw. wählt einen vorhandenen aus) und ruft dann die Funktion mit `approved` auf. Der vorläufige Eintrag wird nie öffentlich geschaltet (E14). Unterschied zu `merged`: `approved` = der Eintrag ist wegen dieser Meldung neu entstanden, `merged` = er war schon vorhanden. @@ -723,7 +724,7 @@ Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_cata ### 2026-11-03 – Umsetzung PR 2c (backend-dev) -Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_reports.test.sql`, 157 Tests; `07` und `00` angepasst), B10 (Runbook Abschnitt 6). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR: +Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_reports.test.sql`, 160 Tests; `07` und `00` angepasst), B10 (Runbook Abschnitt 6). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR: - **Branch:** `feature/2c-katalog-meldungen` statt `feature/2-katalog-meldungen` (Vorgabe des Auftrags), gestapelt auf `feature/2b-katalog-zusammenfuehren`. - **Zusätzliche Indizes** auf beide Fremdschlüssel nach `games` (Regel: Index auf jeden Fremdschlüssel). `provisional_game_id` ist **eindeutig** (`where provisional_game_id is not null`, 1:1: ein vorläufiger Eintrag gehört zu höchstens einer Meldung), `target_game_id` ein einfacher Index. @@ -736,7 +737,7 @@ Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_ - **RLS:** zwei Lese-Policies (eigene Zeilen; `is_admin()` für R4). Bewusst kein Test mit `is_admin()` als Zugang in R1, aber `08` dokumentiert, dass `app_metadata.role = 'admin'` lesen, nicht schreiben kann, und `user_metadata` nichts öffnet. - **Tests und #13:** `08` prüft Schreibrechte je Spalte: beim Anlegen sind `status`, `target_game_id`, `resolved_at`, `origin`, `created_at`, `updated_at` nicht setzbar (nicht pauschal „kein Insert“), ein Update-Recht gibt es auf keine Spalte außer (ab #13) `result_seen_at`. Dazu „Delete verboten“ und „fremde Meldung nicht sichtbar/änderbar“. Einzelne Insert-/Update-Assertions im Abschnitt „Lesen, RLS“ (z. B. Anna legt eine Meldung für Ben an) gehen von fehlenden Insert-Rechten aus; 13a muss diese anpassen, `08` ändert sich mit #13 ohnehin. - **Review 2c:** `admin_resolve_report` prüft zusätzlich, dass der vorläufige Eintrag existiert und dem Meldenden gehört (`report_provisional_not_private`, deckt Katalogspiel, fremdes Spiel und fehlende Zeile ab); Test mit fremdem Spiel in `08`. Hinweise: `service_role` hat Supabase-Standardrechte auf Tabellen (Krit. 9 gilt für `anon`/`authenticated`); `P0001` → HTTP 400 in PostgREST; **der Fremdschlüssel-Wächter existiert in `07` und `08`: #3 (`game_alt_titles.game_id`) und jede weitere Tabelle mit Verweis auf `games` muss beide anpassen**. Optionaler Merkpunkt (nicht gebaut): ein Check-Trigger, dass `target_game_id` auf ein Katalogspiel zeigt (analog `games_merged_into_catalog`); heute stellt das nur `admin_resolve_report` sicher. -- **Bewusst nicht gebaut (offene Entscheidung Chris, Spec #13):** `game_reports.result_seen_at` und `editions_cleared`. #13 ergänzt bei Freigabe das Spaltenrecht `update (result_seen_at)` und ggf. `editions_cleared` sowie Insert-Recht und -Policy; die Tests in `08` sind darauf vorbereitet. +- **Spec-F6 (Chris, Option A) nachträglich umgesetzt:** Spalte `game_reports.editions_cleared integer not null default 0 check (>= 0)`; `admin_resolve_report` setzt sie im Abschluss-Update aus `variants_cleared` (sonst 0). Die Migration von 2c wurde dafür in place ergänzt (noch nicht gemergt). Tests in `08`: Spalte, Default, Check, Wert 1 bei geleerter Edition (Meldung …01), 0 bei `merged` ohne geleerte Edition, bei `rejected` und ohne Umhängen, keine Insert-/Update-Rechte (Mutanten erkannt). Nicht gebaut: `result_seen_at` (kommt mit 13a samt `update (result_seen_at)`, Insert-Recht und -Policy; 13a setzt `editions_cleared` in `adopt_report_target` genauso). - **Fremdschlüssel-Wächter:** erweiterte Liste in `07` und in `08` (`game_reports.target_game_id` behandelt, `game_reports.provisional_game_id` bewusst ausgelassen). `00_schema.test.sql`: 21 Tabellen. - **Runbook Abschnitt 6:** Alle SQL-Bausteine laufen wörtlich in `08` (einmalige Prüfung per Skript, keine CI-Prüfung). Der Trockenlauf-Baustein aus 2b gilt entsprechend (`begin; …; rollback;` in einem Lauf). - **Mutationsprüfung:** 25 Mutanten (u. a. Trigger entfernt/ohne `owner_id`, Grants, Policies, RLS aus, Fremdschlüssel-Aktionen, N12a-Index, Funktionsrumpf-Änderungen) gegen `08`; alle werden erkannt. Bei der Prüfung fiel ein Scheintest auf (Löschen eines Ziels scheiterte an anderen Verweisen), jetzt mit eigenem Ziel. Nicht erkennbar: das Weglassen des wiederholten `revoke` allein (Postgres behält die Rechte bei `create or replace`); erkannt wird stattdessen ein ausdrückliches `grant execute`. diff --git a/supabase/migrations/20261103195151_game_reports.sql b/supabase/migrations/20261103195151_game_reports.sql index c744f0b..610684c 100644 --- a/supabase/migrations/20261103195151_game_reports.sql +++ b/supabase/migrations/20261103195151_game_reports.sql @@ -49,6 +49,10 @@ create table public.game_reports ( -- Zieleintrag: wie merged_into_id on delete restrict (Katalogspiele werden ausgeblendet, nicht gelöscht) target_game_id uuid references public.games (id) on delete restrict, resolved_at timestamptz, + -- Spec-F6 (Option A): Anzahl der Verweise (Sammlungseinträge, Partien), deren Edition beim Umhängen + -- geleert wurde. Setzt nur admin_resolve_report (aus variants_cleared); die App (#13) liest es, + -- um dem Nutzer zu sagen, dass er die Edition neu wählen muss. + editions_cleared integer not null default 0 check (editions_cleared >= 0), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint game_reports_resolved_matches_status check ((status = 'open') = (resolved_at is null)), @@ -152,7 +156,8 @@ begin -- Bei "rejected" mit Ziel wird nur der Verweis gespeichert (Spec-F3); der vorläufige Eintrag -- bleibt unverändert, ob der Nutzer wechselt, entscheidet er in der App. update public.game_reports r - set status = p_status, target_game_id = p_target, resolved_at = now() + set status = p_status, target_game_id = p_target, resolved_at = now(), + editions_cleared = coalesce((result ->> 'variants_cleared')::integer, 0) where r.id = rep.id; return result || jsonb_build_object( diff --git a/supabase/tests/database/08_game_reports.test.sql b/supabase/tests/database/08_game_reports.test.sql index 85da731..c585d3d 100644 --- a/supabase/tests/database/08_game_reports.test.sql +++ b/supabase/tests/database/08_game_reports.test.sql @@ -5,7 +5,7 @@ -- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix 7c1d/f8), gezählt wird nur darüber. -- Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall, sonst würde "alles verweigern" bestehen. begin; -select plan(157); +select plan(160); insert into auth.users (id, email, raw_user_meta_data) values ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), @@ -22,9 +22,9 @@ select ok(has_table_privilege('authenticated', 'public.game_reports', 'select'), -- Formuliert stabil gegenüber späteren Spaltenrechten (#13 ergänzt Insert-Recht, u. a. auf owner_id und -- provisional_game_id, und update (result_seen_at)): geprüft wird je Spalte. -- Insert: nur Spalten, die der Nutzer nie setzen darf (Ergebnis und Herkunft der Meldung). -select is_empty($$select c from unnest(array['status', 'target_game_id', 'resolved_at', 'origin', 'created_at', 'updated_at']) c +select is_empty($$select c from unnest(array['status', 'target_game_id', 'resolved_at', 'editions_cleared', 'origin', 'created_at', 'updated_at']) c where has_column_privilege('authenticated', 'public.game_reports', c, 'insert')$$, - 'KAT-05 Kriterium 9: authenticated kann beim Anlegen status, target_game_id, resolved_at, origin, created_at, updated_at nicht setzen'); + 'KAT-05 Kriterium 9: authenticated kann beim Anlegen status, target_game_id, resolved_at, editions_cleared, origin, created_at, updated_at nicht setzen'); -- Update: auf keine Spalte außer (ab #13) result_seen_at select is_empty($$select a.attname from pg_attribute a where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped and a.attname <> 'result_seen_at' @@ -194,8 +194,12 @@ select lives_ok($$insert into public.game_reports (id, owner_id, title, status, ('f8000000-0000-0000-0000-000000000a03', '22222222-2222-2222-2222-222222222222', 'rejected mit Ziel', 'rejected', now(), '7c1d2c3d-0000-4000-8000-000000000004'), ('f8000000-0000-0000-0000-000000000a04', '22222222-2222-2222-2222-222222222222', 'rejected ohne Ziel', 'rejected', now(), null)$$, 'Positiv: rejected ist mit und ohne Ziel zulässig'); -select is((select origin || ':' || status from public.game_reports where id = 'f8000000-0000-0000-0000-000000000a01'), 'user:open', - 'Standardwerte: origin = user, status = open'); +select is((select origin || ':' || status || ':' || editions_cleared::text from public.game_reports where id = 'f8000000-0000-0000-0000-000000000a01'), 'user:open:0', + 'Standardwerte: origin = user, status = open, editions_cleared = 0'); +select throws_ok($$insert into public.game_reports (owner_id, title, editions_cleared) values ('22222222-2222-2222-2222-222222222222', 'X', -1)$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_editions_cleared_check"', 'Check: editions_cleared nicht negativ'); +select ok(exists (select 1 from pg_attribute where attrelid = 'public.game_reports'::regclass and attname = 'editions_cleared' and not attisdropped and attnotnull), + 'Spec-F6: editions_cleared existiert und ist not null (die Update-Prüfung oben erfasst sie, da nur result_seen_at ausgenommen ist)'); -- --------------------------------------------------------------------------------------------- -- N12a: eine offene Import-Meldung je Nutzer und BGG-Kennung @@ -511,13 +515,13 @@ select is((select r ->> 'report_id' from rb_approve), '7c1d2c3d-0000-4000-8000-0 -- Schritt 4: Nachprüfen create temp table rb_check as -select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, +select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, r.editions_cleared, g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id from public.game_reports r left join public.games g on g.id = r.provisional_game_id where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; -select results_eq($$select id, status, target_game_id, erledigt, vorlaeufig_ausgeblendet, merged_into_id from rb_check$$, - $$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid, 'approved'::text, '7c1d2c3d-0000-4000-8000-000000000002'::uuid, true, true, '7c1d2c3d-0000-4000-8000-000000000002'::uuid)$$, +select results_eq($$select id, status, target_game_id, erledigt, editions_cleared, vorlaeufig_ausgeblendet, merged_into_id from rb_check$$, + $$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid, 'approved'::text, '7c1d2c3d-0000-4000-8000-000000000002'::uuid, true, 1, true, '7c1d2c3d-0000-4000-8000-000000000002'::uuid)$$, 'KAT-05 Kriterium 8: Runbook 6, Nachprüfen: Meldung freigegeben, vorläufiger Eintrag ausgeblendet und verwiesen'); select is((select row(visibility, owner_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), @@ -628,6 +632,12 @@ select is((select row(status, target_game_id)::text from public.game_reports whe select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'rejected')$$, 'P0001', 'report_not_open', 'KAT-05 Kriterium 5: eine freigegebene Meldung lässt sich nicht erneut bearbeiten'); +-- editions_cleared nach allen Abschlüssen (erst jetzt sind …05, …06, …07 und …0b erledigt) +select is((select string_agg(id::text || '=' || editions_cleared::text, ',' order by id) from public.game_reports + where id in ('7c1d2c3d-0000-4000-8000-000000000001', '7c1d2c3d-0000-4000-8000-000000000005', '7c1d2c3d-0000-4000-8000-000000000006', + '7c1d2c3d-0000-4000-8000-000000000007', '7c1d2c3d-0000-4000-8000-00000000000b')), + '7c1d2c3d-0000-4000-8000-000000000001=1,7c1d2c3d-0000-4000-8000-000000000005=0,7c1d2c3d-0000-4000-8000-000000000006=0,7c1d2c3d-0000-4000-8000-000000000007=0,7c1d2c3d-0000-4000-8000-00000000000b=0', + 'Spec-F6: editions_cleared = 1 bei approved mit geleerter Edition (…01), 0 bei merged ohne geleerte Edition, bei rejected (mit und ohne Ziel) und ohne vorläufigen Eintrag'); -- Der Nutzer sieht das Ergebnis seiner Meldung (KAT-04 Krit. 4-6), aber keine fremden select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); set local role authenticated; From 500763d5ca9d5e3ab2b6f067bfeb926e0a720bd0 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 00:24:49 +0100 Subject: [PATCH 21/30] docs(db): late references to merged source as known limitation, with test (KAT-05, 2b) Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/katalog-pflege.md | 29 +++++++ docs/specs/2-katalog-daten.md | 2 + .../tests/database/07_catalog_merge.test.sql | 80 ++++++++++++++++++- 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index 0da7bcb..700cda7 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -262,6 +262,35 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel. Das Ziel bleibt sichtbar; select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002'; ``` +### Bekannte Einschränkung: nachträgliche Verweise auf eine zusammengeführte Quelle + +Nach dem Zusammenführen kann sich noch Neues an die ausgeblendete Quelle hängen, zum Beispiel durch einen veralteten Client oder eine offline erfasste Partie (`plays.client_id`), die erst nach dem Merge synchronisiert wird. Das ist eine bekannte Einschränkung, kein Fehler: Die Quelle wird nie gelöscht und bleibt als Fremdschlüsselziel gültig, die App leitet nur die Anzeige auf das Ziel um. Die Verweise verschwinden also nicht, sie hängen nur am falschen Eintrag. + +**Erkennung** (nach einem Merge, bei Verdacht oder als gelegentliche Kontrolle; die Zahlen müssen überall `0` sein): + +```sql +select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'favorite_games', count(*) from public.favorite_games t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'plays', count(*) from public.plays t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'score_sheets', count(*) from public.score_sheets t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'game_variants', count(*) from public.game_variants t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null; +``` + +**Behebung:** als `postgres` die interne Umhänge-Funktion erneut aufrufen, in einer Transaktion. `admin_merge_games` geht hier nicht mehr (`merge_source_already_merged`): + +```sql +select public.repoint_game_references('7a1b2c3d-0000-4000-8000-000000000002'::uuid, '7a1b2c3d-0000-4000-8000-000000000003'::uuid); +``` + +Die Funktion arbeitet wie beim Merge (Kollisionsregeln, Favoriten, Editionen). Da die Quelle ein Katalogspiel ist, werden Varianten, die erst nachträglich an der Quelle entstanden sind, am Ziel kopiert beziehungsweise auf gleichnamige abgebildet und danach an der Quelle gelöscht. Die Quelle bleibt ausgeblendet, `merged_into_id` und `hidden_at` ändern sich nicht. Das Ergebnis-JSON hat dieselben Schlüssel wie beim Merge (ohne die Felder von `admin_merge_games`). + +**Nachprüfung:** den Erkennungs-Baustein erneut ausführen; er muss überall `0` liefern. + **Was die Funktion macht** | Was | Regel | diff --git a/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index 6fb2d58..b6dcf76 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -720,3 +720,5 @@ Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_cata - **Runbook:** Alle SQL-Bausteine aus Abschnitt 5 laufen wörtlich in `07` (einmalige Prüfung per Skript; keine CI-Prüfung). Abschnitt 5 enthält die Duplikatsuche, eine Vorschau (Verweise, Doppelbesitz), den Aufruf, die Nachprüfung, die Regeln, die Schlüssel des Ergebnis-JSON und die Fehlertexte. - **Nachtrag Review 2b:** Tests ergänzt (Spec-F1-Edition am Ziel, `variants_cleared` im Kollisionszweig, gleichnamige Quellvarianten, zweiter Fremdschlüssel-Wächter auf `game_variants`, unbeteiligte Spiele, Ziel `null`); Runbook um Trockenlauf (`begin; …; rollback;` in einem Lauf, nur dieser Baustein läuft nicht wörtlich in pgTAP), Ketten-Nachprüfung und Nachschlagen von `merged_into_id` ergänzt. Der Null-Check in `admin_merge_games` ist ein äquivalenter Mutant (das spätere `not found` liefert denselben Fehler) und bleibt als ausdrückliche Absicherung. Offen für Chris: Verweise, die nach dem Merge noch auf die Quelle entstehen (W3). + +- **Nachtrag W3 (Chris):** Nachträgliche Verweise auf eine bereits zusammengeführte Quelle sind eine bekannte Einschränkung, kein neuer Code. Runbook 5 beschreibt Erkennung, Behebung (`repoint_game_references` erneut als `postgres`) und Nachprüfung; `07` testet das (Quelle bleibt ausgeblendet, Kollisionen, nachträglich entstandene Variante wird am Ziel kopiert). diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql index c70e81e..3818bdd 100644 --- a/supabase/tests/database/07_catalog_merge.test.sql +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -5,7 +5,7 @@ -- Hinweis für 2c: Der Fremdschlüssel-Wächter am Ende muss um game_reports.target_game_id (behandelt) und -- game_reports.provisional_game_id (bewusst ausgelassen) erweitert werden, sonst wird er rot. begin; -select plan(107); +select plan(118); insert into auth.users (id, email, raw_user_meta_data) values ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), @@ -490,6 +490,84 @@ select is((select v.name || '/' || v.game_id::text from public.collection_items where c.id = 'f7000000-0000-0000-0000-0000000003c2'), 'Jubiläum/f7000000-0000-0000-0000-0000000003b1', 'Spec-F1: Kollision: die Edition der Quelle kommt über die Zuordnung (Kopie am Ziel) in den Zieleintrag'); +-- Nachträgliche Verweise auf eine bereits zusammengeführte Quelle (Runbook 5, bekannte Einschränkung; Entscheidung Chris, W3). +-- Die Quelle ...0002 ist im Hauptfall schon zusammengeführt. Ein veralteter Client bzw. eine offline erfasste Partie +-- hängt danach noch Daten an sie: Variante (neu am Quell-Eintrag), Partie, Sammlungseintrag mit Kollision, Favoriten. +create temp table rb_late_state as +select hidden_at, merged_into_id from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002'; +insert into public.game_variants (id, game_id, name) values + ('f7000000-0000-0000-0000-0000000006e1', '7a1b2c3d-0000-4000-8000-000000000002', 'Nachzügler'); +insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values + ('f7000000-0000-0000-0000-0000000006b1', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', + 'f7000000-0000-0000-0000-0000000006e1', '2026-05-01', 'f7000000-0000-0000-0000-0000000006b1'); +insert into public.collection_items (id, owner_id, game_id, list) values + ('f7000000-0000-0000-0000-0000000006c1', '33333333-3333-3333-3333-333333333333', '7a1b2c3d-0000-4000-8000-000000000002', 'owned'); +insert into public.favorite_games (id, profile_id, game_id, position) values + ('f7000000-0000-0000-0000-0000000006d1', '33333333-3333-3333-3333-333333333333', '7a1b2c3d-0000-4000-8000-000000000003', 4), + ('f7000000-0000-0000-0000-0000000006d2', '33333333-3333-3333-3333-333333333333', '7a1b2c3d-0000-4000-8000-000000000002', 5), + ('f7000000-0000-0000-0000-0000000006d3', '44444444-4444-4444-4444-444444444444', '7a1b2c3d-0000-4000-8000-000000000002', 2); + +-- Runbook 5, nachträgliche Verweise: Erkennung +create temp table rb_late_vorher as +select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'favorite_games', count(*) from public.favorite_games t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'plays', count(*) from public.plays t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'score_sheets', count(*) from public.score_sheets t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'game_variants', count(*) from public.game_variants t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null; +select set_eq($$select tabelle, anzahl from rb_late_vorher$$, + $$values ('collection_items'::text, 1::bigint), ('favorite_games', 2), ('plays', 1), ('score_sheets', 0), ('game_variants', 1)$$, + 'KAT-05 Kriterium 8: Runbook 5, Erkennung nachträglicher Verweise auf die zusammengeführte Quelle'); + +select throws_ok($$select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003')$$, + 'P0001', 'merge_source_already_merged', 'Runbook 5: ein zweiter admin_merge_games scheitert (merge_source_already_merged), deshalb repoint'); + +-- Runbook 5, nachträgliche Verweise: Behebung +create temp table rb_late_fix as +select public.repoint_game_references('7a1b2c3d-0000-4000-8000-000000000002'::uuid, '7a1b2c3d-0000-4000-8000-000000000003'::uuid); +select is((select repoint_game_references from rb_late_fix), + '{"collection_items":0,"collection_conflicts":1,"favorite_games":1,"favorite_conflicts":1,"plays":1,"score_sheets":0,"variants_copied":1,"variants_mapped":0,"variants_cleared":0}'::jsonb, + 'KAT-05 Kriterium 8: Runbook 5, repoint auf eine zusammengeführte Quelle hängt nachträgliche Verweise um (Katalog→Katalog: Variante kopiert)'); + +-- Runbook 5, nachträgliche Verweise: Nachprüfung (Erkennung erneut) +create temp table rb_late_nachher as +select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'favorite_games', count(*) from public.favorite_games t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'plays', count(*) from public.plays t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'score_sheets', count(*) from public.score_sheets t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'game_variants', count(*) from public.game_variants t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null; +select set_eq($$select tabelle, anzahl from rb_late_nachher$$, + $$values ('collection_items'::text, 0::bigint), ('favorite_games', 0), ('plays', 0), ('score_sheets', 0), ('game_variants', 0)$$, + 'KAT-05 Kriterium 8: Runbook 5, nach dem erneuten repoint hängt nichts mehr an der Quelle'); +select is((select count(*) from public.games g, rb_late_state s where g.id = '7a1b2c3d-0000-4000-8000-000000000002' + and g.hidden_at is not distinct from s.hidden_at and g.merged_into_id is not distinct from s.merged_into_id + and g.merged_into_id = '7a1b2c3d-0000-4000-8000-000000000003'), 1::bigint, + 'Die Quelle bleibt ausgeblendet mit unverändertem merged_into_id und hidden_at'); +select is((select row(list, note)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c5'), + '(owned,"Caras Zielnotiz")', 'Kollision: Caras nachträglicher Eintrag fließt in den Zieleintrag (owned vor wishlist), der Quell-Eintrag ist weg'); +select is((select count(*) from public.collection_items where id = 'f7000000-0000-0000-0000-0000000006c1'), 0::bigint, 'Kollision: nachträglicher Quell-Eintrag gelöscht'); +select is((select array_agg(f.id::text order by f.id) from public.favorite_games f where f.id in + ('f7000000-0000-0000-0000-0000000006d1', 'f7000000-0000-0000-0000-0000000006d2', 'f7000000-0000-0000-0000-0000000006d3')), + array['f7000000-0000-0000-0000-0000000006d1', 'f7000000-0000-0000-0000-0000000006d3'], + 'Favoriten: Caras Quell-Favorit entfällt (Ziel-Favorit bleibt), Doras wechselt auf das Ziel'); +select is((select game_id from public.favorite_games where id = 'f7000000-0000-0000-0000-0000000006d3'), '7a1b2c3d-0000-4000-8000-000000000003'::uuid, + 'Favoriten: Doras nachträglicher Favorit hängt am Ziel'); +select is((select p.game_id::text || ':' || v.game_id::text || ':' || v.name from public.plays p join public.game_variants v on v.id = p.variant_id + where p.id = 'f7000000-0000-0000-0000-0000000006b1'), + '7a1b2c3d-0000-4000-8000-000000000003:7a1b2c3d-0000-4000-8000-000000000003:Nachzügler', + 'Partie: die nachträgliche Edition ist eine Kopie am Ziel'); +select is((select count(*) from public.game_variants where id = 'f7000000-0000-0000-0000-0000000006e1'), 0::bigint, + 'Die nachträgliche Variante der Quelle ist gelöscht (kopiert)'); + -- Spec-F1 Kernregel (W1): Doppelbesitz, beide Einträge haben eine eigene Edition -> der Zieleintrag behält seine insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000401', 'Edition Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000402', 'Edition Ziel', 'catalog'); From f078c8a09403906839ed11997c2a2c91e9d8b76d Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:03:16 +0100 Subject: [PATCH 22/30] feat(auth): sign-in prompt card on the home screen for signed-out users Adds SignInPrompt (sign in / create account), showSignInPromptProvider, HomeScreen as ConsumerWidget, l10n de/en, widget/unit tests and a Playwright web test (#49 criterion 1, QUE-09 criterion 13, QUE-06). Refs #49 Co-Authored-By: Claude Sonnet 5.5 --- .../providers/sign_in_prompt_provider.dart | 31 +++ .../providers/sign_in_prompt_provider.g.dart | 76 ++++++ .../presentation/widgets/sign_in_prompt.dart | 75 ++++++ .../home/presentation/home_screen.dart | 45 +++- app/lib/l10n/app_de.arb | 4 + app/lib/l10n/app_en.arb | 4 + app/lib/l10n/app_localizations.dart | 24 ++ app/lib/l10n/app_localizations_de.dart | 13 + app/lib/l10n/app_localizations_en.dart | 12 + .../sign_in_prompt_provider_test.dart | 75 ++++++ .../home/presentation/home_screen_test.dart | 243 ++++++++++++++++++ docs/design/auth-screens.md | 18 ++ e2e/README.md | 2 +- .../web/krit49-anmeldung-einstieg.spec.ts | 78 ++++++ 14 files changed, 690 insertions(+), 10 deletions(-) create mode 100644 app/lib/features/auth/presentation/providers/sign_in_prompt_provider.dart create mode 100644 app/lib/features/auth/presentation/providers/sign_in_prompt_provider.g.dart create mode 100644 app/lib/features/auth/presentation/widgets/sign_in_prompt.dart create mode 100644 app/test/features/auth/presentation/providers/sign_in_prompt_provider_test.dart create mode 100644 app/test/features/home/presentation/home_screen_test.dart create mode 100644 e2e/tests/web/krit49-anmeldung-einstieg.spec.ts diff --git a/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.dart b/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.dart new file mode 100644 index 0000000..f1a8b29 --- /dev/null +++ b/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.dart @@ -0,0 +1,31 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/core/logging/app_logger.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; + +part 'sign_in_prompt_provider.g.dart'; + +final _log = AppLogger('SignInPrompt'); + +/// Whether the home screen shows the sign-in prompt (Spec #49, E1). +/// +/// - signed out: true +/// - signed in / password recovery: false +/// - loading (no first value yet): false, so the prompt does not flash up +/// on start with a stored session +/// - error in the status stream: true (treated as signed out), the error is +/// only logged +@riverpod +bool showSignInPrompt(Ref ref) { + final status = ref.watch(authStatusProvider); + return status.when( + data: (value) => value.isSignedOut, + loading: () => false, + error: (error, _) { + _log.warning( + 'Auth status stream failed, treating as signed out', + error: error.runtimeType, + ); + return true; + }, + ); +} diff --git a/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.g.dart b/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.g.dart new file mode 100644 index 0000000..dfffef0 --- /dev/null +++ b/app/lib/features/auth/presentation/providers/sign_in_prompt_provider.g.dart @@ -0,0 +1,76 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sign_in_prompt_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Whether the home screen shows the sign-in prompt (Spec #49, E1). +/// +/// - signed out: true +/// - signed in / password recovery: false +/// - loading (no first value yet): false, so the prompt does not flash up +/// on start with a stored session +/// - error in the status stream: true (treated as signed out), the error is +/// only logged + +@ProviderFor(showSignInPrompt) +final showSignInPromptProvider = ShowSignInPromptProvider._(); + +/// Whether the home screen shows the sign-in prompt (Spec #49, E1). +/// +/// - signed out: true +/// - signed in / password recovery: false +/// - loading (no first value yet): false, so the prompt does not flash up +/// on start with a stored session +/// - error in the status stream: true (treated as signed out), the error is +/// only logged + +final class ShowSignInPromptProvider + extends $FunctionalProvider + with $Provider { + /// Whether the home screen shows the sign-in prompt (Spec #49, E1). + /// + /// - signed out: true + /// - signed in / password recovery: false + /// - loading (no first value yet): false, so the prompt does not flash up + /// on start with a stored session + /// - error in the status stream: true (treated as signed out), the error is + /// only logged + ShowSignInPromptProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'showSignInPromptProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$showSignInPromptHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return showSignInPrompt(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$showSignInPromptHash() => r'1a87f4e14f79f3e5d9aa16f0f5812afabe603c27'; diff --git a/app/lib/features/auth/presentation/widgets/sign_in_prompt.dart b/app/lib/features/auth/presentation/widgets/sign_in_prompt.dart new file mode 100644 index 0000000..d6155d8 --- /dev/null +++ b/app/lib/features/auth/presentation/widgets/sign_in_prompt.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_primary_button.dart'; + +/// Hint card for signed-out users with the way to sign in and to register +/// (Spec #49 E1, QUE-09 Kriterium 13). Contains no logic: the host decides +/// whether to show it (see `showSignInPromptProvider`). +/// +/// At most [AppBreakpoints.authCardMaxWidth] wide. Heading, text and both +/// buttons are separate semantics nodes in reading and tab order. +class SignInPrompt extends StatelessWidget { + const SignInPrompt({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + return ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: AppBreakpoints.authCardMaxWidth, + ), + child: Card( + key: const Key('sign_in_prompt'), + // Card is a semantics container; keep the children separate. + child: Semantics( + explicitChildNodes: true, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text( + l10n.signInPromptTitle, + key: const Key('sign_in_prompt_title'), + style: textTheme.titleLarge, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.signInPromptBody, + key: const Key('sign_in_prompt_body'), + style: textTheme.bodyMedium, + ), + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('sign_in_prompt_sign_in'), + label: l10n.signInPromptSignIn, + onPressed: () => context.go(AppRoutes.login), + ), + const SizedBox(height: AppSpacing.md), + OutlinedButton( + key: const Key('sign_in_prompt_register'), + onPressed: () => context.go(AppRoutes.register), + child: Text( + l10n.signInPromptRegister, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/home/presentation/home_screen.dart b/app/lib/features/home/presentation/home_screen.dart index 1274e26..9f317f4 100644 --- a/app/lib/features/home/presentation/home_screen.dart +++ b/app/lib/features/home/presentation/home_screen.dart @@ -1,23 +1,50 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/features/auth/presentation/providers/sign_in_prompt_provider.dart'; +import 'package:shuffle/features/auth/presentation/widgets/sign_in_prompt.dart'; /// Platzhalter-Startseite. Wird durch die Katalogsuche bzw. QUE-09 ersetzt. -class HomeScreen extends StatelessWidget { +/// +/// Liest nur [showSignInPromptProvider]; die Entscheidung, ob der +/// Anmelde-Hinweis erscheint, liegt dort (Spec #49 § 5.1). +class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; + final showPrompt = ref.watch(showSignInPromptProvider); return Scaffold( key: const Key('home_screen'), appBar: AppBar(title: Text(l10n.appTitle)), - body: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - l10n.homePlaceholderMessage, - key: const Key('home_placeholder_message'), - textAlign: TextAlign.center, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: AppSpacing.pagePadding(constraints.maxWidth), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - AppSpacing.lg * 2, + minWidth: constraints.maxWidth, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.homePlaceholderMessage, + key: const Key('home_placeholder_message'), + textAlign: TextAlign.center, + ), + if (showPrompt) ...[ + const SizedBox(height: AppSpacing.xl), + const SignInPrompt(), + ], + ], + ), + ), + ), ), ), ), diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 907b00b..416cd15 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -51,6 +51,10 @@ "authLoginSubmit": "Anmelden", "authLoginForgotPassword": "Passwort vergessen?", "authLoginCreateAccount": "Konto erstellen", + "signInPromptTitle": "Dein Konto", + "signInPromptBody": "Melde dich an, um deine Sammlung und Partien zu verwalten.", + "signInPromptSignIn": "Anmelden", + "signInPromptRegister": "Konto erstellen", "authCheckEmailTitle": "Prüfe dein Postfach", "authCheckEmailBody": "Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren.", "authCheckEmailDelayHint": "Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 86e8967..ba96571 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -90,6 +90,10 @@ "authLoginSubmit": "Sign in", "authLoginForgotPassword": "Forgot password?", "authLoginCreateAccount": "Create account", + "signInPromptTitle": "Your account", + "signInPromptBody": "Sign in to manage your collection and games.", + "signInPromptSignIn": "Sign in", + "signInPromptRegister": "Create account", "authCheckEmailTitle": "Check your inbox", "authCheckEmailBody": "We sent you an email if the address is not registered yet. Follow the link in it to continue.", "authCheckEmailDelayHint": "The email can take a few minutes. Also check your spam folder.", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 2f5596f..078f9d7 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -404,6 +404,30 @@ abstract class AppLocalizations { /// **'Create account'** String get authLoginCreateAccount; + /// No description provided for @signInPromptTitle. + /// + /// In en, this message translates to: + /// **'Your account'** + String get signInPromptTitle; + + /// No description provided for @signInPromptBody. + /// + /// In en, this message translates to: + /// **'Sign in to manage your collection and games.'** + String get signInPromptBody; + + /// No description provided for @signInPromptSignIn. + /// + /// In en, this message translates to: + /// **'Sign in'** + String get signInPromptSignIn; + + /// No description provided for @signInPromptRegister. + /// + /// In en, this message translates to: + /// **'Create account'** + String get signInPromptRegister; + /// No description provided for @authCheckEmailTitle. /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 899aaf2..bbb4c85 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -191,6 +191,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get authLoginCreateAccount => 'Konto erstellen'; + @override + String get signInPromptTitle => 'Dein Konto'; + + @override + String get signInPromptBody => + 'Melde dich an, um deine Sammlung und Partien zu verwalten.'; + + @override + String get signInPromptSignIn => 'Anmelden'; + + @override + String get signInPromptRegister => 'Konto erstellen'; + @override String get authCheckEmailTitle => 'Prüfe dein Postfach'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index eb22e28..cc7e7c5 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -188,6 +188,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authLoginCreateAccount => 'Create account'; + @override + String get signInPromptTitle => 'Your account'; + + @override + String get signInPromptBody => 'Sign in to manage your collection and games.'; + + @override + String get signInPromptSignIn => 'Sign in'; + + @override + String get signInPromptRegister => 'Create account'; + @override String get authCheckEmailTitle => 'Check your inbox'; diff --git a/app/test/features/auth/presentation/providers/sign_in_prompt_provider_test.dart b/app/test/features/auth/presentation/providers/sign_in_prompt_provider_test.dart new file mode 100644 index 0000000..7dac161 --- /dev/null +++ b/app/test/features/auth/presentation/providers/sign_in_prompt_provider_test.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/auth_user.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; +import 'package:shuffle/features/auth/presentation/providers/sign_in_prompt_provider.dart'; + +const _user = AuthUser(id: 'u1', email: 'a@example.test', emailConfirmed: true); + +void main() { + ProviderContainer containerWith(Stream stream) { + final container = ProviderContainer.test( + overrides: [authStatusProvider.overrideWith((ref) => stream)], + ); + // Keep the provider alive and listened to, as the home screen does. + container.listen(showSignInPromptProvider, (_, _) {}); + return container; + } + + Future firstValue(ProviderContainer container) => container + .read(authStatusProvider.future) + .then((_) {}, onError: (Object _) {}); + + group('QUE-09 Krit. 13 / #49 Krit. 1: showSignInPromptProvider', () { + test('signedOut -> true', () async { + final container = containerWith( + Stream.value(const AuthStatus.signedOut()), + ); + await firstValue(container); + expect(container.read(showSignInPromptProvider), isTrue); + }); + + test('signedIn -> false', () async { + final container = containerWith( + Stream.value(const AuthStatus.signedIn(_user)), + ); + await firstValue(container); + expect(container.read(showSignInPromptProvider), isFalse); + }); + + test('passwordRecovery -> false', () async { + final container = containerWith( + Stream.value(const AuthStatus.passwordRecovery(_user)), + ); + await firstValue(container); + expect(container.read(showSignInPromptProvider), isFalse); + }); + + test( + 'loading (no first value) -> false, then follows the stream', + () async { + final controller = StreamController(); + addTearDown(controller.close); + final container = containerWith(controller.stream); + expect(container.read(showSignInPromptProvider), isFalse); + + controller.add(const AuthStatus.signedOut()); + await firstValue(container); + expect(container.read(showSignInPromptProvider), isTrue); + + controller.add(const AuthStatus.signedIn(_user)); + await Future.delayed(Duration.zero); + expect(container.read(showSignInPromptProvider), isFalse); + }, + ); + + test('stream error -> true (treated as signed out)', () async { + final container = containerWith(Stream.error(StateError('boom'))); + await firstValue(container); + expect(container.read(showSignInPromptProvider), isTrue); + }); + }); +} diff --git a/app/test/features/home/presentation/home_screen_test.dart b/app/test/features/home/presentation/home_screen_test.dart new file mode 100644 index 0000000..e8c891d --- /dev/null +++ b/app/test/features/home/presentation/home_screen_test.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/auth_user.dart'; +import 'package:shuffle/features/auth/presentation/auth_routes.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; +import 'package:shuffle/features/home/presentation/home_screen.dart'; +import 'package:shuffle/l10n/app_localizations_de.dart'; +import 'package:shuffle/l10n/app_localizations_en.dart'; + +import '../../../helpers/pump_app.dart'; +import '../../auth/presentation/auth_ui_support.dart'; + +final _de = AppLocalizationsDe(); +final _en = AppLocalizationsEn(); + +const _user = AuthUser(id: 'u1', email: 'a@example.test', emailConfirmed: true); + +void main() { + late MockAuthRepository repository; + + setUp(() => repository = MockAuthRepository()); + + /// Pumps the home route plus the real auth routes. [status] `null` means + /// the status stream has no value yet (loading); [statusStream] overrides it. + Future open( + WidgetTester tester, { + AuthStatus? status = const AuthStatus.signedOut(), + Stream? statusStream, + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final router = GoRouter( + routes: [ + GoRoute(path: '/', builder: (_, _) => const HomeScreen()), + ...authRoutes, + ], + ); + addTearDown(router.dispose); + final stream = + statusStream ?? + (status == null + ? const Stream.empty() + : Stream.value(status)); + await pumpApp( + tester, + router: router, + locale: locale, + themeMode: themeMode, + textScale: textScale, + settle: status != null || statusStream != null, + overrides: authUiOverrides( + repository, + locale: locale, + extra: [authStatusProvider.overrideWith((ref) => stream)], + ), + ); + if (status == null && statusStream == null) await tester.pump(); + } + + final promptCard = find.byKey(const Key('sign_in_prompt')); + final signInButton = find.byKey(const Key('sign_in_prompt_sign_in')); + final registerButton = find.byKey(const Key('sign_in_prompt_register')); + + group('#49 Krit. 1: Anmelde-Hinweis auf der Startseite', () { + testWidgets('abgemeldet: Hinweis mit zwei Knöpfen und Platzhaltertext', ( + tester, + ) async { + await open(tester); + expect(promptCard, findsOneWidget); + expect(find.text(_de.signInPromptTitle), findsOneWidget); + expect(find.text(_de.signInPromptBody), findsOneWidget); + expect(find.text(_de.signInPromptSignIn), findsOneWidget); + expect(find.text(_de.signInPromptRegister), findsOneWidget); + expect(find.text(_de.homePlaceholderMessage), findsOneWidget); + }); + + testWidgets('QUE-05 Krit. 2: abgemeldet, en: englische Texte', ( + tester, + ) async { + await open(tester, locale: const Locale('en')); + expect(find.text(_en.signInPromptTitle), findsOneWidget); + expect(find.text(_en.signInPromptBody), findsOneWidget); + expect(find.text(_en.signInPromptSignIn), findsOneWidget); + expect(find.text(_en.signInPromptRegister), findsOneWidget); + }); + + testWidgets('angemeldet: kein Hinweis, Platzhaltertext bleibt', ( + tester, + ) async { + await open(tester, status: const AuthStatus.signedIn(_user)); + expect(promptCard, findsNothing); + expect(find.text(_de.homePlaceholderMessage), findsOneWidget); + }); + + testWidgets('Laden (kein erster Wert): kein Hinweis', (tester) async { + await open(tester, status: null); + expect(promptCard, findsNothing); + expect(find.text(_de.homePlaceholderMessage), findsOneWidget); + }); + + testWidgets('Fehler im Statusstrom: Hinweis wird angezeigt', ( + tester, + ) async { + await open(tester, statusStream: Stream.error(StateError('boom'))); + expect(promptCard, findsOneWidget); + }); + + testWidgets('KTO-01 Krit. 4: Tipp auf „Anmelden“ öffnet den LoginScreen', ( + tester, + ) async { + await open(tester); + await tester.tap(signInButton); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('login_screen')), findsOneWidget); + }); + + testWidgets( + 'KTO-01 Krit. 1: Tipp auf „Konto erstellen“ öffnet den RegisterScreen', + (tester) async { + await open(tester); + await tester.tap(registerButton); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('register_screen')), findsOneWidget); + }, + ); + + testWidgets('breit (1280 dp): Karte höchstens 480 dp breit, zentriert', ( + tester, + ) async { + await open(tester, size: wideScreenSize); + final rect = tester.getRect(promptCard); + expect(rect.width, lessThanOrEqualTo(480)); + expect(rect.center.dx, closeTo(wideScreenSize.width / 2, 1)); + }); + }); + + group('QUE-06: Barrierefreiheit des Anmelde-Hinweises', () { + for (final (name, size, theme) in [ + ('Handy hell', const Size(400, 800), ThemeMode.light), + ('Handy dunkel', const Size(400, 800), ThemeMode.dark), + ('breit hell', wideScreenSize, ThemeMode.light), + ]) { + testWidgets('QUE-06 Krit. 1-3: Guidelines ($name)', (tester) async { + final handle = tester.ensureSemantics(); + await open(tester, size: size, themeMode: theme); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets( + 'QUE-06 Krit. 1: Überschrift, Text und Knöpfe sind eigene Semantics-Knoten', + (tester) async { + final handle = tester.ensureSemantics(); + await open(tester, size: wideScreenSize); + expectSeparateSemantics( + tester, + title: _de.signInPromptTitle, + bodyKey: const Key('sign_in_prompt_body'), + bodyLabel: _de.signInPromptBody, + buttonKey: const Key('sign_in_prompt_sign_in'), + buttonLabel: _de.signInPromptSignIn, + ); + expectButtonSemantics(tester, registerButton, _de.signInPromptRegister); + handle.dispose(); + }, + ); + + for (final locale in const [Locale('de'), Locale('en')]) { + testWidgets( + 'QUE-06 Krit. 3: Schriftgröße 200 % ohne Überlauf (${locale.languageCode})', + (tester) async { + await open( + tester, + textScale: 2, + size: const Size(320, 640), + locale: locale, + ); + expect(tester.takeException(), isNull); + // The page scrolls: the buttons stay reachable and tappable. + await tester.ensureVisible(registerButton); + await tester.pump(); + expect( + tester.getSize(registerButton).height, + greaterThanOrEqualTo(48), + ); + await tester.tap(registerButton); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('register_screen')), findsOneWidget); + }, + ); + } + + testWidgets( + 'QUE-06 Krit. 4: Tab-Reihenfolge Anmelden -> Konto erstellen, Enter löst aus', + (tester) async { + await open(tester); + bool focusIn(Finder button) { + final focus = FocusManager.instance.primaryFocus; + if (focus?.context == null) return false; + return find + .descendant( + of: button, + matching: find.byElementPredicate( + (e) => e == focus!.context, + ), + ) + .evaluate() + .isNotEmpty || + find + .ancestor( + of: find.byElementPredicate((e) => e == focus!.context), + matching: button, + ) + .evaluate() + .isNotEmpty; + } + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusIn(signInButton), isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(focusIn(registerButton), isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('register_screen')), findsOneWidget); + }, + ); + }); +} diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md index b61e82d..d04f41c 100644 --- a/docs/design/auth-screens.md +++ b/docs/design/auth-screens.md @@ -284,3 +284,21 @@ teilt – Entscheidung beim flutter-dev (A2). - Alle interaktiven Elemente >= 48 dp, Tab-Reihenfolge wie Lesereihenfolge, Fokus sichtbar. - Test je Screen mit `textScaler` 2.0 (Android 320 x 640), `androidTapTargetGuideline`, `labeledTapTargetGuideline`, `textContrastGuideline`, Hell und Dunkel (T2). + +## 3. Anmelde-Hinweis auf der Startseite (`SignInPrompt`, Spec #49, PR A) + +Karte auf der Startseite, nur im Status `signedOut` (Entscheidung in `showSignInPromptProvider`; Laden: kein Hinweis, +Stream-Fehler: Hinweis). Auf allen Plattformen inklusive Web. + +``` +Dein Konto (Überschrift, titleLarge, Semantics header) +Melde dich an, um deine Sammlung und Partien zu verwalten. (bodyMedium) +( Anmelden ) -> /login (AppPrimaryButton, volle Breite) +( Konto erstellen ) -> /register (OutlinedButton, volle Breite) +``` + +- Max. 480 dp breit (`AppBreakpoints.authCardMaxWidth`), zentriert unter dem Platzhaltertext; Innenabstand 24 dp, Knopfabstand 12 dp. +- Karteninhalt in `Semantics(explicitChildNodes: true)` (Regel aus Issue #41): Überschrift, Text und beide Knöpfe sind eigene Knoten. +- Lese- und Tab-Reihenfolge: Überschrift, Text, „Anmelden“, „Konto erstellen“. Kein `from`-Parameter. +- l10n: `signInPromptTitle`, `signInPromptBody`, `signInPromptSignIn`, `signInPromptRegister`. +- Keys: `sign_in_prompt`, `sign_in_prompt_title`, `sign_in_prompt_body`, `sign_in_prompt_sign_in`, `sign_in_prompt_register`. diff --git a/e2e/README.md b/e2e/README.md index 3685cd0..bf6043f 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -9,7 +9,7 @@ die Tests laufen deshalb parallel. | Projekt | Ziel | Voraussetzung | Tests | |---|---|---|---| | `api` | GoTrue über Kong (`:54321`) + Mailpit (`:54324`) + Postgres (`:54322`) | `supabase start` | 52 | -| `web` | gebaute Flutter-Web-App auf `http://localhost:3000` | zusätzlich `flutter build web` | 21 | +| `web` | gebaute Flutter-Web-App auf `http://localhost:3000` | zusätzlich `flutter build web` | 27 | | `throttle` | Extra-GoTrue-Container mit `GOTRUE_RATE_LIMIT_HEADER` (AUTH-CFG-13) | `THROTTLE_GOTRUE_URL` | 4 | | `spike` | GoTrue mit unerreichbarem SMTP (Issue #37), nicht blockierend | `SPIKE_GOTRUE_URL` | 3 | diff --git a/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts b/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts new file mode 100644 index 0000000..b900655 --- /dev/null +++ b/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; +import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue'; +import { expectSignedIn, login, open, textOf } from '../../support/web'; + +const gotrue = new GoTrueClient(); + +/** Texte (app_en.arb). */ +const prompt = { + title: 'Your account', + body: 'Sign in to manage your collection and games.', +}; + +test.describe('#49 Kriterium 1 / QUE-09 Kriterium 13 (web): Anmelde-Hinweis auf der Startseite', () => { + test('#49 Kriterium 1: abgemeldet zeigt die Startseite den Hinweis mit „Sign in“ und „Create account“', async ({ page }) => { + await open(page, '/'); + await expect(textOf(page, prompt.title)).toBeVisible(); + await expect(textOf(page, prompt.body)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Create account', exact: true })).toBeVisible(); + }); + + test('#49 Kriterium 1: „Sign in“ fuehrt zu /login', async ({ page }) => { + await open(page, '/'); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('textbox', { name: 'Email address' })).toBeVisible(); + }); + + test('#49 Kriterium 1: „Create account“ fuehrt zu /register', async ({ page }) => { + await open(page, '/'); + await page.getByRole('button', { name: 'Create account', exact: true }).click(); + await expect(page).toHaveURL(/\/register$/); + await expect(page.getByRole('textbox', { name: 'Display name' })).toBeVisible(); + }); + + test('QUE-06 Kriterium 4: Lesereihenfolge; erster Tab erreicht „Sign in“, Enter loest aus', async ({ page }) => { + await open(page, '/'); + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible(); + + // Lesereihenfolge (Semantics-Baum): Ueberschrift, Text, „Sign in“, „Create account“. + const snapshot = await page.locator('body').ariaSnapshot(); + const pos = [ + `heading "${prompt.title}"`, + prompt.body, + 'button "Sign in"', + 'button "Create account"', + ].map((needle) => snapshot.indexOf(needle)); + expect(pos.every((p) => p >= 0)).toBe(true); + expect([...pos].sort((x, y) => x - y)).toEqual(pos); + + // Flutter-Web behandelt Tab im eigenen Fokussystem; `document.activeElement` bleibt auf dem + // Semantics-Platzhalter. Deshalb wird die Reihenfolge ueber das Verhalten geprueft: erster Tab + // = „Sign in“, zweiter Tab = „Create account“, Enter loest den fokussierten Knopf aus. + // Der Fokusring selbst wird im Canvas gezeichnet (Widget-Test, Theme). + await page.keyboard.press('Tab'); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/login$/); + }); + + test('QUE-06 Kriterium 4: zweiter Tab erreicht „Create account“, Enter loest aus', async ({ page }) => { + await open(page, '/'); + await expect(page.getByRole('button', { name: 'Create account', exact: true })).toBeVisible(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/register$/); + }); + + test('#49 Kriterium 1: angemeldet erscheint der Hinweis nicht', async ({ page }) => { + const email = randomEmail(); + const password = randomPassword(); + await gotrue.createConfirmedUser(email, password); + await login(page, email, password); + await expectSignedIn(page); + await expect(page.getByRole('button', { name: 'Create account', exact: true })).toHaveCount(0); + await expect(textOf(page, prompt.body)).toHaveCount(0); + }); +}); From 3bb1c28831c5e7095fa319ffd2a2df15354b5280 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:12:25 +0100 Subject: [PATCH 23/30] feat(db): catalog search with search_normalize, alt titles and search_catalog_games RPC (KAT-01, 3a) Adds pg_trgm/unaccent, search_normalize, generated title_search/name_search columns, game_alt_titles (read-only for API roles), manual_fields key alt_titles and the RPC search_catalog_games. pgTAP 09_catalog_search covers rights, normalization, relevance, filters, paging and the runbook snippets. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- .../20261103235947_catalog_search.sql | 314 ++++++++ supabase/tests/database/00_schema.test.sql | 4 +- .../database/06_catalog_foundation.test.sql | 6 +- .../tests/database/07_catalog_merge.test.sql | 1 + .../tests/database/08_game_reports.test.sql | 1 + .../tests/database/09_catalog_search.test.sql | 759 ++++++++++++++++++ 6 files changed, 1082 insertions(+), 3 deletions(-) create mode 100644 supabase/migrations/20261103235947_catalog_search.sql create mode 100644 supabase/tests/database/09_catalog_search.test.sql diff --git a/supabase/migrations/20261103235947_catalog_search.sql b/supabase/migrations/20261103235947_catalog_search.sql new file mode 100644 index 0000000..0124840 --- /dev/null +++ b/supabase/migrations/20261103235947_catalog_search.sql @@ -0,0 +1,314 @@ +-- KAT-01 / PR 3a: Katalogsuche (Spec docs/specs/3-katalog-suche.md, Abschnitt 3.2 und 4.2). +-- Nur additiv. Enthält: +-- pg_trgm, unaccent Erweiterungen im Schema extensions +-- search_normalize(text) Basisfunktion für alle normalisierten Titel (auch für #13) +-- title_search, name_search generierte Spalten an games, publishers und game_alt_titles + Trigramm-Indizes +-- game_alt_titles alternative Titel mit Sprache (Klärung 3); nur lesbar für API-Rollen +-- games_manual_fields_check vollständige Schlüsselliste von main plus 'alt_titles' +-- search_catalog_games RPC für die Suche (Vertrag: Spec 4.2) + +-- --------------------------------------------------------------------------------------------- +-- 1. Erweiterungen (Bestandteil des Supabase-Postgres-Images) +-- --------------------------------------------------------------------------------------------- +create extension if not exists pg_trgm with schema extensions; +create extension if not exists unaccent with schema extensions; + +-- --------------------------------------------------------------------------------------------- +-- 2. search_normalize: die einzige Basisfunktion für normalisierte Titel (E3) +-- Schritte: Marken- und Schutzzeichen entfernen, Akzente/Umlaute entfernen, Kleinschreibung, alles +-- außer a-z/0-9 zu einem Leerzeichen, Ränder kürzen, leer = null. +-- --------------------------------------------------------------------------------------------- +create function public.search_normalize(p text) +returns text +language sql +immutable +strict +parallel safe +set search_path = '' +as $$ + select nullif( + btrim( + regexp_replace( + lower(extensions.unaccent('extensions.unaccent'::regdictionary, regexp_replace(p, '[®©™℗℠]', '', 'g'))), + '[^a-z0-9]+', ' ', 'g')), + '') +$$; + +comment on function public.search_normalize(text) is + 'Normalisierung für Suche und Titelvergleich (KAT-01, gemeinsame Basis mit KAT-04). ' + 'Rein rechnend, ohne Tabellenzugriff: deshalb für anon, authenticated und service_role ausführbar ' + '(generierte Spalten und security-invoker-Funktionen rechnen mit den Rechten des Aufrufers). ' + 'immutable ist eine Zusicherung: unaccent selbst ist nur stable; der Wrapper nennt das Wörterbuch ausdrücklich. ' + 'Ändert sich diese Funktion oder die Datei unaccent.rules (Image-Upgrade), müssen die generierten Spalten ' + 'games.title_search, game_alt_titles.title_search und publishers.name_search per Migration neu angelegt werden ' + '(Prüfabfrage: docs/katalog-pflege.md, Abschnitt Image-Upgrade). Artikel werden hier nie entfernt.'; + +-- Rechte-Regel: zuerst allen entziehen, dann nur die vorgesehenen Grants. +revoke execute on function public.search_normalize(text) from public, anon, authenticated, service_role; +grant execute on function public.search_normalize(text) to anon, authenticated, service_role; + +-- --------------------------------------------------------------------------------------------- +-- 3. Generierte Spalten und Trigramm-Indizes +-- Die Indizes sind keine Zusage für Geschwindigkeit: Bei 2-Zeichen-Wörtern, der Regel ohne Leerzeichen +-- und den RLS-Prüfungen für anon werden sie weitgehend nicht genutzt (Messung: supabase/perf/). +-- --------------------------------------------------------------------------------------------- +alter table public.games + add column title_search text generated always as (public.search_normalize(title)) stored; +alter table public.publishers + add column name_search text generated always as (public.search_normalize(name)) stored; + +-- Der Partial-Index passt nur zu Abfragen mit genau den Bedingungen der View catalog_games. +create index games_title_search_trgm_idx on public.games + using gin (title_search extensions.gin_trgm_ops) + where visibility = 'catalog' and hidden_at is null and merged_into_id is null; +create index publishers_name_search_trgm_idx on public.publishers + using gin (name_search extensions.gin_trgm_ops); + +-- --------------------------------------------------------------------------------------------- +-- 4. Alternative Titel (Klärung 3, E2). Pflege nur über Studio und das Startbestand-Werkzeug. +-- --------------------------------------------------------------------------------------------- +create table public.game_alt_titles ( + id uuid primary key default gen_random_uuid(), + game_id uuid not null references public.games (id) on delete cascade, + title text not null check (char_length(title) between 1 and 200), + locale text not null check (locale in ('de', 'en')), + title_search text generated always as (public.search_normalize(title)) stored + constraint game_alt_titles_title_search_check check (title_search is not null), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + -- Konfliktziel des Startbestand-Werkzeugs; derselbe Titel in derselben Sprache nur einmal. + constraint game_alt_titles_game_locale_title_key unique (game_id, locale, title_search) +); + +create trigger set_updated_at before update on public.game_alt_titles + for each row execute function public.set_updated_at(); +create trigger game_links_mark_manual after insert or update or delete on public.game_alt_titles + for each row execute function public.game_links_mark_manual('alt_titles'); + +create index game_alt_titles_title_search_trgm_idx on public.game_alt_titles + using gin (title_search extensions.gin_trgm_ops); + +alter table public.game_alt_titles enable row level security; +revoke all on public.game_alt_titles from anon, authenticated; +grant select on public.game_alt_titles to anon, authenticated; + +-- Lesen: wer das Spiel sehen darf (Muster game_publishers_select). Schreiben: keine Policy, kein Recht. +create policy game_alt_titles_select on public.game_alt_titles + for select to anon, authenticated + using (exists (select 1 from public.games g where g.id = game_id)); + +-- --------------------------------------------------------------------------------------------- +-- 5. manual_fields: vollständige Liste von main (Migration catalog_foundation) plus 'alt_titles' +-- --------------------------------------------------------------------------------------------- +alter table public.games + drop constraint games_manual_fields_check, + add constraint games_manual_fields_check + check (manual_fields <@ array['title', 'description', 'description_locale', 'year', + 'min_players', 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path', + 'genres', 'mechanics', 'categories', 'publishers', 'alt_titles']::text[]); + +-- --------------------------------------------------------------------------------------------- +-- 6. RPC search_catalog_games (Vertrag zwischen 3a und 3b: Spec 4.2) +-- security invoker: Für den Aufrufer (auch anon) gelten die Policies von games, catalog_sources, +-- game_publishers, publishers, game_alt_titles, game_genres und game_mechanics; die Funktion umgeht +-- nichts. Gelesen wird über die View catalog_games (ausgeblendete und zusammengeführte Spiele fehlen, +-- Bild und Text sind maskiert); title_search kommt per Primärschlüssel aus games. +-- Der Partial-Index games_title_search_trgm_idx ist nur über die Bedingungen der View nutzbar. +-- Ein set statement_timeout an dieser Funktion wirkt nicht (gilt erst für die nächste Anweisung); +-- es gelten die Zeitlimits der Rollen. Suchbegriffe werden nirgends gespeichert oder protokolliert. +-- --------------------------------------------------------------------------------------------- +create function public.search_catalog_games( + p_query text default null, + p_players smallint default null, + p_max_minutes smallint default null, + p_age smallint default null, + p_genre_ids uuid[] default null, + p_mechanic_ids uuid[] default null, + p_limit integer default 30, + p_offset integer default 0 +) +returns table ( + id uuid, + title text, + year smallint, + min_players smallint, + max_players smallint, + min_minutes smallint, + max_minutes smallint, + min_age smallint, + image_path text, + publisher_names text[], + matched_alt_title text, + match_rank smallint, + total_count bigint +) +language plpgsql +stable +set search_path = '' +as $$ +#variable_conflict use_column +declare + v_limit integer := coalesce(p_limit, 30); + v_offset integer := coalesce(p_offset, 0); + v_q text; + v_qc text; + v_words text[] := '{}'; + v_has_query boolean := false; + v_genres uuid[]; + v_mechanics uuid[]; + v_genre_n integer; + v_mech_n integer; +begin + -- Eingabegrenzen (Fehler statt stilles Abschneiden) + if p_query is not null and char_length(p_query) > 100 then + raise exception 'search_query_too_long' using errcode = 'P0001'; + end if; + if (p_players is not null and p_players not between 1 and 99) + or (p_max_minutes is not null and p_max_minutes not between 1 and 1440) + or (p_age is not null and p_age not between 0 and 99) + or cardinality(p_genre_ids) > 20 + or cardinality(p_mechanic_ids) > 20 then + raise exception 'search_filter_invalid' using errcode = 'P0001'; + end if; + if v_limit not between 1 and 50 or v_offset not between 0 and 10000 then + raise exception 'search_paging_invalid' using errcode = 'P0001'; + end if; + + -- Suchbegriff: null/nur Leerraum = kein Suchbegriff; normalisiert null (z. B. '!!!') = keine Treffer; + -- weniger als 2 Zeichen = kein Suchbegriff. + if p_query is not null and p_query ~ '\S' then + v_q := public.search_normalize(p_query); + if v_q is null then + return; + end if; + if cardinality(string_to_array(v_q, ' ')) > 10 then + raise exception 'search_query_too_long' using errcode = 'P0001'; + end if; + if char_length(v_q) >= 2 then + v_has_query := true; + v_qc := replace(v_q, ' ', ''); + v_words := coalesce( + array(select distinct w from unnest(string_to_array(v_q, ' ')) as w where char_length(w) >= 2), '{}'); + end if; + end if; + + v_genres := array(select distinct x from unnest(p_genre_ids) as x where x is not null); + v_mechanics := array(select distinct x from unnest(p_mechanic_ids) as x where x is not null); + v_genre_n := cardinality(v_genres); + v_mech_n := cardinality(v_mechanics); + + if not v_has_query then + -- Ohne Suchbegriff: Filter wirken, Sortierung nach Titel, match_rank ist null. + return query + select s.id, s.title, s.year, s.min_players, s.max_players, s.min_minutes, s.max_minutes, s.min_age, + s.image_path, + coalesce((select array_agg(p.name order by p.name_search, p.name) + from public.game_publishers gp + join public.publishers p on p.id = gp.publisher_id + where gp.game_id = s.id), '{}'::text[]), + null::text, null::smallint, s.total + from ( + select c.id, c.title, c.year, c.min_players, c.max_players, c.min_minutes, c.max_minutes, c.min_age, + c.image_path, g.title_search, count(*) over () as total + from public.catalog_games c + join public.games g on g.id = c.id + where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) + and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) + and (p_age is null or c.min_age <= p_age) + and (v_genre_n = 0 or (select count(*) from public.game_genres gg + where gg.game_id = c.id and gg.genre_id = any (v_genres)) = v_genre_n) + and (v_mech_n = 0 or (select count(*) from public.game_mechanics gm + where gm.game_id = c.id and gm.mechanic_id = any (v_mechanics)) = v_mech_n) + order by g.title_search collate "C", c.id + limit v_limit offset v_offset + ) s + order by s.title_search collate "C", s.id; + return; + end if; + + return query + with base as ( + select c.id, c.title, c.year, c.min_players, c.max_players, c.min_minutes, c.max_minutes, c.min_age, + c.image_path, g.title_search + from public.catalog_games c + join public.games g on g.id = c.id + where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) + and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) + and (p_age is null or c.min_age <= p_age) + and (v_genre_n = 0 or (select count(*) from public.game_genres gg + where gg.game_id = c.id and gg.genre_id = any (v_genres)) = v_genre_n) + and (v_mech_n = 0 or (select count(*) from public.game_mechanics gm + where gm.game_id = c.id and gm.mechanic_id = any (v_mechanics)) = v_mech_n) + ), + -- Kandidaten-Titel: Haupttitel (alt_title null) und alle alternativen Titel + cand as ( + select b.id, b.title_search as ts, null::text as alt_title from base b + union all + select b.id, a.title_search, a.title + from base b join public.game_alt_titles a on a.game_id = b.id + ), + -- Relevanzstufe je Kandidaten-Titel (null = kein Titeltreffer) + ranked as ( + select t.id, t.ts, t.alt_title, + case + when t.ts = v_q or replace(t.ts, ' ', '') = v_qc then 0 + when t.ts like v_q || '%' then 1 + when t.ts like '% ' || v_q || '%' then 2 + when replace(t.ts, ' ', '') like '%' || v_qc || '%' + or (cardinality(v_words) > 0 + and not exists (select 1 from unnest(v_words) as w where t.ts not like '%' || w || '%')) + then 3 + end as rk, + extensions.similarity(t.ts, v_q) as sim + from cand t + ), + agg as ( + select r.id, + min(r.rk) as best_rk, + min(r.rk) filter (where r.alt_title is null) as main_rk, + min(r.rk) filter (where r.alt_title is not null) as alt_rk, + (array_agg(r.alt_title order by r.rk, r.sim desc nulls last, r.ts, r.alt_title) + filter (where r.alt_title is not null and r.rk is not null))[1] as alt_title, + max(r.sim) as sim + from ranked r + group by r.id + ), + hit as ( + select b.*, a.best_rk, a.main_rk, a.alt_rk, a.alt_title, a.sim + from base b + join agg a on a.id = b.id + where a.best_rk is not null + -- Rang 4: jedes Wort kommt in einem Titel oder einem Verlagsnamen des Spiels vor + or (cardinality(v_words) > 0 + and not exists ( + select 1 from unnest(v_words) as w + where not (exists (select 1 from ranked r2 where r2.id = b.id and r2.ts like '%' || w || '%') + or exists (select 1 from public.game_publishers gp + join public.publishers p on p.id = gp.publisher_id + where gp.game_id = b.id and p.name_search like '%' || w || '%')))) + ), + paged as ( + select h.*, coalesce(h.best_rk, 4) as rk, count(*) over () as total + from hit h + order by coalesce(h.best_rk, 4), h.sim desc nulls last, h.title_search collate "C", h.id + limit v_limit offset v_offset + ) + select s.id, s.title, s.year, s.min_players, s.max_players, s.min_minutes, s.max_minutes, s.min_age, + s.image_path, + coalesce((select array_agg(p.name order by p.name_search, p.name) + from public.game_publishers gp + join public.publishers p on p.id = gp.publisher_id + where gp.game_id = s.id), '{}'::text[]), + -- nur, wenn ein alternativer Titel besser getroffen hat als der Haupttitel + case when s.alt_rk is not null and (s.main_rk is null or s.alt_rk < s.main_rk) then s.alt_title end, + s.rk::smallint, s.total + from paged s + order by s.rk, s.sim desc nulls last, s.title_search collate "C", s.id; +end; +$$; + +-- Rechte-Regel: erst allen entziehen, dann nur anon und authenticated (erste Abfrage-RPC für API-Rollen). +revoke execute on function public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer) + from public, anon, authenticated, service_role; +grant execute on function public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer) + to anon, authenticated; diff --git a/supabase/tests/database/00_schema.test.sql b/supabase/tests/database/00_schema.test.sql index c6ee175..d0999af 100644 --- a/supabase/tests/database/00_schema.test.sql +++ b/supabase/tests/database/00_schema.test.sql @@ -2,8 +2,8 @@ begin; select plan(5); -select is((select count(*) from pg_tables where schemaname = 'public'), 21::bigint, - 'R1 hat genau 21 Tabellen (17 + catalog_sources, publishers, game_publishers, game_reports)'); +select is((select count(*) from pg_tables where schemaname = 'public'), 22::bigint, + 'R1 hat genau 22 Tabellen (17 + catalog_sources, publishers, game_publishers, game_reports, game_alt_titles)'); select is_empty($$ select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace diff --git a/supabase/tests/database/06_catalog_foundation.test.sql b/supabase/tests/database/06_catalog_foundation.test.sql index e9c8241..3d95ec5 100644 --- a/supabase/tests/database/06_catalog_foundation.test.sql +++ b/supabase/tests/database/06_catalog_foundation.test.sql @@ -149,6 +149,10 @@ select is((select manual_fields from public.games where id = 'f6000000-0000-0000 create role catalog_importer nologin; grant catalog_importer to postgres with set true; grant usage on schema public to catalog_importer; +-- Seit KAT-01 berechnet jeder Schreibende title_search selbst: auch der Import braucht EXECUTE auf +-- search_normalize und USAGE auf das Schema extensions (unaccent). Hinweis für SAM-08. +grant execute on function public.search_normalize(text) to catalog_importer; +grant usage on schema extensions to catalog_importer; grant select, insert, update on public.games to catalog_importer; grant select, insert, update, delete on public.game_genres to catalog_importer; create policy t_importer_games on public.games for all to catalog_importer using (true) with check (true); @@ -584,7 +588,7 @@ select is_empty($$select * from snap_rows s where not exists (select 1 from ( -- Prüfabfrage checks.sql (Fixture-Variante derselben Abfrage) \ir ../support/checks_fixture.inc select results_eq($$select rule, ist::int, status from checks_result order by rule$$, - $$values ('Info'::text, 1, 'Information'::text), ('SB0', 3, 'Information'), ('SB1', 2, 'Verstoß'), + $$values ('Info'::text, 1, 'Information'::text), ('Info-Alt', 1, 'Information'), ('SB0', 3, 'Information'), ('SB1', 2, 'Verstoß'), ('SB2', 0, 'ok'), ('SB4', 0, 'ok'), ('SB6', 0, 'ok')$$, 'KAT-05 Kriterium 10: checks.sql meldet für die Testspiele keinen Verstoß bei SB2, SB4, SB6 (SB1 liegt lokal erwartbar unter 180)'); update public.games set min_minutes = null where title = 'Testspiel A'; diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql index 192b9b6..1b7613b 100644 --- a/supabase/tests/database/07_catalog_merge.test.sql +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -633,6 +633,7 @@ select set_eq($$select fk from fk_found$$, ('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'), -- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'), + ('game_alt_titles.game_id'), -- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht ('game_reports.provisional_game_id')$$, 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); diff --git a/supabase/tests/database/08_game_reports.test.sql b/supabase/tests/database/08_game_reports.test.sql index c585d3d..adad298 100644 --- a/supabase/tests/database/08_game_reports.test.sql +++ b/supabase/tests/database/08_game_reports.test.sql @@ -731,6 +731,7 @@ select set_eq($$select fk from fk_found$$, ('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'), -- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'), + ('game_alt_titles.game_id'), -- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht ('game_reports.provisional_game_id')$$, 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); diff --git a/supabase/tests/database/09_catalog_search.test.sql b/supabase/tests/database/09_catalog_search.test.sql new file mode 100644 index 0000000..80f595b --- /dev/null +++ b/supabase/tests/database/09_catalog_search.test.sql @@ -0,0 +1,759 @@ +-- KAT-01 (PR 3a): Katalogsuche. search_normalize, generierte Spalten, game_alt_titles, RPC +-- search_catalog_games, Rechte, Idempotenz der alternativen Titel im Startbestand-Werkzeug und die +-- SQL-Bausteine aus docs/katalog-pflege.md (Abschnitte 3.1, 5 und 8; Kommentare mit Abschnittsverweis). +-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix f9) und dem +-- Kunstwort "qxz" in allen Titeln und Verlagen, damit Suchen nie Startbestandsspiele treffen. +-- Ohne Suchbegriff wird nur auf Fixture-IDs geprüft (Hilfsfunktion pg_temp.hits blättert durch). +-- Die Include-Dateien in ../support/ (\ir) erzeugt supabase/catalog/build_startbestand.ts. +begin; +select plan(201); + +insert into auth.users (id, email, raw_user_meta_data) values + ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), + ('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'); + +-- --------------------------------------------------------------------------------------------- +-- Erweiterungen und Rechte (Sicherheit Kriterium 2, Spec 3.2) +-- --------------------------------------------------------------------------------------------- +select is((select string_agg(extname || ':' || extnamespace::regnamespace::text, ',' order by extname) + from pg_extension where extname in ('pg_trgm', 'unaccent')), + 'pg_trgm:extensions,unaccent:extensions', 'KAT-01: pg_trgm und unaccent liegen im Schema extensions'); +select ok(has_function_privilege('anon', 'extensions.unaccent(regdictionary, text)', 'execute') + and has_function_privilege('authenticated', 'extensions.unaccent(regdictionary, text)', 'execute') + and has_function_privilege('anon', 'extensions.similarity(text, text)', 'execute') + and has_function_privilege('authenticated', 'extensions.similarity(text, text)', 'execute') + and has_schema_privilege('anon', 'extensions', 'usage') and has_schema_privilege('authenticated', 'extensions', 'usage'), + 'KAT-01: anon und authenticated dürfen unaccent und similarity im Schema extensions ausführen (B5)'); + +select ok(has_function_privilege('anon', 'public.search_normalize(text)', 'execute') + and has_function_privilege('authenticated', 'public.search_normalize(text)', 'execute') + and has_function_privilege('service_role', 'public.search_normalize(text)', 'execute'), + 'KAT-01: search_normalize ist für anon, authenticated und service_role ausführbar'); +select is((select p.proacl is not null and not exists (select 1 from aclexplode(p.proacl) a where a.grantee = 0) + from pg_proc p where p.oid = 'public.search_normalize(text)'::regprocedure), true, + 'KAT-01: search_normalize ist für public (alle) nicht ausführbar (Rechteliste ausdrücklich gesetzt)'); +select ok(has_function_privilege('anon', 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)', 'execute') + and has_function_privilege('authenticated', 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)', 'execute'), + 'KAT-01 Sicherheit Kriterium 2: anon und authenticated dürfen search_catalog_games ausführen'); +select ok(not has_function_privilege('service_role', 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)', 'execute'), + 'KAT-01: service_role darf search_catalog_games nicht ausführen'); +select is((select p.proacl is not null and not exists (select 1 from aclexplode(p.proacl) a where a.grantee = 0) + from pg_proc p where p.oid = 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)'::regprocedure), true, + 'KAT-01: search_catalog_games ist für public (alle) nicht ausführbar (Rechteliste ausdrücklich gesetzt)'); +select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'search_normalize' + and p.provolatile = 'i' and p.proisstrict and p.proparallel = 's' and not p.prosecdef + and p.proconfig = array['search_path=""']), 1::bigint, + 'KAT-01: search_normalize ist immutable, strict, parallel safe, security invoker mit leerem search_path'); +select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'search_catalog_games' + and p.provolatile = 's' and not p.prosecdef and p.proconfig = array['search_path=""']), 1::bigint, + 'KAT-01: search_catalog_games ist stable, security invoker mit leerem search_path'); +select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'search_catalog_games'), 1::bigint, + 'KAT-01: es gibt genau eine Überladung von search_catalog_games'); +select ok(exists (select 1 from pg_roles where rolname = 'anon' and exists (select 1 from unnest(rolconfig) c where c like 'statement_timeout=%')) + and exists (select 1 from pg_roles where rolname = 'authenticated' and exists (select 1 from unnest(rolconfig) c where c like 'statement_timeout=%')), + 'KAT-01 Sicherheit (R4): anon und authenticated haben ein statement_timeout (schützt die Suche ohne Anmeldung)'); + +-- --------------------------------------------------------------------------------------------- +-- search_normalize: Pflichtfälle (Spec 3.2 Nr. 2) und weitere Fälle +-- --------------------------------------------------------------------------------------------- +select is(public.search_normalize('Die Siedler von Catan'), 'die siedler von catan', 'KAT-01 Kriterium 1: Pflichtfall Die Siedler von Catan'); +select is(public.search_normalize('CATAN®'), 'catan', 'KAT-01 Kriterium 1: Pflichtfall CATAN® (Schutzzeichen entfernt, kein "catan r")'); +select is(public.search_normalize('Spiel™ ½'), 'spiel 1 2', 'KAT-01 Kriterium 1: Pflichtfall Spiel™ ½ (Bruch wie von unaccent umgeschrieben)'); +select is(public.search_normalize('Æon’s End'), 'aeon s end', 'KAT-01 Kriterium 1: Pflichtfall Æon’s End (Apostroph wird Leerzeichen)'); +select is(public.search_normalize('7 Wonders: Duel'), '7 wonders duel', 'KAT-01 Kriterium 1: Pflichtfall 7 Wonders: Duel'); +select is(public.search_normalize('Café Größe'), 'cafe grosse', 'KAT-01 Kriterium 1: Pflichtfall Café Größe (é, ö, ß)'); +select is(public.search_normalize('Ticket to Ride – Europe'), 'ticket to ride europe', 'KAT-01 Kriterium 1: Pflichtfall Gedankenstrich'); +select is(public.search_normalize('!!!'), null, 'KAT-01 Kriterium 1: nur Sonderzeichen ergibt null'); +select is(public.search_normalize(''), null, 'KAT-01 Kriterium 1: leerer Text ergibt null'); +select is(public.search_normalize(null), null, 'KAT-01: null ergibt null (strict)'); +select is(public.search_normalize(' ©Ärger℠ ℗ Ü '), 'arger u', 'KAT-01 Kriterium 1: alle Schutzzeichen, Umlaute und Ränder'); +select is(public.search_normalize('Die Siedler, von Catan'), 'die siedler von catan', 'KAT-01: mehrere Trenner werden zu einem Leerzeichen'); + +-- --------------------------------------------------------------------------------------------- +-- Generierte Spalten, Indizes, Tabelle game_alt_titles (Kriterium 2) +-- --------------------------------------------------------------------------------------------- +select is((select string_agg(attrelid::regclass::text || '.' || attname, ',' order by attrelid::regclass::text) + from pg_attribute where attgenerated = 's' and attname in ('title_search', 'name_search') + and attrelid in ('public.games'::regclass, 'public.publishers'::regclass, 'public.game_alt_titles'::regclass)), + 'game_alt_titles.title_search,games.title_search,publishers.name_search', + 'KAT-01: title_search und name_search sind gespeicherte generierte Spalten'); +select is((select count(*) from pg_indexes where schemaname = 'public' + and indexdef like '%USING gin (title_search %gin_trgm_ops)%' + and indexname in ('games_title_search_trgm_idx', 'game_alt_titles_title_search_trgm_idx')), 2::bigint, + 'KAT-01: Trigramm-Indizes auf games.title_search und game_alt_titles.title_search'); +select is((select count(*) from pg_indexes where schemaname = 'public' and indexname = 'games_title_search_trgm_idx' + and indexdef like '%WHERE%visibility%catalog%hidden_at IS NULL%merged_into_id IS NULL%'), 1::bigint, + 'KAT-01: der Index auf games.title_search ist ein Partial-Index mit den Bedingungen der View catalog_games'); +select is((select count(*) from pg_indexes where schemaname = 'public' and indexname = 'publishers_name_search_trgm_idx' + and indexdef like '%USING gin (name_search %gin_trgm_ops)%'), 1::bigint, + 'KAT-01: Trigramm-Index auf publishers.name_search'); +select is((select confdeltype::text from pg_constraint where conrelid = 'public.game_alt_titles'::regclass and contype = 'f' + and confrelid = 'public.games'::regclass), 'c', 'KAT-01 Kriterium 2: game_alt_titles.game_id löscht mit dem Spiel (on delete cascade)'); +select ok(exists (select 1 from pg_trigger where tgrelid = 'public.game_alt_titles'::regclass and tgname = 'game_links_mark_manual'), + 'KAT-01: game_alt_titles hat den Trigger game_links_mark_manual'); + +-- Rechte der Tabelle: nur Lesen für API-Rollen +select ok(has_table_privilege('anon', 'public.game_alt_titles', 'select') and has_table_privilege('authenticated', 'public.game_alt_titles', 'select'), + 'KAT-01 Kriterium 2: anon und authenticated dürfen game_alt_titles lesen'); +select is_empty($$select r, p from unnest(array['anon', 'authenticated']) r, + unnest(array['insert', 'update', 'delete', 'truncate', 'references', 'trigger']) p + where has_table_privilege(r, 'public.game_alt_titles', p)$$, + 'KAT-01 Kriterium 2: anon und authenticated haben kein Schreibrecht und keine weiteren Rechte auf game_alt_titles'); +select is_empty($$select r, c from unnest(array['anon', 'authenticated']) r, unnest(array['game_id', 'title', 'locale', 'id']) c + where has_column_privilege(r, 'public.game_alt_titles', c, 'insert') or has_column_privilege(r, 'public.game_alt_titles', c, 'update')$$, + 'KAT-01 Kriterium 2: auch auf Spaltenebene kein Schreibrecht auf game_alt_titles'); +select results_eq($$select polcmd::text, array(select rolname from pg_roles where oid = any(polroles) order by 1) + from pg_policy where polrelid = 'public.game_alt_titles'::regclass$$, + $$values ('r'::text, array['anon', 'authenticated']::name[])$$, + 'KAT-01 Kriterium 2: game_alt_titles hat genau eine Policy, nur select für anon und authenticated'); + +-- --------------------------------------------------------------------------------------------- +-- Fixtures (alle Titel und Verlage mit "qxz") +-- --------------------------------------------------------------------------------------------- +insert into public.genres (id, name_de, name_en) values + ('f9000000-0000-0000-0000-0000000000e1', 'Qxz Genre A', 'Qxz genre A'), + ('f9000000-0000-0000-0000-0000000000e2', 'Qxz Genre B', 'Qxz genre B'), + ('f9000000-0000-0000-0000-0000000000e3', 'Qxz Genre Q', 'Qxz genre Q'); +insert into public.mechanics (id, name_de, name_en) values + ('f9000000-0000-0000-0000-0000000000d1', 'Qxz Mechanik A', 'Qxz mechanic A'), + ('f9000000-0000-0000-0000-0000000000d2', 'Qxz Mechanik B', 'Qxz mechanic B'); +insert into public.publishers (id, name) values + ('f9000000-0000-0000-0000-0000000000b1', 'Qxz Hersteller Eins'), + ('f9000000-0000-0000-0000-0000000000b2', 'Qxz Hersteller Zwei'), + ('f9000000-0000-0000-0000-0000000000b3', 'Qxz Zorb Verlag'); + +insert into public.games (id, title, visibility) values + ('f9000000-0000-0000-0000-000000000001', 'QXZ Zorb', 'catalog'), + ('f9000000-0000-0000-0000-000000000002', 'Qxz Zorb Deluxe', 'catalog'), + ('f9000000-0000-0000-0000-000000000003', 'Das Qxz Zorb', 'catalog'), + ('f9000000-0000-0000-0000-000000000004', 'Zorb of Qxz', 'catalog'), + ('f9000000-0000-0000-0000-000000000005', 'Qxz Great Zorb', 'catalog'), + ('f9000000-0000-0000-0000-000000000006', 'Qxz Spiel', 'catalog'), + -- Gleichstand: erst die größere ID eingefügt, damit die Heap-Reihenfolge nicht zufällig die ID-Reihenfolge ist + ('f9000000-0000-0000-0000-000000000008', 'Qxz Twin', 'catalog'), + ('f9000000-0000-0000-0000-000000000007', 'QXZ TWIN', 'catalog'), + ('f9000000-0000-0000-0000-000000000010', 'Qxz Hauptspiel Alpha', 'catalog'), + ('f9000000-0000-0000-0000-000000000011', 'Alpha Qxz Bird', 'catalog'), + ('f9000000-0000-0000-0000-000000000012', 'Qxz Einzigartig', 'catalog'), + ('f9000000-0000-0000-0000-000000000013', 'Qxz Käse Größe', 'catalog'), + ('f9000000-0000-0000-0000-000000000014', 'Qxz Æon’s End', 'catalog'), + ('f9000000-0000-0000-0000-000000000015', 'Qxz 7 Wonders', 'catalog'), + ('f9000000-0000-0000-0000-000000000016', 'QXZ CATAN®', 'catalog'), + ('f9000000-0000-0000-0000-000000000040', 'Qxz Doppelspiel', 'catalog'), + ('f9000000-0000-0000-0000-000000000041', 'Qxz Einzelspiel', 'catalog'), + ('f9000000-0000-0000-0000-000000000060', 'Qxz Cabin', 'catalog'); + +-- Filter-Fixtures (20-25); 22 ohne jede Angabe, 23 nur max_minutes, 24 nur min_minutes +insert into public.games (id, title, visibility, min_players, max_players, min_minutes, max_minutes, min_age) values + ('f9000000-0000-0000-0000-000000000020', 'Qxz Filter 20', 'catalog', 2, 4, 30, 60, 8), + ('f9000000-0000-0000-0000-000000000021', 'Qxz Filter 21', 'catalog', 3, 6, 60, 90, 12), + ('f9000000-0000-0000-0000-000000000022', 'Qxz Filter 22', 'catalog', null, null, null, null, null), + ('f9000000-0000-0000-0000-000000000023', 'Qxz Filter 23', 'catalog', 2, 2, null, 45, 6), + ('f9000000-0000-0000-0000-000000000024', 'Qxz Filter 24', 'catalog', 1, 8, 20, null, 0), + ('f9000000-0000-0000-0000-000000000025', 'Qxz Filter 25', 'catalog', 2, 5, 30, 30, 10); +insert into public.game_genres (game_id, genre_id) values + ('f9000000-0000-0000-0000-000000000020', 'f9000000-0000-0000-0000-0000000000e1'), + ('f9000000-0000-0000-0000-000000000020', 'f9000000-0000-0000-0000-0000000000e2'), + ('f9000000-0000-0000-0000-000000000021', 'f9000000-0000-0000-0000-0000000000e1'), + ('f9000000-0000-0000-0000-000000000022', 'f9000000-0000-0000-0000-0000000000e1'), + ('f9000000-0000-0000-0000-000000000025', 'f9000000-0000-0000-0000-0000000000e2'), + ('f9000000-0000-0000-0000-000000000005', 'f9000000-0000-0000-0000-0000000000e3'), + ('f9000000-0000-0000-0000-000000000060', 'f9000000-0000-0000-0000-0000000000e3'); +insert into public.game_mechanics (game_id, mechanic_id) values + ('f9000000-0000-0000-0000-000000000020', 'f9000000-0000-0000-0000-0000000000d1'), + ('f9000000-0000-0000-0000-000000000021', 'f9000000-0000-0000-0000-0000000000d1'), + ('f9000000-0000-0000-0000-000000000021', 'f9000000-0000-0000-0000-0000000000d2'), + ('f9000000-0000-0000-0000-000000000025', 'f9000000-0000-0000-0000-0000000000d2'); + +-- Verlage: 01 und 30 bei Eins; 40 bei Eins und Zwei; 41 bei Zwei; 06 bei "Qxz Zorb Verlag" +insert into public.game_publishers (game_id, publisher_id) values + ('f9000000-0000-0000-0000-000000000001', 'f9000000-0000-0000-0000-0000000000b1'), + ('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b2'), + ('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b1'), + ('f9000000-0000-0000-0000-000000000041', 'f9000000-0000-0000-0000-0000000000b2'), + ('f9000000-0000-0000-0000-000000000006', 'f9000000-0000-0000-0000-0000000000b3'); + +-- Ausgeblendet, zusammengeführt, privat +insert into public.games (id, title, visibility, hidden_at) values + ('f9000000-0000-0000-0000-000000000030', 'Qxz Verborgen', 'catalog', now()); +insert into public.games (id, title, visibility, hidden_at, merged_into_id) values + ('f9000000-0000-0000-0000-000000000031', 'Qxz Verschmolzen', 'catalog', now(), 'f9000000-0000-0000-0000-000000000001'); +insert into public.games (id, title, visibility, owner_id) values + ('f9000000-0000-0000-0000-000000000032', 'Qxz Privat', 'private', '11111111-1111-1111-1111-111111111111'), + ('f9000000-0000-0000-0000-000000000033', 'QXZ ZORB', 'private', '22222222-2222-2222-2222-222222222222'); +insert into public.game_publishers (game_id, publisher_id) values + ('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-0000000000b1'), + ('f9000000-0000-0000-0000-000000000033', 'f9000000-0000-0000-0000-0000000000b1'); + +-- Bilder: 50 mit Quelle ohne Erlaubnis (maskiert), 51 manuell gepflegt, 52 ohne Quelle +insert into public.games (id, title, visibility, image_path, catalog_source_id, source_ref) values + ('f9000000-0000-0000-0000-000000000050', 'Qxz Cover Quelle', 'catalog', 'covers/quelle.png', (select id from public.catalog_sources where key = 'bgg'), '501'), + ('f9000000-0000-0000-0000-000000000051', 'Qxz Cover Manuell', 'catalog', 'covers/manuell.png', (select id from public.catalog_sources where key = 'bgg'), '502'), + ('f9000000-0000-0000-0000-000000000052', 'Qxz Cover Eigen', 'catalog', 'covers/eigen.png', null, null); +update public.games set manual_fields = '{}' where id = 'f9000000-0000-0000-0000-000000000050'; +update public.games set manual_fields = array['image_path'] where id = 'f9000000-0000-0000-0000-000000000051'; + +-- 40 Spiele für das Nachladen (Kriterium 5) +insert into public.games (id, title, visibility) +select ('f9000000-0000-0000-0000-0000000001' || lpad(n::text, 2, '0'))::uuid, 'Qxz Seite ' || lpad(n::text, 2, '0'), 'catalog' +from generate_series(1, 40) n; + +-- Alternative Titel (als postgres, wie im Studio) +insert into public.game_alt_titles (id, game_id, locale, title) values + ('f9000000-0000-0000-0000-0000000000a1', 'f9000000-0000-0000-0000-000000000010', 'de', 'Qxz Zugvogel'), + ('f9000000-0000-0000-0000-0000000000a2', 'f9000000-0000-0000-0000-000000000010', 'en', 'Qxz Migrant Bird'), + ('f9000000-0000-0000-0000-0000000000a3', 'f9000000-0000-0000-0000-000000000011', 'en', 'Qxz Bird'), + ('f9000000-0000-0000-0000-0000000000a4', 'f9000000-0000-0000-0000-000000000012', 'de', 'Qxz Einzigartig Alt'), + ('f9000000-0000-0000-0000-0000000000a5', 'f9000000-0000-0000-0000-000000000012', 'en', 'QXZ EINZIGARTIG ALT'), + ('f9000000-0000-0000-0000-0000000000a6', 'f9000000-0000-0000-0000-000000000030', 'de', 'Qxz Verborgen Alt'), + ('f9000000-0000-0000-0000-0000000000a7', 'f9000000-0000-0000-0000-000000000031', 'de', 'Qxz Verschmolzen Alt'), + ('f9000000-0000-0000-0000-0000000000a8', 'f9000000-0000-0000-0000-000000000032', 'de', 'Qxz Privat Alt'), + ('f9000000-0000-0000-0000-0000000000a9', 'f9000000-0000-0000-0000-000000000033', 'en', 'Qxz Bens Alt'); + +select is((select title_search from public.games where id = 'f9000000-0000-0000-0000-000000000014'), 'qxz aeon s end', + 'KAT-01 Kriterium 1: games.title_search wird beim Anlegen gefüllt'); +select is((select title_search from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000a5'), 'qxz einzigartig alt', + 'KAT-01 Kriterium 2: game_alt_titles.title_search wird gefüllt'); +select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b2'), 'qxz hersteller zwei', + 'KAT-01 Kriterium 3: publishers.name_search wird gefüllt'); +update public.publishers set name = 'Qxz Hersteller Zwei & Söhne' where id = 'f9000000-0000-0000-0000-0000000000b2'; +select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b2'), 'qxz hersteller zwei sohne', + 'KAT-01: publishers.name_search folgt einer Namensänderung'); +update public.publishers set name = 'Qxz Hersteller Zwei' where id = 'f9000000-0000-0000-0000-0000000000b2'; + +-- Hilfsfunktion: alle Fixture-Treffer durchblättern (nur als postgres, für Abfragen ohne Suchbegriff) +create function pg_temp.hits(p_query text default null, p_players smallint default null, p_max_minutes smallint default null, + p_age smallint default null, p_genre_ids uuid[] default null, p_mechanic_ids uuid[] default null) +returns setof uuid language sql as $$ + select r.id + from generate_series(0, (select coalesce(max(t.total_count), 0) + from public.search_catalog_games(p_query, p_players, p_max_minutes, p_age, p_genre_ids, p_mechanic_ids, 1, 0) t)::int, 50) o, + lateral public.search_catalog_games(p_query, p_players, p_max_minutes, p_age, p_genre_ids, p_mechanic_ids, 50, o) r + where r.id::text like 'f9000000%' +$$; + +-- --------------------------------------------------------------------------------------------- +-- Kriterium 1: Teilzeichenkette, Groß/Klein, Sonderzeichen, Relevanzstufen +-- --------------------------------------------------------------------------------------------- +select results_eq($$select id, match_rank::int, total_count::int from public.search_catalog_games('qxz zorb')$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid, 0, 6), + ('f9000000-0000-0000-0000-000000000002', 1, 6), + ('f9000000-0000-0000-0000-000000000003', 2, 6), + ('f9000000-0000-0000-0000-000000000004', 3, 6), + ('f9000000-0000-0000-0000-000000000005', 3, 6), + ('f9000000-0000-0000-0000-000000000006', 4, 6)$$, + 'KAT-01 Kriterium 1: Relevanzstufen 0 bis 4 in der erwarteten Reihenfolge; gleiche Stufe nach Ähnlichkeit (nicht nach Titel); Verlagstreffer zuletzt'); +select results_eq($$select id from public.search_catalog_games('QXZ: ZORB!')$$, + $$select id from public.search_catalog_games('qxz zorb')$$, + 'KAT-01 Kriterium 1: Groß-/Kleinschreibung und Sonderzeichen ändern das Ergebnis nicht'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zo')$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid, 1), ('f9000000-0000-0000-0000-000000000002', 1), + ('f9000000-0000-0000-0000-000000000003', 2), ('f9000000-0000-0000-0000-000000000004', 3), + ('f9000000-0000-0000-0000-000000000005', 3), ('f9000000-0000-0000-0000-000000000006', 4)$$, + 'KAT-01 Kriterium 1: ein Teil des Titels genügt (Wort ab 2 Zeichen als Teilzeichenkette)'); +select results_eq($$select id from public.search_catalog_games('qxz twin')$$, + $$values ('f9000000-0000-0000-0000-000000000007'::uuid), ('f9000000-0000-0000-0000-000000000008')$$, + 'KAT-01 Kriterium 5: bei völligem Gleichstand entscheidet die ID (stabile Reihenfolge)'); +select is((select count(*) from public.search_catalog_games('qxz käse')), 1::bigint, 'KAT-01 Kriterium 1: Umlaut im Suchbegriff findet den Umlaut-Titel'); +select is((select count(*) from public.search_catalog_games('QXZ KASE GROSSE')), 1::bigint, 'KAT-01 Kriterium 1: ohne Umlaut und ß wird derselbe Titel gefunden'); +select is((select count(*) from public.search_catalog_games('qxz grösse')), 1::bigint, 'KAT-01 Kriterium 1: ö und ß in der Eingabe'); +select is((select count(*) from public.search_catalog_games('qxz kaese')), 0::bigint, 'KAT-01 Spec-F5: "ae" findet "ä" in R1 nicht'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz aeons end')$$, + $$values ('f9000000-0000-0000-0000-000000000014'::uuid, 0)$$, + 'KAT-01 Kriterium 1: "aeons end" findet "Æon’s End" über die Schreibweise ohne Leerzeichen'); +select is((select count(*) from public.search_catalog_games('qxzaeonsend')), 1::bigint, 'KAT-01 Kriterium 1: ohne jedes Leerzeichen eingegeben'); +select is((select count(*) from public.search_catalog_games('qxz æon’s end')), 1::bigint, 'KAT-01 Kriterium 1: Æ und Apostroph in der Eingabe'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz 7wonders')$$, + $$values ('f9000000-0000-0000-0000-000000000015'::uuid, 0)$$, + 'KAT-01 Kriterium 1: "7wonders" findet "7 Wonders"'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('Qxz-Catan!')$$, + $$values ('f9000000-0000-0000-0000-000000000016'::uuid, 0)$$, + 'KAT-01 Kriterium 1: "CATAN®" wird über "catan" gefunden'); +select is((select count(*) from public.search_catalog_games('!!!')), 0::bigint, 'KAT-01 Kriterium 1: nur Sonderzeichen ergibt keine Treffer'); +select is((select count(*) from public.search_catalog_games('zzznichtvorhanden')), 0::bigint, 'KAT-01 Kriterium 6: Suche ohne Treffer liefert keine Zeilen'); +select results_eq($$select id from public.search_catalog_games('qxz 9 zorb') order by id$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000002'), ('f9000000-0000-0000-0000-000000000003'), + ('f9000000-0000-0000-0000-000000000004'), ('f9000000-0000-0000-0000-000000000005'), ('f9000000-0000-0000-0000-000000000006')$$, + 'KAT-01 Kriterium 1: Wörter mit einem Zeichen werden verworfen ("qxz 9 zorb" = "qxz zorb")'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('a b', null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, 3)$$, + 'KAT-01 Kriterium 1: "a b" (nur Wörter mit einem Zeichen) trifft nur über die Schreibweise ohne Leerzeichen'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('É', null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int), ('f9000000-0000-0000-0000-000000000005', null)$$, + 'KAT-01 Kriterium 1: ein normalisierter Begriff unter 2 Zeichen gilt als kein Suchbegriff (alles, match_rank null)'); +select results_eq($$select id, match_rank::int from public.search_catalog_games(' ', null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int), ('f9000000-0000-0000-0000-000000000005', null)$$, + 'KAT-01 Kriterium 8: nur Leerzeichen gelten als kein Suchbegriff'); +select results_eq($$select id, match_rank::int from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int), ('f9000000-0000-0000-0000-000000000005', null)$$, + 'KAT-01 Kriterium 8: null ist kein Suchbegriff, Sortierung nach Titel'); + +-- --------------------------------------------------------------------------------------------- +-- Kriterium 2: alternative Titel +-- --------------------------------------------------------------------------------------------- +select results_eq($$select id, matched_alt_title, match_rank::int from public.search_catalog_games('qxz zugvogel')$$, + $$values ('f9000000-0000-0000-0000-000000000010'::uuid, 'Qxz Zugvogel'::text, 0)$$, + 'KAT-01 Kriterium 2: der deutsche alternative Titel findet das Spiel, matched_alt_title nennt ihn'); +select results_eq($$select id, matched_alt_title, match_rank::int from public.search_catalog_games('qxz migrant bird')$$, + $$values ('f9000000-0000-0000-0000-000000000010'::uuid, 'Qxz Migrant Bird'::text, 0)$$, + 'KAT-01 Kriterium 2: der englische alternative Titel findet dasselbe Spiel'); +select results_eq($$select id, matched_alt_title, match_rank::int from public.search_catalog_games('qxz hauptspiel')$$, + $$values ('f9000000-0000-0000-0000-000000000010'::uuid, null::text, 1)$$, + 'KAT-01 Kriterium 2: Treffer über den Haupttitel hat matched_alt_title null'); +select results_eq($$select id, matched_alt_title, match_rank::int, total_count::int from public.search_catalog_games('qxz bird')$$, + $$values ('f9000000-0000-0000-0000-000000000011'::uuid, 'Qxz Bird'::text, 0, 2), + ('f9000000-0000-0000-0000-000000000010', 'Qxz Migrant Bird', 3, 2)$$, + 'KAT-01 Kriterium 2: der alternative Titel zählt, wenn er besser trifft als der Haupttitel (Stufe 0 statt 2)'); +select results_eq($$select id, matched_alt_title, match_rank::int, total_count::int from public.search_catalog_games('qxz einzigartig')$$, + $$values ('f9000000-0000-0000-0000-000000000012'::uuid, null::text, 0, 1)$$, + 'KAT-01 Kriterium 2: ein Spiel erscheint nur einmal, auch wenn Haupt- und alternative Titel treffen; bei besserem Haupttitel null'); +select results_eq($$select id, lower(matched_alt_title), total_count::int from public.search_catalog_games('qxz einzigartig alt')$$, + $$values ('f9000000-0000-0000-0000-000000000012'::uuid, 'qxz einzigartig alt'::text, 1)$$, + 'KAT-01 Kriterium 2: zwei alternative Titel (de, en) mit gleicher Stufe ergeben eine Zeile'); + +-- --------------------------------------------------------------------------------------------- +-- Kriterium 3: Verlage +-- --------------------------------------------------------------------------------------------- +select results_eq($$select id, match_rank::int, publisher_names from public.search_catalog_games('qxz hersteller zwei')$$, + $$values ('f9000000-0000-0000-0000-000000000040'::uuid, 4, array['Qxz Hersteller Eins', 'Qxz Hersteller Zwei']::text[]), + ('f9000000-0000-0000-0000-000000000041', 4, array['Qxz Hersteller Zwei'])$$, + 'KAT-01 Kriterium 3: der Verlag findet alle seine Spiele (Stufe 4); ein Spiel mit zwei Verlagen über beide; publisher_names sortiert und vollständig'); +select results_eq($$select id from public.search_catalog_games('qxz hersteller eins') order by id$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000040')$$, + 'KAT-01 Kriterium 3: der andere Verlag des Spiels findet es ebenfalls; ausgeblendete Spiele des Verlags fehlen'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zorb eins')$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid, 4)$$, + 'KAT-01 Kriterium 3: ein Wort aus dem Titel plus ein Wort aus dem Verlag (UND)'); +select results_eq($$select id, publisher_names from public.search_catalog_games('qxz cabin')$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, '{}'::text[])$$, + 'KAT-01 Kriterium 3: publisher_names ist leer statt null'); + +-- --------------------------------------------------------------------------------------------- +-- Kriterium 4 und 8: Filter, mit und ohne Suchbegriff +-- --------------------------------------------------------------------------------------------- +select results_eq($$select h from pg_temp.hits(p_players := 3::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000021'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 8: Spielerzahl 3 ohne Suchbegriff (Spiele ohne Angabe fehlen, 2-2 passt nicht)'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_players := 3::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000021'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 4: Spielerzahl 3 mit Suchbegriff'); +select results_eq($$select h from pg_temp.hits(p_players := 2::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000023'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 8: Spielerzahl 2 (untere und obere Grenze zählen mit)'); +select results_eq($$select h from pg_temp.hits(p_max_minutes := 45::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000023'::uuid), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Spec-F2: höchstens 45 Minuten; nur max_minutes oder nur min_minutes bekannt zählt, ohne Angabe fehlt'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_max_minutes := 60::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000023'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 4: höchstens 60 Minuten (Grenze zählt mit) mit Suchbegriff'); +select results_eq($$select h from pg_temp.hits(p_age := 8::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000023'), ('f9000000-0000-0000-0000-000000000024')$$, + 'KAT-01 Kriterium 8: Alter 8 (Mindestalter bis einschließlich 8)'); +select results_eq($$select h from pg_temp.hits(p_age := 0::smallint) h$$, + $$values ('f9000000-0000-0000-0000-000000000024'::uuid)$$, + 'KAT-01 Kriterium 4: Alter 0 findet nur Mindestalter 0'); +select results_eq($$select h from pg_temp.hits(p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1']::uuid[]) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000021'), ('f9000000-0000-0000-0000-000000000022')$$, + 'KAT-01 Kriterium 4: Genre allein'); +select results_eq($$select h from pg_temp.hits(p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1', 'f9000000-0000-0000-0000-0000000000e2']::uuid[]) h$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid)$$, + 'KAT-01 Spec-F1: zwei Genres: nur Spiele mit beiden'); +select results_eq($$select h from pg_temp.hits(p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1', 'f9000000-0000-0000-0000-0000000000e1']::uuid[]) h order by 1$$, + $$select h from pg_temp.hits(p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1']::uuid[]) h order by 1$$, + 'KAT-01 Kriterium 4: Duplikate im Array zählen einfach'); +select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000021'::uuid), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 4: Mechanik allein'); +select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d1', 'f9000000-0000-0000-0000-0000000000d2']::uuid[]) h$$, + $$values ('f9000000-0000-0000-0000-000000000021'::uuid)$$, + 'KAT-01 Spec-F1: zwei Mechaniken: nur Spiele mit beiden'); +select results_eq($$select h from pg_temp.hits(p_players := 3::smallint, p_age := 12::smallint, + p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1']::uuid[], p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h$$, + $$values ('f9000000-0000-0000-0000-000000000021'::uuid)$$, + 'KAT-01 Kriterium 4: alle Filter zusammen sind UND-verknüpft'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_players := 2::smallint, p_max_minutes := 60::smallint, + p_genre_ids := array['f9000000-0000-0000-0000-0000000000e2']::uuid[]) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 4: Suchbegriff plus Spielerzahl, Dauer und Genre'); +select is((select count(*) from public.search_catalog_games(null, null, null, null, array[gen_random_uuid()])), 0::bigint, + 'KAT-01 Kriterium 4: unbekannte Genre-ID ergibt 0 Treffer'); +select is((select count(*) from public.search_catalog_games(null, null, null, null, null, array[gen_random_uuid()])), 0::bigint, + 'KAT-01 Kriterium 4: unbekannte Mechanik-ID ergibt 0 Treffer'); +select results_eq($$select id, match_rank::int, matched_alt_title, total_count::int from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int, null::text, 2), + ('f9000000-0000-0000-0000-000000000005', null, null, 2)$$, + 'KAT-01 Kriterium 8: ohne Suchbegriff nach Titel sortiert, match_rank und matched_alt_title null'); +select results_eq($$select h from pg_temp.hits() h order by 1$$, + $$select id from public.games where id::text like 'f9000000%' and visibility = 'catalog' and hidden_at is null and merged_into_id is null order by id$$, + 'KAT-01 Kriterium 8: ohne Suchbegriff und ohne Filter erscheinen alle sichtbaren Fixture-Spiele (ohne ausgeblendete, zusammengeführte, private)'); + +-- --------------------------------------------------------------------------------------------- +-- Kriterium 5: Nachladen +-- --------------------------------------------------------------------------------------------- +select results_eq($$select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 0) + union all select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 15) + union all select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 30)$$, + $$select id from public.games where title like 'Qxz Seite %' order by title$$, + 'KAT-01 Kriterium 5: drei Seiten ergeben ohne Überschneidung genau die 40 Treffer in stabiler Reihenfolge'); +select results_eq($$select count(*), count(distinct id), count(distinct total_count), min(total_count) + from (select * from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 0) + union all select * from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 15) + union all select * from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 30)) s$$, + $$values (40::bigint, 40::bigint, 1::bigint, 40::bigint)$$, + 'KAT-01 Kriterium 5: total_count ist in jeder Zeile jeder Seite gleich (40)'); +select is((select count(*) from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 45)), 0::bigint, + 'KAT-01 Kriterium 5: hinter dem Ende keine Zeilen (hasMore false)'); +select is((select count(*) from public.search_catalog_games('qxz seite')), 30::bigint, 'KAT-01 Kriterium 5: Standard-Seitengröße 30'); +select is((select count(*) from public.search_catalog_games('qxz seite', p_limit := 50)), 40::bigint, 'KAT-01 Kriterium 5: Seitengröße bis 50 erlaubt'); +select results_eq($$select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 10, 0)$$, + $$select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 10, 0)$$, + 'KAT-01 Kriterium 5: wiederholter Aufruf liefert dieselbe Reihenfolge'); + +-- --------------------------------------------------------------------------------------------- +-- KAT-05 Kriterium 4 und 10: ausgeblendet, zusammengeführt, privat, Bild +-- --------------------------------------------------------------------------------------------- +select is((select count(*) from public.search_catalog_games('qxz verborgen')), 0::bigint, 'KAT-05 Kriterium 4: ein ausgeblendetes Spiel erscheint nicht über den Titel'); +select is((select count(*) from public.search_catalog_games('qxz verborgen alt')), 0::bigint, 'KAT-05 Kriterium 4: ... nicht über den alternativen Titel'); +select is((select count(*) from public.search_catalog_games('qxz verschmolzen alt')), 0::bigint, 'KAT-05 Kriterium 4: ein zusammengeführtes Spiel erscheint nicht über seinen alternativen Titel'); +select is((select count(*) from public.search_catalog_games('qxz verschmolzen')), 0::bigint, 'KAT-05 Kriterium 4: ... und nicht über den Titel'); +select is((select count(*) from public.search_catalog_games('qxz privat')), 0::bigint, 'KAT-01 Sicherheit: ein privates Spiel erscheint nie (Titel)'); +select is((select count(*) from public.search_catalog_games('qxz bens alt')), 0::bigint, 'KAT-01 Sicherheit: ... auch nicht über einen alternativen Titel'); +select is((select count(*) from public.search_catalog_games(null, null, null, null, null, null, 50, 0) where id::text in ('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-000000000031', 'f9000000-0000-0000-0000-000000000032', 'f9000000-0000-0000-0000-000000000033')), 0::bigint, + 'KAT-05 Kriterium 4: ohne Suchbegriff fehlen ausgeblendete, zusammengeführte und private Spiele ebenso (erste Seite)'); +select is((select count(*) from pg_temp.hits() h where h in ('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-000000000031', 'f9000000-0000-0000-0000-000000000032', 'f9000000-0000-0000-0000-000000000033')), 0::bigint, + 'KAT-05 Kriterium 4: ohne Suchbegriff fehlen ausgeblendete, zusammengeführte und private Spiele auch beim Durchblättern'); +select results_eq($$select id, image_path from public.search_catalog_games('qxz cover') order by id$$, + $$values ('f9000000-0000-0000-0000-000000000050'::uuid, null::text), ('f9000000-0000-0000-0000-000000000051', 'covers/manuell.png'), + ('f9000000-0000-0000-0000-000000000052', 'covers/eigen.png')$$, + 'KAT-05 Kriterium 10: image_path kommt aus catalog_games (maskiert bei Quelle ohne Erlaubnis, sichtbar bei manueller Pflege und ohne Quelle)'); +select results_eq($$select title, year, min_players, max_players, min_minutes, max_minutes, min_age from public.search_catalog_games('qxz filter 20')$$, + $$values ('Qxz Filter 20'::text, null::smallint, 2::smallint, 4::smallint, 30::smallint, 60::smallint, 8::smallint)$$, + 'KAT-01: die Ausgabe enthält Titel und Kurzangaben'); + +-- --------------------------------------------------------------------------------------------- +-- Eingabegrenzen +-- --------------------------------------------------------------------------------------------- +select throws_ok($$select * from public.search_catalog_games(repeat('a', 101))$$, 'P0001', 'search_query_too_long', 'KAT-01: 101 Zeichen sind zu lang'); +select lives_ok($$select * from public.search_catalog_games(repeat('a', 100))$$, 'KAT-01: 100 Zeichen sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games('aa bb cc dd ee ff gg hh ii jj kk')$$, 'P0001', 'search_query_too_long', 'KAT-01: 11 Wörter sind zu viel'); +select throws_ok($$select * from public.search_catalog_games('a b c d e f g h i j k')$$, 'P0001', 'search_query_too_long', 'KAT-01: 11 Wörter zählen auch, wenn sie nur ein Zeichen haben'); +select lives_ok($$select * from public.search_catalog_games('aa bb cc dd ee ff gg hh ii jj')$$, 'KAT-01: 10 Wörter sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games(null, 0::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Spielerzahl 0'); +select throws_ok($$select * from public.search_catalog_games(null, 100::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Spielerzahl 100'); +select lives_ok($$select * from public.search_catalog_games(null, 1::smallint); select * from public.search_catalog_games(null, 99::smallint)$$, 'KAT-01: Spielerzahl 1 und 99 sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games(null, null, 0::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Dauer 0'); +select throws_ok($$select * from public.search_catalog_games(null, null, 1441::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Dauer 1441'); +select lives_ok($$select * from public.search_catalog_games(null, null, 1::smallint); select * from public.search_catalog_games(null, null, 1440::smallint)$$, 'KAT-01: Dauer 1 und 1440 sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, (-1)::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Alter -1'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, 100::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Alter 100'); +select lives_ok($$select * from public.search_catalog_games(null, null, null, 0::smallint); select * from public.search_catalog_games(null, null, null, 99::smallint)$$, 'KAT-01: Alter 0 und 99 sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, (select array_agg(gen_random_uuid()) from generate_series(1, 21)))$$, 'P0001', 'search_filter_invalid', 'KAT-01: 21 Genre-IDs'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, (select array_agg(gen_random_uuid()) from generate_series(1, 21)))$$, 'P0001', 'search_filter_invalid', 'KAT-01: 21 Mechanik-IDs'); +select lives_ok($$select * from public.search_catalog_games(null, null, null, null, (select array_agg(gen_random_uuid()) from generate_series(1, 20)), (select array_agg(gen_random_uuid()) from generate_series(1, 20)))$$, 'KAT-01: je 20 IDs sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 0, 0)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Seitengröße 0'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 51, 0)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Seitengröße 51'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 30, -1)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Versatz -1'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 30, 10001)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Versatz 10001'); +select lives_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 1, 0); select * from public.search_catalog_games(null, null, null, null, null, null, 50, 10000)$$, 'KAT-01: Seitengröße 1 und 50, Versatz 0 und 10000 sind erlaubt'); + +-- --------------------------------------------------------------------------------------------- +-- Tabelle game_alt_titles: Checks, Unique, manual_fields, Kaskade +-- --------------------------------------------------------------------------------------------- +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'fr', 'Titre')$$, + '23514', null, 'KAT-01 Kriterium 2: Sprache nur de oder en (Check)'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', repeat('x', 201))$$, + '23514', null, 'KAT-01 Kriterium 2: Titel höchstens 200 Zeichen (Check)'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', '')$$, + '23514', null, 'KAT-01 Kriterium 2: Titel nicht leer (Check)'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', '!!! ®')$$, + '23514', null, 'KAT-01 Kriterium 2: ein Titel nur aus Sonderzeichen ist nicht zulässig (title_search nicht null)'); +select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', repeat('x', 200))$$, + 'KAT-01 Kriterium 2: 200 Zeichen sind erlaubt'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000012', 'de', 'QXZ einzigartig alt!')$$, + '23505', null, 'KAT-01 Kriterium 2: derselbe Titel in derselben Sprache nur einmal, unabhängig von der Schreibweise (Unique)'); +select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Zweitname')$$, + 'KAT-01 Kriterium 2: weiterer Titel in einer Sprache'); +select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'en', 'QXZ zweitname!')$$, + 'KAT-01 Kriterium 2: derselbe Titel in der anderen Sprache ist zulässig'); +select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Dritter Name')$$, + 'KAT-01 Kriterium 2: mehrere Titel je Sprache sind erlaubt'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values (gen_random_uuid(), 'de', 'Qxz Waise')$$, + '23503', null, 'KAT-01 Kriterium 2: alternative Titel brauchen ein vorhandenes Spiel (Fremdschlüssel)'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title, title_search) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Eigen', 'x')$$, + '428C9', null, 'KAT-01: title_search ist generiert und nicht beschreibbar (auch nicht als postgres)'); +select throws_ok($$update public.games set title_search = 'x' where id = 'f9000000-0000-0000-0000-000000000013'$$, + '428C9', null, 'KAT-01: games.title_search ist nicht beschreibbar'); + +-- manual_fields: Einfügen, Ändern und Löschen markieren alt_titles (nur Katalogspiele) +select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000010'), + 'KAT-01 Kriterium 2: Einfügen alternativer Titel markiert alt_titles in manual_fields (Katalogspiel)'); +select ok(not ((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000040')), + 'KAT-01: ein Spiel ohne alternative Titel ist nicht als alt_titles markiert'); +select is((select manual_fields from public.games where id = 'f9000000-0000-0000-0000-000000000032'), '{}'::text[], + 'KAT-01: alternative Titel eines privaten Spiels markieren nichts'); +insert into public.games (id, title, visibility) values ('f9000000-0000-0000-0000-000000000071', 'Qxz Markierung', 'catalog'); +select ok(not ((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071')), 'Ausgangslage: Markierungsspiel ohne alt_titles'); +insert into public.game_alt_titles (id, game_id, locale, title) values ('f9000000-0000-0000-0000-0000000000b9', 'f9000000-0000-0000-0000-000000000071', 'de', 'Qxz Mark Alt'); +select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071'), 'KAT-01: insert markiert alt_titles'); +update public.games set manual_fields = array_remove(manual_fields, 'alt_titles') where id = 'f9000000-0000-0000-0000-000000000071'; +update public.game_alt_titles set title = 'Qxz Mark Alt Zwei' where id = 'f9000000-0000-0000-0000-0000000000b9'; +select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071'), 'KAT-01: update markiert alt_titles'); +update public.games set manual_fields = array_remove(manual_fields, 'alt_titles') where id = 'f9000000-0000-0000-0000-000000000071'; +delete from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000b9'; +select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071'), 'KAT-01: delete markiert alt_titles'); + +-- manual_fields-Check: vollständige Liste von main plus alt_titles +select lives_ok($$update public.games set manual_fields = array['title', 'description', 'description_locale', 'year', + 'min_players', 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path', + 'genres', 'mechanics', 'categories', 'publishers', 'alt_titles'] where id = 'f9000000-0000-0000-0000-000000000071'$$, + 'KAT-01: der Check akzeptiert alle 14 bisherigen Schlüssel und alt_titles'); +select throws_ok($$update public.games set manual_fields = array['alt_titles', 'unbekannt'] where id = 'f9000000-0000-0000-0000-000000000071'$$, + '23514', null, 'KAT-01: der Check lehnt unbekannte Schlüssel weiterhin ab'); + +-- Kaskade: Spiel löschen löscht seine alternativen Titel +insert into public.games (id, title, visibility) values ('f9000000-0000-0000-0000-000000000070', 'Qxz Löschtest', 'catalog'); +insert into public.game_alt_titles (game_id, locale, title) values + ('f9000000-0000-0000-0000-000000000070', 'de', 'Qxz Löschtest Alt'), ('f9000000-0000-0000-0000-000000000070', 'en', 'Qxz Delete test'); +delete from public.games where id = 'f9000000-0000-0000-0000-000000000070'; +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000070'), 0::bigint, + 'KAT-01 Kriterium 2: Spiel löschen löscht seine alternativen Titel'); + +-- updated_at: Trigger setzt ihn bei Änderung (ctid statt now(), die Transaktion hat eine feste Zeit) +select ok(exists (select 1 from pg_trigger where tgrelid = 'public.game_alt_titles'::regclass and tgname = 'set_updated_at'), + 'KAT-01: game_alt_titles setzt updated_at per Trigger'); + +-- --------------------------------------------------------------------------------------------- +-- Rollen: anon, authenticated (Sicherheit Kriterium 2) +-- --------------------------------------------------------------------------------------------- +set local role anon; +select lives_ok($$select * from public.search_catalog_games('qxz zorb')$$, 'KAT-01 Sicherheit Kriterium 2: die Suche als anon läuft ohne Rechtefehler'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zorb')$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid, 0), ('f9000000-0000-0000-0000-000000000002', 1), + ('f9000000-0000-0000-0000-000000000003', 2), ('f9000000-0000-0000-0000-000000000004', 3), + ('f9000000-0000-0000-0000-000000000005', 3), ('f9000000-0000-0000-0000-000000000006', 4)$$, + 'KAT-01 Sicherheit Kriterium 2: anon sieht dieselben Treffer; das private Spiel "QXZ ZORB" eines Nutzers fehlt'); +select results_eq($$select id, matched_alt_title from public.search_catalog_games('qxz zugvogel')$$, + $$values ('f9000000-0000-0000-0000-000000000010'::uuid, 'Qxz Zugvogel'::text)$$, 'KAT-01 Kriterium 2: alternative Titel funktionieren für anon'); +select results_eq($$select id, publisher_names from public.search_catalog_games('qxz doppelspiel')$$, + $$values ('f9000000-0000-0000-0000-000000000040'::uuid, array['Qxz Hersteller Eins', 'Qxz Hersteller Zwei']::text[])$$, + 'KAT-01 Kriterium 3: Verlage funktionieren für anon'); +select results_eq($$select id, image_path from public.search_catalog_games('qxz cover') order by id$$, + $$values ('f9000000-0000-0000-0000-000000000050'::uuid, null::text), ('f9000000-0000-0000-0000-000000000051', 'covers/manuell.png'), + ('f9000000-0000-0000-0000-000000000052', 'covers/eigen.png')$$, 'KAT-05 Kriterium 10: Maskierung des Bildes gilt für anon'); +select is((select count(*) from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e1', 'f9000000-0000-0000-0000-0000000000e2']::uuid[])), 1::bigint, + 'KAT-01 Kriterium 4: Filter wirken auch für anon'); +select is((select count(*) from public.search_catalog_games('qxz seite', p_limit := 50)), 40::bigint, 'KAT-01 Kriterium 5: Nachladen für anon'); +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000010'), 2::bigint, + 'KAT-01 Kriterium 2: anon liest alternative Titel von Katalogspielen'); +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000032'), 0::bigint, + 'KAT-01 Kriterium 2: anon liest keine alternativen Titel eines privaten Spiels'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Anon')$$, + '42501', null, 'KAT-01 Kriterium 2: anon kann keine alternativen Titel anlegen'); +select throws_ok($$update public.game_alt_titles set title = 'Qxz Anon' where id = 'f9000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-01 Kriterium 2: anon kann keine alternativen Titel ändern'); +select throws_ok($$delete from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-01 Kriterium 2: anon kann keine alternativen Titel löschen'); +reset role; + +-- Anna (authentifiziert): legt ein privates Spiel an und ändert es (title_search wird mit den Rechten des Nutzers berechnet) +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select lives_ok($$insert into public.games (owner_id, title, visibility) values ('11111111-1111-1111-1111-111111111111', 'Qxz Mölkky ®', 'private')$$, + 'KAT-01 Sicherheit: authenticated legt ein privates Spiel an (search_normalize ausführbar, kein 42501)'); +select is((select title_search from public.games where title = 'Qxz Mölkky ®'), 'qxz molkky', + 'KAT-01 Sicherheit: title_search ist nach dem Anlegen gefüllt'); +select is((select count(*) from public.search_catalog_games('qxz molkky')), 0::bigint, 'KAT-01 Sicherheit: Anna findet das gerade angelegte private Spiel nicht über die Katalogsuche'); +select lives_ok($$update public.games set title = 'Spiel™ ½' where title = 'Qxz Mölkky ®'$$, + 'KAT-01 Sicherheit: authenticated ändert den Titel seines privaten Spiels (kein 42501)'); +select is((select title_search from public.games where title = 'Spiel™ ½'), 'spiel 1 2', + 'KAT-01 Sicherheit: title_search ist nach dem Ändern aktualisiert'); +select throws_ok($$insert into public.games (owner_id, title, visibility, title_search) values ('11111111-1111-1111-1111-111111111111', 'Qxz X', 'private', 'x')$$, + '428C9', null, 'KAT-01: authenticated kann title_search nicht setzen (generierte Spalte)'); +select ok(not has_column_privilege('authenticated', 'public.games', 'title_search', 'insert') + and not has_column_privilege('authenticated', 'public.games', 'title_search', 'update') + and not has_column_privilege('anon', 'public.games', 'title_search', 'update') + and has_column_privilege('anon', 'public.games', 'title_search', 'select'), + 'KAT-01: title_search ist für API-Rollen nur lesbar (Spaltenrechte)'); +select lives_ok($$select * from public.search_catalog_games('qxz zorb')$$, 'KAT-01: die Suche als authenticated läuft'); +select results_eq($$select id from public.search_catalog_games('qxz zorb')$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000002'), ('f9000000-0000-0000-0000-000000000003'), + ('f9000000-0000-0000-0000-000000000004'), ('f9000000-0000-0000-0000-000000000005'), ('f9000000-0000-0000-0000-000000000006')$$, + 'KAT-01 Sicherheit: Annas Suche zeigt nur Katalogspiele (Bens privates "QXZ ZORB" fehlt)'); +select is((select count(*) from public.search_catalog_games('qxz privat')), 0::bigint, 'KAT-01 Sicherheit: Anna findet ihr eigenes privates Spiel nicht über die Katalogsuche'); +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000032'), 1::bigint, 'KAT-01 Kriterium 2: Anna liest die alternativen Titel ihres privaten Spiels'); +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000033'), 0::bigint, 'KAT-01 Kriterium 2: Anna liest nicht die alternativen Titel von Bens privatem Spiel'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000032', 'de', 'Qxz Anna Neu')$$, + '42501', null, 'KAT-01 Kriterium 2: auch am eigenen privaten Spiel kann Anna keine alternativen Titel anlegen'); +select throws_ok($$update public.game_alt_titles set title = 'Qxz Anna' where id = 'f9000000-0000-0000-0000-0000000000a8'$$, + '42501', null, 'KAT-01 Kriterium 2: ... nicht ändern'); +select throws_ok($$delete from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000a8'$$, + '42501', null, 'KAT-01 Kriterium 2: ... nicht löschen'); +reset role; + +-- Ben: die eigene Suche zeigt Annas privates Spiel nicht und sieht keine fremden alternativen Titel +select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true); +set local role authenticated; +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000032'), 0::bigint, 'KAT-01 Kriterium 2: Ben liest keine alternativen Titel von Annas privatem Spiel'); +select is((select count(*) from public.search_catalog_games('qxz privat')), 0::bigint, 'KAT-01 Sicherheit: Ben findet Annas privates Spiel nicht'); +select is((select count(*) from public.search_catalog_games('qxz zorb')), 6::bigint, 'KAT-01 Sicherheit: Ben findet sein eigenes privates "QXZ ZORB" nicht, nur die sechs Katalogspiele'); +reset role; + +-- --------------------------------------------------------------------------------------------- +-- Startbestand-Werkzeug: alternative Titel, Idempotenz (Spec 4.3, Krit. 2) +-- Testspiel B hat laut testdata/startbestand.csv zwei alternative Titel, Testspiel A keinen. +-- --------------------------------------------------------------------------------------------- +\ir ../support/vocabulary_fixture.inc +\ir ../support/startbestand_fixture.inc + +select results_eq($$select a.locale, a.title from public.game_alt_titles a join public.games g on g.id = a.game_id + where g.title = 'Testspiel B' order by a.locale$$, + $$values ('de'::text, 'Testspiel Bravo'::text), ('en', 'Test Game Bravo')$$, + 'KAT-01 Kriterium 2: Testspiel B bekommt aus der CSV einen deutschen und einen englischen alternativen Titel'); +select is((select count(*) from public.game_alt_titles a join public.games g on g.id = a.game_id where g.title = 'Testspiel A'), 0::bigint, + 'KAT-01 Kriterium 2: Testspiel A hat keine alternativen Titel'); +select ok((select 'alt_titles' = any(manual_fields) from public.games where title = 'Testspiel B'), + 'KAT-01 Kriterium 2: das Einspielen markiert alt_titles in manual_fields (SB8)'); +select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('test game bravo') + where id = extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'game:testspiel-b')$$, + $$values (extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'game:testspiel-b'), 0, 'Test Game Bravo'::text)$$, + 'KAT-01 Kriterium 2: ein eingespielter alternativer Titel ist über die Suche auffindbar'); + +create temp table snap_alt on commit drop as + select a.id, a.game_id, a.locale, a.title, a.ctid::text as c, a.updated_at as u from public.game_alt_titles a + where a.game_id in (select id from public.games where title like 'Testspiel %'); +\ir ../support/startbestand_fixture.inc +select is((select count(*) from snap_alt), 2::bigint, 'Schnappschuss der alternativen Titel enthält die zwei Zeilen von Testspiel B'); +select is_empty($$select 1 from snap_alt s where not exists ( + select 1 from public.game_alt_titles n where n.id = s.id and n.game_id = s.game_id and n.locale = s.locale + and n.title = s.title and n.ctid::text = s.c and n.updated_at = s.u)$$, + 'KAT-01 Kriterium 2: erneutes Einspielen ändert keine Zeile von game_alt_titles (ctid und updated_at gleich)'); +select is((select count(*) from public.game_alt_titles a join public.games g on g.id = a.game_id where g.title like 'Testspiel %'), 2::bigint, + 'KAT-01 Kriterium 2: erneutes Einspielen legt keine Duplikate an'); + +-- Ein in der CSV nicht (mehr) stehender Titel wird gelöscht, auch bei Testspiel A ohne alt_titles; eine geänderte +-- Schreibweise stellt nur den Titel wieder her (gleiche ID), unveränderte Zeilen bleiben unberührt. +insert into public.game_alt_titles (game_id, locale, title) + select id, 'en', 'Entfernter Titel' from public.games where title = 'Testspiel B'; +insert into public.game_alt_titles (game_id, locale, title) + select id, 'de', 'Fremder Titel' from public.games where title = 'Testspiel A'; +update public.game_alt_titles set title = 'TESTSPIEL BRAVO' where locale = 'de' and game_id = (select id from public.games where title = 'Testspiel B'); +create temp table snap_alt2 on commit drop as + select a.id, a.locale, a.title, a.ctid::text as c from public.game_alt_titles a where a.game_id in (select id from public.games where title like 'Testspiel %'); +\ir ../support/startbestand_fixture.inc +select results_eq($$select a.locale, a.title from public.game_alt_titles a where a.game_id in (select id from public.games where title like 'Testspiel %') order by a.locale$$, + $$values ('de'::text, 'Testspiel Bravo'::text), ('en', 'Test Game Bravo')$$, + 'KAT-01 Kriterium 2: ein in der CSV entfernter alternativer Titel wird gelöscht (auch bei einem Spiel ohne alt_titles); geänderte Schreibweise wird wiederhergestellt'); +select is((select count(*) from public.game_alt_titles a join snap_alt2 s on s.id = a.id and s.title = 'TESTSPIEL BRAVO'), 1::bigint, + 'KAT-01 Kriterium 2: die geänderte Schreibweise behält ihre ID (nur title aktualisiert)'); +select is((select count(*) from public.game_alt_titles a join snap_alt2 s on s.id = a.id and s.locale = 'en' and s.title = 'Test Game Bravo' and s.c = a.ctid::text), 1::bigint, + 'KAT-01 Kriterium 2: die unveränderte Zeile bleibt unberührt (gleiche ctid)'); +select is((select count(*) from public.game_alt_titles a join snap_alt2 s on s.id = a.id and s.locale = 'de' and s.c <> a.ctid::text), 1::bigint, + 'KAT-01 Kriterium 2: nur die geänderte Zeile wurde geschrieben'); + +-- --------------------------------------------------------------------------------------------- +-- SQL-Bausteine aus docs/katalog-pflege.md (unverändert, ohne begin/commit) +-- Abschnitt 3.1: Alternative Titel pflegen (Spiel 7a1b...0001 "Beispielspiel Runbook") +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale, visibility) +values ('7a1b2c3d-0000-4000-8000-000000000001', 'Beispielspiel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog'); + +insert into public.game_alt_titles (game_id, locale, title) +values ('7a1b2c3d-0000-4000-8000-000000000001', 'de', 'Beispielspiel Deutsch'), + ('7a1b2c3d-0000-4000-8000-000000000001', 'en', 'Example game') +on conflict (game_id, locale, title_search) do update set title = excluded.title +where public.game_alt_titles.title is distinct from excluded.title; + +select results_eq($$select locale, title from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000001' order by locale, title$$, + $$values ('de'::text, 'Beispielspiel Deutsch'::text), ('en', 'Example game')$$, + 'Runbook 3.1: Alternative Titel anlegen'); +insert into public.game_alt_titles (game_id, locale, title) +values ('7a1b2c3d-0000-4000-8000-000000000001', 'de', 'BEISPIELSPIEL DEUTSCH'), + ('7a1b2c3d-0000-4000-8000-000000000001', 'en', 'Example game') +on conflict (game_id, locale, title_search) do update set title = excluded.title +where public.game_alt_titles.title is distinct from excluded.title; +select results_eq($$select locale, title from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000001' order by locale, title$$, + $$values ('de'::text, 'BEISPIELSPIEL DEUTSCH'::text), ('en', 'Example game')$$, + 'Runbook 3.1: derselbe Baustein mit geänderter Schreibweise aktualisiert den Titel statt einen zweiten anzulegen'); +select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('example game')$$, + $$values ('7a1b2c3d-0000-4000-8000-000000000001'::uuid, 0, 'Example game'::text)$$, + 'Runbook 3.1: der angelegte Titel ist sofort über die Suche auffindbar'); + +delete from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000001' and locale = 'en' and title = 'Example game'; +select results_eq($$select locale from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000001'$$, + $$values ('de'::text)$$, 'Runbook 3.1: Alternativen Titel löschen'); + +-- Abschnitt 5, Schritt 2b: alternative Titel der Quelle am Ziel ergänzen, danach zusammenführen +insert into public.games (id, title, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale, visibility) values + ('7a1b2c3d-0000-4000-8000-000000000002', 'Duplikat Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog'), + ('7a1b2c3d-0000-4000-8000-000000000003', 'Ziel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog'); +insert into public.game_alt_titles (game_id, locale, title) values + ('7a1b2c3d-0000-4000-8000-000000000002', 'de', 'Runbook Quellname'), + ('7a1b2c3d-0000-4000-8000-000000000002', 'en', 'Runbook source name'), + ('7a1b2c3d-0000-4000-8000-000000000003', 'en', 'RUNBOOK SOURCE NAME'); + +select locale, title from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000002' order by locale, title; + +insert into public.game_alt_titles (game_id, locale, title) +select '7a1b2c3d-0000-4000-8000-000000000003', a.locale, a.title +from public.game_alt_titles a +where a.game_id = '7a1b2c3d-0000-4000-8000-000000000002' +on conflict (game_id, locale, title_search) do nothing; + +select results_eq($$select locale, title from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000003' order by locale, title$$, + $$values ('de'::text, 'Runbook Quellname'::text), ('en', 'RUNBOOK SOURCE NAME')$$, + 'Runbook 5 Schritt 2b: Titel der Quelle werden am Ziel ergänzt, vorhandene (auch in anderer Schreibweise) bleiben unverändert'); +select results_eq($$select id from public.search_catalog_games('runbook quellname')$$, + $$values ('7a1b2c3d-0000-4000-8000-000000000002'::uuid), ('7a1b2c3d-0000-4000-8000-000000000003')$$, + 'Runbook 5 Schritt 2b: das Ziel ist jetzt über den Titel der Quelle auffindbar (vor dem Zusammenführen zusammen mit der Quelle)'); + +select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); + +select is((select count(*) from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000002'), 2::bigint, + 'Spec-F6 A: nach dem Zusammenführen bleiben die alternativen Titel am ausgeblendeten Quell-Eintrag'); +select is((select count(*) from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 2::bigint, + 'Spec-F6 A: das Ziel behält seine alternativen Titel'); +select results_eq($$select id from public.search_catalog_games('runbook quellname')$$, + $$values ('7a1b2c3d-0000-4000-8000-000000000003'::uuid)$$, 'Spec-F6 A: nach dem Zusammenführen findet die Suche nur noch das Ziel, nie die Quelle'); +select is((select count(*) from public.search_catalog_games('duplikat runbook')), 0::bigint, 'KAT-05 Kriterium 4: die zusammengeführte Quelle erscheint nicht mehr'); + +-- --------------------------------------------------------------------------------------------- +-- Kaskade beim Konto-Löschen (QUE-04): alternative Titel privater Spiele verschwinden mit dem Konto +-- --------------------------------------------------------------------------------------------- +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000033'), 1::bigint, 'Ausgangslage: Bens privates Spiel hat einen alternativen Titel'); +delete from auth.users where id = '22222222-2222-2222-2222-222222222222'; +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000033'), 0::bigint, + 'KAT-01: Konto löschen löscht auch die alternativen Titel seiner privaten Spiele'); + +-- --------------------------------------------------------------------------------------------- +-- Abschnitt 8 des Runbooks: Normalisierung nach einem Image-Upgrade prüfen (R6) +-- --------------------------------------------------------------------------------------------- +select results_eq($$select 'games' as tabelle, count(*) as abweichend from public.games + where title_search is distinct from public.search_normalize(title) +union all select 'game_alt_titles', count(*) from public.game_alt_titles + where title_search is distinct from public.search_normalize(title) +union all select 'publishers', count(*) from public.publishers + where name_search is distinct from public.search_normalize(name)$$, + $$values ('games'::text, 0::bigint), ('game_alt_titles', 0), ('publishers', 0)$$, + 'Runbook 8: die Prüfabfrage meldet bei unverändertem Stand überall 0 Abweichungen'); + +-- Drift simulieren: Die Funktion wird geändert, die gespeicherten Spalten bleiben (wie nach einem Image-Upgrade) +create or replace function public.search_normalize(p text) returns text language sql immutable strict parallel safe set search_path = '' + as $$ select upper(p) $$; +select results_eq($$select 'games' as tabelle, count(*) > 0 as abweichend from public.games + where title_search is distinct from public.search_normalize(title) +union all select 'game_alt_titles', count(*) > 0 from public.game_alt_titles + where title_search is distinct from public.search_normalize(title) +union all select 'publishers', count(*) > 0 from public.publishers + where name_search is distinct from public.search_normalize(name)$$, + $$values ('games'::text, true), ('game_alt_titles', true), ('publishers', true)$$, + 'Runbook 8: die Prüfabfrage erkennt eine geänderte Normalisierung in allen drei Tabellen'); + +select * from finish(); +rollback; From 976fbe9fe1b8b208a378bb5da49c6d984d17ea88 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:12:25 +0100 Subject: [PATCH 24/30] feat(db): startbestand tool reads alt_titles column (KAT-01, 3a) Parses de:Title;en:Title (';' forbidden in titles, errors with line number), generates an idempotent reconcile for game_alt_titles, adds an info row to checks.sql and attaches alternative titles to Testspiel B. Regenerated files. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- supabase/catalog/build_startbestand.ts | 96 +++++++++++++++++++ supabase/catalog/build_startbestand_test.ts | 88 ++++++++++++++++- supabase/catalog/checks.sql | 3 + supabase/catalog/testdata/startbestand.csv | 2 +- supabase/tests/support/checks_fixture.inc | 3 + .../tests/support/startbestand_fixture.inc | 15 +++ 6 files changed, 203 insertions(+), 4 deletions(-) diff --git a/supabase/catalog/build_startbestand.ts b/supabase/catalog/build_startbestand.ts index d6723da..a1afa85 100644 --- a/supabase/catalog/build_startbestand.ts +++ b/supabase/catalog/build_startbestand.ts @@ -257,6 +257,12 @@ export interface Game { mechanics: string[]; // name_de description: string; hidden: boolean; + alt_titles: AltTitle[]; +} + +export interface AltTitle { + locale: "de" | "en"; + title: string; } const REQUIRED_COLUMNS = [ @@ -270,6 +276,68 @@ function splitList(value: string): string[] { return value.split(";").map((s) => s.trim()).filter((s) => s !== ""); } +// Annäherung an public.search_normalize (Marken- und Schutzzeichen entfernen, Akzente/Umlaute entfernen, +// Kleinschreibung, alles außer a-z0-9 zu einem Leerzeichen). Maßgeblich bleibt die Datenbank +// (Unique-Constraint game_alt_titles_game_locale_title_key); hier nur für frühe Fehlermeldungen. +const FOLD: Record = { "ß": "ss", "æ": "ae", "œ": "oe", "ø": "o", "đ": "d", "ł": "l", "ð": "d", "þ": "th" }; +export function approxNormalize(s: string): string { + return s + .replace(/[®©™℗℠]/g, "") + .toLowerCase() + .replace(/[ßæœøđłðþ]/g, (c) => FOLD[c]) + .normalize("NFD") + .replace(/\p{M}+/gu, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +// Spalte alt_titles: "de:Titel;en:Title" (Spec #3, 4.3). Sprache und Titel am ersten ":" getrennt. +// ";" ist in Titeln nicht erlaubt (kein Escaping); ein Titel mit ";" fällt als Eintrag ohne passende Sprache auf. +export function parseAltTitles(raw: string, mainTitle: string, fail: (msg: string) => void): AltTitle[] { + const out: AltTitle[] = []; + const seen = new Set(); + const mainNorm = approxNormalize(mainTitle); + const semicolonHint = '(";" ist in alternativen Titeln nicht erlaubt, Titel dieser Art im Studio pflegen)'; + for (const entry of splitList(raw)) { + const colon = entry.indexOf(":"); + if (colon < 0) { + fail(`alt_titles: Eintrag "${entry.slice(0, 40)}" hat kein ":" (Format de:Titel;en:Title) ${semicolonHint}`); + continue; + } + const locale = entry.slice(0, colon).trim(); + const title = entry.slice(colon + 1).trim(); + if (locale !== "de" && locale !== "en") { + fail(`alt_titles: Sprache "${locale.slice(0, 20)}" in "${entry.slice(0, 40)}" muss "de" oder "en" sein ${semicolonHint}`); + continue; + } + if (title === "") { + fail(`alt_titles: leerer Titel bei Sprache "${locale}"`); + continue; + } + if (title.length > 200) { + fail(`alt_titles: Titel "${title.slice(0, 30)}…" länger als 200 Zeichen`); + continue; + } + const norm = approxNormalize(title); + if (norm === "") { + fail(`alt_titles: Titel "${title}" besteht nur aus Sonderzeichen`); + continue; + } + if (norm === mainNorm) { + fail(`alt_titles: "${title}" ist gleich dem Haupttitel (ohne Beachtung von Schreibweise und Sonderzeichen)`); + continue; + } + const k = `${locale}:${norm}`; + if (seen.has(k)) { + fail(`alt_titles: Titel "${title}" doppelt (Sprache ${locale})`); + continue; + } + seen.add(k); + out.push({ locale, title }); + } + return out; +} + export async function parseStartbestand( text: string, vocabulary: VocabEntry[], @@ -363,10 +431,13 @@ export async function parseStartbestand( else if (!allKeys.has(base)) fail(`base_game_key "${base}" hat keinen passenden key (SB7)`); } + const alt_titles = parseAltTitles(cells.alt_titles ?? "", title, fail); + if (problems.length === before) { games.push({ key, id: await uuidV5(`game:${key}`), title, publishers, year, min_players, max_players, min_minutes, max_minutes, min_age, genres, mechanics, description, hidden: hiddenRaw === "ja", + alt_titles, }); } } @@ -515,6 +586,28 @@ export function generateStartbestandSql(games: Game[]): string { reconcile("Genre", "game_genres", "genre_id", "genres", "t.name_de = w.term", (g) => g.genres); reconcile("Mechanik", "game_mechanics", "mechanic_id", "mechanics", "t.name_de = w.term", (g) => g.mechanics); reconcile("Verlag", "game_publishers", "publisher_id", "publishers", "lower(t.name) = lower(w.term)", (g) => g.publishers); + + // 5. Alternative Titel (Spec #3, 4.3): Abgleich je Spiel über die normalisierte Form. Unveränderte + // Zeilen bleiben unberührt (updated_at und manual_fields stabil); Spiele ohne alt_titles verlieren + // früher gesetzte Titel. Keine abgeleitete ID, Konfliktziel ist die Unique-Constraint. + const altRows = games.flatMap((g) => g.alt_titles.map((a) => ` (${q(g.id)}::uuid, ${q(a.locale)}, ${q(a.title)})`)); + out.push("-- Alternative Titel: fehlende anlegen, geänderte Schreibweise aktualisieren, nicht mehr gewünschte löschen"); + if (altRows.length === 0) { + out.push(`delete from public.game_alt_titles a where a.game_id = any(array[${ids}]::uuid[]);`, ""); + } else { + out.push(`with want(game_id, locale, title) as (values`); + out.push(altRows.join(",\n")); + out.push(`), removed as (`); + out.push(` delete from public.game_alt_titles a`); + out.push(` where a.game_id = any(array[${ids}]::uuid[])`); + out.push(` and not exists (select 1 from want w where w.game_id = a.game_id and w.locale = a.locale`); + out.push(` and public.search_normalize(w.title) = a.title_search)`); + out.push(`)`); + out.push(`insert into public.game_alt_titles (game_id, locale, title)`); + out.push(`select game_id, locale, title from want`); + out.push(`on conflict (game_id, locale, title_search) do update set title = excluded.title`); + out.push(`where public.game_alt_titles.title is distinct from excluded.title;`, ""); + } return out.join("\n"); } @@ -559,6 +652,9 @@ export function generateChecksQuery(startbestandIds: string[], vocabulary: Vocab ` select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean`, ` from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id)`, ` + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x`, + ` union all`, + ` select 7, 'Info-Alt', 'Startbestandsspiele mit alternativen Titeln', '-', n, null::boolean`, + ` from (select count(distinct a.game_id) as n from public.game_alt_titles a join sb on sb.id = a.game_id) x`, `) r`, `order by ord`, ].join("\n"); diff --git a/supabase/catalog/build_startbestand_test.ts b/supabase/catalog/build_startbestand_test.ts index 6ec0e07..60ccebd 100644 --- a/supabase/catalog/build_startbestand_test.ts +++ b/supabase/catalog/build_startbestand_test.ts @@ -7,6 +7,8 @@ import { BuildError, generateStartbestandSql, NAMESPACE, + approxNormalize, + parseAltTitles, parseCsv, parseKeys, parseStartbestand, @@ -250,9 +252,89 @@ Deno.test("hidden = ja erzeugt hidden_at, leer fasst hidden_at nicht an", async assert.doesNotMatch(plain, /update public\.games set/); }); -Deno.test("facts_source, alt_titles und base_game_key landen nicht in der SQL", async () => { - const sql = await sqlFor(row({ facts_source: "GEHEIMER-NACHWEIS", alt_titles: "de:Alt", base_game_key: "" })); - assert.doesNotMatch(sql, /GEHEIMER-NACHWEIS|alt_titles|base_game_key|facts_source/); +Deno.test("facts_source und base_game_key landen nicht in der SQL", async () => { + const sql = await sqlFor(row({ facts_source: "GEHEIMER-NACHWEIS", base_game_key: "" })); + assert.doesNotMatch(sql, /GEHEIMER-NACHWEIS|base_game_key|facts_source/); +}); + +// ---- alt_titles (KAT-01, Spec #3 4.3) -------------------------------------------------------- +function altProblems(raw: string, main = "Testspiel A"): string[] { + const problems: string[] = []; + parseAltTitles(raw, main, (m) => problems.push(m)); + return problems; +} + +Deno.test("alt_titles: Format de:Titel;en:Title, Leerraum am Rand wird entfernt, ':' im Titel ist erlaubt", () => { + const problems: string[] = []; + const parsed = parseAltTitles(" de: Catan: Das Duell ; en:Test Duel ", "Testspiel A", (m) => problems.push(m)); + assert.deepEqual(problems, []); + assert.deepEqual(parsed, [{ locale: "de", title: "Catan: Das Duell" }, { locale: "en", title: "Test Duel" }]); + assert.deepEqual(parseAltTitles("", "Testspiel A", () => assert.fail("kein Fehler erwartet")), []); +}); + +Deno.test("alt_titles: Sprache, fehlender Doppelpunkt, leerer und zu langer Titel werden abgelehnt", () => { + assert.match(altProblems("fr:Titre").join("\n"), /Sprache "fr".*"de" oder "en"/); + assert.match(altProblems("Nur ein Titel").join("\n"), /kein ":"/); + assert.match(altProblems("de: ").join("\n"), /leerer Titel/); + assert.match(altProblems("de:" + "x".repeat(201)).join("\n"), /länger als 200 Zeichen/); + assert.equal(altProblems("de:" + "x".repeat(200)).length, 0); + assert.match(altProblems("de:!!!").join("\n"), /nur aus Sonderzeichen/); +}); + +Deno.test("alt_titles: Duplikat in derselben Sprache und Titel gleich dem Haupttitel werden abgelehnt", () => { + assert.match(altProblems("de:Café;de:CAFE").join("\n"), /doppelt \(Sprache de\)/); + assert.equal(altProblems("de:Café;en:Café").length, 0, "gleicher Titel in anderer Sprache ist zulässig"); + assert.match(altProblems("de:testspiel-a").join("\n"), /gleich dem Haupttitel/); + assert.match(altProblems("en:CATAN®", "Catan").join("\n"), /gleich dem Haupttitel/); +}); + +Deno.test("alt_titles: ';' im Titel ergibt einen Fehler mit Zeilennummer und Hinweis", async () => { + await rejects( + csv(row(), row({ key: "testspiel-b", title: "Testspiel B", alt_titles: "de:Catan;Das Duell" })), + /startbestand\.csv:3: alt_titles: Eintrag "Das Duell" hat kein ":".*";" ist in alternativen Titeln nicht erlaubt/, + ); + await rejects( + csv(row({ alt_titles: "de:Eins;Zwei: Drei" })), + /startbestand\.csv:2: alt_titles: Sprache "Zwei" .*";" ist in alternativen Titeln nicht erlaubt/, + ); +}); + +Deno.test("alt_titles: Fehler anderer Art nennen die Zeilennummer", async () => { + await rejects(csv(row(), row({ key: "testspiel-b", alt_titles: "fr:X" })), /startbestand\.csv:3: alt_titles: Sprache "fr"/); +}); + +Deno.test("approxNormalize: Marken- und Schutzzeichen, Akzente, ß, Æ, Sonderzeichen", () => { + assert.equal(approxNormalize("CATAN®"), "catan"); + assert.equal(approxNormalize("Café Größe"), "cafe grosse"); + assert.equal(approxNormalize("Æon’s End"), "aeon s end"); + assert.equal(approxNormalize("7 Wonders: Duel"), "7 wonders duel"); + assert.equal(approxNormalize("Ticket to Ride – Europe"), "ticket to ride europe"); + assert.equal(approxNormalize("!!!"), ""); +}); + +Deno.test("SQL alt_titles: Abgleich über normalisierten Titel, Update nur bei geänderter Schreibweise", async () => { + const sql = await sqlFor(row({ alt_titles: "de:Alter Titel;en:Old 'Title'" })); + assert.match(sql, /with want\(game_id, locale, title\) as \(values/); + assert.match(sql, /'de', 'Alter Titel'\)/); + assert.match(sql, /'en', 'Old ''Title'''\)/); // Apostroph verdoppelt + assert.match(sql, /delete from public\.game_alt_titles a/); + assert.match(sql, /public\.search_normalize\(w\.title\) = a\.title_search/); + assert.match(sql, /on conflict \(game_id, locale, title_search\) do update set title = excluded\.title/); + assert.match(sql, /where public\.game_alt_titles\.title is distinct from excluded\.title;/); + assert.doesNotMatch(sql, /game_alt_titles \(id/); // keine abgeleitete ID +}); + +Deno.test("SQL alt_titles: ohne alternative Titel nur das Löschen, nie ein leeres values", async () => { + const sql = await sqlFor(row()); + assert.match(sql, /delete from public\.game_alt_titles a where a\.game_id = any\(array\['/); + assert.doesNotMatch(sql, /with want\(game_id, locale, title\)/); + assert.doesNotMatch(sql, /insert into public\.game_alt_titles/); +}); + +Deno.test("checks.sql: Informationszeile zu Startbestandsspielen mit alternativen Titeln", async () => { + const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row()), previousKeysCsv: "key,uuid\n" }); + assert.match(out["checks.sql"], /'Info-Alt', 'Startbestandsspiele mit alternativen Titeln'/); + assert.match(out["checks.sql"], /count\(distinct a\.game_id\) as n from public\.game_alt_titles a join sb on sb\.id = a\.game_id/); }); Deno.test("SQL: Apostroph im Text wird verdoppelt, Upsert nutzt is distinct from, Zuordnungen werden abgeglichen", async () => { diff --git a/supabase/catalog/checks.sql b/supabase/catalog/checks.sql index 087ffb4..50537aa 100644 --- a/supabase/catalog/checks.sql +++ b/supabase/catalog/checks.sql @@ -82,5 +82,8 @@ from ( select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id) + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x + union all + select 7, 'Info-Alt', 'Startbestandsspiele mit alternativen Titeln', '-', n, null::boolean + from (select count(distinct a.game_id) as n from public.game_alt_titles a join sb on sb.id = a.game_id) x ) r order by ord; diff --git a/supabase/catalog/testdata/startbestand.csv b/supabase/catalog/testdata/startbestand.csv index e120cb6..b09c81d 100644 --- a/supabase/catalog/testdata/startbestand.csv +++ b/supabase/catalog/testdata/startbestand.csv @@ -1,4 +1,4 @@ key,title,publishers,year,min_players,max_players,min_minutes,max_minutes,min_age,genres,mechanics,description,description_locale,hidden,facts_source,alt_titles,base_game_key testspiel-a,Testspiel A,Testverlag,2020,2,4,30,60,8,Testgenre A;Testgenre B,Testmechanik A,Fiktives Testspiel A. Nur für automatische Tests.,de,,Testdaten,, -testspiel-b,Testspiel B,Testverlag;Zweiter Testverlag,2021,1,5,10,20,6,Testgenre A,Testmechanik A;Testmechanik B,"Fiktives Testspiel B, mit Komma.",de,,Testdaten,,testspiel-a +testspiel-b,Testspiel B,Testverlag;Zweiter Testverlag,2021,1,5,10,20,6,Testgenre A,Testmechanik A;Testmechanik B,"Fiktives Testspiel B, mit Komma.",de,,Testdaten,de:Testspiel Bravo;en:Test Game Bravo,testspiel-a testspiel-c,Testspiel C: Wo's lang geht,TESTVERLAG,1999,3,3,45,45,12,Testgenre B,Testmechanik B,Fiktives Testspiel C mit Apostroph im Titel.,de,ja,Testdaten,, diff --git a/supabase/tests/support/checks_fixture.inc b/supabase/tests/support/checks_fixture.inc index caaedd7..a1eb32d 100644 --- a/supabase/tests/support/checks_fixture.inc +++ b/supabase/tests/support/checks_fixture.inc @@ -45,5 +45,8 @@ from ( select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id) + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x + union all + select 7, 'Info-Alt', 'Startbestandsspiele mit alternativen Titeln', '-', n, null::boolean + from (select count(distinct a.game_id) as n from public.game_alt_titles a join sb on sb.id = a.game_id) x ) r order by ord; diff --git a/supabase/tests/support/startbestand_fixture.inc b/supabase/tests/support/startbestand_fixture.inc index 09a6a2f..0040a6f 100644 --- a/supabase/tests/support/startbestand_fixture.inc +++ b/supabase/tests/support/startbestand_fixture.inc @@ -98,3 +98,18 @@ with want(game_id, term) as (values insert into public.game_publishers (game_id, publisher_id) select game_id, publisher_id from resolved on conflict (game_id, publisher_id) do nothing; + +-- Alternative Titel: fehlende anlegen, geänderte Schreibweise aktualisieren, nicht mehr gewünschte löschen +with want(game_id, locale, title) as (values + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'de', 'Testspiel Bravo'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'en', 'Test Game Bravo') +), removed as ( + delete from public.game_alt_titles a + where a.game_id = any(array['5189ad0e-ab5b-5500-a3e0-a0b3671cdf14', '6577717f-4c1f-5829-af22-ae6bb37c8394', 'c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3']::uuid[]) + and not exists (select 1 from want w where w.game_id = a.game_id and w.locale = a.locale + and public.search_normalize(w.title) = a.title_search) +) +insert into public.game_alt_titles (game_id, locale, title) +select game_id, locale, title from want +on conflict (game_id, locale, title_search) do update set title = excluded.title +where public.game_alt_titles.title is distinct from excluded.title; From 7eaed46dc2077c46dadf4ce7fd8ec5c7203b52c3 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:25:22 +0100 Subject: [PATCH 25/30] test(auth): address review findings on sign-in prompt (PR A) Correct the focus comment (refs #61), test Space key and focus state, prove scrollability at 200 percent text, drop needless minWidth, English doc comment, allow OutlinedButton as secondary action in standalone cards, English prompt text says plays instead of games. Refs #49 Co-Authored-By: Claude Sonnet 5.5 --- .../home/presentation/home_screen.dart | 7 ++--- app/lib/l10n/app_en.arb | 2 +- app/lib/l10n/app_localizations.dart | 2 +- app/lib/l10n/app_localizations_en.dart | 2 +- .../home/presentation/home_screen_test.dart | 30 ++++++++++++++++++- docs/design/auth-screens.md | 2 +- .../web/krit49-anmeldung-einstieg.spec.ts | 21 +++++++++---- 7 files changed, 52 insertions(+), 14 deletions(-) diff --git a/app/lib/features/home/presentation/home_screen.dart b/app/lib/features/home/presentation/home_screen.dart index 9f317f4..6044ac3 100644 --- a/app/lib/features/home/presentation/home_screen.dart +++ b/app/lib/features/home/presentation/home_screen.dart @@ -5,10 +5,10 @@ import 'package:shuffle/core/theme/app_spacing.dart'; import 'package:shuffle/features/auth/presentation/providers/sign_in_prompt_provider.dart'; import 'package:shuffle/features/auth/presentation/widgets/sign_in_prompt.dart'; -/// Platzhalter-Startseite. Wird durch die Katalogsuche bzw. QUE-09 ersetzt. +/// Placeholder home screen. To be replaced by the catalog search / QUE-09. /// -/// Liest nur [showSignInPromptProvider]; die Entscheidung, ob der -/// Anmelde-Hinweis erscheint, liegt dort (Spec #49 § 5.1). +/// Reads only [showSignInPromptProvider]; the decision whether the sign-in +/// prompt appears lives there (Spec #49 § 5.1). class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @@ -26,7 +26,6 @@ class HomeScreen extends ConsumerWidget { child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight - AppSpacing.lg * 2, - minWidth: constraints.maxWidth, ), child: Center( child: Column( diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index ba96571..d466f62 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -91,7 +91,7 @@ "authLoginForgotPassword": "Forgot password?", "authLoginCreateAccount": "Create account", "signInPromptTitle": "Your account", - "signInPromptBody": "Sign in to manage your collection and games.", + "signInPromptBody": "Sign in to manage your collection and plays.", "signInPromptSignIn": "Sign in", "signInPromptRegister": "Create account", "authCheckEmailTitle": "Check your inbox", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 078f9d7..1f4ab53 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -413,7 +413,7 @@ abstract class AppLocalizations { /// No description provided for @signInPromptBody. /// /// In en, this message translates to: - /// **'Sign in to manage your collection and games.'** + /// **'Sign in to manage your collection and plays.'** String get signInPromptBody; /// No description provided for @signInPromptSignIn. diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index cc7e7c5..bba36f6 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -192,7 +192,7 @@ class AppLocalizationsEn extends AppLocalizations { String get signInPromptTitle => 'Your account'; @override - String get signInPromptBody => 'Sign in to manage your collection and games.'; + String get signInPromptBody => 'Sign in to manage your collection and plays.'; @override String get signInPromptSignIn => 'Sign in'; diff --git a/app/test/features/home/presentation/home_screen_test.dart b/app/test/features/home/presentation/home_screen_test.dart index e8c891d..146ddb0 100644 --- a/app/test/features/home/presentation/home_screen_test.dart +++ b/app/test/features/home/presentation/home_screen_test.dart @@ -188,7 +188,15 @@ void main() { locale: locale, ); expect(tester.takeException(), isNull); - // The page scrolls: the buttons stay reachable and tappable. + // The page scrolls: dragging moves the content, the buttons stay + // reachable and tappable. + final before = tester.getTopLeft(registerButton).dy; + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -200), + ); + await tester.pump(); + expect(tester.getTopLeft(registerButton).dy, lessThan(before)); await tester.ensureVisible(registerButton); await tester.pump(); expect( @@ -233,11 +241,31 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); expect(focusIn(registerButton), isTrue); + // Visible focus: keyboard navigation switches to traditional highlight + // mode and the button holds the primary focus (the focus ring comes + // from the focused state of the theme). + expect( + FocusManager.instance.highlightMode, + FocusHighlightMode.traditional, + ); + expect(FocusManager.instance.primaryFocus?.hasPrimaryFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); expect(find.byKey(const Key('register_screen')), findsOneWidget); }, ); + + testWidgets('QUE-06 Krit. 4: Leertaste löst „Konto erstellen“ aus', ( + tester, + ) async { + await open(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('register_screen')), findsOneWidget); + }); }); } diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md index d04f41c..cba224a 100644 --- a/docs/design/auth-screens.md +++ b/docs/design/auth-screens.md @@ -15,7 +15,7 @@ Schlüssel sind `lowerCamelCase` mit Präfix `auth`. - Titel: `headlineSmall`, darunter optional ein Einleitungssatz in `bodyMedium`. Titel als Semantics-Überschrift (`Semantics(header: true)`). - Banner (`AppBanner`) stehen **über** den Feldern, direkt unter dem Titel. Fehler am Feld stehen unter dem Feld. -- Genau ein Primärknopf (`AppPrimaryButton`, volle Breite). Sekundäraktionen sind `TextButton`s (48 dp). +- Genau ein Primärknopf (`AppPrimaryButton`, volle Breite). Sekundäraktionen sind `TextButton`s (48 dp). Für eigenständige Karten (z. B. `SignInPrompt`) ist ein `OutlinedButton` als Sekundäraktion zulässig. - Eingaben bleiben bei jedem Fehler erhalten. Während `submitting` sind Knopf und Felder gesperrt, der Knopf zeigt den Spinner und den gesprochenen Text `authSubmitting`. - Nach einem Fehler setzt der Screen den Fokus auf das erste fehlerhafte Feld; bei Banner-Fehlern bleibt der diff --git a/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts b/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts index b900655..37a353a 100644 --- a/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts +++ b/e2e/tests/web/krit49-anmeldung-einstieg.spec.ts @@ -7,7 +7,7 @@ const gotrue = new GoTrueClient(); /** Texte (app_en.arb). */ const prompt = { title: 'Your account', - body: 'Sign in to manage your collection and games.', + body: 'Sign in to manage your collection and plays.', }; test.describe('#49 Kriterium 1 / QUE-09 Kriterium 13 (web): Anmelde-Hinweis auf der Startseite', () => { @@ -48,10 +48,10 @@ test.describe('#49 Kriterium 1 / QUE-09 Kriterium 13 (web): Anmelde-Hinweis auf expect(pos.every((p) => p >= 0)).toBe(true); expect([...pos].sort((x, y) => x - y)).toEqual(pos); - // Flutter-Web behandelt Tab im eigenen Fokussystem; `document.activeElement` bleibt auf dem - // Semantics-Platzhalter. Deshalb wird die Reihenfolge ueber das Verhalten geprueft: erster Tab - // = „Sign in“, zweiter Tab = „Create account“, Enter loest den fokussierten Knopf aus. - // Der Fokusring selbst wird im Canvas gezeichnet (Widget-Test, Theme). + // Reihenfolge ueber das Verhalten: erster Tab = „Sign in“, zweiter Tab = „Create account“, + // Enter loest den fokussierten Knopf aus. DOM-Fokus (toBeFocused) liegt nur auf „Create account“; + // „Sign in“ bekommt nie DOM-Fokus (kein tabindex, AppPrimaryButton verwirft focusable/focused + // des FilledButton), siehe Issue #61. Der Fokusring selbst wird im Canvas gezeichnet. await page.keyboard.press('Tab'); await page.keyboard.press('Enter'); await expect(page).toHaveURL(/\/login$/); @@ -62,10 +62,21 @@ test.describe('#49 Kriterium 1 / QUE-09 Kriterium 13 (web): Anmelde-Hinweis auf await expect(page.getByRole('button', { name: 'Create account', exact: true })).toBeVisible(); await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Create account', exact: true })).toBeFocused(); await page.keyboard.press('Enter'); await expect(page).toHaveURL(/\/register$/); }); + test('QUE-06 Kriterium 4: Leertaste loest „Create account“ aus', async ({ page }) => { + await open(page, '/'); + await expect(page.getByRole('button', { name: 'Create account', exact: true })).toBeVisible(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Create account', exact: true })).toBeFocused(); + await page.keyboard.press('Space'); + await expect(page).toHaveURL(/\/register$/); + }); + test('#49 Kriterium 1: angemeldet erscheint der Hinweis nicht', async ({ page }) => { const email = randomEmail(); const password = randomPassword(); From 683b5c830ea65ae03e0e9f551eff520fe556949a Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:40:43 +0100 Subject: [PATCH 26/30] perf(db): set-based rank 4 and filters in search_catalog_games, harden tests (KAT-01, 3a) Rank 4 and the genre/mechanic filters ran a correlated subplan per game (13 s at 10 000 games). Now: one set-based word coverage, filters as one hashed IN set, 'like all' patterns, similarity only for hits. Adds pgTAP cases found by mutation testing (paging by relevance, alt title choice, duplicate words, mechanic duplicates) and a Deno case for mark characters. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- supabase/catalog/build_startbestand_test.ts | 1 + .../20261103235947_catalog_search.sql | 82 +++++++++++------- .../tests/database/09_catalog_search.test.sql | 85 +++++++++++++++---- 3 files changed, 123 insertions(+), 45 deletions(-) diff --git a/supabase/catalog/build_startbestand_test.ts b/supabase/catalog/build_startbestand_test.ts index 60ccebd..f096067 100644 --- a/supabase/catalog/build_startbestand_test.ts +++ b/supabase/catalog/build_startbestand_test.ts @@ -305,6 +305,7 @@ Deno.test("alt_titles: Fehler anderer Art nennen die Zeilennummer", async () => Deno.test("approxNormalize: Marken- und Schutzzeichen, Akzente, ß, Æ, Sonderzeichen", () => { assert.equal(approxNormalize("CATAN®"), "catan"); + assert.equal(approxNormalize("Spiel™Ding"), "spielding"); // Schutzzeichen werden entfernt, nicht zum Trenner (wie search_normalize) assert.equal(approxNormalize("Café Größe"), "cafe grosse"); assert.equal(approxNormalize("Æon’s End"), "aeon s end"); assert.equal(approxNormalize("7 Wonders: Duel"), "7 wonders duel"); diff --git a/supabase/migrations/20261103235947_catalog_search.sql b/supabase/migrations/20261103235947_catalog_search.sql index 0124840..70776a2 100644 --- a/supabase/migrations/20261103235947_catalog_search.sql +++ b/supabase/migrations/20261103235947_catalog_search.sql @@ -152,7 +152,8 @@ declare v_offset integer := coalesce(p_offset, 0); v_q text; v_qc text; - v_words text[] := '{}'; + v_words text[] := '{}'::text[]; + v_patterns text[] := '{}'::text[]; -- '%wort%' je Suchwort, für like all v_has_query boolean := false; v_genres uuid[]; v_mechanics uuid[]; @@ -189,6 +190,7 @@ begin v_qc := replace(v_q, ' ', ''); v_words := coalesce( array(select distinct w from unnest(string_to_array(v_q, ' ')) as w where char_length(w) >= 2), '{}'); + v_patterns := array(select '%' || w || '%' from unnest(v_words) as w); end if; end if; @@ -215,10 +217,10 @@ begin where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) and (p_age is null or c.min_age <= p_age) - and (v_genre_n = 0 or (select count(*) from public.game_genres gg - where gg.game_id = c.id and gg.genre_id = any (v_genres)) = v_genre_n) - and (v_mech_n = 0 or (select count(*) from public.game_mechanics gm - where gm.game_id = c.id and gm.mechanic_id = any (v_mechanics)) = v_mech_n) + and (v_genre_n = 0 or c.id in (select gg.game_id from public.game_genres gg where gg.genre_id = any (v_genres) + group by gg.game_id having count(*) = v_genre_n)) + and (v_mech_n = 0 or c.id in (select gm.game_id from public.game_mechanics gm where gm.mechanic_id = any (v_mechanics) + group by gm.game_id having count(*) = v_mech_n)) order by g.title_search collate "C", c.id limit v_limit offset v_offset ) s @@ -235,10 +237,10 @@ begin where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) and (p_age is null or c.min_age <= p_age) - and (v_genre_n = 0 or (select count(*) from public.game_genres gg - where gg.game_id = c.id and gg.genre_id = any (v_genres)) = v_genre_n) - and (v_mech_n = 0 or (select count(*) from public.game_mechanics gm - where gm.game_id = c.id and gm.mechanic_id = any (v_mechanics)) = v_mech_n) + and (v_genre_n = 0 or c.id in (select gg.game_id from public.game_genres gg where gg.genre_id = any (v_genres) + group by gg.game_id having count(*) = v_genre_n)) + and (v_mech_n = 0 or c.id in (select gm.game_id from public.game_mechanics gm where gm.mechanic_id = any (v_mechanics) + group by gm.game_id having count(*) = v_mech_n)) ), -- Kandidaten-Titel: Haupttitel (alt_title null) und alle alternativen Titel cand as ( @@ -247,50 +249,72 @@ begin select b.id, a.title_search, a.title from base b join public.game_alt_titles a on a.game_id = b.id ), - -- Relevanzstufe je Kandidaten-Titel (null = kein Titeltreffer) + -- Relevanzstufe je Kandidaten-Titel (null = kein Titeltreffer). Nur günstige Vergleiche; die teure + -- Trigramm-Ähnlichkeit wird erst für die Treffer berechnet (sims, alt_pick). ranked as ( select t.id, t.ts, t.alt_title, case - when t.ts = v_q or replace(t.ts, ' ', '') = v_qc then 0 + -- Stufe 0: gleich dem Suchbegriff (auch ohne Leerzeichen gleich; das schließt ts = v_q ein) + when replace(t.ts, ' ', '') = v_qc then 0 when t.ts like v_q || '%' then 1 when t.ts like '% ' || v_q || '%' then 2 when replace(t.ts, ' ', '') like '%' || v_qc || '%' - or (cardinality(v_words) > 0 - and not exists (select 1 from unnest(v_words) as w where t.ts not like '%' || w || '%')) + or (cardinality(v_patterns) > 0 and t.ts like all (v_patterns)) then 3 - end as rk, - extensions.similarity(t.ts, v_q) as sim + end as rk from cand t ), agg as ( select r.id, min(r.rk) as best_rk, min(r.rk) filter (where r.alt_title is null) as main_rk, - min(r.rk) filter (where r.alt_title is not null) as alt_rk, - (array_agg(r.alt_title order by r.rk, r.sim desc nulls last, r.ts, r.alt_title) - filter (where r.alt_title is not null and r.rk is not null))[1] as alt_title, - max(r.sim) as sim + min(r.rk) filter (where r.alt_title is not null) as alt_rk from ranked r group by r.id ), + -- der alternative Titel mit der besten Stufe (bei Gleichstand: größte Ähnlichkeit, dann Schreibweise) + alt_pick as ( + select distinct on (r.id) r.id, r.alt_title + from ranked r + where r.alt_title is not null and r.rk is not null + order by r.id, r.rk, extensions.similarity(r.ts, v_q) desc, r.ts, r.alt_title + ), + -- Rang 4: welches Suchwort kommt in welchem Spiel in einem Titel oder einem Verlagsnamen vor? + -- (als Mengenoperation statt korrelierter Unterabfrage je Spiel: sonst wächst die Zeit quadratisch) + word_hits as ( + select r.id, w.w from ranked r cross join unnest(v_words) as w(w) where r.ts like '%' || w.w || '%' + union + select gp.game_id, w.w + from public.publishers p + join public.game_publishers gp on gp.publisher_id = p.id + cross join unnest(v_words) as w(w) + where p.name_search like '%' || w.w || '%' + ), + word_cover as ( + select wh.id, count(*) as n from word_hits wh group by wh.id + ), hit as ( - select b.*, a.best_rk, a.main_rk, a.alt_rk, a.alt_title, a.sim + select b.*, a.best_rk, a.main_rk, a.alt_rk, ap.alt_title from base b join agg a on a.id = b.id + left join alt_pick ap on ap.id = b.id + left join word_cover wc on wc.id = b.id where a.best_rk is not null -- Rang 4: jedes Wort kommt in einem Titel oder einem Verlagsnamen des Spiels vor - or (cardinality(v_words) > 0 - and not exists ( - select 1 from unnest(v_words) as w - where not (exists (select 1 from ranked r2 where r2.id = b.id and r2.ts like '%' || w || '%') - or exists (select 1 from public.game_publishers gp - join public.publishers p on p.id = gp.publisher_id - where gp.game_id = b.id and p.name_search like '%' || w || '%')))) + or (cardinality(v_words) > 0 and wc.n = cardinality(v_words)) + ), + -- Ähnlichkeit (zweites Sortierkriterium): die höchste über Haupttitel und alle alternativen Titel + sims as ( + select r.id, max(extensions.similarity(r.ts, v_q)) as sim + from ranked r + join hit h on h.id = r.id + group by r.id ), paged as ( - select h.*, coalesce(h.best_rk, 4) as rk, count(*) over () as total + select h.*, sm.sim, coalesce(h.best_rk, 4) as rk, count(*) over () as total from hit h - order by coalesce(h.best_rk, 4), h.sim desc nulls last, h.title_search collate "C", h.id + join sims sm on sm.id = h.id + order by coalesce(h.best_rk, 4), sm.sim desc nulls last, h.title_search collate "C", h.id limit v_limit offset v_offset ) select s.id, s.title, s.year, s.min_players, s.max_players, s.min_minutes, s.max_minutes, s.min_age, diff --git a/supabase/tests/database/09_catalog_search.test.sql b/supabase/tests/database/09_catalog_search.test.sql index 80f595b..bb24238 100644 --- a/supabase/tests/database/09_catalog_search.test.sql +++ b/supabase/tests/database/09_catalog_search.test.sql @@ -6,7 +6,7 @@ -- Ohne Suchbegriff wird nur auf Fixture-IDs geprüft (Hilfsfunktion pg_temp.hits blättert durch). -- Die Include-Dateien in ../support/ (\ir) erzeugt supabase/catalog/build_startbestand.ts. begin; -select plan(201); +select plan(211); insert into auth.users (id, email, raw_user_meta_data) values ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), @@ -116,13 +116,14 @@ select results_eq($$select polcmd::text, array(select rolname from pg_roles wher insert into public.genres (id, name_de, name_en) values ('f9000000-0000-0000-0000-0000000000e1', 'Qxz Genre A', 'Qxz genre A'), ('f9000000-0000-0000-0000-0000000000e2', 'Qxz Genre B', 'Qxz genre B'), - ('f9000000-0000-0000-0000-0000000000e3', 'Qxz Genre Q', 'Qxz genre Q'); + ('f9000000-0000-0000-0000-0000000000e3', 'Qxz Genre Q', 'Qxz genre Q'), + ('f9000000-0000-0000-0000-0000000000e4', 'Qxz Genre R', 'Qxz genre R'); insert into public.mechanics (id, name_de, name_en) values ('f9000000-0000-0000-0000-0000000000d1', 'Qxz Mechanik A', 'Qxz mechanic A'), ('f9000000-0000-0000-0000-0000000000d2', 'Qxz Mechanik B', 'Qxz mechanic B'); insert into public.publishers (id, name) values - ('f9000000-0000-0000-0000-0000000000b1', 'Qxz Hersteller Eins'), - ('f9000000-0000-0000-0000-0000000000b2', 'Qxz Hersteller Zwei'), + ('f9000000-0000-0000-0000-0000000000b2', 'Qxz Hersteller Eins'), + ('f9000000-0000-0000-0000-0000000000b1', 'Qxz Hersteller Zwei'), ('f9000000-0000-0000-0000-0000000000b3', 'Qxz Zorb Verlag'); insert into public.games (id, title, visibility) values @@ -144,7 +145,18 @@ insert into public.games (id, title, visibility) values ('f9000000-0000-0000-0000-000000000016', 'QXZ CATAN®', 'catalog'), ('f9000000-0000-0000-0000-000000000040', 'Qxz Doppelspiel', 'catalog'), ('f9000000-0000-0000-0000-000000000041', 'Qxz Einzelspiel', 'catalog'), - ('f9000000-0000-0000-0000-000000000060', 'Qxz Cabin', 'catalog'); + ('f9000000-0000-0000-0000-000000000060', 'Qxz Cabin', 'catalog'), + -- Sortierung ist binär (collate "C"): "qxz b" kommt vor "qxzab". Hinweis: Mit ICU-Sortierung (wie lokal) gilt das auch + -- ohne collate "C"; der Test ist dort nicht mutationsempfindlich und schützt vor libc-Sortierungen, die Leerzeichen ignorieren. + ('f9000000-0000-0000-0000-000000000081', 'Qxzab', 'catalog'), + ('f9000000-0000-0000-0000-000000000095', 'Qxz Zwilling', 'catalog'), + ('f9000000-0000-0000-0000-000000000093', 'Qxz Zwilling', 'catalog'), + ('f9000000-0000-0000-0000-000000000091', 'Qxz Zwilling', 'catalog'), + ('f9000000-0000-0000-0000-000000000094', 'Qxz Zwilling', 'catalog'), + ('f9000000-0000-0000-0000-000000000092', 'Qxz Zwilling', 'catalog'), + ('f9000000-0000-0000-0000-000000000090', 'Qxz Zwilling', 'catalog'), + ('f9000000-0000-0000-0000-000000000017', 'Qxz Wanderer', 'catalog'), + ('f9000000-0000-0000-0000-000000000080', 'Qxz B', 'catalog'); -- Filter-Fixtures (20-25); 22 ohne jede Angabe, 23 nur max_minutes, 24 nur min_minutes insert into public.games (id, title, visibility, min_players, max_players, min_minutes, max_minutes, min_age) values @@ -161,7 +173,9 @@ insert into public.game_genres (game_id, genre_id) values ('f9000000-0000-0000-0000-000000000022', 'f9000000-0000-0000-0000-0000000000e1'), ('f9000000-0000-0000-0000-000000000025', 'f9000000-0000-0000-0000-0000000000e2'), ('f9000000-0000-0000-0000-000000000005', 'f9000000-0000-0000-0000-0000000000e3'), - ('f9000000-0000-0000-0000-000000000060', 'f9000000-0000-0000-0000-0000000000e3'); + ('f9000000-0000-0000-0000-000000000060', 'f9000000-0000-0000-0000-0000000000e3'), + ('f9000000-0000-0000-0000-000000000080', 'f9000000-0000-0000-0000-0000000000e4'), + ('f9000000-0000-0000-0000-000000000081', 'f9000000-0000-0000-0000-0000000000e4'); insert into public.game_mechanics (game_id, mechanic_id) values ('f9000000-0000-0000-0000-000000000020', 'f9000000-0000-0000-0000-0000000000d1'), ('f9000000-0000-0000-0000-000000000021', 'f9000000-0000-0000-0000-0000000000d1'), @@ -170,10 +184,10 @@ insert into public.game_mechanics (game_id, mechanic_id) values -- Verlage: 01 und 30 bei Eins; 40 bei Eins und Zwei; 41 bei Zwei; 06 bei "Qxz Zorb Verlag" insert into public.game_publishers (game_id, publisher_id) values - ('f9000000-0000-0000-0000-000000000001', 'f9000000-0000-0000-0000-0000000000b1'), - ('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b2'), + ('f9000000-0000-0000-0000-000000000001', 'f9000000-0000-0000-0000-0000000000b2'), ('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b1'), - ('f9000000-0000-0000-0000-000000000041', 'f9000000-0000-0000-0000-0000000000b2'), + ('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b2'), + ('f9000000-0000-0000-0000-000000000041', 'f9000000-0000-0000-0000-0000000000b1'), ('f9000000-0000-0000-0000-000000000006', 'f9000000-0000-0000-0000-0000000000b3'); -- Ausgeblendet, zusammengeführt, privat @@ -185,8 +199,8 @@ insert into public.games (id, title, visibility, owner_id) values ('f9000000-0000-0000-0000-000000000032', 'Qxz Privat', 'private', '11111111-1111-1111-1111-111111111111'), ('f9000000-0000-0000-0000-000000000033', 'QXZ ZORB', 'private', '22222222-2222-2222-2222-222222222222'); insert into public.game_publishers (game_id, publisher_id) values - ('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-0000000000b1'), - ('f9000000-0000-0000-0000-000000000033', 'f9000000-0000-0000-0000-0000000000b1'); + ('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-0000000000b2'), + ('f9000000-0000-0000-0000-000000000033', 'f9000000-0000-0000-0000-0000000000b2'); -- Bilder: 50 mit Quelle ohne Erlaubnis (maskiert), 51 manuell gepflegt, 52 ohne Quelle insert into public.games (id, title, visibility, image_path, catalog_source_id, source_ref) values @@ -211,18 +225,22 @@ insert into public.game_alt_titles (id, game_id, locale, title) values ('f9000000-0000-0000-0000-0000000000a6', 'f9000000-0000-0000-0000-000000000030', 'de', 'Qxz Verborgen Alt'), ('f9000000-0000-0000-0000-0000000000a7', 'f9000000-0000-0000-0000-000000000031', 'de', 'Qxz Verschmolzen Alt'), ('f9000000-0000-0000-0000-0000000000a8', 'f9000000-0000-0000-0000-000000000032', 'de', 'Qxz Privat Alt'), - ('f9000000-0000-0000-0000-0000000000a9', 'f9000000-0000-0000-0000-000000000033', 'en', 'Qxz Bens Alt'); + ('f9000000-0000-0000-0000-0000000000a9', 'f9000000-0000-0000-0000-000000000033', 'en', 'Qxz Bens Alt'), + -- drei Treffer-Titel für "qxz nomad": Stufe 2, Stufe 1 (länger, weniger ähnlich), Stufe 1 (kürzer, ähnlicher) + ('f9000000-0000-0000-0000-0000000000c1', 'f9000000-0000-0000-0000-000000000017', 'de', 'Stern Qxz Nomad'), + ('f9000000-0000-0000-0000-0000000000c2', 'f9000000-0000-0000-0000-000000000017', 'en', 'Qxz Nomad Sternenwanderer'), + ('f9000000-0000-0000-0000-0000000000c3', 'f9000000-0000-0000-0000-000000000017', 'de', 'Qxz Nomadisch'); select is((select title_search from public.games where id = 'f9000000-0000-0000-0000-000000000014'), 'qxz aeon s end', 'KAT-01 Kriterium 1: games.title_search wird beim Anlegen gefüllt'); select is((select title_search from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000a5'), 'qxz einzigartig alt', 'KAT-01 Kriterium 2: game_alt_titles.title_search wird gefüllt'); -select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b2'), 'qxz hersteller zwei', +select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b1'), 'qxz hersteller zwei', 'KAT-01 Kriterium 3: publishers.name_search wird gefüllt'); -update public.publishers set name = 'Qxz Hersteller Zwei & Söhne' where id = 'f9000000-0000-0000-0000-0000000000b2'; -select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b2'), 'qxz hersteller zwei sohne', +update public.publishers set name = 'Qxz Hersteller Zwei & Söhne' where id = 'f9000000-0000-0000-0000-0000000000b1'; +select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b1'), 'qxz hersteller zwei sohne', 'KAT-01: publishers.name_search folgt einer Namensänderung'); -update public.publishers set name = 'Qxz Hersteller Zwei' where id = 'f9000000-0000-0000-0000-0000000000b2'; +update public.publishers set name = 'Qxz Hersteller Zwei' where id = 'f9000000-0000-0000-0000-0000000000b1'; -- Hilfsfunktion: alle Fixture-Treffer durchblättern (nur als postgres, für Abfragen ohne Suchbegriff) create function pg_temp.hits(p_query text default null, p_players smallint default null, p_max_minutes smallint default null, @@ -257,6 +275,28 @@ select results_eq($$select id, match_rank::int from public.search_catalog_games( select results_eq($$select id from public.search_catalog_games('qxz twin')$$, $$values ('f9000000-0000-0000-0000-000000000007'::uuid), ('f9000000-0000-0000-0000-000000000008')$$, 'KAT-01 Kriterium 5: bei völligem Gleichstand entscheidet die ID (stabile Reihenfolge)'); +select results_eq($$select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 4, 0)$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000002'), ('f9000000-0000-0000-0000-000000000003'), ('f9000000-0000-0000-0000-000000000004')$$, + 'KAT-01 Kriterium 5: die erste Seite enthält die relevantesten Treffer (Stufe, dann Ähnlichkeit), nicht die ersten nach Titel'); +select results_eq($$select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 0) + union all select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 2) + union all select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 4)$$, + $$select id from public.search_catalog_games('qxz zorb')$$, + 'KAT-01 Kriterium 5: Seiten zu je zwei ergeben dieselbe Reihenfolge wie die ganze Liste'); +select results_eq($$select id from public.search_catalog_games('qxz twin', null, null, null, null, null, 1, 0) + union all select id from public.search_catalog_games('qxz twin', null, null, null, null, null, 1, 1)$$, + $$values ('f9000000-0000-0000-0000-000000000007'::uuid), ('f9000000-0000-0000-0000-000000000008')$$, + 'KAT-01 Kriterium 5: bei völligem Gleichstand entscheidet die ID auch über die Seitengrenze hinweg'); +select results_eq($$select id from public.search_catalog_games('qxz zwilling', null, null, null, null, null, 3, 0) + union all select id from public.search_catalog_games('qxz zwilling', null, null, null, null, null, 3, 3)$$, + $$select id from public.games where title = 'Qxz Zwilling' order by id$$, + 'KAT-01 Kriterium 5: sechs gleich gute Treffer erscheinen seitenübergreifend in ID-Reihenfolge'); +select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('qxz nomad')$$, + $$values ('f9000000-0000-0000-0000-000000000017'::uuid, 1, 'Qxz Nomadisch'::text)$$, + 'KAT-01 Kriterium 2: von mehreren treffenden alternativen Titeln gewinnt die beste Stufe, dann die größte Ähnlichkeit'); +select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('qxz einzig')$$, + $$values ('f9000000-0000-0000-0000-000000000012'::uuid, 1, null::text)$$, + 'KAT-01 Kriterium 2: bei gleicher Stufe von Haupttitel und alternativem Titel gilt der Haupttitel (matched_alt_title null)'); select is((select count(*) from public.search_catalog_games('qxz käse')), 1::bigint, 'KAT-01 Kriterium 1: Umlaut im Suchbegriff findet den Umlaut-Titel'); select is((select count(*) from public.search_catalog_games('QXZ KASE GROSSE')), 1::bigint, 'KAT-01 Kriterium 1: ohne Umlaut und ß wird derselbe Titel gefunden'); select is((select count(*) from public.search_catalog_games('qxz grösse')), 1::bigint, 'KAT-01 Kriterium 1: ö und ß in der Eingabe'); @@ -324,6 +364,9 @@ select results_eq($$select id, match_rank::int, publisher_names from public.sear select results_eq($$select id from public.search_catalog_games('qxz hersteller eins') order by id$$, $$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000040')$$, 'KAT-01 Kriterium 3: der andere Verlag des Spiels findet es ebenfalls; ausgeblendete Spiele des Verlags fehlen'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zorb zorb eins')$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid, 4)$$, + 'KAT-01 Kriterium 3: ein doppelt eingegebenes Wort zählt einfach (Verlagstreffer bleibt auffindbar)'); select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zorb eins')$$, $$values ('f9000000-0000-0000-0000-000000000001'::uuid, 4)$$, 'KAT-01 Kriterium 3: ein Wort aus dem Titel plus ein Wort aus dem Verlag (UND)'); @@ -367,6 +410,9 @@ select results_eq($$select h from pg_temp.hits(p_genre_ids := array['f9000000-00 select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$, $$values ('f9000000-0000-0000-0000-000000000021'::uuid), ('f9000000-0000-0000-0000-000000000025')$$, 'KAT-01 Kriterium 4: Mechanik allein'); +select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2', 'f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$, + $$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$, + 'KAT-01 Kriterium 4: Duplikate im Mechanik-Array zählen einfach'); select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d1', 'f9000000-0000-0000-0000-0000000000d2']::uuid[]) h$$, $$values ('f9000000-0000-0000-0000-000000000021'::uuid)$$, 'KAT-01 Spec-F1: zwei Mechaniken: nur Spiele mit beiden'); @@ -386,6 +432,13 @@ select results_eq($$select id, match_rank::int, matched_alt_title, total_count:: $$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int, null::text, 2), ('f9000000-0000-0000-0000-000000000005', null, null, 2)$$, 'KAT-01 Kriterium 8: ohne Suchbegriff nach Titel sortiert, match_rank und matched_alt_title null'); +select results_eq($$select id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[], null, 1, 0) + union all select id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[], null, 1, 1)$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid), ('f9000000-0000-0000-0000-000000000005')$$, + 'KAT-01 Kriterium 5: ohne Suchbegriff bestimmt der Titel (nicht die ID) auch die Seitengrenze'); +select results_eq($$select id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e4']::uuid[])$$, + $$values ('f9000000-0000-0000-0000-000000000080'::uuid), ('f9000000-0000-0000-0000-000000000081')$$, + 'KAT-01 Kriterium 8: ohne Suchbegriff wird binär nach dem normalisierten Titel sortiert (Leerzeichen vor Buchstaben)'); select results_eq($$select h from pg_temp.hits() h order by 1$$, $$select id from public.games where id::text like 'f9000000%' and visibility = 'catalog' and hidden_at is null and merged_into_id is null order by id$$, 'KAT-01 Kriterium 8: ohne Suchbegriff und ohne Filter erscheinen alle sichtbaren Fixture-Spiele (ohne ausgeblendete, zusammengeführte, private)'); From 729e51620cb93b90b10696ef3bb8cc2b2c4a9e72 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:40:43 +0100 Subject: [PATCH 27/30] test(db): add local search measurement script (KAT-01 B5) supabase/perf/catalog_search_perf.sql loads synthetic data in one rolled-back transaction and measures search_catalog_games as anon with RLS (explain and p50/p95/p99 per case). Lives outside supabase/tests so it never runs in CI. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- supabase/perf/catalog_search_perf.sql | 207 ++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 supabase/perf/catalog_search_perf.sql diff --git a/supabase/perf/catalog_search_perf.sql b/supabase/perf/catalog_search_perf.sql new file mode 100644 index 0000000..0c3e7be --- /dev/null +++ b/supabase/perf/catalog_search_perf.sql @@ -0,0 +1,207 @@ +-- Messung der Katalogsuche (KAT-01, Spec #3 Abschnitt 4.2 "Leistung", Aufgabe B5). +-- KEIN pgTAP-Test und liegt bewusst nicht unter supabase/tests/ (supabase test db würde es sonst mitlaufen lassen). +-- +-- Was es tut: legt in EINER Transaktion synthetische Daten an (Standard: 10 000 sichtbare Katalogspiele, +-- 20 000 alternative Titel, 2 000 Verlage, je Spiel zwei Genres und drei Mechaniken), führt ANALYZE aus und +-- misst search_catalog_games ALS anon mit Row Level Security. Am Ende steht ROLLBACK: nichts bleibt zurück. +-- 1. Phase A: je typischer Fall ein Aufruf mit auto_explain (Plan mit Zeiten und Buffers kommt als NOTICE). +-- 2. Phase B: je Fall n_iter (Standard 100) Aufrufe mit wechselnden Begriffen, Ausgabe p50/p95/p99/max in ms. +-- +-- Nur lokal! Nie gegen Staging oder Produktion (die Daten werden zwar zurückgerollt, die Last und die Sperren +-- sind es nicht). Schutz: Aufruf nur mit -v confirm_local=yes, und das Skript bricht ab, wenn die Datenbank +-- nach echten Daten aussieht (mehr als 20 Konten oder 20 private Spiele). +-- +-- Aufruf (lokaler Stack, Docker-Container der Supabase CLI): +-- docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes -v n_games=10000 \ +-- < supabase/perf/catalog_search_perf.sql +-- Das statement_timeout der Rolle anon (lokal 3 s) wirkt bei set local role nicht von allein und gälte hier für +-- den ganzen DO-Block; deshalb zählt die Ausgabe die Aufrufe über 3 s (Spalte ueber_3s). +\set ON_ERROR_STOP on +\if :{?confirm_local} +\else + \echo 'Abbruch: mit -v confirm_local=yes aufrufen (nur lokal, siehe Kopfkommentar).' + \quit +\endif +\if :{?n_games} +\else + \set n_games 10000 +\endif +\if :{?n_iter} +\else + \set n_iter 100 +\endif + +begin; +do $guard$ +begin + if (select count(*) from auth.users) > 20 or (select count(*) from public.games where visibility = 'private') > 20 then + raise exception 'Abbruch: Die Datenbank sieht nach echten Daten aus (mehr als 20 Konten oder private Spiele). Dieses Skript läuft nur lokal.'; + end if; +end +$guard$; + +select setseed(0.42); + +-- --------------------------------------------------------------------------------------------- +-- Synthetische Daten (als postgres, vor dem Rollenwechsel) +-- --------------------------------------------------------------------------------------------- +create schema perf_tmp; +grant usage on schema perf_tmp to anon; + +-- ~3000 Pseudo-Wörter mit ungleicher Häufigkeit (häufige Wörter wie in echten Titeln) +create table perf_tmp.words as +select i, translate(substr(md5('w' || i), 1, 4 + (i % 6)), '0123456789', 'ghijklmnop') as w +from generate_series(1, 3000) i; +alter table perf_tmp.words add primary key (i); +create function perf_tmp.pick() returns text language sql volatile as + $$ select w from perf_tmp.words where i = (select 1 + floor(power(random(), 2.2) * 3000)::int) $$; + +insert into public.genres (name_de, name_en) +select 'Perf Genre ' || i, 'Perf genre ' || i from generate_series(1, 15) i; +insert into public.mechanics (name_de, name_en) +select 'Perf Mechanik ' || i, 'Perf mechanic ' || i from generate_series(1, 25) i; + +insert into public.publishers (name) +select initcap(perf_tmp.pick()) || ' Spiele ' || i from generate_series(1, 2000) i; + +insert into public.games (title, visibility, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale) +select initcap(concat_ws(' ', perf_tmp.pick(), case when k >= 2 then perf_tmp.pick() end, case when k >= 3 then perf_tmp.pick() end, + case when k >= 4 then perf_tmp.pick() end)), + 'catalog', 1990 + (g % 35), mn, mn + (g % 5), 10 + (g % 12) * 10, 30 + (g % 12) * 10, 6 + (g % 4) * 3, + 'Synthetischer Kurztext ' || g, 'de' +from (select g, 1 + floor(random() * 3.99)::int as k, 1 + (g % 4) as mn from generate_series(1, :n_games) g) s; + +-- 2 alternative Titel je Spiel (de/en), Duplikate je Sprache entfallen +insert into public.game_alt_titles (game_id, locale, title) +select g.id, l.locale, initcap(perf_tmp.pick() || ' ' || perf_tmp.pick()) +from public.games g, (values ('de'), ('en')) as l(locale) +where g.visibility = 'catalog' +on conflict do nothing; + +insert into public.game_publishers (game_id, publisher_id) +select g.id, (select id from public.publishers order by random() limit 1) from public.games g where g.visibility = 'catalog' and g.title <> '' +on conflict do nothing; +insert into public.game_publishers (game_id, publisher_id) +select g.id, (select id from public.publishers order by random() limit 1) from public.games g where random() < 0.3 +on conflict do nothing; + +insert into public.game_genres (game_id, genre_id) +select g.id, t.id from public.games g cross join lateral + (select x.id from public.genres x where x.name_de like 'Perf Genre %' order by md5(g.id::text || x.id::text) limit 2) t on conflict do nothing; +insert into public.game_mechanics (game_id, mechanic_id) +select g.id, t.id from public.games g cross join lateral + (select x.id from public.mechanics x where x.name_de like 'Perf Mechanik %' order by md5(g.id::text || x.id::text) limit 3) t on conflict do nothing; + +analyze public.games; +analyze public.game_alt_titles; +analyze public.publishers; +analyze public.game_publishers; +analyze public.game_genres; +analyze public.game_mechanics; + +\echo === Datenumfang === +select (select count(*) from public.catalog_games) as katalogspiele, + (select count(*) from public.game_alt_titles) as alt_titel, + (select count(*) from public.publishers) as verlage, + (select count(*) from public.game_publishers) as verlag_zuordnungen, + pg_size_pretty(pg_relation_size('public.games')) as games_tabelle, + pg_size_pretty(pg_relation_size('public.games_title_search_trgm_idx')) as games_trgm_index, + pg_size_pretty(pg_relation_size('public.game_alt_titles_title_search_trgm_idx')) as alt_trgm_index; + +-- --------------------------------------------------------------------------------------------- +-- Abfragen für die Fälle (Begriffe aus den vorhandenen Daten, damit sie Treffer haben) +-- --------------------------------------------------------------------------------------------- +create table perf_tmp.q ( + id serial, fall text, term text, players smallint, minutes smallint, age smallint, genre uuid, mech uuid, off int default 0); +grant select on perf_tmp.q to anon; +create table perf_tmp.r (fall text, ms float8, n bigint); +grant insert, select on perf_tmp.r to anon; + +with sample as (select g.id, g.title, lower(g.title) as lt, string_to_array(lower(g.title), ' ') as ws + from public.games g order by random() limit :n_iter), + genre as (select id from public.genres where name_de like 'Perf Genre %' order by random() limit 1), + mech as (select id from public.mechanics where name_de like 'Perf Mechanik %' order by random() limit 1) +insert into perf_tmp.q (fall, term, players, minutes, age, genre, mech, off) +select '01 Titelteil (4 Zeichen)', substr(ws[1], 1, 4), null::smallint, null::smallint, null::smallint, null::uuid, null::uuid, 0 from sample +union all select '02 zwei Zeichen', substr(ws[1], 1, 2), null, null, null, null, null, 0 from sample +union all select '03 ganzes Wort', ws[1], null, null, null, null, null, 0 from sample +union all select '04 zwei Wörter', case when cardinality(ws) >= 2 then ws[1] || ' ' || ws[2] else ws[1] end, null, null, null, null, null, 0 from sample +union all select '05 ohne Leerzeichen', case when cardinality(ws) >= 2 then ws[1] || ws[2] else ws[1] end, null, null, null, null, null, 0 from sample +union all select '06 Verlag (ein Wort)', lower(split_part(p.name, ' ', 1)), null, null, null, null, null, 0 + from (select name from public.publishers order by random() limit :n_iter) p +union all select '06b Verlag + häufiges Wort', lower(split_part(p.name, ' ', 1)) || ' spiele', null, null, null, null, null, 0 + from (select name from public.publishers order by random() limit :n_iter) p +union all select '07 alternativer Titel', split_part(lower(a.title), ' ', 1), null, null, null, null, null, 0 + from (select title from public.game_alt_titles order by random() limit :n_iter) a +union all select '08 Begriff + Filter', ws[1], 3, null, null, (select id from genre), null, 0 from sample +union all select '09 nur Filter (Spielerzahl, Genre)', null, 3, null, null, (select id from genre), null, 0 from sample +union all select '10 nur alle Filter', null, 3, 90, 12, (select id from genre), (select id from mech), 0 from sample +union all select '11 ohne alles (erste Seite)', null, null, null, null, null, null, 0 from sample +union all select '12 ohne alles (Versatz 5000)', null, null, null, null, null, null, 5000 from sample +union all select '13 ohne Treffer', 'zzqqxx' || substr(md5(id::text), 1, 3), null, null, null, null, null, 0 from sample; + +-- --------------------------------------------------------------------------------------------- +-- Phase A: Pläne (auto_explain, als anon mit RLS). Die Pläne kommen als NOTICE des inneren Statements. +-- --------------------------------------------------------------------------------------------- +\echo === Phase A: explain (analyze, buffers) als anon mit RLS === +set local auto_explain.log_min_duration = 0; +set local auto_explain.log_analyze = on; +set local auto_explain.log_buffers = on; +set local auto_explain.log_timing = off; -- die Zeitmessung selbst verfälscht die Zeiten stark; Zeiten stehen in Phase B +set local auto_explain.log_nested_statements = on; +set local auto_explain.log_level = 'notice'; +set local role anon; +set local request.jwt.claims = '{"role":"anon"}'; + +do $a$ +declare + c record; +begin + for c in select distinct on (fall) fall, term, players, minutes, age, genre, mech, off from perf_tmp.q order by fall, id loop + raise notice '##### %: term=%, players=%, off=%', c.fall, c.term, c.players, c.off; + perform * from public.search_catalog_games(c.term, c.players, c.minutes, c.age, + case when c.genre is null then null else array[c.genre] end, case when c.mech is null then null else array[c.mech] end, 30, c.off); + end loop; +end +$a$; +reset role; +set local auto_explain.log_min_duration = -1; + +-- --------------------------------------------------------------------------------------------- +-- Phase B: Zeiten (clock_timestamp um jeden Aufruf), als anon mit RLS +-- --------------------------------------------------------------------------------------------- +\echo === Phase B: Zeiten als anon mit RLS === +set local role anon; +set local request.jwt.claims = '{"role":"anon"}'; +do $b$ +declare + c record; + t0 timestamptz; + n bigint; +begin + for c in select * from perf_tmp.q order by id loop + t0 := clock_timestamp(); + select coalesce(max(total_count), 0) into n from public.search_catalog_games(c.term, c.players, c.minutes, c.age, + case when c.genre is null then null else array[c.genre] end, case when c.mech is null then null else array[c.mech] end, 30, c.off); + insert into perf_tmp.r values (c.fall, extract(epoch from clock_timestamp() - t0) * 1000, n); + end loop; +end +$b$; +reset role; + +select fall, + count(*) as aufrufe, + round(percentile_cont(0.5) within group (order by ms)::numeric, 1) as p50_ms, + round(percentile_cont(0.95) within group (order by ms)::numeric, 1) as p95_ms, + round(percentile_cont(0.99) within group (order by ms)::numeric, 1) as p99_ms, + round(max(ms)::numeric, 1) as max_ms, + count(*) filter (where ms > 3000) as ueber_3s, + round(avg(n)::numeric, 0) as treffer_mittel +from perf_tmp.r group by fall order by fall; + +select 'alle Fälle zusammen' as fall, count(*) as aufrufe, + round(percentile_cont(0.95) within group (order by ms)::numeric, 1) as p95_ms, + round(max(ms)::numeric, 1) as max_ms +from perf_tmp.r; + +rollback; From 0cbe813e5b7aee5d5f2342929a3712ac80107ae7 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:40:43 +0100 Subject: [PATCH 28/30] docs(db): document search, alt titles and image-upgrade check (KAT-01, 3a) datenmodell-r1, supabase README and katalog-pflege (alt titles, merge step 2b, normalization check after image upgrades, rate limit and log hints). Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- docs/datenmodell-r1.md | 40 ++++++++++++++++++--- docs/katalog-pflege.md | 79 +++++++++++++++++++++++++++++++++++++++--- supabase/README.md | 9 ++++- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/docs/datenmodell-r1.md b/docs/datenmodell-r1.md index 01a5a31..627d3d1 100644 --- a/docs/datenmodell-r1.md +++ b/docs/datenmodell-r1.md @@ -28,7 +28,7 @@ Die Pfeile zeigen, welche Tabelle auf welche verweist. Jede Partie (`plays`) hä ## Tabellen -R1 braucht 21 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers`, `game_publishers` und `game_reports` aus KAT-05). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag. +R1 braucht 22 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers`, `game_publishers` und `game_reports` aus KAT-05 sowie `game_alt_titles` aus KAT-01). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag. ### Konto und Profil @@ -42,10 +42,11 @@ R1 braucht 21 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog | Tabelle | Spalten | Zweck | | --- | --- | --- | -| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at` | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die letzten sieben Spalten gehören der Katalogpflege (KAT-05), siehe unten | +| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at`, `title_search` (generiert) | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die sieben Spalten vor `title_search` gehören der Katalogpflege (KAT-05), `title_search` der Suche (KAT-01), siehe unten | | `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at`, `editions_cleared` | Meldungen fehlender Spiele (KAT-05 PR 2c, Anlegen durch Nutzer kommt mit KAT-04); siehe Katalogpflege | | `catalog_sources` | `key`, `name`, `attribution_text`, `attribution_url`, `images_allowed`, `texts_allowed`, `terms_url`, `terms_checked_at` | Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt `bgg` ohne Erlaubnis an | -| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) | +| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise), `name_search` (generiert); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) | +| `game_alt_titles` | `game_id` (FK → `games`), `title`, `locale` (`de`/`en`), `title_search` (generiert) | Alternative Titel eines Spiels mit Sprache (Klärung 3, KAT-01); die Suche findet das Spiel auch darüber | | `game_variants` | `game_id` (FK → `games`), `name`, `year`, `language`, `bgg_id` (optional) | Editionen und Varianten eines Spiels (O4) | | `genres`, `mechanics`, `categories` | `name_de`, `name_en` | Einheitliche Begriffe für Filter und Spieldetails | | `game_genres` | `game_id` (FK → `games`), `genre_id` (FK → `genres`) | Zuordnung Spiel zu Genre; Paar eindeutig | @@ -78,6 +79,7 @@ Jede Tabelle hat Row Level Security (RLS) und eine Policy-Test-Suite (pgTAP). De | `favorite_games`, `bgg_links` | Nur eigene | Nur eigene | | `games`, `game_variants`, Begriffe | Katalog für alle (O6); private Spiele nur der Ersteller | Katalog nur Admin (in R1 nur über Studio/SQL, E10); private Spiele nur der Ersteller, und nur die Spalten laut Spaltenrechten | | `game_reports` | nur der Meldende liest seine Zeilen (Admin-Policy `is_admin()` für R4); Schreiben kein Nutzerrecht | Anlegen ergänzt KAT-04 (#13); Bearbeiten nur über `admin_resolve_report` (Studio) | +| `game_alt_titles` | für alle, auch ohne Anmeldung; folgt dem Spiel (alternative Titel eines fremden privaten Spiels sind unsichtbar) | **niemand über die API** (kein Recht, keine Policy); Pflege nur über Studio und das Startbestand-Werkzeug | | `catalog_sources`, `publishers`, `game_publishers`, View `catalog_games` | für alle, auch ohne Anmeldung (`game_publishers` folgt dem Spiel) | Quellen und Verlage nur Admin; `game_publishers` Admin oder Ersteller des privaten Spiels; die View ist nicht beschreibbar | | `collection_items`, `score_sheets`, `score_categories` | Nur eigene | Nur eigene | | `plays` | Ersteller und eingeladene Mitspieler (offen oder bestätigt) | Nur Ersteller | @@ -197,11 +199,12 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel | `description_locale` | text | nein | | `de` oder `en`, nur zusammen mit `description` | | `hidden_at` | timestamptz | nein | | gesetzt = ausgeblendet | | `merged_into_id` | uuid | nein | | FK → games (`on delete restrict`), nur auf Katalogspiele; nur mit `hidden_at` | -| `manual_fields` | text[] | ja | `'{}'` | von Hand gepflegte Felder (Trigger), nur bekannte Schlüssel | +| `manual_fields` | text[] | ja | `'{}'` | von Hand gepflegte Felder (Trigger), nur bekannte Schlüssel: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`, `alt_titles` | | `catalog_source_id` | uuid | nein | | FK → catalog_sources (`on delete restrict`) | | `source_ref` | text | nein | | Kennung in der Quelle; genau dann gesetzt, wenn `catalog_source_id` gesetzt ist | | `source_url` | text | nein | | `https://`, nur mit Quelle | | `source_synced_at` | timestamptz | nein | | letzter Abgleich, nur mit Quelle | +| `title_search` | text | nein | generiert | `public.search_normalize(title)`, gespeichert; nicht beschreibbar (KAT-01) | | `created_at` | timestamptz | ja | `now()` | | | `updated_at` | timestamptz | ja | `now()` | | @@ -220,7 +223,20 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel | `terms_checked_at` | date | nein | | | | `created_at`, `updated_at` | timestamptz | ja | `now()` | | -**`publishers`** hat `id`, `name` (1 bis 200 Zeichen, eindeutig ohne Beachtung der Schreibweise), `created_at`, `updated_at`. **`game_publishers`** verbindet wie die anderen Zuordnungen ein Spiel (`game_id`, FK → games, `on delete cascade`) mit einem Verlag (`publisher_id`, FK → publishers, `on delete cascade`); das Paar ist eindeutig. +**`publishers`** hat `id`, `name` (1 bis 200 Zeichen, eindeutig ohne Beachtung der Schreibweise), `name_search` (generiert, `public.search_normalize(name)`), `created_at`, `updated_at`. **`game_publishers`** verbindet wie die anderen Zuordnungen ein Spiel (`game_id`, FK → games, `on delete cascade`) mit einem Verlag (`publisher_id`, FK → publishers, `on delete cascade`); das Paar ist eindeutig. + +**`game_alt_titles`** (KAT-01) + +| Spalte | Typ | Pflicht | Standard | Schlüssel | +| --- | --- | --- | --- | --- | +| `id` | uuid | ja | `gen_random_uuid()` | PK | +| `game_id` | uuid | ja | | FK → games (`on delete cascade`) | +| `title` | text | ja | | 1 bis 200 Zeichen | +| `locale` | text | ja | | `de` oder `en` | +| `title_search` | text | ja | generiert | `public.search_normalize(title)`, gespeichert; darf nicht leer sein (ein Titel nur aus Sonderzeichen ist unzulässig) | +| `created_at`, `updated_at` | timestamptz | ja | `now()` | | + +Eindeutig ist `(game_id, locale, title_search)`: derselbe Titel in derselben Sprache nur einmal, unabhängig von der Schreibweise; mehrere Titel je Sprache sind erlaubt. Lesbar wie das Spiel; kein Schreibrecht für API-Rollen. **`game_variants`** @@ -373,3 +389,17 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle - **Startbestand:** `supabase/catalog/` (CSV, Werkzeug, erzeugte SQL-Dateien); `supabase db reset` spielt Vokabular und Startbestand ein. Der Startbestand enthält nur Fakten und einen eigenen deutschen Kurztext, kein Cover, keine Quelle, keine BGG-Kennung. - **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht. - **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`; neu für den Meldenden lesbar: `game_reports`. Das Ändern von `visibility` an einem Spiel ist nicht mehr möglich (P0001 `visibility_immutable`); wer eine Sichtbarkeit mitsendet, muss den alten Wert senden oder die Spalte weglassen. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an. + +## Katalogsuche (KAT-01, Migration `catalog_search`) + +Spec: `docs/specs/3-katalog-suche.md`. Die Suche liest ausschließlich über die RPC `search_catalog_games`, die ihrerseits die View `catalog_games` nutzt. + +- **Erweiterungen:** `pg_trgm` und `unaccent` im Schema `extensions` (Bestandteil des Supabase-Postgres-Images; lokal geprüft, auf Staging/Produktion vor dem Merge mit `select * from pg_available_extensions where name in ('pg_trgm','unaccent')` bestätigen). +- **Gemeinsame Normalisierung:** `public.search_normalize(text)` ist die einzige Basisfunktion für normalisierte Titel. Sie wird von #3 (PR 3a) angelegt, ist rein rechnend und für `anon`, `authenticated` und `service_role` ausführbar, weil generierte Spalten und `security invoker`-Funktionen sie mit den Rechten des Aufrufers berechnen. #13 legt `public.normalize_game_title(text)` darüber an und entfernt nur dort Artikel; `search_normalize` entfernt nie Artikel. Ablauf: Marken- und Schutzzeichen (`®©™℗℠`) entfernen, Akzente/Umlaute entfernen (`unaccent`, `ß` → `ss`, `Æ` → `ae`), Kleinschreibung, alles außer `a–z`/`0–9` zu einem Leerzeichen, Ränder kürzen, leer = `null`. Beispiele: `'CATAN®'` → `'catan'`, `'Spiel™ ½'` → `'spiel 1 2'`, `'Æon’s End'` → `'aeon s end'`, `'Café Größe'` → `'cafe grosse'`, `'!!!'` → `null`. Nicht-lateinische Zeichen werden entfernt. Ein Suchwort wie „kaese“ findet „Käse“ in R1 nicht (Spec-F5). +- **Generierte Spalten** (gespeichert, nicht beschreibbar): `games.title_search`, `game_alt_titles.title_search`, `publishers.name_search`, je mit Trigramm-Index (GIN; der Index auf `games` ist ein Partial-Index mit den Bedingungen der View `catalog_games`). Die Indizes sind keine Geschwindigkeitszusage, maßgeblich ist die Messung (`supabase/perf/catalog_search_perf.sql`, Ergebnis in der Beschreibung von PR 3a). Wird `search_normalize` oder `unaccent.rules` geändert (Image-Upgrade), müssen die Spalten per Migration neu angelegt werden; Prüfabfrage: `docs/katalog-pflege.md`, Abschnitt 8. +- **`manual_fields`:** neuer Schlüssel `alt_titles`. Der Check nennt jetzt 15 Schlüssel (siehe Tabellenstruktur); jede Migration nennt die vollständige Liste. +- **Alternative Titel beim Zusammenführen:** `admin_merge_games` hängt `game_alt_titles` nicht um (Spec-F6 A); sie bleiben am ausgeblendeten Quell-Eintrag. Vor dem Zusammenführen ergänzt der Admin relevante Titel am Ziel (`docs/katalog-pflege.md`, Abschnitt 5, Schritt 2b). +- **RPC `public.search_catalog_games(p_query, p_players, p_max_minutes, p_age, p_genre_ids, p_mechanic_ids, p_limit default 30, p_offset default 0)`** (`stable`, `security invoker`, leerer `search_path`; ausführbar für `anon` und `authenticated`, nicht für `public` und `service_role`). Vertrag und Semantik: Spec 4.2. Ausgabe je Treffer: `id`, `title`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path` (nur aus `catalog_games`, maskiert nach Quelle), `publisher_names`, `matched_alt_title`, `match_rank` (0 bis 4, ohne Suchbegriff `null`), `total_count`. Umsetzungsdetails, die der Vertrag offen ließ: `null` bei `p_limit`/`p_offset` gilt als Standardwert; „nur Leerraum“ (auch Tabulator, Zeilenumbruch) gilt als kein Suchbegriff; die Ähnlichkeit (zweites Sortierkriterium) ist die höchste Trigramm-Ähnlichkeit über Haupttitel und alle alternativen Titel; bei gleicher Stufe und Ähnlichkeit wird binär (`collate "C"`) nach `title_search`, dann nach `id` sortiert (ohne Suchbegriff ebenso nach `title_search`, dann `id`). Fehler (`P0001`, HTTP 400): `search_query_too_long`, `search_filter_invalid`, `search_paging_invalid`. Ein `smallint`-Parameter außerhalb von −32768…32767 scheitert schon beim Umwandeln (kein `P0001`). +- **Rechte der Rolle `catalog_importer`:** Wer `games` schreibt, berechnet `title_search` mit den eigenen Rechten. Der spätere Import (SAM-08) braucht deshalb `execute` auf `public.search_normalize(text)`, `usage` auf das Schema `extensions` und Schreibrechte auf `game_alt_titles`; die Test-Rolle in `06_catalog_foundation.test.sql` bekommt die beiden ersten. +- **Suchbegriffe** werden nicht gespeichert; der Aufruf per `POST` (`client.rpc`) hält sie aus den URLs der Zugriffsprotokolle. Protokoll-Einstellungen der Datenbank: Ergebnis der Prüfung in der Beschreibung von PR 3a. +- **API-Änderungen für das Frontend:** neu lesbar: Tabelle `game_alt_titles`, Spalten `games.title_search` und `publishers.name_search` (über `select=*` mitgeliefert, die App liest sie nicht), RPC `search_catalog_games`. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGameSummary` (3b) folgt der Ausgabe oben. diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index 15239c1..f9991bc 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -2,9 +2,9 @@ Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`. -Stand: PR 2c (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten). +Stand: PR 3a (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten, alternative Titel und Suche). -**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`, Abschnitt 6 in `08_game_reports.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). +**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`, Abschnitt 6 in `08_game_reports.test.sql`, Abschnitte 3.1, 5 (Schritt 2b) und 8 in `09_catalog_search.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). ## 1. Zugang @@ -60,7 +60,7 @@ Kopfzeile ist Pflicht, Trennzeichen Komma, Felder mit Komma oder Zeilenumbruch i | `description_locale` | ja | immer `de` | | `hidden` | nein | `ja` = ausblenden; leer = `hidden_at` bleibt unverändert | | `facts_source` | nein | Herkunftsnachweis für SB3; nicht in der Datenbank | -| `alt_titles` | nein | reserviert für KAT-01 (`de:Titel;en:Title`), wird ignoriert | +| `alt_titles` | nein | alternative Titel mit Sprache: `de:Titel;en:Title`, Einträge durch `;` getrennt, Sprache `de` oder `en`, am **ersten** `:` getrennt (`de:Catan: Das Duell` ist zulässig). **`;` ist in einem alternativen Titel nicht erlaubt** (kein Escaping; solche Titel im Studio pflegen, siehe 3.1). Das Werkzeug bricht mit Zeilennummer ab bei: Sprache nicht `de`/`en`, Eintrag ohne `:`, leerem oder über 200 Zeichen langem Titel, einem Titel nur aus Sonderzeichen, demselben Titel in derselben Sprache doppelt oder gleich dem Haupttitel (ohne Beachtung von Schreibweise und Sonderzeichen). Eine leere Spalte entfernt früher eingespielte alternative Titel des Spiels (bis zum Go-live). | | `base_game_key` | nein | reserviert für KAT-03: `key` des Basisspiels (SB7) | Andere Spalten (Cover, Quelle, BGG-Kennung) lehnt das Werkzeug ab. @@ -166,7 +166,37 @@ Regeln: - **Katalogspiele nie löschen, sondern ausblenden** (siehe 4). Zugeordnete Sammlungen, Favoriten und Partien verweisen auf sie. - **`merged_into_id` nie von Hand setzen**; Zusammenführen geht nur über die Funktion `admin_merge_games` (siehe 5). Der Trigger lehnt einen Verweis auf ein privates Spiel ab (`merged_into_not_catalog`). - Private Spiele von Nutzern im Studio nicht anfassen. -- Zulässige Schlüssel in `manual_fields`: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`. +- Zulässige Schlüssel in `manual_fields`: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`, `alt_titles`. + +### 3.1 Alternative Titel pflegen + +Ein Spiel kann zusätzlich zum Haupttitel alternative Titel mit Sprache (`de` oder `en`) haben, zum Beispiel den deutschen und den englischen Titel. Die Suche findet das Spiel auch darüber (KAT-01). Pflege nur über Studio oder die CSV-Spalte `alt_titles` (siehe 2.3); API-Rollen können sie nicht schreiben. Der Trigger trägt `alt_titles` in `games.manual_fields` ein. Ein Titel nur aus Sonderzeichen wird abgelehnt; derselbe Titel in derselben Sprache ist nur einmal möglich, egal in welcher Schreibweise. Beispiel-ID wie in 3: `7a1b2c3d-0000-4000-8000-000000000001`. + +**Anlegen oder korrigieren** (ein vorhandener Titel in anderer Schreibweise wird aktualisiert statt doppelt angelegt): + +```sql +insert into public.game_alt_titles (game_id, locale, title) +values ('7a1b2c3d-0000-4000-8000-000000000001', 'de', 'Beispielspiel Deutsch'), + ('7a1b2c3d-0000-4000-8000-000000000001', 'en', 'Example game') +on conflict (game_id, locale, title_search) do update set title = excluded.title +where public.game_alt_titles.title is distinct from excluded.title; +``` + +**Ansehen:** + +```sql +select locale, title from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000001' order by locale, title; +``` + +**Löschen:** + +```sql +delete from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000001' and locale = 'en' and title = 'Example game'; +``` + +Die Suche normalisiert Titel selbst (Groß-/Kleinschreibung, Akzente, Sonderzeichen, Marken- und Schutzzeichen); ein alternativer Titel muss deshalb nicht in mehreren Schreibweisen angelegt werden. Ein Suchwort „kaese“ findet „Käse“ in R1 nicht. ## 4. Ausblenden und wieder einblenden @@ -233,6 +263,23 @@ join public.collection_items t on t.owner_id = s.owner_id and t.game_id = '7a1b2 where s.game_id = '7a1b2c3d-0000-4000-8000-000000000002'; ``` +**Schritt 2b: alternative Titel der Quelle am Ziel ergänzen.** Beim Zusammenführen werden `game_alt_titles` **nicht** umgehängt, sie bleiben am ausgeblendeten Quell-Eintrag (Spec-F6 A); die Suche zeigt die Quelle nicht mehr. Damit das Ziel weiter über die Namen der Quelle gefunden wird, vor dem Zusammenführen ansehen und übernehmen: + +```sql +select locale, title from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000002' order by locale, title; +``` + +```sql +insert into public.game_alt_titles (game_id, locale, title) +select '7a1b2c3d-0000-4000-8000-000000000003', a.locale, a.title +from public.game_alt_titles a +where a.game_id = '7a1b2c3d-0000-4000-8000-000000000002' +on conflict (game_id, locale, title_search) do nothing; +``` + +Der Haupttitel der Quelle hat keine Sprache und wird nicht automatisch übernommen; soll er als alternativer Titel am Ziel stehen, legt man ihn mit dem Baustein aus 3.1 mit der passenden Sprache an. + **Schritt 3: Zusammenführen.** Der Baustein steht wie überall ohne `begin`/`commit`; er wird in einer Transaktion ausgeführt. Bei einem Fehler wird nichts geändert: ```sql @@ -499,3 +546,27 @@ update auth.users set raw_app_meta_data = raw_app_meta_data || '{"role":"admin"} **Nicht in R1 ausführen und nicht in pgTAP getestet.** Nie `user_metadata` dafür verwenden (vom Client änderbar). Hinweis für QUE-08 (R4): Der `is_admin()`-Zweig der Policies `game_*_write` (Genre, Mechanik, Kategorie, Verlag) funktioniert für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` (security invoker) `games.manual_fields` schreiben muss und `authenticated` dafür kein Spaltenrecht hat. Ein Admin-Pfad über die API braucht eigene RPCs (security definer). + +## 8. Suche: Normalisierung und Betrieb + +### 8.1 Nach einem Image-Upgrade: Normalisierung prüfen + +Die Suche speichert normalisierte Titel in den generierten Spalten `games.title_search`, `game_alt_titles.title_search` und `publishers.name_search` (Funktion `public.search_normalize`). Die Funktion ruft `unaccent` mit der Datei `unaccent.rules` des Datenbank-Images auf und ist als `immutable` deklariert. Ändert ein **Image-Upgrade** diese Datei (oder wird die Funktion geändert), weichen die gespeicherten Werte von dem ab, was die Funktion heute berechnet; Suchen treffen dann nicht mehr. **Nach jedem Image-Upgrade und vor dem Release** diese Prüfabfrage ausführen; jede Zeile muss `0` zeigen: + +```sql +select 'games' as tabelle, count(*) as abweichend from public.games + where title_search is distinct from public.search_normalize(title) +union all select 'game_alt_titles', count(*) from public.game_alt_titles + where title_search is distinct from public.search_normalize(title) +union all select 'publishers', count(*) from public.publishers + where name_search is distinct from public.search_normalize(name); +``` + +Bei einer Abweichung die betroffenen Spalten **per neuer Migration** entfernen und neu anlegen (gleiche Definition, `generated always as (public.search_normalize(…)) stored`, dazu den Index neu anlegen); nie von Hand im Studio. Dasselbe gilt, wenn `search_normalize` selbst geändert wird. + +### 8.2 Betrieb: Zeitlimits, Mengenbegrenzung und Protokolle + +- **Ohne Anmeldung aufrufbar:** `search_catalog_games` ist für `anon` ausführbar. Die Eingabegrenzen der Funktion (100 Zeichen, 10 Wörter, höchstens 20 IDs je Filter, Seitengröße 50) begrenzen die Last je Aufruf; zusätzlich gelten die `statement_timeout`-Werte der Rollen `anon` und `authenticated` (lokal 3 s und 8 s; im Self-Hosting prüfen, `select rolname, rolconfig from pg_roles where rolname in ('anon', 'authenticated')`). Ein `set statement_timeout` an der Funktion wirkt nicht. +- **Mengenbegrenzung je IP (Entscheidung vor dem Go-live, Aufgabe D1):** Das eigentliche Risiko sind viele parallele Aufrufe ohne Anmeldung. Für R1.1 ist eine Begrenzung kein Muss; vor dem Go-live entscheidet devops, ob das Kong-Plugin `rate-limiting` auf `/rest/v1/rpc/search_catalog_games` oder `limit_req` im vorgelagerten Proxy gesetzt wird, und trägt das in das Hosting-Runbook ein. +- **Suchbegriffe nicht protokollieren (QUE-04):** Die App sendet den Suchbegriff per `POST` im Anfragekörper (nicht in der URL). In den Datenbank-Einstellungen dürfen `log_min_duration_statement`, `log_statement` und `log_parameter_max_length*` Parameterwerte nicht in die Protokolle schreiben; `auto_explain.log_min_duration` und `pgaudit.log_parameter` ebenfalls nicht. Lokal geprüfte Werte und die Abweichungen stehen in der Beschreibung von PR 3a; auf Staging/Produktion mit `select name, setting from pg_settings where name in ('log_min_duration_statement', 'log_statement', 'log_parameter_max_length', 'log_parameter_max_length_on_error', 'auto_explain.log_min_duration', 'pgaudit.log', 'pgaudit.log_parameter')` prüfen. +- **Erweiterungen:** `select * from pg_available_extensions where name in ('pg_trgm', 'unaccent')` muss auf Staging/Produktion beide Zeilen liefern, bevor die Migration `catalog_search` eingespielt wird. diff --git a/supabase/README.md b/supabase/README.md index 6dc0427..04d81f6 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -3,8 +3,9 @@ ## Inhalt | Pfad | Zweck | |---|---| -| `migrations/` | Schema, Trigger und RLS-Policies für R1 (21 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. | +| `migrations/` | Schema, Trigger und RLS-Policies für R1 (22 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. | | `tests/database/` | pgTAP-Tests: Struktur-Regeln und Zugriffsrechte je Bereich. | +| `perf/` | Messskript für die Katalogsuche (kein Test, läuft nicht in `supabase test db`). | | `templates/` | Auth-Mailvorlagen (HTML, Go-Template) für Bestätigung und Passwort-Reset. | | `seed.sql` | Beispieldaten nur für die lokale Entwicklung. | | `catalog/` | Katalog-Vokabular und Startbestand (CSV, Werkzeug `build_startbestand.ts`, erzeugte SQL-Dateien), siehe unten. | @@ -42,6 +43,12 @@ supabase test db # alle pgTAP-Tests ausführen - Ein Pfad in `sql_paths`, der nicht existiert, wird von der CLI nur mit Warnung übersprungen (kein Fehler). Genauere CLI-Ergebnisse: `docs/katalog-pflege.md`, Abschnitt 2.7. - pgTAP-Tests, die Include-Dateien brauchen, nutzen `\ir ../support/…`; die Dateien in `tests/support/` sind erzeugt (gleiches Werkzeug) und keine Tests. +## Katalogsuche (KAT-01) +- `public.search_normalize(text)` (Normalisierung für Suche und Titelvergleich) und die RPC `public.search_catalog_games(…)` sind **für API-Rollen ausführbar** (`search_normalize`: `anon`, `authenticated`, `service_role`, weil generierte Spalten mit den Rechten des Schreibenden rechnen; `search_catalog_games`: `anon`, `authenticated`). Beides ist die Ausnahme von der Regel „Funktionen sind für niemanden aufrufbar“; die Rechte prüft `tests/database/09_catalog_search.test.sql`. +- Alternative Titel (`game_alt_titles`) sind für API-Rollen nur lesbar; Pflege über Studio (`docs/katalog-pflege.md`, Abschnitt 3.1) und die CSV-Spalte `alt_titles` (`de:Titel;en:Title`, `;` im Titel nicht erlaubt). +- Nach einem Image-Upgrade der Datenbank die Normalisierung prüfen (`docs/katalog-pflege.md`, Abschnitt 8). +- Messskript für das Zeitbudget der Suche (nur lokal, nie gegen Staging/Produktion, bewusst nicht unter `tests/`): `perf/catalog_search_perf.sql`, Aufruf im Kopfkommentar. + ## Regeln - Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security. - `updated_at` setzt ein Trigger; `id` und `created_at` sind nach dem Anlegen unveränderlich. From c8bbd10d37ba0ed7cc49986abcb76b2991e8a0c7 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 10:06:46 +0100 Subject: [PATCH 29/30] test(db): pin runbook 8.2 queries and QUE-04 log assumptions in pgTAP (KAT-01, 3a) Adds the unchanged runbook queries (rolconfig, pg_available_extensions, pg_settings) and asserts the settings that keep search terms out of logs. katalog-pflege 8.2 now states that the three log settings depend on each other. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- docs/katalog-pflege.md | 2 +- .../tests/database/09_catalog_search.test.sql | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index f9991bc..dad11e0 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -568,5 +568,5 @@ Bei einer Abweichung die betroffenen Spalten **per neuer Migration** entfernen u - **Ohne Anmeldung aufrufbar:** `search_catalog_games` ist für `anon` ausführbar. Die Eingabegrenzen der Funktion (100 Zeichen, 10 Wörter, höchstens 20 IDs je Filter, Seitengröße 50) begrenzen die Last je Aufruf; zusätzlich gelten die `statement_timeout`-Werte der Rollen `anon` und `authenticated` (lokal 3 s und 8 s; im Self-Hosting prüfen, `select rolname, rolconfig from pg_roles where rolname in ('anon', 'authenticated')`). Ein `set statement_timeout` an der Funktion wirkt nicht. - **Mengenbegrenzung je IP (Entscheidung vor dem Go-live, Aufgabe D1):** Das eigentliche Risiko sind viele parallele Aufrufe ohne Anmeldung. Für R1.1 ist eine Begrenzung kein Muss; vor dem Go-live entscheidet devops, ob das Kong-Plugin `rate-limiting` auf `/rest/v1/rpc/search_catalog_games` oder `limit_req` im vorgelagerten Proxy gesetzt wird, und trägt das in das Hosting-Runbook ein. -- **Suchbegriffe nicht protokollieren (QUE-04):** Die App sendet den Suchbegriff per `POST` im Anfragekörper (nicht in der URL). In den Datenbank-Einstellungen dürfen `log_min_duration_statement`, `log_statement` und `log_parameter_max_length*` Parameterwerte nicht in die Protokolle schreiben; `auto_explain.log_min_duration` und `pgaudit.log_parameter` ebenfalls nicht. Lokal geprüfte Werte und die Abweichungen stehen in der Beschreibung von PR 3a; auf Staging/Produktion mit `select name, setting from pg_settings where name in ('log_min_duration_statement', 'log_statement', 'log_parameter_max_length', 'log_parameter_max_length_on_error', 'auto_explain.log_min_duration', 'pgaudit.log', 'pgaudit.log_parameter')` prüfen. +- **Suchbegriffe nicht protokollieren (QUE-04):** Die App sendet den Suchbegriff per `POST` im Anfragekörper (nicht in der URL); PostgREST reicht ihn als JSON-Parameter `$1` an die Datenbank. Drei Einstellungen müssen **zusammen** geprüft werden, weil sie voneinander abhängen: (1) `log_parameter_max_length_on_error = 0`, sonst stünde der Begriff im Fehlerprotokoll (Fehler wie `search_query_too_long` erscheinen lokal nur mit dem Platzhalter `$1`); (2) `log_min_duration_statement = -1`, `log_statement` höchstens `ddl`, `pgaudit.log = none`; (3) `auto_explain.log_min_duration` (lokal 10 s, mit `auto_explain.log_parameter_max_length = -1`, also **mit** Parameterwerten) muss **größer** sein als das längste `statement_timeout` der Rollen `anon` und `authenticated` (lokal 3 s und 8 s): Eine Suche, die das Zeitlimit erreicht, wird abgebrochen und erzeugt keine auto_explain-Ausgabe. Wird das Zeitlimit von `authenticated` über 10 s angehoben (oder `auto_explain.log_min_duration` gesenkt), landen Suchbegriffe im Protokoll. Prüfung auf Staging/Produktion: `select name, setting from pg_settings where name in ('log_min_duration_statement', 'log_statement', 'log_parameter_max_length', 'log_parameter_max_length_on_error', 'auto_explain.log_min_duration', 'pgaudit.log', 'pgaudit.log_parameter')`. Lokal bestätigt (Aufruf per `curl` gegen Kong, Datenbank- und PostgREST-Protokoll enthalten den Begriff nicht); Hetzner ist **nicht** geprüft. - **Erweiterungen:** `select * from pg_available_extensions where name in ('pg_trgm', 'unaccent')` muss auf Staging/Produktion beide Zeilen liefern, bevor die Migration `catalog_search` eingespielt wird. diff --git a/supabase/tests/database/09_catalog_search.test.sql b/supabase/tests/database/09_catalog_search.test.sql index bb24238..2d293d6 100644 --- a/supabase/tests/database/09_catalog_search.test.sql +++ b/supabase/tests/database/09_catalog_search.test.sql @@ -6,7 +6,7 @@ -- Ohne Suchbegriff wird nur auf Fixture-IDs geprüft (Hilfsfunktion pg_temp.hits blättert durch). -- Die Include-Dateien in ../support/ (\ir) erzeugt supabase/catalog/build_startbestand.ts. begin; -select plan(211); +select plan(217); insert into auth.users (id, email, raw_user_meta_data) values ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), @@ -796,6 +796,28 @@ union all select 'publishers', count(*) from public.publishers $$values ('games'::text, 0::bigint), ('game_alt_titles', 0), ('publishers', 0)$$, 'Runbook 8: die Prüfabfrage meldet bei unverändertem Stand überall 0 Abweichungen'); +-- --------------------------------------------------------------------------------------------- +-- Abschnitt 8.2 des Runbooks: Betrieb (Prüfabfragen unverändert, dazu die Annahmen für QUE-04) +-- --------------------------------------------------------------------------------------------- +select set_eq($$select rolname, rolconfig from pg_roles where rolname in ('anon', 'authenticated')$$, + $$select rolname, rolconfig from pg_roles where rolname in ('anon', 'authenticated') and exists (select 1 from unnest(rolconfig) c where c like 'statement_timeout=%')$$, + 'Runbook 8.2: anon und authenticated haben ein statement_timeout in rolconfig'); +select is((select count(*) from pg_available_extensions where name in ('pg_trgm', 'unaccent')), 2::bigint, + 'Runbook 8.2: pg_trgm und unaccent sind verfügbar (select * from pg_available_extensions …)'); +select is((select count(*) from pg_settings where name in ('log_min_duration_statement', 'log_statement', 'log_parameter_max_length', 'log_parameter_max_length_on_error', 'auto_explain.log_min_duration', 'pgaudit.log', 'pgaudit.log_parameter')), 7::bigint, + 'Runbook 8.2: die sieben Protokoll-Einstellungen der Prüfabfrage sind abfragbar'); +select is((select setting from pg_settings where name = 'log_parameter_max_length_on_error'), '0', + 'QUE-04: bei Fehlern werden keine Parameterwerte protokolliert (Suchbegriffe im JSON-Parameter von PostgREST)'); +select ok((select setting from pg_settings where name = 'log_statement') in ('none', 'ddl') + and (select setting from pg_settings where name = 'log_min_duration_statement') = '-1' + and (select setting from pg_settings where name = 'pgaudit.log') = 'none' + and (select setting from pg_settings where name = 'pgaudit.log_parameter') = 'off', + 'QUE-04: weder Anweisungen mit Parametern (log_statement, log_min_duration_statement, pgaudit) noch langsame Anweisungen werden protokolliert'); +select ok((select max((regexp_match(c, '^statement_timeout=(\d+)s$'))[1]::int) * 1000 + from pg_roles r, unnest(r.rolconfig) c where r.rolname in ('anon', 'authenticated') and c like 'statement_timeout=%') + < (select setting::int from pg_settings where name = 'auto_explain.log_min_duration'), + 'QUE-04: auto_explain (mit Parametern) greift erst nach dem längsten Zeitlimit der API-Rollen, eine abgebrochene Suche erscheint also nie im Protokoll'); + -- Drift simulieren: Die Funktion wird geändert, die gespeicherten Spalten bleiben (wie nach einem Image-Upgrade) create or replace function public.search_normalize(p text) returns text language sql immutable strict parallel safe set search_path = '' as $$ select upper(p) $$; From 9fa91eb684665668b5dee3749eff920fc42a1b30 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 10:39:02 +0100 Subject: [PATCH 30/30] fix(db): single filter set, filter tests with query, perf guards, evidence docs (KAT-01, 3a review) Filters are evaluated once into an id set used by both search branches. Adds tests for age/genre/mechanic filters with a search term, rank 2 vs 3 and null p_limit. approxNormalize uses NFKD. Perf script checks confirm_local value and unix socket and re-analyzes after rollback. Adds docs/specs/3-katalog-suche-umsetzung.md (measurements, log check incl. GET finding), release checklist section 2a and the CSV-only note for alt titles. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- docs/datenmodell-r1.md | 4 +- docs/katalog-pflege.md | 4 +- docs/release-checkliste.md | 12 ++++ docs/specs/3-katalog-suche-umsetzung.md | 72 +++++++++++++++++++ supabase/catalog/build_startbestand.ts | 2 +- supabase/catalog/build_startbestand_test.ts | 1 + .../20261103235947_catalog_search.sql | 31 ++++---- supabase/perf/catalog_search_perf.sql | 24 ++++++- .../tests/database/09_catalog_search.test.sql | 18 ++++- 9 files changed, 145 insertions(+), 23 deletions(-) create mode 100644 docs/specs/3-katalog-suche-umsetzung.md diff --git a/docs/datenmodell-r1.md b/docs/datenmodell-r1.md index 627d3d1..17173b8 100644 --- a/docs/datenmodell-r1.md +++ b/docs/datenmodell-r1.md @@ -396,10 +396,10 @@ Spec: `docs/specs/3-katalog-suche.md`. Die Suche liest ausschließlich über die - **Erweiterungen:** `pg_trgm` und `unaccent` im Schema `extensions` (Bestandteil des Supabase-Postgres-Images; lokal geprüft, auf Staging/Produktion vor dem Merge mit `select * from pg_available_extensions where name in ('pg_trgm','unaccent')` bestätigen). - **Gemeinsame Normalisierung:** `public.search_normalize(text)` ist die einzige Basisfunktion für normalisierte Titel. Sie wird von #3 (PR 3a) angelegt, ist rein rechnend und für `anon`, `authenticated` und `service_role` ausführbar, weil generierte Spalten und `security invoker`-Funktionen sie mit den Rechten des Aufrufers berechnen. #13 legt `public.normalize_game_title(text)` darüber an und entfernt nur dort Artikel; `search_normalize` entfernt nie Artikel. Ablauf: Marken- und Schutzzeichen (`®©™℗℠`) entfernen, Akzente/Umlaute entfernen (`unaccent`, `ß` → `ss`, `Æ` → `ae`), Kleinschreibung, alles außer `a–z`/`0–9` zu einem Leerzeichen, Ränder kürzen, leer = `null`. Beispiele: `'CATAN®'` → `'catan'`, `'Spiel™ ½'` → `'spiel 1 2'`, `'Æon’s End'` → `'aeon s end'`, `'Café Größe'` → `'cafe grosse'`, `'!!!'` → `null`. Nicht-lateinische Zeichen werden entfernt. Ein Suchwort wie „kaese“ findet „Käse“ in R1 nicht (Spec-F5). -- **Generierte Spalten** (gespeichert, nicht beschreibbar): `games.title_search`, `game_alt_titles.title_search`, `publishers.name_search`, je mit Trigramm-Index (GIN; der Index auf `games` ist ein Partial-Index mit den Bedingungen der View `catalog_games`). Die Indizes sind keine Geschwindigkeitszusage, maßgeblich ist die Messung (`supabase/perf/catalog_search_perf.sql`, Ergebnis in der Beschreibung von PR 3a). Wird `search_normalize` oder `unaccent.rules` geändert (Image-Upgrade), müssen die Spalten per Migration neu angelegt werden; Prüfabfrage: `docs/katalog-pflege.md`, Abschnitt 8. +- **Generierte Spalten** (gespeichert, nicht beschreibbar): `games.title_search`, `game_alt_titles.title_search`, `publishers.name_search`, je mit Trigramm-Index (GIN; der Index auf `games` ist ein Partial-Index mit den Bedingungen der View `catalog_games`). Die Indizes sind keine Geschwindigkeitszusage, maßgeblich ist die Messung (`supabase/perf/catalog_search_perf.sql`, Ergebnisse und Pläne: `docs/specs/3-katalog-suche-umsetzung.md`). Wird `search_normalize` oder `unaccent.rules` geändert (Image-Upgrade), müssen die Spalten per Migration neu angelegt werden; Prüfabfrage: `docs/katalog-pflege.md`, Abschnitt 8. - **`manual_fields`:** neuer Schlüssel `alt_titles`. Der Check nennt jetzt 15 Schlüssel (siehe Tabellenstruktur); jede Migration nennt die vollständige Liste. - **Alternative Titel beim Zusammenführen:** `admin_merge_games` hängt `game_alt_titles` nicht um (Spec-F6 A); sie bleiben am ausgeblendeten Quell-Eintrag. Vor dem Zusammenführen ergänzt der Admin relevante Titel am Ziel (`docs/katalog-pflege.md`, Abschnitt 5, Schritt 2b). - **RPC `public.search_catalog_games(p_query, p_players, p_max_minutes, p_age, p_genre_ids, p_mechanic_ids, p_limit default 30, p_offset default 0)`** (`stable`, `security invoker`, leerer `search_path`; ausführbar für `anon` und `authenticated`, nicht für `public` und `service_role`). Vertrag und Semantik: Spec 4.2. Ausgabe je Treffer: `id`, `title`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path` (nur aus `catalog_games`, maskiert nach Quelle), `publisher_names`, `matched_alt_title`, `match_rank` (0 bis 4, ohne Suchbegriff `null`), `total_count`. Umsetzungsdetails, die der Vertrag offen ließ: `null` bei `p_limit`/`p_offset` gilt als Standardwert; „nur Leerraum“ (auch Tabulator, Zeilenumbruch) gilt als kein Suchbegriff; die Ähnlichkeit (zweites Sortierkriterium) ist die höchste Trigramm-Ähnlichkeit über Haupttitel und alle alternativen Titel; bei gleicher Stufe und Ähnlichkeit wird binär (`collate "C"`) nach `title_search`, dann nach `id` sortiert (ohne Suchbegriff ebenso nach `title_search`, dann `id`). Fehler (`P0001`, HTTP 400): `search_query_too_long`, `search_filter_invalid`, `search_paging_invalid`. Ein `smallint`-Parameter außerhalb von −32768…32767 scheitert schon beim Umwandeln (kein `P0001`). - **Rechte der Rolle `catalog_importer`:** Wer `games` schreibt, berechnet `title_search` mit den eigenen Rechten. Der spätere Import (SAM-08) braucht deshalb `execute` auf `public.search_normalize(text)`, `usage` auf das Schema `extensions` und Schreibrechte auf `game_alt_titles`; die Test-Rolle in `06_catalog_foundation.test.sql` bekommt die beiden ersten. -- **Suchbegriffe** werden nicht gespeichert; der Aufruf per `POST` (`client.rpc`) hält sie aus den URLs der Zugriffsprotokolle. Protokoll-Einstellungen der Datenbank: Ergebnis der Prüfung in der Beschreibung von PR 3a. +- **Suchbegriffe** werden nicht gespeichert; der Aufruf per `POST` (`client.rpc`) hält sie aus den URLs der Zugriffsprotokolle. Protokoll-Einstellungen der Datenbank und der Befund zu GET-Aufrufen: `docs/specs/3-katalog-suche-umsetzung.md`, Abschnitt Protokollprüfung. - **API-Änderungen für das Frontend:** neu lesbar: Tabelle `game_alt_titles`, Spalten `games.title_search` und `publishers.name_search` (über `select=*` mitgeliefert, die App liest sie nicht), RPC `search_catalog_games`. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGameSummary` (3b) folgt der Ausgabe oben. diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index be81c14..17e4a48 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -60,7 +60,7 @@ Kopfzeile ist Pflicht, Trennzeichen Komma, Felder mit Komma oder Zeilenumbruch i | `description_locale` | ja | immer `de` | | `hidden` | nein | `ja` = ausblenden; leer = `hidden_at` bleibt unverändert | | `facts_source` | nein | Herkunftsnachweis für SB3; nicht in der Datenbank | -| `alt_titles` | nein | alternative Titel mit Sprache: `de:Titel;en:Title`, Einträge durch `;` getrennt, Sprache `de` oder `en`, am **ersten** `:` getrennt (`de:Catan: Das Duell` ist zulässig). **`;` ist in einem alternativen Titel nicht erlaubt** (kein Escaping; solche Titel im Studio pflegen, siehe 3.1). Das Werkzeug bricht mit Zeilennummer ab bei: Sprache nicht `de`/`en`, Eintrag ohne `:`, leerem oder über 200 Zeichen langem Titel, einem Titel nur aus Sonderzeichen, demselben Titel in derselben Sprache doppelt oder gleich dem Haupttitel (ohne Beachtung von Schreibweise und Sonderzeichen). Eine leere Spalte entfernt früher eingespielte alternative Titel des Spiels (bis zum Go-live). | +| `alt_titles` | nein | alternative Titel mit Sprache: `de:Titel;en:Title`, Einträge durch `;` getrennt, Sprache `de` oder `en`, am **ersten** `:` getrennt (`de:Catan: Das Duell` ist zulässig). **`;` ist in einem alternativen Titel nicht erlaubt** (kein Escaping; solche Titel im Studio pflegen, siehe 3.1). Das Werkzeug bricht mit Zeilennummer ab bei: Sprache nicht `de`/`en`, Eintrag ohne `:`, leerem oder über 200 Zeichen langem Titel, einem Titel nur aus Sonderzeichen, demselben Titel in derselben Sprache doppelt oder gleich dem Haupttitel (ohne Beachtung von Schreibweise und Sonderzeichen). Eine leere Spalte entfernt früher eingespielte alternative Titel des Spiels (bis zum Go-live). **Bis zum Go-live gehören die alternativen Titel von Startbestandsspielen der CSV:** Das nächste Einspielen von `startbestand.sql` löscht alle Titel, die nicht in der Spalte stehen, also auch im Studio ergänzte. | | `base_game_key` | nein | reserviert für KAT-03: `key` des Basisspiels (SB7) | Andere Spalten (Cover, Quelle, BGG-Kennung) lehnt das Werkzeug ab. @@ -170,7 +170,7 @@ Regeln: ### 3.1 Alternative Titel pflegen -Ein Spiel kann zusätzlich zum Haupttitel alternative Titel mit Sprache (`de` oder `en`) haben, zum Beispiel den deutschen und den englischen Titel. Die Suche findet das Spiel auch darüber (KAT-01). Pflege nur über Studio oder die CSV-Spalte `alt_titles` (siehe 2.3); API-Rollen können sie nicht schreiben. Der Trigger trägt `alt_titles` in `games.manual_fields` ein. Ein Titel nur aus Sonderzeichen wird abgelehnt; derselbe Titel in derselben Sprache ist nur einmal möglich, egal in welcher Schreibweise. Beispiel-ID wie in 3: `7a1b2c3d-0000-4000-8000-000000000001`. +Ein Spiel kann zusätzlich zum Haupttitel alternative Titel mit Sprache (`de` oder `en`) haben, zum Beispiel den deutschen und den englischen Titel. Die Suche findet das Spiel auch darüber (KAT-01). Pflege nur über Studio oder die CSV-Spalte `alt_titles` (siehe 2.3); API-Rollen können sie nicht schreiben. **Bei Startbestandsspielen bis zum Go-live nur über die CSV pflegen:** Sonst entfernt das nächste Einspielen von `startbestand.sql` die im Studio angelegten Titel wieder (Abgleich wie bei Verlagen und Genres). Nach dem Go-live (Startbestand eingefroren) und für alle anderen Spiele ist das Studio der Weg. Der Trigger trägt `alt_titles` in `games.manual_fields` ein. Ein Titel nur aus Sonderzeichen wird abgelehnt; derselbe Titel in derselben Sprache ist nur einmal möglich, egal in welcher Schreibweise. Beispiel-ID wie in 3: `7a1b2c3d-0000-4000-8000-000000000001`. **Anlegen oder korrigieren** (ein vorhandener Titel in anderer Schreibweise wird aktualisiert statt doppelt angelegt): diff --git a/docs/release-checkliste.md b/docs/release-checkliste.md index f42f9bf..e19ab63 100644 --- a/docs/release-checkliste.md +++ b/docs/release-checkliste.md @@ -41,6 +41,18 @@ Reihenfolge: Migrationen, `vocabulary.sql`, `startbestand.sql` (Runbook `docs/ka Issue #2 wird erst nach der Abnahme von `checks.sql` auf Produktion (C1) und nach Fertigstellung von #4 (KAT-02) geschlossen, und nur von Chris. +## 2a. Katalogsuche (Spec #3, KAT-01; Belege: `docs/specs/3-katalog-suche-umsetzung.md`) + +| ☐ | Punkt | Wer | Prüfung | +|---|---|---|---| +| ☐ | **Erweiterungen** `pg_trgm` und `unaccent` verfügbar, bevor die Migration `catalog_search` läuft | devops | `select * from pg_available_extensions where name in ('pg_trgm', 'unaccent');` liefert zwei Zeilen | +| ☐ | **`statement_timeout`** der Rollen `anon` und `authenticated` gesetzt (lokal 3 s und 8 s) | devops | `select rolname, rolconfig from pg_roles where rolname in ('anon', 'authenticated');` | +| ☐ | **Protokoll-Einstellungen** halten Suchbegriffe aus den Datenbank-Logs: `log_parameter_max_length_on_error = 0`, `log_min_duration_statement = -1`, `log_statement` höchstens `ddl`, `pgaudit.log = none`, und `auto_explain.log_min_duration` größer als das längste Rollen-Zeitlimit | devops | Abfrage aus `docs/katalog-pflege.md` 8.2; danach eine Suche mit Markerbegriff absetzen und DB-, PostgREST- und Gateway-Logs durchsuchen (Marker darf nicht vorkommen) | +| ☐ | **GET auf die RPC sperren oder aus dem Zugriffsprotokoll halten:** `GET /rest/v1/rpc/search_catalog_games?p_query=…` ist technisch möglich und schreibt den Suchbegriff ins Zugriffsprotokoll des Gateways (lokal bestätigt; die App nutzt POST) | devops | Im Proxy/Kong nur POST auf diese Route zulassen oder Query-String nicht protokollieren; Gegenprobe mit Markerbegriff per GET | +| ☐ | **Prüfabfrage R6** (Normalisierung) nach jedem Image-Upgrade und vor dem Release: überall 0 | devops | Abfrage aus `docs/katalog-pflege.md` 8.1 | +| ☐ | **Krit. 7 gegen Staging** messen (Playwright-Projekt `perf`, 3c) und die Datenbankzeit mit `supabase/perf/catalog_search_perf.sql` nur auf einer lokalen Kopie; Budget p95 ≤ 150 ms in der Datenbank bei 10 000 Spielen | Chris, devops | Ergebnis im Release-PR; lokal war das Budget im ruhigen Lauf erfüllt, auf belastetem Host nicht | +| ☐ | **Mengenbegrenzung je IP** für `search_catalog_games` entschieden (Kong `rate-limiting` oder `limit_req`) | Chris, devops | Entscheidung im Hosting-Runbook | + ## 3. Auth, SMTP und Gateway (Spec #1, D1, Issue #46) Vollständige Liste mit AUTH-CFG-Verweisen: `docs/auth-konfiguration.md` § 5; Gateway-Vorgaben § 3. Release-Blocker: diff --git a/docs/specs/3-katalog-suche-umsetzung.md b/docs/specs/3-katalog-suche-umsetzung.md new file mode 100644 index 0000000..83fcf8e --- /dev/null +++ b/docs/specs/3-katalog-suche-umsetzung.md @@ -0,0 +1,72 @@ +# Spec #3 – KAT-01 Spiele suchen: Umsetzungs-Nachtrag PR 3a (Backend) + +Ergänzt `docs/specs/3-katalog-suche.md` (die Spec selbst wird hier nicht geändert). Stand: 2026-11-04, Branch `feature/3a-katalog-suche-backend`, Migration `20261103235947_catalog_search.sql`, Tests `supabase/tests/database/09_catalog_search.test.sql`. + +## 1. Abweichungen und Präzisierungen zum Vertrag + +- `null` für `p_limit`/`p_offset` gilt als Standardwert (30/0). Nur Leerraum (auch Tab, Zeilenumbruch) gilt als kein Suchbegriff. +- Ein `smallint`-Parameter außerhalb des Typbereichs scheitert beim Umwandeln mit 22003, nicht mit P0001. +- Die Ähnlichkeit (zweites Sortierkriterium) ist das Maximum über Haupttitel und alle alternativen Titel. Sortierung binär (`collate "C"`), dann `id`. +- Rang 0 prüft nur `replace(ts,' ','') = qc` (schließt `ts = q` ein). +- Von mehreren treffenden alternativen Titeln gewinnt die beste Stufe, dann die größte Ähnlichkeit, dann die Schreibweise. Bei gleicher Stufe von Haupt- und alternativem Titel ist `matched_alt_title` null. +- **Stufe 4 trifft auch ohne Verlag:** Jedes Suchwort muss in einem Titel (Haupt- oder alternativer Titel) oder einem Verlagsnamen des Spiels vorkommen. Verteilen sich die Wörter auf Haupt- und alternativen Titel (kein einzelner Titel enthält alle), ist der Treffer ebenfalls Stufe 4, obwohl kein Verlag beteiligt ist. Die Spec nennt als Stufe-4-Fall nur den Verlagstreffer. +- Doppelt eingegebene Wörter zählen einfach. +- **Filter einmal berechnet:** Spielerzahl, Dauer, Alter, Genre und Mechanik werden zu einer ID-Menge ausgewertet, die beide Zweige (mit und ohne Suchbegriff) nutzen. +- **Messskript unter `supabase/perf/`** statt `supabase/tests/perf/`: `supabase-db-tests.sh` und `supabase test db` laufen rekursiv über `tests/` und würden es mitlaufen lassen. +- Testplan: `00_schema` 22 Tabellen; `07`/`08` tragen `game_alt_titles.game_id` als "bewusst ausgelassen" ein; `06` bekam die Prüfabfrage-Zeile `Info-Alt` (statt einer zweiten `Info`) und Grants für die Test-Rolle `catalog_importer`. +- **Für SAM-08:** Die Import-Rolle braucht `execute` auf `public.search_normalize(text)`, `usage` auf das Schema `extensions` und Schreibrechte auf `game_alt_titles`. +- **Performance-Fehler der ersten Fassung:** korrelierte Unterabfragen je Spiel (Rang 4, Filter) brauchten 13 s bei 10 000 Spielen. Behoben durch Mengenoperationen (`word_hits`/`word_cover`, gehashte ID-Menge, `like all`, Ähnlichkeit nur für Treffer). Stufen 1 und 2 (Kandidaten-CTE, `title_compact`) sind nicht gebaut. + +## 2. Messung B5 (lokal, Docker auf macOS, Postgres 17.11) + +Skript `supabase/perf/catalog_search_perf.sql`: 10 000 Katalogspiele, 20 002 alternative Titel, 2 000 Verlage, 12 919 Verlagszuordnungen, je Spiel 2 Genres und 3 Mechaniken; eine Transaktion mit Rollback (danach `vacuum (analyze)`, weil `ANALYZE` nicht zurückrollbar ist); Messung als `anon` mit RLS, `set local role anon`, Generic Plans (ab der 6. Ausführung, wie bei PostgREST-Verbindungen). Das Rollen-`statement_timeout` (3 s) gilt bei `set local role` nicht und wird nicht angewandt; Aufrufe über 3 s wurden gezählt (keiner). + +### 2.1 Zeiten je Fall (Endstand, n = 100 je Fall, 1400 Aufrufe, Host-Last 1,7 bis 2,2) + +| Fall | p50 ms | p95 ms | p99 ms | max ms | +|---|---|---|---|---| +| 01 Titelteil (4 Zeichen) | 70,4 | 77,4 | 84,8 | 115,8 | +| 02 zwei Zeichen | 77,8 | 90,7 | 112,6 | 116,6 | +| 03 ganzes Wort | 70,4 | 75,8 | 78,6 | 82,8 | +| 04 zwei Wörter | 73,5 | 77,9 | 89,0 | 105,2 | +| 05 ohne Leerzeichen | 69,4 | 73,4 | 85,2 | 98,4 | +| 06 Verlag (ein Wort) | 69,8 | 73,1 | 81,9 | 91,8 | +| 06b Verlag + häufiges Wort | 76,6 | 81,3 | 86,6 | 147,7 | +| 07 alternativer Titel | 70,1 | 77,9 | 87,2 | 112,4 | +| 08 Begriff + Filter | 18,6 | 20,5 | 22,6 | 23,4 | +| 09 nur Filter (Spielerzahl, Genre) | 8,5 | 8,7 | 8,8 | 8,9 | +| 10 nur alle Filter | 10,7 | 11,3 | 12,8 | 12,8 | +| 11 ohne alles, erste Seite | 8,9 | 9,4 | 10,4 | 11,4 | +| 12 ohne alles, Versatz 5000 | 10,7 | 12,3 | 13,3 | 13,3 | +| 13 ohne Treffer | 66,3 | 72,3 | 93,7 | 101,3 | +| **alle** | | **78,6** | | 147,7 | + +Budget p95 ≤ 150 ms: erfüllt. Frühere Läufe derselben Fassung (vor dem einmaligen Filter-Set): ruhiger Host gesamt 110 ms (je Fall 77 bis 131 ms); auf einem stark belasteten Host (Last 8 bis 15) je Textfall 190 bis 325 ms, gesamt 215 ms, also über Budget. Das Ergebnis hängt von der CPU-Verfügbarkeit ab. Die Fixkosten sind der Volltext-Scan über rund 30 000 Titel (Suche ohne Treffer: 66 bis 72 ms). **Hetzner/Staging ist nicht gemessen.** + +### 2.2 Pläne (`auto_explain`, `analyze`, `buffers`, als `anon` mit RLS) + +Das Skript gibt für alle 13 Fälle den Plan mit Zeilenzahlen und Buffers als NOTICE aus (`log_timing` aus, weil die Zeitmessung selbst die Zeiten stark verfälscht). Gesamtdauer der Anweisung und Buffers, sechs Fälle: + +| Fall | Dauer der Anweisung | Treffer (Zeilen) | Buffers (shared hit) | Plan | +|---|---|---|---|---| +| 01 Titelteil | 47,7 ms | 30 | 10 102 | Custom Plan | +| 02 zwei Zeichen | 75,8 ms | 30 | 9 795 | Custom Plan | +| 06 Verlag | 75,1 ms | 11 | 177 098 | Generic Plan | +| 08 Begriff + Filter | 21,5 ms | 2 | 26 236 | Generic Plan | +| 10 alle Filter | 13,3 ms | 30 | 14 437 | Generic Plan | +| 13 ohne Treffer | 72,2 ms | 0 | 176 884 | Generic Plan | + +Die Pläne zeigen `Seq Scan` auf `games` (View und Join) und auf `game_alt_titles`; die Trigramm-Indizes werden nicht genutzt (Zweizeichen-Begriffe, Regel ohne Leerzeichen, RLS-Prüfungen für `anon`), wie in Spec 3.2 Nr. 3 vorhergesagt. Die Generic Plans lesen mehr Buffers (RLS-Probe auf `games` je Verlagszeile), bleiben aber in der Zeit gleich. + +## 3. Protokollprüfung (QUE-04) + +Lokale Werte: `log_min_duration_statement = -1`, `log_statement = ddl`, `log_parameter_max_length = -1`, `log_parameter_max_length_on_error = 0`, `pgaudit.log = none`, `pgaudit.log_parameter = off`, `auto_explain.log_min_duration = 10 s` mit `auto_explain.log_parameter_max_length = -1`. + +- **POST über Kong:** Ein Aufruf mit dem Marker `geheimerbegriff9z` (Erfolg und Fehler `search_query_too_long`, `search_paging_invalid`) steht weder im Datenbank- noch im PostgREST-Protokoll. Fehler stehen mit dem Platzhalter `$1` im Protokoll (`STATEMENT: WITH pgrst_source AS (SELECT $1 AS json_data) …`), weil `log_parameter_max_length_on_error = 0`. +- **GET über Kong (Befund der Review, am 2026-11-04 bestätigt):** `GET /rest/v1/rpc/search_catalog_games?p_query=markergetxyz` liefert 200 und steht im **Zugriffsprotokoll des Gateways (Kong)**; das POST-Gegenstück (`markerpostxyz`) nicht. Datenbank-Log in beiden Fällen ohne Marker. Die App nutzt POST; der GET-Weg muss im Proxy gesperrt werden (Release-Checkliste 2a). +- **Abhängigkeit:** Suchbegriffe bleiben nur deshalb aus dem `auto_explain`-Log, weil die Rollen-Zeitlimits (3 s, 8 s) kürzer als 10 s sind und abgebrochene Anweisungen keine `auto_explain`-Ausgabe erzeugen. `log_parameter_max_length_on_error`, `auto_explain.log_min_duration` und die Rollen-Zeitlimits müssen zusammen geprüft werden (pgTAP prüft die lokalen Annahmen; Runbook `docs/katalog-pflege.md` 8.2). +- Hetzner/Staging nicht geprüft. + +## 4. Mutationsprüfung + +Mutationen in einer Kopie der Testdatei (Rollback), nicht am echten Schema: 103 von 107 SQL-Mutationen (RPC, `search_normalize`, Rechte, Tabelle, Check, Indizes) erkannt; Überlebende: ID-Tie-Break innen und außen (der Upstream-Plan liefert bereits ID-Reihenfolge), beide `collate "C"`-Mutationen (lokal ICU-Sortierung, für `[a-z0-9 ]` gleich). Fixtures: 4 von 5 (überlebt: Rohtitel-Vergleich, gleicher Endzustand durch die Semantik datenändernder CTEs). Deno-Werkzeug: 17 von 17. diff --git a/supabase/catalog/build_startbestand.ts b/supabase/catalog/build_startbestand.ts index a1afa85..5a06691 100644 --- a/supabase/catalog/build_startbestand.ts +++ b/supabase/catalog/build_startbestand.ts @@ -285,7 +285,7 @@ export function approxNormalize(s: string): string { .replace(/[®©™℗℠]/g, "") .toLowerCase() .replace(/[ßæœøđłðþ]/g, (c) => FOLD[c]) - .normalize("NFD") + .normalize("NFKD") .replace(/\p{M}+/gu, "") .replace(/[^a-z0-9]+/g, " ") .trim(); diff --git a/supabase/catalog/build_startbestand_test.ts b/supabase/catalog/build_startbestand_test.ts index f096067..dfa0cb5 100644 --- a/supabase/catalog/build_startbestand_test.ts +++ b/supabase/catalog/build_startbestand_test.ts @@ -311,6 +311,7 @@ Deno.test("approxNormalize: Marken- und Schutzzeichen, Akzente, ß, Æ, Sonderze assert.equal(approxNormalize("7 Wonders: Duel"), "7 wonders duel"); assert.equal(approxNormalize("Ticket to Ride – Europe"), "ticket to ride europe"); assert.equal(approxNormalize("!!!"), ""); + assert.equal(approxNormalize("Spiel™ ½"), "spiel 1 2"); // Bruch wie unaccent: NFKD }); Deno.test("SQL alt_titles: Abgleich über normalisierten Titel, Update nur bei geänderter Schreibweise", async () => { diff --git a/supabase/migrations/20261103235947_catalog_search.sql b/supabase/migrations/20261103235947_catalog_search.sql index 70776a2..7616599 100644 --- a/supabase/migrations/20261103235947_catalog_search.sql +++ b/supabase/migrations/20261103235947_catalog_search.sql @@ -159,6 +159,8 @@ declare v_mechanics uuid[]; v_genre_n integer; v_mech_n integer; + v_filtered boolean; + v_ids uuid[]; -- Spiele, die alle gesetzten Filter erfüllen (einmal berechnet) begin -- Eingabegrenzen (Fehler statt stilles Abschneiden) if p_query is not null and char_length(p_query) > 100 then @@ -199,6 +201,19 @@ begin v_genre_n := cardinality(v_genres); v_mech_n := cardinality(v_mechanics); + -- Die Filter werden einmal zu einer ID-Menge ausgewertet; beide Zweige unten nutzen sie. + v_filtered := p_players is not null or p_max_minutes is not null or p_age is not null or v_genre_n > 0 or v_mech_n > 0; + if v_filtered then + v_ids := array(select c.id from public.catalog_games c + where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) + and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) + and (p_age is null or c.min_age <= p_age) + and (v_genre_n = 0 or c.id in (select gg.game_id from public.game_genres gg where gg.genre_id = any (v_genres) + group by gg.game_id having count(*) = v_genre_n)) + and (v_mech_n = 0 or c.id in (select gm.game_id from public.game_mechanics gm where gm.mechanic_id = any (v_mechanics) + group by gm.game_id having count(*) = v_mech_n))); + end if; + if not v_has_query then -- Ohne Suchbegriff: Filter wirken, Sortierung nach Titel, match_rank ist null. return query @@ -214,13 +229,7 @@ begin c.image_path, g.title_search, count(*) over () as total from public.catalog_games c join public.games g on g.id = c.id - where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) - and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) - and (p_age is null or c.min_age <= p_age) - and (v_genre_n = 0 or c.id in (select gg.game_id from public.game_genres gg where gg.genre_id = any (v_genres) - group by gg.game_id having count(*) = v_genre_n)) - and (v_mech_n = 0 or c.id in (select gm.game_id from public.game_mechanics gm where gm.mechanic_id = any (v_mechanics) - group by gm.game_id having count(*) = v_mech_n)) + where (not v_filtered or c.id in (select unnest(v_ids))) order by g.title_search collate "C", c.id limit v_limit offset v_offset ) s @@ -234,13 +243,7 @@ begin c.image_path, g.title_search from public.catalog_games c join public.games g on g.id = c.id - where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) - and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) - and (p_age is null or c.min_age <= p_age) - and (v_genre_n = 0 or c.id in (select gg.game_id from public.game_genres gg where gg.genre_id = any (v_genres) - group by gg.game_id having count(*) = v_genre_n)) - and (v_mech_n = 0 or c.id in (select gm.game_id from public.game_mechanics gm where gm.mechanic_id = any (v_mechanics) - group by gm.game_id having count(*) = v_mech_n)) + where (not v_filtered or c.id in (select unnest(v_ids))) ), -- Kandidaten-Titel: Haupttitel (alt_title null) und alle alternativen Titel cand as ( diff --git a/supabase/perf/catalog_search_perf.sql b/supabase/perf/catalog_search_perf.sql index 0c3e7be..af6df50 100644 --- a/supabase/perf/catalog_search_perf.sql +++ b/supabase/perf/catalog_search_perf.sql @@ -3,13 +3,17 @@ -- -- Was es tut: legt in EINER Transaktion synthetische Daten an (Standard: 10 000 sichtbare Katalogspiele, -- 20 000 alternative Titel, 2 000 Verlage, je Spiel zwei Genres und drei Mechaniken), führt ANALYZE aus und --- misst search_catalog_games ALS anon mit Row Level Security. Am Ende steht ROLLBACK: nichts bleibt zurück. +-- misst search_catalog_games ALS anon mit Row Level Security. Am Ende steht ROLLBACK: die Daten bleiben nicht +-- zurück. Achtung: ANALYZE ist NICHT rollback-fähig, die Planer-Statistiken (pg_statistic) der Tabellen zeigen +-- danach die synthetischen Daten. Deshalb läuft nach dem ROLLBACK ein vacuum (analyze) der betroffenen Tabellen. -- 1. Phase A: je typischer Fall ein Aufruf mit auto_explain (Plan mit Zeiten und Buffers kommt als NOTICE). -- 2. Phase B: je Fall n_iter (Standard 100) Aufrufe mit wechselnden Begriffen, Ausgabe p50/p95/p99/max in ms. -- -- Nur lokal! Nie gegen Staging oder Produktion (die Daten werden zwar zurückgerollt, die Last und die Sperren --- sind es nicht). Schutz: Aufruf nur mit -v confirm_local=yes, und das Skript bricht ab, wenn die Datenbank --- nach echten Daten aussieht (mehr als 20 Konten oder 20 private Spiele). +-- sind es nicht). Schutz in drei Stufen: (1) -v confirm_local=yes (der Wert wird geprüft), (2) die Verbindung muss +-- ein Unix-Socket sein (psql-Variable HOST beginnt mit /, also Aufruf per docker exec im lokalen Container; ein +-- Aufruf über TCP/Host gegen einen Server bricht ab), (3) Abbruch, wenn die Datenbank nach echten Daten aussieht +-- (mehr als 20 Konten oder 20 private Spiele). -- -- Aufruf (lokaler Stack, Docker-Container der Supabase CLI): -- docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes -v n_games=10000 \ @@ -18,10 +22,20 @@ -- den ganzen DO-Block; deshalb zählt die Ausgabe die Aufrufe über 3 s (Spalte ueber_3s). \set ON_ERROR_STOP on \if :{?confirm_local} +\else + \set confirm_local no +\endif +select :'confirm_local' = 'yes' as confirm_ok, coalesce(:'HOST', '') like '/%' as socket_ok \gset +\if :confirm_ok \else \echo 'Abbruch: mit -v confirm_local=yes aufrufen (nur lokal, siehe Kopfkommentar).' \quit \endif +\if :socket_ok +\else + \echo 'Abbruch: nur per Unix-Socket im lokalen Container (docker exec ... psql), nie über TCP gegen einen Server.' + \quit +\endif \if :{?n_games} \else \set n_games 10000 @@ -205,3 +219,7 @@ select 'alle Fälle zusammen' as fall, count(*) as aufrufe, from perf_tmp.r; rollback; + +-- ANALYZE lässt sich nicht zurückrollen: Statistiken der betroffenen Tabellen neu berechnen +vacuum (analyze) public.games, public.game_alt_titles, public.publishers, public.game_publishers, + public.game_genres, public.game_mechanics, public.genres, public.mechanics; diff --git a/supabase/tests/database/09_catalog_search.test.sql b/supabase/tests/database/09_catalog_search.test.sql index 2d293d6..58c9558 100644 --- a/supabase/tests/database/09_catalog_search.test.sql +++ b/supabase/tests/database/09_catalog_search.test.sql @@ -6,7 +6,7 @@ -- Ohne Suchbegriff wird nur auf Fixture-IDs geprüft (Hilfsfunktion pg_temp.hits blättert durch). -- Die Include-Dateien in ../support/ (\ir) erzeugt supabase/catalog/build_startbestand.ts. begin; -select plan(217); +select plan(222); insert into auth.users (id, email, raw_user_meta_data) values ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), @@ -156,6 +156,9 @@ insert into public.games (id, title, visibility) values ('f9000000-0000-0000-0000-000000000092', 'Qxz Zwilling', 'catalog'), ('f9000000-0000-0000-0000-000000000090', 'Qxz Zwilling', 'catalog'), ('f9000000-0000-0000-0000-000000000017', 'Qxz Wanderer', 'catalog'), + -- Stufe 2 (Wortanfang) gegen Stufe 3 (nur Teilzeichenkette "aqxz") + ('f9000000-0000-0000-0000-000000000019', 'Aqxz Bolt', 'catalog'), + ('f9000000-0000-0000-0000-000000000018', 'Das Qxz Bolt', 'catalog'), ('f9000000-0000-0000-0000-000000000080', 'Qxz B', 'catalog'); -- Filter-Fixtures (20-25); 22 ohne jede Angabe, 23 nur max_minutes, 24 nur min_minutes @@ -389,6 +392,15 @@ select results_eq($$select h from pg_temp.hits(p_players := 2::smallint) h order select results_eq($$select h from pg_temp.hits(p_max_minutes := 45::smallint) h order by 1$$, $$values ('f9000000-0000-0000-0000-000000000023'::uuid), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, 'KAT-01 Spec-F2: höchstens 45 Minuten; nur max_minutes oder nur min_minutes bekannt zählt, ohne Angabe fehlt'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_age := 8::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000023'), ('f9000000-0000-0000-0000-000000000024')$$, + 'KAT-01 Kriterium 4: Alter 8 mit Suchbegriff'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1', 'f9000000-0000-0000-0000-0000000000e2']::uuid[]) h$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid)$$, + 'KAT-01 Spec-F1: zwei Genres mit Suchbegriff: nur Spiele mit beiden'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d1', 'f9000000-0000-0000-0000-0000000000d2']::uuid[]) h$$, + $$values ('f9000000-0000-0000-0000-000000000021'::uuid)$$, + 'KAT-01 Spec-F1: zwei Mechaniken mit Suchbegriff: nur Spiele mit beiden'); select results_eq($$select h from pg_temp.hits('qxz filter', p_max_minutes := 60::smallint) h order by 1$$, $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000023'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, 'KAT-01 Kriterium 4: höchstens 60 Minuten (Grenze zählt mit) mit Suchbegriff'); @@ -461,6 +473,10 @@ select is((select count(*) from public.search_catalog_games('qxz seite', null, n 'KAT-01 Kriterium 5: hinter dem Ende keine Zeilen (hasMore false)'); select is((select count(*) from public.search_catalog_games('qxz seite')), 30::bigint, 'KAT-01 Kriterium 5: Standard-Seitengröße 30'); select is((select count(*) from public.search_catalog_games('qxz seite', p_limit := 50)), 40::bigint, 'KAT-01 Kriterium 5: Seitengröße bis 50 erlaubt'); +select is((select count(*) from public.search_catalog_games('qxz seite', p_limit := null, p_offset := null)), 30::bigint, 'KAT-01 Kriterium 5: p_limit null gilt als Standard 30'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz bolt')$$, + $$values ('f9000000-0000-0000-0000-000000000018'::uuid, 2), ('f9000000-0000-0000-0000-000000000019', 3)$$, + 'KAT-01 Kriterium 1: Stufe 2 (Wortanfang) kommt vor Stufe 3 (Teilzeichenkette mitten im Wort)'); select results_eq($$select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 10, 0)$$, $$select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 10, 0)$$, 'KAT-01 Kriterium 5: wiederholter Aufruf liefert dieselbe Reihenfolge');