Files
myeasycms-v2/apps/web/supabase/migrations/20260414000004_member_transfer.sql

199 lines
7.1 KiB
PL/PgSQL

/*
* -------------------------------------------------------
* Member Transfer Between Accounts
*
* Enables transferring a member from one Verein to another
* within the same Verband hierarchy.
*
* Design:
* - Personal data always moves with the member
* - Course enrollments, event registrations, bookings are
* linked via member_id (not account_id) so they survive
* the transfer automatically
* - Only org-specific admin data is cleared:
* dues_category_id (org-specific pricing),
* member_number (unique per org)
* - SEPA bank data (IBAN/BIC) is preserved, but mandate
* status resets to 'pending' (needs re-confirmation)
* - Financial records (invoices, SEPA batches) stay in
* source org — they're legally tied to that entity
* -------------------------------------------------------
*/
-- Transfer log table
CREATE TABLE IF NOT EXISTS public.member_transfers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
source_account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE SET NULL,
target_account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE SET NULL,
transferred_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
reason text,
-- Snapshot what was cleared so it can be reviewed later
cleared_data jsonb NOT NULL DEFAULT '{}'::jsonb,
transferred_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_member_transfers_member
ON public.member_transfers(member_id);
CREATE INDEX IF NOT EXISTS ix_member_transfers_source
ON public.member_transfers(source_account_id);
CREATE INDEX IF NOT EXISTS ix_member_transfers_target
ON public.member_transfers(target_account_id);
ALTER TABLE public.member_transfers ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.member_transfers FROM authenticated, service_role;
GRANT SELECT, INSERT ON public.member_transfers TO authenticated;
GRANT ALL ON public.member_transfers TO service_role;
-- Readable by members of source or target account (or ancestor via hierarchy)
CREATE POLICY member_transfers_read ON public.member_transfers
FOR SELECT TO authenticated
USING (
public.has_role_on_account_or_ancestor(source_account_id) OR
public.has_role_on_account_or_ancestor(target_account_id)
);
-- -------------------------------------------------------
-- Transfer function
--
-- Only clears org-specific admin data. All cross-org
-- relationships (courses, events, bookings) survive because
-- they reference member_id, not account_id.
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.transfer_member(
p_member_id uuid,
p_target_account_id uuid,
p_reason text DEFAULT NULL,
p_keep_sepa boolean DEFAULT true
)
RETURNS uuid -- returns the transfer log ID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_source_account_id uuid;
v_transfer_id uuid;
v_caller_id uuid;
v_old_member_number text;
v_old_dues_category_id uuid;
v_old_sepa_mandate_id text;
v_source_name text;
v_target_name text;
v_active_courses int;
v_active_events int;
BEGIN
v_caller_id := (SELECT auth.uid());
-- Get the member's current account and data we'll clear
SELECT account_id, member_number, dues_category_id, sepa_mandate_id
INTO v_source_account_id, v_old_member_number, v_old_dues_category_id, v_old_sepa_mandate_id
FROM public.members
WHERE id = p_member_id;
IF v_source_account_id IS NULL THEN
RAISE EXCEPTION 'Mitglied nicht gefunden';
END IF;
IF v_source_account_id = p_target_account_id THEN
RAISE EXCEPTION 'Mitglied ist bereits in dieser Organisation';
END IF;
-- Target must be a team account
IF NOT EXISTS (
SELECT 1 FROM public.accounts
WHERE id = p_target_account_id AND is_personal_account = false
) THEN
RAISE EXCEPTION 'Zielorganisation nicht gefunden';
END IF;
-- Verify caller has visibility on BOTH accounts via hierarchy
IF NOT (
public.has_role_on_account_or_ancestor(v_source_account_id)
AND public.has_role_on_account_or_ancestor(p_target_account_id)
) THEN
RAISE EXCEPTION 'Keine Berechtigung für den Transfer';
END IF;
-- Verify both accounts share a common ancestor (same Verband)
IF NOT EXISTS (
SELECT 1
FROM public.get_account_ancestors(v_source_account_id) sa
JOIN public.get_account_ancestors(p_target_account_id) ta ON sa = ta
) THEN
RAISE EXCEPTION 'Organisationen gehören nicht zum selben Verband';
END IF;
-- Get org names for the transfer note
SELECT name INTO v_source_name FROM public.accounts WHERE id = v_source_account_id;
SELECT name INTO v_target_name FROM public.accounts WHERE id = p_target_account_id;
-- Count active relationships (informational, for the log)
SELECT count(*) INTO v_active_courses
FROM public.course_participants cp
JOIN public.courses c ON c.id = cp.course_id
WHERE cp.member_id = p_member_id AND cp.status = 'enrolled';
SELECT count(*) INTO v_active_events
FROM public.event_registrations er
JOIN public.events e ON e.id = er.event_id
WHERE er.email = (SELECT email FROM public.members WHERE id = p_member_id)
AND er.status IN ('confirmed', 'pending')
AND e.event_date >= current_date;
-- Perform the transfer
UPDATE public.members
SET
account_id = p_target_account_id,
-- Clear org-specific admin data
dues_category_id = NULL,
member_number = NULL,
-- SEPA: keep bank data (IBAN/BIC/account_holder), just reset mandate status
sepa_mandate_id = CASE WHEN p_keep_sepa THEN sepa_mandate_id ELSE NULL END,
sepa_mandate_date = CASE WHEN p_keep_sepa THEN sepa_mandate_date ELSE NULL END,
sepa_mandate_status = 'pending', -- always needs re-confirmation in new org
-- Append transfer note
notes = COALESCE(notes, '') ||
E'\n[Transfer ' || now()::date || '] ' ||
v_source_name || '' || v_target_name ||
COALESCE(E'' || p_reason, '') ||
CASE WHEN v_active_courses > 0
THEN E'\n ↳ ' || v_active_courses || ' aktive Kurseinschreibungen bleiben erhalten'
ELSE '' END ||
CASE WHEN v_active_events > 0
THEN E'\n ↳ ' || v_active_events || ' aktive Veranstaltungsanmeldungen bleiben erhalten'
ELSE '' END,
updated_by = v_caller_id,
updated_at = now()
WHERE id = p_member_id;
-- Log the transfer with snapshot of cleared data
INSERT INTO public.member_transfers (
member_id, source_account_id, target_account_id, transferred_by, reason, cleared_data
) VALUES (
p_member_id,
v_source_account_id,
p_target_account_id,
v_caller_id,
p_reason,
jsonb_build_object(
'old_member_number', v_old_member_number,
'old_dues_category_id', v_old_dues_category_id,
'old_sepa_mandate_id', v_old_sepa_mandate_id,
'active_courses_at_transfer', v_active_courses,
'active_events_at_transfer', v_active_events,
'sepa_kept', p_keep_sepa
)
)
RETURNING id INTO v_transfer_id;
RETURN v_transfer_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.transfer_member(uuid, uuid, text, boolean)
TO authenticated, service_role;