diff --git a/supabase/migrations/20260719204512_gradebook.sql b/supabase/migrations/20260719204512_gradebook.sql new file mode 100644 index 0000000..386b3bf --- /dev/null +++ b/supabase/migrations/20260719204512_gradebook.sql @@ -0,0 +1,90 @@ +-- Gradebook: weighted-category grading, per-class assignments & grades, student role. +-- (Applied to the remote via the Supabase MCP; kept here as the source of record.) + +ALTER TYPE public.app_role ADD VALUE IF NOT EXISTS 'student'; + +ALTER TABLE public.students ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_students_user ON public.students(user_id); + +CREATE OR REPLACE FUNCTION public.teaches_class(_class UUID) +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT EXISTS (SELECT 1 FROM public.classes c WHERE c.id = _class AND c.teacher_id = auth.uid()) +$$; +CREATE OR REPLACE FUNCTION public.is_student_account(_student UUID) +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT EXISTS (SELECT 1 FROM public.students s WHERE s.id = _student AND s.user_id = auth.uid()) +$$; + +CREATE TABLE IF NOT EXISTS public.grade_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID REFERENCES public.classes(id) ON DELETE CASCADE, + name TEXT NOT NULL, weight NUMERIC NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.grade_categories ENABLE ROW LEVEL SECURITY; + +CREATE TABLE IF NOT EXISTS public.grade_scale ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID REFERENCES public.classes(id) ON DELETE CASCADE, + letter TEXT NOT NULL, min_percent NUMERIC NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.grade_scale ENABLE ROW LEVEL SECURITY; + +CREATE TABLE IF NOT EXISTS public.assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES public.classes(id) ON DELETE CASCADE, + category_id UUID REFERENCES public.grade_categories(id) ON DELETE SET NULL, + name TEXT NOT NULL, max_points NUMERIC NOT NULL DEFAULT 100, date DATE, + created_by UUID REFERENCES auth.users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_assignments_class ON public.assignments(class_id); +ALTER TABLE public.assignments ENABLE ROW LEVEL SECURITY; + +CREATE TABLE IF NOT EXISTS public.grades ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id UUID NOT NULL REFERENCES public.assignments(id) ON DELETE CASCADE, + student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, + points NUMERIC, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (assignment_id, student_id) +); +CREATE INDEX IF NOT EXISTS idx_grades_student ON public.grades(student_id); +ALTER TABLE public.grades ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "grade_categories read" ON public.grade_categories FOR SELECT TO authenticated USING (TRUE); +CREATE POLICY "grade_categories admin" ON public.grade_categories FOR ALL TO authenticated + USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin')); +CREATE POLICY "grade_categories teacher" ON public.grade_categories FOR ALL TO authenticated + USING (class_id IS NOT NULL AND public.teaches_class(class_id)) WITH CHECK (class_id IS NOT NULL AND public.teaches_class(class_id)); + +CREATE POLICY "grade_scale read" ON public.grade_scale FOR SELECT TO authenticated USING (TRUE); +CREATE POLICY "grade_scale admin" ON public.grade_scale FOR ALL TO authenticated + USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin')); +CREATE POLICY "grade_scale teacher" ON public.grade_scale FOR ALL TO authenticated + USING (class_id IS NOT NULL AND public.teaches_class(class_id)) WITH CHECK (class_id IS NOT NULL AND public.teaches_class(class_id)); + +CREATE POLICY "assignments read" ON public.assignments FOR SELECT TO authenticated USING ( + public.current_user_has_role('admin') OR public.teaches_class(class_id) + OR EXISTS (SELECT 1 FROM public.students s WHERE s.class_id = assignments.class_id AND (public.is_parent_of(s.id) OR public.is_student_account(s.id))) +); +CREATE POLICY "assignments write" ON public.assignments FOR ALL TO authenticated + USING (public.current_user_has_role('admin') OR public.teaches_class(class_id)) + WITH CHECK (public.current_user_has_role('admin') OR public.teaches_class(class_id)); + +CREATE POLICY "grades read" ON public.grades FOR SELECT TO authenticated USING ( + public.current_user_has_role('admin') OR public.is_parent_of(student_id) OR public.is_student_account(student_id) + OR EXISTS (SELECT 1 FROM public.assignments a WHERE a.id = grades.assignment_id AND public.teaches_class(a.class_id)) +); +CREATE POLICY "grades write" ON public.grades FOR ALL TO authenticated + USING (public.current_user_has_role('admin') OR EXISTS (SELECT 1 FROM public.assignments a WHERE a.id = grades.assignment_id AND public.teaches_class(a.class_id))) + WITH CHECK (public.current_user_has_role('admin') OR EXISTS (SELECT 1 FROM public.assignments a WHERE a.id = grades.assignment_id AND public.teaches_class(a.class_id))); + +INSERT INTO public.grade_categories (class_id, name, weight, sort_order) +SELECT v.class_id, v.name, v.weight, v.sort_order +FROM (VALUES (NULL::UUID, 'Homework', 20::NUMERIC, 0), (NULL, 'Quizzes', 30, 1), (NULL, 'Tests', 50, 2)) AS v(class_id, name, weight, sort_order) +WHERE NOT EXISTS (SELECT 1 FROM public.grade_categories WHERE class_id IS NULL); + +INSERT INTO public.grade_scale (class_id, letter, min_percent) +SELECT v.class_id, v.letter, v.min_percent +FROM (VALUES (NULL::UUID, 'A', 90::NUMERIC), (NULL, 'B', 80), (NULL, 'C', 70), (NULL, 'D', 60), (NULL, 'F', 0)) AS v(class_id, letter, min_percent) +WHERE NOT EXISTS (SELECT 1 FROM public.grade_scale WHERE class_id IS NULL);