-- 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;