-- KAT-05 / PR 2b: Zusammenführen (Spec docs/specs/2-katalog-daten.md, Abschnitt 3.3). -- Nur additiv: zwei neue Funktionen, keine neuen Tabellen. -- repoint_game_references(p_from, p_to) interne Funktion: hängt alle Verweise um -- admin_merge_games(p_source, p_target) Admin-Funktion: zwei Katalogspiele zusammenführen -- Beide sind security invoker mit leerem search_path (alles schema-qualifiziert) und nur für den -- Eigentümer (postgres) ausführbar. Es gibt in R1 keinen API-Aufrufer (E10). -- admin_resolve_report (privater Eintrag -> Katalogspiel) und der Verweis game_reports.target_game_id -- kommen mit PR 2c; repoint_game_references ist dafür schon vorbereitet. -- --------------------------------------------------------------------------------------------- -- Interne Funktion: alle Verweise von p_from auf p_to umhängen (in der Transaktion des Aufrufers) -- --------------------------------------------------------------------------------------------- create function public.repoint_game_references(p_from uuid, p_to uuid) returns jsonb language plpgsql security invoker set search_path = '' as $$ declare from_visibility text; -- Zuordnung "alte Variante -> neue Variante" (Schlüssel: alte ID als Text; JSON null = geleert). -- Bewusst keine temporäre Tabelle: mehrere Aufrufe in einer Transaktion bleiben möglich. vmap jsonb := '{}'::jsonb; existing_variants uuid[]; v record; match_id uuid; new_id uuid; r record; n_copied integer := 0; n_mapped integer := 0; n_cleared integer := 0; n_cleared_part integer; n_ci integer := 0; n_ci_conflicts integer := 0; n_fav integer := 0; n_fav_conflicts integer := 0; n_plays integer; n_sheets integer; new_variant uuid; new_list text; list_rank constant jsonb := '{"owned": 1, "wishlist": 2, "shopping": 3}'::jsonb; begin if p_from = p_to then raise exception 'repoint_same_game' using errcode = 'P0001'; end if; if not exists ( select 1 from public.games g where g.id = p_to and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null ) then raise exception 'repoint_target_not_listed' using errcode = 'P0001'; end if; select g.visibility into from_visibility from public.games g where g.id = p_from; -- Varianten: Ob kopiert wird, folgt aus der Sichtbarkeit der Quelle (Spec 3.3, RB1). -- Katalogspiel -> gleichnamige Zielvariante, sonst Kopie am Ziel -- privater Eintrag -> gleichnamige Zielvariante, sonst keine (null); nichts wird kopiert -- Namensabgleich: lower(trim(name)). Mehrere gleichnamige Zielvarianten: immer die älteste -- (created_at), bei Gleichstand die kleinste id. Abgeglichen wird nur gegen die Varianten, die -- das Ziel vor diesem Aufruf schon hatte (nicht gegen eben kopierte). existing_variants := array(select gv.id from public.game_variants gv where gv.game_id = p_to); for v in select gv.id, gv.name, gv.year, gv.language, gv.bgg_id from public.game_variants gv where gv.game_id = p_from order by gv.created_at, gv.id loop select gv.id into match_id from public.game_variants gv where gv.id = any(existing_variants) and lower(btrim(gv.name)) = lower(btrim(v.name)) order by gv.created_at, gv.id limit 1; if match_id is not null then vmap := vmap || jsonb_build_object(v.id::text, match_id); n_mapped := n_mapped + 1; elsif from_visibility = 'catalog' then new_id := gen_random_uuid(); insert into public.game_variants (id, game_id, name, year, language, bgg_id) values (new_id, p_to, v.name, v.year, v.language, v.bgg_id); vmap := vmap || jsonb_build_object(v.id::text, new_id); n_copied := n_copied + 1; else vmap := vmap || jsonb_build_object(v.id::text, null); end if; end loop; -- 1. collection_items. Kollision (derselbe Nutzer hat p_to schon, Spec-F1): Der Zieleintrag -- bleibt, leere Felder füllt der Quell-Eintrag, die Liste gewinnt in der Reihenfolge -- owned > wishlist > shopping (Klärung 34); die Variante läuft immer über vmap. Danach wird der -- Quell-Eintrag gelöscht. Felder aus SAM-02/SAM-04 gibt es noch nicht; sie kommen dort dazu. for r in select s.id as sid, s.list as slist, s.note as snote, s.acquired_on as sacq, s.variant_id as svar, t.id as tid, t.list as tlist, t.note as tnote, t.acquired_on as tacq, t.variant_id as tvar from public.collection_items s join public.collection_items t on t.owner_id = s.owner_id and t.game_id = p_to where s.game_id = p_from order by s.id loop new_variant := coalesce(r.tvar, (vmap ->> r.svar::text)::uuid); if r.tvar is null and r.svar is not null and new_variant is null then n_cleared := n_cleared + 1; end if; new_list := case when (list_rank ->> r.slist)::int < (list_rank ->> r.tlist)::int then r.slist else r.tlist end; update public.collection_items c set list = new_list, note = case when c.note is null or c.note = '' then r.snote else c.note end, acquired_on = coalesce(c.acquired_on, r.sacq), variant_id = new_variant where c.id = r.tid; delete from public.collection_items c where c.id = r.sid; n_ci_conflicts := n_ci_conflicts + 1; end loop; select count(*) into n_cleared_part from public.collection_items c where c.game_id = p_from and c.variant_id is not null and (vmap ->> c.variant_id::text) is null; n_cleared := n_cleared + n_cleared_part; update public.collection_items c set game_id = p_to, variant_id = (vmap ->> c.variant_id::text)::uuid where c.game_id = p_from; get diagnostics n_ci = row_count; -- 2. favorite_games: Hat der Nutzer p_to schon als Favorit, entfällt die Zeile zu p_from -- (die Lücke in position ist zulässig). delete from public.favorite_games f where f.game_id = p_from and exists (select 1 from public.favorite_games t where t.profile_id = f.profile_id and t.game_id = p_to); get diagnostics n_fav_conflicts = row_count; update public.favorite_games f set game_id = p_to where f.game_id = p_from; get diagnostics n_fav = row_count; -- 3. plays (mit Variante über vmap) select count(*) into n_cleared_part from public.plays p where p.game_id = p_from and p.variant_id is not null and (vmap ->> p.variant_id::text) is null; n_cleared := n_cleared + n_cleared_part; update public.plays p set game_id = p_to, variant_id = (vmap ->> p.variant_id::text)::uuid where p.game_id = p_from; get diagnostics n_plays = row_count; -- 4. score_sheets. Sobald PAR-04 (#18) "ein Bogen je Spiel und Nutzer" erzwingt, braucht -- dieser Schritt eine Kollisionsregel (Spec 9.5). update public.score_sheets s set game_id = p_to where s.game_id = p_from; get diagnostics n_sheets = row_count; -- 5. Nur Katalog -> Katalog: die alten Varianten sind jetzt unreferenziert (kopiert oder -- abgebildet) und werden gelöscht. Private Varianten bleiben am ausgeblendeten vorläufigen -- Eintrag, sie gehören dem Nutzer. if from_visibility = 'catalog' then delete from public.game_variants gv where gv.game_id = p_from; end if; -- 6. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. return jsonb_build_object( 'collection_items', n_ci, 'collection_conflicts', n_ci_conflicts, 'favorite_games', n_fav, 'favorite_conflicts', n_fav_conflicts, 'plays', n_plays, 'score_sheets', n_sheets, 'variants_copied', n_copied, 'variants_mapped', n_mapped, 'variants_cleared', n_cleared ); end; $$; -- --------------------------------------------------------------------------------------------- -- Admin-Funktion: zwei Katalogspiele zusammenführen -- security invoker (kein security definer): Die Rollenprüfung ist die einzige Schranke, und der -- Aufrufer braucht ohnehin die Rechte von postgres/supabase_admin. Kein is_admin()-Pfad (E10). -- --------------------------------------------------------------------------------------------- create 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; 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. Ab 2c zusätzlich: game_reports.target_game_id von p_source auf p_target. 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 ); end; $$; -- Rechte-Regel: nur der Eigentümer darf ausführen (Supabase vergibt sonst an anon, authenticated, -- service_role). Der Import und spätere Stories ergänzen gezielt Rechte, falls nötig. revoke execute on function public.repoint_game_references(uuid, uuid) from public, anon, authenticated, service_role; revoke execute on function public.admin_merge_games(uuid, uuid) from public, anon, authenticated, service_role;