275 lines
11 KiB
PL/PgSQL
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;
|