refactor: remove obsolete member management API module
This commit is contained in:
274
apps/web/supabase/migrations/20260416000010_member_merge.sql
Normal file
274
apps/web/supabase/migrations/20260416000010_member_merge.sql
Normal file
@@ -0,0 +1,274 @@
|
||||
-- =====================================================
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user