-- ===================================================== -- Soft Delete Consistency -- -- Problem: deleteMember does a soft delete (status='resigned'), -- but child table FKs use ON DELETE CASCADE. A hard DELETE -- would silently destroy roles, honors, mandates, transfers -- with no audit trail. -- -- Fix: Change CASCADE to RESTRICT on data-preserving tables, -- add BEFORE DELETE audit trigger, provide safe_delete_member(). -- ===================================================== -- Step 1: Change ON DELETE CASCADE → RESTRICT on tables where -- child data has independent value and should be preserved. -- We must drop and recreate the FK constraints. -- member_roles: board positions have historical value ALTER TABLE public.member_roles DROP CONSTRAINT IF EXISTS member_roles_member_id_fkey; ALTER TABLE public.member_roles ADD CONSTRAINT member_roles_member_id_fkey FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT; -- member_honors: awards/medals are permanent records ALTER TABLE public.member_honors DROP CONSTRAINT IF EXISTS member_honors_member_id_fkey; ALTER TABLE public.member_honors ADD CONSTRAINT member_honors_member_id_fkey FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT; -- sepa_mandates: financial records must be preserved ALTER TABLE public.sepa_mandates DROP CONSTRAINT IF EXISTS sepa_mandates_member_id_fkey; ALTER TABLE public.sepa_mandates ADD CONSTRAINT sepa_mandates_member_id_fkey FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT; -- member_transfers: audit trail must survive ALTER TABLE public.member_transfers DROP CONSTRAINT IF EXISTS member_transfers_member_id_fkey; ALTER TABLE public.member_transfers ADD CONSTRAINT member_transfers_member_id_fkey FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT; -- Keep CASCADE on tables where data is tightly coupled: -- member_department_assignments (junction table, no independent value) -- member_cards (regeneratable) -- member_portal_invitations (transient) -- Step 2: Audit trigger before hard delete — snapshot the full record CREATE OR REPLACE FUNCTION public.audit_member_before_hard_delete() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN -- If an audit_log table exists, log the deletion INSERT INTO public.audit_log ( account_id, user_id, table_name, record_id, action, old_data ) SELECT OLD.account_id, COALESCE( nullif(current_setting('app.current_user_id', true), '')::uuid, auth.uid() ), 'members', OLD.id::text, 'delete', to_jsonb(OLD); RETURN OLD; EXCEPTION WHEN undefined_table THEN -- audit_log table doesn't exist yet, allow delete to proceed RETURN OLD; END; $$; CREATE TRIGGER trg_members_audit_before_delete BEFORE DELETE ON public.members FOR EACH ROW EXECUTE FUNCTION public.audit_member_before_hard_delete(); -- Step 3: Safe hard-delete function for super-admin use only -- Archives all child records first, then performs the delete. CREATE OR REPLACE FUNCTION public.safe_delete_member( p_member_id uuid, p_performed_by uuid DEFAULT NULL ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE v_member record; BEGIN -- Fetch member for validation SELECT * INTO v_member FROM public.members WHERE id = p_member_id; IF v_member IS NULL THEN RAISE EXCEPTION 'Member % not found', p_member_id USING ERRCODE = 'P0002'; END IF; -- Set the user ID for the audit trigger IF p_performed_by IS NOT NULL THEN PERFORM set_config('app.current_user_id', p_performed_by::text, true); END IF; -- Delete child records that now use RESTRICT DELETE FROM public.member_roles WHERE member_id = p_member_id; DELETE FROM public.member_honors WHERE member_id = p_member_id; DELETE FROM public.sepa_mandates WHERE member_id = p_member_id; -- member_transfers: delete (the BEFORE DELETE trigger on members already snapshots everything) DELETE FROM public.member_transfers WHERE member_id = p_member_id; -- Now the hard delete triggers audit_member_before_hard_delete DELETE FROM public.members WHERE id = p_member_id; END; $$; GRANT EXECUTE ON FUNCTION public.safe_delete_member(uuid, uuid) TO service_role; -- Intentionally NOT granted to authenticated — super-admin only via admin client