diff --git a/supabase/migrations/20260807001400_vacation.sql b/supabase/migrations/20260807001400_vacation.sql new file mode 100644 index 0000000..61c6e24 --- /dev/null +++ b/supabase/migrations/20260807001400_vacation.sql @@ -0,0 +1,300 @@ +-- Vacation balances and requests — spec section 18. +-- +-- "Full-time, year-round students receive four full weeks" is the default, not +-- a constant: section 18 requires the rules to be configurable, so the four +-- weeks live in vacation_policies and can be overridden per campus. +-- +-- "Prevents duplicate use" is enforced by an exclusion constraint rather than +-- application logic — two approved vacations for one student cannot overlap +-- even if two administrators approve simultaneously. + +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- ============================================================================ +-- 1. POLICIES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.vacation_policies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + -- NULL campus = organization-wide default. + campus_id UUID REFERENCES public.campuses(id) ON DELETE CASCADE, + + weeks_per_year NUMERIC(4,2) NOT NULL DEFAULT 4 CHECK (weeks_per_year >= 0), + + -- NULL on either means "applies to all". + applies_to_calendar_basis TEXT, + applies_to_attendance_basis TEXT, + + partial_weeks_allowed BOOLEAN NOT NULL DEFAULT FALSE, + -- Smallest bookable unit in days: 5 = whole weeks only, 1 = single days. + min_increment_days SMALLINT NOT NULL DEFAULT 5 CHECK (min_increment_days > 0), + + -- Whether tuition still accrues while a student is on vacation. + tuition_due_during_vacation BOOLEAN NOT NULL DEFAULT TRUE, + + -- Where the vacation year turns over. + reset_month SMALLINT NOT NULL DEFAULT 8 CHECK (reset_month BETWEEN 1 AND 12), + reset_day SMALLINT NOT NULL DEFAULT 1 CHECK (reset_day BETWEEN 1 AND 31), + + is_active BOOLEAN NOT NULL DEFAULT TRUE, + priority INTEGER NOT NULL DEFAULT 100, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT vp_calendar_valid + CHECK (applies_to_calendar_basis IS NULL + OR applies_to_calendar_basis IN ('year_round','school_year')), + CONSTRAINT vp_attendance_valid + CHECK (applies_to_attendance_basis IS NULL + OR applies_to_attendance_basis IN ('full_time','part_time')) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.vacation_policies TO authenticated; +GRANT ALL ON public.vacation_policies TO service_role; +ALTER TABLE public.vacation_policies ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS vp_campus_idx ON public.vacation_policies (campus_id); + +DROP TRIGGER IF EXISTS trg_vp_upd ON public.vacation_policies; +CREATE TRIGGER trg_vp_upd BEFORE UPDATE ON public.vacation_policies + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- The directive's stated default, as data. +INSERT INTO public.vacation_policies + (name, weeks_per_year, applies_to_calendar_basis, applies_to_attendance_basis, notes) +SELECT 'Full-time year-round — four weeks', 4, 'year_round', 'full_time', + 'Spec section 18 default. Edit rather than replace; students reference the resolved policy.' +WHERE NOT EXISTS ( + SELECT 1 FROM public.vacation_policies WHERE name = 'Full-time year-round — four weeks' +); + +-- Most specific active policy for a student. +CREATE OR REPLACE FUNCTION public.resolve_vacation_policy(_student UUID) +RETURNS public.vacation_policies +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT p.* + FROM public.vacation_policies p + JOIN public.students s ON s.id = _student + WHERE p.is_active + AND (p.campus_id IS NULL OR p.campus_id = s.primary_campus_id) + AND (p.applies_to_calendar_basis IS NULL OR p.applies_to_calendar_basis = s.calendar_basis) + AND (p.applies_to_attendance_basis IS NULL OR p.applies_to_attendance_basis = s.attendance_basis) + ORDER BY p.priority DESC, (p.campus_id IS NOT NULL) DESC + LIMIT 1 +$$; + +-- ============================================================================ +-- 2. REQUESTS +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.vacation_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, + policy_year INTEGER NOT NULL, + + start_date DATE NOT NULL, + end_date DATE NOT NULL, + -- Charged against the balance. Stored rather than derived so an authorized + -- partial-week approval can differ from the raw day count. + weeks_charged NUMERIC(4,2) NOT NULL DEFAULT 1 CHECK (weeks_charged >= 0), + + status TEXT NOT NULL DEFAULT 'pending', + -- Snapshot of the policy at approval, so a later policy edit cannot silently + -- change whether an already-taken vacation was billable. + tuition_due BOOLEAN NOT NULL DEFAULT TRUE, + + reason TEXT, + requested_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + approved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + approved_at TIMESTAMPTZ, + notes TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT vr_status_valid CHECK (status IN ('pending','approved','denied','cancelled')), + CONSTRAINT vr_dates_ordered CHECK (start_date <= end_date), + + -- No student may hold two approved vacations covering the same day. + CONSTRAINT vr_no_overlap EXCLUDE USING gist ( + student_id WITH =, + daterange(start_date, end_date, '[]') WITH && + ) WHERE (status = 'approved') +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.vacation_requests TO authenticated; +GRANT ALL ON public.vacation_requests TO service_role; +ALTER TABLE public.vacation_requests ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS vr_student_idx ON public.vacation_requests (student_id, start_date DESC); +CREATE INDEX IF NOT EXISTS vr_pending_idx ON public.vacation_requests (start_date) + WHERE status = 'pending'; + +DROP TRIGGER IF EXISTS trg_vr_upd ON public.vacation_requests; +CREATE TRIGGER trg_vr_upd BEFORE UPDATE ON public.vacation_requests + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- ============================================================================ +-- 3. BALANCES +-- ============================================================================ +-- weeks_used is maintained from approved requests rather than incremented by +-- the application, so a denied-then-reapproved request cannot double-count. + +CREATE TABLE IF NOT EXISTS public.vacation_balances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, + policy_year INTEGER NOT NULL, + policy_id UUID REFERENCES public.vacation_policies(id) ON DELETE SET NULL, + + weeks_allotted NUMERIC(4,2) NOT NULL DEFAULT 0, + weeks_used NUMERIC(4,2) NOT NULL DEFAULT 0, + weeks_remaining NUMERIC(5,2) GENERATED ALWAYS AS (weeks_allotted - weeks_used) STORED, + + -- Administrative override of the allotment, with its justification. + override_weeks NUMERIC(4,2), + override_reason TEXT, + overridden_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (student_id, policy_year) +); + +GRANT SELECT, INSERT, UPDATE ON public.vacation_balances TO authenticated; +GRANT ALL ON public.vacation_balances TO service_role; +ALTER TABLE public.vacation_balances ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS vb_student_idx ON public.vacation_balances (student_id); +CREATE INDEX IF NOT EXISTS vb_policy_idx ON public.vacation_balances (policy_id); + +-- Which vacation year a date belongs to, given the policy's reset point. +CREATE OR REPLACE FUNCTION public.vacation_year_for(_student UUID, _on DATE) +RETURNS INTEGER +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$ +DECLARE + p public.vacation_policies%ROWTYPE; + reset DATE; +BEGIN + SELECT * INTO p FROM public.resolve_vacation_policy(_student); + IF p.id IS NULL THEN RETURN EXTRACT(YEAR FROM _on)::int; END IF; + reset := make_date(EXTRACT(YEAR FROM _on)::int, p.reset_month, p.reset_day); + RETURN CASE WHEN _on >= reset + THEN EXTRACT(YEAR FROM _on)::int + ELSE EXTRACT(YEAR FROM _on)::int - 1 END; +END; +$$; + +CREATE OR REPLACE FUNCTION public.recalc_vacation_balance(_student UUID, _year INTEGER) +RETURNS VOID +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + p public.vacation_policies%ROWTYPE; + used NUMERIC; + bal public.vacation_balances%ROWTYPE; +BEGIN + SELECT * INTO p FROM public.resolve_vacation_policy(_student); + + SELECT COALESCE(SUM(weeks_charged), 0) INTO used + FROM public.vacation_requests + WHERE student_id = _student AND policy_year = _year AND status = 'approved'; + + SELECT * INTO bal FROM public.vacation_balances + WHERE student_id = _student AND policy_year = _year; + + INSERT INTO public.vacation_balances + (student_id, policy_year, policy_id, weeks_allotted, weeks_used) + VALUES (_student, _year, p.id, + COALESCE(bal.override_weeks, p.weeks_per_year, 0), used) + ON CONFLICT (student_id, policy_year) DO UPDATE + SET weeks_used = EXCLUDED.weeks_used, + weeks_allotted = COALESCE(public.vacation_balances.override_weeks, + EXCLUDED.weeks_allotted), + policy_id = EXCLUDED.policy_id, + updated_at = now(); +END; +$$; + +CREATE OR REPLACE FUNCTION public.sync_vacation_balance() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + PERFORM public.recalc_vacation_balance(OLD.student_id, OLD.policy_year); + RETURN OLD; + END IF; + + -- Stamp the year and the billability snapshot on the way in. + IF TG_OP = 'INSERT' AND NEW.policy_year IS NULL THEN + NEW.policy_year := public.vacation_year_for(NEW.student_id, NEW.start_date); + END IF; + + PERFORM public.recalc_vacation_balance(NEW.student_id, NEW.policy_year); + IF TG_OP = 'UPDATE' AND OLD.policy_year IS DISTINCT FROM NEW.policy_year THEN + PERFORM public.recalc_vacation_balance(NEW.student_id, OLD.policy_year); + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_vr_balance ON public.vacation_requests; +CREATE TRIGGER trg_vr_balance AFTER INSERT OR UPDATE OR DELETE ON public.vacation_requests + FOR EACH ROW EXECUTE FUNCTION public.sync_vacation_balance(); + +-- Default policy_year and tuition_due from the resolved policy at insert. +CREATE OR REPLACE FUNCTION public.default_vacation_request() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + p public.vacation_policies%ROWTYPE; +BEGIN + IF NEW.policy_year IS NULL THEN + NEW.policy_year := public.vacation_year_for(NEW.student_id, NEW.start_date); + END IF; + SELECT * INTO p FROM public.resolve_vacation_policy(NEW.student_id); + IF p.id IS NOT NULL AND TG_OP = 'INSERT' THEN + NEW.tuition_due := p.tuition_due_during_vacation; + IF NOT p.partial_weeks_allowed THEN + -- Round part-weeks up to whole weeks when the policy forbids partials. + NEW.weeks_charged := CEIL(NEW.weeks_charged); + END IF; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_vr_defaults ON public.vacation_requests; +CREATE TRIGGER trg_vr_defaults BEFORE INSERT ON public.vacation_requests + FOR EACH ROW EXECUTE FUNCTION public.default_vacation_request(); + +-- ============================================================================ +-- 4. POLICIES +-- ============================================================================ + +DROP POLICY IF EXISTS "vacation policies read" ON public.vacation_policies; +CREATE POLICY "vacation policies read" ON public.vacation_policies FOR SELECT TO authenticated + USING (TRUE); +DROP POLICY IF EXISTS "vacation policies manage" ON public.vacation_policies; +CREATE POLICY "vacation policies manage" ON public.vacation_policies FOR ALL TO authenticated + USING (public.is_org_admin()) WITH CHECK (public.is_org_admin()); + +DROP POLICY IF EXISTS "vacation requests read" ON public.vacation_requests; +CREATE POLICY "vacation requests read" ON public.vacation_requests FOR SELECT TO authenticated + USING (public.can_access_student(student_id) OR public.is_auditor()); + +-- Parents may request; only staff who can manage the student may approve, which +-- is why approval fields sit behind the manage policy below. +DROP POLICY IF EXISTS "vacation requests insert" ON public.vacation_requests; +CREATE POLICY "vacation requests insert" ON public.vacation_requests FOR INSERT TO authenticated + WITH CHECK (public.is_parent_of(student_id) OR public.can_manage_student(student_id)); + +DROP POLICY IF EXISTS "vacation requests manage" ON public.vacation_requests; +CREATE POLICY "vacation requests manage" ON public.vacation_requests FOR UPDATE TO authenticated + USING (public.can_manage_student(student_id)) WITH CHECK (public.can_manage_student(student_id)); + +DROP POLICY IF EXISTS "vacation requests delete" ON public.vacation_requests; +CREATE POLICY "vacation requests delete" ON public.vacation_requests FOR DELETE TO authenticated + USING (public.can_manage_student(student_id)); + +DROP POLICY IF EXISTS "vacation balances read" ON public.vacation_balances; +CREATE POLICY "vacation balances read" ON public.vacation_balances FOR SELECT TO authenticated + USING (public.can_access_student(student_id) OR public.is_auditor()); +DROP POLICY IF EXISTS "vacation balances manage" ON public.vacation_balances; +CREATE POLICY "vacation balances manage" ON public.vacation_balances FOR ALL TO authenticated + USING (public.can_manage_student(student_id)) WITH CHECK (public.can_manage_student(student_id));