refactor: remove obsolete member management API module
This commit is contained in:
260
apps/web/supabase/migrations/20260416000007_member_audit_log.sql
Normal file
260
apps/web/supabase/migrations/20260416000007_member_audit_log.sql
Normal file
@@ -0,0 +1,260 @@
|
||||
-- =====================================================
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user