Shuffle/supabase/migrations/20261103193928_catalog_merge.sql
cschlaefke daeae999dd feat(db): add admin_merge_games and repoint_game_references (KAT-05, 2b)
Refs #2

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

280 lines
12 KiB
PL/PgSQL

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