Shuffle/supabase/migrations/20261103195151_game_reports.sql
cschlaefke 147dd1870e feat(db): game reports table, admin_resolve_report and visibility trigger (KAT-05 2c) Refs #2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 21:05:02 +01:00

280 lines
14 KiB
PL/PgSQL

-- KAT-05 / PR 2c: Meldungen (Spec docs/specs/2-katalog-daten.md, Abschnitt 3.4).
-- Nur additiv. Enthält:
-- games_visibility_immutable Trigger: Sichtbarkeit und Eigentümer eines Spiels sind unveränderlich (E14)
-- game_reports Tabelle mit RLS (Nutzer lesen nur ihre eigenen Meldungen)
-- admin_resolve_report Admin-Funktion: Meldung freigeben, zusammenführen oder ablehnen
-- admin_merge_games per create or replace erweitert: hängt auch game_reports.target_game_id um
-- Anlegen von Meldungen durch Nutzer (Insert-Recht, Insert-Policy, Tageslimit) liefert #13 (KAT-04).
-- ---------------------------------------------------------------------------------------------
-- E14: visibility und owner_id einer bestehenden games-Zeile ändern sich nie, für keine Rolle
-- (auch nicht für postgres). Ein privater Eintrag wird nie öffentlich geschaltet; freigegebene
-- Meldungen bekommen immer einen neuen Katalogeintrag. Das Konto-Löschen ist nicht betroffen
-- (private Spiele werden per on delete cascade gelöscht, nicht geändert).
-- Hinweis: Ein BEFORE-Trigger läuft vor der RLS-Prüfung (with check). Ein Nutzer, der visibility
-- ändert, bekommt deshalb P0001 visibility_immutable statt 42501.
-- ---------------------------------------------------------------------------------------------
create function public.games_visibility_immutable()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
if new.visibility is distinct from old.visibility or new.owner_id is distinct from old.owner_id then
raise exception 'visibility_immutable' using errcode = 'P0001';
end if;
return new;
end;
$$;
create trigger games_visibility_immutable before update on public.games
for each row execute function public.games_visibility_immutable();
-- ---------------------------------------------------------------------------------------------
-- Tabelle game_reports (Klärungen 6, 10, 26, 27, N12, N12a; E12)
-- ---------------------------------------------------------------------------------------------
create table public.game_reports (
id uuid primary key default gen_random_uuid(),
-- meldender Nutzer; Klärung 10: beim Konto-Löschen hart gelöscht
owner_id uuid not null references public.profiles (id) on delete cascade,
origin text not null default 'user' check (origin in ('user', 'import')),
title text not null check (char_length(title) between 1 and 200),
publisher_name text check (char_length(publisher_name) <= 200),
year smallint check (year between 1800 and 2100),
link text check (char_length(link) <= 500 and link ~ '^https?://'),
bgg_id integer,
-- vorläufiger privater Eintrag des Meldenden (KAT-04 Krit. 3); verschwindet er, bleibt die Meldung
provisional_game_id uuid references public.games (id) on delete set null,
status text not null default 'open' check (status in ('open', 'approved', 'rejected', 'merged')),
-- Zieleintrag: wie merged_into_id on delete restrict (Katalogspiele werden ausgeblendet, nicht gelöscht)
target_game_id uuid references public.games (id) on delete restrict,
resolved_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint game_reports_resolved_matches_status check ((status = 'open') = (resolved_at is null)),
constraint game_reports_open_has_no_target check (status <> 'open' or target_game_id is null),
constraint game_reports_target_required check (status not in ('approved', 'merged') or target_game_id is not null)
);
create trigger set_updated_at before update on public.game_reports
for each row execute function public.set_updated_at();
-- Tageslimit (#13): (owner_id, created_at)
create index game_reports_owner_created_idx on public.game_reports (owner_id, created_at);
-- N12a: je Nutzer höchstens eine offene Import-Meldung je BGG-Kennung
create unique index game_reports_import_open_key on public.game_reports (owner_id, bgg_id)
where origin = 'import' and status = 'open' and bgg_id is not null;
-- Admin-Liste: offene Meldungen, älteste zuerst
create index game_reports_open_idx on public.game_reports (created_at) where status = 'open';
-- Fremdschlüssel (Ergänzung zur Spec: Index auf jeden Fremdschlüssel). Ein vorläufiger Eintrag
-- gehört zu höchstens einer Meldung (1:1), deshalb eindeutig; mehrere Meldungen können dasselbe
-- Ziel haben, daher dort ein einfacher Index.
create unique index game_reports_provisional_game_id_key on public.game_reports (provisional_game_id)
where provisional_game_id is not null;
create index game_reports_target_game_id_idx on public.game_reports (target_game_id);
-- RLS: Der Meldende liest seine eigenen Meldungen (Ergebnishinweis, KAT-04 Krit. 4-6). Die
-- Admin-Policy (is_admin) ist für R4 vorbereitet, in R1 wird die Rolle niemandem vergeben (E10).
-- Schreibrechte gibt es für anon und authenticated nicht; #13 ergänzt Insert-Recht und -Policy.
alter table public.game_reports enable row level security;
revoke all on public.game_reports from anon, authenticated;
grant select on public.game_reports to authenticated;
create policy game_reports_select_own on public.game_reports
for select to authenticated
using (owner_id = (select auth.uid()));
create policy game_reports_select_admin on public.game_reports
for select to authenticated
using (public.is_admin());
-- ---------------------------------------------------------------------------------------------
-- Admin-Funktion: Meldung bearbeiten (Krit. 5). security invoker, nur postgres/supabase_admin.
-- ---------------------------------------------------------------------------------------------
create function public.admin_resolve_report(p_report uuid, p_status text, p_target uuid default null)
returns jsonb
language plpgsql
security invoker
set search_path = ''
as $$
declare
rep public.game_reports;
prov public.games;
result jsonb := '{}'::jsonb;
repointed boolean := false;
begin
if current_user not in ('postgres', 'supabase_admin') then
raise exception 'admin_only' using errcode = 'P0001';
end if;
select * into rep from public.game_reports r where r.id = p_report for update;
if not found then
raise exception 'report_not_found' using errcode = 'P0001';
end if;
if rep.status <> 'open' then
raise exception 'report_not_open' using errcode = 'P0001';
end if;
if p_status is null or p_status not in ('approved', 'merged', 'rejected') then
raise exception 'report_status_invalid' using errcode = 'P0001';
end if;
if p_status in ('approved', 'merged') and p_target is null then
raise exception 'report_target_required' using errcode = 'P0001';
end if;
-- Beteiligte Spiele in fester Reihenfolge sperren (wie admin_merge_games).
perform 1 from public.games g
where g.id in (p_target, rep.provisional_game_id) order by g.id for update;
if p_target is not null and not exists (
select 1 from public.games g
where g.id = p_target and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null
) then
raise exception 'report_target_not_listed' using errcode = 'P0001';
end if;
if p_status in ('approved', 'merged') and rep.provisional_game_id is not null then
select * into prov from public.games g where g.id = rep.provisional_game_id;
-- Zusatzprüfung (nicht in der Spec): Nur ein privater Eintrag darf als vorläufiger Eintrag
-- umgehängt werden; ein Katalogspiel würde sonst über den Variantenfall "Katalog" kopiert.
if prov.visibility <> 'private' then
raise exception 'report_provisional_not_private' using errcode = 'P0001';
end if;
-- privat -> Katalog: nichts wird kopiert (RB1, Klärung N15)
result := public.repoint_game_references(prov.id, p_target);
repointed := true;
-- merged_into_id verlangt hidden_at (Check), deshalb in einem Statement.
update public.games g
set merged_into_id = p_target, hidden_at = coalesce(g.hidden_at, now())
where g.id = prov.id;
end if;
-- Bei "rejected" mit Ziel wird nur der Verweis gespeichert (Spec-F3); der vorläufige Eintrag
-- bleibt unverändert, ob der Nutzer wechselt, entscheidet er in der App.
update public.game_reports r
set status = p_status, target_game_id = p_target, resolved_at = now()
where r.id = rep.id;
return result || jsonb_build_object(
'report_id', rep.id,
'status', p_status,
'target_id', p_target,
'provisional_game_id', rep.provisional_game_id,
'repointed', repointed
);
end;
$$;
-- ---------------------------------------------------------------------------------------------
-- admin_merge_games: Rumpf aus PR 2b unverändert, nur Schritt 4 neu (game_reports.target_game_id
-- von der Quelle auf das Ziel umhängen) und der Zähler reports_repointed im Ergebnis.
-- ---------------------------------------------------------------------------------------------
create or replace function public.admin_merge_games(p_source uuid, p_target uuid)
returns jsonb
language plpgsql
security invoker
set search_path = ''
as $$
declare
s public.games;
t public.games;
result jsonb;
n_chain integer;
n_reports integer;
bgg_transferred boolean := false;
source_transferred boolean := false;
begin
if current_user not in ('postgres', 'supabase_admin') then
raise exception 'admin_only' using errcode = 'P0001';
end if;
if p_source = p_target then
raise exception 'merge_same_game' using errcode = 'P0001';
end if;
if p_source is null or p_target is null then
raise exception 'merge_game_not_found' using errcode = 'P0001';
end if;
-- Beide Zeilen in fester Reihenfolge sperren, damit zwei gleichzeitige Aufrufe nicht verklemmen.
perform 1 from public.games g where g.id in (p_source, p_target) order by g.id for update;
select * into s from public.games g where g.id = p_source;
if not found then
raise exception 'merge_game_not_found' using errcode = 'P0001';
end if;
select * into t from public.games g where g.id = p_target;
if not found then
raise exception 'merge_game_not_found' using errcode = 'P0001';
end if;
if s.visibility <> 'catalog' or t.visibility <> 'catalog' then
raise exception 'merge_not_catalog' using errcode = 'P0001';
end if;
if t.hidden_at is not null or t.merged_into_id is not null then
raise exception 'merge_target_not_listed' using errcode = 'P0001';
end if;
if s.merged_into_id is not null then
raise exception 'merge_source_already_merged' using errcode = 'P0001';
end if;
-- 1. Verweise umhängen (beide Katalog -> Varianten werden kopiert)
result := public.repoint_game_references(p_source, p_target);
-- 2. BGG-Kennung und Quelle übernehmen (W6). Wegen der eindeutigen Indizes (bgg_id im Katalog,
-- (Quelle, source_ref) im Katalog) zuerst an der Quelle leeren, dann am Ziel setzen.
-- Gleichzeitig wird die Quelle als zusammengeführt und ausgeblendet markiert (ein Statement:
-- merged_into_id verlangt hidden_at). Bereits ausgeblendete Quellen behalten ihr hidden_at.
update public.games g
set bgg_id = null,
catalog_source_id = null, source_ref = null, source_url = null, source_synced_at = null,
merged_into_id = p_target,
hidden_at = coalesce(g.hidden_at, now())
where g.id = p_source;
if s.bgg_id is not null then
if t.bgg_id is null then
update public.games g set bgg_id = s.bgg_id where g.id = p_target;
bgg_transferred := true;
elsif t.bgg_id <> s.bgg_id then
-- Das Ziel behält seinen Wert; der geleerte Wert der Quelle bleibt hier erhalten.
result := result || jsonb_build_object('bgg_id_conflict',
jsonb_build_object('source', s.bgg_id, 'target', t.bgg_id));
end if;
end if;
if s.catalog_source_id is not null then
if t.catalog_source_id is null then
update public.games g
set catalog_source_id = s.catalog_source_id, source_ref = s.source_ref,
source_url = s.source_url, source_synced_at = s.source_synced_at
where g.id = p_target;
source_transferred := true;
elsif (t.catalog_source_id, t.source_ref) is distinct from (s.catalog_source_id, s.source_ref) then
result := result || jsonb_build_object('source_conflict', jsonb_build_object(
'source', jsonb_build_object('catalog_source_id', s.catalog_source_id, 'source_ref', s.source_ref,
'source_url', s.source_url, 'source_synced_at', s.source_synced_at),
'target', jsonb_build_object('catalog_source_id', t.catalog_source_id, 'source_ref', t.source_ref,
'source_url', t.source_url, 'source_synced_at', t.source_synced_at)));
end if;
end if;
-- 3. Ketten glätten: Spiele, die bisher auf die Quelle zeigten, zeigen jetzt auf das Ziel.
update public.games g set merged_into_id = p_target where g.merged_into_id = p_source;
get diagnostics n_chain = row_count;
-- 4. (neu in 2c) Meldungen, die auf die Quelle als Zieleintrag zeigen, zeigen jetzt auf das Ziel.
update public.game_reports r set target_game_id = p_target where r.target_game_id = p_source;
get diagnostics n_reports = row_count;
return result || jsonb_build_object(
'source_id', p_source,
'target_id', p_target,
'bgg_id_transferred', bgg_transferred,
'source_transferred', source_transferred,
'chain_flattened', n_chain,
'reports_repointed', n_reports
);
end;
$$;
-- Rechte-Regel: nur der Eigentümer darf ausführen. Nach dem create or replace wird der revoke
-- für admin_merge_games ausdrücklich wiederholt (create or replace behält zwar die bisherigen
-- Rechte, aber das soll nicht still vorausgesetzt werden).
revoke execute on function public.games_visibility_immutable() from public, anon, authenticated, service_role;
revoke execute on function public.admin_resolve_report(uuid, text, uuid) from public, anon, authenticated, service_role;
revoke execute on function public.admin_merge_games(uuid, uuid) from public, anon, authenticated, service_role;