Files
info-share-spot/supabase/migrations/20260807001500_ratios.sql
T
2026-08-07 12:56:26 +00:00

270 lines
11 KiB
PL/PgSQL

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