Files
info-share-spot/supabase/migrations/20260725120000_student_plans_504_iep.sql
T
adminandClaude Opus 5 9d7d18669d Add 504 / IEP plans with tiered confidentiality
Plans, accommodations, related services, annual goals with progress
monitoring, meetings/team, and signed documents — plus a compliance
list and dashboard alerts for annual-review and triennial re-evaluation
dates (overdue in red, due-within-30-days in amber).

Access is tiered because special-education records are need-to-know
under FERPA:

  FULL  admin, the plan's case manager, the student's parents
  IMPL  the above, plus any teacher of the student — accommodations
        and services only, never eligibility or meeting notes

RLS is row-level and every app role is the same Postgres role
(`authenticated`), so column grants cannot separate the tiers. The
split is therefore physical: confidential fields live in plan_details,
plan_goals, plan_meetings and plan_documents rather than as columns on
student_plans.

The UI asks the database which tier applies via the same predicates the
policies use (can_view_plan_full / can_edit_plan) instead of re-deriving
the rules client-side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:14:40 -04:00

366 lines
20 KiB
PL/PgSQL

-- 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 "<plan_id>/<filename>", 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 $$;