126 lines
4.3 KiB
PL/PgSQL
126 lines
4.3 KiB
PL/PgSQL
-- =====================================================
|
|
-- 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
|