-- 71-class-management.sql -- Foundational: capture the (previously untracked) class-management schema and harden its RLS. -- -- Background: `classes`, `class_teachers`, `class_students`, `enrollment_requests` existed only on -- live dev (.94), created out-of-band, with NO tracked DDL. Their schema/FKs/uniques are sound, -- but RLS exposed `class_students` / `class_teachers` to service_role ONLY — so any API path that -- calls Supabase AS THE USER (the correct, RLS-enforced pattern) reads ZERO rows. This migration: -- 1. captures the real schema (idempotent; no-op on environments that already have it), -- 2. adds SECURITY DEFINER membership helpers (avoid RLS recursion in policies), -- 3. adds as-user RLS policies so teachers/admins/students can read rosters under RLS, -- while keeping the existing service_role policies intact. -- Verified against live .94 schema 2026-06-06 (all FKs/uniques/checks already present there). --========================================================================================== -- 1. Tables (idempotent capture for fresh environments; skipped where they already exist) --========================================================================================== create table if not exists public.classes ( id uuid primary key default gen_random_uuid(), institute_id uuid not null references public.institutes(id) on delete cascade, name varchar not null, class_code text, subject varchar, key_stage text, year_group varchar, academic_year varchar, description text, type varchar not null default 'standard', is_active boolean not null default true, created_by uuid not null references public.profiles(id), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists public.class_teachers ( id uuid primary key default gen_random_uuid(), class_id uuid not null references public.classes(id) on delete cascade, teacher_id uuid not null references public.profiles(id) on delete cascade, is_primary boolean not null default false, can_edit boolean not null default true, assigned_at timestamptz not null default now(), assigned_by uuid references public.profiles(id), constraint class_teachers_class_id_teacher_id_key unique (class_id, teacher_id) ); create table if not exists public.class_students ( id uuid primary key default gen_random_uuid(), class_id uuid not null references public.classes(id) on delete cascade, student_id uuid not null references public.profiles(id) on delete cascade, status varchar not null default 'active' check (status::text = any (array['active','inactive','pending'])), enrolled_at timestamptz not null default now(), enrolled_by uuid references public.profiles(id), constraint class_students_class_id_student_id_key unique (class_id, student_id) ); create table if not exists public.enrollment_requests ( id uuid primary key default gen_random_uuid(), class_id uuid not null references public.classes(id) on delete cascade, student_id uuid not null references public.profiles(id) on delete cascade, status varchar not null default 'pending' check (status::text = any (array['pending','approved','rejected'])), request_message text, requested_at timestamptz not null default now(), responded_at timestamptz, responded_by uuid references public.profiles(id), response_message text ); create index if not exists idx_classes_institute on public.classes(institute_id); create index if not exists idx_classes_created_by on public.classes(created_by); create index if not exists idx_classes_class_code on public.classes(class_code); create index if not exists idx_class_teachers_class on public.class_teachers(class_id); create index if not exists idx_class_students_class on public.class_students(class_id); --========================================================================================== -- 2. SECURITY DEFINER membership helpers -- Run as owner (bypass RLS on the inner tables) → no policy recursion when referenced below. --========================================================================================== create or replace function public.user_institute_ids() returns setof uuid language sql stable security definer set search_path = public as $$ select institute_id from public.institute_memberships where profile_id = auth.uid() $$; create or replace function public.is_class_teacher(p_class uuid) returns boolean language sql stable security definer set search_path = public as $$ select exists (select 1 from public.class_teachers where class_id = p_class and teacher_id = auth.uid()) $$; create or replace function public.is_class_admin(p_class uuid) returns boolean language sql stable security definer set search_path = public as $$ select exists ( select 1 from public.classes c join public.institute_memberships m on m.institute_id = c.institute_id where c.id = p_class and m.profile_id = auth.uid() and m.role in ('school_admin','department_head')) $$; create or replace function public.is_institute_member_of_class(p_class uuid) returns boolean language sql stable security definer set search_path = public as $$ select exists ( select 1 from public.classes c join public.institute_memberships m on m.institute_id = c.institute_id where c.id = p_class and m.profile_id = auth.uid()) $$; --========================================================================================== -- 3. RLS — enable + (re)declare every policy so this file is the source of truth. -- Existing service_role / institute_read / er_own policies are preserved verbatim; -- the cs_read/cs_write/ct_read/ct_write policies are the NEW as-user grants. --========================================================================================== alter table public.classes enable row level security; alter table public.class_teachers enable row level security; alter table public.class_students enable row level security; alter table public.enrollment_requests enable row level security; -- classes --------------------------------------------------------------------------------- drop policy if exists classes_service_role on public.classes; create policy classes_service_role on public.classes using (auth.role() = 'service_role'); drop policy if exists classes_institute_read on public.classes; create policy classes_institute_read on public.classes for select to authenticated using (institute_id in (select public.user_institute_ids())); -- NEW: teachers/admins of a class can update it; admins can insert/delete within their institute drop policy if exists classes_admin_write on public.classes; create policy classes_admin_write on public.classes for all to authenticated using (institute_id in (select public.user_institute_ids()) and (public.is_class_admin(id) or public.is_class_teacher(id))) with check (institute_id in (select public.user_institute_ids())); -- class_teachers -------------------------------------------------------------------------- drop policy if exists ct_service_role on public.class_teachers; create policy ct_service_role on public.class_teachers using (auth.role() = 'service_role'); -- NEW: institute members can see who teaches a class; the teacher can see their own rows drop policy if exists ct_read on public.class_teachers; create policy ct_read on public.class_teachers for select to authenticated using (teacher_id = auth.uid() or public.is_institute_member_of_class(class_id)); -- NEW: only school admins assign/unassign teachers drop policy if exists ct_write on public.class_teachers; create policy ct_write on public.class_teachers for all to authenticated using (public.is_class_admin(class_id)) with check (public.is_class_admin(class_id)); -- class_students -------------------------------------------------------------------------- drop policy if exists cs_service_role on public.class_students; create policy cs_service_role on public.class_students using (auth.role() = 'service_role'); -- NEW: a student sees their own enrolment; teachers/admins of the class see the roster drop policy if exists cs_read on public.class_students; create policy cs_read on public.class_students for select to authenticated using (student_id = auth.uid() or public.is_class_teacher(class_id) or public.is_class_admin(class_id)); -- NEW: teachers (can_edit) and admins of the class manage enrolments drop policy if exists cs_write on public.class_students; create policy cs_write on public.class_students for all to authenticated using (public.is_class_teacher(class_id) or public.is_class_admin(class_id)) with check (public.is_class_teacher(class_id) or public.is_class_admin(class_id)); -- enrollment_requests --------------------------------------------------------------------- drop policy if exists er_service_role on public.enrollment_requests; create policy er_service_role on public.enrollment_requests using (auth.role() = 'service_role'); drop policy if exists er_own on public.enrollment_requests; create policy er_own on public.enrollment_requests for all to authenticated using (student_id = auth.uid()) with check (student_id = auth.uid()); -- NEW: teachers/admins of the class can read + respond to requests for their class drop policy if exists er_class_staff on public.enrollment_requests; create policy er_class_staff on public.enrollment_requests for all to authenticated using (public.is_class_teacher(class_id) or public.is_class_admin(class_id)) with check (public.is_class_teacher(class_id) or public.is_class_admin(class_id));