-- KAT-05 / PR 2a: Katalog-Fundament (Spec docs/specs/2-katalog-daten.md, Abschnitt 3.2). -- Nur additiv. Enthält: Quellen (catalog_sources), neue Spalten an games, Verlage, -- Kennzeichnung manuell gepflegter Felder, Ausblenden/Zusammenführen (Spalten), View catalog_games -- und die neu geschnittenen Spaltenrechte auf games. -- Nicht enthalten (spätere PRs): admin_merge_games (2b), game_reports und -- games_visibility_immutable (2c). -- --------------------------------------------------------------------------------------------- -- Quellen: woher Katalogdaten stammen und was davon angezeigt werden darf (Krit. 2, 6; E5) -- --------------------------------------------------------------------------------------------- create table public.catalog_sources ( id uuid primary key default gen_random_uuid(), key text not null unique check (key ~ '^[a-z0-9_]{2,30}$'), name text not null check (char_length(name) between 1 and 100), attribution_text text check (char_length(attribution_text) <= 200), attribution_url text check (char_length(attribution_url) <= 500 and attribution_url ~ '^https://'), images_allowed boolean not null default false, texts_allowed boolean not null default false, terms_url text check (char_length(terms_url) <= 500 and terms_url ~ '^https://'), terms_checked_at date, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create trigger set_updated_at before update on public.catalog_sources for each row execute function public.set_updated_at(); -- Referenzdaten, keine Katalogdaten: BGG ist als Quelle bekannt, aber ohne Anzeige-Erlaubnis. insert into public.catalog_sources (key, name, attribution_text, attribution_url, images_allowed, texts_allowed) values ('bgg', 'BoardGameGeek', 'Powered by BGG', 'https://boardgamegeek.com', false, false); -- --------------------------------------------------------------------------------------------- -- Verlage (Klärung 4, E7) -- --------------------------------------------------------------------------------------------- create table public.publishers ( id uuid primary key default gen_random_uuid(), name text not null check (char_length(name) between 1 and 200), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create unique index publishers_name_key on public.publishers (lower(name)); create table public.game_publishers ( id uuid primary key default gen_random_uuid(), game_id uuid not null references public.games (id) on delete cascade, publisher_id uuid not null references public.publishers (id) on delete cascade, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), unique (game_id, publisher_id) ); create index game_publishers_publisher_id_idx on public.game_publishers (publisher_id); create trigger set_updated_at before update on public.publishers for each row execute function public.set_updated_at(); create trigger set_updated_at before update on public.game_publishers for each row execute function public.set_updated_at(); -- --------------------------------------------------------------------------------------------- -- Neue Spalten an games -- --------------------------------------------------------------------------------------------- alter table public.games add column description_locale text, add column hidden_at timestamptz, add column merged_into_id uuid, add column manual_fields text[] not null default '{}', add column catalog_source_id uuid, add column source_ref text, add column source_url text, add column source_synced_at timestamptz; -- Fremdschlüssel: Ziel eines Zusammenführens und Quellen werden nie gelöscht (H7). alter table public.games add constraint games_merged_into_id_fkey foreign key (merged_into_id) references public.games (id) on delete restrict, add constraint games_catalog_source_id_fkey foreign key (catalog_source_id) references public.catalog_sources (id) on delete restrict; alter table public.games add constraint games_description_locale_check check (description_locale in ('de', 'en')), add constraint games_description_locale_needs_description check (description_locale is null or description is not null), add constraint games_merged_into_not_self check (merged_into_id <> id), add constraint games_merged_requires_hidden check (merged_into_id is null or hidden_at is not null), -- Schlüsselliste siehe Spec 3.2. Neue Schlüssel ergänzt die jeweilige Story per Migration. add constraint games_manual_fields_check 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']::text[]), add constraint games_source_ref_check check (char_length(source_ref) between 1 and 100), add constraint games_source_url_check check (char_length(source_url) <= 500 and source_url ~ '^https://'), add constraint games_source_ref_matches_source check ((catalog_source_id is null) = (source_ref is null)), add constraint games_source_details_need_source check (catalog_source_id is not null or (source_url is null and source_synced_at is null)); create unique index games_catalog_source_ref_key on public.games (catalog_source_id, source_ref) where visibility = 'catalog' and catalog_source_id is not null; create index games_catalog_source_id_idx on public.games (catalog_source_id); create index games_merged_into_id_idx on public.games (merged_into_id); -- Grundlage der Suche (KAT-01 baut darauf aus): nur sichtbare Katalogspiele. create index games_catalog_listed_idx on public.games (lower(title)) where visibility = 'catalog' and hidden_at is null and merged_into_id is null; -- --------------------------------------------------------------------------------------------- -- Trigger: manuell gepflegte Felder kennzeichnen (Krit. 1, 4; E4) -- Beide Funktionen: security invoker, search_path leer. EXECUTE wird entzogen; ein Trigger feuert -- unabhängig davon. -- --------------------------------------------------------------------------------------------- create function public.games_mark_manual_fields() returns trigger language plpgsql set search_path = '' as $$ declare cols constant text[] := array['title', 'description', 'description_locale', 'year', 'min_players', 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path']; col text; n jsonb := to_jsonb(new); o jsonb; marked text[] := '{}'; begin if new.visibility <> 'catalog' then return new; end if; -- Ausnahme für den späteren Import: nur als Rolle catalog_importer UND mit Einstellung 'on' (der Import setzt sie per set local; das ist Konvention). -- Ein bloßes set im Studio (Rolle postgres) schaltet die Kennzeichnung nicht ab (W7). if current_user = 'catalog_importer' and coalesce(current_setting('shuffle.catalog_import', true), '') = 'on' then return new; end if; if tg_op = 'UPDATE' then o := to_jsonb(old); end if; foreach col in array cols loop if tg_op = 'INSERT' then if n -> col <> 'null'::jsonb then marked := marked || col; end if; elsif n -> col is distinct from o -> col then marked := marked || col; end if; end loop; -- Es wird nur ergänzt, nie entfernt: Eine bewusste Änderung nur an manual_fields bleibt erhalten. if cardinality(marked) > 0 or tg_op = 'INSERT' then new.manual_fields := array( select distinct x from unnest(new.manual_fields || marked) as x order by 1); end if; return new; end; $$; create trigger games_mark_manual_fields before insert or update on public.games for each row execute function public.games_mark_manual_fields(); -- Zuordnungen (Genre, Mechanik, Kategorie, Verlag) zählen als Pseudo-Felder. Argument: Feldname. create function public.game_links_mark_manual() returns trigger language plpgsql set search_path = '' as $$ declare field constant text := tg_argv[0]; ids uuid[] := '{}'; gid uuid; begin if current_user = 'catalog_importer' and coalesce(current_setting('shuffle.catalog_import', true), '') = 'on' then return null; end if; if tg_op in ('INSERT', 'UPDATE') then ids := ids || new.game_id; end if; if tg_op in ('UPDATE', 'DELETE') then if not (old.game_id = any(ids)) then ids := ids || old.game_id; end if; end if; foreach gid in array ids loop -- Erst prüfen, dann schreiben: Nutzer ändern Zuordnungen privater Spiele und haben kein -- Schreibrecht auf manual_fields; die UPDATE-Anweisung darf für sie nie ausgeführt werden. -- Nur bei fehlendem Schlüssel schreiben, damit updated_at des Spiels stabil bleibt. if exists ( select 1 from public.games g where g.id = gid and g.visibility = 'catalog' and not (field = any(g.manual_fields)) ) then update public.games g set manual_fields = array(select distinct x from unnest(g.manual_fields || field) as x order by 1) where g.id = gid; end if; end loop; return null; end; $$; create trigger game_links_mark_manual after insert or update or delete on public.game_genres for each row execute function public.game_links_mark_manual('genres'); create trigger game_links_mark_manual after insert or update or delete on public.game_mechanics for each row execute function public.game_links_mark_manual('mechanics'); create trigger game_links_mark_manual after insert or update or delete on public.game_categories for each row execute function public.game_links_mark_manual('categories'); create trigger game_links_mark_manual after insert or update or delete on public.game_publishers for each row execute function public.game_links_mark_manual('publishers'); -- merged_into_id darf nur auf Katalogspiele zeigen (H7). Ein Check-Constraint darf keine andere -- Zeile lesen. Ohne den Trigger könnte ein Studio-Handgriff einen Verweis auf ein privates Spiel -- erzeugen, dessen Löschen beim Konto-Löschen an on delete restrict scheitern würde (KTO-05). create function public.games_merged_into_catalog() returns trigger language plpgsql set search_path = '' as $$ declare target_visibility text; begin if new.merged_into_id is not null then select g.visibility into target_visibility from public.games g where g.id = new.merged_into_id; -- Existiert das Ziel nicht, meldet der Fremdschlüssel den Fehler (23503). if target_visibility is not null and target_visibility <> 'catalog' then raise exception 'merged_into_not_catalog' using errcode = 'P0001'; end if; end if; return new; end; $$; create trigger games_merged_into_catalog before insert or update of merged_into_id on public.games for each row execute function public.games_merged_into_catalog(); -- Rechte-Regel: neue Funktionen sind für niemanden aufrufbar (nur Eigentümer). revoke execute on function public.games_mark_manual_fields() from public, anon, authenticated, service_role; revoke execute on function public.game_links_mark_manual() from public, anon, authenticated, service_role; revoke execute on function public.games_merged_into_catalog() from public, anon, authenticated, service_role; -- --------------------------------------------------------------------------------------------- -- Rechte und RLS (Supabase-Standardrechte zuerst entziehen) -- --------------------------------------------------------------------------------------------- alter table public.catalog_sources enable row level security; alter table public.publishers enable row level security; alter table public.game_publishers enable row level security; revoke all on public.catalog_sources, public.publishers, public.game_publishers from anon, authenticated; grant select on public.catalog_sources, public.publishers, public.game_publishers to anon, authenticated; grant insert, update, delete on public.catalog_sources, public.publishers, public.game_publishers to authenticated; create policy catalog_sources_select on public.catalog_sources for select to anon, authenticated using (true); -- Schreiben nur Admin (is_admin); in R1 ungenutzt, Pflege über Studio (E10). create policy catalog_sources_write_admin on public.catalog_sources for all to authenticated using (public.is_admin()) with check (public.is_admin()); create policy publishers_select on public.publishers for select to anon, authenticated using (true); create policy publishers_write_admin on public.publishers for all to authenticated using (public.is_admin()) with check (public.is_admin()); create policy game_publishers_select on public.game_publishers for select to anon, authenticated using (exists (select 1 from public.games g where g.id = game_id)); create policy game_publishers_write on public.game_publishers for all to authenticated using ( public.is_admin() or exists (select 1 from public.games g where g.id = game_id and g.owner_id = (select auth.uid())) ) with check ( public.is_admin() or exists (select 1 from public.games g where g.id = game_id and g.owner_id = (select auth.uid())) ); -- games: Schreibrechte spaltenweise. Nutzer können hidden_at, merged_into_id, manual_fields, -- catalog_source_id und source_* nie setzen, auch nicht an eigenen privaten Spielen (Krit. 9). -- id fehlt bewusst: Clients wählen keine IDs. select und delete bleiben wie bisher. 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; -- --------------------------------------------------------------------------------------------- -- View catalog_games: was Suche und Entdecken sehen (Krit. 4, E3, E6) -- Ausgeblendete und zusammengeführte Spiele fehlen. description/image_path nur, wenn die Quelle -- die Anzeige erlaubt, das Feld manuell gepflegt ist oder es keine Quelle gibt (zweite Sicherung; -- die erste ist, solche Daten gar nicht zu speichern). -- security_invoker: die RLS-Regeln von games und catalog_sources gelten für den Aufrufer. -- --------------------------------------------------------------------------------------------- create view public.catalog_games with (security_invoker = true) as select g.id, g.title, g.year, g.min_players, g.max_players, g.min_minutes, g.max_minutes, g.min_age, g.bgg_id, g.description_locale, g.created_at, g.updated_at, case when g.catalog_source_id is null or 'description' = any(g.manual_fields) or s.texts_allowed then g.description end as description, case when g.catalog_source_id is null or 'image_path' = any(g.manual_fields) or s.images_allowed then g.image_path end as image_path, s.key as source_key, s.name as source_name, s.attribution_text as source_attribution_text, s.attribution_url as source_attribution_url, g.source_url as source_url from public.games g left join public.catalog_sources s on s.id = g.catalog_source_id where g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null; revoke all on public.catalog_games from anon, authenticated; grant select on public.catalog_games to anon, authenticated;