From 8b6be4b20ef1d30ec3897e254baf3a72a6fbe86c Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 7 Aug 2026 04:33:50 +0000 Subject: [PATCH] Modified by www.SourceFiles.app --- .../migrations/20260807000100_campuses.sql | 448 ++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 supabase/migrations/20260807000100_campuses.sql diff --git a/supabase/migrations/20260807000100_campuses.sql b/supabase/migrations/20260807000100_campuses.sql new file mode 100644 index 0000000..025b1bf --- /dev/null +++ b/supabase/migrations/20260807000100_campuses.sql @@ -0,0 +1,448 @@ +-- Campuses, campus-scoped access, and student campus assignment. +-- Spec sections 2 (campus structure), 3 (roles), and the campus/enrollment +-- portions of section 4. +-- +-- This is the foundation the rest of the build sits on: tuition, invoicing, +-- alerts, notes and compliance are all campus-scoped, so the access helpers +-- defined here (`is_org_admin`, `has_campus_access`, `can_access_student`, +-- `can_manage_student`) are the single authority every later migration reuses. + +-- ============================================================================ +-- 1. ROLE HELPERS +-- ============================================================================ +-- Every pre-existing policy is written as current_user_has_role('admin'). +-- Rather than rewrite ~40 policies across nine migrations (each an opportunity +-- to silently widen or drop access), the seniority rule is taught to the helper +-- itself: super_admin and org_admin now satisfy 'admin' everywhere at once. +-- +-- The implication is worth stating plainly: this grants the two new senior +-- roles the full reach that 'admin' already had on every existing table. +-- campus_admin deliberately does NOT satisfy 'admin' — it is campus-scoped and +-- gets its own policies below. +-- +-- auth.uid() is wrapped in a scalar subquery throughout so the planner +-- evaluates it once per query rather than once per row. + +CREATE OR REPLACE FUNCTION public.has_role(_user_id UUID, _role app_role) +RETURNS BOOLEAN +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.user_roles + WHERE user_id = _user_id + AND (role = _role OR (_role = 'admin' AND role IN ('org_admin', 'super_admin'))) + ) +$$; + +CREATE OR REPLACE FUNCTION public.current_user_has_role(_role app_role) +RETURNS BOOLEAN +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.user_roles + WHERE user_id = (SELECT auth.uid()) + AND (role = _role OR (_role = 'admin' AND role IN ('org_admin', 'super_admin'))) + ) +$$; + +-- Any of the listed roles. Avoids stacking OR'd calls in policy bodies. +CREATE OR REPLACE FUNCTION public.current_user_has_any_role(_roles app_role[]) +RETURNS BOOLEAN +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.user_roles + WHERE user_id = (SELECT auth.uid()) AND role = ANY(_roles) + ) +$$; + +CREATE OR REPLACE FUNCTION public.is_super_admin() +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ SELECT public.current_user_has_any_role(ARRAY['super_admin']::app_role[]) $$; + +-- Organization-wide administrative reach. The workhorse of most policies. +CREATE OR REPLACE FUNCTION public.is_org_admin() +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ SELECT public.current_user_has_any_role(ARRAY['admin','org_admin','super_admin']::app_role[]) $$; + +CREATE OR REPLACE FUNCTION public.is_billing_admin() +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ SELECT public.current_user_has_any_role(ARRAY['billing_admin','admin','org_admin','super_admin']::app_role[]) $$; + +CREATE OR REPLACE FUNCTION public.is_management() +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ SELECT public.current_user_has_any_role(ARRAY['management','admin','org_admin','super_admin']::app_role[]) $$; + +-- Read-only reviewer. Never paired with a write policy anywhere in this build. +CREATE OR REPLACE FUNCTION public.is_auditor() +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ SELECT public.current_user_has_any_role(ARRAY['auditor']::app_role[]) $$; + +-- ============================================================================ +-- 2. CAMPUSES +-- ============================================================================ +-- Addresses, hours and cutoffs are ordinary editable columns, per the spec's +-- instruction to keep them configurable rather than hard-coded. The seed at the +-- bottom therefore creates the three campuses by name only and leaves the +-- address and time fields NULL for an administrator to fill in. + +CREATE TABLE IF NOT EXISTS public.campuses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + code TEXT UNIQUE, + campus_type TEXT, + + physical_street1 TEXT, + physical_street2 TEXT, + physical_city TEXT, + physical_state TEXT, + physical_postal_code TEXT, + physical_country TEXT NOT NULL DEFAULT 'USA', + + -- NULL mailing address means "same as physical"; the app should not require + -- duplicate entry to express the common case. + mailing_street1 TEXT, + mailing_street2 TEXT, + mailing_city TEXT, + mailing_state TEXT, + mailing_postal_code TEXT, + mailing_country TEXT, + + phone TEXT, + email TEXT, + + -- Per-weekday open/close, e.g. {"monday":{"open":"06:30","close":"18:00"}}. + -- JSONB because campuses keep irregular schedules and a column-per-day + -- layout would need a migration every time one changes. + operating_hours JSONB NOT NULL DEFAULT '{}'::jsonb, + early_dropoff_time TIME, + standard_start_time TIME, + standard_dismissal_time TIME, + late_pickup_cutoff TIME, + + timezone TEXT NOT NULL DEFAULT 'America/New_York', + student_capacity INTEGER CHECK (student_capacity IS NULL OR student_capacity >= 0), + grade_levels_served TEXT[] NOT NULL DEFAULT '{}', + min_age_months INTEGER CHECK (min_age_months IS NULL OR min_age_months >= 0), + max_age_months INTEGER CHECK (max_age_months IS NULL OR max_age_months >= 0), + + is_active BOOLEAN NOT NULL DEFAULT TRUE, + notes TEXT, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT campuses_age_range_ordered + CHECK (min_age_months IS NULL OR max_age_months IS NULL OR min_age_months <= max_age_months) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.campuses TO authenticated; +GRANT ALL ON public.campuses TO service_role; +ALTER TABLE public.campuses ENABLE ROW LEVEL SECURITY; + +CREATE INDEX IF NOT EXISTS campuses_active_idx ON public.campuses (is_active) WHERE is_active; + +DROP TRIGGER IF EXISTS trg_campuses_upd ON public.campuses; +CREATE TRIGGER trg_campuses_upd BEFORE UPDATE ON public.campuses + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- Programs offered at a campus (spec: "supported programs"). +CREATE TABLE IF NOT EXISTS public.campus_programs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + campus_id UUID NOT NULL REFERENCES public.campuses(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + min_age_months INTEGER, + max_age_months INTEGER, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (campus_id, name) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.campus_programs TO authenticated; +GRANT ALL ON public.campus_programs TO service_role; +ALTER TABLE public.campus_programs ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS campus_programs_campus_idx ON public.campus_programs (campus_id); + +-- ============================================================================ +-- 3. STAFF ↔ CAMPUS ASSIGNMENT +-- ============================================================================ +-- Drives campus_admin / teacher / staff scoping. A row here is what makes a +-- campus_admin an administrator *of that campus*. + +CREATE TABLE IF NOT EXISTS public.staff_campus_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + campus_id UUID NOT NULL REFERENCES public.campuses(id) ON DELETE CASCADE, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + title TEXT, + start_date DATE, + end_date DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, campus_id), + CONSTRAINT staff_campus_dates_ordered + CHECK (start_date IS NULL OR end_date IS NULL OR start_date <= end_date) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.staff_campus_assignments TO authenticated; +GRANT ALL ON public.staff_campus_assignments TO service_role; +ALTER TABLE public.staff_campus_assignments ENABLE ROW LEVEL SECURITY; +CREATE INDEX IF NOT EXISTS staff_campus_user_idx ON public.staff_campus_assignments (user_id); +CREATE INDEX IF NOT EXISTS staff_campus_campus_idx ON public.staff_campus_assignments (campus_id); + +-- Campuses the caller is currently assigned to. Expired assignments (end_date +-- in the past) do not count. +CREATE OR REPLACE FUNCTION public.user_campus_ids() +RETURNS SETOF UUID +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT campus_id FROM public.staff_campus_assignments + WHERE user_id = (SELECT auth.uid()) + AND (start_date IS NULL OR start_date <= CURRENT_DATE) + AND (end_date IS NULL OR end_date >= CURRENT_DATE) +$$; + +-- Org admins reach every campus; everyone else only their assignments. +CREATE OR REPLACE FUNCTION public.has_campus_access(_campus UUID) +RETURNS BOOLEAN +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT public.is_org_admin() + OR (_campus IS NOT NULL AND _campus IN (SELECT public.user_campus_ids())) +$$; + +-- ============================================================================ +-- 4. STUDENT ENROLLMENT + CAMPUS FIELDS +-- ============================================================================ +-- Existing first_name / last_name serve as the legal names; the spec's legal +-- middle name and preferred name are added alongside. + +ALTER TABLE public.students + ADD COLUMN IF NOT EXISTS middle_name TEXT, + ADD COLUMN IF NOT EXISTS preferred_name TEXT, + ADD COLUMN IF NOT EXISTS primary_campus_id UUID REFERENCES public.campuses(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS program_id UUID REFERENCES public.campus_programs(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS enrollment_status TEXT NOT NULL DEFAULT 'prospective', + ADD COLUMN IF NOT EXISTS enrollment_start_date DATE, + ADD COLUMN IF NOT EXISTS enrollment_end_date DATE, + ADD COLUMN IF NOT EXISTS attendance_basis TEXT NOT NULL DEFAULT 'full_time', + ADD COLUMN IF NOT EXISTS calendar_basis TEXT NOT NULL DEFAULT 'school_year', + ADD COLUMN IF NOT EXISTS is_support_student BOOLEAN NOT NULL DEFAULT FALSE; + +DO $$ BEGIN + ALTER TABLE public.students ADD CONSTRAINT students_enrollment_status_valid + CHECK (enrollment_status IN ('prospective','enrolled','waitlisted','withdrawn','graduated','on_hold')); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + ALTER TABLE public.students ADD CONSTRAINT students_attendance_basis_valid + CHECK (attendance_basis IN ('full_time','part_time')); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + ALTER TABLE public.students ADD CONSTRAINT students_calendar_basis_valid + CHECK (calendar_basis IN ('year_round','school_year')); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + ALTER TABLE public.students ADD CONSTRAINT students_enrollment_dates_ordered + CHECK (enrollment_start_date IS NULL OR enrollment_end_date IS NULL + OR enrollment_start_date <= enrollment_end_date); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE INDEX IF NOT EXISTS students_primary_campus_idx ON public.students (primary_campus_id); +CREATE INDEX IF NOT EXISTS students_program_idx ON public.students (program_id); +CREATE INDEX IF NOT EXISTS students_enrollment_status_idx ON public.students (enrollment_status); + +-- ============================================================================ +-- 5. WEEKLY CAMPUS SCHEDULE +-- ============================================================================ +-- One row per (student, campus, effective period). Modelling it this way is +-- what lets a student be full-time overall while part-time at two campuses in +-- the same billing week — the case the spec calls out explicitly — and the +-- effective_start/effective_end pair doubles as the student's campus history. +-- +-- Seven booleans rather than a day-of-week child table: it maps directly onto +-- the checkbox grid the spec asks for, and the billing engine needs a cheap +-- per-week day count, which the generated column provides. + +CREATE TABLE IF NOT EXISTS public.student_campus_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, + campus_id UUID NOT NULL REFERENCES public.campuses(id) ON DELETE CASCADE, + assignment_type TEXT NOT NULL DEFAULT 'primary', + + effective_start DATE NOT NULL DEFAULT CURRENT_DATE, + effective_end DATE, + + monday BOOLEAN NOT NULL DEFAULT FALSE, + tuesday BOOLEAN NOT NULL DEFAULT FALSE, + wednesday BOOLEAN NOT NULL DEFAULT FALSE, + thursday BOOLEAN NOT NULL DEFAULT FALSE, + friday BOOLEAN NOT NULL DEFAULT FALSE, + saturday BOOLEAN NOT NULL DEFAULT FALSE, + sunday BOOLEAN NOT NULL DEFAULT FALSE, + + scheduled_days_per_week SMALLINT GENERATED ALWAYS AS ( + (monday::int + tuesday::int + wednesday::int + thursday::int + + friday::int + saturday::int + sunday::int)::smallint + ) STORED, + + early_dropoff BOOLEAN NOT NULL DEFAULT FALSE, + late_pickup BOOLEAN NOT NULL DEFAULT FALSE, + notes TEXT, + + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT scs_assignment_type_valid + CHECK (assignment_type IN ('primary','additional','temporary')), + CONSTRAINT scs_dates_ordered + CHECK (effective_end IS NULL OR effective_start <= effective_end) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.student_campus_schedules TO authenticated; +GRANT ALL ON public.student_campus_schedules TO service_role; +ALTER TABLE public.student_campus_schedules ENABLE ROW LEVEL SECURITY; + +CREATE INDEX IF NOT EXISTS scs_student_idx ON public.student_campus_schedules (student_id); +CREATE INDEX IF NOT EXISTS scs_campus_idx ON public.student_campus_schedules (campus_id); +CREATE INDEX IF NOT EXISTS scs_effective_idx + ON public.student_campus_schedules (student_id, effective_start, effective_end); + +DROP TRIGGER IF EXISTS trg_scs_upd ON public.student_campus_schedules; +CREATE TRIGGER trg_scs_upd BEFORE UPDATE ON public.student_campus_schedules + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- Campuses a student is attached to over a date range: the primary campus plus +-- any schedule row overlapping the range. Used by access checks and billing. +CREATE OR REPLACE FUNCTION public.student_campus_ids(_student UUID, _from DATE DEFAULT NULL, _to DATE DEFAULT NULL) +RETURNS SETOF UUID +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT s.primary_campus_id + FROM public.students s + WHERE s.id = _student AND s.primary_campus_id IS NOT NULL + UNION + SELECT sc.campus_id + FROM public.student_campus_schedules sc + WHERE sc.student_id = _student + AND (_to IS NULL OR sc.effective_start <= _to) + AND (_from IS NULL OR sc.effective_end IS NULL OR sc.effective_end >= _from) +$$; + +-- ============================================================================ +-- 6. CENTRAL STUDENT ACCESS CHECKS +-- ============================================================================ +-- Every later migration routes student-scoped RLS through these two functions, +-- so the rules live in exactly one place. +-- +-- Read: org admins, campus admins/staff/teachers at any campus the student +-- attends, the student's teacher, the student's parents, and auditors. +-- Write: org admins and campus admins of the student's campuses only — +-- deliberately excluding teachers, staff, parents and auditors. + +CREATE OR REPLACE FUNCTION public.can_access_student(_student UUID) +RETURNS BOOLEAN +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT public.is_org_admin() + OR public.is_auditor() + OR public.is_parent_of(_student) + OR public.teaches_student(_student) + OR EXISTS ( + SELECT 1 FROM public.student_campus_ids(_student) c + WHERE c IN (SELECT public.user_campus_ids()) + ) +$$; + +CREATE OR REPLACE FUNCTION public.can_manage_student(_student UUID) +RETURNS BOOLEAN +LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public +AS $$ + SELECT public.is_org_admin() + OR ( + public.current_user_has_any_role(ARRAY['campus_admin']::app_role[]) + AND EXISTS ( + SELECT 1 FROM public.student_campus_ids(_student) c + WHERE c IN (SELECT public.user_campus_ids()) + ) + ) +$$; + +-- ============================================================================ +-- 7. POLICIES +-- ============================================================================ + +-- Campuses are reference data: any authenticated user may read them (parents +-- need campus names and hours). Only org admins may create or delete; campus +-- admins may edit the campuses they are assigned to. +DROP POLICY IF EXISTS "campuses read" ON public.campuses; +CREATE POLICY "campuses read" ON public.campuses FOR SELECT TO authenticated USING (TRUE); + +DROP POLICY IF EXISTS "campuses org admin manage" ON public.campuses; +CREATE POLICY "campuses org admin manage" ON public.campuses FOR ALL TO authenticated + USING (public.is_org_admin()) WITH CHECK (public.is_org_admin()); + +DROP POLICY IF EXISTS "campuses campus admin update" ON public.campuses; +CREATE POLICY "campuses campus admin update" ON public.campuses FOR UPDATE TO authenticated + USING (public.current_user_has_any_role(ARRAY['campus_admin']::app_role[]) + AND id IN (SELECT public.user_campus_ids())) + WITH CHECK (public.current_user_has_any_role(ARRAY['campus_admin']::app_role[]) + AND id IN (SELECT public.user_campus_ids())); + +DROP POLICY IF EXISTS "campus_programs read" ON public.campus_programs; +CREATE POLICY "campus_programs read" ON public.campus_programs FOR SELECT TO authenticated USING (TRUE); + +DROP POLICY IF EXISTS "campus_programs manage" ON public.campus_programs; +CREATE POLICY "campus_programs manage" ON public.campus_programs FOR ALL TO authenticated + USING (public.has_campus_access(campus_id)) WITH CHECK (public.has_campus_access(campus_id)); + +-- Staff assignments: a user always sees their own; org admins manage all; +-- campus admins manage assignments at their own campuses. +DROP POLICY IF EXISTS "staff assignments read" ON public.staff_campus_assignments; +CREATE POLICY "staff assignments read" ON public.staff_campus_assignments FOR SELECT TO authenticated + USING (user_id = (SELECT auth.uid()) OR public.has_campus_access(campus_id) OR public.is_auditor()); + +DROP POLICY IF EXISTS "staff assignments manage" ON public.staff_campus_assignments; +CREATE POLICY "staff assignments manage" ON public.staff_campus_assignments FOR ALL TO authenticated + USING (public.is_org_admin() + OR (public.current_user_has_any_role(ARRAY['campus_admin']::app_role[]) + AND campus_id IN (SELECT public.user_campus_ids()))) + WITH CHECK (public.is_org_admin() + OR (public.current_user_has_any_role(ARRAY['campus_admin']::app_role[]) + AND campus_id IN (SELECT public.user_campus_ids()))); + +DROP POLICY IF EXISTS "scs read" ON public.student_campus_schedules; +CREATE POLICY "scs read" ON public.student_campus_schedules FOR SELECT TO authenticated + USING (public.can_access_student(student_id)); + +DROP POLICY IF EXISTS "scs manage" ON public.student_campus_schedules; +CREATE POLICY "scs manage" ON public.student_campus_schedules FOR ALL TO authenticated + USING (public.can_manage_student(student_id)) WITH CHECK (public.can_manage_student(student_id)); + +-- Widen the existing student policies for the campus-scoped roles. These are +-- additive: permissive policies OR together, so the original admin/parent/ +-- teacher policies from the first migration keep working untouched. +DROP POLICY IF EXISTS "students campus scoped read" ON public.students; +CREATE POLICY "students campus scoped read" ON public.students FOR SELECT TO authenticated + USING (public.can_access_student(id)); + +DROP POLICY IF EXISTS "students campus admin manage" ON public.students; +CREATE POLICY "students campus admin manage" ON public.students FOR ALL TO authenticated + USING (public.can_manage_student(id)) WITH CHECK (public.can_manage_student(id)); + +-- ============================================================================ +-- 8. SEED +-- ============================================================================ +-- Names only. Addresses, phone numbers and bell times are left NULL on purpose: +-- the spec requires them to be administrator-configurable, and inventing +-- plausible-looking addresses would be worse than an obviously empty field. + +INSERT INTO public.campuses (name, code, campus_type, is_active) +VALUES + ('Meadowlane', 'MDL', 'school', TRUE), + ('Enterprise', 'ENT', 'school', TRUE), + ('Suntree', 'SUN', 'school', TRUE) +ON CONFLICT (name) DO NOTHING;