From 4938da0ea8ab9ddd073edfb85f8d5a4b3b749abb Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 22 Aug 2026 23:10:56 +0000 Subject: [PATCH] Terminal --- .../migrations/20260807000100_campuses.sql | 448 ------------------ 1 file changed, 448 deletions(-) delete mode 100644 supabase/migrations/20260807000100_campuses.sql diff --git a/supabase/migrations/20260807000100_campuses.sql b/supabase/migrations/20260807000100_campuses.sql deleted file mode 100644 index 025b1bf..0000000 --- a/supabase/migrations/20260807000100_campuses.sql +++ /dev/null @@ -1,448 +0,0 @@ --- 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;