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