All checks were successful
admin_resolve_report weist rejected mit Ziel ab (report_reject_has_target), Check game_reports_rejected_no_target, Tests und Docs angepasst. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
575 lines
49 KiB
Markdown
575 lines
49 KiB
Markdown
# Katalog pflegen (Runbook, KAT-05)
|
||
|
||
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 13a (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten inkl. Nutzer-Meldungen, 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`, 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
|
||
|
||
- Studio läuft als Datenbankrolle `postgres` (bzw. `supabase_admin`) und **umgeht Row Level Security**. Wer Studio erreicht, kann alles lesen und ändern, auch private Nutzerdaten. Mit `select current_user;` im SQL-Editor prüfen, unter welcher Rolle man arbeitet.
|
||
- **Nicht öffentlich erreichbar** sein dürfen: Studio, der Postgres-Port 5432, der Pooler (Supavisor, Standard 6543) und die Kong-Route `/pg/` (pg-meta). Zugriff nur über SSH-Tunnel oder VPN; die Basic-Auth des Gateways (`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD`) bleibt mit starkem Passwort als zweite Schicht. Gilt ebenso für Staging. (Einrichtung und Bestätigung: Aufgabe D1, devops; Prüfschritte in [`release-checkliste.md`](release-checkliste.md) Abschnitt 4.)
|
||
- **Immer in einer Transaktion arbeiten:** `begin; … commit;` (bei Fehler `rollback;`). Vor größeren Änderungen den Stand des Backups prüfen.
|
||
- **Niemals `set …` ohne `local` verwenden** (zum Beispiel `set shuffle.catalog_import`): Ohne `local` gilt die Einstellung für die ganze Sitzung. Der Import-Schalter `shuffle.catalog_import` ist ohnehin nur für die Rolle `catalog_importer` wirksam und gehört nicht ins Studio. Hinweis: `set local` statt `set` ist eine Konvention des Imports; ein sitzungsweites `set` bleibt hinter einem Pooler bis zum Ende der Verbindung bestehen und kann auf die nächste Anfrage durchschlagen.
|
||
- Die Rolle `app_metadata.role = 'admin'` (JWT) wird in R1 **niemandem vergeben** (siehe 7).
|
||
|
||
## 2. Startbestand pflegen, einspielen und abnehmen
|
||
|
||
Der Startbestand sind etwa 200 Spiele (mindestens 180) mit **nur Fakten**: Titel, Verlag, Erscheinungsjahr, Spielerzahl, Spieldauer, Mindestalter, Genre, Mechanik, dazu ein eigener deutscher Kurztext. Kein Cover, keine fremden Beschreibungstexte (Krit. 7 und 10, Klärung N11).
|
||
|
||
### 2.1 Regeln (SB1–SB8)
|
||
|
||
| # | Regel |
|
||
|---|---|
|
||
| SB1 | Zum Go-live mindestens 180 sichtbare Katalogspiele aus dem Startbestand (Ziel etwa 200). |
|
||
| SB2 | Jedes Spiel hat alle Pflicht-Fakten: Titel, mindestens ein Verlag, Jahr, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, mindestens ein Genre, mindestens eine Mechanik. |
|
||
| SB3 | Herkunft der Fakten: Verlagsseiten, Spielschachtel, eigene Kenntnis, ergänzend Wikidata (CC0). **Nicht** systematisch aus BGG oder einer anderen fremden Datenbank übernehmen. Die Spalte `facts_source` in der CSV hält die Herkunft fest, sie landet nicht in der Datenbank. |
|
||
| SB4 | Jedes Spiel hat einen eigenen deutschen Kurztext (`description_locale` = `de`), geschrieben oder geprüft und freigegeben von Chris. KI-Entwürfe nur aus den Fakten, nie aus fremden Texten (auch nicht umformuliert). |
|
||
| SB5 | Genres und Mechaniken nur aus `supabase/catalog/vocabulary.csv` (Freigabe durch Chris, C0). |
|
||
| SB6 | Kein Cover, keine Quelle, keine BGG-Kennung. Die CSV hat dafür keine Spalten. |
|
||
| SB7 | Erweiterungen sind erlaubt; `base_game_key` merkt das Basisspiel vor (wird nur auf Existenz geprüft, bis KAT-03 die Verknüpfung baut). |
|
||
| SB8 | Alle gesetzten Felder gelten als manuell gepflegt (`manual_fields`); das erledigt der Trigger beim Einspielen. |
|
||
|
||
### 2.2 Dateien
|
||
|
||
| Datei | Art | Inhalt |
|
||
|---|---|---|
|
||
| `supabase/catalog/vocabulary.csv` | von Hand | Spalten `kind` (`genre`/`mechanic`), `vocab_key`, `name_de`, `name_en` |
|
||
| `supabase/catalog/startbestand.csv` | von Hand | die Spiele (Spalten siehe 2.3) |
|
||
| `supabase/catalog/build_startbestand.ts` | Werkzeug | prüft beide CSV-Dateien und erzeugt die Dateien darunter |
|
||
| `supabase/catalog/vocabulary.sql`, `startbestand.sql`, `startbestand-keys.csv`, `checks.sql` | **erzeugt**, nie von Hand ändern | Einspiel-Skripte, Schlüsselliste, Prüfabfrage |
|
||
| `supabase/catalog/testdata/` und `supabase/tests/support/*.inc` | Testdaten (fiktiv) und daraus erzeugt | nur für pgTAP |
|
||
|
||
### 2.3 CSV-Format `startbestand.csv`
|
||
|
||
Kopfzeile ist Pflicht, Trennzeichen Komma, Felder mit Komma oder Zeilenumbruch in Anführungszeichen (Tabellenkalkulationen exportieren das korrekt). Listen innerhalb eines Feldes trennt `;`.
|
||
|
||
| Spalte | Pflicht | Inhalt |
|
||
|---|---|---|
|
||
| `key` | ja | stabiler Schlüssel, `^[a-z0-9-]{2,60}$`, eindeutig, **unveränderlich** |
|
||
| `title` | ja | Haupttitel |
|
||
| `publishers` | ja | ein oder mehrere Verlage, getrennt durch `;` |
|
||
| `year` | ja | Erscheinungsjahr (1800–2100) |
|
||
| `min_players`, `max_players` | ja | Spielerzahl (`max` nicht kleiner als `min`) |
|
||
| `min_minutes`, `max_minutes` | ja | Spieldauer in Minuten |
|
||
| `min_age` | ja | Mindestalter |
|
||
| `genres` | ja | Genres aus dem Vokabular (`name_de`), getrennt durch `;` |
|
||
| `mechanics` | ja | Mechaniken aus dem Vokabular (`name_de`), getrennt durch `;` |
|
||
| `description` | ja | eigener Kurztext (SB4), höchstens 5000 Zeichen |
|
||
| `description_locale` | ja | immer `de` |
|
||
| `hidden` | nein | `ja` = ausblenden; leer = `hidden_at` bleibt unverändert |
|
||
| `facts_source` | nein | Herkunftsnachweis für SB3; nicht in der Datenbank |
|
||
| `alt_titles` | nein | alternative Titel mit Sprache: `de:Titel;en:Title`, Einträge durch `;` getrennt, Sprache `de` oder `en`, am **ersten** `:` getrennt (`de:Catan: Das Duell` ist zulässig). **`;` ist in einem alternativen Titel nicht erlaubt** (kein Escaping; solche Titel im Studio pflegen, siehe 3.1). Das Werkzeug bricht mit Zeilennummer ab bei: Sprache nicht `de`/`en`, Eintrag ohne `:`, leerem oder über 200 Zeichen langem Titel, einem Titel nur aus Sonderzeichen, demselben Titel in derselben Sprache doppelt oder gleich dem Haupttitel (ohne Beachtung von Schreibweise und Sonderzeichen). Eine leere Spalte entfernt früher eingespielte alternative Titel des Spiels (bis zum Go-live). **Bis zum Go-live gehören die alternativen Titel von Startbestandsspielen der CSV:** Das nächste Einspielen von `startbestand.sql` löscht alle Titel, die nicht in der Spalte stehen, also auch im Studio ergänzte. |
|
||
| `base_game_key` | nein | reserviert für KAT-03: `key` des Basisspiels (SB7) |
|
||
|
||
Andere Spalten (Cover, Quelle, BGG-Kennung) lehnt das Werkzeug ab.
|
||
|
||
### 2.4 Ablauf: pflegen, prüfen, erzeugen, committen
|
||
|
||
```bash
|
||
# Deno wird für das Werkzeug gebraucht (https://deno.com, getestet mit 2.9.8)
|
||
deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts
|
||
deno test --allow-read=supabase supabase/catalog/
|
||
git add supabase/catalog supabase/tests/support && git commit
|
||
```
|
||
|
||
- Das Werkzeug bricht mit **Dateiname:Zeile und Grund** ab, wenn etwas nicht stimmt (fehlende Pflichtangabe, unbekanntes Genre oder unbekannte Mechanik, doppelter Schlüssel, `max < min`, Wert außerhalb der Datenbank-Grenzen, fehlender Kurztext, Sprache ≠ `de`, `base_game_key` ohne passenden Schlüssel, entfernter Schlüssel). Dabei wird nichts geschrieben.
|
||
- **Schlüssel nie umbenennen oder entfernen.** Jeder Schlüssel ergibt eine feste Spiel-ID (UUID v5, Namespace unten); `startbestand-keys.csv` hält sie fest und wächst nur (CI vergleicht mit `main`). Ein falsch geschriebener Schlüssel bleibt stehen, nur der Titel wird korrigiert.
|
||
- **Spiel entfernen:** Zeile bleibt in der CSV, `hidden` = `ja`. Gelöscht wird nie (Verweise aus Sammlungen bleiben gültig).
|
||
- **Wieder einblenden** geht nur im Studio (`hidden_at = null`, siehe 4): Ist `hidden` leer, fasst die Datei `hidden_at` nicht an.
|
||
- **UUID-v5-Namespace** (fest, nie ändern): `6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4`. Namen: Spiel `game:<key>`, Genre `genre:<vocab_key>`, Mechanik `mechanic:<vocab_key>`. Verlage haben keine abgeleitete ID (sie werden über `lower(name)` aufgelöst, damit im Studio angelegte Verlage nicht stören).
|
||
- Das Vokabular kann in `vocabulary.csv` um Begriffe wachsen. Begriffe umbenennen: nicht in der CSV (die ID ist an `vocab_key` gebunden), sondern im Studio.
|
||
|
||
### 2.5 Einspielen
|
||
|
||
**Reihenfolge immer: Migrationen, `vocabulary.sql`, `startbestand.sql`.** Beide Dateien sind idempotent und dürfen **bis zum Go-live** wiederholt laufen; unveränderte Zeilen werden nicht angefasst.
|
||
|
||
| Umgebung | Weg |
|
||
|---|---|
|
||
| lokal, CI | `supabase db reset` (`supabase/config.toml`, `[db.seed] sql_paths`: `seed.sql`, `catalog/vocabulary.sql`, `catalog/startbestand.sql`) |
|
||
| Staging, Produktion | per Tunnel/VPN: `psql "$DB_URL" -v ON_ERROR_STOP=1 --single-transaction -f supabase/catalog/vocabulary.sql -f supabase/catalog/startbestand.sql` (alternativ Inhalt im Studio-SQL-Editor ausführen) |
|
||
|
||
- `startbestand.sql` bricht mit einer Fehlermeldung ab, wenn ein Genre oder eine Mechanik in der Datenbank fehlt (Vokabular nicht eingespielt oder im Studio umbenannt). Es löscht dann nichts.
|
||
- **Kollision mit im Studio angelegten Begriffen:** `vocabulary.sql` fügt mit `on conflict do nothing` ein, der vorhandene Begriff gewinnt; `startbestand.sql` löst Genres und Mechaniken über `name_de` auf. `checks.sql` meldet solche Begriffe als Information.
|
||
- **Ab dem Go-live ist `startbestand.sql` eingefroren** und wird nicht mehr eingespielt (sonst überschreibt es Korrekturen aus dem Studio). Korrekturen danach nur noch im Studio (siehe 3).
|
||
|
||
### 2.6 Abnehmen: `checks.sql`
|
||
|
||
```bash
|
||
psql "$DB_URL" -f supabase/catalog/checks.sql # oder Inhalt im Studio-SQL-Editor
|
||
```
|
||
|
||
Je Regel eine Zeile (Soll, Ist, Status `ok`/`Verstoß`/`Information`). **Abnahme (Krit. 7 und 10):** SB1 meldet mindestens 180 sichtbare Spiele, SB2, SB4 und SB6 melden 0. Die Abfrage betrachtet nur Spiele aus `startbestand-keys.csv`; im Studio angelegte Spiele und das Beispielspiel aus `seed.sql` (nur lokal) zählen nicht. Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird. Vor dem Go-live ist `checks.sql` Teil der [Release-Checkliste](release-checkliste.md) (Abschnitt 2). Die Herkunft der Fakten (SB3) und der Kurztexte (SB4) bestätigt Chris selbst; die rechtliche Prüfung folgt vor dem Release.
|
||
|
||
### 2.7 Ergebnis der Prüfung der CLI-Annahmen (Spec R11, Supabase CLI 3.0.0, geprüft am 2026-11-03)
|
||
|
||
| Annahme | Ergebnis |
|
||
|---|---|
|
||
| `db reset` lädt alle Dateien aus `sql_paths` in der angegebenen Reihenfolge | **bestätigt** (Ausgabe `Seeding data from supabase/seed.sql…`, danach die weiteren Dateien in Listenreihenfolge) |
|
||
| `begin;`/`commit;` in einer Seed-Datei ist zulässig | **bestätigt**: Die Anweisungen laufen, die Daten sind danach da. Trotzdem enthalten die erzeugten Dateien keine, weil die CLI jede Datei als Batch sendet (siehe nächste Zeile) und ein Fehler sie ganz zurückrollt; auf Staging/Prod sorgt `--single-transaction` für dasselbe. |
|
||
| Fehler in einer Seed-Datei | Die Datei wird **als Ganzes zurückgerollt** (eine Anweisung vor dem Fehler blieb nicht erhalten), `db reset` endet mit Exit-Code 1 und `MigrationSeedError`. |
|
||
| Erzeugte Dateien mit echtem Inhalt (Wächter-`do`-Block, Zuordnungs-CTEs) über den Seed-Lader der CLI | **bestätigt**: `db reset` endet mit Exit 0, danach sind 3 Testspiele und je 4 Genre-, Mechanik- und Verlagszuordnungen da. Der Wächter löst bei einem umbenannten Genre den Fehler `startbestand: Genre fehlt in der Datenbank …` aus und lässt alle Zuordnungen unverändert. |
|
||
| Pfad ohne Treffer in `sql_paths` | Nur die Warnung `no files matched pattern: …`, **kein Fehler**, Exit-Code 0. Ein Tippfehler im Pfad überspringt die Datei also still; nach dem Reset `checks.sql` bzw. die Zeilenzahlen ansehen. |
|
||
| Datei, die nur Kommentare enthält | läuft ohne Fehler |
|
||
| psql-Metabefehle (`\i` …) in Seed-Dateien | nicht nutzbar (die Datei wird als SQL-Batch gesendet) |
|
||
| `supabase start` lädt Seeds nur beim ersten Start einer leeren Datenbank | **nicht geprüft** (hätte Chris' laufenden lokalen Stack stoppen müssen). Nicht darauf verlassen: nach Änderungen an Migrationen oder Seeds immer `supabase db reset`. Nur lokal relevant. `supabase db reset` niemals gegen Staging/Prod (löscht alle Daten); Seeds dort ausschließlich per `psql` (2.5, [Release-Checkliste](release-checkliste.md) Abschnitt 2). |
|
||
|
||
## 3. Spiel anlegen und bearbeiten
|
||
|
||
Jede Änderung an einem Fakt eines Katalogspiels (und jede Zuordnung zu Verlag, Genre, Mechanik oder Kategorie) trägt der Trigger automatisch in `games.manual_fields` ein, damit ein späterer Import sie nicht überschreibt. Im Studio muss dafür nichts zusätzlich gepflegt werden. Als Rolle `postgres` bleibt das Kennzeichnen auch dann aktiv, wenn jemand `shuffle.catalog_import` setzt.
|
||
|
||
**Spiel finden, bevor man es ändert.** Titel sind nicht eindeutig (es kann gleichnamige Duplikate geben, siehe 5). Deshalb zuerst die ID nachschlagen und alle weiteren Bausteine über `id` ausführen:
|
||
|
||
```sql
|
||
select id, title, year, hidden_at from public.games
|
||
where visibility = 'catalog' and lower(title) = lower('Beispielspiel Runbook');
|
||
```
|
||
|
||
**Neues Katalogspiel mit Verlag und Genre** (Werte ersetzen; in einer Transaktion). Die ID vorher mit `select gen_random_uuid();` erzeugen und in diesem und den folgenden Bausteinen einsetzen (hier die Beispiel-ID):
|
||
|
||
```sql
|
||
insert into public.publishers (name) values ('Beispielverlag')
|
||
on conflict ((lower(name))) do nothing;
|
||
|
||
with neu as (
|
||
insert into public.games (id, title, year, min_players, max_players, min_minutes, max_minutes, min_age,
|
||
description, description_locale, visibility)
|
||
values ('7a1b2c3d-0000-4000-8000-000000000001', 'Beispielspiel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog')
|
||
returning id
|
||
), verlag as (
|
||
insert into public.game_publishers (game_id, publisher_id)
|
||
select neu.id, p.id from neu, public.publishers p where lower(p.name) = lower('Beispielverlag')
|
||
)
|
||
insert into public.game_genres (game_id, genre_id)
|
||
select neu.id, g.id from neu, public.genres g where g.name_de = 'Strategiespiel';
|
||
```
|
||
|
||
Weitere Mechaniken, Kategorien und Verlage ordnet man mit `insert into public.game_mechanics (game_id, mechanic_id) …` usw. nach demselben Muster zu. Kurztext immer selbst schreiben (SB4), nie fremde Texte kopieren. Katalogspiele haben `owner_id` leer.
|
||
|
||
**Fakten ändern:**
|
||
|
||
```sql
|
||
update public.games set min_age = 10, max_minutes = 90
|
||
where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog';
|
||
```
|
||
|
||
**Markierung „manuell gepflegt“ bewusst entfernen** (nur per SQL; eine Änderung allein an `manual_fields` wird vom Trigger nicht zurückgesetzt):
|
||
|
||
```sql
|
||
update public.games set manual_fields = array_remove(manual_fields, 'min_age')
|
||
where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog';
|
||
```
|
||
|
||
Regeln:
|
||
|
||
- **Katalogspiele nie löschen, sondern ausblenden** (siehe 4). Zugeordnete Sammlungen, Favoriten und Partien verweisen auf sie.
|
||
- **`merged_into_id` nie von Hand setzen**; Zusammenführen 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`, `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. **Bei Startbestandsspielen bis zum Go-live nur über die CSV pflegen:** Sonst entfernt das nächste Einspielen von `startbestand.sql` die im Studio angelegten Titel wieder (Abgleich wie bei Verlagen und Genres). Nach dem Go-live (Startbestand eingefroren) und für alle anderen Spiele ist das Studio der Weg. Der Trigger trägt `alt_titles` in `games.manual_fields` ein. Ein Titel nur aus Sonderzeichen wird abgelehnt; derselbe Titel in derselben Sprache ist nur einmal möglich, egal in welcher Schreibweise. Beispiel-ID wie in 3: `7a1b2c3d-0000-4000-8000-000000000001`.
|
||
|
||
**Anlegen oder korrigieren** (ein vorhandener Titel in anderer Schreibweise wird aktualisiert statt doppelt angelegt):
|
||
|
||
```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
|
||
|
||
Ausgeblendete Spiele (`hidden_at` gesetzt) erscheinen nicht mehr in Suche und Entdecken, weil die App Katalogdaten über die View `catalog_games` liest. Detailseite, bestehende Sammlungen, Favoriten und Partien bleiben erhalten (Spec-F2); die App zeigt dort „nicht mehr im Katalog“.
|
||
|
||
```sql
|
||
-- ausblenden
|
||
update public.games set hidden_at = now()
|
||
where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog' and hidden_at is null;
|
||
|
||
-- wieder einblenden
|
||
update public.games set hidden_at = null
|
||
where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog';
|
||
```
|
||
|
||
Hinweis: Der Eintrag bleibt über die rohe Schnittstelle per ID lesbar (`games` ist öffentlich lesbar). Muss ein Spiel aus rechtlichen Gründen wirklich verschwinden, zusätzlich Titel und Texte inhaltlich leeren.
|
||
|
||
## 5. Zusammenführen
|
||
|
||
Zwei Katalogspiele, die dasselbe Spiel beschreiben (Duplikat), führt man mit der Funktion `public.admin_merge_games(quelle, ziel)` zusammen. **Nie per Hand-SQL** (`update … set game_id`): Das Zusammenführen berührt Sammlungen, Favoriten, Partien (mit Edition), Wertungsbögen und Varianten, dazu eindeutige Schlüssel und zusammengesetzte Fremdschlüssel. Die Funktion macht alles in einer Transaktion und meldet, was passiert ist.
|
||
|
||
- **Quelle** ist das Duplikat, das verschwindet. Sie wird **nicht gelöscht**, sondern ausgeblendet und auf das Ziel verwiesen (`hidden_at`, `merged_into_id`); ihre ID bleibt gültig, die App leitet alte Links auf das Ziel um (KAT-02).
|
||
- **Ziel** ist der Eintrag, der bleibt. Er muss sichtbar sein (nicht ausgeblendet, nicht selbst zusammengeführt). Das Ziel behält seine Fakten, Kurztexte und Zuordnungen (Verlag, Genre, Mechanik, Kategorie); die Zuordnungen der Quelle bleiben am ausgeblendeten Quell-Eintrag. Was am Ziel fehlt, ergänzt man vorher oder nachher mit den Bausteinen aus 3.
|
||
- Beide müssen **Katalogspiele** sein. Einen vorläufigen privaten Eintrag eines Nutzers führt man nicht hiermit zusammen (dafür gibt es die Meldungs-Funktion, siehe 6).
|
||
- **Es gibt kein Rückgängigmachen.** Vor dem Zusammenführen den Stand des Backups prüfen; im Zweifel zuerst die Vorschau (unten) lesen.
|
||
|
||
**Schritt 1: Duplikate finden** (normalisierter Titel + Jahr, nur sichtbare Katalogspiele; die älteste ID steht zuerst):
|
||
|
||
```sql
|
||
select lower(btrim(title)) as titel_normalisiert, year,
|
||
array_agg(id order by created_at, id) as ids, count(*) as anzahl
|
||
from public.catalog_games
|
||
group by lower(btrim(title)), year
|
||
having count(*) > 1
|
||
order by 1, 2;
|
||
```
|
||
|
||
Ein Duplikat kann auch einen leicht anderen Titel haben. Dann gezielt suchen und **die IDs ablesen** (Titel sind nicht eindeutig, alle weiteren Bausteine laufen über `id`):
|
||
|
||
```sql
|
||
select id, title, year, hidden_at, merged_into_id, bgg_id, created_at from public.games
|
||
where visibility = 'catalog' and lower(btrim(title)) = lower(btrim('Duplikat Runbook'))
|
||
order by created_at, id;
|
||
```
|
||
|
||
Welcher Eintrag das Ziel wird, entscheidet der Admin; sinnvoll ist der mit den besseren Fakten, der BGG-Kennung oder den meisten Verweisen. In den Bausteinen unten stehen Beispiel-IDs: `7a1b2c3d-0000-4000-8000-000000000002` ist die Quelle, `7a1b2c3d-0000-4000-8000-000000000003` das Ziel. Immer durch die echten IDs ersetzen.
|
||
|
||
**Schritt 2: Vorschau.** Wie viele Verweise hängen an der Quelle?
|
||
|
||
```sql
|
||
select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items where game_id = '7a1b2c3d-0000-4000-8000-000000000002'
|
||
union all select 'favorite_games', count(*) from public.favorite_games where game_id = '7a1b2c3d-0000-4000-8000-000000000002'
|
||
union all select 'plays', count(*) from public.plays where game_id = '7a1b2c3d-0000-4000-8000-000000000002'
|
||
union all select 'score_sheets', count(*) from public.score_sheets where game_id = '7a1b2c3d-0000-4000-8000-000000000002'
|
||
union all select 'game_variants', count(*) from public.game_variants where game_id = '7a1b2c3d-0000-4000-8000-000000000002';
|
||
```
|
||
|
||
Wer hat beide Spiele in der Sammlung (Doppelbesitz)? Für diese Nutzer gilt die Kollisionsregel (siehe unten):
|
||
|
||
```sql
|
||
select s.owner_id, s.list as liste_quelle, t.list as liste_ziel
|
||
from public.collection_items s
|
||
join public.collection_items t on t.owner_id = s.owner_id and t.game_id = '7a1b2c3d-0000-4000-8000-000000000003'
|
||
where s.game_id = '7a1b2c3d-0000-4000-8000-000000000002';
|
||
```
|
||
|
||
**Schritt 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
|
||
select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003');
|
||
```
|
||
|
||
**Trockenlauf (empfohlen vor dem ersten echten Merge).** Das Ergebnis lesen und alles zurückrollen, **in einem einzigen Lauf**:
|
||
|
||
```sql
|
||
begin;
|
||
select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003');
|
||
rollback;
|
||
```
|
||
|
||
Danach ist nichts verändert; mit `commit;` statt `rollback;` wird der Lauf endgültig. Achtung: `begin;` und `commit;`/`rollback;` in getrennten Läufen funktionieren nur in `psql` (eine Sitzung). Der SQL-Editor in Studio führt jeden Lauf womöglich in einer eigenen Verbindung bzw. Transaktion aus; dort immer alles in einem Lauf ausführen. (Dieser Trockenlauf enthält Transaktionssteuerung und kann deshalb nicht wörtlich in pgTAP laufen; der enthaltene Aufruf ist identisch zu Schritt 3 und dort getestet.)
|
||
|
||
**Schritt 4: Nachprüfen.** Der Vorschau-Baustein aus Schritt 2 (mit der Quelle) muss jetzt überall `0` liefern. Zustand beider Einträge:
|
||
|
||
```sql
|
||
select id, title, hidden_at is not null as ausgeblendet, merged_into_id, bgg_id, catalog_source_id
|
||
from public.games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003');
|
||
```
|
||
|
||
Die Quelle ist ausgeblendet und verweist auf das Ziel. Das Ziel bleibt sichtbar; Fakten, Kurztext und Zuordnungen sind unverändert. **Ändern können sich `bgg_id` und die Quelle (`catalog_source_id`, `source_*`)**, wenn das Ziel sie von der Quelle übernommen hat (siehe `bgg_id_transferred` und `source_transferred` im Ergebnis). Außerdem darf kein Spiel mehr auf die Quelle verweisen; diese Abfrage muss leer sein (Ketten sind geglättet):
|
||
|
||
```sql
|
||
select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002';
|
||
```
|
||
|
||
### Bekannte Einschränkung: nachträgliche Verweise auf eine zusammengeführte Quelle
|
||
|
||
Nach dem Zusammenführen kann sich noch Neues an die ausgeblendete Quelle hängen, zum Beispiel durch einen veralteten Client oder eine offline erfasste Partie (`plays.client_id`), die erst nach dem Merge synchronisiert wird. Das ist eine bekannte Einschränkung, kein Fehler: Die Quelle wird nie gelöscht und bleibt als Fremdschlüsselziel gültig, die App leitet nur die Anzeige auf das Ziel um. Die Verweise verschwinden also nicht, sie hängen nur am falschen Eintrag.
|
||
|
||
**Erkennung** (nach einem Merge, bei Verdacht oder als gelegentliche Kontrolle; die Zahlen müssen überall `0` sein):
|
||
|
||
```sql
|
||
select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items t join public.games g on g.id = t.game_id
|
||
where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null
|
||
union all select 'favorite_games', count(*) from public.favorite_games t join public.games g on g.id = t.game_id
|
||
where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null
|
||
union all select 'plays', count(*) from public.plays t join public.games g on g.id = t.game_id
|
||
where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null
|
||
union all select 'score_sheets', count(*) from public.score_sheets t join public.games g on g.id = t.game_id
|
||
where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null
|
||
union all select 'game_variants', count(*) from public.game_variants t join public.games g on g.id = t.game_id
|
||
where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null;
|
||
```
|
||
|
||
**Behebung:** als `postgres` die interne Umhänge-Funktion erneut aufrufen, in einer Transaktion. `admin_merge_games` geht hier nicht mehr (`merge_source_already_merged`):
|
||
|
||
```sql
|
||
select public.repoint_game_references('7a1b2c3d-0000-4000-8000-000000000002'::uuid, '7a1b2c3d-0000-4000-8000-000000000003'::uuid);
|
||
```
|
||
|
||
Die Funktion arbeitet wie beim Merge (Kollisionsregeln, Favoriten, Editionen). Da die Quelle ein Katalogspiel ist, werden Varianten, die erst nachträglich an der Quelle entstanden sind, am Ziel kopiert beziehungsweise auf gleichnamige abgebildet und danach an der Quelle gelöscht. Die Quelle bleibt ausgeblendet, `merged_into_id` und `hidden_at` ändern sich nicht. Das Ergebnis-JSON hat dieselben Schlüssel wie beim Merge (ohne die Felder von `admin_merge_games`).
|
||
|
||
**Nachprüfung:** den Erkennungs-Baustein erneut ausführen; er muss überall `0` liefern.
|
||
|
||
**Was die Funktion macht**
|
||
|
||
| Was | Regel |
|
||
|---|---|
|
||
| Sammlung (`collection_items`) | Hat der Nutzer nur die Quelle, wechselt der Eintrag auf das Ziel. Hat er beide (Doppelbesitz, Spec-F1): Der Zieleintrag bleibt; leere Felder (Notiz, Erwerbsdatum, Edition) füllt der Quell-Eintrag; bei unterschiedlicher Liste gilt Sammlung vor Wunschliste vor Einkaufsliste; der Quell-Eintrag wird gelöscht. Eine nicht leere Notiz am Ziel wird nie überschrieben. |
|
||
| Favoriten | Hat der Nutzer das Ziel auch als Favorit, entfällt der Favorit der Quelle (die Lücke in der Reihenfolge ist zulässig); sonst wechselt er auf das Ziel. |
|
||
| Partien, Wertungsbögen | wechseln auf das Ziel; die Edition einer Partie wird über die Variantenzuordnung übertragen. |
|
||
| Varianten (Editionen) | Für jede Variante der Quelle: Hat das Ziel eine gleichnamige Variante (Vergleich ohne Groß-/Kleinschreibung und ohne **Leerzeichen** am Rand; Tabulatoren und Zeilenumbrüche am Rand zählen als Teil des Namens), wird darauf abgebildet; gibt es mehrere gleichnamige, gilt die älteste (bei Gleichstand die kleinste ID). Sonst wird die Variante am Ziel neu angelegt (Name, Jahr, Sprache, BGG-Kennung). Danach werden die Varianten der Quelle gelöscht. |
|
||
| BGG-Kennung und Quelle | Hat das Ziel keine, aber die Quelle, übernimmt das Ziel sie (`bgg_id`; ebenso Quelle, `source_ref`, `source_url`, `source_synced_at` als Gruppe). An der Quelle werden sie in jedem Fall geleert, damit der BGG-Abgleich später den ausgeblendeten Eintrag nicht trifft. |
|
||
| Ketten | Spiele, die bisher auf die Quelle verwiesen, verweisen jetzt auf das Ziel. |
|
||
|
||
**Ergebnis lesen** (Beispiel):
|
||
|
||
```
|
||
{"plays": 7, "target_id": "…", "source_id": "…", "score_sheets": 1, "favorite_games": 1, "collection_items": 3,
|
||
"variants_copied": 2, "variants_mapped": 1, "variants_cleared": 0, "chain_flattened": 0,
|
||
"favorite_conflicts": 0, "bgg_id_transferred": true, "source_transferred": false,
|
||
"collection_conflicts": 1, "reports_repointed": 0}
|
||
```
|
||
|
||
| Schlüssel | Bedeutung |
|
||
|---|---|
|
||
| `collection_items` | Sammlungseinträge, die ohne Kollision auf das Ziel gewechselt sind |
|
||
| `collection_conflicts` | Doppelbesitz: in den Zieleintrag eingearbeitet, Quell-Eintrag gelöscht |
|
||
| `favorite_games`, `favorite_conflicts` | Favoriten umgehängt bzw. wegen vorhandenem Favoriten am Ziel entfallen |
|
||
| `plays`, `score_sheets` | umgehängte Partien und Wertungsbögen |
|
||
| `variants_copied`, `variants_mapped` | Varianten, die am Ziel neu angelegt bzw. auf eine gleichnamige abgebildet wurden |
|
||
| `variants_cleared` | Sammlungseinträge und Partien, deren Edition geleert wurde. Beim Zusammenführen zweier Katalogspiele bleibt das `0`; es kommt nur beim Umhängen privater Einträge vor (Abschnitt 6) |
|
||
| `bgg_id_transferred`, `source_transferred` | `true`, wenn das Ziel die BGG-Kennung bzw. die Quelle der Quelle übernommen hat |
|
||
| `bgg_id_conflict` | nur vorhanden, wenn beide eine **unterschiedliche** BGG-Kennung hatten: `{"source": 13, "target": 42}`. Das Ziel behält seinen Wert, der Wert der Quelle ist geleert und steht **nur noch hier**. Bei Bedarf notieren und prüfen, ob das Ziel das richtige Spiel ist |
|
||
| `source_conflict` | wie `bgg_id_conflict` für die Quelle (`source` und `target` mit `catalog_source_id`, `source_ref`, `source_url`, `source_synced_at`) |
|
||
| `chain_flattened` | Anzahl früher zusammengeführter Spiele, die jetzt direkt auf das Ziel zeigen |
|
||
| `reports_repointed` | Meldungen (`game_reports`), deren Zieleintrag die Quelle war und die jetzt auf das Ziel zeigen (Abschnitt 6) |
|
||
|
||
**Fehlermeldungen** (alle ohne Änderung, `P0001`):
|
||
|
||
| Meldung | Bedeutung |
|
||
|---|---|
|
||
| `admin_only` | Die Sitzung läuft nicht als `postgres` bzw. `supabase_admin` (`select current_user;` prüfen) |
|
||
| `merge_same_game` | Quelle und Ziel sind dieselbe ID |
|
||
| `merge_game_not_found` | Quelle oder Ziel gibt es nicht (ID vertippt?) |
|
||
| `merge_not_catalog` | Quelle oder Ziel ist ein privater Eintrag; Zusammenführen gilt nur für Katalogspiele |
|
||
| `merge_target_not_listed` | Das Ziel ist ausgeblendet oder selbst schon zusammengeführt. Ist es zusammengeführt, das endgültige Ziel über `merged_into_id` nachschlagen (Baustein unten, hier mit der Quelle aus dem Beispiel; Ketten sind geglättet, ein Schritt genügt) oder ein ausgeblendetes Ziel zuerst wieder einblenden (siehe 4) |
|
||
| `merge_source_already_merged` | Die Quelle ist bereits zusammengeführt |
|
||
|
||
Nachschlagen, wohin ein zusammengeführtes Spiel verweist (`merged_into_id` ist das endgültige Ziel):
|
||
|
||
```sql
|
||
select id, title, hidden_at, merged_into_id from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002';
|
||
```
|
||
|
||
Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Bei Zeitüberschreitung oder anderem Fehler `rollback;` und erneut prüfen. Löscht man ein Zielspiel, auf das etwas verweist, schlägt das mit `23503` fehl (gewollt: ausblenden statt löschen).
|
||
|
||
## 6. Meldungen bearbeiten
|
||
|
||
Nutzer melden fehlende Spiele (KAT-04, #13); Meldungen, die ein Import anlegt, gibt es ab SAM-08 (`origin = 'import'`). In der Tabelle `game_reports` steht, was der Nutzer angegeben hat (Titel, Verlag, Jahr, Link, BGG-Kennung) und, falls vorhanden, sein **vorläufiger privater Eintrag** (`provisional_game_id`, nur für ihn sichtbar). Der Admin entscheidet in drei Wegen, immer mit der Funktion `public.admin_resolve_report(meldung, status, ziel)`:
|
||
|
||
| Entscheidung | Aufruf | Wirkung |
|
||
|---|---|---|
|
||
| **freigeben** | `'approved'` + Ziel | Der Eintrag ist wegen dieser Meldung **neu** entstanden. Der vorläufige Eintrag des Nutzers wird auf das Ziel umgehängt (Sammlung, Favoriten, Partien, Wertungsbögen) und ausgeblendet. |
|
||
| **zusammenführen / auf vorhandenen Eintrag verweisen** | `'merged'` + Ziel | Der Eintrag war **schon vorhanden**. Wirkung wie bei `'approved'`: der vorläufige Eintrag wird direkt umgehängt, ohne Aktion des Nutzers. **Das ist der einzige Weg, den Nutzer auf einen vorhandenen Eintrag zu verweisen.** |
|
||
| **ablehnen** | `'rejected'`, **nur ohne Ziel** | Ändert nichts außer Status und Zeitpunkt; der vorläufige Eintrag bleibt privat und unverändert. Mit Ziel scheitert der Aufruf (`report_reject_has_target`); auch die Tabelle erlaubt kein Ziel bei `rejected` (Check `game_reports_rejected_no_target`). *Entscheidung Chris, 2026-11-04: Es gibt kein „abgelehnt mit Verweis“ mehr.* |
|
||
|
||
**Nutzer-Meldungen (KAT-04, ab PR 13a):** Jede Meldung, die ein Nutzer in der App abschickt, hat einen **vorläufigen Eintrag** (`provisional_game_id`), sofern der Nutzer ihn nicht inzwischen gelöscht hat (dann ist die Spalte leer; `approved`/`merged` ändern dann nur Status und Ziel). Die Meldung entsteht über die RPC `report_missing_game` (höchstens 10 Meldungen je Nutzer und Berliner Kalendertag; Import-Meldungen zählen nicht). Daraus folgt für die Bearbeitung:
|
||
|
||
- **Mit Verweis auf einen vorhandenen Eintrag heißt `'merged'`, nie `'rejected'`.** Status nur über `admin_resolve_report` setzen, nie von Hand: Die App leitet den Hinweis an den Nutzer aus Status, `merged_into_id` am vorläufigen Eintrag und `result_seen_at` ab.
|
||
- **Vor `approved` und `merged` die Edition berücksichtigen** (Regel „Editionen werden nicht kopiert“ unten): Die App zeigt dem Nutzer nach einer Freigabe den Hinweis „Edition neu wählen“, wenn `editions_cleared > 0`.
|
||
- Der Nutzer kann nur `result_seen_at` ändern (Hinweis „Verstanden“); das Ergebnis der Bearbeitung kann er nicht beeinflussen.
|
||
|
||
**Regeln**
|
||
|
||
- **Nie einen privaten Eintrag öffentlich schalten.** `visibility` und `owner_id` eines Spiels lassen sich nicht ändern (der Trigger `games_visibility_immutable` lehnt es für jede Rolle ab, auch für `postgres`, Fehler `visibility_immutable`). Freigeben heißt immer: **neuen** Katalogeintrag anlegen.
|
||
- **Beim Anlegen nur Fakten aus den Meldungsfeldern übernehmen:** `title`, `publisher_name`, `year`; `link` dient nur als Recherchehilfe. **Nie** `description` oder `image_path` des vorläufigen Eintrags übernehmen (das sind private Angaben des Nutzers). Den Kurztext schreibt der Admin selbst (SB4, deutsch).
|
||
- **Datenschutz:** vom vorläufigen Eintrag nur ansehen, was die Entscheidung braucht (Anzahl Verweise, Namen der Editionen). Die Meldung selbst gehört zum Konto des Nutzers und wird mit ihm gelöscht.
|
||
- **Editionen werden nicht kopiert** (RB1, Klärung N15). Hat der Katalogeintrag eine gleichnamige Edition (Vergleich ohne Groß-/Kleinschreibung und ohne Leerzeichen am Rand), bleibt die Edition des Nutzers erhalten, sonst wird sie an Sammlungseintrag und Partien geleert (`variants_cleared` im Ergebnis). Soll die Edition erhalten bleiben, **vorher** am Katalogeintrag eine Variante anlegen, und zwar nur aus belegbaren Fakten (Name und Jahr der Edition aus Verlags- oder Schachtelangaben), nie durch Übernahme der privaten Variante. Die private Variante bleibt am ausgeblendeten vorläufigen Eintrag und wird mit dem Konto gelöscht.
|
||
- Das Ziel muss ein sichtbares Katalogspiel sein (nicht ausgeblendet, nicht zusammengeführt). Es gibt kein Rückgängigmachen; in einer Transaktion arbeiten (`begin;` davor, nach dem Lesen des Ergebnisses `commit;`, bei Zweifel `rollback;`; die Bausteine stehen wie überall ohne `begin`/`commit`).
|
||
- Wird ein Zieleintrag später mit einem anderen Katalogspiel zusammengeführt (Abschnitt 5), zeigen die Meldungen automatisch auf das neue Ziel (`reports_repointed` im Ergebnis).
|
||
|
||
In den Bausteinen stehen Beispiel-IDs: `7c1d2c3d-0000-4000-8000-000000000001` ist die Meldung, `…0002` das neu angelegte Katalogspiel, `…0004` ein vorhandenes Katalogspiel. Immer durch die echten IDs ersetzen (neue ID erzeugen: `select gen_random_uuid();`).
|
||
|
||
**Schritt 1: Offene Meldungen** (älteste zuerst):
|
||
|
||
```sql
|
||
select r.id, r.created_at, r.origin, r.title, r.publisher_name, r.year, r.link, r.bgg_id, r.provisional_game_id
|
||
from public.game_reports r
|
||
where r.status = 'open'
|
||
order by r.created_at, r.id;
|
||
```
|
||
|
||
**Schritt 2: Ähnliche Spiele im Katalog suchen** (gleicher Titel ohne Groß-/Kleinschreibung oder gleiche BGG-Kennung wie die Meldung). Gibt es schon einen Eintrag, entscheidet man sich für `'merged'`:
|
||
|
||
```sql
|
||
select g.id, g.title, g.year, g.bgg_id
|
||
from public.game_reports r
|
||
join public.catalog_games g
|
||
on lower(btrim(g.title)) = lower(btrim(r.title))
|
||
or (r.bgg_id is not null and g.bgg_id = r.bgg_id)
|
||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001'
|
||
order by g.title, g.id;
|
||
```
|
||
|
||
Was hängt am vorläufigen Eintrag des Nutzers? Nur Anzahlen:
|
||
|
||
```sql
|
||
select g.id, g.title,
|
||
(select count(*) from public.collection_items c where c.game_id = g.id) as sammlung,
|
||
(select count(*) from public.favorite_games f where f.game_id = g.id) as favoriten,
|
||
(select count(*) from public.plays p where p.game_id = g.id) as partien,
|
||
(select count(*) from public.score_sheets s where s.game_id = g.id) as wertungsboegen
|
||
from public.game_reports r
|
||
join public.games g on g.id = r.provisional_game_id
|
||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
|
||
```
|
||
|
||
Und die Namen seiner Editionen (für die Entscheidung, ob am Katalogeintrag eine gleichnamige Variante angelegt wird):
|
||
|
||
```sql
|
||
select v.id, v.name, v.year, v.language
|
||
from public.game_reports r
|
||
join public.game_variants v on v.game_id = r.provisional_game_id
|
||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001'
|
||
order by v.name, v.id;
|
||
```
|
||
|
||
**Schritt 3a: Freigeben** (neuer Eintrag). Zuerst den Katalogeintrag mit den Fakten aus der Meldung und einem eigenen Kurztext anlegen (jedes gesetzte Feld wird automatisch in `manual_fields` vermerkt):
|
||
|
||
```sql
|
||
insert into public.games (id, title, year, description, description_locale, visibility)
|
||
values ('7c1d2c3d-0000-4000-8000-000000000002', 'Meldungsspiel', 2024, 'Eigener Kurztext des Admins.', 'de', 'catalog');
|
||
```
|
||
|
||
Verlag zuordnen (ein vorhandener Verlag wird wiederverwendet, die Schreibweise entscheidet nicht):
|
||
|
||
```sql
|
||
insert into public.publishers (name) values ('Beispielverlag') on conflict ((lower(name))) do nothing;
|
||
insert into public.game_publishers (game_id, publisher_id)
|
||
select '7c1d2c3d-0000-4000-8000-000000000002', p.id from public.publishers p where lower(p.name) = lower('Beispielverlag');
|
||
```
|
||
|
||
Optional, wenn die Edition des Nutzers erhalten bleiben soll (nur belegbare Fakten, siehe Regeln):
|
||
|
||
```sql
|
||
insert into public.game_variants (game_id, name, year) values ('7c1d2c3d-0000-4000-8000-000000000002', 'Deluxe', 2024);
|
||
```
|
||
|
||
Dann die Meldung abschließen und das Ergebnis lesen:
|
||
|
||
```sql
|
||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', '7c1d2c3d-0000-4000-8000-000000000002');
|
||
```
|
||
|
||
**Schritt 3b: Zusammenführen** (der Eintrag war schon da, Ziel ist das vorhandene Katalogspiel):
|
||
|
||
```sql
|
||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004');
|
||
```
|
||
|
||
**Schritt 3c: Ablehnen**, immer ohne Ziel (soll auf ein vorhandenes Katalogspiel verwiesen werden, ist es Schritt 3b mit `'merged'`):
|
||
|
||
```sql
|
||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected');
|
||
```
|
||
|
||
**Schritt 4: Nachprüfen.** Status der Meldung und Zustand des vorläufigen Eintrags (bei `approved`/`merged`: ausgeblendet und verwiesen; bei `rejected`: unverändert):
|
||
|
||
```sql
|
||
select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, r.editions_cleared,
|
||
g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id
|
||
from public.game_reports r
|
||
left join public.games g on g.id = r.provisional_game_id
|
||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
|
||
```
|
||
|
||
**Ergebnis lesen** (Beispiel, `approved` mit vorläufigem Eintrag):
|
||
|
||
```
|
||
{"plays": 2, "status": "approved", "repointed": true, "report_id": "…", "target_id": "…", "score_sheets": 1,
|
||
"favorite_games": 1, "collection_items": 1, "variants_copied": 0, "variants_mapped": 1, "variants_cleared": 1,
|
||
"favorite_conflicts": 0, "collection_conflicts": 0, "provisional_game_id": "…"}
|
||
```
|
||
|
||
`repointed` ist `false`, wenn nichts umgehängt wurde (`rejected` oder Meldung ohne vorläufigen Eintrag); dann stehen die Zähler nicht im Ergebnis. Die Zähler haben dieselbe Bedeutung wie bei der Zusammenführung (Abschnitt 5). Wichtig hier: **`variants_cleared`** zählt die Sammlungseinträge und Partien, deren Edition geleert wurde, weil der Katalogeintrag keine gleichnamige Variante hatte. Das ist der Hinweis, dass der Nutzer die Edition neu wählen muss. Derselbe Wert steht danach in der Meldung (`game_reports.editions_cleared`, Spec-F6); die App (#13) liest ihn, um den Nutzer zum Neuwählen aufzufordern. Er ist 0 bei `rejected` und bei Meldungen ohne Umhängen. Eine Kollision (`collection_conflicts`) bedeutet: Der Nutzer hatte das Ziel schon in seiner Sammlung; der Zieleintrag bleibt, leere Felder füllt der Eintrag aus dem vorläufigen Spiel.
|
||
|
||
**Fehlermeldungen** (alle ohne Änderung, `P0001`):
|
||
|
||
| Meldung | Bedeutung |
|
||
|---|---|
|
||
| `admin_only` | Die Sitzung läuft nicht als `postgres` bzw. `supabase_admin` (`select current_user;` prüfen) |
|
||
| `report_not_found` | Die Meldung gibt es nicht (ID vertippt?) |
|
||
| `report_not_open` | Die Meldung ist schon bearbeitet (`approved`, `merged` oder `rejected`) |
|
||
| `report_status_invalid` | Der Status ist nicht `approved`, `merged` oder `rejected` |
|
||
| `report_target_required` | `approved` und `merged` brauchen ein Ziel |
|
||
| `report_reject_has_target` | `rejected` darf kein Ziel haben; soll auf ein vorhandenes Spiel verwiesen werden, `'merged'` verwenden |
|
||
| `report_target_not_listed` | Das Ziel ist kein sichtbares Katalogspiel (privat, ausgeblendet, zusammengeführt oder unbekannt); das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) |
|
||
| `report_provisional_not_private` | Der vorläufige Eintrag der Meldung ist kein privates Spiel **des Meldenden** (Datenfehler, z. B. ein Katalogspiel oder das Spiel eines anderen Nutzers); nicht umhängen, sondern die Meldung prüfen |
|
||
|
||
**Falsch gesetzte `visibility` oder `owner_id`** lassen sich nicht mit `update` korrigieren (Trigger `games_visibility_immutable`, Fehler `visibility_immutable`). Korrigieren geht nur durch Löschen und Neuanlegen des Spiels oder, bewusst und nur als Tabelleneigentümer in einer Transaktion, mit `alter table public.games disable trigger games_visibility_immutable;` … `enable trigger …`. Für Clients: Der Fehler `P0001` kommt über PostgREST als HTTP 400 an (Hinweis für die Fehlerabbildung in #3 und #13).
|
||
|
||
Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Hinweis: Die Rolle `service_role` hat (Supabase-Standard) volle Tabellenrechte und umgeht RLS; die Zugriffsgrenzen in Krit. 9 gelten für die API-Rollen `anon` und `authenticated`, den Funktionen ist `service_role` ausdrücklich entzogen. Nutzer können ab KAT-04 (PR 13a) eigene Meldungen nur über die RPC `report_missing_game` bzw. einen Insert mit eng begrenzten Spaltenrechten anlegen und an eigenen, bearbeiteten Meldungen nur `result_seen_at` ändern; Status, Ziel und Löschen bleiben ihnen verwehrt, sie lesen nur ihre eigenen. Das Löschen eines Zielspiels, auf das eine Meldung verweist, schlägt mit `23503` fehl (gewollt: ausblenden statt löschen).
|
||
|
||
## 7. Admin-Rolle in der App
|
||
|
||
In R1 gibt es **kein App-Konto mit Admin-Rolle** (E10, Klärung 44). Die vorhandenen `is_admin()`-Policies (JWT `app_metadata.role = 'admin'`) bleiben für spätere Releases (QUE-08, R4) stehen und greifen in R1 nie. Falls das später nötig wird, geht es nur serverseitig (als `postgres`), der Nutzer muss sich danach neu anmelden:
|
||
|
||
```
|
||
update auth.users set raw_app_meta_data = raw_app_meta_data || '{"role":"admin"}' where id = '<nutzer-id>';
|
||
```
|
||
|
||
**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); PostgREST reicht ihn als JSON-Parameter `$1` an die Datenbank. Drei Einstellungen müssen **zusammen** geprüft werden, weil sie voneinander abhängen: (1) `log_parameter_max_length_on_error = 0`, sonst stünde der Begriff im Fehlerprotokoll (Fehler wie `search_query_too_long` erscheinen lokal nur mit dem Platzhalter `$1`); (2) `log_min_duration_statement = -1`, `log_statement` höchstens `ddl`, `pgaudit.log = none`; (3) `auto_explain.log_min_duration` (lokal 10 s, mit `auto_explain.log_parameter_max_length = -1`, also **mit** Parameterwerten) muss **größer** sein als das längste `statement_timeout` der Rollen `anon` und `authenticated` (lokal 3 s und 8 s): Eine Suche, die das Zeitlimit erreicht, wird abgebrochen und erzeugt keine auto_explain-Ausgabe. Wird das Zeitlimit von `authenticated` über 10 s angehoben (oder `auto_explain.log_min_duration` gesenkt), landen Suchbegriffe im Protokoll. Prüfung auf Staging/Produktion: `select name, setting from pg_settings where name in ('log_min_duration_statement', 'log_statement', 'log_parameter_max_length', 'log_parameter_max_length_on_error', 'auto_explain.log_min_duration', 'pgaudit.log', 'pgaudit.log_parameter')`. Lokal bestätigt (Aufruf per `curl` gegen Kong, Datenbank- und PostgREST-Protokoll enthalten den Begriff nicht); Hetzner ist **nicht** geprüft.
|
||
- **Erweiterungen:** `select * from pg_available_extensions where name in ('pg_trgm', 'unaccent')` muss auf Staging/Produktion beide Zeilen liefern, bevor die Migration `catalog_search` eingespielt wird.
|