Shuffle/supabase/migrations/20261103142204_catalog_foundation.sql
cschlaefke 9952efa2fe
All checks were successful
CI / flutter (pull_request) Successful in 1m28s
CI / web-build (pull_request) Successful in 59s
CI / supabase (pull_request) Successful in 9s
CI / supabase-tests (pull_request) Successful in 34s
CI / e2e (pull_request) Successful in 3m2s
test(catalog): address review findings on PR 2a
Replace tests that could not fail (manual_fields stability, no-op update), select runbook games by id, add grant matrix and forged-admin tests, assert vocabulary seeding and Deno checksum in CI, clarify the set local convention and API notes.

Refs #2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 18:18:32 +01:00

336 lines
16 KiB
PL/PgSQL

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