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