270 lines
11 KiB
PL/PgSQL
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));
|