refactor: remove obsolete member management API module
This commit is contained in:
170
apps/web/supabase/migrations/20260416000011_gdpr_retention.sql
Normal file
170
apps/web/supabase/migrations/20260416000011_gdpr_retention.sql
Normal file
@@ -0,0 +1,170 @@
|
||||
-- =====================================================
|
||||
-- GDPR Data Retention Automation
|
||||
--
|
||||
-- Configurable retention policies per account.
|
||||
-- Automatic anonymization of resigned/excluded/deceased
|
||||
-- members after retention period expires.
|
||||
-- =====================================================
|
||||
|
||||
-- Retention policy configuration per account
|
||||
CREATE TABLE IF NOT EXISTS public.gdpr_retention_policies (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
policy_name text NOT NULL DEFAULT 'Standard',
|
||||
retention_days int NOT NULL DEFAULT 1095, -- 3 years
|
||||
auto_anonymize boolean NOT NULL DEFAULT false,
|
||||
applies_to_status text[] NOT NULL DEFAULT ARRAY['resigned', 'excluded', 'deceased'],
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(account_id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.gdpr_retention_policies ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.gdpr_retention_policies FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE ON public.gdpr_retention_policies TO authenticated;
|
||||
GRANT ALL ON public.gdpr_retention_policies TO service_role;
|
||||
|
||||
CREATE POLICY gdpr_retention_select
|
||||
ON public.gdpr_retention_policies FOR SELECT TO authenticated
|
||||
USING (public.has_role_on_account(account_id));
|
||||
|
||||
CREATE POLICY gdpr_retention_mutate
|
||||
ON public.gdpr_retention_policies FOR ALL TO authenticated
|
||||
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||
|
||||
-- Anonymize a single member (replaces all PII with placeholder)
|
||||
CREATE OR REPLACE FUNCTION public.anonymize_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;
|
||||
v_user_id uuid;
|
||||
BEGIN
|
||||
v_user_id := COALESCE(p_performed_by, auth.uid());
|
||||
|
||||
SELECT * INTO v_member FROM public.members WHERE id = p_member_id;
|
||||
IF v_member IS NULL THEN
|
||||
RAISE EXCEPTION 'Member not found';
|
||||
END IF;
|
||||
|
||||
-- Verify caller access
|
||||
IF v_user_id IS NOT NULL AND NOT public.has_permission(v_user_id, v_member.account_id, 'members.write'::public.app_permissions) THEN
|
||||
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- Snapshot full record to audit log before anonymization
|
||||
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
|
||||
VALUES (
|
||||
p_member_id, v_member.account_id, v_user_id, 'gdpr_anonymized',
|
||||
jsonb_build_object(
|
||||
'original_first_name', v_member.first_name,
|
||||
'original_last_name', v_member.last_name,
|
||||
'original_email', v_member.email,
|
||||
'reason', 'GDPR retention policy'
|
||||
)
|
||||
);
|
||||
|
||||
-- Replace all PII with anonymized placeholders
|
||||
UPDATE public.members SET
|
||||
first_name = 'ANONYMISIERT',
|
||||
last_name = 'ANONYMISIERT',
|
||||
email = NULL,
|
||||
phone = NULL,
|
||||
mobile = NULL,
|
||||
phone2 = NULL,
|
||||
fax = NULL,
|
||||
street = NULL,
|
||||
house_number = NULL,
|
||||
street2 = NULL,
|
||||
postal_code = NULL,
|
||||
city = NULL,
|
||||
date_of_birth = NULL,
|
||||
birthplace = NULL,
|
||||
birth_country = NULL,
|
||||
iban = NULL,
|
||||
bic = NULL,
|
||||
account_holder = NULL,
|
||||
sepa_mandate_reference = NULL,
|
||||
sepa_mandate_id = NULL,
|
||||
primary_mandate_id = NULL,
|
||||
guardian_name = NULL,
|
||||
guardian_phone = NULL,
|
||||
guardian_email = NULL,
|
||||
notes = '[GDPR anonymisiert am ' || to_char(now(), 'YYYY-MM-DD') || ']',
|
||||
custom_data = '{}'::jsonb,
|
||||
online_access_key = NULL,
|
||||
online_access_blocked = true,
|
||||
gdpr_consent = false,
|
||||
gdpr_newsletter = false,
|
||||
gdpr_internet = false,
|
||||
gdpr_print = false,
|
||||
gdpr_birthday_info = false,
|
||||
is_archived = true,
|
||||
updated_by = v_user_id
|
||||
WHERE id = p_member_id;
|
||||
|
||||
-- Anonymize SEPA mandates (can't DELETE due to ON DELETE RESTRICT from Phase 1)
|
||||
-- primary_mandate_id already cleared above in the members UPDATE
|
||||
-- Anonymize SEPA PII fields (keep row for audit, revoke mandate)
|
||||
UPDATE public.sepa_mandates
|
||||
SET iban = 'DE00ANON0000000000000', bic = NULL, account_holder = 'ANONYMISIERT',
|
||||
mandate_reference = 'ANON-' || id::text, status = 'revoked',
|
||||
notes = '[GDPR anonymisiert]'
|
||||
WHERE member_id = p_member_id;
|
||||
|
||||
-- Remove communications (may contain PII)
|
||||
BEGIN
|
||||
DELETE FROM public.member_communications WHERE member_id = p_member_id;
|
||||
EXCEPTION WHEN undefined_table THEN NULL;
|
||||
END;
|
||||
|
||||
-- Remove portal invitations
|
||||
DELETE FROM public.member_portal_invitations WHERE member_id = p_member_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.anonymize_member(uuid, uuid)
|
||||
TO authenticated, service_role;
|
||||
|
||||
-- Batch enforcement: find and anonymize members matching retention criteria
|
||||
CREATE OR REPLACE FUNCTION public.enforce_gdpr_retention_policies()
|
||||
RETURNS int
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
v_policy record;
|
||||
v_member record;
|
||||
v_count int := 0;
|
||||
BEGIN
|
||||
FOR v_policy IN
|
||||
SELECT * FROM public.gdpr_retention_policies
|
||||
WHERE auto_anonymize = true
|
||||
LOOP
|
||||
FOR v_member IN
|
||||
SELECT m.id
|
||||
FROM public.members m
|
||||
WHERE m.account_id = v_policy.account_id
|
||||
AND m.status = ANY(v_policy.applies_to_status::public.membership_status[])
|
||||
AND m.first_name != 'ANONYMISIERT' -- not already anonymized
|
||||
AND m.exit_date IS NOT NULL -- only retain based on actual exit date
|
||||
AND m.exit_date + (v_policy.retention_days || ' days')::interval <= current_date
|
||||
LOOP
|
||||
PERFORM public.anonymize_member(v_member.id, NULL);
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RETURN v_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.enforce_gdpr_retention_policies()
|
||||
TO service_role;
|
||||
Reference in New Issue
Block a user