Refs #2 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
63 KiB
Spec #2 – KAT-05 Katalogdaten laden und aktuell halten
| Issue | #2 (KAT-05) |
| Status | freigegeben (Chris, 2026-11-03); Fragen F1–F9 entschieden wie empfohlen (§ 9.2) |
| Autor | architect |
| Stand | 2026-11-03 |
| Etappe | R1.1 (Teil 2a: Datenmodell, Rechte, Startbestand), R1.2 (Teil 2b: Admin-Pflege per Studio; Teil 2c: Meldungen bearbeiten); Import (Krit. 1, 3, 6, Quellenteil von 2) erst mit Lizenz |
| Abhängigkeiten | keine (Fundament). Abhängig von #2: #3 KAT-01, #13 KAT-04, #25 SAM-08 |
| Quellen | requirements/20-r1-akzeptanzkriterien.md § KAT-05 und § Querschnitt, requirements/r1-klaerungen.md (3, 4, 5, 6, 7, 10, 24, 25/44, 26, 27, 39, 48, N11, N12, N12a), requirements/backlog.md (Abschnitt „Datenmodell-Änderungen für architect“), docs/datenmodell-r1.md, supabase/migrations/20261102100000_base.sql, …100200_catalog.sql, …100300_collection.sql, …100400_plays.sql, supabase/tests/database/02_catalog.test.sql, supabase/seed.sql, supabase/config.toml, ADR-0001, ADR-0002 |
1. Ziel
Der Katalog ist das Fundament aller anderen Stories: Suche (KAT-01), Detailseite (KAT-02), Sammlung, Partien und Meldungen verweisen auf games. KAT-05 sorgt in R1 dafür, dass
- das Datenmodell alles enthält, was Admin-Pflege und ein späterer Import brauchen (ausgeblendet, zusammengeführt, manuell gepflegte Felder, Quelle mit Anzeige-Erlaubnis; Klärung 7), ohne dass der Import später eine Migration braucht,
- nur der Admin den Katalog und Meldungen ändern kann, und zwar in R1 ausschließlich über Supabase Studio/SQL (Klärungen 25/44),
- zum Start von R1 ein manuell gepflegter Startbestand von etwa 200 Spielen (mindestens 180, F8) vorliegt, der nur Fakten enthält, ohne fremde Cover und fremde Beschreibungstexte (Klärung 24, N11),
- der Ablauf der Admin-Pflege im Repo dokumentiert ist (Krit. 8).
Ein Import-Job (BGG oder eine andere Quelle) ist nicht Teil dieser Spec. Es gibt keine Edge Function und keine Admin-Oberfläche in der App.
Kernentscheidungen (Kurzfassung)
| # | Entscheidung | Begründung |
|---|---|---|
| E1 | Ein Issue #2, drei PRs: 2a (R1.1) Datenmodell, Rechte, Startbestand-Lieferweg, Platzhalter-Cover; 2b (R1.2) Zusammenführen und Runbook; 2c (R1.2) Meldungstabelle und Bearbeiten von Meldungen. 2a und 2b referenzieren Refs #2, 2c Closes #2. |
Entspricht dem Etappenschnitt im Issue. 2a blockiert KAT-01 (#3), 2c muss vor der Umsetzung von KAT-04 (#13) gemergt sein. Eigene Issues würden nur Abhängigkeiten verschieben. |
| E2 | Größe neu bewertet: L statt XL. 2a ≈ 2–3 Tage (davon der Großteil Migration und pgTAP), 2b ≈ 1–2 Tage, 2c ≈ 1 Tag. Das Einpflegen der ~200 Spiele durch Chris ist nicht eingerechnet. | Import, Edge Function und Admin-UI entfallen (Klärungen 24, 44). Das Label size/xl ändert nur Chris. |
| E3 | Ausblenden über games.hidden_at, Zusammenführen über games.merged_into_id. Die Lese-Policy von games bleibt unverändert. „Nicht mehr auffindbar“ (Krit. 4) wird über die neue View catalog_games umgesetzt, die ausgeblendete und zusammengeführte Einträge nicht enthält. Suche und Entdecken (KAT-01) lesen nur diese View. |
Würde RLS ausgeblendete Spiele verbergen, liefen bestehende Sammlungs-, Favoriten- und Partie-Zeilen ins Leere (Joins liefern null, die with check-Klausel von collection_items schlägt beim Bearbeiten fehl). Entschieden (F2): Ausgeblendete Einträge bleiben per ID lesbar; ein eigener Status „gesperrt“ für rechtliche Fälle kommt erst bei Bedarf. |
| E4 | Manuell gepflegte Felder als Liste games.manual_fields text[], gepflegt von einem Trigger: Jedes Feld eines Katalogspiels, das ein Mensch (Studio/SQL) setzt oder ändert, landet automatisch in der Liste. Nur ein späterer Import setzt in seiner Transaktion set local shuffle.catalog_import = 'on' und wird dann nicht markiert. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) zählen als Pseudo-Felder (genres, mechanics, categories, publishers). |
Der Admin muss in Studio nichts zusätzlich pflegen, die Kennzeichnung kann nicht vergessen werden (Krit. 4). Der Import (Krit. 1) überschreibt später genau die Felder nicht, die in der Liste stehen. Eine Liste statt einer Boolean-Spalte je Feld hält das Schema klein; ein Check begrenzt die erlaubten Schlüssel. Entschieden (F4): Im Startbestand gelten alle gesetzten Felder als manuell gepflegt. |
| E5 | Quellen als eigene Tabelle catalog_sources (Schlüssel, Name, Quellenangabe, Anzeige-Erlaubnis für Bilder und Texte je Quelle) und je Spiel source_id, source_ref (Kennung in der Quelle), source_url, source_synced_at. Die Migration legt die Quelle bgg mit images_allowed = false, texts_allowed = false an. |
Klärung 7 und Krit. 2, 6: Felder und Erlaubnis je Quelle ab R1 vorhanden. games.bgg_id bleibt unverändert der Abgleichsschlüssel für SAM-08 (auch bei privaten Spielen); source_* beschreibt, woher die Katalogdaten stammen (Quellenangabe). Die Doppelung bei BGG ist bewusst; die Konsistenz (source_ref = bgg_id::text bei Quelle bgg) stellt der Import sicher (Spec des Imports). Eine Quelle je Spiel reicht, bis es eine zweite Quelle gibt. |
| E6 | Die View catalog_games setzt die Anzeige-Erlaubnis schon jetzt serverseitig um: description und image_path erscheinen nur, wenn das Feld manuell gepflegt ist, das Spiel keine Quelle hat oder die Quelle die Anzeige erlaubt. |
Vorbereitung von Krit. 6 ohne Mehraufwand im Client. Im Startbestand hat kein Spiel eine Quelle, die Regel wirkt also erst mit dem Import. |
| E7 | Verlage (Klärung 4) entstehen in dieser Spec (publishers, game_publishers), nicht erst in KAT-01. Alternative Titel (Klärung 3, #3) und Basisspiel-Verweis (Klärung 5, #12) bleiben in ihren Stories; das Pflegeformat des Startbestands hat dafür aber schon Spalten (siehe 4.3), damit Chris die ~200 Spiele nicht zweimal anfassen muss. |
Krit. 10 verlangt den Verlag im Startbestand. Alternative Titel und Erweiterungen sind für den Startbestand nicht Pflicht und werden in #3 bzw. #12 modelliert und getestet. |
| E8 | Sprache des Kurztexts als Spalte games.description_locale (de/en, optional), schon in 2a. |
Der Startbestand füllt sie (immer de, F5); KAT-02 Krit. 7 (Klärung 48) zeigt damit den Sprachhinweis. Die Sprache des Titels gehört zu den alternativen Titeln (#3). |
| E9 | Startbestand ist keine Migration und nicht Teil von seed.sql, sondern eine versionierte, idempotente SQL-Datei supabase/catalog/startbestand.sql mit festen IDs. Lokal und in CI lädt sie supabase db reset über [db.seed] sql_paths; auf Staging und Produktion spielt Chris sie nach den Migrationen einmalig per Studio-SQL-Editor oder psql ein (Runbook). |
Migrationen sind unveränderlich und nur additiv, Katalogdaten werden bis zum Start aber laufend korrigiert. seed.sql ist ausdrücklich nur für die lokale Entwicklung. Entschieden (F6): Chris pflegt eine CSV, ein Skript erzeugt daraus die SQL-Datei; nach dem Go-live ist die Datei eingefroren. |
| E10 | In R1 gibt es kein App-Konto mit Admin-Rolle. Chris pflegt ausschließlich über Studio bzw. psql als Datenbankrolle postgres (umgeht RLS). Die bestehenden is_admin()-Policies (JWT app_metadata.role = 'admin') bleiben für QUE-08 (R4) stehen. Die neuen Admin-Funktionen sind für anon und authenticated nicht ausführbar, auch nicht mit Admin-Claim. |
Klärung 44: Admin-Rolle nur als Datenbankrolle. Kein Admin-JWT heißt weniger Angriffsfläche. Krit. 9 wird dadurch gegen die Rollen anon und authenticated getestet (mit und ohne gefälschten Claim). |
| E11 | Zusammenführen und Meldungsergebnis laufen über SQL-Funktionen, nicht über Hand-SQL im Studio: admin_merge_games(source, target) und admin_resolve_report(report, status, target), beide nutzen die interne Funktion repoint_game_references(from, to). |
Zusammenführen ist kein einzelnes UPDATE: Varianten mit zusammengesetzten Fremdschlüsseln, unique (owner_id, game_id) in collection_items, unique (profile_id, game_id) in favorite_games und künftig ein Wertungsbogen je Spiel (Klärung 39). Hand-SQL wäre fehleranfällig. Freigabe einer Meldung (KAT-04 Krit. 4) braucht dasselbe Umhängen, das ist der zweite Anwendungsfall. |
| E12 | Die Meldungstabelle game_reports entsteht in 2c (diese Spec), vollständig mit den Spalten, die KAT-04 und SAM-08 brauchen (Herkunft Nutzer/Import, BGG-Kennung, vorläufiger Eintrag). Anlegen durch Nutzer (Insert-Policy, Tageslimit 10, Ähnlichkeitsprüfung) liefert #13. |
#13 hängt in Forgejo von #2 ab. Läge die Tabelle in #13, wäre 2c von #13 blockiert und #2 nicht abschließbar. Die Spalten ergeben sich vollständig aus den Klärungen 6, 26, 27, N12 und N12a. |
| E13 | Keine neuen Pakete, keine Edge Function, keine Supabase-Cloud-Funktionen. Alles ist reines PostgreSQL 17 (Trigger, Views mit security_invoker, Spaltenrechte, Custom-GUC), PostgREST und Studio, die es im Self-Hosting gibt (ADR-0002). [db.seed] sql_paths ist eine Funktion der lokalen CLI. |
Vorgabe aus CLAUDE.md und ADR-0002. |
2. Akzeptanzkriterien
Übernommen aus Issue #2 / 20-r1-akzeptanzkriterien.md § KAT-05. Referenz in Tests und PRs: „KAT-05 Kriterium n“.
- Zurückgestellt bis zur Lizenz (Klärung 24): Gegeben eine zugelassene Quelle, wenn der Import läuft, dann werden neue Spiele angelegt und geänderte aktualisiert, ohne manuell gepflegte Felder zu überschreiben.
- Gegeben ein Katalogeintrag, dann hat er eine eigene Shuffle-ID (F2). Zurückgestellt bis zur Lizenz (Klärung 24): optional die Kennung der Quelle (zum Beispiel BGG) samt Quellenangabe; die Felder dafür sind ab R1 im Datenmodell vorhanden (Klärung 7).
- Zurückgestellt bis zur Lizenz (Klärung 24): Gegeben die Quelle ist nicht erreichbar oder liefert fehlerhafte Daten, dann bleibt der Katalog unverändert, der Fehler wird protokolliert und die App funktioniert normal weiter.
- Gegeben der Admin bearbeitet, blendet aus oder führt Einträge zusammen (in R1 über Supabase Studio/SQL), dann sind ausgeblendete Einträge in der App nicht mehr auffindbar, und manuell gepflegte Felder sind als solche gekennzeichnet, damit ein späterer Import sie nicht überschreibt (Klärungen 7, 44).
- Gegeben Meldungen aus KAT-04, dann kann der Admin sie über Supabase Studio/SQL freigeben, mit einem Eintrag zusammenführen oder ablehnen; Status (offen, freigegeben, abgelehnt, zusammengeführt) und gegebenenfalls der Zieleintrag werden gespeichert (Klärungen 6, 44).
- Zurückgestellt bis zur Lizenz (Klärung 24): Gegeben Cover und Texte einer Quelle, dann werden sie nur angezeigt, wenn deren Nutzungsbedingungen es erlauben; die Erlaubnis ist je Quelle vermerkt. Das Feld für die Erlaubnis je Quelle ist ab R1 im Datenmodell vorhanden (Klärung 7).
- Gegeben R1 geht live, dann enthält der Katalog einen manuell gepflegten Startbestand von etwa 200 Spielen, den der Admin (Chris) per Supabase Studio bzw. Seed einpflegt (Klärung 24, N11).
- Gegeben die Admin-Pflege über Supabase Studio/SQL, dann ist der Ablauf für Bearbeiten, Ausblenden, Zusammenführen und das Bearbeiten von Meldungen im Repo dokumentiert (Klärung 44).
- Gegeben ein Nutzer ohne Admin-Rolle, wenn er über die App oder direkt über die Schnittstelle Katalogeinträge oder fremde Meldungen ändern will, dann wird der Zugriff verweigert; die Admin-Rolle existiert in R1 nur als Datenbankrolle (Klärung 44).
- Gegeben ein Spiel aus dem Startbestand, dann enthält es nur Fakten (Titel, Verlag, Erscheinungsjahr, Spielerzahl, Spieldauer, Mindestalter, Genre, Mechanik); fremde Cover und fremde Beschreibungstexte werden nicht übernommen, stattdessen zeigt die App ein Platzhalter-Cover und einen eigenen Kurztext (N11).
Abnahme von Krit. 7 und 10 (konkretisiert durch F5, F7, F8, entschieden 2026-11-03)
- Krit. 7 gilt als erfüllt, wenn die Prüfabfrage
supabase/catalog/checks.sqlauf Produktion mindestens 180 sichtbare Katalogspiele (catalog_games) meldet und für die Startbestandsspiele 0 Verstöße gegen die Randbedingungen aus 4.3 zeigt. Chris nimmt das Ergebnis vor dem Go-live ab. - Krit. 10 gilt als erfüllt, wenn jedes Startbestandsspiel alle Pflicht-Fakten hat (mindestens ein Verlag, ein Genre, eine Mechanik), einen eigenen Kurztext auf Deutsch (
description_locale = 'de') hat, keinimage_pathund keine Quelle (source_id) gesetzt ist, die Fakten nach F7 erhoben wurden (Bestätigung durch Chris) und die App für alle diese Spiele das Platzhalter-Cover zeigt (KAT-01/KAT-02).
Zuordnung zu den PRs
| PR | Etappe | Kriterien | Bei zurückgestellten Kriterien: nur Datenmodell |
|---|---|---|---|
| 2a | R1.1 | 2, 7 (Lieferweg, Werkzeug, Prüfabfrage; der Datenbestand selbst kommt von Chris), 9 (Katalog), 10 | 1 (Kennzeichnung manual_fields), 2 (Quellenteil), 6 (Erlaubnis je Quelle) |
| 2b | R1.2 | 4, 8 (Bearbeiten, Ausblenden, Zusammenführen) | – |
| 2c | R1.2 | 5, 8 (Meldungen), 9 (fremde Meldungen) | – |
Krit. 3 hat keinen Anteil am Datenmodell und wird vollständig mit dem Import spezifiziert.
Relevante Querschnittskriterien
- Sicherheit der Daten Kriterium 2: Katalog ohne Anmeldung lesbar (
catalog_games,catalog_sources,publishersfüranon), nie durch Nutzer veränderbar (O6). - Sicherheit der Daten Kriterium 1 und 3:
game_reportsenthält Nutzerdaten; nur der Meldende liest seine Meldungen, pgTAP prüft fremden Zugriff. - Datenschutz (QUE-04): Meldungen werden beim Konto-Löschen hart gelöscht (
on delete cascade, Klärung 10). Der Startbestand enthält keine personenbezogenen Daten. - Sprachen (QUE-05) Kriterium 2: Die Semantics-Beschriftung des Platzhalter-Covers kommt aus ARB (de/en). Begriffe (Genre, Mechanik) haben
name_de/name_en. - Barrierefreiheit (QUE-06) Kriterien 1, 2: Platzhalter-Cover mit sinnvoller Beschriftung und Kontrast AA (Kontrast der Grafik zum Hintergrund mindestens 3:1).
- Plattformen Kriterium 1: Das Platzhalter-Cover ist ein reines Flutter-Widget ohne Plattformcode.
3. Datenmodell
3.1 Bestand gegen die Kriterien
| Bedarf | Bestand | Abgleich | Handlung |
|---|---|---|---|
| Eigene Shuffle-ID (Krit. 2) | games.id uuid primary key default gen_random_uuid(), set_updated_at schützt id |
passt | keine; pgTAP-Test als Nachweis |
| Kennung + Quellenangabe (Krit. 2, Quellenteil) | nur games.bgg_id |
Quellenangabe fehlt | catalog_sources, games.source_* (3.2) |
| Ausblenden (Krit. 4) | – | fehlt | games.hidden_at, View catalog_games |
| Zusammenführen (Krit. 4) | – | fehlt | games.merged_into_id, Funktion admin_merge_games (2b) |
| Manuell gepflegte Felder (Krit. 1, 4) | – | fehlt | games.manual_fields, Trigger |
| Erlaubnis je Quelle (Krit. 6) | – | fehlt | catalog_sources.images_allowed, texts_allowed |
| Verlag (Krit. 10) | – | fehlt (Klärung 4) | publishers, game_publishers |
| Genre, Mechanik (Krit. 10) | genres, mechanics, Zuordnungstabellen |
passt | Vokabular für den Startbestand: Vorschlag durch Agents, Freigabe durch Chris (F8) |
| Kurztext + Sprache (Krit. 10, KAT-02 Krit. 7) | games.description (≤ 5000), keine Sprache |
Sprache fehlt | games.description_locale |
| Kein fremdes Cover (Krit. 10) | games.image_path nullable |
passt | Startbestand setzt kein image_path; Platzhalter in der App |
| Meldungen (Krit. 5) | – | fehlt (Klärung 6) | game_reports (2c) |
| Schreibrechte (Krit. 9) | Katalog nur is_admin(); Nutzer dürfen eigene private Spiele vollständig ändern (Tabellenrecht update für authenticated) |
Nutzer könnten an eigenen privaten Spielen hidden_at, manual_fields, source_*, merged_into_id setzen |
Spaltenrechte statt Tabellenrechte für insert/update auf games (3.2) |
3.2 Migration 2a supabase/migrations/<ts>_catalog_foundation.sql (R1.1)
Nur additiv. Bestehende Dateien bleiben unverändert; die Änderung der Rechte auf games erfolgt über revoke/grant in der neuen Datei.
Rechte-Regel für alle neuen Objekte (2a–2c): Supabase vergibt für neue Tabellen, Views und Funktionen standardmäßig alle Rechte an anon, authenticated und service_role. Jede neue Tabelle und View bekommt deshalb zuerst revoke all … from anon, authenticated und danach nur die genannten Grants (wie in den bestehenden Migrationen und supabase/README.md); jede neue Funktion revoke execute … from public, anon, authenticated.
Neue Tabelle catalog_sources
| Spalte | Typ | Hinweis |
|---|---|---|
id |
uuid | PK, gen_random_uuid() |
key |
text | not null, unique, ^[a-z0-9_]{2,30}$ (z. B. bgg) |
name |
text | not null, 1–100 Zeichen (z. B. „BoardGameGeek“) |
attribution_text |
text | nullable, ≤ 200 (z. B. „Powered by BGG“, KAT-02 Krit. 5) |
attribution_url |
text | nullable, ≤ 500, Check ^https:// |
images_allowed |
boolean | not null, default false (Krit. 6) |
texts_allowed |
boolean | not null, default false (Krit. 6) |
terms_url |
text | nullable, ≤ 500, Nutzungsbedingungen der Quelle |
terms_checked_at |
date | nullable, wann die Erlaubnis zuletzt geprüft wurde |
created_at, updated_at |
timestamptz | Standard, Trigger set_updated_at |
Die Migration fügt eine Zeile ein: key = 'bgg', name = 'BoardGameGeek', attribution_text = 'Powered by BGG', attribution_url = 'https://boardgamegeek.com', beide Erlaubnisse false. Das ist Referenzdaten-Pflege, keine Katalogdaten.
Neue Spalten in games
| Spalte | Typ | Hinweis |
|---|---|---|
description_locale |
text | nullable, Check in ('de','en') (E8) |
hidden_at |
timestamptz | nullable; gesetzt = ausgeblendet (Krit. 4) |
merged_into_id |
uuid | nullable, FK → games (id); gesetzt = in den Zieleintrag zusammengeführt. Check merged_into_id <> id; Check merged_into_id is null or hidden_at is not null |
manual_fields |
text[] | not null, default '{}'. Check manual_fields <@ array['title','description','description_locale','year','min_players','max_players','min_minutes','max_minutes','min_age','image_path','genres','mechanics','categories','publishers']. Neue Schlüssel (z. B. alternative Titel in #3, Basisspiel in #12) ergänzt die jeweilige Story per neuer Migration (Check ersetzen). |
source_id |
uuid | nullable, FK → catalog_sources (id) |
source_ref |
text | nullable, 1–100, Kennung des Spiels in der Quelle |
source_url |
text | nullable, ≤ 500, Check ^https:// |
source_synced_at |
timestamptz | nullable, letzter erfolgreicher Abgleich (Import) |
Weitere Checks: (source_id is null) = (source_ref is null); source_url und source_synced_at nur mit source_id.
Indizes: unique (source_id, source_ref) where visibility = 'catalog' and source_id is not null; games_merged_into_id_idx auf merged_into_id; games_catalog_listed_idx auf (lower(title)) where visibility = 'catalog' and hidden_at is null and merged_into_id is null (Suche baut KAT-01 darauf aus).
Neue Tabellen publishers und game_publishers (Klärung 4, E7)
| Tabelle | Spalte | Typ | Hinweis |
|---|---|---|---|
publishers |
name |
text | not null, 1–200; eindeutig über unique index on lower(name) |
game_publishers |
game_id |
uuid | not null, FK → games on delete cascade |
game_publishers |
publisher_id |
uuid | not null, FK → publishers on delete cascade; unique (game_id, publisher_id); Index auf publisher_id |
Beide mit id, created_at, updated_at und Trigger set_updated_at. Ein Spiel kann mehrere Verlage haben (KAT-01 Krit. 3).
Trigger games_mark_manual_fields (E4)
before insert or update on public.games, nur wennnew.visibility = 'catalog'.- Wenn
coalesce(current_setting('shuffle.catalog_import', true), '') <> 'on':- bei INSERT: jedes Feld aus der Schlüsselliste, das nicht
nullist, wird ergänzt (F4, entschieden: alle gesetzten Felder), - bei UPDATE: jedes Feld aus der Schlüsselliste, dessen Wert sich ändert (
is distinct from), wird ergänzt.
- bei INSERT: jedes Feld aus der Schlüsselliste, das nicht
- Eine Änderung nur an
manual_fieldsselbst wird nicht verändert, damit der Admin eine Markierung bewusst entfernen kann. - Sortiert und ohne Duplikate speichern (
array(select distinct unnest(...) order by 1)), damit Tests stabil vergleichen können.
Trigger game_links_mark_manual auf game_genres, game_mechanics, game_categories, game_publishers: after insert or delete, wenn das Spiel ein Katalogspiel ist und kein Import läuft, ergänzt er genres / mechanics / categories / publishers in manual_fields des Spiels. Funktion security invoker, search_path = ''.
Ein Nutzer kann shuffle.catalog_import nicht setzen: PostgREST stellt set_config nicht bereit, und Katalogspiele kann er ohnehin nicht schreiben.
View catalog_games (with (security_invoker = true), E3, E6)
- Zeilen:
gamesmitvisibility = 'catalog' and hidden_at is null and merged_into_id is null. - Spalten:
id,title,year,min_players,max_players,min_minutes,max_minutes,min_age,bgg_id,description_locale,created_at,updated_at, dazudescription=case when g.source_id is null or 'description' = any(g.manual_fields) or s.texts_allowed then g.description end,image_path= gleiche Regel mit'image_path'unds.images_allowed,source_key,source_name,source_attribution_text,source_attribution_url,source_urlauscatalog_sources(Left Join).
grant select on public.catalog_games to anon, authenticated. Weilsecurity_invokergesetzt ist, gelten die RLS-Regeln vongamesundcatalog_sourcesfür den Aufrufer.- KAT-01 und KAT-02 lesen Katalogdaten nur über diese View (bzw. über eine RPC, die darauf aufbaut). Der Direktzugriff auf
gamesbleibt für eigene private Spiele und für bestehende Verweise (Sammlung, Partien) nötig.
Rechte auf games neu schneiden (Krit. 9)
revoke insert, update on public.games from authenticated;
grant insert (owner_id, title, description, description_locale, year, min_players, max_players,
min_minutes, max_minutes, min_age, image_path, bgg_id, visibility)
on public.games to authenticated;
grant update (title, description, description_locale, year, min_players, max_players,
min_minutes, max_minutes, min_age, image_path, bgg_id, visibility)
on public.games to authenticated;
delete und select bleiben wie bisher. Die Policies regeln weiterhin, welche Zeilen (eigene private Spiele bzw. Admin). Damit kann kein Nutzer hidden_at, merged_into_id, manual_fields oder source_* setzen, auch nicht an eigenen privaten Spielen (Fehler 42501). Hinweis für QUE-08 (R4): Ein Admin über die API wäre davon ebenfalls betroffen; die Admin-UI nutzt dann RPCs.
RLS in Klartext (2a)
| Tabelle/Objekt | Lesen | Schreiben |
|---|---|---|
catalog_sources |
alle, auch ohne Anmeldung | nur Admin (is_admin()-Policy, für R4); in R1 nur Studio/postgres |
publishers |
alle, auch ohne Anmeldung | nur Admin |
game_publishers |
wie das Spiel (wer das Spiel sehen darf) | Admin oder Ersteller des privaten Spiels (gleiches Muster wie game_genres) |
games |
unverändert: Katalog für alle, private Spiele nur der Ersteller; ausgeblendete und zusammengeführte Katalogspiele bleiben per ID lesbar (E3, F2 entschieden) | unverändert per Policy; zusätzlich Spaltenrechte |
catalog_games |
alle, auch ohne Anmeldung, nur sichtbare Katalogspiele | nicht schreibbar (View ohne Insert/Update-Rechte) |
3.3 Migration 2b supabase/migrations/<ts>_catalog_merge.sql (R1.2)
Interne Funktion public.repoint_game_references(p_from uuid, p_to uuid)
language plpgsql, security invoker, search_path = ''; revoke execute ... from public, anon, authenticated. Hängt alle Verweise von p_from auf p_to um, in einer Transaktion:
- Varianten: Für jede Variante von
p_fromwird eine Kopie (name,year,language,bgg_id) anp_toangelegt; eine Zuordnungstabelle alt → neu (temporär) entsteht. Grund: Die zusammengesetzten Fremdschlüssel(variant_id, game_id)incollection_itemsundplayserlauben kein direktes Umhängen vongame_variants.game_id. Ein UPDATE, dasgame_idundvariant_idin derselben Zeile gemeinsam setzt, ist zulässig. collection_items: Hat derselbe Nutzer schon einen Eintrag zup_to, gilt die Kollisionsregel (F1, entschieden): Der Zieleintrag bleibt; seine leeren Felder (note,acquired_on,variant_idsowie die Felder, die SAM-02/SAM-04 ergänzen) werden aus dem Quell-Eintrag gefüllt; bei unterschiedlicher Liste giltownedvorwishlistvorshopping(wie Klärung 34 „besessen und gewünscht → Sammlung“); danach wird der Quell-Eintrag gelöscht. Ohne Kollision:game_id := p_to,variant_id := neu.favorite_games: Hat der Nutzerp_toschon als Favorit, wird die Zeile zup_fromgelöscht (die Lücke inpositionist zulässig). Sonstgame_id := p_to.plays:game_id := p_to,variant_id := neu.score_sheets:game_id := p_to. Sobald PAR-04 (#18) „ein Bogen je Spiel und Nutzer“ (Klärung 39) erzwingt, muss #18 hier eine Kollisionsregel ergänzen (Hinweis in 9.4).- Alte Varianten von
p_fromlöschen (sie sind jetzt unreferenziert). - Zuordnungen (Genre, Mechanik, Kategorie, Verlag) von
p_frombleiben unverändert am ausgeblendeten Quell-Eintrag; der Zieleintrag behält seine eigenen.
Rückgabe: jsonb mit Zählern je Tabelle (z. B. {"collection_items": 3, "collection_conflicts": 1, "plays": 7, …}), damit der Admin im Studio sieht, was passiert ist.
Funktion public.admin_merge_games(p_source uuid, p_target uuid) returns jsonb
security invoker, search_path = ''; revoke execute ... from public, anon, authenticated (E10). Prüft zusätzlich zu Beginn current_user in ('postgres', 'supabase_admin') or public.is_admin() (Schutz in der Tiefe).
| Prüfung | Fehler (raise exception … using errcode = 'P0001') |
|---|---|
p_source = p_target |
merge_same_game |
| Quelle oder Ziel existiert nicht | merge_game_not_found |
| Quelle oder Ziel ist kein Katalogspiel | merge_not_catalog |
| Ziel ist ausgeblendet oder selbst zusammengeführt | merge_target_not_listed |
| Quelle ist bereits zusammengeführt | merge_source_already_merged |
Ablauf: repoint_game_references(p_source, p_target); p_source.merged_into_id := p_target, hidden_at := coalesce(hidden_at, now()); Ketten glätten (update games set merged_into_id = p_target where merged_into_id = p_source). Ab 2c zusätzlich: game_reports.target_game_id von p_source auf p_target. Die Quelle wird nicht gelöscht: Ihre Shuffle-ID bleibt gültig, die App kann alte Links (Web-URLs, Deep Links) auf den Zieleintrag weiterleiten (KAT-02).
Ausblenden braucht keine Funktion: update public.games set hidden_at = now() where id = … (Wieder einblenden: hidden_at = null). Das Runbook (4.4) enthält die Anweisungen.
3.4 Migration 2c supabase/migrations/<ts>_game_reports.sql (R1.2)
Neue Tabelle game_reports (Klärungen 6, 10, 26, 27, N12, N12a; E12)
| Spalte | Typ | Hinweis |
|---|---|---|
id |
uuid | PK |
owner_id |
uuid | not null, FK → profiles on delete cascade (meldender Nutzer; Klärung 10: hart löschen) |
origin |
text | not null, default 'user', Check in ('user','import') (N12) |
title |
text | not null, 1–200 (KAT-04 Krit. 1) |
publisher_name |
text | nullable, ≤ 200 (Freitext des Nutzers) |
year |
smallint | nullable, 1800–2100 |
link |
text | nullable, ≤ 500, Check ^https?:// |
bgg_id |
integer | nullable (N12, Deduplizierung) |
provisional_game_id |
uuid | nullable, FK → games on delete set null (vorläufiger privater Eintrag, KAT-04 Krit. 3) |
status |
text | not null, default 'open', Check in ('open','approved','rejected','merged') |
target_game_id |
uuid | nullable, FK → games |
resolved_at |
timestamptz | nullable |
created_at, updated_at |
timestamptz | Standard, Trigger set_updated_at |
Checks: (status = 'open') = (resolved_at is null); status <> 'open' or target_game_id is null; status not in ('approved','merged') or target_game_id is not null.
Indizes: (owner_id, created_at) für das Tageslimit (#13); unique (owner_id, bgg_id) where origin = 'import' and status = 'open' and bgg_id is not null (N12a: höchstens eine offene Import-Meldung je Nutzer und BGG-Spiel); (created_at) where status = 'open' für die Admin-Liste.
RLS in Klartext
enable row level security;revoke all … from anon, authenticated;grant select on game_reports to authenticated.- Lesen: Der Meldende sieht seine eigenen Meldungen (für den Ergebnishinweis, KAT-04 Krit. 4–6); Admin-Policy
is_admin()für R4.anonsieht nichts. - Anlegen: in 2c nicht möglich (kein Insert-Recht). #13 ergänzt Insert-Recht, Insert-Policy (
owner_id = auth.uid(),origin = 'user',status = 'open',target_game_id is null,provisional_game_idnur ein eigenes privates Spiel) und den Limit-Trigger (10 je Nutzer und Tag, nurorigin = 'user'). - Ändern, Löschen: kein Recht für
anonundauthenticated(Krit. 9). Der Admin ändert in R1 nur überadmin_resolve_reportim Studio.
Funktion public.admin_resolve_report(p_report uuid, p_status text, p_target uuid default null) returns jsonb
Rechte wie admin_merge_games.
| Prüfung | Fehler |
|---|---|
| Meldung existiert nicht | report_not_found |
Meldung ist nicht open |
report_not_open |
p_status nicht in approved, merged, rejected |
report_status_invalid |
approved/merged ohne p_target |
report_target_required |
p_target ist kein sichtbares Katalogspiel |
report_target_not_listed |
Ablauf: status, target_game_id, resolved_at = now() setzen. Hat die Meldung einen vorläufigen Eintrag, dann (F3, entschieden)
- bei
approvedund beimerged:repoint_game_references(provisional, target); der vorläufige Eintrag bekommtmerged_into_id = target,hidden_at = now()(KAT-04 Krit. 4: der Katalogeintrag ersetzt ihn, eigene Angaben bleiben erhalten), - bei
rejectedmit Ziel: nurtarget_game_idwird gespeichert, der vorläufige Eintrag bleibt unverändert; ob der Nutzer auf den Zieleintrag wechselt, entscheidet er in der App (KAT-04 Krit. 5, #13), - bei
rejectedohne Ziel: keine Änderung am vorläufigen Eintrag (KAT-04 Krit. 6).
„Freigeben“ heißt in R1: Der Admin legt im Studio zuerst den Katalogeintrag an (bzw. wählt ihn aus) und ruft dann die Funktion mit approved auf. Unterschied zu merged: approved = der Eintrag ist wegen dieser Meldung neu entstanden, merged = er war schon vorhanden.
Die Migration ersetzt admin_merge_games per create or replace, sodass beim Zusammenführen auch game_reports.target_game_id umgehängt wird.
3.5 Doku zum Datenmodell
docs/datenmodell-r1.md wird in jedem der drei PRs nachgezogen (neue Tabellen, Spalten, View, Funktionen, Regeln). Dabei wird der doppelte Abschnitt „Tabellenstruktur (aus den Migrationen)“ (steht zweimal im Dokument) zusammengeführt. supabase/README.md bekommt einen Abschnitt „Katalog (KAT-05)“.
4. Backend
4.1 Überblick
| Objekt | Art | PR | Aufrufbar von |
|---|---|---|---|
catalog_sources, publishers, game_publishers, neue Spalten games |
Tabellen | 2a | Lesen: alle; Schreiben: Studio (postgres) |
catalog_games |
View | 2a | anon, authenticated (lesen) |
games_mark_manual_fields, game_links_mark_manual |
Trigger | 2a | automatisch |
repoint_game_references |
interne Funktion | 2b | nur postgres |
admin_merge_games |
Funktion | 2b | nur postgres (Studio/psql) |
game_reports |
Tabelle | 2c | Lesen: eigene; Schreiben: Studio |
admin_resolve_report |
Funktion | 2c | nur postgres |
Keine Edge Functions, keine RPCs für die App in dieser Spec.
4.2 Admin-Zugang (self-hosted)
- Studio läuft als Datenbankrolle
postgresbzw.supabase_admin(je nach Self-Hosting-Setup; D1 bestätigt das mitselect current_userim Studio-SQL-Editor) und umgeht RLS. Die Admin-Funktionen akzeptieren beide Rollen. Dieis_admin()-Policies sind deshalb nicht der Studio-Pfad; Schutz bietet allein der Zugang zu Studio. - Studio darf nicht öffentlich erreichbar sein. Im Self-Hosting gibt es in Studio keine Benutzerverwaltung, nur die Basic-Auth des Gateways (
DASHBOARD_USERNAME/DASHBOARD_PASSWORD). Vorgabe für devops (D1): Studio und der Postgres-Port sind nur über SSH-Tunnel oder VPN erreichbar; Basic-Auth bleibt als zweite Schicht mit starkem Passwort. Gilt ebenso für Staging. app_metadata.role = 'admin'wird in R1 niemandem gesetzt (E10). Falls doch nötig (R4), geht das nur serverseitig:update auth.users set raw_app_meta_data = raw_app_meta_data || '{"role":"admin"}' where id = …alspostgres; der Nutzer muss sich danach neu anmelden. Das Runbook dokumentiert das als „nicht in R1“.
4.3 Startbestand (Krit. 7, 10; E9)
Verbindliche Randbedingungen (F5, F7, F8, entschieden 2026-11-03)
| # | Regel | Prüfung |
|---|---|---|
| S1 | Zum Go-live mindestens 180 sichtbare Katalogspiele (Ziel etwa 200). | checks.sql (Release-Checkliste) |
| S2 | Jedes Spiel hat alle Pflicht-Fakten: Titel, mindestens ein Verlag, Jahr, Spielerzahl, Spieldauer, Mindestalter, mindestens ein Genre, mindestens eine Mechanik. | Skript (CSV-Prüfung) und checks.sql |
| S3 | Herkunft der Fakten (F7): Verlagsseiten, Spielschachtel und eigene Kenntnis, ergänzend Wikidata (CC0). Fakten werden nicht systematisch aus BGG oder einer anderen fremden Datenbank übernommen. Die endgültige Bewertung erfolgt mit der rechtlichen Prüfung vor dem Release. | Bestätigung durch Chris; optional Spalte facts_source in der CSV als Nachweis |
| S4 | Kurztext (F5): jedes Spiel hat einen eigenen Kurztext auf Deutsch (description_locale = 'de'); geschrieben von Chris oder als KI-Entwurf, den Chris geprüft und freigegeben hat. Keine fremden Beschreibungstexte. |
Skript (Pflicht, Sprache de) und checks.sql |
| S5 | Vokabular (F8): Genres und Mechaniken nur aus der von Chris freigegebenen Liste (Vorschlag durch Agents: ca. 12–15 Genres, 20–30 Mechaniken, de/en). | Skript (unbekannter Begriff → Fehler) |
| S6 | Kein Cover (image_path leer), keine Quelle (source_id leer), keine BGG-Kennung aus einem Abgleich (N11, Krit. 10). |
CSV hat keine solchen Spalten; checks.sql |
| S7 | Erweiterungen (F8) sind erlaubt; das Basisspiel wird in base_game_key vorgemerkt, die Verknüpfung entsteht mit KAT-03 (#12). Bis dahin erscheinen Erweiterungen als eigenständige Katalogeinträge. |
Skript prüft, dass base_game_key auf einen vorhandenen key zeigt |
| S8 | Alle gesetzten Felder gelten als manuell gepflegt (F4); das erledigt der Trigger beim Einspielen. | pgTAP 06 |
Lieferweg
| Umgebung | Weg |
|---|---|
| lokal, CI | supabase/config.toml: [db.seed] sql_paths = ["./seed.sql", "./catalog/vocabulary.sql", "./catalog/startbestand.sql"]; supabase db reset spielt alles ein |
| Staging, Produktion | Nach den Migrationen spielt Chris vocabulary.sql und startbestand.sql per Studio-SQL-Editor oder psql ein (Runbook § „Startbestand einspielen“). Die Dateien sind idempotent und dürfen wiederholt laufen, bis zum Go-live; danach ist die Datei eingefroren und Korrekturen erfolgen nur noch im Studio (F6). |
Dateien
supabase/catalog/vocabulary.sql: Genres und Mechaniken (de/en) mit festen UUIDs,insert … on conflict (id) do update set name_de = excluded.name_de, name_en = excluded.name_en. Inhalt: Vorschlag durch Agents (Task B3a), gültig erst nach Freigabe durch Chris (S5). Die Begriffe ausseed.sqlsind nur Beispieldaten für die Entwicklung;seed.sqlwird so angepasst, dass es keine Begriffe mehr anlegt, die mit dem Vokabular kollidieren (Unique aufname_de/name_en).supabase/catalog/startbestand.sql: je Spiel ein Eintrag ingames(visibility = 'catalog',image_path = null,source_id = null) mit fester UUID sowie Zuordnungen zu Verlagen, Genres, Mechaniken. Upsert aufid(on conflict (id) do update). Am Anfangbegin;, am Endecommit;. Die Datei wird erzeugt, nie von Hand bearbeitet.- Entschieden (F6): Chris pflegt
supabase/catalog/startbestand.csv(z. B. aus einer Tabellenkalkulation exportiert). Das Skriptsupabase/catalog/build_startbestand.ts(Deno, ohne Fremdpakete außer der Standardbibliothek) prüft die CSV und erzeugt darausstartbestand.sql. Die UUID eines Spiels ergibt sich deterministisch (UUID v5) aus der Spaltekey(z. B.azul), damit Korrekturen dieselbe Zeile treffen.
CSV-Format (F6)
| Spalte | Pflicht | Inhalt |
|---|---|---|
key |
ja | stabiler Schlüssel, ^[a-z0-9-]{2,60}$, eindeutig |
title |
ja | Haupttitel |
publishers |
ja | ein oder mehrere Verlage, getrennt durch ; |
year |
ja | Erscheinungsjahr |
min_players, max_players |
ja | Spielerzahl |
min_minutes, max_minutes |
ja | Spieldauer |
min_age |
ja | Mindestalter |
genres |
ja | ein oder mehrere Genres aus dem freigegebenen Vokabular (name_de), getrennt durch ; |
mechanics |
ja | eine oder mehrere Mechaniken aus dem freigegebenen Vokabular, getrennt durch ; |
description |
ja (S4) | eigener Kurztext |
description_locale |
ja (S4) | immer de |
facts_source |
nein | Herkunft der Fakten als Nachweis für S3 (z. B. „Verlag“, „Schachtel“, „Wikidata Q…“); wird nicht in die Datenbank übernommen |
alt_titles |
nein | reserviert für KAT-01 (#3), Format de:Titel;en:Title; das Skript ignoriert die Spalte, bis #3 die Tabelle hat |
base_game_key |
nein | reserviert für KAT-03 (#12), key des Basisspiels (S7); wird nur auf Existenz geprüft, bis #12 die Spalte hat |
Das Skript bricht mit Zeilennummer und Grund ab bei: fehlender Pflichtangabe, unbekanntem Genre/Mechanik, doppeltem key, max < min, Werten außerhalb der DB-Checks, fehlendem Kurztext, description_locale ≠ de, base_game_key ohne passenden key. Es gibt keine Spalte für ein Cover und keine Quellen-Spalten (Krit. 10). So kann ein unvollständiges Spiel gar nicht erst in die SQL-Datei gelangen; CI bleibt grün, auch während Chris noch einpflegt.
Prüfabfrage vor dem Go-live (supabase/catalog/checks.sql, Teil der Release-Checkliste): Anzahl sichtbarer Katalogspiele (S1, mindestens 180), Spiele ohne Verlag, Genre, Mechanik oder Kurztext (S2, S4), Kurztexte mit description_locale ≠ de, Katalogspiele mit image_path oder source_id (S6, müssen 0 sein). Die Abfrage gibt je Regel eine Zeile mit Sollwert, Istwert und „ok“/„Verstoß“ aus.
Daten werden nicht von Agents erfunden. Agents liefern Werkzeug, Format, Prüfungen, den Vokabular-Vorschlag und höchstens offensichtlich fiktive Testzeilen in Fixtures (z. B. „Testspiel A“). Den Inhalt liefert Chris (Krit. 7).
4.4 Runbook docs/katalog-pflege.md (Krit. 8)
Ein Dokument, wachsend über die PRs:
| Abschnitt | PR | Inhalt |
|---|---|---|
| Zugang | 2a | Studio über SSH-Tunnel/VPN, Rolle postgres, „immer in einer Transaktion arbeiten“ (begin; … commit;), vor größeren Änderungen Backup-Stand prüfen |
| Startbestand pflegen, einspielen und abnehmen | 2a | Randbedingungen S1–S8; CSV bearbeiten → Skript ausführen → Commit; Reihenfolge Migrationen → vocabulary.sql → startbestand.sql; Prüfabfrage checks.sql; ab dem Go-live ist die Datei eingefroren, Korrekturen nur noch im Studio (F6) |
| Spiel anlegen und bearbeiten | 2a | Tabelleneditor oder SQL; Zuordnungen zu Verlag, Genre, Mechanik; Hinweis: jede Änderung wird automatisch in manual_fields vermerkt; Markierung bewusst entfernen nur per SQL |
| Ausblenden und wieder einblenden | 2b | hidden_at setzen bzw. leeren; Wirkung: nicht mehr in Suche/Entdecken, Detailseite und bestehende Sammlungen und Partien bleiben (F2) |
| Zusammenführen | 2b | Duplikat finden (Abfrage nach normalisiertem Titel + Jahr), select public.admin_merge_games('<quelle>', '<ziel>');, Ergebnis-JSON lesen (inkl. Kollisionen nach F1), Fehlertexte und ihre Bedeutung |
| Meldungen bearbeiten | 2c | offene Meldungen abfragen (älteste zuerst), Ähnliche im Katalog suchen, Entscheidung: freigeben (erst Katalogeintrag anlegen, dann admin_resolve_report(…, 'approved', '<ziel>')), zusammenführen ('merged', hängt den vorläufigen Eintrag um), ablehnen mit Verweis ('rejected' mit Ziel, hängt nicht um) oder ohne Verweis (F3); Fehlertexte |
| Admin-Rolle in der App | 2a | in R1 nicht vergeben (E10), Vorgehen für R4 |
Jeder SQL-Baustein im Runbook wird in pgTAP mindestens einmal genau so ausgeführt (siehe 7.1), damit das Runbook nicht veraltet.
5. Frontend
KAT-05 hat in R1 keine eigenen Screens (keine Admin-UI, Klärung 44). Die App-Anteile der Kriterien entstehen in den Stories, die Katalogdaten anzeigen:
| Kriterium | App-Anteil | Wo umgesetzt |
|---|---|---|
| Krit. 4 („nicht auffindbar“) | Suche und Entdecken lesen nur catalog_games |
KAT-01 (#3), Spec #3 |
| Krit. 4 (zusammengeführt, ausgeblendet) | Detailseite eines zusammengeführten Eintrags leitet auf den Zieleintrag weiter; ein ausgeblendeter Eintrag bleibt per Direktaufruf und aus Sammlung/Partien erreichbar und zeigt den Hinweis „nicht mehr im Katalog“ (F2) | KAT-02 (#4) |
| Krit. 10 (Platzhalter-Cover) | Platzhalter, wenn image_path leer |
Widget in 2a, Einsatz in #3/#4 |
| Krit. 10 (eigener Kurztext) | Anzeige von description, Sprachhinweis über description_locale (englische Oberfläche: „Beschreibung auf Deutsch“) |
KAT-02 (#4) |
| Krit. 5 (Ergebnis einer Meldung) | Hinweis am vorläufigen Eintrag; bei „abgelehnt mit Verweis“ Angebot, auf den Zieleintrag zu wechseln (F3) | KAT-04 (#13) |
In 2a: Widget GameCover in app/lib/core/widgets/game_cover.dart (wird von Katalog, Sammlung, Dashboard und Partien genutzt, deshalb core/).
- Eingaben:
imageUrl(nullable; die Auflösung des Storage-Pfads liefert später die Dateizugriffsschicht),title(für die Beschriftung),size(klein für Listen, groß für die Detailseite). - Zustände: kein Bild → Platzhalter; Bild lädt → Platzhalter mit dezenter Ladeanzeige; Fehler beim Laden → Platzhalter (kein Fehlerzustand, ein fehlendes Cover ist kein Fehler).
- Platzhalter (F9, entschieden): ein einheitlicher, neutraler Platzhalter für alle Spiele, mit Flutter gezeichnet (Fläche in Theme-Farbe, neutrales Symbol), keine Bilddatei von Dritten, kein neues Paket, keine Variante je Genre.
- Semantics: Bild ausgeblendet aus dem Semantics-Baum, wenn der Titel daneben steht (
excludeSemantics-Variante); sonst Beschriftung aus ARBgameCoverPlaceholderLabel(„Kein Cover für {title}“ / „No cover for {title}“). - Keine Geschäftslogik, keine Domänenmodelle in 2a. Das Domänenmodell
CatalogGame(freezed) und das Repository legt KAT-01 (#3) an, auf Basis der Spalten voncatalog_games.
6. Plattform-Besonderheiten
- iOS, Android, Web: keine Unterschiede. Das Platzhalter-Cover ist Flutter-Code, keine Plattform-API.
- Offline: nicht betroffen. Katalogsuche braucht eine Verbindung (PAR-01 Krit. 6).
- Dateien/Kamera/Push: nicht betroffen. Cover-Uploads gibt es in R1 nicht (N11); Storage-Buckets für Spielbilder entstehen erst, wenn ein Cover erlaubt ist.
- Self-Hosting: Studio-Zugang nur über Tunnel/VPN (4.2); Startbestand per SQL-Datei (4.3).
7. Teststrategie
Werkzeuge: pgTAP (supabase test db), Deno-Tests für das Erzeugungsskript, flutter_test für das Widget. Testnamen enthalten die Referenz („KAT-05 Kriterium n“).
Neue pgTAP-Dateien
06_catalog_foundation.test.sql(2a): Spalten, Checks, Spaltenrechte, Triggermanual_fields, Viewcatalog_games,catalog_sources, Verlage.07_catalog_merge.test.sql(2b):repoint_game_references,admin_merge_games, Ausblenden; Fremdschlüssel-Wächter.08_game_reports.test.sql(2c): Tabelle, RLS,admin_resolve_report.
Die Tests müssen auch mit eingespieltem Startbestand grün sein: Sie legen eigene Fixtures mit festen Test-UUIDs an und zählen nur diese (Filter auf die Fixture-IDs), statt den gesamten Katalog zu löschen. 02_catalog.test.sql löscht weiterhin alles (delete from public.games innerhalb der zurückgerollten Transaktion); das funktioniert auch mit dem Startbestand, weil der Startbestand keine Verweise aus Nutzertabellen hat.
Fremdschlüssel-Wächter (2b): Ein Test liest aus pg_constraint alle Fremdschlüssel, die auf public.games zeigen, und vergleicht sie mit der Liste, die repoint_game_references behandelt (bzw. bewusst auslässt: Zuordnungstabellen, games.merged_into_id, game_reports). Legt eine spätere Story eine neue Tabelle mit Verweis auf games an, wird der Test rot und erzwingt die Erweiterung der Funktion.
7.1 Abdeckungsmatrix
| AK | Unit | Widget | Integration/E2E | RLS/pgTAP | Task |
|---|---|---|---|---|---|
| KAT-05 Krit. 1 (nur Modell) | – | – | – | 06: Update eines Katalogspiels als postgres → Feld in manual_fields; mit set local shuffle.catalog_import = 'on' → nicht markiert; Insert markiert alle gesetzten Felder (F4); Zuordnung Genre/Verlag → genres/publishers markiert; unbekannter Schlüssel in manual_fields → 23514 |
B1, B2 |
| Krit. 2 | – | – | – | 06: games.id ist uuid-PK mit Standard; ID bleibt bei Update unverändert (bestehender Trigger); source_id ohne source_ref → 23514; (source_id, source_ref) im Katalog eindeutig → 23505; Quelle bgg vorhanden |
B1, B2 |
| Krit. 3 | – | – | – | – (zurückgestellt, kein Modellanteil) | – |
| Krit. 4 | – | – | (KAT-01: Suche findet ausgeblendete Spiele nicht, E2E in #3) | 06: catalog_games ohne ausgeblendete und zusammengeführte Spiele, für anon und authenticated; ausgeblendetes Spiel bleibt per ID in games lesbar (F2); Sammlungseintrag eines Nutzers zu einem ausgeblendeten Spiel bleibt lesbar. 07: Ausblenden/Einblenden per Runbook-SQL; admin_merge_games hängt Sammlung, Favoriten, Partien (mit Variante) und Wertungsbögen um; Kollision (F1): Ziel bleibt, leere Felder aus der Quelle gefüllt, owned schlägt wishlist/shopping, Quell-Eintrag gelöscht; Quelle danach merged_into_id/hidden_at gesetzt, Ketten geglättet; alle Fehlerfälle (merge_same_game … merge_source_already_merged); Fremdschlüssel-Wächter |
B1, B2, B5, B6 |
| Krit. 5 | – | – | – | 08: Checks der Status-Kombinationen; admin_resolve_report für approved und merged (vorläufiger Eintrag umgehängt und zusammengeführt), rejected mit Ziel (nur Verweis gespeichert, vorläufiger Eintrag unverändert; F3), rejected ohne Ziel (vorläufiger Eintrag unverändert); report_not_open bei zweitem Aufruf; report_target_required, report_target_not_listed; N12a-Index: zweite offene Import-Meldung desselben Nutzers zur selben BGG-ID → 23505, bei anderem Nutzer erlaubt; Konto löschen → Meldungen weg |
B8, B9 |
| Krit. 6 (nur Modell) | – | – | – | 06: catalog_games.description/image_path sind null bei Quelle ohne Erlaubnis, sichtbar mit Erlaubnis, sichtbar bei manuell gepflegtem Feld, sichtbar ohne Quelle |
B1, B2 |
| Krit. 7 | Deno: Skript erzeugt aus einer Fixture-CSV die erwartete SQL (Snapshot), gleiche key → gleiche UUID, Fehler mit Zeilennummer je Prüfregel, facts_source landet nicht in der SQL |
– | CI: supabase db reset mit vocabulary.sql + startbestand.sql läuft durch; CI-Schritt „erzeugte SQL ist aktuell“ (Skript erneut ausführen, git diff --exit-code); checks.sql läuft gegen die lokale DB ohne Fehler (die Mindestzahl S1 ist dort nur Information, kein CI-Abbruch) |
– | B3, T1 |
| Krit. 8 | – | – | – | 07/08: jeder SQL-Baustein aus docs/katalog-pflege.md wird im Test genau so ausgeführt (Kommentar mit Abschnittsverweis); Review prüft Vollständigkeit |
B4, B7, B10 |
| Krit. 9 | – | – | API-Test optional (PostgREST PATCH /rest/v1/games mit Nutzer-JWT → 0 Zeilen bzw. 401/403), nur falls test-engineer ihn für nötig hält; pgTAP deckt dieselben Rollen ab |
06: anon und Nutzer (auch mit gefälschtem user_metadata.role = 'admin') können Katalogspiele, catalog_sources, publishers, game_publishers (Katalog) nicht anlegen, ändern, löschen; Nutzer kann an eigenem privaten Spiel hidden_at, merged_into_id, manual_fields, source_id nicht setzen (42501), wohl aber title; View nicht schreibbar. 07/08: admin_merge_games, admin_resolve_report, repoint_game_references für anon/authenticated nicht ausführbar (42501), auch mit app_metadata.role = 'admin'; game_reports: Nutzer B liest A's Meldungen nicht, niemand außer postgres ändert oder löscht Meldungen, anon liest nichts |
B2, B6, B9 |
| Krit. 10 | Deno: CSV ohne Pflicht-Fakten, ohne Kurztext oder mit description_locale ≠ de → Fehler; unbekannter Begriff → Fehler; CSV hat keine Cover-/Quellen-Spalte |
GameCover: ohne imageUrl → einheitlicher Platzhalter; Ladefehler → Platzhalter; Semantics-Label de/en; Kontrast und textScaler 2.0 ohne Überlauf |
(KAT-02: Detailseite zeigt Platzhalter, Kurztext und Sprachhinweis, in #4) | 06: Fixture-Spiele aus dem Startbestands-Format haben image_path is null, source_id is null, description_locale = 'de'; Prüfabfrage checks.sql meldet für die Fixtures 0 Verstöße und für ein absichtlich fehlerhaftes Fixture den erwarteten Verstoß |
B3, U1, T1, T2 |
| Sicherheit Krit. 2 | – | – | – | 06: anon liest catalog_games, catalog_sources, publishers, game_publishers (Katalog), schreibt nichts |
B2 |
| Sicherheit Krit. 3 | – | – | – | 08: fremder Zugriff auf game_reports |
B9 |
| QUE-05 Krit. 2, QUE-06 Krit. 1–3 | – | GameCover in de/en, a11y-Guidelines |
– | – | U1, T2 |
Nicht automatisiert testbar, manuelle Prüfung: Studio nicht öffentlich erreichbar (Staging/Prod, D1); Einspielen des Startbestands auf Staging/Prod und Prüfabfrage mit mindestens 180 Spielen (S1, Release-Checkliste, Abnahme durch Chris); Herkunft der Fakten nach S3 (Bestätigung durch Chris, rechtliche Prüfung vor dem Release); inhaltliche Richtigkeit der Fakten und Kurztexte (Chris).
8. Aufgaben
8.1 PR 2a – Fundament (R1.1), Branch feature/2-katalog-fundament
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| B1 | backend-dev | Migration <ts>_catalog_foundation.sql nach 3.2: catalog_sources (+ Zeile bgg), neue Spalten in games mit Checks und Indizes, publishers, game_publishers, Trigger games_mark_manual_fields (Insert markiert alle gesetzten Felder, F4) und game_links_mark_manual, View catalog_games, Spaltenrechte auf games, RLS und Grants für alle neuen Tabellen |
– |
| B2 | backend-dev | pgTAP 06_catalog_foundation.test.sql nach 7.1 (Krit. 1, 2, 4, 6, 9, 10, Sicherheit Krit. 2); bestehende Tests mit Startbestand lauffähig halten; 00_schema.test.sql prüft fest „genau 17 Tabellen“ → auf 20 anpassen (catalog_sources, publishers, game_publishers), Zahlen in supabase/README.md („17 Tabellen, 2 Views“) nachziehen (3 Views mit catalog_games) |
B1 |
| B3a | backend-dev | Vokabular-Vorschlag (ca. 12–15 Genres, 20–30 Mechaniken, je name_de/name_en) als supabase/catalog/vocabulary.sql mit festen UUIDs; Vorlage zur Freigabe an Chris (im PR als eigener Abschnitt). Gilt erst nach Freigabe (C0). |
B1 |
| B3 | backend-dev | Startbestand-Werkzeug nach 4.3: CSV-Vorlage startbestand.csv (nur Kopfzeile + Hinweise), Skript build_startbestand.ts mit allen Prüfregeln (S2, S4–S7) und Deno-Tests, erzeugtes startbestand.sql (zunächst leer bzw. nur Kopf), checks.sql mit Regeln S1, S2, S4, S6 (Soll/Ist/Ergebnis), config.toml [db.seed] sql_paths, seed.sql bereinigen (keine kollidierenden Begriffe) |
B1, B3a |
| B4 | backend-dev | Doku: docs/katalog-pflege.md Abschnitte Zugang, Startbestand (Randbedingungen S1–S8, Ablauf CSV → Skript → Einspielen → Abnahme, Einfrieren nach Go-live), Spiel anlegen/bearbeiten, Admin-Rolle (4.4); docs/datenmodell-r1.md (neue Objekte, doppelten Abschnitt zusammenführen); supabase/README.md |
B1, B3 |
| U1 | ui-designer | GameCover in core/widgets/ nach 5: ein einheitlicher neutraler Platzhalter (F9), Größen klein/groß, Ladezustand, Semantics, ARB-Schlüssel de/en |
– |
| T1 | test-engineer | Review der pgTAP-Abdeckung gegen 7.1; Deno-Tests des Skripts ergänzen (jede Prüfregel S2, S4–S7 mit Positiv- und Negativfall); CI-Schritt „erzeugte SQL ist aktuell“ mit devops abstimmen | B2, B3 |
| T2 | test-engineer | Widget-Tests GameCover (Platzhalter, Ladefehler, de/en, a11y-Guidelines, textScaler 2.0) |
U1 |
| D1 | devops | Studio-/Postgres-Zugang auf Staging/Prod nur über SSH-Tunnel/VPN, Basic-Auth gesetzt, Studio-Rolle per select current_user bestätigen (Doku in docs/, kein Eingriff ohne Freigabe); CI: Deno für das Skript bereitstellen, Schritt „erzeugte SQL aktuell“; Release-Checkliste um checks.sql (S1: mindestens 180) ergänzen |
B3 |
| C0 | Chris | Vokabular-Vorschlag aus B3a freigeben oder korrigieren | B3a |
| C1 | Chris | Startbestand in der CSV pflegen (mindestens 180, Ziel ~200; Fakten nach S3, deutsche Kurztexte nach S4), Skript ausführen, committen; vor dem Go-live auf Produktion einspielen und checks.sql abnehmen |
B3, C0 |
8.2 PR 2b – Admin-Pflege (R1.2), Branch feature/2-katalog-pflege
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| B5 | backend-dev | Migration <ts>_catalog_merge.sql nach 3.3: repoint_game_references mit Kollisionsregel F1, admin_merge_games, Rechte (revoke execute) |
2a gemergt |
| B6 | backend-dev | pgTAP 07_catalog_merge.test.sql inkl. Kollisionsfällen (F1: gleiche Liste, unterschiedliche Liste, leere/gefüllte Felder) und Fremdschlüssel-Wächter |
B5 |
| B7 | backend-dev | Runbook: Abschnitte Ausblenden und Zusammenführen (SQL-Bausteine identisch zu den Tests) | B5, B6 |
8.3 PR 2c – Meldungen (R1.2), Branch feature/2-katalog-meldungen
| # | Agent | Aufgabe | abhängig von |
|---|---|---|---|
| B8 | backend-dev | Migration <ts>_game_reports.sql nach 3.4: Tabelle, Checks, Indizes, RLS (nur Lesen der eigenen), admin_resolve_report (Umhängen bei approved und merged, nur Verweis bei rejected mit Ziel; F3), admin_merge_games erweitert |
2b gemergt |
| B9 | backend-dev | pgTAP 08_game_reports.test.sql; 00_schema.test.sql auf 21 Tabellen, README-Zahlen nachziehen |
B8 |
| B10 | backend-dev | Runbook: Abschnitt Meldungen bearbeiten | B8, B9 |
8.4 Reihenfolge und Parallelität
R1.1 B1 → B2 | B3a → C0 → B3 → B4, T1, D1 parallel: U1 → T2 danach: C1 (Chris, laufend bis Go-live)
2a gemergt → #3 KAT-01 kann starten
R1.2 B5 → B6 → B7 (2b) → B8 → B9 → B10 (2c) → #13 KAT-04 Umsetzung
B3 kann mit einem vorläufigen Fixture-Vokabular für die Tests beginnen; die Datei vocabulary.sql wird erst nach C0 endgültig. 2c muss vor der Umsetzung von #13 gemergt sein (E12); die Spec von #13 kann parallel entstehen und baut auf 3.4 auf. Merge nur durch Chris.
9. Risiken & offene Fragen
9.1 Risiken
| # | Risiko | Auswirkung | Gegenmaßnahme |
|---|---|---|---|
| R1 | Studio umgeht RLS und hat im Self-Hosting keine eigene Benutzerverwaltung | Wer Studio erreicht, kann alles lesen und ändern, auch private Nutzerdaten | Studio und Postgres nur über SSH-Tunnel/VPN (D1), Basic-Auth als zweite Schicht; Runbook: immer in Transaktionen, Funktionen statt Hand-SQL für Zusammenführen und Meldungen |
| R2 | EU-Datenbankrecht und Nutzungsbedingungen: Werden die Fakten für ~200 Spiele in großem Umfang aus BGG oder einer anderen Datenbank übernommen, kann das das Schutzrecht des Datenbankherstellers bzw. deren Nutzungsbedingungen verletzen, auch wenn Einzelfakten nicht geschützt sind | Rechtliches Risiko für den Start | Entschieden (F7): Fakten aus Verlagsseiten, Spielschachtel, eigener Kenntnis, ergänzend Wikidata (CC0); keine systematische Übernahme aus BGG (S3); optionaler Herkunftsnachweis facts_source; endgültige Bewertung mit der rechtlichen Prüfung vor dem Release (R11 in Spec #1) |
| R3 | Spätere Tabellen mit Verweis auf games werden beim Zusammenführen vergessen |
Verwaiste Verweise auf ausgeblendete Quell-Einträge | Fremdschlüssel-Wächter in 07 (7) |
| R4 | Ausgeblendete Spiele bleiben über die rohe API per ID lesbar (E3) | Ein aus rechtlichen Gründen ausgeblendeter Eintrag wäre weiter abrufbar | Akzeptiert (F2); im Ernstfall Eintrag zusammenführen oder inhaltlich leeren; Status „gesperrt“ mit RLS-Filter erst bei Bedarf |
| R5 | Startbestand weicht zwischen CSV/SQL-Datei und Produktion ab, sobald Chris nach dem Go-live im Studio korrigiert | Erneutes Einspielen der Datei überschreibt Studio-Korrekturen (Upsert) | Entschieden (F6): Datei ist ab dem Go-live eingefroren und wird nicht mehr eingespielt; Regel im Runbook |
| R6 | manual_fields markiert den ganzen Startbestand als manuell (F4) |
Ein späterer Import ergänzt nur leere Felder und korrigiert keine Fakten | Bewusst entschieden (F4); Admin kann Markierungen per SQL gezielt entfernen (Runbook) |
| R7 | game_reports wird in #2 angelegt, die Regeln zum Anlegen kommen erst mit #13 |
Zwischen 2c und #13 gibt es Meldungen nur per Studio (für Tests) | Kein Insert-Recht für Nutzer in 2c, deshalb kein offener Zwischenzustand |
| R8 | Spaltenrechte auf games schränken auch einen künftigen Admin über die API ein |
QUE-08 (R4) braucht eigene RPCs | Bewusst; Hinweis in 3.2 |
| R9 | ~180–200 Spiele mit deutschen Kurztexten einpflegen ist viel Handarbeit für Chris | Startbestand zum R1-Start unvollständig | CSV mit Prüfungen (F6), KI-Entwürfe für Kurztexte mit Prüfung durch Chris zulässig (F5), Mindestzahl 180 (F8); checks.sql zeigt den Fortschritt |
9.2 Entscheidungen zu den offenen Fragen (Chris, 2026-11-03)
Alle neun Fragen hat Chris am 2026-11-03 entschieden („Alle wie empfohlen“). Keine Frage blockiert mehr einen PR.
| # | Frage | Status | Entscheidung | Wirkung in der Spec |
|---|---|---|---|---|
| F1 | Zusammenführen bei Doppelbesitz (Nutzer hat Quell- und Zielspiel) | entschieden 2026-11-03 | Option A: Zieleintrag bleibt; leere Felder des Ziels werden aus dem Quell-Eintrag gefüllt; bei unterschiedlicher Liste gilt Sammlung vor Wunsch- vor Einkaufsliste (wie Klärung 34); Quell-Eintrag wird gelöscht. | 3.3 Schritt 2, 7.1 Krit. 4, B5, B6 |
| F2 | Sichtbarkeit ausgeblendeter Einträge | entschieden 2026-11-03 | Option A: nur aus Suche und Entdecken entfernt; Detailseite, Sammlungen und Partien zeigen sie weiter mit Hinweis „nicht mehr im Katalog“. Ein Status „gesperrt“ (Option C) kommt erst bei Bedarf. | E3, 3.2 RLS, 5, R4, KAT-02 (#4) |
| F3 | Umhängen bei Meldung „zusammengeführt“ / „abgelehnt mit Verweis“ | entschieden 2026-11-03 | Option A: „zusammengeführt“ hängt den vorläufigen Eintrag automatisch um (wie „freigegeben“); „abgelehnt mit Verweis“ speichert nur den Verweis, der Nutzer entscheidet in der App. | 3.4, 4.4, 5, 7.1 Krit. 5, B8, Spec #13 |
| F4 | Kennzeichnung des Startbestands als manuell gepflegt | entschieden 2026-11-03 | Option A: alle beim Einpflegen gesetzten Felder gelten als manuell gepflegt; ein späterer Import ergänzt nur leere Felder. | E4, 3.2 Trigger, S8, R6, B1 |
| F5 | Eigene Kurztexte: Sprache, Autor, Pflicht | entschieden 2026-11-03 | Deutsch; Chris schreibt selbst oder prüft und gibt KI-Entwürfe frei; Pflicht für alle Startbestandsspiele. Englische Oberflächen zeigen den Sprachhinweis (Klärung 48). | E8, § 2 Abnahme, S4, CSV, checks.sql, B3 |
| F6 | Pflegeformat des Startbestands | entschieden 2026-11-03 | Option A: CSV im Repo, Deno-Skript prüft und erzeugt die SQL-Datei; nach dem Go-live ist die Datei eingefroren, Korrekturen nur noch im Studio. | E9, 4.3, 4.4, R5, B3 |
| F7 | Herkunft der Fakten | entschieden 2026-11-03 | Option A, ergänzend C: Verlagsseiten, Spielschachtel, eigene Kenntnis, ergänzend Wikidata (CC0); keine systematische Übernahme aus BGG; endgültige Bewertung mit der rechtlichen Prüfung vor dem Release. Verbindliche Randbedingung S3. | § 2 Abnahme, 4.3 S3, facts_source, R2, C1 |
| F8 | Umfang, Abnahme, Vokabular, Erweiterungen | entschieden 2026-11-03 | Mindestens 180 sichtbare Katalogspiele mit allen Pflicht-Fakten zum Go-live (Ziel ~200); Vokabular schlagen Agents vor, Chris gibt frei; Erweiterungen erlaubt und über base_game_key vorgemerkt. Verbindliche Randbedingungen S1, S5, S7. |
§ 1, § 2 Abnahme, 4.3, B3a, C0, C1, D1 |
| F9 | Platzhalter-Cover | entschieden 2026-11-03 | Option A: ein einheitlicher, neutraler Platzhalter für alle Spiele. | 5, U1, T2 |
9.3 Ausstehende Prozessschritte
- Spec freigegeben am 2026-11-03, F1–F9 eingearbeitet. Nächste Schritte: B1 (backend-dev), parallel U1 (ui-designer); B3a liefert den Vokabular-Vorschlag zur Freigabe durch Chris (C0).
- Label
size/xl→size/l(E2) ändert nur Chris. - Spec #13 (KAT-04) baut auf 3.4 auf: Insert-Policy, Tageslimit, Ähnlichkeitsprüfung, Hinweis am vorläufigen Eintrag inkl. Wechsel auf den Zieleintrag bei „abgelehnt mit Verweis“ (F3), Herkunft „aus Import“ (N12).
- Kein ADR nötig: Die Entscheidungen bleiben innerhalb von ADR-0001/0002 und sind ohne großen Aufwand änderbar (Startbestand ist eine Datei,
manual_fieldseine Spalte).
9.4 Hinweise für andere Stories, nicht Teil von #2
- KAT-01 (#3): Suche über
catalog_games(bzw. eine RPC darauf); Tabelle für alternative Titel (Klärung 3) mit neuemmanual_fields-Schlüssel; Spaltealt_titlesder Startbestands-CSV auswerten. - KAT-02 (#4): Weiterleitung bei
merged_into_id; beihidden_atDetailseite weiter anzeigen mit Hinweis „nicht mehr im Katalog“ (F2); Quellenangabe aussource_*(KAT-02 Krit. 5); Sprachhinweis überdescription_locale(Startbestand: Deutsch). - KAT-03 (#12): Basisspiel-Verweis (Klärung 5) mit
manual_fields-Schlüssel;base_game_keyder CSV auswerten und Erweiterungen des Startbestands verknüpfen (S7);repoint_game_referencesfür die neue Spalte prüfen (Fremdschlüssel-Wächter). - PAR-04 (#18): Sobald ein Wertungsbogen je Spiel und Nutzer erzwungen wird, Kollisionsregel in
repoint_game_referencesergänzen. - SAM-02/SAM-04 (#7, #8): Neue Felder an
collection_items(Zustand, Standort, Tags, Priorität) in die Kollisionsregel F1 („leere Felder des Ziels aus der Quelle füllen“) aufnehmen. - SAM-08 (#25): Import setzt
set local shuffle.catalog_import = 'on', pflegtsource_*undsource_synced_at, legt Import-Meldungen mitorigin = 'import'an (N12, N12a). - KTO-05 (#23):
game_reportswird peron delete cascademit dem Profil gelöscht; im Export enthalten.
10. Änderungsprotokoll
2026-11-03 – Entwurf
Erste Fassung durch architect, wartet auf Freigabe und Antworten zu F1–F9.
Nachtrag am selben Tag: Rechte-Regel für neue Objekte (Standardrechte von Supabase erst entziehen) in 3.2 ausdrücklich ergänzt; Studio-Rolle postgres bzw. supabase_admin (4.2); B2/B9 um die Anpassung von 00_schema.test.sql (fest „17 Tabellen“) und der README-Zahlen ergänzt.
2026-11-03 – Freigabe und Entscheidungen F1–F9
Chris hat die Spec freigegeben und alle Fragen wie empfohlen entschieden. Eingearbeitet:
- Status auf „freigegeben“; § 9.2 als Entscheidungstabelle.
- E3, E4, E8, E9 mit den Entscheidungen F2, F4, F5, F6.
- § 2: Abnahme von Krit. 7 (mindestens 180 sichtbare Katalogspiele,
checks.sql) und Krit. 10 (Pflicht-Fakten, deutscher Kurztext, kein Cover, Herkunft nach F7) konkretisiert. - 4.3: verbindliche Randbedingungen S1–S8 für den Startbestand; CSV-Format mit Pflicht-Kurztext (
de), optionaler Spaltefacts_sourceund Prüfung vonbase_game_key;checks.sqlmit Soll/Ist je Regel. - 3.3/3.4: Kollisionsregel F1 und Umhäng-Regel F3 ausformuliert.
- 5: einheitlicher Platzhalter (F9), Hinweis „nicht mehr im Katalog“ (F2).
- 7.1 und 8: Tests und Aufgaben nachgezogen; neue Tasks B3a (Vokabular-Vorschlag) und C0 (Freigabe Vokabular durch Chris); Abhängigkeiten von offenen Fragen entfernt; D1 um die Release-Prüfung S1 ergänzt.
- 9.4: Hinweis für SAM-02/SAM-04 zur Kollisionsregel.