From 23169e1da6d1f29e58817ab0507d3c8865955964 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 22 Aug 2026 23:10:54 +0000 Subject: [PATCH] Terminal --- .../20260725120000_student_plans_504_iep.sql | 365 ------------------ 1 file changed, 365 deletions(-) delete mode 100644 supabase/migrations/20260725120000_student_plans_504_iep.sql diff --git a/supabase/migrations/20260725120000_student_plans_504_iep.sql b/supabase/migrations/20260725120000_student_plans_504_iep.sql deleted file mode 100644 index f35967a..0000000 --- a/supabase/migrations/20260725120000_student_plans_504_iep.sql +++ /dev/null @@ -1,365 +0,0 @@ --- 504 / IEP plans. --- --- Confidentiality is tiered, because special-education records are the most --- sensitive data in the portal and FERPA access is need-to-know: --- --- FULL tier — admin, the plan's case manager, and the student's parents. --- Sees everything: eligibility/disability category, evaluations, --- goals and progress data, meeting notes, signed documents. --- --- IMPL tier — the above, PLUS any teacher of the student's class. --- Sees only what they must implement: that a plan exists, its --- type/status/dates, the accommodations, and the service --- schedule. Never the disability category or meeting notes. --- --- The split is physical, not cosmetic: RLS is row-level, and every role here is --- the same Postgres role (`authenticated`), so column grants cannot separate --- these tiers. Confidential fields therefore live in their own tables --- (plan_details, plan_goals, plan_meetings, plan_documents) rather than as --- columns on student_plans. - --- ── Enums ─────────────────────────────────────────────────────────────────── -DO $$ BEGIN CREATE TYPE public.plan_type AS ENUM ('iep', 'section_504'); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -DO $$ BEGIN CREATE TYPE public.plan_status AS ENUM ('draft', 'active', 'archived'); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -DO $$ BEGIN CREATE TYPE public.plan_goal_status AS ENUM ('not_started', 'in_progress', 'met', 'not_met', 'discontinued'); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - -DO $$ BEGIN CREATE TYPE public.plan_meeting_type AS ENUM ('initial', 'annual_review', 'reevaluation', 'eligibility', 'amendment', 'other'); -EXCEPTION WHEN duplicate_object THEN NULL; END $$; - --- ── Core plan (IMPL tier) ─────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.student_plans ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, - plan_type plan_type NOT NULL, - status plan_status NOT NULL DEFAULT 'draft', - case_manager_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - effective_date DATE, - end_date DATE, - -- Compliance clocks. Annual review = yearly; reevaluation = triennial under - -- IDEA. Stored explicitly rather than derived, because districts amend them. - next_annual_review_date DATE, - next_reevaluation_date DATE, - created_by UUID REFERENCES auth.users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS idx_student_plans_student ON public.student_plans(student_id); -CREATE INDEX IF NOT EXISTS idx_student_plans_case_manager ON public.student_plans(case_manager_id); -CREATE INDEX IF NOT EXISTS idx_student_plans_review ON public.student_plans(next_annual_review_date); -CREATE INDEX IF NOT EXISTS idx_student_plans_reeval ON public.student_plans(next_reevaluation_date); --- A student may hold an IEP and a 504 historically, but only one active of each. -CREATE UNIQUE INDEX IF NOT EXISTS idx_student_plans_one_active - ON public.student_plans(student_id, plan_type) WHERE status = 'active'; -GRANT SELECT, INSERT, UPDATE, DELETE ON public.student_plans TO authenticated; -GRANT ALL ON public.student_plans TO service_role; -ALTER TABLE public.student_plans ENABLE ROW LEVEL SECURITY; - --- ── Access predicates ─────────────────────────────────────────────────────── --- SECURITY DEFINER so they can read student_plans without re-entering its RLS. -CREATE OR REPLACE FUNCTION public.can_view_plan_full(_plan UUID) -RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ - SELECT EXISTS ( - SELECT 1 FROM public.student_plans p - WHERE p.id = _plan AND ( - public.current_user_has_role('admin') - OR p.case_manager_id = auth.uid() - OR public.is_parent_of(p.student_id) - ) - ) -$$; - -CREATE OR REPLACE FUNCTION public.can_view_plan_impl(_plan UUID) -RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ - SELECT EXISTS ( - SELECT 1 FROM public.student_plans p - WHERE p.id = _plan AND ( - public.current_user_has_role('admin') - OR p.case_manager_id = auth.uid() - OR public.is_parent_of(p.student_id) - OR public.teaches_student(p.student_id) - ) - ) -$$; - --- Authoring a plan is admin or case manager only. Parents read, never write. -CREATE OR REPLACE FUNCTION public.can_edit_plan(_plan UUID) -RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ - SELECT EXISTS ( - SELECT 1 FROM public.student_plans p - WHERE p.id = _plan AND ( - public.current_user_has_role('admin') OR p.case_manager_id = auth.uid() - ) - ) -$$; - --- Predicates for goals and meetings are defined after those tables exist — --- LANGUAGE SQL bodies are parsed at CREATE time, so they cannot forward-reference. - --- ── Confidential detail (FULL tier), 1:1 with the plan ────────────────────── -CREATE TABLE IF NOT EXISTS public.plan_details ( - plan_id UUID PRIMARY KEY REFERENCES public.student_plans(id) ON DELETE CASCADE, - eligibility_category TEXT, -- IDEA disability category, or the 504 impairment - eligibility_notes TEXT, - last_evaluation_date DATE, - consent_date DATE, - evaluation_summary TEXT, - student_strengths TEXT, - parent_concerns TEXT, - confidential_notes TEXT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_details TO authenticated; -GRANT ALL ON public.plan_details TO service_role; -ALTER TABLE public.plan_details ENABLE ROW LEVEL SECURITY; - --- ── Accommodations (IMPL tier) ────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.plan_accommodations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - plan_id UUID NOT NULL REFERENCES public.student_plans(id) ON DELETE CASCADE, - category TEXT NOT NULL DEFAULT 'instructional', -- instructional | environmental | assessment | behavioral | other - description TEXT NOT NULL, - setting TEXT, - sort_order INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS idx_plan_accommodations_plan ON public.plan_accommodations(plan_id); -GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_accommodations TO authenticated; -GRANT ALL ON public.plan_accommodations TO service_role; -ALTER TABLE public.plan_accommodations ENABLE ROW LEVEL SECURITY; - --- ── Related services (IMPL tier — teachers need the pull-out schedule) ────── -CREATE TABLE IF NOT EXISTS public.plan_services ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - plan_id UUID NOT NULL REFERENCES public.student_plans(id) ON DELETE CASCADE, - service_type TEXT NOT NULL, -- speech, OT, PT, counseling, … - provider_name TEXT, - provider_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - minutes_per_session INT, - sessions_per_period INT, - period TEXT NOT NULL DEFAULT 'week', -- week | month - location TEXT, - delivery TEXT, -- push_in | pull_out - start_date DATE, - end_date DATE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS idx_plan_services_plan ON public.plan_services(plan_id); -GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_services TO authenticated; -GRANT ALL ON public.plan_services TO service_role; -ALTER TABLE public.plan_services ENABLE ROW LEVEL SECURITY; - --- ── Goals + progress monitoring (FULL tier) ───────────────────────────────── -CREATE TABLE IF NOT EXISTS public.plan_goals ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - plan_id UUID NOT NULL REFERENCES public.student_plans(id) ON DELETE CASCADE, - area TEXT, -- reading, math, behavior, speech, … - statement TEXT NOT NULL, - baseline TEXT, - target_criteria TEXT, - target_value NUMERIC, -- optional numeric target for charting - unit TEXT, -- %, wpm, trials, … - target_date DATE, - status plan_goal_status NOT NULL DEFAULT 'not_started', - sort_order INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS idx_plan_goals_plan ON public.plan_goals(plan_id); -GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_goals TO authenticated; -GRANT ALL ON public.plan_goals TO service_role; -ALTER TABLE public.plan_goals ENABLE ROW LEVEL SECURITY; - -CREATE TABLE IF NOT EXISTS public.plan_goal_progress ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - goal_id UUID NOT NULL REFERENCES public.plan_goals(id) ON DELETE CASCADE, - date DATE NOT NULL DEFAULT CURRENT_DATE, - value NUMERIC, - narrative TEXT, - recorded_by UUID REFERENCES auth.users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS idx_plan_goal_progress_goal ON public.plan_goal_progress(goal_id, date); -GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_goal_progress TO authenticated; -GRANT ALL ON public.plan_goal_progress TO service_role; -ALTER TABLE public.plan_goal_progress ENABLE ROW LEVEL SECURITY; - --- ── Meetings + team (FULL tier) ───────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.plan_meetings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - plan_id UUID NOT NULL REFERENCES public.student_plans(id) ON DELETE CASCADE, - meeting_type plan_meeting_type NOT NULL DEFAULT 'annual_review', - meeting_date DATE NOT NULL, - location TEXT, - notes TEXT, - outcome TEXT, - created_by UUID REFERENCES auth.users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS idx_plan_meetings_plan ON public.plan_meetings(plan_id, meeting_date); -GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_meetings TO authenticated; -GRANT ALL ON public.plan_meetings TO service_role; -ALTER TABLE public.plan_meetings ENABLE ROW LEVEL SECURITY; - -CREATE TABLE IF NOT EXISTS public.plan_meeting_attendees ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - meeting_id UUID NOT NULL REFERENCES public.plan_meetings(id) ON DELETE CASCADE, - user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - name TEXT NOT NULL, - team_role TEXT, -- parent, gen-ed teacher, sped teacher, LEA rep, … - attended BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS idx_plan_meeting_attendees_meeting ON public.plan_meeting_attendees(meeting_id); -GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_meeting_attendees TO authenticated; -GRANT ALL ON public.plan_meeting_attendees TO service_role; -ALTER TABLE public.plan_meeting_attendees ENABLE ROW LEVEL SECURITY; - --- ── Signed documents (FULL tier) ──────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS public.plan_documents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - plan_id UUID NOT NULL REFERENCES public.student_plans(id) ON DELETE CASCADE, - file_path TEXT NOT NULL, - title TEXT, - uploaded_by UUID REFERENCES auth.users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS idx_plan_documents_plan ON public.plan_documents(plan_id); -GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_documents TO authenticated; -GRANT ALL ON public.plan_documents TO service_role; -ALTER TABLE public.plan_documents ENABLE ROW LEVEL SECURITY; - --- ── Goal / meeting predicates (tables now exist) ──────────────────────────── -CREATE OR REPLACE FUNCTION public.can_edit_goal(_goal UUID) -RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ - SELECT EXISTS (SELECT 1 FROM public.plan_goals g WHERE g.id = _goal AND public.can_edit_plan(g.plan_id)) -$$; - -CREATE OR REPLACE FUNCTION public.can_view_goal(_goal UUID) -RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ - SELECT EXISTS (SELECT 1 FROM public.plan_goals g WHERE g.id = _goal AND public.can_view_plan_full(g.plan_id)) -$$; - -CREATE OR REPLACE FUNCTION public.can_edit_meeting(_meeting UUID) -RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ - SELECT EXISTS (SELECT 1 FROM public.plan_meetings m WHERE m.id = _meeting AND public.can_edit_plan(m.plan_id)) -$$; - -CREATE OR REPLACE FUNCTION public.can_view_meeting(_meeting UUID) -RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ - SELECT EXISTS (SELECT 1 FROM public.plan_meetings m WHERE m.id = _meeting AND public.can_view_plan_full(m.plan_id)) -$$; - --- ── Policies ──────────────────────────────────────────────────────────────── - --- student_plans: IMPL tier reads. Admin manages; case manager updates own. -CREATE POLICY "plans read" ON public.student_plans FOR SELECT TO authenticated - USING ( - public.current_user_has_role('admin') - OR case_manager_id = auth.uid() - OR public.is_parent_of(student_id) - OR public.teaches_student(student_id) - ); -CREATE POLICY "plans admin manage" ON public.student_plans FOR ALL TO authenticated - USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin')); -CREATE POLICY "plans case manager update" ON public.student_plans FOR UPDATE TO authenticated - USING (case_manager_id = auth.uid()) WITH CHECK (case_manager_id = auth.uid()); - --- plan_details: FULL tier only. -CREATE POLICY "plan_details read" ON public.plan_details FOR SELECT TO authenticated - USING (public.can_view_plan_full(plan_id)); -CREATE POLICY "plan_details write" ON public.plan_details FOR ALL TO authenticated - USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id)); - --- plan_accommodations: IMPL tier read, author write. -CREATE POLICY "plan_accommodations read" ON public.plan_accommodations FOR SELECT TO authenticated - USING (public.can_view_plan_impl(plan_id)); -CREATE POLICY "plan_accommodations write" ON public.plan_accommodations FOR ALL TO authenticated - USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id)); - --- plan_services: IMPL tier read, author write. -CREATE POLICY "plan_services read" ON public.plan_services FOR SELECT TO authenticated - USING (public.can_view_plan_impl(plan_id)); -CREATE POLICY "plan_services write" ON public.plan_services FOR ALL TO authenticated - USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id)); - --- plan_goals + progress: FULL tier only. -CREATE POLICY "plan_goals read" ON public.plan_goals FOR SELECT TO authenticated - USING (public.can_view_plan_full(plan_id)); -CREATE POLICY "plan_goals write" ON public.plan_goals FOR ALL TO authenticated - USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id)); - -CREATE POLICY "plan_goal_progress read" ON public.plan_goal_progress FOR SELECT TO authenticated - USING (public.can_view_goal(goal_id)); -CREATE POLICY "plan_goal_progress write" ON public.plan_goal_progress FOR ALL TO authenticated - USING (public.can_edit_goal(goal_id)) WITH CHECK (public.can_edit_goal(goal_id)); - --- plan_meetings + attendees: FULL tier only. -CREATE POLICY "plan_meetings read" ON public.plan_meetings FOR SELECT TO authenticated - USING (public.can_view_plan_full(plan_id)); -CREATE POLICY "plan_meetings write" ON public.plan_meetings FOR ALL TO authenticated - USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id)); - -CREATE POLICY "plan_meeting_attendees read" ON public.plan_meeting_attendees FOR SELECT TO authenticated - USING (public.can_view_meeting(meeting_id)); -CREATE POLICY "plan_meeting_attendees write" ON public.plan_meeting_attendees FOR ALL TO authenticated - USING (public.can_edit_meeting(meeting_id)) WITH CHECK (public.can_edit_meeting(meeting_id)); - --- plan_documents: FULL tier only. -CREATE POLICY "plan_documents read" ON public.plan_documents FOR SELECT TO authenticated - USING (public.can_view_plan_full(plan_id)); -CREATE POLICY "plan_documents write" ON public.plan_documents FOR ALL TO authenticated - USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id)); - --- ── Storage: signed plan documents, FULL tier ─────────────────────────────── --- Path convention "/", so folder[1] is the plan id. -INSERT INTO storage.buckets (id, name, public) -VALUES ('plan-documents', 'plan-documents', false) -ON CONFLICT (id) DO NOTHING; - -CREATE POLICY "plan docs read" ON storage.objects FOR SELECT TO authenticated - USING (bucket_id = 'plan-documents' AND public.can_view_plan_full(((storage.foldername(name))[1])::uuid)); -CREATE POLICY "plan docs write" ON storage.objects FOR INSERT TO authenticated - WITH CHECK (bucket_id = 'plan-documents' AND public.can_edit_plan(((storage.foldername(name))[1])::uuid)); -CREATE POLICY "plan docs update" ON storage.objects FOR UPDATE TO authenticated - USING (bucket_id = 'plan-documents' AND public.can_edit_plan(((storage.foldername(name))[1])::uuid)); -CREATE POLICY "plan docs delete" ON storage.objects FOR DELETE TO authenticated - USING (bucket_id = 'plan-documents' AND public.can_edit_plan(((storage.foldername(name))[1])::uuid)); - --- ── updated_at triggers ───────────────────────────────────────────────────── -DROP TRIGGER IF EXISTS trg_student_plans_upd ON public.student_plans; -CREATE TRIGGER trg_student_plans_upd BEFORE UPDATE ON public.student_plans - FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); - -DROP TRIGGER IF EXISTS trg_plan_details_upd ON public.plan_details; -CREATE TRIGGER trg_plan_details_upd BEFORE UPDATE ON public.plan_details - FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); - -DROP TRIGGER IF EXISTS trg_plan_goals_upd ON public.plan_goals; -CREATE TRIGGER trg_plan_goals_upd BEFORE UPDATE ON public.plan_goals - FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); - --- ── Lock the predicates to signed-in users ────────────────────────────────── --- Postgres grants EXECUTE to PUBLIC by default, which would expose these --- SECURITY DEFINER helpers as callable REST RPC endpoints to the anon role. --- They exist only to back RLS policies. -DO $$ -DECLARE fn TEXT; -BEGIN - FOREACH fn IN ARRAY ARRAY[ - 'public.can_view_plan_full(uuid)', - 'public.can_view_plan_impl(uuid)', - 'public.can_edit_plan(uuid)', - 'public.can_view_goal(uuid)', - 'public.can_edit_goal(uuid)', - 'public.can_view_meeting(uuid)', - 'public.can_edit_meeting(uuid)' - ] LOOP - EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC, anon', fn); - EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO authenticated, service_role', fn); - END LOOP; -END $$;