-- ===================================================== -- Member Audit Log -- -- Full change history for compliance: who changed what -- field, old value→new value, when. Plus activity timeline. -- ===================================================== -- 1. Audit log table CREATE TABLE IF NOT EXISTS public.member_audit_log ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, action text NOT NULL CHECK (action IN ( 'created', 'updated', 'status_changed', 'archived', 'unarchived', 'department_assigned', 'department_removed', 'role_assigned', 'role_removed', 'honor_awarded', 'honor_removed', 'mandate_created', 'mandate_updated', 'mandate_revoked', 'transferred', 'merged', 'application_approved', 'application_rejected', 'portal_invited', 'portal_linked', 'card_generated', 'imported', 'exported', 'gdpr_consent_changed', 'gdpr_anonymized', 'tag_added', 'tag_removed', 'communication_logged', 'note_added', 'bulk_status_changed', 'bulk_archived' )), changes jsonb NOT NULL DEFAULT '{}', metadata jsonb NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now() ); COMMENT ON TABLE public.member_audit_log IS 'Immutable audit trail for all member lifecycle events'; CREATE INDEX ix_member_audit_member ON public.member_audit_log(member_id, created_at DESC); CREATE INDEX ix_member_audit_account ON public.member_audit_log(account_id, created_at DESC); CREATE INDEX ix_member_audit_user ON public.member_audit_log(user_id) WHERE user_id IS NOT NULL; CREATE INDEX ix_member_audit_action ON public.member_audit_log(account_id, action); ALTER TABLE public.member_audit_log ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.member_audit_log FROM authenticated, service_role; GRANT SELECT ON public.member_audit_log TO authenticated; GRANT ALL ON public.member_audit_log TO service_role; -- Read access: must have role on the account CREATE POLICY member_audit_log_select ON public.member_audit_log FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); -- No direct insert/update/delete for authenticated — only via SECURITY DEFINER functions -- 2. Auto-audit trigger on members UPDATE -- Computes field-by-field diff and classifies the action type. CREATE OR REPLACE FUNCTION public.trg_member_audit_on_update() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE v_changes jsonb := '{}'::jsonb; v_user_id uuid; v_action text; v_old jsonb; v_new jsonb; v_key text; BEGIN v_user_id := nullif(current_setting('app.current_user_id', true), '')::uuid; v_old := to_jsonb(OLD); v_new := to_jsonb(NEW); -- Compare each field, skip meta columns FOR v_key IN SELECT jsonb_object_keys(v_new) EXCEPT SELECT unnest(ARRAY['updated_at', 'updated_by', 'version']) LOOP IF (v_old -> v_key) IS DISTINCT FROM (v_new -> v_key) THEN v_changes := v_changes || jsonb_build_object( v_key, jsonb_build_object('old', v_old -> v_key, 'new', v_new -> v_key) ); END IF; END LOOP; -- Skip if nothing actually changed IF v_changes = '{}'::jsonb THEN RETURN NEW; END IF; -- Classify the action IF (v_old ->> 'status') IS DISTINCT FROM (v_new ->> 'status') THEN v_action := 'status_changed'; ELSIF (v_old ->> 'is_archived') IS DISTINCT FROM (v_new ->> 'is_archived') AND COALESCE((v_new ->> 'is_archived'), 'false') = 'true' THEN v_action := 'archived'; ELSIF (v_old ->> 'is_archived') IS DISTINCT FROM (v_new ->> 'is_archived') THEN v_action := 'unarchived'; ELSE v_action := 'updated'; END IF; INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, changes) VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes); RETURN NEW; END; $$; CREATE TRIGGER trg_members_audit_on_update AFTER UPDATE ON public.members FOR EACH ROW EXECUTE FUNCTION public.trg_member_audit_on_update(); -- 3. Auto-audit trigger on members INSERT CREATE OR REPLACE FUNCTION public.trg_member_audit_on_insert() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE v_user_id uuid; BEGIN v_user_id := COALESCE( nullif(current_setting('app.current_user_id', true), '')::uuid, NEW.created_by ); INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata) VALUES ( NEW.id, NEW.account_id, v_user_id, 'created', jsonb_build_object( 'member_number', NEW.member_number, 'first_name', NEW.first_name, 'last_name', NEW.last_name, 'status', NEW.status ) ); RETURN NEW; END; $$; CREATE TRIGGER trg_members_audit_on_insert AFTER INSERT ON public.members FOR EACH ROW EXECUTE FUNCTION public.trg_member_audit_on_insert(); -- 4. Helper function to log explicit audit events (for related tables) CREATE OR REPLACE FUNCTION public.log_member_audit_event( p_member_id uuid, p_account_id uuid, p_action text, p_changes jsonb DEFAULT '{}', p_metadata jsonb DEFAULT '{}' ) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN -- Verify caller has access to the account IF NOT public.has_role_on_account(p_account_id) THEN RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501'; END IF; -- Force user_id to be the actual caller INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, changes, metadata) VALUES (p_member_id, p_account_id, auth.uid(), p_action, p_changes, p_metadata); END; $$; GRANT EXECUTE ON FUNCTION public.log_member_audit_event(uuid, uuid, text, jsonb, jsonb) TO authenticated, service_role; -- 5. Activity timeline RPC (read layer on audit log) CREATE OR REPLACE FUNCTION public.get_member_timeline( p_member_id uuid, p_page int DEFAULT 1, p_page_size int DEFAULT 50, p_action_filter text DEFAULT NULL ) RETURNS TABLE ( id bigint, action text, changes jsonb, metadata jsonb, user_id uuid, user_display_name text, created_at timestamptz, total_count bigint ) LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$ DECLARE v_account_id uuid; v_total bigint; v_offset int; BEGIN -- Get member's account for access check SELECT m.account_id INTO v_account_id FROM public.members m WHERE m.id = p_member_id; IF v_account_id IS NULL THEN RAISE EXCEPTION 'Member not found'; END IF; IF NOT public.has_role_on_account(v_account_id) THEN RAISE EXCEPTION 'Access denied'; END IF; -- Clamp page size to prevent unbounded queries p_page_size := LEAST(GREATEST(p_page_size, 1), 200); v_offset := GREATEST(0, (p_page - 1)) * p_page_size; -- Get total count SELECT count(*) INTO v_total FROM public.member_audit_log al WHERE al.member_id = p_member_id AND (p_action_filter IS NULL OR al.action = p_action_filter); -- Return paginated results with user names RETURN QUERY SELECT al.id, al.action, al.changes, al.metadata, al.user_id, COALESCE( u.raw_user_meta_data ->> 'display_name', u.email, al.user_id::text ) AS user_display_name, al.created_at, v_total AS total_count FROM public.member_audit_log al LEFT JOIN auth.users u ON u.id = al.user_id WHERE al.member_id = p_member_id AND (p_action_filter IS NULL OR al.action = p_action_filter) ORDER BY al.created_at DESC OFFSET v_offset LIMIT p_page_size; END; $$; GRANT EXECUTE ON FUNCTION public.get_member_timeline(uuid, int, int, text) TO authenticated;