Files
myeasycms-v2/apps/web/supabase/migrations/20260416000007_member_audit_log.sql
T. Zehetbauer 5c5aaabae5
Some checks failed
Workflow / ʦ TypeScript (pull_request) Failing after 5m57s
Workflow / ⚫️ Test (pull_request) Has been skipped
refactor: remove obsolete member management API module
2026-04-03 14:08:31 +02:00

261 lines
7.6 KiB
PL/PgSQL

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