Phase 1d. merge_parties soft-merges live parties into a survivor (merged_into + archived_at), unions alias keys and copies an org number the survivor lacks; facts, identities and role links stay where they are and readers resolve through canonical_party_id(). undo_party_merge restores the merged rows and the survivor snapshot within 30 days and logs a split decision; a second undo and other companies are refused. pg-real tests cover merge, undo, the window, chained merges and every rejection path. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
194 lines
8.4 KiB
PL/PgSQL
194 lines
8.4 KiB
PL/PgSQL
-- Parties, phase 1d: merge with survivor choice, and undo within 30 days.
|
|
--
|
|
-- A merge is soft: the merged party rows stay, with merged_into pointing at
|
|
-- the survivor and archived_at set. Facts, identities and role links keep
|
|
-- their party_id; readers resolve the canonical party through
|
|
-- canonical_party_id(). That is what makes undo a plain restore rather than
|
|
-- a reconstruction, which the July research found is where Attio, Pennylane
|
|
-- and Ramp lose data (merges irreversible, a paid undo market exists).
|
|
--
|
|
-- What the survivor gains at merge time: the union of alias keys (so the
|
|
-- suggestion pipeline attaches future keys to the survivor) and an org
|
|
-- number if it had none. Both are snapshotted in the decision and restored
|
|
-- on undo.
|
|
|
|
CREATE OR REPLACE FUNCTION public.canonical_party_id(p_party_id uuid)
|
|
RETURNS uuid
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY INVOKER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
WITH RECURSIVE chain AS (
|
|
SELECT p.id, p.merged_into, 1 AS depth
|
|
FROM public.parties p WHERE p.id = p_party_id
|
|
UNION ALL
|
|
SELECT p.id, p.merged_into, c.depth + 1
|
|
FROM chain c JOIN public.parties p ON p.id = c.merged_into
|
|
WHERE c.merged_into IS NOT NULL AND c.depth < 16
|
|
)
|
|
SELECT id FROM chain ORDER BY depth DESC LIMIT 1;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.canonical_party_id(uuid) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.canonical_party_id(uuid) TO authenticated, service_role;
|
|
COMMENT ON FUNCTION public.canonical_party_id(uuid) IS
|
|
'Follows merged_into to the surviving party (at most 16 hops). Returns the input id for a live party.';
|
|
|
|
-- ── Merge ───────────────────────────────────────────────────────────────────
|
|
CREATE OR REPLACE FUNCTION public.merge_parties(
|
|
p_company_id uuid,
|
|
p_user_id uuid,
|
|
p_survivor uuid,
|
|
p_merged uuid[],
|
|
p_note text DEFAULT NULL
|
|
)
|
|
RETURNS uuid
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
DECLARE
|
|
v_survivor public.parties%ROWTYPE;
|
|
v_ids uuid[];
|
|
v_before jsonb;
|
|
v_alias text[];
|
|
v_org text;
|
|
v_decision uuid;
|
|
BEGIN
|
|
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
|
|
RAISE EXCEPTION 'merge_parties: p_user_id must be the caller' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
SELECT * INTO v_survivor FROM public.parties
|
|
WHERE id = p_survivor AND company_id = p_company_id AND merged_into IS NULL
|
|
FOR UPDATE;
|
|
IF v_survivor.id IS NULL THEN
|
|
RAISE EXCEPTION 'merge_parties: survivor is not a live party of this company' USING ERRCODE = '23503';
|
|
END IF;
|
|
|
|
-- Only live parties of this company, never the survivor, each once.
|
|
SELECT coalesce(array_agg(p.id ORDER BY p.created_at, p.id), '{}') INTO v_ids
|
|
FROM public.parties p
|
|
WHERE p.company_id = p_company_id AND p.id = ANY(p_merged) AND p.id <> p_survivor AND p.merged_into IS NULL;
|
|
IF coalesce(array_length(v_ids, 1), 0) <> (SELECT count(DISTINCT x) FROM unnest(p_merged) AS x WHERE x <> p_survivor) THEN
|
|
RAISE EXCEPTION 'merge_parties: every merged id must be a live party of this company' USING ERRCODE = '23503';
|
|
END IF;
|
|
IF coalesce(array_length(v_ids, 1), 0) = 0 THEN
|
|
RAISE EXCEPTION 'merge_parties: nothing to merge' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
PERFORM 1 FROM public.parties WHERE id = ANY(v_ids) FOR UPDATE;
|
|
|
|
SELECT ARRAY(SELECT DISTINCT x FROM (
|
|
SELECT unnest(v_survivor.alias_keys) AS x
|
|
UNION ALL
|
|
SELECT unnest(p.alias_keys) FROM public.parties p WHERE p.id = ANY(v_ids)
|
|
) a),
|
|
coalesce(v_survivor.org_number, (
|
|
SELECT p.org_number FROM public.parties p
|
|
WHERE p.id = ANY(v_ids) AND p.org_number IS NOT NULL
|
|
ORDER BY p.created_at, p.id LIMIT 1))
|
|
INTO v_alias, v_org;
|
|
|
|
v_before := jsonb_build_object(
|
|
'survivor', jsonb_build_object('id', v_survivor.id, 'alias_keys', to_jsonb(v_survivor.alias_keys), 'org_number', v_survivor.org_number),
|
|
'merged', (SELECT jsonb_agg(jsonb_build_object('id', p.id, 'display_name', p.display_name, 'status', p.status, 'archived_at', p.archived_at) ORDER BY p.created_at, p.id)
|
|
FROM public.parties p WHERE p.id = ANY(v_ids))
|
|
);
|
|
|
|
UPDATE public.parties
|
|
SET merged_into = p_survivor, archived_at = coalesce(archived_at, now())
|
|
WHERE id = ANY(v_ids);
|
|
|
|
UPDATE public.parties
|
|
SET alias_keys = v_alias, org_number = v_org
|
|
WHERE id = p_survivor;
|
|
|
|
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
|
|
VALUES (p_survivor, p_company_id, p_user_id, 'merge', v_before,
|
|
jsonb_build_object('survivor', jsonb_build_object('id', p_survivor, 'alias_keys', to_jsonb(v_alias), 'org_number', v_org), 'merged', to_jsonb(v_ids)),
|
|
p_note)
|
|
RETURNING id INTO v_decision;
|
|
|
|
RETURN v_decision;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.merge_parties(uuid, uuid, uuid, uuid[], text) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.merge_parties(uuid, uuid, uuid, uuid[], text) TO authenticated, service_role;
|
|
COMMENT ON FUNCTION public.merge_parties(uuid, uuid, uuid, uuid[], text) IS
|
|
'Soft-merges live parties into a survivor (merged_into + archived_at), unions alias keys, copies an org number the survivor lacks. Returns the merge decision id for undo_party_merge.';
|
|
|
|
-- ── Undo ────────────────────────────────────────────────────────────────────
|
|
CREATE OR REPLACE FUNCTION public.undo_party_merge(
|
|
p_company_id uuid,
|
|
p_user_id uuid,
|
|
p_decision_id uuid
|
|
)
|
|
RETURNS integer
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
DECLARE
|
|
v_d public.party_decisions%ROWTYPE;
|
|
v_ids uuid[];
|
|
v_restored integer;
|
|
BEGIN
|
|
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
|
|
RAISE EXCEPTION 'undo_party_merge: p_user_id must be the caller' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
SELECT * INTO v_d FROM public.party_decisions
|
|
WHERE id = p_decision_id AND company_id = p_company_id AND kind = 'merge'
|
|
FOR UPDATE;
|
|
IF v_d.id IS NULL THEN
|
|
RAISE EXCEPTION 'undo_party_merge: no merge decision % in this company', p_decision_id USING ERRCODE = '23503';
|
|
END IF;
|
|
IF v_d.created_at < now() - interval '30 days' THEN
|
|
RAISE EXCEPTION 'undo_party_merge: the 30-day undo window has passed' USING ERRCODE = '22023';
|
|
END IF;
|
|
IF EXISTS (SELECT 1 FROM public.party_decisions u
|
|
WHERE u.company_id = p_company_id AND u.kind = 'split' AND u.before->>'decision_id' = p_decision_id::text) THEN
|
|
RAISE EXCEPTION 'undo_party_merge: merge % is already undone', p_decision_id USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
v_ids := ARRAY(SELECT (x)::uuid FROM jsonb_array_elements_text(v_d.after->'merged') AS x);
|
|
|
|
-- Survivor first: it may carry an org number copied from a merged row, and
|
|
-- that row cannot become live again while the survivor still holds it.
|
|
UPDATE public.parties
|
|
SET alias_keys = ARRAY(SELECT jsonb_array_elements_text(v_d.before->'survivor'->'alias_keys')),
|
|
org_number = nullif(v_d.before->'survivor'->>'org_number', '')
|
|
WHERE id = v_d.party_id AND company_id = p_company_id;
|
|
|
|
-- Only rows still merged into this survivor come back; a party merged
|
|
-- onward since then belongs to a later decision.
|
|
WITH restored AS (
|
|
UPDATE public.parties p
|
|
SET merged_into = NULL,
|
|
archived_at = (SELECT nullif(m->>'archived_at', '')::timestamptz
|
|
FROM jsonb_array_elements(v_d.before->'merged') m WHERE (m->>'id')::uuid = p.id)
|
|
WHERE p.id = ANY(v_ids) AND p.company_id = p_company_id AND p.merged_into = v_d.party_id
|
|
RETURNING p.id
|
|
)
|
|
SELECT count(*) INTO v_restored FROM restored;
|
|
|
|
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
|
|
VALUES (v_d.party_id, p_company_id, p_user_id, 'split',
|
|
jsonb_build_object('decision_id', p_decision_id, 'merged', to_jsonb(v_ids)),
|
|
jsonb_build_object('restored', v_restored),
|
|
'undo merge');
|
|
|
|
RETURN v_restored;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.undo_party_merge(uuid, uuid, uuid) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.undo_party_merge(uuid, uuid, uuid) TO authenticated, service_role;
|
|
COMMENT ON FUNCTION public.undo_party_merge(uuid, uuid, uuid) IS
|
|
'Restores the parties merged by one merge decision within 30 days: clears merged_into, restores the survivor''s alias keys and org number, logs a split decision. Returns the number of restored parties.';
|
|
|
|
NOTIFY pgrst, 'reload schema';
|