205 lines
16 KiB
Markdown
205 lines
16 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 2a (Datenmodell, Startbestand, Ausblenden). Die Abschnitte **Zusammenführen** (PR 2b) und **Meldungen bearbeiten** (PR 2c) kommen mit den jeweiligen PRs dazu.
|
||
|
||
**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (`supabase/tests/database/06_catalog_foundation.test.sql`, Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1).
|
||
|
||
## 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 | reserviert für KAT-01 (`de:Titel;en:Title`), wird ignoriert |
|
||
| `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`. Auf Staging/Prod nicht für Seeds nutzen, sondern per `psql` einspielen (2.5, [Release-Checkliste](release-checkliste.md) Abschnitt 2). |
|
||
|
||
## 3. Spiel anlegen und bearbeiten
|
||
|
||
Jede Änderung an einem Fakt eines Katalogspiels (und jede Zuordnung zu Verlag, Genre, Mechanik oder Kategorie) trägt der Trigger automatisch in `games.manual_fields` ein, damit ein späterer Import sie nicht überschreibt. Im Studio muss dafür nichts zusätzlich gepflegt werden. Als Rolle `postgres` bleibt das Kennzeichnen auch dann aktiv, wenn jemand `shuffle.catalog_import` setzt.
|
||
|
||
**Spiel finden, bevor man es ändert.** Titel sind nicht eindeutig (ab PR 2b kann es gleichnamige Duplikate geben). Deshalb zuerst die ID nachschlagen und alle weiteren Bausteine über `id` ausführen:
|
||
|
||
```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 kommt mit PR 2b über die Funktion `admin_merge_games`. 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`.
|
||
|
||
## 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
|
||
|
||
Kommt mit PR 2b.
|
||
|
||
## 6. Meldungen bearbeiten
|
||
|
||
Kommt mit PR 2c.
|
||
|
||
## 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).
|