fix(classes): atomic signup/cancel under per-session advisory lock (D11)

Deferred deeper-audit P1. signUpForClass (count->decide->insert) and
cancelMySignup (cancel->recount->promote) were non-atomic: concurrent
signups at the capacity boundary over-enrolled and concurrent cancels
double-promoted waitlisters (dynamic capacity can't be a DB constraint).
Migration 045 adds class_signup_tx/class_cancel_tx (SECURITY DEFINER,
pg_advisory_xact_lock per session) doing the decision+write atomically;
actions call the RPCs and keep all pre-checks. Removed the now-unused
effectiveCapacity/computeSignupStatus/pickPromotion imports +
isUniqueViolation (pure rules still live + tested in classes-logic).
528 tests; build clean.
This commit is contained in:
Moheeb Zara
2026-05-19 01:30:07 -07:00
parent 6105047ad7
commit 705f03a299
5 changed files with 206 additions and 93 deletions
+73
View File
@@ -2243,6 +2243,79 @@ INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT id, 'board', 'classes.instruct' FROM public.spaces
ON CONFLICT (space_id, subject, permission) DO NOTHING;
-- Atomic signup/cancel (equivalent to scripts/045). Per-session advisory
-- xact lock serializes the capacity decision + write so concurrent signups
-- can't over-enroll and concurrent cancels can't double-promote. Mirrors the
-- pure lib/classes-logic rule; this SQL is the runtime authority.
CREATE OR REPLACE FUNCTION public.class_signup_tx(
p_session_id uuid, p_space_id uuid, p_member_id uuid
) RETURNS TABLE(signup_id uuid, signup_status text, err text)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE
v_cap int; v_reg int; v_status text; v_id uuid;
BEGIN
PERFORM pg_advisory_xact_lock(hashtext(p_session_id::text));
IF EXISTS (
SELECT 1 FROM class_signups
WHERE session_id = p_session_id AND member_id = p_member_id
AND space_id = p_space_id AND status <> 'cancelled'
) THEN
RETURN QUERY SELECT NULL::uuid, NULL::text, 'already'::text; RETURN;
END IF;
SELECT COALESCE(s.capacity, c.capacity) INTO v_cap
FROM class_sessions s JOIN classes c ON c.id = s.class_id
WHERE s.id = p_session_id AND s.space_id = p_space_id;
IF NOT FOUND THEN
RETURN QUERY SELECT NULL::uuid, NULL::text, 'no_session'::text; RETURN;
END IF;
SELECT count(*) INTO v_reg FROM class_signups
WHERE session_id = p_session_id AND status = 'registered';
v_status := CASE WHEN v_cap IS NULL OR v_reg < v_cap
THEN 'registered' ELSE 'waitlisted' END;
INSERT INTO class_signups (session_id, space_id, member_id, status)
VALUES (p_session_id, p_space_id, p_member_id, v_status)
RETURNING id INTO v_id;
RETURN QUERY SELECT v_id, v_status, NULL::text;
END;
$$;
CREATE OR REPLACE FUNCTION public.class_cancel_tx(
p_session_id uuid, p_space_id uuid, p_member_id uuid
) RETURNS TABLE(cancelled_id uuid, promoted_id uuid, err text)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE
v_sid uuid; v_status text; v_cap int; v_reg int; v_prom uuid;
BEGIN
PERFORM pg_advisory_xact_lock(hashtext(p_session_id::text));
SELECT id, status INTO v_sid, v_status FROM class_signups
WHERE session_id = p_session_id AND member_id = p_member_id
AND space_id = p_space_id AND status <> 'cancelled'
LIMIT 1;
IF NOT FOUND THEN
RETURN QUERY SELECT NULL::uuid, NULL::uuid, 'not_signed_up'::text; RETURN;
END IF;
UPDATE class_signups SET status = 'cancelled' WHERE id = v_sid;
v_prom := NULL;
IF v_status = 'registered' THEN
SELECT COALESCE(s.capacity, c.capacity) INTO v_cap
FROM class_sessions s JOIN classes c ON c.id = s.class_id
WHERE s.id = p_session_id AND s.space_id = p_space_id;
SELECT count(*) INTO v_reg FROM class_signups
WHERE session_id = p_session_id AND status = 'registered';
IF v_cap IS NULL OR v_reg < v_cap THEN
SELECT id INTO v_prom FROM class_signups
WHERE session_id = p_session_id AND space_id = p_space_id
AND status = 'waitlisted'
ORDER BY signed_up_at ASC LIMIT 1;
IF v_prom IS NOT NULL THEN
UPDATE class_signups SET status = 'registered' WHERE id = v_prom;
END IF;
END IF;
END IF;
RETURN QUERY SELECT v_sid, v_prom, NULL::text;
END;
$$;
-- =============================================================================
-- 20. Equipment registry + reservations