feat(catalog): Katalogsuche Backend – RPC, Normalisierung, alternative Titel (KAT-01, 3a Backend) #64

Merged
cschlaefke merged 8 commits from feature/3a-katalog-suche-backend into main 2026-11-04 10:04:03 +00:00
10 changed files with 1631 additions and 9 deletions
Showing only changes of commit 354206c766 - Show all commits

View file

@ -4,6 +4,7 @@ argument-hint: <version, z. B. 0.3.0>
---
Bereite Release $ARGUMENTS vor:
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

View file

@ -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 |

View file

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

View file

@ -9,7 +9,7 @@ Stand: PR 3a (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen
## 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`. 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

122
docs/release-checkliste.md Normal file
View file

@ -0,0 +1,122 @@
# 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. **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.
**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 |
| ☐ | **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: 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 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; `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:** `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.
## 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` 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://<domain>/auth/v1/token?grant_type=password` mit Header `-H "apikey: <anon-key>"` (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://<domain>/auth/v1/otp -H "apikey: <anon-key>" -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://<domain>/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, 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 `<Site-URL>/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 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, 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://<studio-adresse>/` 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 <server-ipv4> 5432` und `nc -6zv -w 5 <server-ipv6> 5432` schlagen fehl. Zusätzlich allgemeiner Port-Scan der öffentlichen Adressen (z. B. `nmap -Pn -p- <server-ip>`, 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 <server-ip> 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://<domain>/pg/tables -H "apikey: <anon-key>"` 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 |
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 (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 und Runner pausieren (B.3) |
| ☐ | **B.1** Erreichbarkeit des dind aus `docker`-Jobs prüfen | Chris | `curl -s -m 3 http://<Default-Gateway>: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 |
## 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<version>` 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)
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. 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)
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).

View file

@ -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.

View file

@ -0,0 +1,511 @@
# Spec #13 – KAT-04 Fehlendes Spiel melden
| | |
|---|---|
| Issue | #13 (KAT-04) |
| Status | Entwurf, Review-Befunde eingearbeitet; wartet auf Freigabe durch Chris und Antworten zu Spec-F1 bis Spec-F9 (§ 9.2). **Spec-F6 muss vor dem Merge von PR #57 (2c) entschieden werden.** |
| Autor | architect |
| Stand | 2026-11-03 |
| Etappe | R1.2 Sammlung |
| Abhängigkeiten | **Vorbedingung 13a:** #2 PR 2b und 2c gemergt (Spec #2 E12: `game_reports`, `admin_resolve_report`, `repoint_game_references`, Trigger `games_visibility_immutable`); #3 PR 3a gemergt (`search_normalize`, `game_alt_titles`, `title_search`, `pg_trgm`, `unaccent`). **Vorbedingung 13b (Merge):** #3 PR 3b gemergt (`onReportGame` im Leerzustand, Krit. 6 von KAT-01). Außerdem #1. Fachlich zusätzlich #6 SAM-01 und #4 KAT-02 (Orte für „in die Sammlung“ und den Hinweis „am Eintrag“), in Forgejo bisher **nicht** als Abhängigkeit gesetzt (Spec-F8). |
| Quellen | Issue #13 inkl. Kommentare (u. a. Hinweis aus PR #51 vom 2026-11-03), `requirements/20-r1-akzeptanzkriterien.md` § KAT-04 und § Querschnitt, `requirements/r1-klaerungen.md` (6, 10, 12, 15, 20, 23, 26, 27, 28, 36, 44/25, 45, N12, N12a, N15), `requirements/backlog.md`, `docs/specs/2-katalog-daten.md` (E10–E14, 3.2–3.4, 4.4, 9.5), Spec #3 (3.2: `search_normalize`, `title_search`, `game_alt_titles`), `docs/datenmodell-r1.md`, `supabase/migrations/…catalog.sql`, `…collection.sql`, `…plays.sql`, `…catalog_foundation.sql`, CLAUDE.md |
**Namenskonvention:** „Spec-F1“ bis „Spec-F9“ sind die Fragen an Chris aus § 9.2. „E1“ usw. sind Kernentscheidungen dieser Spec, „#2 E12“ verweist auf Spec #2. „B1“, „F1“, „U1“, „T1“ sind Aufgaben (§ 8).
## 1. Ziel
Ein Nutzer, der ein Spiel im Katalog nicht findet, meldet es mit wenigen Angaben und kann es **sofort** als vorläufigen privaten Eintrag nutzen (Entscheidung O5). Der Admin (Chris) prüft jede Meldung manuell im Studio (Klärungen 27, 44; Werkzeug aus #2 PR 2c). Das Ergebnis sieht der Nutzer als Hinweis in der App, nie als E-Mail (Klärungen 26, 45).
Diese Spec baut auf `game_reports` und `admin_resolve_report` aus Spec #2 § 3.4 (PR 2c) und auf der Normalisierung aus Spec #3 auf. Sie ergänzt nur, was der **Nutzer** braucht: Anlegen mit Tageslimit, Ähnlichkeitsprüfung, vorläufigen Eintrag, Ergebnishinweise und das Wechseln auf den Zieleintrag.
**Nicht im Scope** (aus dem Issue): öffentliche Anzeige gemeldeter Spiele, Bild-Upload, E-Mail/Push, Admin-Oberfläche in der App, automatische Import-Meldungen (SAM-08 #25). Außerdem nicht: Bearbeiten weiterer Felder des vorläufigen Eintrags, Zurückziehen einer Meldung, Auswahl vorläufiger Spiele in Partien (PAR-01 #15 Krit. 3).
### Kernentscheidungen (Kurzfassung)
| # | Entscheidung | Begründung |
|---|---|---|
| E1 | **Jede Nutzer-Meldung legt genau einen vorläufigen privaten Eintrag an** (1:1; der eindeutige Teilindex auf `provisional_game_id` kommt aus 2c). Er bekommt nur `title` und `year`. | Der Hinweis braucht einen festen Ort, Krit. 3 verlangt die sofortige Nutzung; entspricht Backlog (N12) und Import (SAM-08 Krit. 4). Mit nur Titel und Jahr kann außer der Edition nichts verloren gehen (Krit. 4, N15). |
| E2 | **Anlegen über die RPC `report_missing_game` (security invoker)**: Limit-Vorprüfung, Ähnlichkeitsprüfung, vorläufiger Eintrag, Meldung, optional Listeneintrag in einer Transaktion. | Getrennte Client-Aufrufe ließen verwaiste vorläufige Spiele zurück. `security invoker`: RLS, Spaltenrechte und Trigger gelten weiter. |
| E3 | **Schutz liegt in der Datenbank:** Spaltenrechte auf `game_reports`, Insert-Policy, Limit-Trigger mit Sperre je Nutzer. | Ein direkter REST-Insert umgeht nur den UX-Schritt der Ähnlichkeitsprüfung, nicht Limit oder Rechte. `created_at` ist nicht schreibbar, das Limit lässt sich nicht durch ein falsches Datum umgehen. |
| E4 | **Ähnlichkeitsprüfung serverseitig** über `find_similar_catalog_games` mit `extensions.similarity`. Grundlage sind die von #3 gepflegten Spalten `title_search` (Haupttitel in `games`, alternative Titel in `game_alt_titles`); 13a entfernt darüber nur führende Artikel (`strip_leading_article`, `normalize_game_title`). Nur sichtbare Katalogspiele. | Klärung 15 ist eine Datenbankregel. Eine einzige Normalisierung für Suche und Meldung (gemeinsamer Absatz in 3.3). Die RPC prüft beim Absenden noch einmal; ohne Bestätigung wird nicht gespeichert (Krit. 2). |
| E5 | **„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`, 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` kommt aus 3a (Spec #3 E14). | CLAUDE.md, ADR-0002. |
| E10 | **Zwei PRs:** 13a Backend (`Refs #13`), 13b Flutter (`Closes #13`). 13b beginnt gegen ein gemocktes Repository, sobald der RPC-Vertrag (4.2–4.5) freigegeben ist. | Der Vertrag steht vollständig in dieser Spec. |
## 2. Akzeptanzkriterien
Übernommen aus Issue #13 / `20-r1-akzeptanzkriterien.md` § KAT-04 (Stand 2026-11-02 inkl. Klärungen und N12). Referenz in Tests und PRs: „KAT-04 Kriterium n“.
1. Gegeben ich finde ein Spiel nicht, wenn ich Titel (Pflicht), Verlag, Erscheinungsjahr und optional einen Link angebe, dann wird die Meldung gespeichert und ich erhalte eine Bestätigung.
2. Gegeben es gibt schon einen sehr ähnlichen Katalogeintrag, wenn ich melden will, dann sehe ich ihn zuerst und muss ausdrücklich bestätigen, dass mein Spiel ein anderes ist. „Sehr ähnlich“ heißt: gleicher normalisierter Titel (Groß-/Kleinschreibung, Sonderzeichen und Artikel ignoriert) oder Titel-Ähnlichkeit von mindestens 0,8 (Trigramm) bei Erscheinungsjahr ± 1 (Klärung 15).
3. Gegeben ich habe ein Spiel gemeldet, dann kann ich es als vorläufigen Eintrag in meine Sammlung oder Listen aufnehmen; der Eintrag ist nur für mich sichtbar.
4. Gegeben die Meldung wurde freigegeben, dann ersetzt der Katalogeintrag meinen vorläufigen Eintrag, ohne dass meine eigenen Angaben verloren gehen, und ich sehe das als Hinweis in der App am Eintrag (Klärung 26).
5. Gegeben die Meldung wurde mit einem anderen Eintrag zusammengeführt oder unter Verweis auf einen vorhandenen Eintrag abgelehnt, dann sehe ich das als Hinweis in der App am vorläufigen Eintrag (keine E-Mail), und mein vorläufiger Eintrag verweist auf den richtigen Eintrag (Klärungen 26, 45).
6. Gegeben die Meldung wurde ohne Zieleintrag abgelehnt, dann bleibt mein vorläufiger Eintrag privat bestehen und ist als „abgelehnt“ markiert (Klärung 26).
7. Gegeben ich habe heute schon 10 Meldungen abgeschickt, wenn ich eine weitere melden will, dann wird sie nicht gespeichert und ich sehe einen Hinweis auf das Tageslimit (Klärung 27). Meldungen, die der BGG-Import automatisch anlegt (SAM-08 Kriterium 4), zählen nicht auf dieses Limit (N12).
8. Gegeben ich habe eine Meldung abgeschickt, dann bleibt sie im Status „offen“, bis der Admin sie manuell bearbeitet; eine automatische Freigabe gibt es nicht (Klärungen 6, 27, 44).
**Abweichung bei Krit. 4 (Klärung N15, entschieden von Chris am 2026-11-03):** Beim Ersetzen bleibt die **Edition** nur erhalten, wenn der Katalogeintrag eine gleichnamige Variante hat; sonst wird sie an Sammlungseintrag und Partien geleert, und die App fordert auf, sie neu zu wählen (E7). Alle anderen eigenen Angaben (Liste, Notiz, Kaufdatum, Angaben aus SAM-02/SAM-04, Partien) bleiben erhalten (`repoint_game_references`, 2b).
**Lesart von Krit. 2 (Klärung 15):** „Erscheinungsjahr ± 1“ gehört nur zur Trigramm-Regel; gleicher normalisierter Titel gilt unabhängig vom Jahr. Fehlt ein Jahr, siehe Spec-F2.
**Lesart von Krit. 1:** Nur der Titel ist Pflicht; Verlag und Jahr sind optional (Spec-F1).
**Relevante Querschnittskriterien**
- **Sicherheit der Daten Krit. 1 und 3:** Meldungen und vorläufige Einträge sieht nur der Meldende (und der Admin im Studio). pgTAP prüft fremden Zugriff auf `game_reports`, vorläufige Spiele und die neuen RPCs.
- **Sicherheit der Daten Krit. 2:** `anon` kann nichts melden und keine Meldungen lesen.
- **Datenschutz (QUE-04):** Meldungen und vorläufige Einträge werden mit dem Konto hart gelöscht (Klärung 10) und gehören in den Export (KTO-05 #23, Klärung 20). Freigegebene Katalogeinträge übernehmen nur Fakten aus den Meldungsfeldern (Runbook aus 2c). Der Link wird in der App nicht geöffnet und nicht vorgeladen.
- **Sprachen (QUE-05) Krit. 2, 4:** alle Texte aus ARB (de/en).
- **Barrierefreiheit (QUE-06) Krit. 1–4:** Feldbeschriftungen, Status mit Text (nicht nur Farbe), Touch-Ziele ≥ 48 dp, `textScaler` 2.0, Tastaturbedienung im Web inkl. Bestätigungsschritt.
- **Zustände, Fehler und Netz Krit. 1–3:** „Meine Meldungen“ mit Lade-, Leer-, Fehler- und Datenzustand; offline Hinweis statt Fehler; bei Limit- oder Netzfehler bleiben die Eingaben erhalten.
- **Plattformen Krit. 1, 2:** identisch auf iOS, Android und Web; breites Layout im Web.
## 3. Datenmodell
### 3.1 Ausgangslage (nach #2 PR 2c und #3 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). 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 PR 3a: `search_normalize(text)`, die generierten Spalten `games.title_search` und `game_alt_titles.title_search`, die Tabelle `game_alt_titles` sowie die Erweiterungen `pg_trgm` und `unaccent`. Der Fremdschlüssel `game_alt_titles.game_id` und seine Einordnung im Fremdschlüssel-Wächter gehören zu #3.
### 3.2 Änderungen in 13a (Migration `supabase/migrations/<ts>_game_reports_user.sql`)
**Vorbedingung 13a:** #2 PR 2b und 2c gemergt; #3 PR 3a gemergt (`search_normalize`, `game_alt_titles`). Nur additiv; der Zeitstempel liegt nach diesen Migrationen. `pg_trgm` und `unaccent` legt Spec #3 (3a) an; 13a nutzt `extensions.similarity`. Rechte-Regel aus Spec #2 § 3.2: jede neue Funktion zuerst `revoke execute … from public, anon, authenticated, service_role`, danach nur die Grants aus 4.1.
**Neue Spalte in `game_reports`**
| Spalte | Typ | Hinweis |
|---|---|---|
| `result_seen_at` | timestamptz | nullable; der Nutzer hat den Ergebnishinweis weggeklickt (E6, Spec-F5). Check `result_seen_at is null or status <> 'open'`. |
Bei Spec-F6 = Zähler (Empfehlung) legt **2c** die Spalte `editions_cleared integer not null default 0 check (editions_cleared >= 0)` an (Nachtrag Spec #2 § 3.4 / PR #57), nicht 13a.
**Rechte auf `game_reports`** (E3)
```
grant insert (owner_id, title, publisher_name, year, link, provisional_game_id)
on public.game_reports to authenticated;
grant update (result_seen_at) on public.game_reports to authenticated;
```
**Bewusste Änderung gegenüber Spec #2 § 3.4:** Dort haben Nutzer kein Insert- und kein Update-Recht, und `08_game_reports.test.sql` prüft, dass nur `postgres` Meldungen ändert. 13a erlaubt das Anlegen eigener Meldungen und das Ändern genau einer Spalte (`result_seen_at`) an **eigenen**, bereits bearbeiteten Meldungen. Das bleibt mit KAT-05 Krit. 9 vereinbar (verboten ist das Ändern **fremder** Meldungen); zu den Tests aus `08` siehe § 7 „Bestehende Tests“.
`select` bleibt wie in 2c. **Nicht** schreibbar für Nutzer: `id`, `origin`, `status`, `bgg_id`, `target_game_id`, `resolved_at`, `created_at`, `updated_at` (42501). Damit setzt die Datenbank `origin = 'user'`, `status = 'open'` (Krit. 8) und `created_at = now()` (Tageslimit) selbst. `delete` bekommt der Nutzer nicht.
**Merkpunkt für den Client (aus #2 PR 2a):** Clients schreiben `games` und `game_reports` nie mit `id`, `created_at` oder `updated_at`, und ein `toJson()` eines Modells geht nie ungefiltert an `insert`/`update`. Die App schreibt in #13 nur über die RPCs (4.2, 4.4) und das Update von `result_seen_at` (4.5) mit ausdrücklicher Spaltenliste.
**RLS in Klartext (neu in 13a)**
- **Anlegen (`game_reports_insert_own`, `for insert to authenticated`):** `owner_id = auth.uid()`, `origin = 'user'`, `status = 'open'`, `target_game_id is null` und **`provisional_game_id is not null`**, und dieser zeigt auf ein eigenes privates, nicht ausgeblendetes und nicht zusammengeführtes Spiel (`exists (select 1 from games g where g.id = provisional_game_id and g.visibility = 'private' and g.owner_id = auth.uid() and g.hidden_at is null and g.merged_into_id is null)`). Ein zweiter Insert mit demselben vorläufigen Eintrag scheitert am Teilindex aus 2c (23505); `report_missing_game` bildet das auf `report_provisional_in_use` ab, die App auf „unbekannter Fehler“.
- **Ändern (`game_reports_update_seen`, `for update to authenticated`):** `using (owner_id = auth.uid() and status <> 'open')`, `with check` gleich. Über die Spaltenrechte ist nur `result_seen_at` änderbar.
- **Lesen:** unverändert aus 2c (nur eigene). **Löschen:** kein Recht.
- **Vorläufige Spiele:** unverändert; der Ersteller sieht sie auch ausgeblendet oder zusammengeführt (wichtig für die Ableitung in E7). Er kann sie über `games_delete_private` löschen, solange nichts darauf verweist; dann wird `provisional_game_id` per `on delete set null` leer (E6, 5.4).
**Trigger `game_reports_daily_limit`** (Krit. 7)
- `create trigger … before insert on public.game_reports for each row when (new.origin = 'user') execute function public.game_reports_daily_limit()`.
- Sperre gegen gleichzeitige Inserts: `perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0))`. **Setzt die Isolationsstufe READ COMMITTED voraus** (Standard bei PostgREST und Supabase): Die Zählung nach der Sperre sieht dann die inzwischen bestätigten Meldungen der anderen Transaktion. Unter REPEATABLE READ oder SERIALIZABLE wäre die Sperre wirkungslos, weil der Snapshot vor der Sperre feststeht.
- Zählt `origin = 'user'` und `owner_id = new.owner_id` mit `public.report_day(created_at) = public.report_day(now())`. Bei ≥ 10 Fehler `report_daily_limit` (P0001). Die Zahl 10 ist eine Konstante in der Funktion; Änderung = neue Migration. 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).
**Keine neue Tabelle, kein neuer Fremdschlüssel auf `games`.** Die Tabellenzahl in `00_schema.test.sql` bleibt gegenüber `main` unverändert; der Fremdschlüssel-Wächter aus #2 bleibt durch 13a unberührt.
### 3.3 Normalisierung und „sehr ähnlich“ (Krit. 2, Klärung 15)
> **Gemeinsame Normalisierung (gleichlautend in Spec #3 und Spec #13):** `public.search_normalize(text)` ist die einzige Basisfunktion für normalisierte Titel. Sie wird von #3 (PR 3a) angelegt, ist rein rechnend und fü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.
`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)))`.
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 (Kandidatentitel `c`, **verpflichtend**):
- **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.
## 4. Backend
### 4.1 Überblick und Rechte
| Objekt | Art | `execute` | Zweck |
|---|---|---|---|
| `strip_leading_article(text)`, `normalize_game_title(text)` | Funktion, sql, immutable | `authenticated` | Normalisierung (3.3) |
| `report_day(timestamptz)` | Funktion, sql, immutable | `authenticated` | Berliner Kalendertag (3.2) |
| `find_similar_catalog_games(p_title text, p_year integer)` | RPC, security invoker | `authenticated` | Ähnliche Katalogspiele (Krit. 2) |
| `report_missing_game(...)` | RPC, security invoker | `authenticated` | Meldung + vorläufiger Eintrag + optional Listeneintrag (Krit. 1, 2, 3, 7, 8) |
| `adopt_report_target(p_report uuid)` | RPC, **security definer**, Eigentümer `postgres` | `authenticated` | Wechsel auf den Zieleintrag (Krit. 5) |
| `game_reports_daily_limit()` | Trigger-Funktion, security invoker | – | Tageslimit (Krit. 7) |
| Policies `game_reports_insert_own`, `game_reports_update_seen`, Spaltenrechte | RLS | – | 3.2 |
Jede Funktion: zuerst `revoke execute … from public, anon, authenticated, service_role`, dann nur die Grants dieser Tabelle. pgTAP prüft mit `has_function_privilege` für **alle vier Rollen** (`public` über eine Rolle ohne eigene Rechte, `anon`, `authenticated`, `service_role`).
Fehler: `raise exception '<code>' using errcode = 'P0001'`; die App bildet den Code auf einen Domänenfehler ab (5.3).
### 4.2 RPC `public.report_missing_game`
```
report_missing_game(
p_title text,
p_publisher_name text default null,
p_year integer default null,
p_link text default null,
p_list text default null, -- 'owned' | 'wishlist' | 'shopping' | null
p_confirmed_distinct boolean default false
) returns jsonb -- {"report_id": uuid, "provisional_game_id": uuid, "collection_item_id": uuid | null}
```
`language plpgsql`, `security invoker`, `search_path = ''`. `p_year` ist bewusst `integer` und wird selbst geprüft (ein `smallint`-Parameter würde bei großen Werten schon beim Aufruf mit 22003 scheitern, ohne verständlichen Code).
Ablauf (eine Transaktion; jeder Fehler rollt alles zurück):
1. `auth.uid()` ist null → `not_authenticated`.
2. `btrim` auf Titel, Verlag, Link; leere Zeichenketten werden `null`.
3. Prüfungen in dieser Reihenfolge:
| Prüfung | Fehler |
|---|---|
| Titel leer | `report_title_required` |
| Titel > 200 Zeichen | `report_title_too_long` |
| Verlag > 200 Zeichen | `report_publisher_too_long` |
| Jahr außerhalb 1800–2100 | `report_year_invalid` |
| Link > 500 Zeichen oder nicht `^https?://` | `report_link_invalid` |
| `p_list` nicht in `owned`, `wishlist`, `shopping`, null | `report_list_invalid` |
| **Vorprüfung Tageslimit:** schon 10 eigene Nutzer-Meldungen heute (`report_day`) | `report_daily_limit` |
| `p_confirmed_distinct = false` und `find_similar_catalog_games` liefert ≥ 1 Treffer | `report_similar_exists` |
Die Limit-Vorprüfung steht **vor** der Ähnlichkeitsprüfung, damit der Nutzer nicht erst Kandidaten bestätigt und dann am Limit scheitert. Maßgeblich bleibt der Trigger (Sperre, Schritt 5). Die übrigen Prüfungen spiegeln die Checks aus 2c für gezielte Feldfehler; Tabellen-Checks bleiben die Grenze (23514 → „unbekannter Fehler“).
4. `insert into public.games (owner_id, title, year, visibility) values (auth.uid(), title, year, 'private') returning id` (keine `id`).
5. `insert into public.game_reports (owner_id, title, publisher_name, year, link, provisional_game_id) … returning id`. Hier greift der Limit-Trigger; 23505 aus dem Teilindex → `report_provisional_in_use`.
6. Wenn `p_list` gesetzt: `insert into public.collection_items (owner_id, game_id, list) values (auth.uid(), provisional_id, p_list) returning id`. Keine Edition.
7. Rückgabe als `jsonb`.
Aufrufreihenfolge in der App: erst `find_similar_catalog_games`, dann `report_missing_game` mit `p_confirmed_distinct = true` nach Bestätigung. Ein Client, der direkt `true` sendet, umgeht nur den UX-Schritt (E3).
### 4.3 RPC `public.find_similar_catalog_games`
```
find_similar_catalog_games(p_title text, p_year integer default null)
returns table (
game_id uuid,
title text, -- Haupttitel
matched_title text, -- Titel, der getroffen hat (Haupt- oder alternativer Titel)
year smallint,
publishers text[], -- sortiert
match_kind text, -- 'same_title' | 'similar_title'
similarity real -- 1.0 bei 'same_title'
)
```
`language sql`, `stable`, `security invoker`, `search_path = ''`. Liest `catalog_games` 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`
```
adopt_report_target(p_report uuid) returns jsonb -- Zähler aus repoint_game_references
```
`language plpgsql`, `security definer`, Eigentümer `postgres`, `set search_path = ''` (als Funktionseinstellung). Nötig, weil Nutzer `merged_into_id`/`hidden_at` nicht schreiben und `repoint_game_references` nicht ausführen dürfen. **Innerhalb der Funktion ist `current_user = postgres`;** deshalb darf sie keine `admin_*`-Funktion aufrufen (deren Rollenprüfung würde sonst bestehen), weder jetzt noch in späteren Änderungen. pgTAP prüft `prosecdef = true`, `proowner = postgres` und `proconfig` mit `search_path=""`.
**Sperrreihenfolge** (wie im 2c-Entwurf von `admin_resolve_report`, gegen Verklemmungen): zuerst die Meldung `select … for update`, danach beide Spiele `select … from games where id in (provisional, target) order by id for update`.
| Prüfung | Fehler |
|---|---|
| `auth.uid()` ist null | `not_authenticated` |
| Meldung existiert nicht oder gehört nicht `auth.uid()` (gleicher Fehler) | `report_not_found` |
| `status <> 'rejected'` oder `target_game_id is null` | `report_not_adoptable` |
| `provisional_game_id is null` (z. B. vom Nutzer gelöscht) | `report_no_provisional` |
| vorläufiger Eintrag ist nicht `private` mit `owner_id = auth.uid()` | `report_not_found` |
| vorläufiger Eintrag ist schon zusammengeführt | `report_already_adopted` |
| Ziel ist kein sichtbares Katalogspiel (`visibility = 'catalog' and hidden_at is null and merged_into_id is null`) | `report_target_not_listed` |
| Verweise auf den vorläufigen Eintrag mit fremdem oder fehlendem Eigentümer: `collection_items.owner_id`, `favorite_games.profile_id`, `plays.owner_id`, `score_sheets.owner_id` jeweils `is distinct from auth.uid()` (null zählt als fremd) | `report_foreign_references` |
Ablauf: `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`.
### 4.5 Ergebnishinweis wegklicken
Kein RPC. Die App sendet `PATCH /rest/v1/game_reports?id=eq.<id>` mit genau `{'result_seen_at': <aktuelle Zeit, ISO-8601 UTC>}` (PostgREST setzt Werte, keine Ausdrücke wie `now()`). Die Client-Zeit ist unkritisch (steuert nur die eigene Anzeige). Bei offener oder fremder Meldung ändert sich keine Zeile.
### 4.6 Lesen für die App
- **Meine Meldungen:** `game_reports` mit `id, origin, title, publisher_name, year, link, status, target_game_id, provisional_game_id, result_seen_at, created_at, resolved_at` (bei Spec-F6 = Zähler zusätzlich `editions_cleared`), `created_at desc`, je 20. Eingebettet: `provisional:games!provisional_game_id(id, merged_into_id, hidden_at, game_variants(id))`, `target:games!target_game_id(id, title, year)`. Fremdschlüsselnamen prüft flutter-dev gegen die 2c-Migration.
- **Hinweis zu einem Spiel** (5.4): `game_reports` mit `or=(provisional_game_id.eq.<id>,target_game_id.eq.<id>)` (Indizes aus 2c) plus eingebettetem `provisional`.
- **Edition, nur bei Rückfall Ableitung:** `collection_items` des Nutzers zum Zielspiel mit `list = 'owned'` (`variant_id`) und `provisional.game_variants`.
### 4.7 Doku
- `docs/datenmodell-r1.md`: `result_seen_at`, Spaltenrechte, Policies, Trigger, Funktionen, Abschnitt „Meldungen (KAT-04)“ mit Ablauf und Fehlercodes; 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
### 5.1 Struktur
Neues Feature `app/lib/features/game_reports/` (Feature-first, Abhängigkeiten nach innen):
```
features/game_reports/
domain/
game_report.dart freezed: GameReport
game_report_status.dart enum GameReportStatus { open, approved, rejected, merged }
game_report_origin.dart enum GameReportOrigin { user, import }
report_outcome.dart sealed ReportOutcome (5.4) + reine Funktion outcomeOf(...)
game_report_draft.dart freezed: Eingaben + Validierung
similar_game.dart freezed: SimilarGame (gameId, title, matchedTitle, year, publishers, matchKind)
game_report_failure.dart sealed: dailyLimit, similarExists, validation(field, code), notAdoptable,
noProvisional, alreadyAdopted, targetNotListed, offline, unknown
game_report_repository.dart abstrakte Schnittstelle
data/
supabase_game_report_repository.dart RPC-Aufrufe und Abfragen (4.2–4.6), explizite Parameter-Maps
game_report_dto.dart nur fromJson; kein toJson für Schreibzugriffe
game_report_failure_mapper.dart P0001-Codes → GameReportFailure
presentation/
report_form/ similar_games/ my_reports/ report_detail/
widgets/report_outcome_notice.dart wiederverwendbar (auch für #4/#6/#11)
providers/ Repository-, Listen- und Hinweis-Provider (Riverpod, generiert)
game_report_routes.dart
```
Die Domäne importiert weder Flutter noch Supabase; Validierung in `GameReportDraft`, nicht im Widget.
### 5.2 Routen (go_router)
| Route | Screen | Hinweis |
|---|---|---|
| `/reports/new?title=<suchbegriff>` | `ReportFormScreen` | Einstieg aus der leeren Trefferliste von KAT-01 (`onReportGame`, #3 PR 3b, Krit. 6); Anmeldung nötig (Redirect `/login?from=…`, Spec #1) |
| `/reports` | `MyReportsScreen` | eigene Meldungen |
| `/reports/:reportId` | `ReportDetailScreen` | Status, Angaben, Hinweis, Aktionen; fremde/unbekannte ID → „nicht gefunden“ |
Konstanten in `AppRoutes`. Weitere Einstiege zu `/reports` (Einstellungen, Dashboard) stimmt ui-designer mit Chris ab.
### 5.3 Melden (Krit. 1, 2, 3, 7, 8)
`ReportFormController` (Riverpod-Notifier, Zustände als freezed-Union):
| Zustand | UI |
|---|---|
| `editing(draft, fieldErrors)` | Titel (Pflicht), Verlag, Jahr, Link (optional); „Gleich aufnehmen in“: Sammlung / Wunschliste / Einkaufsliste / nicht aufnehmen (Standard Sammlung, Spec-F9) |
| `checking` | `find_similar_catalog_games` läuft; Ladeanzeige, Felder gesperrt |
| `similarFound(draft, candidates)` | Kandidaten (Titel, getroffener Titel falls alternativ, Jahr, Verlage, `GameCover`). „Das ist mein Spiel“ → Detailseite des Katalogspiels (#4), keine Meldung. Ausdrückliche Bestätigung: Checkbox „Mein Spiel ist keines davon“ + „Trotzdem melden“ (erst mit Haken aktiv) |
| `submitting` | `report_missing_game` läuft |
| `submitted(result)` | Bestätigung (Krit. 1): „Danke, deine Meldung ist eingegangen. Wir prüfen sie von Hand; bis dahin kannst du das Spiel als vorläufigen Eintrag nutzen.“ ggf. Liste; Aktionen „Zur Meldung“, „Zur Sammlung“ |
| `failure(draft, failure)` | Eingaben bleiben erhalten. `dailyLimit` → „Du hast heute schon 10 Spiele gemeldet. Morgen geht es wieder.“ (Krit. 7); `similarExists` → zurück zu `similarFound`; Feldfehler am Feld; offline → „Melden braucht eine Verbindung“ |
Ohne Treffer geht `checking` direkt in `submitting` (`p_confirmed_distinct = false`), mit Treffern nach Bestätigung mit `true`. Lokale Vorprüfung derselben Feldregeln wie 4.2; das Limit prüft nur der Server (die Vorprüfung in der RPC meldet es vor dem Kandidatenschritt, wenn der Nutzer ohne Treffer sendet; bei Treffern spätestens beim Absenden).
**Krit. 3 nachträglich:** Bei „nicht aufnehmen“ kann der Nutzer den vorläufigen Eintrag später auf der Meldungsdetailseite zu einer Liste hinzufügen, über die Funktion aus SAM-01/SAM-04/SAM-05 (#6/#8/#9) bzw. einen Insert in `collection_items` mit Spaltenliste `owner_id`, `game_id`, `list` (Spec-F8).
### 5.4 Ergebnishinweise (Krit. 4, 5, 6, 8)
`outcomeOf(report, provisional?, ownedItemOnTarget?)` ist eine reine Funktion (Unit-Tests je Fall). **Fehlt der vorläufige Eintrag** (`provisional_game_id` null, vom Nutzer gelöscht), folgt das Ergebnis nur aus `status` und `target_game_id`; Aktion „wechseln“ und Edition-Ableitung entfallen.
| Fall | Erkennung | Hinweis (Kurzform, ARB) | Aktionen | Wo sichtbar |
|---|---|---|---|---|
| `pending` (Krit. 8) | `status = open` | „Gemeldet am …, wird geprüft“ | – | Meldung; vorläufiger Eintrag (Badge „vorläufig“) |
| `approved` (Krit. 4) | `status = approved` | „Dein gemeldetes Spiel ist jetzt im Katalog. Deine Angaben wurden übernommen.“ | „Zum Spiel“, „Verstanden“ | Meldung; Eintrag am Zielspiel, **nur wenn** `provisional.merged_into_id = target` |
| `merged` (Krit. 5) | `status = merged` | „Dein Spiel gab es schon im Katalog als „{title}“. Dein Eintrag zeigt jetzt dorthin.“ | „Zum Spiel“, „Verstanden“ | wie `approved` |
| `rejectedWithTarget` (Krit. 5) | `status = rejected`, Ziel gesetzt, vorläufiger Eintrag vorhanden und nicht zusammengeführt | „Dein Spiel gibt es bereits als „{title}“.“ | „Zu „{title}“ wechseln“ (`adopt_report_target`, Bestätigungsdialog „Deine Angaben werden auf den Katalogeintrag übertragen.“), „Vorläufig behalten“ | Meldung; **nur** am vorläufigen Eintrag, nicht am Ziel |
| `rejectedReference` | `status = rejected`, Ziel gesetzt, vorläufiger Eintrag fehlt | „Dein Spiel gibt es bereits als „{title}“.“ | „Zum Spiel“ | nur Meldung |
| `rejectedAdopted` | `status = rejected`, Ziel gesetzt, `provisional.merged_into_id = target` | wie `merged` | „Zum Spiel“, „Verstanden“ | Meldung; Eintrag am Zielspiel bis „Verstanden“ (`result_seen_at` bleibt nach dem Wechsel null, 4.4) |
| `rejected` (Krit. 6) | `status = rejected`, kein Ziel | dauerhafte Markierung „Abgelehnt“ + „Dieses Spiel wird nicht in den Katalog aufgenommen. Dein Eintrag bleibt privat für dich.“ | „Verstanden“ (blendet nur den Text aus) | Meldung; vorläufiger Eintrag |
**Zusatz Edition (E7, N15)** bei `approved`, `merged`, `rejectedAdopted`: „Deine Edition konnte nicht übernommen werden. Bitte wähle sie neu.“
- Bei Spec-F6 = Zähler (Empfehlung): wenn `editions_cleared > 0`. Der Text nennt Sammlung **und** Partien („in deiner Sammlung oder deinen Partien“).
- Rückfall Ableitung: wenn der vorläufige Eintrag mindestens eine eigene Variante hat **und** der Sammlungseintrag am Ziel mit `list = 'owned'` `variant_id = null` hat. Wunsch- und Einkaufsliste haben nach Klärung 28 nie eine Edition und werden nicht geprüft; Partien werden nicht einzeln geprüft (Grenze der Ableitung).
- Aktion „Edition wählen“ → Bearbeitung des Sammlungseintrags (KAT-03 #12 / SAM-02 #7), sobald vorhanden.
**„Verstanden“** setzt `result_seen_at` (4.5); danach kein Hinweis mehr am Eintrag, in „Meine Meldungen“ bleibt der Status sichtbar.
**Hinweis „am Eintrag“:** `reportNoticeForGameProvider(gameId)` liefert den passenden Fall: am vorläufigen Eintrag (`provisional_game_id = gameId`) die Fälle `pending`, `rejectedWithTarget`, `rejected`; am Zielspiel (`target_game_id = gameId`, `result_seen_at is null`) nur `approved`, `merged`, `rejectedAdopted`, und nur wenn `provisional.merged_into_id = gameId`. `ReportOutcomeNotice` (kompakt/ausführlich) wird eingebaut in 13b in „Meine Meldungen“ und die Meldungsdetailseite, in #4/#6/#11 soweit gemergt, sonst übernehmen diese Stories den Einbau (Spec-F8, 9.4). Vorläufige Einträge zeigen ein Badge „vorläufig“ (Text) und den `GameCover`-Platzhalter.
**Aktualität:** kein Realtime; Neuladen beim Öffnen und per Pull-to-Refresh.
### 5.5 Meine Meldungen
`MyReportsController` (AsyncNotifier mit Paging, 20 je Seite): Laden, Leer („Du hast noch keine Spiele gemeldet“ + Weg zur Suche), Fehler mit „Erneut versuchen“, Daten. Je Zeile Titel, Jahr, Datum, Status-Badge, ggf. kompakter Hinweis; Import-Meldungen mit Kennzeichen „aus BGG-Import“ (Text liefert #25).
### 5.6 l10n (Auszug, de/en)
Präfix `gameReport…`: Felder und Fehler (`gameReportTitleRequired`, `gameReportTitleTooLong`, `gameReportPublisherTooLong`, `gameReportYearInvalid`, `gameReportLinkInvalid`), Ähnlichkeit (`gameReportSimilarHeading`, `gameReportSimilarIsMine`, `gameReportSimilarConfirmDistinct`, `gameReportSubmitAnyway`), Listenwahl, Bestätigung, `gameReportDailyLimit`, Offline, Status-Badges, `gameReportProvisionalBadge`, Hinweise aus 5.4 inkl. Edition, Aktionen, Leer-/Fehlerzustand, Semantics-Labels; Platzhalter `{title}`, Datum über `intl`.
## 6. Plattform-Besonderheiten
- **iOS, Android, Web:** gleiches Verhalten; Link-Feld `TextInputType.url`, Jahr mit Ziffern-Tastatur; der Link wird nur als Text angezeigt, nicht geöffnet.
- **Web:** teilbare URLs; Bestätigungsschritt per Tastatur bedienbar; breites Layout (Formular und Kandidaten nebeneinander ab ≥ 840 dp, ui-designer).
- **Offline:** Melden braucht eine Verbindung (wie SAM-01 Krit. 3); Hinweis statt Fehler, Eingaben bleiben. Keine Offline-Warteschlange.
- **Push, Kamera, Dateien:** nicht betroffen.
## 7. Teststrategie
Werkzeuge: pgTAP (`supabase test db`), `flutter_test` + mocktail, Playwright (Web-E2E). Testnamen enthalten „KAT-04 Kriterium n“.
**Neue pgTAP-Datei `supabase/tests/database/10_game_reports_user.test.sql`** (`08` gehört 2c, `09` gehört #3). Fixtures mit festen Test-UUIDs, Nutzer A und B, unabhängig vom Startbestand. Einfügungen „als Nutzer“ laufen wirklich als Rolle `authenticated` mit gesetztem `request.jwt.claims`, nicht als `postgres`.
### 7.1 Abdeckungsmatrix
| AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task |
|---|---|---|---|---|---|
| Krit. 1 | `GameReportDraft`: Titel Pflicht (auch nur Leerzeichen), Längen, Jahr, Link; Fehler-Mapping | Feldfehler, Bestätigung, Listenwahl | Playwright: leere Suche → „Spiel melden“ → Titel vorbelegt → Bestätigung | `report_missing_game` als `authenticated`: Meldung (`open`, `user`) und vorläufiges Spiel (`private`, nur Titel/Jahr); Trimmen; alle Validierungsfehler; Jahr 99999 → `report_year_invalid` (kein 22003); `anon` → 42501 | B1, B2, F2, T1, T2 |
| Krit. 2 | Controller: Treffer → `similarFound`; ohne Haken kein Senden; „Das ist mein Spiel“ sendet nichts; `similarExists` → zurück | Kandidatenliste, Checkbox aktiviert Button, Tastatur, Semantics | Playwright: Meldung „Catan“ zeigt „CATAN“ | `strip_leading_article`/`normalize_game_title`: Artikel de/en nur führend, Großbuchstaben, Sonderzeichen, Umlaute über `search_normalize`, nur Artikel, nicht-lateinischer Titel (Rückfall); `find_similar_catalog_games`: gleicher normalisierter Titel bei beliebigem Jahr; Trigramm ≥ 0,8 bei ± 1 trifft, bei ± 2 nicht; < 0,8 nicht; fehlendes Jahr nach Spec-F2; **Treffer über `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, 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 |
| Sicherheit 1, 3 | – | – | – | B liest A's Meldungen und Spiele nicht, kann sie nicht wegklicken (0 Zeilen) und nicht übernehmen; Insert mit `owner_id = A` als B, ohne `provisional_game_id`, mit fremdem/Katalog-/ausgeblendetem Spiel → Policy-Fehler; zweite Meldung zum selben vorläufigen Eintrag → 23505; Spaltenrechte per `has_column_privilege` | B2 |
| Sicherheit 2 | – | – | – | `anon`: kein `select`, `insert`, `update`, keine RPC | B2 |
| Datenschutz | – | – | – | Konto A löschen → Meldungen, vorläufige Spiele, Listeneinträge weg; B unverändert | B2 |
| QUE-05/06, Zustände | – | de/en, `meetsGuideline`, `textScaler` 2.0, Lade/Leer/Fehler/Daten, Offline | Playwright: Tastatur im Bestätigungsschritt | – | F2, F3, T2 |
**Nicht automatisiert:** echte Parallelität des Limits (Code-Review und dokumentierter manueller Test mit zwei `psql`-Sitzungen unter READ COMMITTED); Bearbeitung echter Meldungen auf Staging (Chris).
**Bestehende Tests:** Die Tests aus `08_game_reports.test.sql` (2c), die einen Insert oder ein Update als `authenticated` mit 42501 erwarten, bleiben nach 13a grün, aber aus anderem Grund: Statt des fehlenden Tabellenrechts greifen jetzt Spaltenrechte bzw. RLS. B2 passt nur die **Testbeschreibungen** an, nicht die Erwartungen. Tabellenzahl in `00_schema.test.sql` unverändert gegenüber `main`.
## 8. Aufgaben
### 8.1 PR 13a – Backend (`Refs #13`), Branch `feature/13a-meldungen-backend`
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| B1 | backend-dev | Migration nach 3.2–4.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 |
### 8.2 PR 13b – Flutter (`Closes #13`), Branch `feature/13b-meldungen-app`
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| U1 | ui-designer | Formular, Kandidatenschritt, Bestätigung, „Meine Meldungen“, Detail, `ReportOutcomeNotice`, Badge, Web-Layout; Texte de/en | Spec freigegeben |
| F1 | flutter-dev | Domäne (5.1) inkl. `outcomeOf` mit allen Fällen aus 5.4 (auch „vorläufiger Eintrag fehlt“); Unit-Tests | Spec freigegeben (parallel zu B1) |
| F2 | flutter-dev | Data-Schicht (explizite Parameter, kein `toJson()` an Schreibzugriffe, Fehler-Mapper inkl. `report_provisional_in_use`) und Melden-Ablauf (5.3), Routen, Einstieg über `onReportGame` aus #3 PR 3b, l10n | F1, U1; gegen Backend: B1 |
| F3 | flutter-dev | „Meine Meldungen“, Detail, Hinweise (5.4), Einbau in #4/#6/#11 soweit gemergt | F1, F2 |
| T2 | test-engineer | Widget-Tests und Playwright nach 7.1 | F2, F3, B1 |
### 8.3 Reihenfolge und Parallelität
```
Vorbedingung 13a: #2 PR 2b und 2c gemergt; #3 PR 3a gemergt (search_normalize, game_alt_titles)
Vorbedingung 13b (Merge): #3 PR 3b gemergt (onReportGame im Leerzustand, Krit. 6 von KAT-01)
13a: B1 → B2 → B3, T1
13b: U1 ─┐
F1 ─┴→ F2 (Mock-Repository, danach gegen B1) → F3 → T2
13b wird nach 13a gemergt.
```
F1 und U1 starten nach Freigabe der Spec, parallel zu B1. Merge nur durch Chris.
## 9. Risiken & offene Fragen
### 9.1 Risiken
| # | Risiko | Auswirkung | Gegenmaßnahme |
|---|---|---|---|
| R1 | 2b/2c/#3 weichen von den beschriebenen Namen oder Rückgaben ab | 13a passt nicht | B1 liest die gemergten Migrationen zuerst; Abweichungen im PR nennen |
| R2 | `adopt_report_target` ist `security 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)
Ohne andere Entscheidung gilt mit der Freigabe die Empfehlung. **Ausnahme Spec-F6:** Sie blockiert nicht die Umsetzung von #13, muss aber **vor dem Merge von PR #57 (2c)** entschieden werden. Alle anderen Fragen blockieren nichts.
| # | Frage | Empfehlung | Begründung |
|---|---|---|---|
| Spec-F1 | **Krit. 1:** Sind Verlag und Jahr Pflicht? | **Optional**, nur der Titel ist Pflicht. | Nutzer kennen das Jahr oft nicht; 2c hat beide Spalten nullable. |
| Spec-F2 | **Krit. 2:** Trigramm-Regel bei fehlendem Jahr? | ≥ 0,8 **ohne Jahresbedingung**. | Lieber einmal mehr nachfragen; der Schritt verlangt nur eine Bestätigung. |
| Spec-F3 | **Krit. 2 „Artikel ignoriert“:** welche Artikel und wo? (Sonderzeichen, Groß-/Kleinschreibung und Akzente regelt `search_normalize` aus Spec #3.) | Nur **ein führender** Artikel auf Deutsch oder Englisch (Liste in 3.3). | Artikel mitten im Titel sind bedeutungstragend („Herr der Ringe“); andere Sprachen sind im Katalog selten. |
| Spec-F4 | **Krit. 7 „heute“:** Kalendertag oder 24 Stunden? | **Kalendertag Europe/Berlin.** | Entspricht „heute“; verständlich („morgen geht es wieder“). |
| Spec-F5 | **Krit. 4–6:** Wie lange bleibt der Hinweis sichtbar? | **Bis „Verstanden“**; „abgelehnt“ bleibt dauerhaft markiert. | Dauerhafte Hinweise stören, zeitgesteuertes Ausblenden wird übersehen. |
| Spec-F6 | **N15, Edition:** Woran erkennt die App die geleerte Edition? **Entscheidung vor dem Merge von PR #57.** | **Zähler `game_reports.editions_cleared integer not null default 0 check (editions_cleared >= 0)`.** Nachtrag **nur** in Spec #2 § 3.4 / PR 2c: Spalte anlegen und in `admin_resolve_report` im bestehenden Update `editions_cleared = coalesce((result->>'variants_cleared')::int, 0)` setzen. 2b bleibt unverändert: `repoint_game_references` liefert `variants_cleared` bereits (geleerte Verweise in `collection_items` inkl. Kollisionsfall und in `plays`). 13a setzt den Wert in `adopt_report_target` genauso. Fällt die Entscheidung erst nach dem Merge von 2c, bleibt nur ein `create or replace` von `admin_resolve_report` in 13a. Rückfall: Ableitung (5.4). | Genau, erfasst Sammlung und Partien, und vor dem Merge von PR #57 nur eine Spalte und eine Zuweisung. Die Ableitung kommt ohne Änderung an 2c aus, ist aber ungenau (R5). |
| Spec-F7 | **Krit. 2:** auch eigene frühere Meldungen als ähnlich zeigen? | **Nein in R1.** | Kriterium nennt nur Katalogeinträge. |
| Spec-F8 | **Abhängigkeiten:** Soll #13 zusätzlich von #4 und #6 abhängen? | **Ja** (beide liegen ohnehin vorher); #11 übernimmt den Einbau selbst. | Sonst Zwischenlösungen, die später ersetzt werden. |
| Spec-F9 | **Krit. 3:** vorausgewählte Liste? | **Sammlung.** | Häufigster Fall laut Story. |
### 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 und den Kandidatenschlüssel über `title_search` (E4).
- Kein Realtime, keine Edge Function, keine neuen Pakete.
### 9.4 Hinweise für andere Stories
- **Spec #2 / PR 2c (PR #57), falls Spec-F6 = Zähler:** vor dem Merge Spalte `editions_cleared` anlegen und in `admin_resolve_report` setzen; 2b unverändert.
- **KAT-01 (#3):** Einstieg `/reports/new?title=<suchbegriff>` ü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`).
- **PAR-01 (#15):** vorläufige Spiele wählbar (Klärung 36), mit Badge; nach Freigabe/Wechsel zeigen Partien das Katalogspiel, auch für Mitspieler.
- **KTO-05 (#23):** Export enthält `game_reports` und vorläufige Spiele.
- **SAM-08 (#25):** Import-Meldungen mit `origin = 'import'`, `bgg_id` und vorläufigem Eintrag; Limit-Trigger greift nicht (N12); Hinweise aus 5.4 gelten.
### 9.5 Ausstehende Prozessschritte
- Freigabe der Spec und Antworten zu Spec-F1 bis Spec-F9 (Label `status/spec-review`).
- **Spec-F6 vor dem Merge von PR #57:** bei Zähler Nachtrag in Spec #2 § 3.4 und in PR 2c (PR #57, vor dem Merge).
- Bei Spec-F8 = Ja: Forgejo-Abhängigkeiten #13 → #4, #6 setzen.
## 10. Änderungsprotokoll
### 2026-11-03 – Entwurf
Erste Fassung durch architect. Berücksichtigt den Hinweis aus der KAT-05-Spec (Issue-Kommentar vom 2026-11-03, Klärung N15) zur eingeschränkten Erhaltung der Edition bei Krit. 4.
Nachtrag am selben Tag: Normalisierung faltet Akzente per `translate()` (Spec-F3 neu begründet: ohne Faltung läge „Mölkky/Molkky“ bei etwa 0,4); Trigramm-Index gestrichen (wäre mit `similarity() >= 0.8` nicht nutzbar); Abweichung von Spec #2 § 3.4 (Update-Recht auf `result_seen_at`) ausdrücklich vermerkt und Testanpassung in B2 ergänzt; Hilfsfunktion `report_day` für testbare Tagesgrenze.
### 2026-11-03 – Review (code-reviewer)
- **B1:** `report_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.
### 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`.

View file

@ -0,0 +1,622 @@
# 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); 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); 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).
## 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 | **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. 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` 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
Ü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.), 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). 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; Umgebung und Abnahme nach Spec-F4 (§ 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 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; 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.
- **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` (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` |
| 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/<ts>_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` | `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**
```
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 – 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. 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, 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 (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**
| 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:
- **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)
| 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`).
- `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**
- `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 von `main` plus `'alt_titles'`.
**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)
**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` 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 `<ts>_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` (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
### 4.1 Überblick
| Objekt | Art | PR | Aufrufbar von |
|---|---|---|---|
| `pg_trgm`, `unaccent` | Erweiterungen (Schema `extensions`) | 3a | – |
| `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` |
| 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: {...})`). 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, ≤ 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`; 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. 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.
**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 | **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 |
| `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)`, `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. 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.
| 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(<p_genre_ids ohne Duplikate>)`; 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 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 (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 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 **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. **`;` 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 = <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 **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.
## 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), 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<String>`, 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<String>`, `mechanicIds` `Set<String>`). 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`:**
- `Future<Result<CatalogSearchPage, CatalogFailure>> search(CatalogSearchQuery query, {required int offset, int limit = 30})`
- `Future<Result<List<CatalogTerm>, 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 && items.length < 10000`.
**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 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.
### 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: ü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)
| 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 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; `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, 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) **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
| 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 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, 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` (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 (`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 | – | – | – (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 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 ö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, 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.
**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 `<ts>_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` 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 |
| 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/`; 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ß 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` 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, 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, 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
```
#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 (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).
- 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
### 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, æ → 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 | `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 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, 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 |
### 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, 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, 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, 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, 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 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(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.
- **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
### 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.
### 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).
### 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.

View file

@ -0,0 +1,361 @@
# 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; 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):
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 `<SITE_URL>/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 `<SITE_URL>/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) |
| 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 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). 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). |
## 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://<Produktions-Domain>` | Bedingung: Host = Associated Domain (APPLINK-01). Auf Prod setzt das Chris (C5). |
| `GOTRUE_URI_ALLOW_LIST` (AUTH-CFG-02) | `<SITE_URL>/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 | `<TEAM_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 `<TEAM_ID>` 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://<Domain>/.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": ["<TEAM_ID>.work.cshomelabs.shuffle"],
"components": [
{ "/": "/auth/confirm", "comment": "E-Mail-Links (Bestätigung, Reset, später E-Mail-Wechsel)" }
]
}
]
},
"webcredentials": {
"apps": ["<TEAM_ID>.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": ["<UPLOAD_KEY_SHA256>", "<PLAY_APP_SIGNING_SHA256>"]
}
}
]
```
`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 **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/`, 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.
### 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; 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. |
| 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)`. **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.
**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:<Host>`, 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 (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; 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
- 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://<APP_LINK_HOST>/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); **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-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)
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, 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.
### 7.3 Ergänzung Release-Checkliste (`docs/auth-konfiguration.md` § 5)
- [ ] `curl -sI https://<Domain>/.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/<Domain>` liefert die aktuelle Datei.
- [ ] Google-Prüfung: Digital Asset Links API (`https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://<Domain>&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, 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 | **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–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 → (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 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
### 9.1 Risiken
| # | Risiko | Auswirkung | Gegenmaßnahme |
|---|---|---|---|
| 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 |
| 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) |
| 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)
| # | 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. 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 |
| 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 |
| 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 |

View file

@ -21,7 +21,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)