261 lines
7.6 KiB
PL/PgSQL
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;
|