-- ===================================================== -- 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;