From 0cbe813e5b7aee5d5f2342929a3712ac80107ae7 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:40:43 +0100 Subject: [PATCH] docs(db): document search, alt titles and image-upgrade check (KAT-01, 3a) datenmodell-r1, supabase README and katalog-pflege (alt titles, merge step 2b, normalization check after image upgrades, rate limit and log hints). Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- docs/datenmodell-r1.md | 40 ++++++++++++++++++--- docs/katalog-pflege.md | 79 +++++++++++++++++++++++++++++++++++++++--- supabase/README.md | 9 ++++- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/docs/datenmodell-r1.md b/docs/datenmodell-r1.md index 01a5a31..627d3d1 100644 --- a/docs/datenmodell-r1.md +++ b/docs/datenmodell-r1.md @@ -28,7 +28,7 @@ Die Pfeile zeigen, welche Tabelle auf welche verweist. Jede Partie (`plays`) hä ## Tabellen -R1 braucht 21 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers`, `game_publishers` und `game_reports` aus KAT-05). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag. +R1 braucht 22 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers`, `game_publishers` und `game_reports` aus KAT-05 sowie `game_alt_titles` aus KAT-01). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag. ### Konto und Profil @@ -42,10 +42,11 @@ R1 braucht 21 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog | Tabelle | Spalten | Zweck | | --- | --- | --- | -| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at` | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die letzten sieben Spalten gehören der Katalogpflege (KAT-05), siehe unten | +| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at`, `title_search` (generiert) | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die sieben Spalten vor `title_search` gehören der Katalogpflege (KAT-05), `title_search` der Suche (KAT-01), siehe unten | | `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at`, `editions_cleared` | Meldungen fehlender Spiele (KAT-05 PR 2c, Anlegen durch Nutzer kommt mit KAT-04); siehe Katalogpflege | | `catalog_sources` | `key`, `name`, `attribution_text`, `attribution_url`, `images_allowed`, `texts_allowed`, `terms_url`, `terms_checked_at` | Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt `bgg` ohne Erlaubnis an | -| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) | +| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise), `name_search` (generiert); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) | +| `game_alt_titles` | `game_id` (FK → `games`), `title`, `locale` (`de`/`en`), `title_search` (generiert) | Alternative Titel eines Spiels mit Sprache (Klärung 3, KAT-01); die Suche findet das Spiel auch darüber | | `game_variants` | `game_id` (FK → `games`), `name`, `year`, `language`, `bgg_id` (optional) | Editionen und Varianten eines Spiels (O4) | | `genres`, `mechanics`, `categories` | `name_de`, `name_en` | Einheitliche Begriffe für Filter und Spieldetails | | `game_genres` | `game_id` (FK → `games`), `genre_id` (FK → `genres`) | Zuordnung Spiel zu Genre; Paar eindeutig | @@ -78,6 +79,7 @@ Jede Tabelle hat Row Level Security (RLS) und eine Policy-Test-Suite (pgTAP). De | `favorite_games`, `bgg_links` | Nur eigene | Nur eigene | | `games`, `game_variants`, Begriffe | Katalog für alle (O6); private Spiele nur der Ersteller | Katalog nur Admin (in R1 nur über Studio/SQL, E10); private Spiele nur der Ersteller, und nur die Spalten laut Spaltenrechten | | `game_reports` | nur der Meldende liest seine Zeilen (Admin-Policy `is_admin()` für R4); Schreiben kein Nutzerrecht | Anlegen ergänzt KAT-04 (#13); Bearbeiten nur über `admin_resolve_report` (Studio) | +| `game_alt_titles` | für alle, auch ohne Anmeldung; folgt dem Spiel (alternative Titel eines fremden privaten Spiels sind unsichtbar) | **niemand über die API** (kein Recht, keine Policy); Pflege nur über Studio und das Startbestand-Werkzeug | | `catalog_sources`, `publishers`, `game_publishers`, View `catalog_games` | für alle, auch ohne Anmeldung (`game_publishers` folgt dem Spiel) | Quellen und Verlage nur Admin; `game_publishers` Admin oder Ersteller des privaten Spiels; die View ist nicht beschreibbar | | `collection_items`, `score_sheets`, `score_categories` | Nur eigene | Nur eigene | | `plays` | Ersteller und eingeladene Mitspieler (offen oder bestätigt) | Nur Ersteller | @@ -197,11 +199,12 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel | `description_locale` | text | nein | | `de` oder `en`, nur zusammen mit `description` | | `hidden_at` | timestamptz | nein | | gesetzt = ausgeblendet | | `merged_into_id` | uuid | nein | | FK → games (`on delete restrict`), nur auf Katalogspiele; nur mit `hidden_at` | -| `manual_fields` | text[] | ja | `'{}'` | von Hand gepflegte Felder (Trigger), nur bekannte Schlüssel | +| `manual_fields` | text[] | ja | `'{}'` | von Hand gepflegte Felder (Trigger), nur bekannte Schlüssel: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`, `alt_titles` | | `catalog_source_id` | uuid | nein | | FK → catalog_sources (`on delete restrict`) | | `source_ref` | text | nein | | Kennung in der Quelle; genau dann gesetzt, wenn `catalog_source_id` gesetzt ist | | `source_url` | text | nein | | `https://`, nur mit Quelle | | `source_synced_at` | timestamptz | nein | | letzter Abgleich, nur mit Quelle | +| `title_search` | text | nein | generiert | `public.search_normalize(title)`, gespeichert; nicht beschreibbar (KAT-01) | | `created_at` | timestamptz | ja | `now()` | | | `updated_at` | timestamptz | ja | `now()` | | @@ -220,7 +223,20 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel | `terms_checked_at` | date | nein | | | | `created_at`, `updated_at` | timestamptz | ja | `now()` | | -**`publishers`** hat `id`, `name` (1 bis 200 Zeichen, eindeutig ohne Beachtung der Schreibweise), `created_at`, `updated_at`. **`game_publishers`** verbindet wie die anderen Zuordnungen ein Spiel (`game_id`, FK → games, `on delete cascade`) mit einem Verlag (`publisher_id`, FK → publishers, `on delete cascade`); das Paar ist eindeutig. +**`publishers`** hat `id`, `name` (1 bis 200 Zeichen, eindeutig ohne Beachtung der Schreibweise), `name_search` (generiert, `public.search_normalize(name)`), `created_at`, `updated_at`. **`game_publishers`** verbindet wie die anderen Zuordnungen ein Spiel (`game_id`, FK → games, `on delete cascade`) mit einem Verlag (`publisher_id`, FK → publishers, `on delete cascade`); das Paar ist eindeutig. + +**`game_alt_titles`** (KAT-01) + +| Spalte | Typ | Pflicht | Standard | Schlüssel | +| --- | --- | --- | --- | --- | +| `id` | uuid | ja | `gen_random_uuid()` | PK | +| `game_id` | uuid | ja | | FK → games (`on delete cascade`) | +| `title` | text | ja | | 1 bis 200 Zeichen | +| `locale` | text | ja | | `de` oder `en` | +| `title_search` | text | ja | generiert | `public.search_normalize(title)`, gespeichert; darf nicht leer sein (ein Titel nur aus Sonderzeichen ist unzulässig) | +| `created_at`, `updated_at` | timestamptz | ja | `now()` | | + +Eindeutig ist `(game_id, locale, title_search)`: derselbe Titel in derselben Sprache nur einmal, unabhängig von der Schreibweise; mehrere Titel je Sprache sind erlaubt. Lesbar wie das Spiel; kein Schreibrecht für API-Rollen. **`game_variants`** @@ -373,3 +389,17 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle - **Startbestand:** `supabase/catalog/` (CSV, Werkzeug, erzeugte SQL-Dateien); `supabase db reset` spielt Vokabular und Startbestand ein. Der Startbestand enthält nur Fakten und einen eigenen deutschen Kurztext, kein Cover, keine Quelle, keine BGG-Kennung. - **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht. - **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`; neu für den Meldenden lesbar: `game_reports`. Das Ändern von `visibility` an einem Spiel ist nicht mehr möglich (P0001 `visibility_immutable`); wer eine Sichtbarkeit mitsendet, muss den alten Wert senden oder die Spalte weglassen. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an. + +## Katalogsuche (KAT-01, Migration `catalog_search`) + +Spec: `docs/specs/3-katalog-suche.md`. Die Suche liest ausschließlich über die RPC `search_catalog_games`, die ihrerseits die View `catalog_games` nutzt. + +- **Erweiterungen:** `pg_trgm` und `unaccent` im Schema `extensions` (Bestandteil des Supabase-Postgres-Images; lokal geprüft, auf Staging/Produktion vor dem Merge mit `select * from pg_available_extensions where name in ('pg_trgm','unaccent')` bestätigen). +- **Gemeinsame Normalisierung:** `public.search_normalize(text)` ist die einzige Basisfunktion für normalisierte Titel. Sie wird von #3 (PR 3a) angelegt, ist rein rechnend und für `anon`, `authenticated` und `service_role` ausführbar, weil generierte Spalten und `security invoker`-Funktionen sie mit den Rechten des Aufrufers berechnen. #13 legt `public.normalize_game_title(text)` darüber an und entfernt nur dort Artikel; `search_normalize` entfernt nie Artikel. Ablauf: Marken- und Schutzzeichen (`®©™℗℠`) entfernen, Akzente/Umlaute entfernen (`unaccent`, `ß` → `ss`, `Æ` → `ae`), Kleinschreibung, alles außer `a–z`/`0–9` zu einem Leerzeichen, Ränder kürzen, leer = `null`. Beispiele: `'CATAN®'` → `'catan'`, `'Spiel™ ½'` → `'spiel 1 2'`, `'Æon’s End'` → `'aeon s end'`, `'Café Größe'` → `'cafe grosse'`, `'!!!'` → `null`. Nicht-lateinische Zeichen werden entfernt. Ein Suchwort wie „kaese“ findet „Käse“ in R1 nicht (Spec-F5). +- **Generierte Spalten** (gespeichert, nicht beschreibbar): `games.title_search`, `game_alt_titles.title_search`, `publishers.name_search`, je mit Trigramm-Index (GIN; der Index auf `games` ist ein Partial-Index mit den Bedingungen der View `catalog_games`). Die Indizes sind keine Geschwindigkeitszusage, maßgeblich ist die Messung (`supabase/perf/catalog_search_perf.sql`, Ergebnis in der Beschreibung von PR 3a). Wird `search_normalize` oder `unaccent.rules` geändert (Image-Upgrade), müssen die Spalten per Migration neu angelegt werden; Prüfabfrage: `docs/katalog-pflege.md`, Abschnitt 8. +- **`manual_fields`:** neuer Schlüssel `alt_titles`. Der Check nennt jetzt 15 Schlüssel (siehe Tabellenstruktur); jede Migration nennt die vollständige Liste. +- **Alternative Titel beim Zusammenführen:** `admin_merge_games` hängt `game_alt_titles` nicht um (Spec-F6 A); sie bleiben am ausgeblendeten Quell-Eintrag. Vor dem Zusammenführen ergänzt der Admin relevante Titel am Ziel (`docs/katalog-pflege.md`, Abschnitt 5, Schritt 2b). +- **RPC `public.search_catalog_games(p_query, p_players, p_max_minutes, p_age, p_genre_ids, p_mechanic_ids, p_limit default 30, p_offset default 0)`** (`stable`, `security invoker`, leerer `search_path`; ausführbar für `anon` und `authenticated`, nicht für `public` und `service_role`). Vertrag und Semantik: Spec 4.2. Ausgabe je Treffer: `id`, `title`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path` (nur aus `catalog_games`, maskiert nach Quelle), `publisher_names`, `matched_alt_title`, `match_rank` (0 bis 4, ohne Suchbegriff `null`), `total_count`. Umsetzungsdetails, die der Vertrag offen ließ: `null` bei `p_limit`/`p_offset` gilt als Standardwert; „nur Leerraum“ (auch Tabulator, Zeilenumbruch) gilt als kein Suchbegriff; die Ähnlichkeit (zweites Sortierkriterium) ist die höchste Trigramm-Ähnlichkeit über Haupttitel und alle alternativen Titel; bei gleicher Stufe und Ähnlichkeit wird binär (`collate "C"`) nach `title_search`, dann nach `id` sortiert (ohne Suchbegriff ebenso nach `title_search`, dann `id`). Fehler (`P0001`, HTTP 400): `search_query_too_long`, `search_filter_invalid`, `search_paging_invalid`. Ein `smallint`-Parameter außerhalb von −32768…32767 scheitert schon beim Umwandeln (kein `P0001`). +- **Rechte der Rolle `catalog_importer`:** Wer `games` schreibt, berechnet `title_search` mit den eigenen Rechten. Der spätere Import (SAM-08) braucht deshalb `execute` auf `public.search_normalize(text)`, `usage` auf das Schema `extensions` und Schreibrechte auf `game_alt_titles`; die Test-Rolle in `06_catalog_foundation.test.sql` bekommt die beiden ersten. +- **Suchbegriffe** werden nicht gespeichert; der Aufruf per `POST` (`client.rpc`) hält sie aus den URLs der Zugriffsprotokolle. Protokoll-Einstellungen der Datenbank: Ergebnis der Prüfung in der Beschreibung von PR 3a. +- **API-Änderungen für das Frontend:** neu lesbar: Tabelle `game_alt_titles`, Spalten `games.title_search` und `publishers.name_search` (über `select=*` mitgeliefert, die App liest sie nicht), RPC `search_catalog_games`. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGameSummary` (3b) folgt der Ausgabe oben. diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index 15239c1..f9991bc 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -2,9 +2,9 @@ Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`. -Stand: PR 2c (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten). +Stand: PR 3a (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten, alternative Titel und Suche). -**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`, Abschnitt 6 in `08_game_reports.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). +**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`, Abschnitt 6 in `08_game_reports.test.sql`, Abschnitte 3.1, 5 (Schritt 2b) und 8 in `09_catalog_search.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). ## 1. Zugang @@ -60,7 +60,7 @@ Kopfzeile ist Pflicht, Trennzeichen Komma, Felder mit Komma oder Zeilenumbruch i | `description_locale` | ja | immer `de` | | `hidden` | nein | `ja` = ausblenden; leer = `hidden_at` bleibt unverändert | | `facts_source` | nein | Herkunftsnachweis für SB3; nicht in der Datenbank | -| `alt_titles` | nein | reserviert für KAT-01 (`de:Titel;en:Title`), wird ignoriert | +| `alt_titles` | nein | alternative Titel mit Sprache: `de:Titel;en:Title`, Einträge durch `;` getrennt, Sprache `de` oder `en`, am **ersten** `:` getrennt (`de:Catan: Das Duell` ist zulässig). **`;` ist in einem alternativen Titel nicht erlaubt** (kein Escaping; solche Titel im Studio pflegen, siehe 3.1). Das Werkzeug bricht mit Zeilennummer ab bei: Sprache nicht `de`/`en`, Eintrag ohne `:`, leerem oder über 200 Zeichen langem Titel, einem Titel nur aus Sonderzeichen, demselben Titel in derselben Sprache doppelt oder gleich dem Haupttitel (ohne Beachtung von Schreibweise und Sonderzeichen). Eine leere Spalte entfernt früher eingespielte alternative Titel des Spiels (bis zum Go-live). | | `base_game_key` | nein | reserviert für KAT-03: `key` des Basisspiels (SB7) | Andere Spalten (Cover, Quelle, BGG-Kennung) lehnt das Werkzeug ab. @@ -166,7 +166,37 @@ Regeln: - **Katalogspiele nie löschen, sondern ausblenden** (siehe 4). Zugeordnete Sammlungen, Favoriten und Partien verweisen auf sie. - **`merged_into_id` nie von Hand setzen**; Zusammenführen geht nur über die Funktion `admin_merge_games` (siehe 5). Der Trigger lehnt einen Verweis auf ein privates Spiel ab (`merged_into_not_catalog`). - Private Spiele von Nutzern im Studio nicht anfassen. -- Zulässige Schlüssel in `manual_fields`: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`. +- Zulässige Schlüssel in `manual_fields`: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`, `alt_titles`. + +### 3.1 Alternative Titel pflegen + +Ein Spiel kann zusätzlich zum Haupttitel alternative Titel mit Sprache (`de` oder `en`) haben, zum Beispiel den deutschen und den englischen Titel. Die Suche findet das Spiel auch darüber (KAT-01). Pflege nur über Studio oder die CSV-Spalte `alt_titles` (siehe 2.3); API-Rollen können sie nicht schreiben. Der Trigger trägt `alt_titles` in `games.manual_fields` ein. Ein Titel nur aus Sonderzeichen wird abgelehnt; derselbe Titel in derselben Sprache ist nur einmal möglich, egal in welcher Schreibweise. Beispiel-ID wie in 3: `7a1b2c3d-0000-4000-8000-000000000001`. + +**Anlegen oder korrigieren** (ein vorhandener Titel in anderer Schreibweise wird aktualisiert statt doppelt angelegt): + +```sql +insert into public.game_alt_titles (game_id, locale, title) +values ('7a1b2c3d-0000-4000-8000-000000000001', 'de', 'Beispielspiel Deutsch'), + ('7a1b2c3d-0000-4000-8000-000000000001', 'en', 'Example game') +on conflict (game_id, locale, title_search) do update set title = excluded.title +where public.game_alt_titles.title is distinct from excluded.title; +``` + +**Ansehen:** + +```sql +select locale, title from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000001' order by locale, title; +``` + +**Löschen:** + +```sql +delete from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000001' and locale = 'en' and title = 'Example game'; +``` + +Die Suche normalisiert Titel selbst (Groß-/Kleinschreibung, Akzente, Sonderzeichen, Marken- und Schutzzeichen); ein alternativer Titel muss deshalb nicht in mehreren Schreibweisen angelegt werden. Ein Suchwort „kaese“ findet „Käse“ in R1 nicht. ## 4. Ausblenden und wieder einblenden @@ -233,6 +263,23 @@ join public.collection_items t on t.owner_id = s.owner_id and t.game_id = '7a1b2 where s.game_id = '7a1b2c3d-0000-4000-8000-000000000002'; ``` +**Schritt 2b: alternative Titel der Quelle am Ziel ergänzen.** Beim Zusammenführen werden `game_alt_titles` **nicht** umgehängt, sie bleiben am ausgeblendeten Quell-Eintrag (Spec-F6 A); die Suche zeigt die Quelle nicht mehr. Damit das Ziel weiter über die Namen der Quelle gefunden wird, vor dem Zusammenführen ansehen und übernehmen: + +```sql +select locale, title from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000002' order by locale, title; +``` + +```sql +insert into public.game_alt_titles (game_id, locale, title) +select '7a1b2c3d-0000-4000-8000-000000000003', a.locale, a.title +from public.game_alt_titles a +where a.game_id = '7a1b2c3d-0000-4000-8000-000000000002' +on conflict (game_id, locale, title_search) do nothing; +``` + +Der Haupttitel der Quelle hat keine Sprache und wird nicht automatisch übernommen; soll er als alternativer Titel am Ziel stehen, legt man ihn mit dem Baustein aus 3.1 mit der passenden Sprache an. + **Schritt 3: Zusammenführen.** Der Baustein steht wie überall ohne `begin`/`commit`; er wird in einer Transaktion ausgeführt. Bei einem Fehler wird nichts geändert: ```sql @@ -499,3 +546,27 @@ update auth.users set raw_app_meta_data = raw_app_meta_data || '{"role":"admin"} **Nicht in R1 ausführen und nicht in pgTAP getestet.** Nie `user_metadata` dafür verwenden (vom Client änderbar). Hinweis für QUE-08 (R4): Der `is_admin()`-Zweig der Policies `game_*_write` (Genre, Mechanik, Kategorie, Verlag) funktioniert für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` (security invoker) `games.manual_fields` schreiben muss und `authenticated` dafür kein Spaltenrecht hat. Ein Admin-Pfad über die API braucht eigene RPCs (security definer). + +## 8. Suche: Normalisierung und Betrieb + +### 8.1 Nach einem Image-Upgrade: Normalisierung prüfen + +Die Suche speichert normalisierte Titel in den generierten Spalten `games.title_search`, `game_alt_titles.title_search` und `publishers.name_search` (Funktion `public.search_normalize`). Die Funktion ruft `unaccent` mit der Datei `unaccent.rules` des Datenbank-Images auf und ist als `immutable` deklariert. Ändert ein **Image-Upgrade** diese Datei (oder wird die Funktion geändert), weichen die gespeicherten Werte von dem ab, was die Funktion heute berechnet; Suchen treffen dann nicht mehr. **Nach jedem Image-Upgrade und vor dem Release** diese Prüfabfrage ausführen; jede Zeile muss `0` zeigen: + +```sql +select 'games' as tabelle, count(*) as abweichend from public.games + where title_search is distinct from public.search_normalize(title) +union all select 'game_alt_titles', count(*) from public.game_alt_titles + where title_search is distinct from public.search_normalize(title) +union all select 'publishers', count(*) from public.publishers + where name_search is distinct from public.search_normalize(name); +``` + +Bei einer Abweichung die betroffenen Spalten **per neuer Migration** entfernen und neu anlegen (gleiche Definition, `generated always as (public.search_normalize(…)) stored`, dazu den Index neu anlegen); nie von Hand im Studio. Dasselbe gilt, wenn `search_normalize` selbst geändert wird. + +### 8.2 Betrieb: Zeitlimits, Mengenbegrenzung und Protokolle + +- **Ohne Anmeldung aufrufbar:** `search_catalog_games` ist für `anon` ausführbar. Die Eingabegrenzen der Funktion (100 Zeichen, 10 Wörter, höchstens 20 IDs je Filter, Seitengröße 50) begrenzen die Last je Aufruf; zusätzlich gelten die `statement_timeout`-Werte der Rollen `anon` und `authenticated` (lokal 3 s und 8 s; im Self-Hosting prüfen, `select rolname, rolconfig from pg_roles where rolname in ('anon', 'authenticated')`). Ein `set statement_timeout` an der Funktion wirkt nicht. +- **Mengenbegrenzung je IP (Entscheidung vor dem Go-live, Aufgabe D1):** Das eigentliche Risiko sind viele parallele Aufrufe ohne Anmeldung. Für R1.1 ist eine Begrenzung kein Muss; vor dem Go-live entscheidet devops, ob das Kong-Plugin `rate-limiting` auf `/rest/v1/rpc/search_catalog_games` oder `limit_req` im vorgelagerten Proxy gesetzt wird, und trägt das in das Hosting-Runbook ein. +- **Suchbegriffe nicht protokollieren (QUE-04):** Die App sendet den Suchbegriff per `POST` im Anfragekörper (nicht in der URL). In den Datenbank-Einstellungen dürfen `log_min_duration_statement`, `log_statement` und `log_parameter_max_length*` Parameterwerte nicht in die Protokolle schreiben; `auto_explain.log_min_duration` und `pgaudit.log_parameter` ebenfalls nicht. Lokal geprüfte Werte und die Abweichungen stehen in der Beschreibung von PR 3a; auf Staging/Produktion mit `select name, setting from pg_settings where name in ('log_min_duration_statement', 'log_statement', 'log_parameter_max_length', 'log_parameter_max_length_on_error', 'auto_explain.log_min_duration', 'pgaudit.log', 'pgaudit.log_parameter')` prüfen. +- **Erweiterungen:** `select * from pg_available_extensions where name in ('pg_trgm', 'unaccent')` muss auf Staging/Produktion beide Zeilen liefern, bevor die Migration `catalog_search` eingespielt wird. diff --git a/supabase/README.md b/supabase/README.md index 6dc0427..04d81f6 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -3,8 +3,9 @@ ## Inhalt | Pfad | Zweck | |---|---| -| `migrations/` | Schema, Trigger und RLS-Policies für R1 (21 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. | +| `migrations/` | Schema, Trigger und RLS-Policies für R1 (22 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. | | `tests/database/` | pgTAP-Tests: Struktur-Regeln und Zugriffsrechte je Bereich. | +| `perf/` | Messskript für die Katalogsuche (kein Test, läuft nicht in `supabase test db`). | | `templates/` | Auth-Mailvorlagen (HTML, Go-Template) für Bestätigung und Passwort-Reset. | | `seed.sql` | Beispieldaten nur für die lokale Entwicklung. | | `catalog/` | Katalog-Vokabular und Startbestand (CSV, Werkzeug `build_startbestand.ts`, erzeugte SQL-Dateien), siehe unten. | @@ -42,6 +43,12 @@ supabase test db # alle pgTAP-Tests ausführen - Ein Pfad in `sql_paths`, der nicht existiert, wird von der CLI nur mit Warnung übersprungen (kein Fehler). Genauere CLI-Ergebnisse: `docs/katalog-pflege.md`, Abschnitt 2.7. - pgTAP-Tests, die Include-Dateien brauchen, nutzen `\ir ../support/…`; die Dateien in `tests/support/` sind erzeugt (gleiches Werkzeug) und keine Tests. +## Katalogsuche (KAT-01) +- `public.search_normalize(text)` (Normalisierung für Suche und Titelvergleich) und die RPC `public.search_catalog_games(…)` sind **für API-Rollen ausführbar** (`search_normalize`: `anon`, `authenticated`, `service_role`, weil generierte Spalten mit den Rechten des Schreibenden rechnen; `search_catalog_games`: `anon`, `authenticated`). Beides ist die Ausnahme von der Regel „Funktionen sind für niemanden aufrufbar“; die Rechte prüft `tests/database/09_catalog_search.test.sql`. +- Alternative Titel (`game_alt_titles`) sind für API-Rollen nur lesbar; Pflege über Studio (`docs/katalog-pflege.md`, Abschnitt 3.1) und die CSV-Spalte `alt_titles` (`de:Titel;en:Title`, `;` im Titel nicht erlaubt). +- Nach einem Image-Upgrade der Datenbank die Normalisierung prüfen (`docs/katalog-pflege.md`, Abschnitt 8). +- Messskript für das Zeitbudget der Suche (nur lokal, nie gegen Staging/Produktion, bewusst nicht unter `tests/`): `perf/catalog_search_perf.sql`, Aufruf im Kopfkommentar. + ## Regeln - Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security. - `updated_at` setzt ein Trigger; `id` und `created_at` sind nach dem Anlegen unveränderlich.