-- 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, -- Spec-F6 (Option A): Anzahl der Verweise (Sammlungseinträge, Partien), deren Edition beim Umhängen -- geleert wurde. Setzt nur admin_resolve_report (aus variants_cleared); die App (#13) liest es, -- um dem Nutzer zu sagen, dass er die Edition neu wählen muss. editions_cleared integer not null default 0 check (editions_cleared >= 0), 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 des Meldenden darf umgehängt -- werden. Ein Katalogspiel würde sonst über den Variantenfall "Katalog" kopiert, und ein -- fremdes privates Spiel (Datenfehler im Studio) würde Daten eines anderen Nutzers umhängen. -- "not found" deckt eine fehlende Zeile ab (null <> 'private' wäre sonst nicht wahr). if not found or prov.visibility <> 'private' or prov.owner_id is distinct from rep.owner_id 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(), editions_cleared = coalesce((result ->> 'variants_cleared')::integer, 0) 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;