diff --git a/supabase/migrations/20260807001900_vacation_entitlement_guard.sql b/supabase/migrations/20260807001900_vacation_entitlement_guard.sql new file mode 100644 index 0000000..cca0278 --- /dev/null +++ b/supabase/migrations/20260807001900_vacation_entitlement_guard.sql @@ -0,0 +1,95 @@ +-- Vacation is for full calendar year students only — spec section 18, refined. +-- +-- The seeded policy grants four weeks to year-round full-time students and to +-- nobody else. That is deliberate: school-year students and part-time +-- year-round students carry no vacation entitlement. +-- +-- Nothing enforced it, though. resolve_vacation_policy() simply returns no row +-- for those students, recalc_vacation_balance() then writes weeks_allotted = 0, +-- and an approved request still counted against it — driving weeks_remaining +-- negative and reporting a balance the student never had. This adds the missing +-- guard at the only point that matters: the moment a request becomes approved. +-- +-- The escape hatch is vacation_balances.override_weeks. An administrator who +-- genuinely wants to grant vacation to a student outside the policy sets an +-- override on that student's balance row and the approval then succeeds, which +-- keeps the exception deliberate, attributable and visible in reporting rather +-- than silent. + +-- ============================================================================ +-- 1. ENTITLEMENT +-- ============================================================================ + +-- The weeks a student is actually entitled to for a policy year: an explicit +-- override if one has been set, otherwise the resolved policy, otherwise none. +-- This mirrors the COALESCE that recalc_vacation_balance() already applies, so +-- the guard and the balance can never disagree about what a student is owed. +CREATE OR REPLACE FUNCTION public.vacation_entitlement_weeks(_student UUID, _year INTEGER) +RETURNS NUMERIC +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$ +DECLARE + p public.vacation_policies%ROWTYPE; + override NUMERIC; +BEGIN + SELECT vb.override_weeks INTO override + FROM public.vacation_balances vb + WHERE vb.student_id = _student AND vb.policy_year = _year; + + IF override IS NOT NULL THEN + RETURN override; + END IF; + + SELECT * INTO p FROM public.resolve_vacation_policy(_student); + RETURN COALESCE(p.weeks_per_year, 0); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.vacation_entitlement_weeks(UUID, INTEGER) TO authenticated; + +-- ============================================================================ +-- 2. THE GUARD +-- ============================================================================ + +-- Fires only on the transition into 'approved'. Requests may still be created, +-- edited, rejected and cancelled freely for any student — it is consuming +-- entitlement that requires having some. +CREATE OR REPLACE FUNCTION public.enforce_vacation_entitlement() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + yr INTEGER; + weeks NUMERIC; + student TEXT; +BEGIN + IF NEW.status <> 'approved' THEN + RETURN NEW; + END IF; + + -- Only the moment of approval is checked, so re-saving an already-approved + -- request (or a later edit to it) does not fail after the fact. + IF TG_OP = 'UPDATE' AND OLD.status = 'approved' THEN + RETURN NEW; + END IF; + + yr := COALESCE(NEW.policy_year, public.vacation_year_for(NEW.student_id, NEW.start_date)); + weeks := public.vacation_entitlement_weeks(NEW.student_id, yr); + + IF weeks > 0 THEN + RETURN NEW; + END IF; + + SELECT s.first_name || ' ' || s.last_name INTO student + FROM public.students s WHERE s.id = NEW.student_id; + + RAISE EXCEPTION + 'Vacation cannot be approved for % — no vacation entitlement for % (vacation applies to full calendar year, full-time students). To grant an exception, set override_weeks on that student''s % vacation balance first.', + COALESCE(student, NEW.student_id::text), yr, yr + USING ERRCODE = 'check_violation'; +END; +$$; + +-- Named to sort after trg_vr_defaults, which populates policy_year on insert: +-- triggers sharing a timing and event fire in alphabetical order, and this one +-- reads the value that one sets. +DROP TRIGGER IF EXISTS trg_vr_entitlement ON public.vacation_requests; +CREATE TRIGGER trg_vr_entitlement BEFORE INSERT OR UPDATE ON public.vacation_requests + FOR EACH ROW EXECUTE FUNCTION public.enforce_vacation_entitlement();