From bc064d004dec590745ce750308a161e13e4616d3 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 7 Aug 2026 12:52:18 +0000 Subject: [PATCH] Modified by www.SourceFiles.app --- .../20260807001300_attendance_expansion.sql | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 supabase/migrations/20260807001300_attendance_expansion.sql diff --git a/supabase/migrations/20260807001300_attendance_expansion.sql b/supabase/migrations/20260807001300_attendance_expansion.sql new file mode 100644 index 0000000..b7d16bb --- /dev/null +++ b/supabase/migrations/20260807001300_attendance_expansion.sql @@ -0,0 +1,311 @@ +-- Actual attendance, overrides, and the anticipated/actual split — +-- spec sections 16 and 17. +-- +-- Section 17 is the organizing rule: the anticipated schedule +-- (student_campus_schedules) and the actual record (attendance) are separate, +-- and a day's actual campus may differ from the schedule without altering it. +-- attendance.campus_id is therefore the *actual* campus for that day, defaulted +-- from the schedule but freely overridable. +-- +-- Section 16 requires every manual override to record the original value, the +-- new value, a reason, the user and the timestamp. That is enforced by a +-- trigger writing to attendance_overrides, not by trusting the client to send +-- an audit row. + +-- ============================================================================ +-- 1. ANTICIPATED SCHEDULE — expected times (section 17) +-- ============================================================================ + +ALTER TABLE public.student_campus_schedules + ADD COLUMN IF NOT EXISTS expected_arrival TIME, + ADD COLUMN IF NOT EXISTS expected_departure TIME; + +-- ============================================================================ +-- 2. ACTUAL ATTENDANCE +-- ============================================================================ + +ALTER TABLE public.attendance + ADD COLUMN IF NOT EXISTS campus_id UUID REFERENCES public.campuses(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS check_in_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS check_out_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS checked_in_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS checked_out_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + -- Snapshot of what was expected, so a later schedule edit cannot rewrite + -- whether a child was late on a day that has already passed. + ADD COLUMN IF NOT EXISTS expected_arrival TIME, + ADD COLUMN IF NOT EXISTS expected_departure TIME, + ADD COLUMN IF NOT EXISTS was_scheduled BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS early_arrival BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS late_arrival BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS early_pickup BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS late_pickup BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS absence_reason TEXT, + ADD COLUMN IF NOT EXISTS is_manual_override BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +CREATE INDEX IF NOT EXISTS attendance_campus_idx ON public.attendance (campus_id, date); +CREATE INDEX IF NOT EXISTS attendance_date_idx ON public.attendance (date); +CREATE INDEX IF NOT EXISTS attendance_checked_in_by_idx ON public.attendance (checked_in_by); +CREATE INDEX IF NOT EXISTS attendance_checked_out_by_idx ON public.attendance (checked_out_by); +-- Roll-call screen: everyone at one campus on one day. +CREATE INDEX IF NOT EXISTS attendance_rollcall_idx ON public.attendance (date, campus_id, status); + +DROP TRIGGER IF EXISTS trg_attendance_upd ON public.attendance; +CREATE TRIGGER trg_attendance_upd BEFORE UPDATE ON public.attendance + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- ============================================================================ +-- 3. OVERRIDE LOG +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.attendance_overrides ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + attendance_id UUID NOT NULL REFERENCES public.attendance(id) ON DELETE CASCADE, + student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, + + field TEXT NOT NULL, + original_value TEXT, + new_value TEXT, + reason TEXT, + + changed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + changed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Evidence: readable, insertable, never editable from the client. +GRANT SELECT, INSERT ON public.attendance_overrides TO authenticated; +GRANT ALL ON public.attendance_overrides TO service_role; +ALTER TABLE public.attendance_overrides ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS ao_attendance_idx ON public.attendance_overrides (attendance_id, changed_at DESC); +CREATE INDEX IF NOT EXISTS ao_student_idx ON public.attendance_overrides (student_id, changed_at DESC); +CREATE INDEX IF NOT EXISTS ao_changed_by_idx ON public.attendance_overrides (changed_by); + +-- The reason for an override is carried in on the row being updated rather than +-- passed as a function argument, because PostgREST updates go straight to the +-- table. Setting attendance.override_reason marks the edit; the trigger moves +-- it into the log and clears it. +ALTER TABLE public.attendance + ADD COLUMN IF NOT EXISTS override_reason TEXT; + +CREATE OR REPLACE FUNCTION public.log_attendance_override() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + reason TEXT := NULLIF(NEW.override_reason, ''); +BEGIN + IF NEW.status IS DISTINCT FROM OLD.status THEN + INSERT INTO public.attendance_overrides + (attendance_id, student_id, field, original_value, new_value, reason, changed_by) + VALUES (NEW.id, NEW.student_id, 'status', OLD.status::text, NEW.status::text, + reason, (SELECT auth.uid())); + NEW.is_manual_override := TRUE; + END IF; + + IF NEW.campus_id IS DISTINCT FROM OLD.campus_id THEN + INSERT INTO public.attendance_overrides + (attendance_id, student_id, field, original_value, new_value, reason, changed_by) + VALUES (NEW.id, NEW.student_id, 'campus_id', OLD.campus_id::text, NEW.campus_id::text, + reason, (SELECT auth.uid())); + NEW.is_manual_override := TRUE; + END IF; + + IF NEW.check_in_at IS DISTINCT FROM OLD.check_in_at THEN + INSERT INTO public.attendance_overrides + (attendance_id, student_id, field, original_value, new_value, reason, changed_by) + VALUES (NEW.id, NEW.student_id, 'check_in_at', OLD.check_in_at::text, NEW.check_in_at::text, + reason, (SELECT auth.uid())); + END IF; + + IF NEW.check_out_at IS DISTINCT FROM OLD.check_out_at THEN + INSERT INTO public.attendance_overrides + (attendance_id, student_id, field, original_value, new_value, reason, changed_by) + VALUES (NEW.id, NEW.student_id, 'check_out_at', OLD.check_out_at::text, NEW.check_out_at::text, + reason, (SELECT auth.uid())); + END IF; + + NEW.override_reason := NULL; -- consumed + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_attendance_override ON public.attendance; +CREATE TRIGGER trg_attendance_override BEFORE UPDATE ON public.attendance + FOR EACH ROW EXECUTE FUNCTION public.log_attendance_override(); + +-- ============================================================================ +-- 4. DERIVED FLAGS +-- ============================================================================ +-- Early/late is a comparison against the campus's published times, with the +-- schedule's per-student expected times taking precedence when set. Computed +-- server-side so two tablets cannot disagree about whether a child was late. + +CREATE OR REPLACE FUNCTION public.derive_attendance_flags() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +DECLARE + cam public.campuses%ROWTYPE; + sched RECORD; + exp_in TIME; + exp_out TIME; + local_in TIME; + local_out TIME; + tz TEXT; +BEGIN + -- Default the actual campus from the anticipated schedule for that date. + IF NEW.campus_id IS NULL THEN + SELECT sc.campus_id INTO NEW.campus_id + FROM public.student_campus_schedules sc + WHERE sc.student_id = NEW.student_id + AND sc.effective_start <= NEW.date + AND (sc.effective_end IS NULL OR sc.effective_end >= NEW.date) + ORDER BY CASE sc.assignment_type WHEN 'primary' THEN 0 ELSE 1 END + LIMIT 1; + END IF; + + IF NEW.campus_id IS NULL THEN + SELECT primary_campus_id INTO NEW.campus_id FROM public.students WHERE id = NEW.student_id; + END IF; + + SELECT * INTO cam FROM public.campuses WHERE id = NEW.campus_id; + tz := COALESCE(cam.timezone, 'America/New_York'); + + -- Was the student due in at all on this weekday? + SELECT sc.* INTO sched + FROM public.student_campus_schedules sc + WHERE sc.student_id = NEW.student_id + AND sc.campus_id = NEW.campus_id + AND sc.effective_start <= NEW.date + AND (sc.effective_end IS NULL OR sc.effective_end >= NEW.date) + LIMIT 1; + + IF sched IS NULL THEN + NEW.was_scheduled := FALSE; + ELSE + NEW.was_scheduled := CASE EXTRACT(ISODOW FROM NEW.date) + WHEN 1 THEN sched.monday WHEN 2 THEN sched.tuesday WHEN 3 THEN sched.wednesday + WHEN 4 THEN sched.thursday WHEN 5 THEN sched.friday WHEN 6 THEN sched.saturday + ELSE sched.sunday END; + END IF; + + exp_in := COALESCE(NEW.expected_arrival, sched.expected_arrival, cam.standard_start_time); + exp_out := COALESCE(NEW.expected_departure, sched.expected_departure, cam.standard_dismissal_time); + NEW.expected_arrival := exp_in; + NEW.expected_departure := exp_out; + + IF NEW.check_in_at IS NOT NULL AND exp_in IS NOT NULL THEN + local_in := (NEW.check_in_at AT TIME ZONE tz)::time; + NEW.early_arrival := local_in < exp_in; + NEW.late_arrival := local_in > exp_in; + END IF; + + IF NEW.check_out_at IS NOT NULL AND exp_out IS NOT NULL THEN + local_out := (NEW.check_out_at AT TIME ZONE tz)::time; + NEW.early_pickup := local_out < exp_out; + NEW.late_pickup := local_out > exp_out; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_attendance_flags ON public.attendance; +CREATE TRIGGER trg_attendance_flags BEFORE INSERT OR UPDATE ON public.attendance + FOR EACH ROW EXECUTE FUNCTION public.derive_attendance_flags(); + +-- ============================================================================ +-- 5. ROLL CALL +-- ============================================================================ +-- One call powering the teacher screen: every student expected at a campus on a +-- date, with photo, alert count, expected times, and whatever has been recorded +-- so far. Students not scheduled that day come back with was_scheduled = false +-- so the UI can grey them out while still allowing a manual override. + +CREATE OR REPLACE FUNCTION public.campus_roll_call(_campus UUID, _date DATE) +RETURNS TABLE ( + student_id UUID, + first_name TEXT, + last_name TEXT, + preferred_name TEXT, + photo_path TEXT, + class_name TEXT, + was_scheduled BOOLEAN, + expected_arrival TIME, + expected_departure TIME, + attendance_id UUID, + status public.attendance_status, + check_in_at TIMESTAMPTZ, + check_out_at TIMESTAMPTZ, + is_manual_override BOOLEAN, + early_arrival BOOLEAN, + late_arrival BOOLEAN, + early_pickup BOOLEAN, + late_pickup BOOLEAN, + alert_count BIGINT +) +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + WITH scheduled AS ( + SELECT + s.id AS student_id, + CASE EXTRACT(ISODOW FROM _date) + WHEN 1 THEN sc.monday WHEN 2 THEN sc.tuesday WHEN 3 THEN sc.wednesday + WHEN 4 THEN sc.thursday WHEN 5 THEN sc.friday WHEN 6 THEN sc.saturday + ELSE sc.sunday END AS due_today, + sc.expected_arrival, + sc.expected_departure + FROM public.students s + JOIN public.student_campus_schedules sc ON sc.student_id = s.id + WHERE sc.campus_id = _campus + AND sc.effective_start <= _date + AND (sc.effective_end IS NULL OR sc.effective_end >= _date) + AND s.enrollment_status = 'enrolled' + ) + SELECT + s.id, s.first_name, s.last_name, s.preferred_name, s.photo_path, + c.name, + COALESCE(sch.due_today, FALSE), + COALESCE(sch.expected_arrival, cam.standard_start_time), + COALESCE(sch.expected_departure, cam.standard_dismissal_time), + a.id, a.status, a.check_in_at, a.check_out_at, + COALESCE(a.is_manual_override, FALSE), + COALESCE(a.early_arrival, FALSE), COALESCE(a.late_arrival, FALSE), + COALESCE(a.early_pickup, FALSE), COALESCE(a.late_pickup, FALSE), + (SELECT COUNT(*) FROM public.student_alerts al + WHERE al.student_id = s.id AND al.is_active + AND al.effective_date <= _date + AND (al.expiration_date IS NULL OR al.expiration_date >= _date) + AND (public.is_org_admin() OR public.current_user_has_any_role(al.visible_to_roles))) + FROM public.students s + LEFT JOIN scheduled sch ON sch.student_id = s.id + LEFT JOIN public.attendance a ON a.student_id = s.id AND a.date = _date + LEFT JOIN public.classes c ON c.id = s.class_id + CROSS JOIN LATERAL (SELECT * FROM public.campuses WHERE id = _campus) cam + WHERE s.enrollment_status = 'enrolled' + AND (sch.student_id IS NOT NULL OR s.primary_campus_id = _campus) + AND public.can_access_student(s.id) + ORDER BY s.last_name, s.first_name +$$; + +-- ============================================================================ +-- 6. POLICIES +-- ============================================================================ + +DROP POLICY IF EXISTS "attendance overrides read" ON public.attendance_overrides; +CREATE POLICY "attendance overrides read" ON public.attendance_overrides FOR SELECT TO authenticated + USING (public.can_access_student(student_id) OR public.is_auditor()); + +DROP POLICY IF EXISTS "attendance overrides insert" ON public.attendance_overrides; +CREATE POLICY "attendance overrides insert" ON public.attendance_overrides FOR INSERT TO authenticated + WITH CHECK (public.can_access_student(student_id)); + +-- Widen attendance writes to campus staff. The original policy allowed the +-- student's class teacher and admins only; a front desk marking arrivals is not +-- necessarily either. +DROP POLICY IF EXISTS "attendance campus staff write" ON public.attendance; +CREATE POLICY "attendance campus staff write" ON public.attendance FOR ALL TO authenticated + USING ( + public.is_org_admin() + OR (campus_id IS NOT NULL AND campus_id IN (SELECT public.user_campus_ids())) + ) + WITH CHECK ( + public.is_org_admin() + OR (campus_id IS NOT NULL AND campus_id IN (SELECT public.user_campus_ids())) + );