-- ===================================================== -- Atomic Course Enrollment -- -- Problem: Enrolling a participant in a course requires -- multiple queries (check capacity, count enrolled, insert). -- Race conditions can over-enroll a course. -- -- Fix: Single transactional PG function that locks the -- course row, validates capacity, and inserts with the -- correct status (enrolled vs waitlisted). -- ===================================================== CREATE OR REPLACE FUNCTION public.enroll_course_participant( p_course_id uuid, p_member_id uuid DEFAULT NULL, p_first_name text DEFAULT NULL, p_last_name text DEFAULT NULL, p_email text DEFAULT NULL, p_phone text DEFAULT NULL ) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE v_course record; v_enrolled_count bigint; v_status public.enrollment_status; v_waitlist_position bigint; v_participant_id uuid; BEGIN -- 1. Lock the course row to prevent concurrent enrollment races SELECT * INTO v_course FROM public.courses WHERE id = p_course_id FOR UPDATE; IF v_course IS NULL THEN RAISE EXCEPTION 'Course % not found', p_course_id USING ERRCODE = 'P0002'; END IF; -- 2. Validate course status is open for enrollment IF v_course.status != 'open' THEN RAISE EXCEPTION 'Course is not open for enrollment (current status: %)', v_course.status USING ERRCODE = 'P0001'; END IF; -- 3. Check registration deadline hasn't passed IF v_course.registration_deadline IS NOT NULL AND v_course.registration_deadline < current_date THEN RAISE EXCEPTION 'Registration deadline (%) has passed', v_course.registration_deadline USING ERRCODE = 'P0001'; END IF; -- 4. Count currently enrolled participants SELECT count(*) INTO v_enrolled_count FROM public.course_participants WHERE course_id = p_course_id AND status = 'enrolled'; -- 5. Determine status based on capacity IF v_enrolled_count >= v_course.capacity THEN v_status := 'waitlisted'; ELSE v_status := 'enrolled'; END IF; -- 6. Insert the participant INSERT INTO public.course_participants ( course_id, member_id, first_name, last_name, email, phone, status, enrolled_at ) VALUES ( p_course_id, p_member_id, p_first_name, p_last_name, p_email, p_phone, v_status, now() ) RETURNING id INTO v_participant_id; -- 7. Calculate waitlist position if waitlisted IF v_status = 'waitlisted' THEN SELECT count(*) INTO v_waitlist_position FROM public.course_participants WHERE course_id = p_course_id AND status = 'waitlisted'; END IF; -- 8. Return result RETURN jsonb_build_object( 'participant_id', v_participant_id, 'status', v_status::text, 'waitlist_position', CASE WHEN v_status = 'waitlisted' THEN v_waitlist_position ELSE NULL END ); END; $$; GRANT EXECUTE ON FUNCTION public.enroll_course_participant(uuid, uuid, text, text, text, text) TO authenticated; GRANT EXECUTE ON FUNCTION public.enroll_course_participant(uuid, uuid, text, text, text, text) TO service_role;