Files
myeasycms-v2/apps/web/supabase/migrations/20260416000010_member_merge.sql
T. Zehetbauer 5c5aaabae5
Some checks failed
Workflow / ʦ TypeScript (pull_request) Failing after 5m57s
Workflow / ⚫️ Test (pull_request) Has been skipped
refactor: remove obsolete member management API module
2026-04-03 14:08:31 +02:00

275 lines
11 KiB
PL/PgSQL

-- =====================================================
-- Member Merge / Deduplication
--
-- Atomic function to merge two member records:
-- picks field values, moves all references, archives secondary.
-- =====================================================
-- Merge log table for audit trail and potential undo
CREATE TABLE IF NOT EXISTS public.member_merges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
primary_member_id uuid NOT NULL,
secondary_member_id uuid NOT NULL,
secondary_snapshot jsonb NOT NULL,
field_choices jsonb NOT NULL,
references_moved jsonb NOT NULL,
performed_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
performed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_member_merges_account ON public.member_merges(account_id);
ALTER TABLE public.member_merges ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.member_merges FROM authenticated, service_role;
GRANT SELECT ON public.member_merges TO authenticated;
GRANT ALL ON public.member_merges TO service_role;
CREATE POLICY member_merges_select
ON public.member_merges FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
-- Atomic merge function
CREATE OR REPLACE FUNCTION public.merge_members(
p_primary_id uuid,
p_secondary_id uuid,
p_field_choices jsonb DEFAULT '{}',
p_performed_by uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_primary record;
v_secondary record;
v_account_id uuid;
v_user_id uuid;
v_refs_moved jsonb := '{}'::jsonb;
v_count int;
v_field text;
v_choice text;
v_update jsonb := '{}'::jsonb;
BEGIN
v_user_id := COALESCE(p_performed_by, auth.uid());
-- 1. Fetch both members
SELECT * INTO v_primary FROM public.members WHERE id = p_primary_id;
SELECT * INTO v_secondary FROM public.members WHERE id = p_secondary_id;
IF v_primary IS NULL THEN RAISE EXCEPTION 'Primary member not found'; END IF;
IF v_secondary IS NULL THEN RAISE EXCEPTION 'Secondary member not found'; END IF;
IF v_primary.account_id != v_secondary.account_id THEN
RAISE EXCEPTION 'Members must belong to the same account';
END IF;
v_account_id := v_primary.account_id;
-- Verify caller access
IF NOT public.has_permission(auth.uid(), v_account_id, 'members.write'::public.app_permissions) THEN
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
END IF;
-- 2. Apply field choices: for each conflicting field, pick primary or secondary value
FOR v_field, v_choice IN SELECT * FROM jsonb_each_text(p_field_choices)
LOOP
-- Validate choice value
IF v_choice NOT IN ('primary', 'secondary') THEN
RAISE EXCEPTION 'Invalid choice "%" for field "%". Must be "primary" or "secondary"', v_choice, v_field;
END IF;
-- Whitelist of mergeable fields (no IDs, FKs, or system columns)
IF v_field NOT IN (
'first_name', 'last_name', 'email', 'phone', 'mobile', 'phone2', 'fax',
'street', 'house_number', 'street2', 'postal_code', 'city', 'country',
'date_of_birth', 'gender', 'title', 'salutation', 'birthplace', 'birth_country',
'notes', 'guardian_name', 'guardian_phone', 'guardian_email'
) THEN
RAISE EXCEPTION 'Field "%" cannot be merged', v_field;
END IF;
IF v_choice = 'secondary' THEN
v_update := v_update || jsonb_build_object(v_field, to_jsonb(v_secondary) -> v_field);
END IF;
END LOOP;
-- Apply chosen fields to primary
IF v_update != '{}'::jsonb THEN
-- Build dynamic UPDATE
EXECUTE format(
'UPDATE public.members SET %s WHERE id = $1',
(SELECT string_agg(format('%I = %L', key, value #>> '{}'), ', ')
FROM jsonb_each(v_update))
) USING p_primary_id;
END IF;
-- 3. Move references from secondary to primary
-- Department assignments
SELECT count(*) INTO v_count FROM public.member_department_assignments WHERE member_id = p_secondary_id;
INSERT INTO public.member_department_assignments (member_id, department_id)
SELECT p_primary_id, department_id
FROM public.member_department_assignments
WHERE member_id = p_secondary_id
ON CONFLICT (member_id, department_id) DO NOTHING;
DELETE FROM public.member_department_assignments WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('departments', v_count);
-- Roles
SELECT count(*) INTO v_count FROM public.member_roles WHERE member_id = p_secondary_id;
UPDATE public.member_roles SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('roles', v_count);
-- Honors
SELECT count(*) INTO v_count FROM public.member_honors WHERE member_id = p_secondary_id;
UPDATE public.member_honors SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('honors', v_count);
-- SEPA mandates
SELECT count(*) INTO v_count FROM public.sepa_mandates WHERE member_id = p_secondary_id;
UPDATE public.sepa_mandates SET member_id = p_primary_id, is_primary = false WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('mandates', v_count);
-- Member cards
SELECT count(*) INTO v_count FROM public.member_cards WHERE member_id = p_secondary_id;
UPDATE public.member_cards SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('cards', v_count);
-- Portal invitations
SELECT count(*) INTO v_count FROM public.member_portal_invitations WHERE member_id = p_secondary_id;
UPDATE public.member_portal_invitations SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('invitations', v_count);
-- Tag assignments
BEGIN
SELECT count(*) INTO v_count FROM public.member_tag_assignments WHERE member_id = p_secondary_id;
INSERT INTO public.member_tag_assignments (member_id, tag_id, assigned_by)
SELECT p_primary_id, tag_id, assigned_by
FROM public.member_tag_assignments
WHERE member_id = p_secondary_id
ON CONFLICT (member_id, tag_id) DO NOTHING;
DELETE FROM public.member_tag_assignments WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('tags', v_count);
EXCEPTION WHEN undefined_table THEN NULL; -- tags table may not exist yet
END;
-- Event registrations (if member_id column exists)
BEGIN
SELECT count(*) INTO v_count FROM public.event_registrations WHERE member_id = p_secondary_id;
UPDATE public.event_registrations SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('events', v_count);
EXCEPTION WHEN undefined_column THEN NULL;
END;
-- Communications
BEGIN
SELECT count(*) INTO v_count FROM public.member_communications WHERE member_id = p_secondary_id;
UPDATE public.member_communications SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('communications', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Course participants
BEGIN
SELECT count(*) INTO v_count FROM public.course_participants WHERE member_id = p_secondary_id;
UPDATE public.course_participants SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('courses', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Catch books (Fischerei)
BEGIN
SELECT count(*) INTO v_count FROM public.catch_books WHERE member_id = p_secondary_id;
UPDATE public.catch_books SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('catch_books', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Catches
BEGIN
SELECT count(*) INTO v_count FROM public.catches WHERE member_id = p_secondary_id;
UPDATE public.catches SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('catches', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Water leases
BEGIN
SELECT count(*) INTO v_count FROM public.water_leases WHERE member_id = p_secondary_id;
UPDATE public.water_leases SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('water_leases', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Competition participants
BEGIN
SELECT count(*) INTO v_count FROM public.competition_participants WHERE member_id = p_secondary_id;
UPDATE public.competition_participants SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('competitions', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Invoices
BEGIN
SELECT count(*) INTO v_count FROM public.invoices WHERE member_id = p_secondary_id;
UPDATE public.invoices SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('invoices', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Audit log entries
UPDATE public.member_audit_log SET member_id = p_primary_id WHERE member_id = p_secondary_id;
-- 4. Merge custom_data (union of keys, primary wins on conflicts)
UPDATE public.members
SET custom_data = v_secondary.custom_data || v_primary.custom_data
WHERE id = p_primary_id;
-- 5. Append merge note
UPDATE public.members
SET notes = COALESCE(notes, '') ||
E'\n[Zusammenführung ' || to_char(now(), 'YYYY-MM-DD') || '] ' ||
'Zusammengeführt mit ' || v_secondary.first_name || ' ' || v_secondary.last_name ||
COALESCE(' (Nr. ' || v_secondary.member_number || ')', '')
WHERE id = p_primary_id;
-- 6. Archive the secondary member
UPDATE public.members
SET status = 'resigned', is_archived = true,
exit_date = current_date, exit_reason = 'Zusammenführung mit Mitglied ' || p_primary_id::text,
notes = COALESCE(notes, '') || E'\n[Zusammenführung] Archiviert zugunsten von ' || v_primary.first_name || ' ' || v_primary.last_name
WHERE id = p_secondary_id;
-- 7. Create merge log entry
INSERT INTO public.member_merges (
account_id, primary_member_id, secondary_member_id,
secondary_snapshot, field_choices, references_moved, performed_by
) VALUES (
v_account_id, p_primary_id, p_secondary_id,
to_jsonb(v_secondary), p_field_choices, v_refs_moved, v_user_id
);
-- 8. Audit log
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
VALUES (p_primary_id, v_account_id, v_user_id, 'merged',
jsonb_build_object(
'secondary_member_id', p_secondary_id,
'secondary_name', v_secondary.first_name || ' ' || v_secondary.last_name,
'references_moved', v_refs_moved,
'field_choices', p_field_choices
)
);
RETURN jsonb_build_object(
'primary_id', p_primary_id,
'secondary_id', p_secondary_id,
'references_moved', v_refs_moved
);
END;
$$;
GRANT EXECUTE ON FUNCTION public.merge_members(uuid, uuid, jsonb, uuid)
TO authenticated, service_role;