diff --git a/supabase/migrations/20260807001500_ratios.sql b/supabase/migrations/20260807001500_ratios.sql new file mode 100644 index 0000000..714c5c6 --- /dev/null +++ b/supabase/migrations/20260807001500_ratios.sql @@ -0,0 +1,269 @@ +-- Student-to-staff ratios — spec section 20. +-- +-- Ratios are a licensing matter and vary by program, age and campus, so the +-- required numbers are rows, never constants in code. No default rule is +-- seeded: an invented ratio that looks authoritative is worse than an empty +-- table, because staff would trust it. +-- +-- Staff presence needs its own record. Student attendance exists; nothing +-- tracked whether an adult was in the building, which is half of every ratio. + +-- ============================================================================ +-- 1. STAFF ATTENDANCE +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.staff_attendance ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + campus_id UUID NOT NULL REFERENCES public.campuses(id) ON DELETE CASCADE, + date DATE NOT NULL, + + status TEXT NOT NULL DEFAULT 'present', + check_in_at TIMESTAMPTZ, + check_out_at TIMESTAMPTZ, + -- Counts toward ratio: a site administrator may be present but not + -- ratio-bearing, depending on licensing. + counts_toward_ratio BOOLEAN NOT NULL DEFAULT TRUE, + classroom_id UUID REFERENCES public.classes(id) ON DELETE SET NULL, + + note TEXT, + recorded_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE (user_id, campus_id, date), + CONSTRAINT sa_status_valid CHECK (status IN ('present','absent','late','leave','training')) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.staff_attendance TO authenticated; +GRANT ALL ON public.staff_attendance TO service_role; +ALTER TABLE public.staff_attendance ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS sta_campus_date_idx ON public.staff_attendance (campus_id, date); +CREATE INDEX IF NOT EXISTS sta_user_idx ON public.staff_attendance (user_id, date DESC); +CREATE INDEX IF NOT EXISTS sta_classroom_idx ON public.staff_attendance (classroom_id); +CREATE INDEX IF NOT EXISTS sta_recorded_by_idx ON public.staff_attendance (recorded_by); + +DROP TRIGGER IF EXISTS trg_staff_att_upd ON public.staff_attendance; +CREATE TRIGGER trg_staff_att_upd BEFORE UPDATE ON public.staff_attendance + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- ============================================================================ +-- 2. RATIO RULES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.ratio_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + + campus_id UUID REFERENCES public.campuses(id) ON DELETE CASCADE, + program_id UUID REFERENCES public.campus_programs(id) ON DELETE CASCADE, + classroom_id UUID REFERENCES public.classes(id) ON DELETE CASCADE, + + -- Age band the rule governs, in months. NULL bounds are open-ended. + min_age_months INTEGER, + max_age_months INTEGER, + grade_level TEXT, + + -- The licensing number: how many students one qualifying adult may supervise. + students_per_staff NUMERIC(5,2) NOT NULL CHECK (students_per_staff > 0), + max_group_size INTEGER, + + licensing_reference TEXT, + effective_start DATE NOT NULL DEFAULT CURRENT_DATE, + effective_end DATE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + priority INTEGER NOT NULL DEFAULT 100, + notes TEXT, + + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT rr_ages_ordered + CHECK (min_age_months IS NULL OR max_age_months IS NULL OR min_age_months <= max_age_months), + CONSTRAINT rr_dates_ordered + CHECK (effective_end IS NULL OR effective_start <= effective_end) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.ratio_rules TO authenticated; +GRANT ALL ON public.ratio_rules TO service_role; +ALTER TABLE public.ratio_rules ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS rr_campus_idx ON public.ratio_rules (campus_id); +CREATE INDEX IF NOT EXISTS rr_program_idx ON public.ratio_rules (program_id); +CREATE INDEX IF NOT EXISTS rr_classroom_idx ON public.ratio_rules (classroom_id); +CREATE INDEX IF NOT EXISTS rr_active_idx ON public.ratio_rules (effective_start, priority DESC) + WHERE is_active; + +DROP TRIGGER IF EXISTS trg_rr_upd ON public.ratio_rules; +CREATE TRIGGER trg_rr_upd BEFORE UPDATE ON public.ratio_rules + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- Strictest rule wins where several match: understaffing is the failure mode +-- worth guarding against, so the lowest students_per_staff breaks ties. +CREATE OR REPLACE FUNCTION public.resolve_ratio_rule( + _campus UUID, _on DATE, _age_months INTEGER DEFAULT NULL, _classroom UUID DEFAULT NULL +) +RETURNS public.ratio_rules +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT r.* + FROM public.ratio_rules r + WHERE r.is_active + AND r.effective_start <= _on + AND (r.effective_end IS NULL OR r.effective_end >= _on) + AND (r.campus_id IS NULL OR r.campus_id = _campus) + AND (r.classroom_id IS NULL OR r.classroom_id = _classroom) + AND (_age_months IS NULL OR r.min_age_months IS NULL OR _age_months >= r.min_age_months) + AND (_age_months IS NULL OR r.max_age_months IS NULL OR _age_months <= r.max_age_months) + ORDER BY r.priority DESC, r.students_per_staff ASC + LIMIT 1 +$$; + +-- ============================================================================ +-- 3. RATIO CALCULATION +-- ============================================================================ +-- Reports scheduled and actual counts side by side, because a campus can be +-- compliant on paper and short-staffed in practice. + +CREATE OR REPLACE FUNCTION public.campus_ratio(_campus UUID, _date DATE) +RETURNS TABLE ( + campus_id UUID, + campus_name TEXT, + scheduled_students BIGINT, + present_students BIGINT, + scheduled_staff BIGINT, + present_staff BIGINT, + required_students_per_staff NUMERIC, + actual_students_per_staff NUMERIC, + staff_required NUMERIC, + is_compliant BOOLEAN, + rule_id UUID +) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$ +DECLARE + rule public.ratio_rules%ROWTYPE; + sched_students BIGINT; + pres_students BIGINT; + sched_staff BIGINT; + pres_staff BIGINT; + cname TEXT; +BEGIN + SELECT name INTO cname FROM public.campuses WHERE id = _campus; + + SELECT COUNT(*) INTO sched_students + FROM public.campus_roll_call(_campus, _date) rc + WHERE rc.was_scheduled; + + SELECT COUNT(*) INTO pres_students + FROM public.attendance a + WHERE a.date = _date AND a.campus_id = _campus AND a.status IN ('present','late'); + + SELECT COUNT(*) INTO sched_staff + FROM public.staff_campus_assignments sca + WHERE sca.campus_id = _campus + AND (sca.start_date IS NULL OR sca.start_date <= _date) + AND (sca.end_date IS NULL OR sca.end_date >= _date); + + SELECT COUNT(*) INTO pres_staff + FROM public.staff_attendance sa + WHERE sa.campus_id = _campus AND sa.date = _date + AND sa.status IN ('present','late') AND sa.counts_toward_ratio; + + SELECT * INTO rule FROM public.resolve_ratio_rule(_campus, _date); + + RETURN QUERY SELECT + _campus, + cname, + sched_students, + pres_students, + sched_staff, + pres_staff, + rule.students_per_staff, + CASE WHEN pres_staff > 0 THEN ROUND(pres_students::numeric / pres_staff, 2) END, + CASE WHEN rule.students_per_staff IS NOT NULL + THEN CEIL(pres_students::numeric / rule.students_per_staff) END, + -- No rule configured means "unknown", not "compliant". + CASE WHEN rule.students_per_staff IS NULL THEN NULL + WHEN pres_students = 0 THEN TRUE + WHEN pres_staff = 0 THEN FALSE + ELSE (pres_students::numeric / pres_staff) <= rule.students_per_staff END, + rule.id; +END; +$$; + +-- ============================================================================ +-- 4. SNAPSHOTS +-- ============================================================================ +-- Section 20 asks for "times when the ratio was exceeded", which needs a record +-- taken through the day, not a figure computed on demand after the fact. + +CREATE TABLE IF NOT EXISTS public.ratio_snapshots ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + campus_id UUID NOT NULL REFERENCES public.campuses(id) ON DELETE CASCADE, + taken_at TIMESTAMPTZ NOT NULL DEFAULT now(), + date DATE NOT NULL DEFAULT CURRENT_DATE, + + present_students INTEGER NOT NULL, + present_staff INTEGER NOT NULL, + required_students_per_staff NUMERIC(5,2), + actual_students_per_staff NUMERIC(6,2), + is_compliant BOOLEAN, + rule_id UUID REFERENCES public.ratio_rules(id) ON DELETE SET NULL, + note TEXT +); + +GRANT SELECT, INSERT ON public.ratio_snapshots TO authenticated; +GRANT ALL ON public.ratio_snapshots TO service_role; +ALTER TABLE public.ratio_snapshots ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS rs_campus_idx ON public.ratio_snapshots (campus_id, taken_at DESC); +CREATE INDEX IF NOT EXISTS rs_rule_idx ON public.ratio_snapshots (rule_id); +-- Compliance review looks for the breaches, not the quiet hours. +CREATE INDEX IF NOT EXISTS rs_breach_idx ON public.ratio_snapshots (campus_id, date) + WHERE is_compliant IS FALSE; + +CREATE OR REPLACE FUNCTION public.take_ratio_snapshot(_campus UUID) +RETURNS BIGINT +LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + r RECORD; + new_id BIGINT; +BEGIN + SELECT * INTO r FROM public.campus_ratio(_campus, CURRENT_DATE); + INSERT INTO public.ratio_snapshots + (campus_id, present_students, present_staff, required_students_per_staff, + actual_students_per_staff, is_compliant, rule_id) + VALUES (_campus, r.present_students, r.present_staff, r.required_students_per_staff, + r.actual_students_per_staff, r.is_compliant, r.rule_id) + RETURNING id INTO new_id; + RETURN new_id; +END; +$$; + +-- ============================================================================ +-- 5. POLICIES +-- ============================================================================ + +DROP POLICY IF EXISTS "staff attendance read" ON public.staff_attendance; +CREATE POLICY "staff attendance read" ON public.staff_attendance FOR SELECT TO authenticated + USING ( + user_id = (SELECT auth.uid()) + OR public.has_campus_access(campus_id) + OR public.is_management() OR public.is_auditor() + ); + +DROP POLICY IF EXISTS "staff attendance write" ON public.staff_attendance; +CREATE POLICY "staff attendance write" ON public.staff_attendance FOR ALL TO authenticated + USING (public.has_campus_access(campus_id)) WITH CHECK (public.has_campus_access(campus_id)); + +DROP POLICY IF EXISTS "ratio rules read" ON public.ratio_rules; +CREATE POLICY "ratio rules read" ON public.ratio_rules FOR SELECT TO authenticated USING (TRUE); +DROP POLICY IF EXISTS "ratio rules manage" ON public.ratio_rules; +CREATE POLICY "ratio rules manage" ON public.ratio_rules FOR ALL TO authenticated + USING (public.is_org_admin()) WITH CHECK (public.is_org_admin()); + +DROP POLICY IF EXISTS "ratio snapshots read" ON public.ratio_snapshots; +CREATE POLICY "ratio snapshots read" ON public.ratio_snapshots FOR SELECT TO authenticated + USING (public.has_campus_access(campus_id) OR public.is_auditor()); +DROP POLICY IF EXISTS "ratio snapshots insert" ON public.ratio_snapshots; +CREATE POLICY "ratio snapshots insert" ON public.ratio_snapshots FOR INSERT TO authenticated + WITH CHECK (public.has_campus_access(campus_id));