Compare commits
No commits in common. "main" and "feat/exam-marker-region-kinds" have entirely different histories.
main
...
feat/exam-
@ -51,7 +51,6 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -96,7 +95,6 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -141,7 +139,6 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -188,7 +185,6 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -245,7 +241,6 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -302,7 +297,6 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -365,7 +359,6 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -421,7 +414,6 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -477,7 +469,6 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -525,7 +516,6 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
|
||||||
methods:
|
methods:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
@ -606,7 +596,6 @@ services:
|
|||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
- "http://localhost:3000" # keep for local dev if needed
|
- "http://localhost:3000" # keep for local dev if needed
|
||||||
- "http://localhost:5173" # vite default
|
- "http://localhost:5173" # vite default
|
||||||
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
|
|
||||||
- "http://192.168.0.94:50001"
|
- "http://192.168.0.94:50001"
|
||||||
- "http://192.168.0.74"
|
- "http://192.168.0.74"
|
||||||
methods:
|
methods:
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
-- 74-exam-marker-layout.sql
|
|
||||||
-- S5 exam-marker layout layer + AI/manual seam provenance.
|
|
||||||
--
|
|
||||||
-- Adds the per-page layout projection that the docling auto-map pipeline writes to, plus
|
|
||||||
-- provenance columns needed to distinguish unconfirmed AI suggestions from teacher/manual data.
|
|
||||||
-- Safe/idempotent on top of 72-exam-marker.sql + 73-exam-marker-regions.sql.
|
|
||||||
|
|
||||||
--==========================================================================================
|
|
||||||
-- 1. exam_template_layout: stage-2 per-page layout layer
|
|
||||||
--==========================================================================================
|
|
||||||
create table if not exists public.exam_template_layout (
|
|
||||||
id uuid primary key default gen_random_uuid(),
|
|
||||||
template_id uuid not null references public.exam_templates(id) on delete cascade,
|
|
||||||
page_index int not null,
|
|
||||||
role text,
|
|
||||||
margin_left numeric,
|
|
||||||
margin_right numeric,
|
|
||||||
margin_top numeric,
|
|
||||||
margin_bottom numeric,
|
|
||||||
margins_enabled boolean not null default true,
|
|
||||||
source text not null default 'manual' check (source in ('manual','ai')),
|
|
||||||
confirmed boolean not null default true,
|
|
||||||
confidence numeric,
|
|
||||||
derivation text,
|
|
||||||
meta jsonb not null default '{}'::jsonb,
|
|
||||||
created_at timestamptz not null default timezone('utc', now()),
|
|
||||||
updated_at timestamptz not null default timezone('utc', now()),
|
|
||||||
unique (template_id, page_index)
|
|
||||||
);
|
|
||||||
|
|
||||||
comment on table public.exam_template_layout is
|
|
||||||
'Per-template/per-page layout projection for exam-marker auto-map review: page role, nullable margins, provenance and future layout-profile metadata.';
|
|
||||||
comment on column public.exam_template_layout.role is
|
|
||||||
'Page role such as cover|question|continuation|blank|appendix; nullable when unknown/manual draft.';
|
|
||||||
comment on column public.exam_template_layout.meta is
|
|
||||||
'Forward-compatible layout metadata: columns, front-matter, appendix/blank markers, future profile_id linkage, etc.';
|
|
||||||
|
|
||||||
create index if not exists idx_exam_template_layout_template on public.exam_template_layout(template_id);
|
|
||||||
create index if not exists idx_exam_template_layout_source_confirmed on public.exam_template_layout(template_id, source, confirmed);
|
|
||||||
|
|
||||||
drop trigger if exists handle_exam_template_layout_updated_at on public.exam_template_layout;
|
|
||||||
create trigger handle_exam_template_layout_updated_at before update on public.exam_template_layout
|
|
||||||
for each row execute function public.handle_updated_at();
|
|
||||||
|
|
||||||
--==========================================================================================
|
|
||||||
-- 2. AI seam/provenance columns on existing physical template tables
|
|
||||||
--==========================================================================================
|
|
||||||
-- Existing/manual rows default to authoritative manual data. Auto-map rows must explicitly set
|
|
||||||
-- source='ai' and confirmed=false so reruns can refresh only unconfirmed AI suggestions.
|
|
||||||
alter table public.exam_questions add column if not exists source text not null default 'manual' check (source in ('manual','ai'));
|
|
||||||
alter table public.exam_questions add column if not exists confirmed boolean not null default true;
|
|
||||||
alter table public.exam_questions add column if not exists confidence numeric;
|
|
||||||
alter table public.exam_questions add column if not exists derivation text;
|
|
||||||
|
|
||||||
alter table public.exam_response_areas add column if not exists mark_subtype text;
|
|
||||||
alter table public.exam_response_areas add column if not exists derivation text;
|
|
||||||
|
|
||||||
alter table public.exam_response_areas drop constraint if exists exam_response_areas_mark_subtype_check;
|
|
||||||
alter table public.exam_response_areas add constraint exam_response_areas_mark_subtype_check
|
|
||||||
check (
|
|
||||||
mark_subtype is null
|
|
||||||
or (kind = 'mark_area' and mark_subtype in ('part_marks','question_total','grader_box'))
|
|
||||||
);
|
|
||||||
|
|
||||||
alter table public.exam_boundaries add column if not exists confidence numeric;
|
|
||||||
alter table public.exam_boundaries add column if not exists derivation text;
|
|
||||||
|
|
||||||
-- Confidence values are normalized model/layout confidence scores when present.
|
|
||||||
alter table public.exam_template_layout drop constraint if exists exam_template_layout_confidence_check;
|
|
||||||
alter table public.exam_template_layout add constraint exam_template_layout_confidence_check
|
|
||||||
check (confidence is null or (confidence >= 0 and confidence <= 1));
|
|
||||||
alter table public.exam_questions drop constraint if exists exam_questions_confidence_check;
|
|
||||||
alter table public.exam_questions add constraint exam_questions_confidence_check
|
|
||||||
check (confidence is null or (confidence >= 0 and confidence <= 1));
|
|
||||||
alter table public.exam_response_areas drop constraint if exists exam_response_areas_confidence_check;
|
|
||||||
alter table public.exam_response_areas add constraint exam_response_areas_confidence_check
|
|
||||||
check (confidence is null or (confidence >= 0 and confidence <= 1));
|
|
||||||
alter table public.exam_boundaries drop constraint if exists exam_boundaries_confidence_check;
|
|
||||||
alter table public.exam_boundaries add constraint exam_boundaries_confidence_check
|
|
||||||
check (confidence is null or (confidence >= 0 and confidence <= 1));
|
|
||||||
|
|
||||||
comment on column public.exam_questions.derivation is
|
|
||||||
'Lightweight provenance such as ai|manual|boundary_derived|margin_derived|layout_derived.';
|
|
||||||
comment on column public.exam_response_areas.mark_subtype is
|
|
||||||
'Only meaningful for kind=mark_area: part_marks|question_total|grader_box. Grader boxes are persisted only when explicitly detected/provided.';
|
|
||||||
comment on column public.exam_response_areas.derivation is
|
|
||||||
'Lightweight provenance such as ai|manual|detected|layout_derived.';
|
|
||||||
comment on column public.exam_boundaries.derivation is
|
|
||||||
'Lightweight provenance such as ai|manual|band_derived.';
|
|
||||||
|
|
||||||
create index if not exists idx_exam_questions_source_confirmed on public.exam_questions(template_id, source, confirmed);
|
|
||||||
create index if not exists idx_exam_regions_source_confirmed on public.exam_response_areas(template_id, source, confirmed);
|
|
||||||
create index if not exists idx_exam_boundaries_source_confirmed on public.exam_boundaries(template_id, source, confirmed);
|
|
||||||
|
|
||||||
--==========================================================================================
|
|
||||||
-- 3. RLS — mirror exam_templates ownership/institute scope via the owning template
|
|
||||||
--==========================================================================================
|
|
||||||
alter table public.exam_template_layout enable row level security;
|
|
||||||
|
|
||||||
drop policy if exists exam_template_layout_service on public.exam_template_layout;
|
|
||||||
create policy exam_template_layout_service on public.exam_template_layout
|
|
||||||
using (auth.role() = 'service_role');
|
|
||||||
|
|
||||||
drop policy if exists exam_template_layout_all on public.exam_template_layout;
|
|
||||||
create policy exam_template_layout_all on public.exam_template_layout for all to authenticated
|
|
||||||
using (exists (select 1 from public.exam_templates t
|
|
||||||
where t.id = exam_template_layout.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_template_layout.template_id
|
|
||||||
and t.teacher_id = auth.uid()
|
|
||||||
and t.institute_id in (select public.user_institute_ids())));
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
--==========================================================================================
|
|
||||||
-- 75. exam_response_areas.meta — rich recognition payload from the extraction service (P2)
|
|
||||||
--==========================================================================================
|
|
||||||
-- The extraction service (docling-exam-spike analyse contract) returns richer per-region detail than
|
|
||||||
-- the thin first-pass could: per-option OMR box geometry + fill (boxes[]), select_n ("tick 2 of 5"),
|
|
||||||
-- n_options, the expected unit/quantity for single-line answers, and the data_table an MC question reads.
|
|
||||||
-- These have no dedicated columns; keep them in a jsonb sidecar so the canvas can render tick boxes /
|
|
||||||
-- units / MC option counts without a column per attribute. Nullable, defaults to {}.
|
|
||||||
alter table public.exam_response_areas add column if not exists meta jsonb not null default '{}'::jsonb;
|
|
||||||
|
|
||||||
comment on column public.exam_response_areas.meta is
|
|
||||||
'Extraction-service recognition detail: {n_lines, unit, quantity, n_boxes, n_options, select_n, box_form, boxes:[{x0,y0,x1,y1,fill}], table:{grid,n_cells}}. Rendered by the canvas; not authoritative for marking.';
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
-- Allow our file-GC helpers to satisfy Supabase storage.protect_delete
|
|
||||||
-- without weakening the managed storage trigger for arbitrary raw deletes.
|
|
||||||
--
|
|
||||||
-- Supabase storage.protect_delete permits direct storage.objects deletes only
|
|
||||||
-- when the transaction-local GUC storage.allow_delete_query is 'true'. The
|
|
||||||
-- Storage API sets that GUC for its own deletes. public.files GC is our
|
|
||||||
-- trigger-side cleanup path, so scope the same GUC to these SECURITY DEFINER
|
|
||||||
-- helpers immediately before their storage.objects DELETE statements.
|
|
||||||
|
|
||||||
create or replace function public._delete_storage_objects(p_bucket text, p_path text)
|
|
||||||
returns void
|
|
||||||
language plpgsql security definer
|
|
||||||
set search_path to 'public', 'storage'
|
|
||||||
as $$
|
|
||||||
begin
|
|
||||||
if p_bucket is null or p_path is null then
|
|
||||||
return;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
perform set_config('storage.allow_delete_query', 'true', true);
|
|
||||||
|
|
||||||
delete from storage.objects where bucket_id = p_bucket and name = p_path;
|
|
||||||
delete from storage.objects where bucket_id = p_bucket and name like p_path || '/%';
|
|
||||||
end
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function public._delete_storage_prefix(p_bucket text, p_prefix text)
|
|
||||||
returns void
|
|
||||||
language plpgsql security definer
|
|
||||||
set search_path to 'public', 'storage'
|
|
||||||
as $$
|
|
||||||
begin
|
|
||||||
if p_bucket is null or p_prefix is null then
|
|
||||||
return;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
perform set_config('storage.allow_delete_query', 'true', true);
|
|
||||||
|
|
||||||
-- Delete any objects whose name starts with the prefix + '/'
|
|
||||||
delete from storage.objects where bucket_id = p_bucket and name like p_prefix || '/%';
|
|
||||||
-- In case an object exists exactly at the prefix (rare but safe)
|
|
||||||
delete from storage.objects where bucket_id = p_bucket and name = p_prefix;
|
|
||||||
end
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- These helpers are intended for our SECURITY DEFINER GC triggers/helpers, not
|
|
||||||
-- as a general SQL delete API for application roles.
|
|
||||||
revoke all on function public._delete_storage_objects(text, text) from public;
|
|
||||||
revoke all on function public._delete_storage_prefix(text, text) from public;
|
|
||||||
revoke all on function public._delete_storage_objects(text, text) from anon, authenticated;
|
|
||||||
revoke all on function public._delete_storage_prefix(text, text) from anon, authenticated;
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
-- 76-cabinet-memberships-rls-definer.sql
|
|
||||||
-- Fix cabinet_memberships/file_cabinets/files recursive RLS.
|
|
||||||
--
|
|
||||||
-- The original cabinet membership policies joined file_cabinets directly, while
|
|
||||||
-- the file_cabinets/files membership policies joined cabinet_memberships
|
|
||||||
-- directly. Under an authenticated as-user SELECT this creates an RLS cycle and
|
|
||||||
-- PostgreSQL raises 42P17 (infinite recursion detected in policy for relation
|
|
||||||
-- "cabinet_memberships").
|
|
||||||
--
|
|
||||||
-- SECURITY DEFINER helpers evaluate the ownership/membership checks as the
|
|
||||||
-- function owner (bypassing the inner RLS checks) while still keying the result
|
|
||||||
-- to auth.uid(), matching the class-management helper pattern in 71.
|
|
||||||
--
|
|
||||||
-- The existing public tables are owned by supabase_admin; Supabase migrations
|
|
||||||
-- run as that table owner on dev/prod so the SECURITY DEFINER helpers are owned
|
|
||||||
-- by a role that bypasses the inner RLS checks.
|
|
||||||
|
|
||||||
create or replace function public.is_cabinet_owner(p_cabinet uuid)
|
|
||||||
returns boolean
|
|
||||||
language sql
|
|
||||||
stable
|
|
||||||
security definer
|
|
||||||
set search_path = public
|
|
||||||
as $$
|
|
||||||
select exists (
|
|
||||||
select 1
|
|
||||||
from public.file_cabinets c
|
|
||||||
where c.id = p_cabinet
|
|
||||||
and c.user_id = auth.uid()
|
|
||||||
)
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function public.is_cabinet_member(p_cabinet uuid)
|
|
||||||
returns boolean
|
|
||||||
language sql
|
|
||||||
stable
|
|
||||||
security definer
|
|
||||||
set search_path = public
|
|
||||||
as $$
|
|
||||||
select exists (
|
|
||||||
select 1
|
|
||||||
from public.cabinet_memberships m
|
|
||||||
where m.cabinet_id = p_cabinet
|
|
||||||
and m.profile_id = auth.uid()
|
|
||||||
)
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Keep function execution available to the roles used by RLS policies. The
|
|
||||||
-- functions disclose only a boolean about the caller's own auth.uid() state.
|
|
||||||
revoke all on function public.is_cabinet_owner(uuid) from public, anon;
|
|
||||||
revoke all on function public.is_cabinet_member(uuid) from public, anon;
|
|
||||||
grant execute on function public.is_cabinet_owner(uuid) to authenticated, service_role;
|
|
||||||
grant execute on function public.is_cabinet_member(uuid) to authenticated, service_role;
|
|
||||||
|
|
||||||
-- Re-declare cabinet_memberships policies without direct file_cabinets subqueries.
|
|
||||||
drop policy if exists cm_read_self_or_owner on public.cabinet_memberships;
|
|
||||||
create policy cm_read_self_or_owner on public.cabinet_memberships
|
|
||||||
for select to authenticated
|
|
||||||
using (profile_id = auth.uid() or public.is_cabinet_owner(cabinet_id));
|
|
||||||
|
|
||||||
drop policy if exists cm_insert_by_owner on public.cabinet_memberships;
|
|
||||||
create policy cm_insert_by_owner on public.cabinet_memberships
|
|
||||||
for insert to authenticated
|
|
||||||
with check (public.is_cabinet_owner(cabinet_id));
|
|
||||||
|
|
||||||
drop policy if exists cm_update_by_owner on public.cabinet_memberships;
|
|
||||||
create policy cm_update_by_owner on public.cabinet_memberships
|
|
||||||
for update to authenticated
|
|
||||||
using (public.is_cabinet_owner(cabinet_id))
|
|
||||||
with check (public.is_cabinet_owner(cabinet_id));
|
|
||||||
|
|
||||||
drop policy if exists cm_delete_by_owner on public.cabinet_memberships;
|
|
||||||
create policy cm_delete_by_owner on public.cabinet_memberships
|
|
||||||
for delete to authenticated
|
|
||||||
using (public.is_cabinet_owner(cabinet_id));
|
|
||||||
|
|
||||||
-- Re-declare membership-based cabinet/file read policies without direct
|
|
||||||
-- cabinet_memberships subqueries, so selecting cabinets/files does not recurse
|
|
||||||
-- back through cabinet_memberships RLS.
|
|
||||||
drop policy if exists "User can access cabinets via membership" on public.file_cabinets;
|
|
||||||
create policy "User can access cabinets via membership" on public.file_cabinets
|
|
||||||
for select to authenticated
|
|
||||||
using (public.is_cabinet_member(id));
|
|
||||||
|
|
||||||
drop policy if exists "User can access files via membership" on public.files;
|
|
||||||
create policy "User can access files via membership" on public.files
|
|
||||||
for select to authenticated
|
|
||||||
using (public.is_cabinet_member(cabinet_id));
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
--==========================================================================================
|
|
||||||
-- 76. exam_templates.extraction_meta — trust signal + provenance from the extraction service (P3)
|
|
||||||
--==========================================================================================
|
|
||||||
-- Records, per template, how the recognition was produced and whether it reconciled: the audit
|
|
||||||
-- cover-total gate (cover_total vs detected_total, status, anomaly), the engine + slug used, and
|
|
||||||
-- region counts. Lets the setup UI show "extraction: cover 100 = detected 100 ✓" (or flag an under-read),
|
|
||||||
-- and lets the digital-text view resolve the paper's replica by slug. Nullable, defaults to {}.
|
|
||||||
alter table public.exam_templates add column if not exists extraction_meta jsonb not null default '{}'::jsonb;
|
|
||||||
|
|
||||||
comment on column public.exam_templates.extraction_meta is
|
|
||||||
'Extraction-service provenance + trust gate: {engine, slug, audit:{cover_total,detected_total,status,anomaly}, counts}.';
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
-- 77-storage-taxonomy-rls.sql
|
|
||||||
-- RLS for taxonomy storage buckets. cc.examboards intentionally gets no
|
|
||||||
-- direct object-read policy: exam-board PDF bytes are exposed only through
|
|
||||||
-- API-issued, time-limited signed URLs.
|
|
||||||
|
|
||||||
create or replace function public.is_platform_admin()
|
|
||||||
returns boolean
|
|
||||||
language sql
|
|
||||||
stable
|
|
||||||
security definer
|
|
||||||
set search_path = public
|
|
||||||
as $$
|
|
||||||
select exists (
|
|
||||||
select 1
|
|
||||||
from public.admin_profiles ap
|
|
||||||
where ap.id = auth.uid()
|
|
||||||
and coalesce(ap.is_super_admin, false)
|
|
||||||
)
|
|
||||||
$$;
|
|
||||||
|
|
||||||
grant execute on function public.is_platform_admin() to authenticated;
|
|
||||||
|
|
||||||
-- Use the first path segment of cc.institutes objects as the institute id.
|
|
||||||
-- Expected layout: cc.institutes/<institute_uuid>/...
|
|
||||||
create or replace function public.storage_object_first_path_segment_uuid(p_name text)
|
|
||||||
returns uuid
|
|
||||||
language plpgsql
|
|
||||||
immutable
|
|
||||||
as $$
|
|
||||||
declare
|
|
||||||
v_segment text;
|
|
||||||
begin
|
|
||||||
v_segment := split_part(coalesce(p_name, ''), '/', 1);
|
|
||||||
if v_segment = '' then
|
|
||||||
return null;
|
|
||||||
end if;
|
|
||||||
begin
|
|
||||||
return v_segment::uuid;
|
|
||||||
exception when invalid_text_representation then
|
|
||||||
return null;
|
|
||||||
end;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
grant execute on function public.storage_object_first_path_segment_uuid(text) to authenticated;
|
|
||||||
|
|
||||||
-- Exam-board catalogue rows are authenticated metadata. PDF bytes remain
|
|
||||||
-- protected separately in cc.examboards storage and require signed URLs.
|
|
||||||
alter table public.eb_specifications enable row level security;
|
|
||||||
alter table public.eb_exams enable row level security;
|
|
||||||
|
|
||||||
drop policy if exists "eb_specifications read by authenticated users" on public.eb_specifications;
|
|
||||||
drop policy if exists "eb_exams read by authenticated users" on public.eb_exams;
|
|
||||||
|
|
||||||
create policy "eb_specifications read by authenticated users"
|
|
||||||
on public.eb_specifications
|
|
||||||
for select
|
|
||||||
to authenticated
|
|
||||||
using (true);
|
|
||||||
|
|
||||||
create policy "eb_exams read by authenticated users"
|
|
||||||
on public.eb_exams
|
|
||||||
for select
|
|
||||||
to authenticated
|
|
||||||
using (true);
|
|
||||||
|
|
||||||
alter table storage.objects enable row level security;
|
|
||||||
|
|
||||||
-- Replace only the policies owned by this migration. The absence of any
|
|
||||||
-- cc.examboards SELECT policy is the deny-by-default posture for raw reads.
|
|
||||||
drop policy if exists "cc.admin objects read by platform admins" on storage.objects;
|
|
||||||
drop policy if exists "cc.institutes objects read by institute members" on storage.objects;
|
|
||||||
drop policy if exists "cc.public objects read by authenticated users" on storage.objects;
|
|
||||||
|
|
||||||
create policy "cc.admin objects read by platform admins"
|
|
||||||
on storage.objects
|
|
||||||
for select
|
|
||||||
to authenticated
|
|
||||||
using (
|
|
||||||
bucket_id = 'cc.admin'
|
|
||||||
and public.is_platform_admin()
|
|
||||||
);
|
|
||||||
|
|
||||||
create policy "cc.institutes objects read by institute members"
|
|
||||||
on storage.objects
|
|
||||||
for select
|
|
||||||
to authenticated
|
|
||||||
using (
|
|
||||||
bucket_id = 'cc.institutes'
|
|
||||||
and public.storage_object_first_path_segment_uuid(name) in (
|
|
||||||
select public.user_institute_ids()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
create policy "cc.public objects read by authenticated users"
|
|
||||||
on storage.objects
|
|
||||||
for select
|
|
||||||
to authenticated
|
|
||||||
using (bucket_id = 'cc.public');
|
|
||||||
|
|
||||||
comment on policy "cc.admin objects read by platform admins" on storage.objects
|
|
||||||
is 'cc.admin raw reads are limited to platform admins.';
|
|
||||||
comment on policy "cc.institutes objects read by institute members" on storage.objects
|
|
||||||
is 'cc.institutes raw reads are scoped by leading path segment institute uuid.';
|
|
||||||
comment on policy "cc.public objects read by authenticated users" on storage.objects
|
|
||||||
is 'cc.public raw reads are available to any authenticated user.';
|
|
||||||
comment on table storage.objects
|
|
||||||
is 'Classroom Copilot taxonomy buckets use RLS; cc.examboards raw reads stay denied and are served through API signed URLs.';
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
--==========================================================================================
|
|
||||||
-- 78. exam_questions.command_word + preamble — richer recognition carried by the analyse contract v2 (WS-2)
|
|
||||||
--==========================================================================================
|
|
||||||
-- The extraction service (docling-exam-spike analyse.py v2, scripts/ANALYSE-CONTRACT.md) now carries two
|
|
||||||
-- fields the thin v1 bridge dropped:
|
|
||||||
-- * command_word — the primary command verb per part (calculate / explain / suggest / describe …), from
|
|
||||||
-- structure.command_words / part_info.primary. Drives the question-tree command pill + future filtering.
|
|
||||||
-- * preamble — the stem prose that introduces a question/part (structure.preamble_by_label), as a
|
|
||||||
-- jsonb sidecar {n, text:[…lines], nontext:[{kind,cls,page}], bands:[{page,y0,y1}]}. Kept as jsonb (not
|
|
||||||
-- a column per line) so the tree/doc view can show the stem and the canvas can locate its band. The
|
|
||||||
-- preamble band is ALSO emitted as a drawable context region (context_type='preamble'); this column is
|
|
||||||
-- the authoritative text for the outline view.
|
|
||||||
-- Both nullable; containers/parts without a detected command/preamble stay null. Additive + idempotent.
|
|
||||||
alter table public.exam_questions add column if not exists command_word text;
|
|
||||||
alter table public.exam_questions add column if not exists preamble jsonb;
|
|
||||||
|
|
||||||
comment on column public.exam_questions.command_word is
|
|
||||||
'Primary command verb for a leaf part (calculate/explain/suggest/describe/…), from the analyse contract v2 (structure command_words / part_info). Null for container questions or when none detected.';
|
|
||||||
comment on column public.exam_questions.preamble is
|
|
||||||
'Stem prose introducing this question/part, from the analyse contract v2 (structure.preamble_by_label): {n, text:[lines], nontext:[{kind,cls,page}], bands:[{page,y0,y1}]}. Authoritative text for the outline view; the band is also drawn as a context_type=preamble region.';
|
|
||||||
Loading…
x
Reference in New Issue
Block a user