-- Business rule: PM Duty always comes with ON_CALL (consecutive overnight -- shift, same person, same calendar day). Accepting a swap that involves a -- PM schedule must also atomically transfer the companion on_call duty -- schedule to the new PM holder so the pairing stays intact. -- -- Handles BOTH swap directions: -- A) target_shift_id is PM → requester receives PM; transfer recipient's -- companion ON_CALL to requester. -- B) shift_id is PM → recipient receives PM; transfer requester's -- companion ON_CALL to recipient. -- -- Example (direction A — Normal user initiates): -- Before: User A owns Normal (08:00–17:00), User B owns PM (15:00–23:00) -- and ON_CALL (23:00–07:00) on the same calendar day. -- After accept: User A owns PM + ON_CALL, User B owns Normal. -- -- Example (direction B — PM user initiates): -- Before: User B owns PM + ON_CALL, User A owns Normal. -- After accept: same result — User A owns PM + ON_CALL, User B owns Normal. -- -- Supersedes 20260322180000_auto_reject_orphaned_swaps.sql. CREATE OR REPLACE FUNCTION public.respond_shift_swap(p_swap_id uuid, p_action text) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_swap RECORD; v_target_type text; v_target_date date; v_requester_type text; v_requester_date date; v_companion_id uuid; BEGIN SELECT * INTO v_swap FROM public.swap_requests WHERE id = p_swap_id; IF NOT FOUND THEN RAISE EXCEPTION 'swap request not found'; END IF; IF p_action NOT IN ('accepted', 'rejected', 'admin_review') THEN RAISE EXCEPTION 'invalid action'; END IF; -- Idempotency guard: already terminal → nothing to do. IF v_swap.status IN ('accepted', 'rejected') THEN RETURN; END IF; -- ── ACCEPTED ────────────────────────────────────────────────────────────── IF p_action = 'accepted' THEN -- Permission: recipient or admin/dispatcher IF NOT ( v_swap.recipient_id = auth.uid() OR EXISTS ( SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher') ) ) THEN RAISE EXCEPTION 'permission denied'; END IF; -- Ownership validation before swapping IF NOT EXISTS ( SELECT 1 FROM public.duty_schedules WHERE id = v_swap.shift_id AND user_id = v_swap.requester_id ) THEN RAISE EXCEPTION 'requester shift ownership changed, cannot accept swap'; END IF; IF v_swap.target_shift_id IS NULL THEN RAISE EXCEPTION 'target shift missing'; END IF; IF NOT EXISTS ( SELECT 1 FROM public.duty_schedules WHERE id = v_swap.target_shift_id AND user_id = v_swap.recipient_id ) THEN RAISE EXCEPTION 'target shift ownership changed, cannot accept swap'; END IF; -- Read both schedules' types and dates BEFORE the swap so we can decide -- companion direction independently of ownership changes. SELECT shift_type, DATE(start_time AT TIME ZONE 'Asia/Manila') INTO v_target_type, v_target_date FROM public.duty_schedules WHERE id = v_swap.target_shift_id; SELECT shift_type, DATE(start_time AT TIME ZONE 'Asia/Manila') INTO v_requester_type, v_requester_date FROM public.duty_schedules WHERE id = v_swap.shift_id; -- Primary swap: transfer both schedules atomically UPDATE public.duty_schedules SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id WHERE id = v_swap.shift_id; -- requester's schedule → recipient UPDATE public.duty_schedules SET user_id = v_swap.requester_id, swap_request_id = p_swap_id WHERE id = v_swap.target_shift_id; -- recipient's schedule → requester -- ── Companion ON_CALL transfer ──────────────────────────────────────── -- Direction A: target is PM → requester is the new PM holder. -- Find companion ON_CALL previously owned by the RECIPIENT and give it -- to the REQUESTER. IF v_target_type = 'pm' THEN SELECT id INTO v_companion_id FROM public.duty_schedules WHERE user_id = v_swap.recipient_id AND shift_type = 'on_call' AND DATE(start_time AT TIME ZONE 'Asia/Manila') = v_target_date ORDER BY start_time LIMIT 1; IF v_companion_id IS NOT NULL THEN UPDATE public.duty_schedules SET user_id = v_swap.requester_id, swap_request_id = p_swap_id WHERE id = v_companion_id; UPDATE public.swap_requests SET status = 'rejected', updated_at = now() WHERE id <> p_swap_id AND status IN ('pending', 'admin_review') AND ( shift_id = v_companion_id OR target_shift_id = v_companion_id ); END IF; -- Direction B: requester's schedule is PM → recipient is the new PM holder. -- Find companion ON_CALL previously owned by the REQUESTER and give it -- to the RECIPIENT. ELSIF v_requester_type = 'pm' THEN SELECT id INTO v_companion_id FROM public.duty_schedules WHERE user_id = v_swap.requester_id AND shift_type = 'on_call' AND DATE(start_time AT TIME ZONE 'Asia/Manila') = v_requester_date ORDER BY start_time LIMIT 1; IF v_companion_id IS NOT NULL THEN UPDATE public.duty_schedules SET user_id = v_swap.recipient_id, swap_request_id = p_swap_id WHERE id = v_companion_id; UPDATE public.swap_requests SET status = 'rejected', updated_at = now() WHERE id <> p_swap_id AND status IN ('pending', 'admin_review') AND ( shift_id = v_companion_id OR target_shift_id = v_companion_id ); END IF; END IF; -- ───────────────────────────────────────────────────────────────────── -- Accept the swap request UPDATE public.swap_requests SET status = 'accepted', updated_at = now() WHERE id = p_swap_id; -- Auto-reject all other pending swaps referencing either primary schedule UPDATE public.swap_requests SET status = 'rejected', updated_at = now() WHERE id <> p_swap_id AND status IN ('pending', 'admin_review') AND ( shift_id = v_swap.shift_id OR shift_id = v_swap.target_shift_id OR target_shift_id = v_swap.shift_id OR target_shift_id = v_swap.target_shift_id ); INSERT INTO public.swap_request_participants (swap_request_id, user_id, role) VALUES (p_swap_id, auth.uid(), 'approver') ON CONFLICT DO NOTHING; INSERT INTO public.notifications (user_id, actor_id, type, created_at) VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); -- ── REJECTED ────────────────────────────────────────────────────────────── ELSIF p_action = 'rejected' THEN IF NOT ( v_swap.recipient_id = auth.uid() OR EXISTS ( SELECT 1 FROM public.profiles p WHERE p.id = auth.uid() AND p.role IN ('admin', 'dispatcher') ) ) THEN RAISE EXCEPTION 'permission denied'; END IF; UPDATE public.swap_requests SET status = 'rejected', updated_at = now() WHERE id = p_swap_id; INSERT INTO public.swap_request_participants (swap_request_id, user_id, role) VALUES (p_swap_id, auth.uid(), 'approver') ON CONFLICT DO NOTHING; INSERT INTO public.notifications (user_id, actor_id, type, created_at) VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); -- ── ADMIN_REVIEW ────────────────────────────────────────────────────────── ELSE IF NOT (v_swap.requester_id = auth.uid()) THEN RAISE EXCEPTION 'permission denied'; END IF; UPDATE public.swap_requests SET status = 'admin_review', updated_at = now() WHERE id = p_swap_id; INSERT INTO public.notifications (user_id, actor_id, type, created_at) VALUES (v_swap.requester_id, auth.uid(), 'swap_update', now()); END IF; END; $$; -- Re-grant after SECURITY DEFINER replace GRANT EXECUTE ON FUNCTION public.respond_shift_swap(uuid, text) TO authenticated;