All checks were successful
Stand von 2c (PR #57) und Fremdschlüssel-Wächter in 07 und 08, normalize_game_title ausgeschrieben, Release-Checkliste als offener PR #54 gekennzeichnet, Änderungsprotokoll nur ergänzt. Refs #3 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
622 lines
81 KiB
Markdown
622 lines
81 KiB
Markdown
# 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.
|