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