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