From fcab68f57a3e5e2da66c17b953797874fa5dd34f Mon Sep 17 00:00:00 2001 From: kcar Date: Sat, 6 Jun 2026 14:43:29 +0000 Subject: [PATCH 1/2] feat(db): track class-management schema + add as-user RLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classes/class_teachers/class_students/enrollment_requests tables existed only on live dev (.94) with no tracked DDL, and RLS exposed class_students / class_teachers to service_role ONLY — so any API path calling Supabase as the user read zero rows. - 71-class-management.sql captures the real schema (idempotent), adds SECURITY DEFINER membership helpers, and adds as-user RLS policies (cs_read/cs_write, ct_read/ct_write, classes_admin_write, er_class_staff) while preserving the existing service_role / institute_read / er_own policies. Applied + verified on dev .94: class teacher sees roster (1), unrelated teacher denied (0), service_role unaffected (full). FKs/uniques/checks already present on .94 (no constraint changes needed). Co-Authored-By: Claude Opus 4.8 --- volumes/db/cc/71-class-management.sql | 184 ++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 volumes/db/cc/71-class-management.sql diff --git a/volumes/db/cc/71-class-management.sql b/volumes/db/cc/71-class-management.sql new file mode 100644 index 0000000..196ea50 --- /dev/null +++ b/volumes/db/cc/71-class-management.sql @@ -0,0 +1,184 @@ +-- 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)); From 10314ddd62fc027ffb969037d14c76389219b530 Mon Sep 17 00:00:00 2001 From: kcar Date: Sat, 6 Jun 2026 16:11:52 +0000 Subject: [PATCH 2/2] feat(db): exam-marker operational tables + RLS (72-exam-marker.sql) Adds the 7 Supabase tables (exam_templates, exam_questions, exam_response_areas, exam_boundaries, marking_batches, student_submissions, mark_entries) with FKs, indexes, updated_at triggers (reusing handle_updated_at), and inline RLS. Authorization owned by this layer (exam API calls as-user): per-table service_role passthrough + as-user policies scoped via user_institute_ids() (from 71); marks readable by the owning teacher's batch and by the student themselves (UI deferred). marking_batches.class_id FKs to public.classes (71). Applied + verified on dev .94: 7 tables, RLS on, class_id FK valid, teacher can insert+read a template under RLS. Stacked on feat/class-management-foundation. Co-Authored-By: Claude Opus 4.8 --- volumes/db/cc/72-exam-marker.sql | 238 +++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 volumes/db/cc/72-exam-marker.sql diff --git a/volumes/db/cc/72-exam-marker.sql b/volumes/db/cc/72-exam-marker.sql new file mode 100644 index 0000000..6905c96 --- /dev/null +++ b/volumes/db/cc/72-exam-marker.sql @@ -0,0 +1,238 @@ +-- 72-exam-marker.sql +-- Exam-marker operational tables (Supabase = source of truth for geometry/marks/submissions). +-- Neo4j cc.public.exams holds the knowledge graph; joined by shared UUIDs (see spec §2). +-- +-- Authorization is owned by this layer: the exam API calls Supabase AS THE USER, so these RLS +-- policies are enforced (service_role policies cover the Neo4j-projection / seed paths only). +-- Depends on: 71-class-management.sql (marking_batches.class_id → classes; user_institute_ids()). + +--========================================================================================== +-- 1. Tables +--========================================================================================== + +create table if not exists public.exam_templates ( + id uuid primary key default gen_random_uuid(), + exam_id uuid references public.eb_exams(id) on delete set null, -- null for ad-hoc upload + exam_code text, -- denormalised → Neo4j join + institute_id uuid not null references public.institutes(id) on delete cascade, + teacher_id uuid not null references public.profiles(id) on delete cascade, + title text not null, + subject text, + source_file_id uuid references public.files(id) on delete set null, -- uploaded PDF (R2.2) + page_count int, + status text not null default 'draft' check (status in ('draft','ready','archived')), + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.exam_questions ( + id uuid primary key default gen_random_uuid(), + template_id uuid not null references public.exam_templates(id) on delete cascade, + parent_id uuid references public.exam_questions(id) on delete cascade, + label text not null, + "order" int not null default 0, + max_marks numeric not null default 0, + answer_type text check (answer_type in ('written','mcq','short','diagram')), + mcq_options jsonb, + mark_scheme jsonb not null default '{}'::jsonb, -- MarkScheme union from exam-marker types.ts + is_container boolean not null default false, -- true → Neo4j Question, false → Part + spec_ref text, -- manual spec-point tag → ASSESSES (R3.5.3) + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.exam_response_areas ( + id uuid primary key default gen_random_uuid(), -- == Neo4j Region.uuid_string + question_id uuid not null references public.exam_questions(id) on delete cascade, + template_id uuid not null references public.exam_templates(id) on delete cascade, -- RLS denorm + page int not null, + bounds jsonb not null, -- {x,y,w,h} + kind text not null check (kind in ('response','context')), + response_form text check (response_form in + ('lines','answer-box','working','diagram','tick-boxes','table','blanks')), + source text not null default 'manual' check (source in ('manual','ai')), + confirmed boolean not null default true, + confidence numeric, + created_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.exam_boundaries ( + id uuid primary key default gen_random_uuid(), + template_id uuid not null references public.exam_templates(id) on delete cascade, + question_id uuid references public.exam_questions(id) on delete set null, + label text, + page_index int not null, + y numeric not null, + bounds jsonb, + source text not null default 'manual' check (source in ('manual','ai')), + confirmed boolean not null default true, + created_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.marking_batches ( + id uuid primary key default gen_random_uuid(), + template_id uuid not null references public.exam_templates(id) on delete cascade, + class_id uuid references public.classes(id) on delete set null, -- roster via class_students + institute_id uuid not null references public.institutes(id) on delete cascade, + teacher_id uuid not null references public.profiles(id) on delete cascade, -- batch owner (R2.4) + title text, + status text not null default 'open' check (status in ('open','marking','complete','archived')), + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.student_submissions ( + id uuid primary key default gen_random_uuid(), + batch_id uuid not null references public.marking_batches(id) on delete cascade, + student_id uuid references public.profiles(id) on delete set null, -- null until matched + student_name text, + scan_file_id uuid references public.files(id) on delete set null, + scan_url text, + qr_code text, + matching_method text check (matching_method in ('ordered','ocr_name','qr_code','manual')), + match_confidence numeric, + page_start int, + page_count int, + status text not null default 'unmatched' + check (status in ('unmatched','matched','marking','complete','absent')), + annotation_snapshot jsonb, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.mark_entries ( + id uuid primary key default gen_random_uuid(), + submission_id uuid not null references public.student_submissions(id) on delete cascade, + question_id uuid not null references public.exam_questions(id) on delete cascade, + batch_id uuid not null references public.marking_batches(id) on delete cascade, -- RLS denorm + awarded_marks numeric not null default 0, + mark_scheme_detail jsonb not null default '{}'::jsonb, + annotation_shape_ids jsonb not null default '[]'::jsonb, + comment text, + marked_by text not null default 'teacher' check (marked_by in ('teacher','ai')), + ai_confidence numeric, + confirmed boolean not null default true, + marked_at timestamptz not null default timezone('utc', now()) +); + +create index if not exists idx_exam_templates_institute on public.exam_templates(institute_id); +create index if not exists idx_exam_templates_teacher on public.exam_templates(teacher_id); +create index if not exists idx_exam_questions_template on public.exam_questions(template_id); +create index if not exists idx_exam_questions_parent on public.exam_questions(parent_id); +create index if not exists idx_exam_regions_question on public.exam_response_areas(question_id); +create index if not exists idx_exam_regions_template on public.exam_response_areas(template_id); +create index if not exists idx_exam_boundaries_template on public.exam_boundaries(template_id); +create index if not exists idx_batches_template on public.marking_batches(template_id); +create index if not exists idx_batches_institute on public.marking_batches(institute_id); +create index if not exists idx_submissions_batch on public.student_submissions(batch_id); +create index if not exists idx_marks_submission on public.mark_entries(submission_id); +create index if not exists idx_marks_batch on public.mark_entries(batch_id); + +--========================================================================================== +-- 2. updated_at triggers (reuse public.handle_updated_at from 62-functions-triggers.sql) +--========================================================================================== + +drop trigger if exists handle_exam_templates_updated_at on public.exam_templates; +create trigger handle_exam_templates_updated_at before update on public.exam_templates + for each row execute function public.handle_updated_at(); + +drop trigger if exists handle_exam_questions_updated_at on public.exam_questions; +create trigger handle_exam_questions_updated_at before update on public.exam_questions + for each row execute function public.handle_updated_at(); + +drop trigger if exists handle_marking_batches_updated_at on public.marking_batches; +create trigger handle_marking_batches_updated_at before update on public.marking_batches + for each row execute function public.handle_updated_at(); + +drop trigger if exists handle_student_submissions_updated_at on public.student_submissions; +create trigger handle_student_submissions_updated_at before update on public.student_submissions + for each row execute function public.handle_updated_at(); + +--========================================================================================== +-- 3. RLS — every table: a service_role passthrough (Neo4j projection / seeds) + as-user policies +--========================================================================================== + +alter table public.exam_templates enable row level security; +alter table public.exam_questions enable row level security; +alter table public.exam_response_areas enable row level security; +alter table public.exam_boundaries enable row level security; +alter table public.marking_batches enable row level security; +alter table public.student_submissions enable row level security; +alter table public.mark_entries enable row level security; + +-- exam_templates ------------------------------------------------------------------------- +drop policy if exists exam_templates_service on public.exam_templates; +create policy exam_templates_service on public.exam_templates using (auth.role() = 'service_role'); +drop policy if exists exam_templates_read on public.exam_templates; +create policy exam_templates_read on public.exam_templates for select to authenticated + using (institute_id in (select public.user_institute_ids())); +drop policy if exists exam_templates_write on public.exam_templates; +create policy exam_templates_write on public.exam_templates for all to authenticated + using (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids())) + with check (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids())); + +-- exam_questions / exam_response_areas / exam_boundaries: cascade authz from owning template +drop policy if exists exam_questions_service on public.exam_questions; +create policy exam_questions_service on public.exam_questions using (auth.role() = 'service_role'); +drop policy if exists exam_questions_all on public.exam_questions; +create policy exam_questions_all on public.exam_questions for all to authenticated + using (exists (select 1 from public.exam_templates t + where t.id = exam_questions.template_id + and t.institute_id in (select public.user_institute_ids()))) + with check (exists (select 1 from public.exam_templates t + where t.id = exam_questions.template_id and t.teacher_id = auth.uid())); + +drop policy if exists exam_regions_service on public.exam_response_areas; +create policy exam_regions_service on public.exam_response_areas using (auth.role() = 'service_role'); +drop policy if exists exam_regions_all on public.exam_response_areas; +create policy exam_regions_all on public.exam_response_areas for all to authenticated + using (exists (select 1 from public.exam_templates t + where t.id = exam_response_areas.template_id + and t.institute_id in (select public.user_institute_ids()))) + with check (exists (select 1 from public.exam_templates t + where t.id = exam_response_areas.template_id and t.teacher_id = auth.uid())); + +drop policy if exists exam_boundaries_service on public.exam_boundaries; +create policy exam_boundaries_service on public.exam_boundaries using (auth.role() = 'service_role'); +drop policy if exists exam_boundaries_all on public.exam_boundaries; +create policy exam_boundaries_all on public.exam_boundaries for all to authenticated + using (exists (select 1 from public.exam_templates t + where t.id = exam_boundaries.template_id + and t.institute_id in (select public.user_institute_ids()))) + with check (exists (select 1 from public.exam_templates t + where t.id = exam_boundaries.template_id and t.teacher_id = auth.uid())); + +-- marking_batches: read = same institute (colleagues), write = owning teacher (R2.4) +drop policy if exists marking_batches_service on public.marking_batches; +create policy marking_batches_service on public.marking_batches using (auth.role() = 'service_role'); +drop policy if exists marking_batches_read on public.marking_batches; +create policy marking_batches_read on public.marking_batches for select to authenticated + using (institute_id in (select public.user_institute_ids())); +drop policy if exists marking_batches_write on public.marking_batches; +create policy marking_batches_write on public.marking_batches for all to authenticated + using (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids())) + with check (teacher_id = auth.uid() and institute_id in (select public.user_institute_ids())); + +-- student_submissions: authz cascades from batch ownership +drop policy if exists submissions_service on public.student_submissions; +create policy submissions_service on public.student_submissions using (auth.role() = 'service_role'); +drop policy if exists submissions_all on public.student_submissions; +create policy submissions_all on public.student_submissions for all to authenticated + using (exists (select 1 from public.marking_batches b + where b.id = student_submissions.batch_id and b.teacher_id = auth.uid())) + with check (exists (select 1 from public.marking_batches b + where b.id = student_submissions.batch_id and b.teacher_id = auth.uid())); + +-- mark_entries: teacher (batch owner) full; student may read their own marks (R1.5, UI deferred) +drop policy if exists marks_service on public.mark_entries; +create policy marks_service on public.mark_entries using (auth.role() = 'service_role'); +drop policy if exists marks_teacher_all on public.mark_entries; +create policy marks_teacher_all on public.mark_entries for all to authenticated + using (exists (select 1 from public.marking_batches b + where b.id = mark_entries.batch_id and b.teacher_id = auth.uid())) + with check (exists (select 1 from public.marking_batches b + where b.id = mark_entries.batch_id and b.teacher_id = auth.uid())); +drop policy if exists marks_student_read on public.mark_entries; +create policy marks_student_read on public.mark_entries for select to authenticated + using (exists (select 1 from public.student_submissions s + where s.id = mark_entries.submission_id and s.student_id = auth.uid()));