Terminal
This commit is contained in:
@@ -1,269 +0,0 @@
|
||||
-- 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));
|
||||
Reference in New Issue
Block a user