All checks were successful
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>
336 lines
16 KiB
PL/PgSQL
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;
|