-- 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()));