-- ===================================================== -- Audit Logging for Courses, Events, Bookings -- -- Full change history for compliance: who changed what -- field, old value -> new value, when. Mirrors the -- member_audit_log pattern from 20260416000007. -- ===================================================== -- ------------------------------------------------------- -- A) Add created_by / updated_by to main tables -- ------------------------------------------------------- ALTER TABLE public.courses ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL; ALTER TABLE public.bookings ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL; -- ------------------------------------------------------- -- B) Audit log tables -- ------------------------------------------------------- -- B.1 Course audit log CREATE TABLE IF NOT EXISTS public.course_audit_log ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, course_id uuid NOT NULL REFERENCES public.courses(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', 'cancelled', 'participant_enrolled', 'participant_cancelled', 'participant_waitlisted', 'participant_promoted', 'session_created', 'session_cancelled', 'attendance_marked', 'instructor_changed', 'location_changed' )), changes jsonb NOT NULL DEFAULT '{}', metadata jsonb NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now() ); COMMENT ON TABLE public.course_audit_log IS 'Immutable audit trail for all course lifecycle events'; CREATE INDEX IF NOT EXISTS ix_course_audit_course ON public.course_audit_log(course_id, created_at DESC); CREATE INDEX IF NOT EXISTS ix_course_audit_account ON public.course_audit_log(account_id, created_at DESC); CREATE INDEX IF NOT EXISTS ix_course_audit_action ON public.course_audit_log(account_id, action); ALTER TABLE public.course_audit_log ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.course_audit_log FROM authenticated, service_role; GRANT SELECT ON public.course_audit_log TO authenticated; GRANT INSERT, SELECT ON public.course_audit_log TO service_role; CREATE POLICY course_audit_log_select ON public.course_audit_log FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); -- B.2 Event audit log CREATE TABLE IF NOT EXISTS public.event_audit_log ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_id uuid NOT NULL REFERENCES public.events(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', 'cancelled', 'registration_confirmed', 'registration_waitlisted', 'registration_cancelled', 'registration_promoted' )), changes jsonb NOT NULL DEFAULT '{}', metadata jsonb NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now() ); COMMENT ON TABLE public.event_audit_log IS 'Immutable audit trail for all event lifecycle events'; CREATE INDEX IF NOT EXISTS ix_event_audit_event ON public.event_audit_log(event_id, created_at DESC); CREATE INDEX IF NOT EXISTS ix_event_audit_account ON public.event_audit_log(account_id, created_at DESC); CREATE INDEX IF NOT EXISTS ix_event_audit_action ON public.event_audit_log(account_id, action); ALTER TABLE public.event_audit_log ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.event_audit_log FROM authenticated, service_role; GRANT SELECT ON public.event_audit_log TO authenticated; GRANT INSERT, SELECT ON public.event_audit_log TO service_role; CREATE POLICY event_audit_log_select ON public.event_audit_log FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); -- B.3 Booking audit log CREATE TABLE IF NOT EXISTS public.booking_audit_log ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, booking_id uuid NOT NULL REFERENCES public.bookings(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', 'checked_in', 'checked_out', 'cancelled', 'no_show', 'price_changed' )), changes jsonb NOT NULL DEFAULT '{}', metadata jsonb NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now() ); COMMENT ON TABLE public.booking_audit_log IS 'Immutable audit trail for all booking lifecycle events'; CREATE INDEX IF NOT EXISTS ix_booking_audit_booking ON public.booking_audit_log(booking_id, created_at DESC); CREATE INDEX IF NOT EXISTS ix_booking_audit_account ON public.booking_audit_log(account_id, created_at DESC); CREATE INDEX IF NOT EXISTS ix_booking_audit_action ON public.booking_audit_log(account_id, action); ALTER TABLE public.booking_audit_log ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.booking_audit_log FROM authenticated, service_role; GRANT SELECT ON public.booking_audit_log TO authenticated; GRANT INSERT, SELECT ON public.booking_audit_log TO service_role; CREATE POLICY booking_audit_log_select ON public.booking_audit_log FOR SELECT TO authenticated USING (public.has_role_on_account(account_id)); -- ------------------------------------------------------- -- C) Auto-audit triggers for UPDATE -- ------------------------------------------------------- -- C.1 Courses UPDATE trigger CREATE OR REPLACE FUNCTION public.trg_course_audit_on_update() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE v_changes jsonb := '{}'::jsonb; v_action text := 'updated'; v_user_id uuid; BEGIN -- Build changes diff (field by field) IF OLD.name IS DISTINCT FROM NEW.name THEN v_changes := v_changes || jsonb_build_object('name', jsonb_build_object('old', OLD.name, 'new', NEW.name)); END IF; IF OLD.description IS DISTINCT FROM NEW.description THEN v_changes := v_changes || jsonb_build_object('description', jsonb_build_object('old', OLD.description, 'new', NEW.description)); END IF; IF OLD.course_number IS DISTINCT FROM NEW.course_number THEN v_changes := v_changes || jsonb_build_object('course_number', jsonb_build_object('old', OLD.course_number, 'new', NEW.course_number)); END IF; IF OLD.category_id IS DISTINCT FROM NEW.category_id THEN v_changes := v_changes || jsonb_build_object('category_id', jsonb_build_object('old', OLD.category_id, 'new', NEW.category_id)); END IF; IF OLD.instructor_id IS DISTINCT FROM NEW.instructor_id THEN v_changes := v_changes || jsonb_build_object('instructor_id', jsonb_build_object('old', OLD.instructor_id, 'new', NEW.instructor_id)); END IF; IF OLD.location_id IS DISTINCT FROM NEW.location_id THEN v_changes := v_changes || jsonb_build_object('location_id', jsonb_build_object('old', OLD.location_id, 'new', NEW.location_id)); END IF; IF OLD.start_date IS DISTINCT FROM NEW.start_date THEN v_changes := v_changes || jsonb_build_object('start_date', jsonb_build_object('old', OLD.start_date, 'new', NEW.start_date)); END IF; IF OLD.end_date IS DISTINCT FROM NEW.end_date THEN v_changes := v_changes || jsonb_build_object('end_date', jsonb_build_object('old', OLD.end_date, 'new', NEW.end_date)); END IF; IF OLD.fee IS DISTINCT FROM NEW.fee THEN v_changes := v_changes || jsonb_build_object('fee', jsonb_build_object('old', OLD.fee, 'new', NEW.fee)); END IF; IF OLD.reduced_fee IS DISTINCT FROM NEW.reduced_fee THEN v_changes := v_changes || jsonb_build_object('reduced_fee', jsonb_build_object('old', OLD.reduced_fee, 'new', NEW.reduced_fee)); END IF; IF OLD.capacity IS DISTINCT FROM NEW.capacity THEN v_changes := v_changes || jsonb_build_object('capacity', jsonb_build_object('old', OLD.capacity, 'new', NEW.capacity)); END IF; IF OLD.min_participants IS DISTINCT FROM NEW.min_participants THEN v_changes := v_changes || jsonb_build_object('min_participants', jsonb_build_object('old', OLD.min_participants, 'new', NEW.min_participants)); END IF; IF OLD.status IS DISTINCT FROM NEW.status THEN v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status)); END IF; IF OLD.registration_deadline IS DISTINCT FROM NEW.registration_deadline THEN v_changes := v_changes || jsonb_build_object('registration_deadline', jsonb_build_object('old', OLD.registration_deadline, 'new', NEW.registration_deadline)); END IF; IF OLD.notes IS DISTINCT FROM NEW.notes THEN v_changes := v_changes || jsonb_build_object('notes', jsonb_build_object('old', OLD.notes, 'new', NEW.notes)); END IF; -- Skip if nothing actually changed IF v_changes = '{}'::jsonb THEN RETURN NULL; END IF; -- Classify the action IF OLD.status IS DISTINCT FROM NEW.status THEN v_action := 'status_changed'; END IF; v_user_id := COALESCE( NULLIF(current_setting('app.current_user_id', true), '')::uuid, auth.uid() ); INSERT INTO public.course_audit_log (course_id, account_id, user_id, action, changes) VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes); RETURN NULL; END; $$; CREATE OR REPLACE TRIGGER trg_courses_audit_on_update AFTER UPDATE ON public.courses FOR EACH ROW EXECUTE FUNCTION public.trg_course_audit_on_update(); -- C.2 Events UPDATE trigger CREATE OR REPLACE FUNCTION public.trg_event_audit_on_update() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE v_changes jsonb := '{}'::jsonb; v_action text := 'updated'; v_user_id uuid; BEGIN -- Build changes diff (field by field) IF OLD.name IS DISTINCT FROM NEW.name THEN v_changes := v_changes || jsonb_build_object('name', jsonb_build_object('old', OLD.name, 'new', NEW.name)); END IF; IF OLD.description IS DISTINCT FROM NEW.description THEN v_changes := v_changes || jsonb_build_object('description', jsonb_build_object('old', OLD.description, 'new', NEW.description)); END IF; IF OLD.event_date IS DISTINCT FROM NEW.event_date THEN v_changes := v_changes || jsonb_build_object('event_date', jsonb_build_object('old', OLD.event_date, 'new', NEW.event_date)); END IF; IF OLD.event_time IS DISTINCT FROM NEW.event_time THEN v_changes := v_changes || jsonb_build_object('event_time', jsonb_build_object('old', OLD.event_time, 'new', NEW.event_time)); END IF; IF OLD.end_date IS DISTINCT FROM NEW.end_date THEN v_changes := v_changes || jsonb_build_object('end_date', jsonb_build_object('old', OLD.end_date, 'new', NEW.end_date)); END IF; IF OLD.location IS DISTINCT FROM NEW.location THEN v_changes := v_changes || jsonb_build_object('location', jsonb_build_object('old', OLD.location, 'new', NEW.location)); END IF; IF OLD.capacity IS DISTINCT FROM NEW.capacity THEN v_changes := v_changes || jsonb_build_object('capacity', jsonb_build_object('old', OLD.capacity, 'new', NEW.capacity)); END IF; IF OLD.min_age IS DISTINCT FROM NEW.min_age THEN v_changes := v_changes || jsonb_build_object('min_age', jsonb_build_object('old', OLD.min_age, 'new', NEW.min_age)); END IF; IF OLD.max_age IS DISTINCT FROM NEW.max_age THEN v_changes := v_changes || jsonb_build_object('max_age', jsonb_build_object('old', OLD.max_age, 'new', NEW.max_age)); END IF; IF OLD.fee IS DISTINCT FROM NEW.fee THEN v_changes := v_changes || jsonb_build_object('fee', jsonb_build_object('old', OLD.fee, 'new', NEW.fee)); END IF; IF OLD.status IS DISTINCT FROM NEW.status THEN v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status)); END IF; IF OLD.registration_deadline IS DISTINCT FROM NEW.registration_deadline THEN v_changes := v_changes || jsonb_build_object('registration_deadline', jsonb_build_object('old', OLD.registration_deadline, 'new', NEW.registration_deadline)); END IF; IF OLD.contact_name IS DISTINCT FROM NEW.contact_name THEN v_changes := v_changes || jsonb_build_object('contact_name', jsonb_build_object('old', OLD.contact_name, 'new', NEW.contact_name)); END IF; IF OLD.contact_email IS DISTINCT FROM NEW.contact_email THEN v_changes := v_changes || jsonb_build_object('contact_email', jsonb_build_object('old', OLD.contact_email, 'new', NEW.contact_email)); END IF; IF OLD.contact_phone IS DISTINCT FROM NEW.contact_phone THEN v_changes := v_changes || jsonb_build_object('contact_phone', jsonb_build_object('old', OLD.contact_phone, 'new', NEW.contact_phone)); END IF; -- Skip if nothing actually changed IF v_changes = '{}'::jsonb THEN RETURN NULL; END IF; -- Classify the action IF OLD.status IS DISTINCT FROM NEW.status THEN v_action := 'status_changed'; END IF; v_user_id := COALESCE( NULLIF(current_setting('app.current_user_id', true), '')::uuid, auth.uid() ); INSERT INTO public.event_audit_log (event_id, account_id, user_id, action, changes) VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes); RETURN NULL; END; $$; CREATE OR REPLACE TRIGGER trg_events_audit_on_update AFTER UPDATE ON public.events FOR EACH ROW EXECUTE FUNCTION public.trg_event_audit_on_update(); -- C.3 Bookings UPDATE trigger CREATE OR REPLACE FUNCTION public.trg_booking_audit_on_update() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE v_changes jsonb := '{}'::jsonb; v_action text := 'updated'; v_user_id uuid; BEGIN -- Build changes diff (field by field) IF OLD.room_id IS DISTINCT FROM NEW.room_id THEN v_changes := v_changes || jsonb_build_object('room_id', jsonb_build_object('old', OLD.room_id, 'new', NEW.room_id)); END IF; IF OLD.guest_id IS DISTINCT FROM NEW.guest_id THEN v_changes := v_changes || jsonb_build_object('guest_id', jsonb_build_object('old', OLD.guest_id, 'new', NEW.guest_id)); END IF; IF OLD.check_in IS DISTINCT FROM NEW.check_in THEN v_changes := v_changes || jsonb_build_object('check_in', jsonb_build_object('old', OLD.check_in, 'new', NEW.check_in)); END IF; IF OLD.check_out IS DISTINCT FROM NEW.check_out THEN v_changes := v_changes || jsonb_build_object('check_out', jsonb_build_object('old', OLD.check_out, 'new', NEW.check_out)); END IF; IF OLD.adults IS DISTINCT FROM NEW.adults THEN v_changes := v_changes || jsonb_build_object('adults', jsonb_build_object('old', OLD.adults, 'new', NEW.adults)); END IF; IF OLD.children IS DISTINCT FROM NEW.children THEN v_changes := v_changes || jsonb_build_object('children', jsonb_build_object('old', OLD.children, 'new', NEW.children)); END IF; IF OLD.status IS DISTINCT FROM NEW.status THEN v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status)); END IF; IF OLD.total_price IS DISTINCT FROM NEW.total_price THEN v_changes := v_changes || jsonb_build_object('total_price', jsonb_build_object('old', OLD.total_price, 'new', NEW.total_price)); END IF; IF OLD.notes IS DISTINCT FROM NEW.notes THEN v_changes := v_changes || jsonb_build_object('notes', jsonb_build_object('old', OLD.notes, 'new', NEW.notes)); END IF; -- Skip if nothing actually changed IF v_changes = '{}'::jsonb THEN RETURN NULL; END IF; -- Classify the action IF OLD.status IS DISTINCT FROM NEW.status THEN v_action := 'status_changed'; END IF; v_user_id := COALESCE( NULLIF(current_setting('app.current_user_id', true), '')::uuid, auth.uid() ); INSERT INTO public.booking_audit_log (booking_id, account_id, user_id, action, changes) VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes); RETURN NULL; END; $$; CREATE OR REPLACE TRIGGER trg_bookings_audit_on_update AFTER UPDATE ON public.bookings FOR EACH ROW EXECUTE FUNCTION public.trg_booking_audit_on_update(); -- ------------------------------------------------------- -- D) Auto-audit triggers for INSERT -- ------------------------------------------------------- -- D.1 Courses INSERT trigger CREATE OR REPLACE FUNCTION public.trg_course_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.course_audit_log (course_id, account_id, user_id, action, metadata) VALUES ( NEW.id, NEW.account_id, v_user_id, 'created', jsonb_build_object( 'course_number', NEW.course_number, 'name', NEW.name, 'status', NEW.status, 'fee', NEW.fee, 'capacity', NEW.capacity, 'start_date', NEW.start_date, 'end_date', NEW.end_date ) ); RETURN NEW; END; $$; CREATE OR REPLACE TRIGGER trg_courses_audit_on_insert AFTER INSERT ON public.courses FOR EACH ROW EXECUTE FUNCTION public.trg_course_audit_on_insert(); -- D.2 Events INSERT trigger CREATE OR REPLACE FUNCTION public.trg_event_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.event_audit_log (event_id, account_id, user_id, action, metadata) VALUES ( NEW.id, NEW.account_id, v_user_id, 'created', jsonb_build_object( 'name', NEW.name, 'status', NEW.status, 'event_date', NEW.event_date, 'location', NEW.location, 'capacity', NEW.capacity, 'fee', NEW.fee ) ); RETURN NEW; END; $$; CREATE OR REPLACE TRIGGER trg_events_audit_on_insert AFTER INSERT ON public.events FOR EACH ROW EXECUTE FUNCTION public.trg_event_audit_on_insert(); -- D.3 Bookings INSERT trigger CREATE OR REPLACE FUNCTION public.trg_booking_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.booking_audit_log (booking_id, account_id, user_id, action, metadata) VALUES ( NEW.id, NEW.account_id, v_user_id, 'created', jsonb_build_object( 'room_id', NEW.room_id, 'guest_id', NEW.guest_id, 'check_in', NEW.check_in, 'check_out', NEW.check_out, 'status', NEW.status, 'total_price', NEW.total_price, 'adults', NEW.adults, 'children', NEW.children ) ); RETURN NEW; END; $$; CREATE OR REPLACE TRIGGER trg_bookings_audit_on_insert AFTER INSERT ON public.bookings FOR EACH ROW EXECUTE FUNCTION public.trg_booking_audit_on_insert();