diff --git a/supabase/migrations/20260807001300_attendance_expansion.sql b/supabase/migrations/20260807001300_attendance_expansion.sql deleted file mode 100644 index b7d16bb..0000000 --- a/supabase/migrations/20260807001300_attendance_expansion.sql +++ /dev/null @@ -1,311 +0,0 @@ --- 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())) - );