This commit is contained in:
2026-08-22 23:11:03 +00:00
parent d4438812ba
commit 5306a0577b
@@ -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()))
);