From c187e00f386308455e333cb22ced9ec42a89ac96 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 7 Aug 2026 04:12:41 +0000 Subject: [PATCH] Modified by www.SourceFiles.app --- .../20260725120000_student_plans_504_iep.sql | 365 ++++++++++++++++++ 1 file changed, 365 insertions(+) create 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 new file mode 100644 index 0000000..f35967a --- /dev/null +++ b/supabase/migrations/20260725120000_student_plans_504_iep.sql @@ -0,0 +1,365 @@ +-- 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 $$;