Shuffle/docs/katalog-pflege.md

36 KiB
Raw Blame History

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 2c (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten).

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

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

# 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

psql "$DB_URL" -f supabase/catalog/checks.sql      # oder Inhalt im Studio-SQL-Editor

Je Regel eine Zeile (Soll, Ist, Status ok/Verstoß/Information). Abnahme (Krit. 7 und 10): SB1 meldet mindestens 180 sichtbare Spiele, SB2, SB4 und SB6 melden 0. Die Abfrage betrachtet nur Spiele aus startbestand-keys.csv; im Studio angelegte Spiele und das Beispielspiel aus seed.sql (nur lokal) zählen nicht. Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird. Die Herkunft der Fakten (SB3) und der Kurztexte (SB4) bestätigt Chris selbst; die rechtliche Prüfung folgt vor dem Release.

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.

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:

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):

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:

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):

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.

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

-- 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):

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):

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?

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):

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

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:

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:

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):

select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002';

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):

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 'merged' + Ziel Der Eintrag war schon vorhanden. Wirkung wie bei 'approved'.
ablehnen 'rejected', optional mit Ziel Mit Ziel wird nur der Verweis gespeichert (die App kann dem Nutzer den Zieleintrag anbieten); der vorläufige Eintrag bleibt unverändert. Ohne Ziel ändert sich nichts außer Status und Zeitpunkt.

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):

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':

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:

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):

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):

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):

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):

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:

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):

select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004');

Schritt 3c: Ablehnen, ohne Verweis oder mit Verweis auf ein vorhandenes Katalogspiel:

select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected');
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004');

Schritt 4: Nachprüfen. Status der Meldung und Zustand des vorläufigen Eintrags (bei approved/merged: ausgeblendet und verwiesen; bei rejected: unverändert):

select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt,
       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. 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_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 (Datenfehler); nicht umhängen, sondern die Meldung prüfen

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). Meldungen anlegen, ändern oder löschen können Nutzer in dieser Etappe nicht (das Anlegen kommt mit KAT-04); 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).