-- 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));