Compare commits

..

1 Commits

Author SHA1 Message Date
02f68387e8 Consolidate Supabase migrations and dev seed 2026-05-28 11:51:27 +00:00
27 changed files with 1728 additions and 1860 deletions

View File

@ -355,17 +355,16 @@ services:
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/59-logs.sql:Z
# Changes required for Pooler support
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/59-pooler.sql:Z
# ClassroomCopilot changes
- ./volumes/db/cc/61-core-schema.sql:/docker-entrypoint-initdb.d/migrations/61-core-schema.sql:Z
- ./volumes/db/cc/62-functions-triggers.sql:/docker-entrypoint-initdb.d/migrations/62-functions-triggers.sql:Z
- ./volumes/db/cc/63-storage-policies.sql:/docker-entrypoint-initdb.d/migrations/63-storage-policies.sql:Z
- ./volumes/db/cc/64-initial-admin.sql:/docker-entrypoint-initdb.d/migrations/64-initial-admin.sql:Z
- ./volumes/db/cc/65-filesystem-augments.sql:/docker-entrypoint-initdb.d/migrations/65-filesystem-augments.sql:Z
- ./volumes/db/cc/66-rls-policies.sql:/docker-entrypoint-initdb.d/migrations/66-rls-policies.sql:Z
- ./volumes/db/cc/67-vectors.sql:/docker-entrypoint-initdb.d/migrations/67-vectors.sql:Z
- ./volumes/db/cc/68-cabinet-memberships.sql:/docker-entrypoint-initdb.d/migrations/68-cabinet-memberships.sql:Z
- ./volumes/db/cc/69-gc-prefix-cleanup.sql:/docker-entrypoint-initdb.d/migrations/69-gc-prefix-cleanup.sql:Z
- ./volumes/db/cc/70-add-directory-support.sql:/docker-entrypoint-initdb.d/migrations/70-add-directory-support.sql:Z
# Classroom Copilot consolidated application schema and deterministic dev seed.
# Keep this chain ordered; GAIS reference tables are schema-only here, while
# full GAIS open-data bulk loads remain outside the small dev seed.
- ./volumes/db/cc/61-gais-reference.sql:/docker-entrypoint-initdb.d/migrations/61-gais-reference.sql:Z
- ./volumes/db/cc/62-application-schema.sql:/docker-entrypoint-initdb.d/migrations/62-application-schema.sql:Z
- ./volumes/db/cc/63-academic-calendar.sql:/docker-entrypoint-initdb.d/migrations/63-academic-calendar.sql:Z
- ./volumes/db/cc/64-extended-schema.sql:/docker-entrypoint-initdb.d/migrations/64-extended-schema.sql:Z
- ./volumes/db/cc/65-phase-c.sql:/docker-entrypoint-initdb.d/migrations/65-phase-c.sql:Z
- ./volumes/db/cc/66-taught-lessons-nullable.sql:/docker-entrypoint-initdb.d/migrations/66-taught-lessons-nullable.sql:Z
- ./volumes/db/cc/67-dev-seed.sql:/docker-entrypoint-initdb.d/migrations/67-dev-seed.sql:Z
# PGDATA directory - persists database files between restarts
- ./volumes/db-data:/var/lib/postgresql/data:Z
# Use named volume to persist pgsodium decryption key between restarts

View File

@ -0,0 +1,105 @@
# Classroom Copilot Supabase migrations and deterministic dev seed
Status: branch implementation validated on Supabase dev host 192.168.0.94 using an isolated throwaway database.
## Consolidated init chain
The Docker Compose `db` service now mounts a single ordered Classroom Copilot chain:
1. `volumes/db/cc/61-gais-reference.sql` — GAIS reference table schema and open-data read policies only.
2. `volumes/db/cc/62-application-schema.sql` — canonical app schema, storage/file metadata tables, class/lesson/CIS tables, indexes, and base RLS.
3. `volumes/db/cc/63-academic-calendar.sql` — school timetable, academic year/term/week/day, teacher timetable, and teacher slot tables.
4. `volumes/db/cc/64-extended-schema.sql` — term breaks, academic periods, invitations, taught lessons, and week-cycle slot uniqueness.
5. `volumes/db/cc/65-phase-c.sql` — Phase C cleanup after taught lessons exist; links lesson deliveries to taught lessons.
6. `volumes/db/cc/66-taught-lessons-nullable.sql` — nullable taught lesson `class_id` and teacher slot class FK.
7. `volumes/db/cc/67-dev-seed.sql` — deterministic, non-sensitive dev fixtures.
The old `61-core-schema.sql` through `70-add-directory-support.sql` bootstrap files were removed from the active chain because they represented an older ClassConcepts/filesystem schema and stale role vocabulary. The Git history remains the archive.
## Deterministic dev seed contents
`67-dev-seed.sql` creates only fixture data:
- 1 platform admin in `admin_profiles`.
- 1 school/institute.
- 1 school admin, 2 teachers, 3 students.
- institute memberships for the school admin, teachers, and students.
- 2 classes with class-teacher and class-student rows.
- 1 school timetable, 1 academic year, 1 term, 1 week, 4 academic days, and 16 academic periods.
- 2 teacher timetables, 3 teacher timetable slots, and 3 taught lessons.
- 2 planned lessons and 1 delivered lesson fixture.
- Storage buckets `cc.users`, `cc.public.snapshots`, and `cc.examboards`.
- TLDraw default snapshot paths on the teacher whiteboard rooms; object rows are not pre-created.
Fixture emails use the `classroomcopilot.dev` domain and are not real users. Do not replace this seed with live student/teacher data.
## Validation pattern used on Supabase dev
Do not run schema experiments on production. To validate this branch without mutating the live dev database, create a throwaway database on the Supabase dev Postgres container, clone only the `auth` and `storage` schema definitions from dev, apply the ordered chain, check row counts, then drop the throwaway database.
The 2026-05-28 validation used this shape on `ubuntu-ct-supabase-dev` (`192.168.0.94`):
```bash
DB=cc_mig_validate_<timestamp>
BASE=/tmp/cc-supabase-migration-validate
docker exec supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS $DB WITH (FORCE);"
docker exec supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE $DB;"
# The dev auth schema has a trigger that references public.handle_new_user();
# create a no-op stub before restoring auth/storage schema-only into the throwaway DB.
cat >/tmp/create_dummy.sql <<'SQL'
create or replace function public.handle_new_user()
returns trigger
language plpgsql
as $$
begin
return new;
end;
$$;
SQL
docker exec -i supabase-db psql -U postgres -d "$DB" -v ON_ERROR_STOP=1 < /tmp/create_dummy.sql
docker exec supabase-db pg_dump -U postgres -d postgres --schema-only --no-owner --no-privileges -n auth -n storage > /tmp/${DB}_auth_storage_schema.sql
docker exec -i supabase-db psql -U postgres -d "$DB" -v ON_ERROR_STOP=1 < /tmp/${DB}_auth_storage_schema.sql
for f in \
"$BASE"/volumes/db/cc/61-gais-reference.sql \
"$BASE"/volumes/db/cc/62-application-schema.sql \
"$BASE"/volumes/db/cc/63-academic-calendar.sql \
"$BASE"/volumes/db/cc/64-extended-schema.sql \
"$BASE"/volumes/db/cc/65-phase-c.sql \
"$BASE"/volumes/db/cc/66-taught-lessons-nullable.sql \
"$BASE"/volumes/db/cc/67-dev-seed.sql; do
docker exec -i supabase-db psql -U postgres -d "$DB" -v ON_ERROR_STOP=1 < "$f"
done
# Smoke counts, then cleanup.
docker exec supabase-db psql -U postgres -d "$DB" -Atc "select 'profiles='||count(*) from public.profiles union all select 'institutes='||count(*) from public.institutes union all select 'memberships='||count(*) from public.institute_memberships union all select 'classes='||count(*) from public.classes union all select 'academic_periods='||count(*) from public.academic_periods union all select 'teacher_timetable_slots='||count(*) from public.teacher_timetable_slots union all select 'taught_lessons='||count(*) from public.taught_lessons union all select 'planned_lessons='||count(*) from public.planned_lessons union all select 'lesson_deliveries='||count(*) from public.lesson_deliveries union all select 'buckets='||count(*) from storage.buckets where id like 'cc.%';"
docker exec supabase-db psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS $DB WITH (FORCE);"
```
Expected smoke counts from the deterministic seed:
```text
profiles=7
institutes=1
memberships=6
classes=2
academic_periods=16
teacher_timetable_slots=3
taught_lessons=3
planned_lessons=2
lesson_deliveries=1
buckets=3
```
## Production rule
This branch is not a production migration by itself. Before production use:
1. Take a production schema/data backup.
2. Compare live production schema drift against this consolidated chain.
3. Prepare explicit forward migrations for any live-only objects or data transforms.
4. Validate those forward migrations on Supabase dev first.
5. Only apply to production after human approval.

View File

@ -51,7 +51,6 @@ services:
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods:
- GET
- POST
@ -96,7 +95,6 @@ services:
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods:
- GET
- POST
@ -141,7 +139,6 @@ services:
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods:
- GET
- POST
@ -188,7 +185,6 @@ services:
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods:
- GET
- POST
@ -245,7 +241,6 @@ services:
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods:
- GET
- POST
@ -302,7 +297,6 @@ services:
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods:
- GET
- POST
@ -365,7 +359,6 @@ services:
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods:
- GET
- POST
@ -421,7 +414,6 @@ services:
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods:
- GET
- POST
@ -477,7 +469,6 @@ services:
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods:
- GET
- POST
@ -525,7 +516,6 @@ services:
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "http://localhost:5173" # vite default
- "http://192.168.0.251:13000" # app-dev LAN (deployed dev frontend)
methods:
- GET
- POST
@ -606,7 +596,6 @@ services:
- "http://192.168.0.74"
- "http://localhost:3000" # keep for local dev if needed
- "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.74"
methods:

View File

@ -1,364 +0,0 @@
--[ Database Schema Version ]--
-- Version: 1.0.0
-- Last Updated: 2024-02-24
-- Description: Core schema setup for ClassConcepts with neoFS filesystem integration
-- Dependencies: auth.users (Supabase Auth)
--[ Validation ]--
do $$
begin
-- Verify required extensions
if not exists (select 1 from pg_extension where extname = 'uuid-ossp') then
raise exception 'Required extension uuid-ossp is not installed';
end if;
-- Verify auth schema exists
if not exists (select 1 from information_schema.schemata where schema_name = 'auth') then
raise exception 'Required auth schema is not available';
end if;
-- Verify storage schema exists
if not exists (select 1 from information_schema.schemata where schema_name = 'storage') then
raise exception 'Required storage schema is not available';
end if;
end $$;
--[ 1. Extensions ]--
create extension if not exists "uuid-ossp";
-- Create rpc schema if it doesn't exist
create schema if not exists rpc;
grant usage on schema rpc to anon, authenticated;
-- Create exec_sql function for admin operations
create or replace function exec_sql(query text)
returns void as $$
begin
execute query;
end;
$$ language plpgsql security definer;
-- Create updated_at trigger function
create or replace function public.handle_updated_at()
returns trigger as $$
begin
new.updated_at = timezone('utc'::text, now());
return new;
end;
$$ language plpgsql security definer;
-- Create completed_at trigger function for document artefacts
create or replace function public.set_completed_at()
returns trigger as $$
begin
if NEW.status = 'completed' and OLD.status != 'completed' then
NEW.completed_at = now();
end if;
return NEW;
end;
$$ language plpgsql security definer;
--[ 5. Core Tables ]--
-- Base user profiles
create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text not null unique,
user_type text not null check (
user_type in (
'teacher',
'student',
'email_teacher',
'email_student',
'developer',
'superadmin'
)
),
username text not null unique,
full_name text,
display_name text,
metadata jsonb default '{}'::jsonb,
user_db_name text,
school_db_name text,
neo4j_sync_status text default 'pending' check (neo4j_sync_status in ('pending', 'ready', 'failed')),
neo4j_synced_at timestamp with time zone,
last_login timestamp with time zone,
created_at timestamp with time zone default timezone('utc'::text, now()),
updated_at timestamp with time zone default timezone('utc'::text, now())
);
comment on table public.profiles is 'User profiles linked to Supabase auth.users';
comment on column public.profiles.user_type is 'Type of user: teacher or student';
-- Active institutes
create table if not exists public.institutes (
id uuid primary key default uuid_generate_v4(),
name text not null,
urn text unique,
status text not null default 'active' check (status in ('active', 'inactive', 'pending')),
address jsonb default '{}'::jsonb,
website text,
metadata jsonb default '{}'::jsonb,
geo_coordinates jsonb default '{}'::jsonb,
neo4j_uuid_string text,
neo4j_public_sync_status text default 'pending' check (neo4j_public_sync_status in ('pending', 'synced', 'failed')),
neo4j_public_sync_at timestamp with time zone,
neo4j_private_sync_status text default 'not_started' check (neo4j_private_sync_status in ('not_started', 'pending', 'synced', 'failed')),
neo4j_private_sync_at timestamp with time zone,
created_at timestamp with time zone default timezone('utc'::text, now()),
updated_at timestamp with time zone default timezone('utc'::text, now())
);
comment on table public.institutes is 'Active institutes in the system';
comment on column public.institutes.geo_coordinates is 'Geospatial coordinates from OSM search (latitude, longitude, boundingbox)';
--[ 6. neoFS Filesystem Tables ]--
-- File cabinets for organizing files
create table if not exists public.file_cabinets (
id uuid primary key default uuid_generate_v4(),
user_id uuid not null references public.profiles(id) on delete cascade,
name text not null,
created_at timestamp with time zone default timezone('utc'::text, now())
);
comment on table public.file_cabinets is 'User file cabinets for organizing documents and files';
-- Files stored in cabinets
create table if not exists public.files (
id uuid primary key default uuid_generate_v4(),
cabinet_id uuid not null references public.file_cabinets(id) on delete cascade,
name text not null,
path text not null,
bucket text default 'file-cabinets' not null,
created_at timestamp with time zone default timezone('utc'::text, now()),
mime_type text,
metadata jsonb default '{}'::jsonb,
size text,
category text generated always as (
case
when mime_type like 'image/%' then 'image'
when mime_type = 'application/pdf' then 'document'
when mime_type in ('application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') then 'document'
when mime_type in ('application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') then 'spreadsheet'
when mime_type in ('application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation') then 'presentation'
when mime_type like 'audio/%' then 'audio'
when mime_type like 'video/%' then 'video'
else 'other'
end
) stored
);
comment on table public.files is 'Files stored in user cabinets with automatic categorization';
comment on column public.files.category is 'Automatically determined file category based on MIME type';
-- AI brains for processing files
create table if not exists public.brains (
id uuid primary key default uuid_generate_v4(),
user_id uuid not null references public.profiles(id) on delete cascade,
name text not null,
purpose text,
created_at timestamp with time zone default timezone('utc'::text, now())
);
comment on table public.brains is 'AI brains for processing and analyzing user files';
-- Brain-file associations
create table if not exists public.brain_files (
brain_id uuid not null references public.brains(id) on delete cascade,
file_id uuid not null references public.files(id) on delete cascade,
primary key (brain_id, file_id)
);
comment on table public.brain_files is 'Associations between AI brains and files for processing';
-- Document artefacts from file processing
create table if not exists public.document_artefacts (
id uuid primary key default uuid_generate_v4(),
file_id uuid references public.files(id) on delete cascade,
page_number integer default 0 not null,
type text not null,
rel_path text not null,
size_tag text,
language text,
chunk_index integer,
extra jsonb,
created_at timestamp with time zone default timezone('utc'::text, now()),
status text default 'completed' not null check (status in ('pending', 'processing', 'completed', 'failed')),
started_at timestamp with time zone default timezone('utc'::text, now()),
completed_at timestamp with time zone,
error_message text
);
comment on table public.document_artefacts is 'Extracted artefacts from document processing';
comment on column public.document_artefacts.status is 'Extraction status: pending, processing, completed, or failed';
comment on column public.document_artefacts.started_at is 'Timestamp when extraction process started';
comment on column public.document_artefacts.completed_at is 'Timestamp when extraction process completed (success or failure)';
comment on column public.document_artefacts.error_message is 'Error details if extraction failed';
-- Function execution logs
create table if not exists public.function_logs (
id serial primary key,
file_id uuid references public.files(id) on delete cascade,
timestamp timestamp with time zone default timezone('utc'::text, now()),
step text,
message text,
data jsonb
);
comment on table public.function_logs is 'Logs of function executions and processing steps';
--[ 7. Relationship Tables ]--
-- Institute memberships
create table if not exists public.institute_memberships (
id uuid primary key default uuid_generate_v4(),
profile_id uuid references public.profiles(id) on delete cascade,
institute_id uuid references public.institutes(id) on delete cascade,
role text not null check (role in ('teacher', 'student')),
tldraw_preferences jsonb default '{}'::jsonb,
metadata jsonb default '{}'::jsonb,
created_at timestamp with time zone default timezone('utc'::text, now()),
updated_at timestamp with time zone default timezone('utc'::text, now()),
unique(profile_id, institute_id)
);
comment on table public.institute_memberships is 'Manages user roles and relationships with institutes';
-- Membership requests
create table if not exists public.institute_membership_requests (
id uuid primary key default uuid_generate_v4(),
profile_id uuid references public.profiles(id) on delete cascade,
institute_id uuid references public.institutes(id) on delete cascade,
requested_role text check (requested_role in ('teacher', 'student')),
status text default 'pending' check (status in ('pending', 'approved', 'rejected')),
metadata jsonb default '{}'::jsonb,
created_at timestamp with time zone default timezone('utc'::text, now()),
updated_at timestamp with time zone default timezone('utc'::text, now())
);
comment on table public.institute_membership_requests is 'Tracks requests to join institutes';
--[ 8. Audit Tables ]--
-- System audit logs
create table if not exists public.audit_logs (
id uuid primary key default uuid_generate_v4(),
profile_id uuid references public.profiles(id) on delete set null,
action_type text,
table_name text,
record_id uuid,
changes jsonb,
created_at timestamp with time zone default timezone('utc'::text, now())
);
comment on table public.audit_logs is 'System-wide audit trail for important operations';
--[ 9. Exam Specifications ]--
create table if not exists public.eb_specifications (
id uuid primary key default uuid_generate_v4(),
spec_code text unique,
exam_board_code text,
award_code text,
subject_code text,
first_teach text,
spec_ver text,
-- Document storage details
storage_loc text,
doc_type text check (doc_type in ('pdf', 'json', 'md', 'html', 'txt', 'doctags')),
doc_details jsonb default '{}'::jsonb, -- e.g. Tika extract
docling_docs jsonb default '{}'::jsonb, -- e.g. Docling extracts settings and storage locations
created_at timestamp with time zone default timezone('utc'::text, now()),
updated_at timestamp with time zone default timezone('utc'::text, now())
);
comment on table public.eb_specifications is 'Exam board specifications and their primary document';
comment on column public.eb_specifications.spec_code is 'Unique code for the specification, used for linking exams';
comment on column public.eb_specifications.doc_details is 'Tika extract of the specification document';
comment on column public.eb_specifications.docling_docs is 'Docling extracts settings and storage locations for the specification document';
--[ 10. Exam Papers / Entries ]--
create table if not exists public.eb_exams (
id uuid primary key default uuid_generate_v4(),
exam_code text unique,
spec_code text references public.eb_specifications(spec_code) on delete cascade,
paper_code text,
tier text,
session text,
type_code text,
-- Document storage details
storage_loc text,
doc_type text check (doc_type in ('pdf', 'json', 'md', 'html', 'txt', 'doctags')),
doc_details jsonb default '{}'::jsonb, -- e.g. Tika extract
docling_docs jsonb default '{}'::jsonb, -- e.g. Docling extracts settings and storage locations
created_at timestamp with time zone default timezone('utc'::text, now()),
updated_at timestamp with time zone default timezone('utc'::text, now())
);
comment on table public.eb_exams is 'Exam papers and related documents linked to specifications';
comment on column public.eb_exams.exam_code is 'Unique code for the exam paper, used for linking questions';
comment on column public.eb_exams.type_code is 'Type code for the exam document: Question Paper (QP), Mark Scheme (MS), Examiner Report (ER), Other (OT)';
comment on column public.eb_exams.doc_details is 'Tika extract of the exam paper document';
comment on column public.eb_exams.docling_docs is 'Docling extracts settings and storage locations for the exam paper document';
--[ 11. Indexes ]--
-- Index for geospatial queries
create index if not exists idx_institutes_geo_coordinates on public.institutes using gin(geo_coordinates);
create index if not exists idx_institutes_urn on public.institutes(urn);
-- Document artefacts indexes
create index if not exists idx_document_artefacts_file_status on public.document_artefacts(file_id, status);
create index if not exists idx_document_artefacts_file_type on public.document_artefacts(file_id, type);
create index if not exists idx_document_artefacts_status on public.document_artefacts(status);
-- File indexes
create index if not exists idx_files_cabinet_id on public.files(cabinet_id);
create index if not exists idx_files_mime_type on public.files(mime_type);
create index if not exists idx_files_category on public.files(category);
-- Brain indexes
create index if not exists idx_brains_user_id on public.brains(user_id);
-- Exam board indexes
create index if not exists idx_eb_exams_exam_code on public.eb_exams(exam_code);
create index if not exists idx_eb_exams_spec_code on public.eb_exams(spec_code);
create index if not exists idx_eb_exams_paper_code on public.eb_exams(paper_code);
create index if not exists idx_eb_exams_tier on public.eb_exams(tier);
create index if not exists idx_eb_exams_session on public.eb_exams(session);
create index if not exists idx_eb_exams_type_code on public.eb_exams(type_code);
create index if not exists idx_eb_specifications_spec_code on public.eb_specifications(spec_code);
create index if not exists idx_eb_specifications_exam_board_code on public.eb_specifications(exam_board_code);
create index if not exists idx_eb_specifications_award_code on public.eb_specifications(award_code);
create index if not exists idx_eb_specifications_subject_code on public.eb_specifications(subject_code);
--[ 12. Triggers ]--
-- Set completed_at when document artefact status changes to completed
create trigger trigger_set_completed_at
before update on public.document_artefacts
for each row
execute function public.set_completed_at();
-- Set updated_at on profile updates
create trigger trigger_profiles_updated_at
before update on public.profiles
for each row
execute function public.handle_updated_at();
-- Set updated_at on institute updates
create trigger trigger_institutes_updated_at
before update on public.institutes
for each row
execute function public.handle_updated_at();
-- Set updated_at on institute_memberships updates
create trigger trigger_institute_memberships_updated_at
before update on public.institute_memberships
for each row
execute function public.handle_updated_at();
-- Set updated_at on institute_membership_requests updates
create trigger trigger_institute_membership_requests_updated_at
before update on public.institute_memberships
for each row
execute function public.handle_updated_at();
-- Set updated_at on eb_specifications updates
create trigger trigger_eb_specifications_updated_at
before update on public.eb_specifications
for each row
execute function public.handle_updated_at();
-- Set updated_at on eb_exams updates
create trigger trigger_eb_exams_updated_at
before update on public.eb_exams
for each row
execute function public.handle_updated_at();

View File

@ -0,0 +1,71 @@
-- 001_gais_seed.sql
-- GAIS (Get Information About Schools) reference tables
-- Source: Edubase open data, https://www.get-information-schools.service.gov.uk/
-- Apply once to the Supabase Postgres instance via the SQL editor.
-- ─── Local Authorities ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS gais_local_authorities (
code TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- ─── Schools ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS gais_schools (
urn TEXT PRIMARY KEY,
name TEXT NOT NULL,
status TEXT, -- Open | Closed | Proposed to Open
phase TEXT, -- Primary | Secondary | 16 plus | etc.
type TEXT, -- Voluntary aided school | Academy | etc.
type_group TEXT, -- Local authority maintained | Independent | etc.
street TEXT,
locality TEXT,
town TEXT,
county TEXT,
postcode TEXT,
website TEXT,
telephone TEXT,
head_title TEXT,
head_first_name TEXT,
head_last_name TEXT,
la_code TEXT REFERENCES gais_local_authorities(code),
la_name TEXT,
number_of_pupils INTEGER,
open_date DATE,
close_date DATE,
gender TEXT, -- Mixed | Girls | Boys
religious_character TEXT,
region TEXT, -- Government Office Region
created_at TIMESTAMPTZ DEFAULT now()
);
-- ─── Indexes ─────────────────────────────────────────────────────────────────
-- Full-text search on name + town + postcode
CREATE INDEX IF NOT EXISTS gais_schools_name_fts
ON gais_schools USING gin(to_tsvector('english', coalesce(name, '') || ' ' || coalesce(town, '') || ' ' || coalesce(postcode, '')));
-- Trigram index for ILIKE search (pg_trgm extension required)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS gais_schools_name_trgm ON gais_schools USING gin(name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS gais_schools_town_trgm ON gais_schools USING gin(town gin_trgm_ops);
CREATE INDEX IF NOT EXISTS gais_schools_postcode_trgm ON gais_schools USING gin(postcode gin_trgm_ops);
-- Status and LA for filtered queries
CREATE INDEX IF NOT EXISTS gais_schools_status ON gais_schools(status);
CREATE INDEX IF NOT EXISTS gais_schools_la_code ON gais_schools(la_code);
-- ─── RLS ─────────────────────────────────────────────────────────────────────
-- Public read (these are open-data reference tables).
-- Writes are only via service-role (admin imports / seed scripts).
ALTER TABLE gais_local_authorities ENABLE ROW LEVEL SECURITY;
ALTER TABLE gais_schools ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public read gais_local_authorities"
ON gais_local_authorities FOR SELECT USING (true);
CREATE POLICY "Public read gais_schools"
ON gais_schools FOR SELECT USING (true);

View File

@ -0,0 +1,763 @@
-- ============================================================
-- Classroom Copilot — Application Schema
-- Migration 002: All application tables (non-GAIS)
-- Run after: 001_gais_seed.sql
-- ============================================================
-- Extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================
-- 1. Core user & school tables
-- ============================================================
-- 1.1 Profiles — mirrors auth.users, extended user data
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
user_type TEXT NOT NULL CHECK (user_type IN ('teacher','student','admin')),
username TEXT NOT NULL UNIQUE,
full_name TEXT,
display_name TEXT,
school_id UUID, -- FK to institutes added below (circular avoidance)
metadata JSONB NOT NULL DEFAULT '{}',
neo4j_sync_status TEXT DEFAULT 'pending', -- tracks Neo4j knowledge-graph sync
neo4j_synced_at TIMESTAMPTZ,
last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 1.2 Admin profiles — separate table for system-level admins
CREATE TABLE IF NOT EXISTS admin_profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
display_name TEXT,
admin_role TEXT NOT NULL DEFAULT 'admin',
is_super_admin BOOLEAN NOT NULL DEFAULT false,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 1.3 Institutes (schools)
CREATE TABLE IF NOT EXISTS institutes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
urn TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
address JSONB NOT NULL DEFAULT '{}',
website TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
geo_coordinates JSONB NOT NULL DEFAULT '{}',
neo4j_uuid_string TEXT,
neo4j_public_sync_status TEXT DEFAULT 'pending',
neo4j_public_sync_at TIMESTAMPTZ,
neo4j_private_sync_status TEXT DEFAULT 'not_started',
neo4j_private_sync_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Deferred FK: profiles.school_id → institutes
ALTER TABLE profiles
DROP CONSTRAINT IF EXISTS profiles_school_id_fkey;
ALTER TABLE profiles
ADD CONSTRAINT profiles_school_id_fkey
FOREIGN KEY (school_id) REFERENCES institutes(id) ON DELETE SET NULL;
-- 1.4 Institute memberships
CREATE TABLE IF NOT EXISTS institute_memberships (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('school_admin','teacher','student')),
tldraw_preferences JSONB NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (profile_id, institute_id)
);
-- 1.5 Institute membership requests (teacher invite / student join flow)
CREATE TABLE IF NOT EXISTS institute_membership_requests (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
requested_role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- 2. TLDraw whiteboard rooms
-- ============================================================
CREATE TABLE IF NOT EXISTS whiteboard_rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
institute_id UUID REFERENCES institutes(id) ON DELETE SET NULL,
name TEXT NOT NULL DEFAULT 'My Workspace',
context_type TEXT NOT NULL DEFAULT 'profile',
context_id TEXT,
is_default BOOLEAN NOT NULL DEFAULT false,
storage_path TEXT,
neo4j_node_id TEXT,
neo4j_db_name TEXT,
node_type TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- 3. File cabinet system
-- ============================================================
-- 3.1 Cabinets — top-level containers owned by a user
CREATE TABLE IF NOT EXISTS file_cabinets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 3.2 Files — records for files stored in Supabase Storage
CREATE TABLE IF NOT EXISTS files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
cabinet_id UUID NOT NULL REFERENCES file_cabinets(id) ON DELETE CASCADE,
uploaded_by UUID REFERENCES profiles(id) ON DELETE SET NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
bucket TEXT NOT NULL DEFAULT 'cc.users',
mime_type TEXT,
size_bytes BIGINT,
size TEXT,
category TEXT,
source TEXT DEFAULT 'uploader-web',
is_directory BOOLEAN NOT NULL DEFAULT false,
parent_directory_id UUID REFERENCES files(id) ON DELETE SET NULL,
relative_path TEXT,
directory_manifest JSONB,
upload_session_id UUID,
processing_status TEXT NOT NULL DEFAULT 'uploaded',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 3.3 Cabinet memberships — share a cabinet with other users
CREATE TABLE IF NOT EXISTS cabinet_memberships (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
cabinet_id UUID NOT NULL REFERENCES file_cabinets(id) ON DELETE CASCADE,
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer','editor','owner')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (cabinet_id, profile_id)
);
-- 3.4 Document artefacts — processed outputs from files (Docling, Tika, etc.)
CREATE TABLE IF NOT EXISTS document_artefacts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
type TEXT NOT NULL,
rel_path TEXT NOT NULL,
page_number INTEGER NOT NULL DEFAULT 0,
chunk_index INTEGER,
size_tag TEXT,
language TEXT,
extra JSONB,
status TEXT NOT NULL DEFAULT 'completed',
started_at TIMESTAMPTZ DEFAULT now(),
completed_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- 4. Knowledge banks (brains) — Phase G: RAG over file collections
-- ============================================================
CREATE TABLE IF NOT EXISTS brains (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
purpose TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS brain_files (
brain_id UUID NOT NULL REFERENCES brains(id) ON DELETE CASCADE,
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
PRIMARY KEY (brain_id, file_id)
);
-- ============================================================
-- 5. Class system
-- ============================================================
-- 5.1 Classes
CREATE TABLE IF NOT EXISTS classes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
name VARCHAR NOT NULL,
class_code TEXT, -- MIS identifier e.g. '9YO/Bi', '10Da'
subject VARCHAR,
key_stage TEXT, -- '3', '4', '5'
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 profiles(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 5.2 Class teachers
CREATE TABLE IF NOT EXISTS class_teachers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
teacher_id UUID NOT NULL REFERENCES 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 profiles(id),
UNIQUE (class_id, teacher_id)
);
-- 5.3 Class students
CREATE TABLE IF NOT EXISTS class_students (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
student_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
status VARCHAR NOT NULL DEFAULT 'active' CHECK (status IN ('active','inactive','pending')),
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),
enrolled_by UUID REFERENCES profiles(id),
UNIQUE (class_id, student_id)
);
-- 5.4 Enrollment requests — student self-enrollment flow (Phase D)
CREATE TABLE IF NOT EXISTS enrollment_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
student_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
status VARCHAR NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
request_message TEXT,
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
responded_at TIMESTAMPTZ,
responded_by UUID REFERENCES profiles(id),
response_message TEXT
);
-- ============================================================
-- 6. Curriculum reference (flat Supabase lookup — full graph in Neo4j Phase G)
-- ============================================================
-- 6.0 Curriculum topics — importable from curriculum.xlsx, referenced by planned_lessons
CREATE TABLE IF NOT EXISTS curriculum_topics (
id TEXT PRIMARY KEY, -- e.g. '7B1', '10P10' — matches xlsx TopicID
title TEXT NOT NULL,
subject TEXT,
key_stage TEXT, -- '3', '4', '5'
year_group TEXT,
topic_type TEXT, -- 'Standard', 'Assessment', etc.
total_lessons INTEGER,
department TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- 7. Lesson planning — Phase C
-- ============================================================
-- 7.1 Planned lessons — teacher-authored lesson plans
CREATE TABLE IF NOT EXISTS planned_lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_by UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id),
class_id UUID REFERENCES classes(id) ON DELETE SET NULL,
whiteboard_room_id UUID REFERENCES whiteboard_rooms(id) ON DELETE SET NULL,
topic_code TEXT REFERENCES curriculum_topics(id) ON DELETE SET NULL,
timetable_period_id TEXT, -- Neo4j period node reference (e.g. 'AMon1')
title TEXT NOT NULL,
subject TEXT,
year_group TEXT,
estimated_duration_minutes INTEGER,
objectives JSONB NOT NULL DEFAULT '[]',
activities JSONB NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','ready','archived')),
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 6.2 Lesson collaborators — co-planning: additional teachers on a lesson
CREATE TABLE IF NOT EXISTS lesson_collaborators (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
planned_lesson_id UUID NOT NULL REFERENCES planned_lessons(id) ON DELETE CASCADE,
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
can_edit BOOLEAN NOT NULL DEFAULT true,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (planned_lesson_id, profile_id)
);
-- 6.3 Lesson deliveries — records of when a planned lesson is actually taught
CREATE TABLE IF NOT EXISTS lesson_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
planned_lesson_id UUID REFERENCES planned_lessons(id) ON DELETE SET NULL,
delivered_by UUID NOT NULL REFERENCES profiles(id),
class_id UUID REFERENCES classes(id) ON DELETE SET NULL,
institute_id UUID NOT NULL REFERENCES institutes(id),
whiteboard_room_id UUID REFERENCES whiteboard_rooms(id) ON DELETE SET NULL,
transcription_session_id UUID, -- FK to transcription_sessions added after CIS tables
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- 8. Exam board reference (Phase F)
-- ============================================================
CREATE TABLE IF NOT EXISTS eb_specifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
spec_code TEXT UNIQUE,
exam_board_code TEXT,
award_code TEXT,
subject_code TEXT,
first_teach TEXT,
spec_ver TEXT,
storage_loc TEXT,
doc_type TEXT,
doc_details JSONB NOT NULL DEFAULT '{}',
docling_docs JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS eb_exams (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
exam_code TEXT,
spec_code TEXT REFERENCES eb_specifications(spec_code),
paper_code TEXT,
tier TEXT,
session TEXT,
type_code TEXT,
storage_loc TEXT,
doc_type TEXT,
doc_details JSONB NOT NULL DEFAULT '{}',
docling_docs JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- 9. CIS: Transcription & Canvas event system
-- ============================================================
-- 8.1 Transcription sessions
CREATE TABLE IF NOT EXISTS transcription_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT,
canvas_type TEXT DEFAULT 'tldraw',
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
duration_seconds INTEGER,
timetable_period_id TEXT,
timetable_event_type TEXT,
timetable_event_label TEXT,
auto_tagged BOOLEAN NOT NULL DEFAULT false,
llm_provider TEXT,
llm_model TEXT,
word_count INTEGER NOT NULL DEFAULT 0,
segment_count INTEGER NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}',
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 8.2 Transcription segments
CREATE TABLE IF NOT EXISTS transcription_segments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES transcription_sessions(id) ON DELETE CASCADE,
sequence_index INTEGER NOT NULL,
text TEXT NOT NULL,
start_seconds REAL NOT NULL DEFAULT 0,
end_seconds REAL NOT NULL DEFAULT 0,
is_final BOOLEAN NOT NULL DEFAULT true,
speaker_label TEXT,
keyword_matches TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 8.3 Canvas events (TLDraw interactions during a session)
CREATE TABLE IF NOT EXISTS canvas_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID REFERENCES transcription_sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
session_elapsed_seconds REAL,
event_type TEXT NOT NULL,
event_payload JSONB NOT NULL DEFAULT '{}',
canvas_snapshot_url TEXT,
tldraw_page_id TEXT,
tldraw_shape_ids TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 8.4 AI-generated summaries
CREATE TABLE IF NOT EXISTS transcription_summaries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES transcription_sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
summary_type TEXT NOT NULL,
content TEXT NOT NULL,
prompt_used TEXT,
llm_provider TEXT,
llm_model TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
segment_range_start INTEGER,
segment_range_end INTEGER,
canvas_snapshot_urls TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 8.5 Keyword watch list
CREATE TABLE IF NOT EXISTS keyword_watches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
keyword TEXT NOT NULL,
match_type TEXT NOT NULL DEFAULT 'contains',
action TEXT NOT NULL DEFAULT 'notify',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, keyword)
);
-- 8.6 Keyword events
CREATE TABLE IF NOT EXISTS keyword_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES transcription_sessions(id) ON DELETE CASCADE,
segment_id UUID REFERENCES transcription_segments(id) ON DELETE SET NULL,
keyword_watch_id UUID REFERENCES keyword_watches(id) ON DELETE SET NULL,
keyword_text TEXT NOT NULL,
matched_in_text TEXT NOT NULL,
session_elapsed_seconds REAL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Deferred FK: lesson_deliveries.transcription_session_id
ALTER TABLE lesson_deliveries
DROP CONSTRAINT IF EXISTS lesson_deliveries_transcription_session_id_fkey;
ALTER TABLE lesson_deliveries
ADD CONSTRAINT lesson_deliveries_transcription_session_id_fkey
FOREIGN KEY (transcription_session_id)
REFERENCES transcription_sessions(id) ON DELETE SET NULL;
-- ============================================================
-- 9. Updated_at trigger
-- ============================================================
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$ DECLARE
t TEXT;
BEGIN
FOREACH t IN ARRAY ARRAY[
'profiles', 'admin_profiles', 'institutes',
'institute_memberships', 'institute_membership_requests',
'whiteboard_rooms', 'cabinet_memberships',
'classes', 'planned_lessons', 'lesson_deliveries',
'transcription_sessions', 'keyword_watches',
'eb_specifications', 'eb_exams'
] LOOP
EXECUTE format(
'DROP TRIGGER IF EXISTS trg_updated_at ON %I;
CREATE TRIGGER trg_updated_at
BEFORE UPDATE ON %I
FOR EACH ROW EXECUTE FUNCTION set_updated_at();',
t, t
);
END LOOP;
END $$;
-- ============================================================
-- 10. Indexes
-- ============================================================
-- Profiles
CREATE INDEX IF NOT EXISTS idx_profiles_school_id ON profiles(school_id);
CREATE INDEX IF NOT EXISTS idx_profiles_email ON profiles(email);
CREATE INDEX IF NOT EXISTS idx_profiles_username ON profiles(username);
-- Institutes
CREATE INDEX IF NOT EXISTS idx_institutes_urn ON institutes(urn);
-- Institute memberships
CREATE INDEX IF NOT EXISTS idx_im_profile ON institute_memberships(profile_id);
CREATE INDEX IF NOT EXISTS idx_im_institute ON institute_memberships(institute_id);
-- Whiteboard rooms
CREATE INDEX IF NOT EXISTS idx_wr_user ON whiteboard_rooms(user_id);
CREATE INDEX IF NOT EXISTS idx_wr_context ON whiteboard_rooms(context_type, context_id);
-- Files
CREATE INDEX IF NOT EXISTS idx_files_cabinet ON files(cabinet_id);
CREATE INDEX IF NOT EXISTS idx_files_uploaded_by ON files(uploaded_by);
CREATE INDEX IF NOT EXISTS idx_files_status ON files(processing_status);
CREATE INDEX IF NOT EXISTS idx_files_parent ON files(parent_directory_id);
-- Document artefacts
CREATE INDEX IF NOT EXISTS idx_artefacts_file ON document_artefacts(file_id);
CREATE INDEX IF NOT EXISTS idx_artefacts_type ON document_artefacts(type);
-- Cabinet memberships
CREATE INDEX IF NOT EXISTS idx_cabinet_mb_profile ON cabinet_memberships(profile_id);
-- Brains
CREATE INDEX IF NOT EXISTS idx_brains_user ON brains(user_id);
-- Curriculum topics
CREATE INDEX IF NOT EXISTS idx_ct_subject ON curriculum_topics(subject);
CREATE INDEX IF NOT EXISTS idx_ct_key_stage ON curriculum_topics(key_stage);
CREATE INDEX IF NOT EXISTS idx_ct_year_group ON curriculum_topics(year_group);
-- Classes
CREATE INDEX IF NOT EXISTS idx_classes_institute ON classes(institute_id);
CREATE INDEX IF NOT EXISTS idx_classes_class_code ON classes(class_code);
CREATE INDEX IF NOT EXISTS idx_classes_created_by ON classes(created_by);
CREATE INDEX IF NOT EXISTS idx_class_teachers_class ON class_teachers(class_id);
CREATE INDEX IF NOT EXISTS idx_class_students_class ON class_students(class_id);
-- Planned lessons
CREATE INDEX IF NOT EXISTS idx_pl_created_by ON planned_lessons(created_by);
CREATE INDEX IF NOT EXISTS idx_pl_institute ON planned_lessons(institute_id);
CREATE INDEX IF NOT EXISTS idx_pl_class ON planned_lessons(class_id);
CREATE INDEX IF NOT EXISTS idx_pl_status ON planned_lessons(status);
CREATE INDEX IF NOT EXISTS idx_pl_topic_code ON planned_lessons(topic_code);
CREATE INDEX IF NOT EXISTS idx_pl_timetable_period ON planned_lessons(timetable_period_id);
-- Lesson deliveries
CREATE INDEX IF NOT EXISTS idx_ld_delivered_by ON lesson_deliveries(delivered_by);
CREATE INDEX IF NOT EXISTS idx_ld_planned_lesson ON lesson_deliveries(planned_lesson_id);
CREATE INDEX IF NOT EXISTS idx_ld_started_at ON lesson_deliveries(started_at DESC);
-- CIS
CREATE INDEX IF NOT EXISTS idx_ts_user ON transcription_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_ts_started ON transcription_sessions(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_ts_deleted ON transcription_sessions(deleted_at) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_seg_session ON transcription_segments(session_id, sequence_index);
CREATE INDEX IF NOT EXISTS idx_ce_session ON canvas_events(session_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_ce_user ON canvas_events(user_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_kw_user ON keyword_watches(user_id) WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_ke_session ON keyword_events(session_id);
-- ============================================================
-- 11. Row Level Security
-- ============================================================
-- Enable RLS
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE admin_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE institutes ENABLE ROW LEVEL SECURITY;
ALTER TABLE institute_memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE institute_membership_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE whiteboard_rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE file_cabinets ENABLE ROW LEVEL SECURITY;
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
ALTER TABLE cabinet_memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE document_artefacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE brains ENABLE ROW LEVEL SECURITY;
ALTER TABLE brain_files ENABLE ROW LEVEL SECURITY;
ALTER TABLE classes ENABLE ROW LEVEL SECURITY;
ALTER TABLE class_teachers ENABLE ROW LEVEL SECURITY;
ALTER TABLE class_students ENABLE ROW LEVEL SECURITY;
ALTER TABLE enrollment_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE planned_lessons ENABLE ROW LEVEL SECURITY;
ALTER TABLE lesson_collaborators ENABLE ROW LEVEL SECURITY;
ALTER TABLE lesson_deliveries ENABLE ROW LEVEL SECURITY;
ALTER TABLE curriculum_topics ENABLE ROW LEVEL SECURITY;
ALTER TABLE eb_specifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE eb_exams ENABLE ROW LEVEL SECURITY;
ALTER TABLE transcription_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE transcription_segments ENABLE ROW LEVEL SECURITY;
ALTER TABLE canvas_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE transcription_summaries ENABLE ROW LEVEL SECURITY;
ALTER TABLE keyword_watches ENABLE ROW LEVEL SECURITY;
ALTER TABLE keyword_events ENABLE ROW LEVEL SECURITY;
-- Profiles: own row + service role full access
DROP POLICY IF EXISTS "profiles_own" ON profiles;
DROP POLICY IF EXISTS "profiles_service_role" ON profiles;
CREATE POLICY "profiles_own" ON profiles FOR ALL USING (id = auth.uid());
CREATE POLICY "profiles_service_role" ON profiles FOR ALL USING (auth.role() = 'service_role');
-- Admin profiles: super admins only
DROP POLICY IF EXISTS "admin_profiles_service_role" ON admin_profiles;
CREATE POLICY "admin_profiles_service_role" ON admin_profiles FOR ALL USING (auth.role() = 'service_role');
-- Institutes: members can read, school_admin can write, service role full access
DROP POLICY IF EXISTS "institutes_member_read" ON institutes;
DROP POLICY IF EXISTS "institutes_service_role" ON institutes;
CREATE POLICY "institutes_member_read" ON institutes FOR SELECT
USING (id IN (SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()));
CREATE POLICY "institutes_service_role" ON institutes FOR ALL USING (auth.role() = 'service_role');
-- Institute memberships
DROP POLICY IF EXISTS "im_own" ON institute_memberships;
DROP POLICY IF EXISTS "im_service_role" ON institute_memberships;
CREATE POLICY "im_own" ON institute_memberships FOR ALL USING (profile_id = auth.uid());
CREATE POLICY "im_service_role" ON institute_memberships FOR ALL USING (auth.role() = 'service_role');
-- Institute membership requests
DROP POLICY IF EXISTS "imr_own" ON institute_membership_requests;
DROP POLICY IF EXISTS "imr_service_role" ON institute_membership_requests;
CREATE POLICY "imr_own" ON institute_membership_requests FOR ALL USING (profile_id = auth.uid());
CREATE POLICY "imr_service_role" ON institute_membership_requests FOR ALL USING (auth.role() = 'service_role');
-- Whiteboard rooms: own rooms
DROP POLICY IF EXISTS "wr_own" ON whiteboard_rooms;
DROP POLICY IF EXISTS "wr_service_role" ON whiteboard_rooms;
CREATE POLICY "wr_own" ON whiteboard_rooms FOR ALL USING (user_id = auth.uid());
CREATE POLICY "wr_service_role" ON whiteboard_rooms FOR ALL USING (auth.role() = 'service_role');
-- File cabinets: own cabinets
DROP POLICY IF EXISTS "fc_own" ON file_cabinets;
DROP POLICY IF EXISTS "fc_service_role" ON file_cabinets;
CREATE POLICY "fc_own" ON file_cabinets FOR ALL USING (user_id = auth.uid());
CREATE POLICY "fc_service_role" ON file_cabinets FOR ALL USING (auth.role() = 'service_role');
-- Files: own via cabinet
DROP POLICY IF EXISTS "files_own" ON files;
DROP POLICY IF EXISTS "files_service_role" ON files;
CREATE POLICY "files_own" ON files FOR ALL
USING (cabinet_id IN (SELECT id FROM file_cabinets WHERE user_id = auth.uid()));
CREATE POLICY "files_service_role" ON files FOR ALL USING (auth.role() = 'service_role');
-- Cabinet memberships
DROP POLICY IF EXISTS "cm_own" ON cabinet_memberships;
DROP POLICY IF EXISTS "cm_service_role" ON cabinet_memberships;
CREATE POLICY "cm_own" ON cabinet_memberships FOR ALL USING (profile_id = auth.uid());
CREATE POLICY "cm_service_role" ON cabinet_memberships FOR ALL USING (auth.role() = 'service_role');
-- Document artefacts: via file ownership
DROP POLICY IF EXISTS "da_own" ON document_artefacts;
DROP POLICY IF EXISTS "da_service_role" ON document_artefacts;
CREATE POLICY "da_own" ON document_artefacts FOR ALL
USING (file_id IN (
SELECT f.id FROM files f
JOIN file_cabinets fc ON fc.id = f.cabinet_id
WHERE fc.user_id = auth.uid()
));
CREATE POLICY "da_service_role" ON document_artefacts FOR ALL USING (auth.role() = 'service_role');
-- Brains
DROP POLICY IF EXISTS "brains_own" ON brains;
DROP POLICY IF EXISTS "brains_service_role" ON brains;
CREATE POLICY "brains_own" ON brains FOR ALL USING (user_id = auth.uid());
CREATE POLICY "brains_service_role" ON brains FOR ALL USING (auth.role() = 'service_role');
-- Brain files
DROP POLICY IF EXISTS "brain_files_own" ON brain_files;
DROP POLICY IF EXISTS "brain_files_service_role" ON brain_files;
CREATE POLICY "brain_files_own" ON brain_files FOR ALL
USING (brain_id IN (SELECT id FROM brains WHERE user_id = auth.uid()));
CREATE POLICY "brain_files_service_role" ON brain_files FOR ALL USING (auth.role() = 'service_role');
-- Classes: members of institute can read; teachers can write
DROP POLICY IF EXISTS "classes_institute_read" ON classes;
DROP POLICY IF EXISTS "classes_service_role" ON classes;
CREATE POLICY "classes_institute_read" ON classes FOR SELECT
USING (institute_id IN (SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()));
CREATE POLICY "classes_service_role" ON classes FOR ALL USING (auth.role() = 'service_role');
-- Class teachers / students
DROP POLICY IF EXISTS "ct_service_role" ON class_teachers;
DROP POLICY IF EXISTS "cs_service_role" ON class_students;
CREATE POLICY "ct_service_role" ON class_teachers FOR ALL USING (auth.role() = 'service_role');
CREATE POLICY "cs_service_role" ON class_students FOR ALL USING (auth.role() = 'service_role');
-- Enrollment requests
DROP POLICY IF EXISTS "er_own" ON enrollment_requests;
DROP POLICY IF EXISTS "er_service_role" ON enrollment_requests;
CREATE POLICY "er_own" ON enrollment_requests FOR ALL USING (student_id = auth.uid());
CREATE POLICY "er_service_role" ON enrollment_requests FOR ALL USING (auth.role() = 'service_role');
-- Planned lessons
DROP POLICY IF EXISTS "pl_own" ON planned_lessons;
DROP POLICY IF EXISTS "pl_collab_read" ON planned_lessons;
DROP POLICY IF EXISTS "pl_service_role" ON planned_lessons;
CREATE POLICY "pl_own" ON planned_lessons FOR ALL USING (created_by = auth.uid());
CREATE POLICY "pl_collab_read" ON planned_lessons FOR SELECT
USING (id IN (SELECT planned_lesson_id FROM lesson_collaborators WHERE profile_id = auth.uid()));
CREATE POLICY "pl_service_role" ON planned_lessons FOR ALL USING (auth.role() = 'service_role');
-- Lesson collaborators
DROP POLICY IF EXISTS "lc_own" ON lesson_collaborators;
DROP POLICY IF EXISTS "lc_service_role" ON lesson_collaborators;
CREATE POLICY "lc_own" ON lesson_collaborators FOR ALL USING (profile_id = auth.uid());
CREATE POLICY "lc_service_role" ON lesson_collaborators FOR ALL USING (auth.role() = 'service_role');
-- Lesson deliveries
DROP POLICY IF EXISTS "ld_own" ON lesson_deliveries;
DROP POLICY IF EXISTS "ld_service_role" ON lesson_deliveries;
CREATE POLICY "ld_own" ON lesson_deliveries FOR ALL USING (delivered_by = auth.uid());
CREATE POLICY "ld_service_role" ON lesson_deliveries FOR ALL USING (auth.role() = 'service_role');
-- Curriculum topics: public read, service role write
DROP POLICY IF EXISTS "ct_public_read" ON curriculum_topics;
DROP POLICY IF EXISTS "ct_service_role" ON curriculum_topics;
CREATE POLICY "ct_public_read" ON curriculum_topics FOR SELECT USING (true);
CREATE POLICY "ct_service_role" ON curriculum_topics FOR ALL USING (auth.role() = 'service_role');
-- Exam boards: public read
DROP POLICY IF EXISTS "eb_spec_public_read" ON eb_specifications;
DROP POLICY IF EXISTS "eb_spec_service_role" ON eb_specifications;
DROP POLICY IF EXISTS "eb_exam_public_read" ON eb_exams;
DROP POLICY IF EXISTS "eb_exam_service_role" ON eb_exams;
CREATE POLICY "eb_spec_public_read" ON eb_specifications FOR SELECT USING (true);
CREATE POLICY "eb_spec_service_role" ON eb_specifications FOR ALL USING (auth.role() = 'service_role');
CREATE POLICY "eb_exam_public_read" ON eb_exams FOR SELECT USING (true);
CREATE POLICY "eb_exam_service_role" ON eb_exams FOR ALL USING (auth.role() = 'service_role');
-- CIS: users own their sessions
DROP POLICY IF EXISTS "ts_own" ON transcription_sessions;
DROP POLICY IF EXISTS "seg_own" ON transcription_segments;
DROP POLICY IF EXISTS "ce_own" ON canvas_events;
DROP POLICY IF EXISTS "sum_own" ON transcription_summaries;
DROP POLICY IF EXISTS "kw_own" ON keyword_watches;
DROP POLICY IF EXISTS "ke_own" ON keyword_events;
CREATE POLICY "ts_own" ON transcription_sessions FOR ALL USING (user_id = auth.uid());
CREATE POLICY "seg_own" ON transcription_segments FOR ALL
USING (session_id IN (SELECT id FROM transcription_sessions WHERE user_id = auth.uid()));
CREATE POLICY "ce_own" ON canvas_events FOR ALL USING (user_id = auth.uid());
CREATE POLICY "sum_own" ON transcription_summaries FOR ALL USING (user_id = auth.uid());
CREATE POLICY "kw_own" ON keyword_watches FOR ALL USING (user_id = auth.uid());
CREATE POLICY "ke_own" ON keyword_events FOR ALL
USING (session_id IN (SELECT id FROM transcription_sessions WHERE user_id = auth.uid()));

View File

@ -1,191 +0,0 @@
--[ 8. Auth Functions ]--
-- Create a secure function to check admin status
create or replace function public.is_admin()
returns boolean as $$
select coalesce(
(select true
from public.profiles
where id = auth.uid()
and user_type = 'admin'),
false
);
$$ language sql security definer;
-- Create a secure function to check super admin status
create or replace function public.is_super_admin()
returns boolean as $$
select coalesce(
(select true
from public.profiles
where id = auth.uid()
and user_type = 'admin'),
false
);
$$ language sql security definer;
-- Create public wrapper functions
-- Note: These are now the main implementation functions, not wrappers
-- The original auth schema functions have been moved to public schema
-- Grant execute permissions
grant execute on function public.is_admin to authenticated;
grant execute on function public.is_super_admin to authenticated;
-- Initial admin setup function
create or replace function public.setup_initial_admin(admin_email text)
returns json
language plpgsql
security definer
as $$
declare
result json;
begin
-- Only allow this to run as service role or superuser
if not (
current_user = 'service_role'
or exists (
select 1 from pg_roles
where rolname = current_user
and rolsuper
)
) then
raise exception 'Must be run as service_role or superuser';
end if;
-- Update user_type and username for admin
update public.profiles
set user_type = 'admin',
username = coalesce(username, 'superadmin'),
display_name = coalesce(display_name, 'Super Admin')
where email = admin_email
returning json_build_object(
'id', id,
'email', email,
'user_type', user_type,
'username', username,
'display_name', display_name
) into result;
if result is null then
raise exception 'Admin user with email % not found', admin_email;
end if;
return result;
end;
$$;
-- Grant execute permissions
revoke execute on function public.setup_initial_admin from public;
grant execute on function public.setup_initial_admin to authenticated, service_role;
-- Create RPC wrapper for REST API access
create or replace function rpc.setup_initial_admin(admin_email text)
returns json
language plpgsql
security definer
as $$
begin
return public.setup_initial_admin(admin_email);
end;
$$;
-- Grant execute permissions for RPC wrapper
grant execute on function rpc.setup_initial_admin to authenticated, service_role;
--[ 9. Utility Functions ]--
-- Check if database is ready
create or replace function check_db_ready()
returns boolean
language plpgsql
security definer
as $$
begin
-- Check if essential schemas exist
if not exists (
select 1
from information_schema.schemata
where schema_name in ('auth', 'storage', 'public')
) then
return false;
end if;
-- Check if essential tables exist
if not exists (
select 1
from information_schema.tables
where table_schema = 'auth'
and table_name = 'users'
) then
return false;
end if;
-- Check if RLS is enabled on public.profiles
if not exists (
select 1
from pg_tables
where schemaname = 'public'
and tablename = 'profiles'
and rowsecurity = true
) then
return false;
end if;
return true;
end;
$$;
-- Grant execute permission
grant execute on function check_db_ready to anon, authenticated, service_role;
-- Function to handle new user registration
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = public
as $$
declare
default_user_type text := 'email_student';
default_username text;
begin
-- Generate username from email
default_username := split_part(new.email, '@', 1);
insert into public.profiles (
id,
email,
user_type,
username,
display_name
)
values (
new.id,
new.email,
coalesce(new.raw_user_meta_data->>'user_type', default_user_type),
coalesce(new.raw_user_meta_data->>'username', default_username),
coalesce(new.raw_user_meta_data->>'display_name', default_username)
);
return new;
end;
$$;
-- Trigger for new user creation
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
--[ 11. Database Triggers ]--
drop trigger if exists handle_profiles_updated_at on public.profiles;
create trigger handle_profiles_updated_at
before update on public.profiles
for each row execute function public.handle_updated_at();
drop trigger if exists handle_institute_memberships_updated_at on public.institute_memberships;
create trigger handle_institute_memberships_updated_at
before update on public.institute_memberships
for each row execute function public.handle_updated_at();
drop trigger if exists handle_membership_requests_updated_at on public.institute_membership_requests;
create trigger handle_membership_requests_updated_at
before update on public.institute_membership_requests
for each row execute function public.handle_updated_at();

View File

@ -0,0 +1,225 @@
-- ============================================================
-- Classroom Copilot — Academic Calendar Source of Truth
-- Migration 003: Supabase-backed academic calendar & timetable tables
-- Run after: 002_schema.sql
--
-- Design: Supabase is the source of truth for all editable calendar
-- and timetable data. Neo4j is a derived graph rebuilt from these tables.
-- All tables include neo4j_node_id to track the corresponding Neo4j uuid_string.
-- ============================================================
-- ─── 1. school_timetables ────────────────────────────────────────────────────
-- One row per academic year configuration per school.
-- periods_template JSONB stores the period definitions (code, name, times, type).
CREATE TABLE IF NOT EXISTS school_timetables (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
year_label TEXT NOT NULL, -- e.g. '2025-2026'
start_date DATE NOT NULL,
end_date DATE NOT NULL,
periods_template JSONB NOT NULL DEFAULT '[]',
neo4j_node_id TEXT, -- SchoolTimetable.uuid_string in Neo4j
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (institute_id, year_label)
);
-- ─── 2. academic_years ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS academic_years (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
school_timetable_id UUID NOT NULL REFERENCES school_timetables(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
year_label TEXT NOT NULL, -- '2025-2026'
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (school_timetable_id, year_label)
);
-- ─── 3. academic_terms ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS academic_terms (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
academic_year_id UUID NOT NULL REFERENCES academic_years(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
term_name TEXT NOT NULL,
term_number INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (academic_year_id, term_number)
);
-- ─── 4. academic_weeks ───────────────────────────────────────────────────────
-- week_cycle 'A'|'B' for two-week timetable cycles.
CREATE TABLE IF NOT EXISTS academic_weeks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
academic_term_id UUID NOT NULL REFERENCES academic_terms(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
week_number INTEGER NOT NULL, -- sequential within term
start_date DATE NOT NULL, -- Monday of this week
week_cycle TEXT NOT NULL DEFAULT 'A' CHECK (week_cycle IN ('A', 'B', '')),
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (academic_term_id, week_number)
);
-- ─── 5. academic_days ────────────────────────────────────────────────────────
-- One row per school day (MonFri within term bounds).
-- excluded_period_codes: period codes from the template that do NOT apply this day.
-- academic_day_number: sequential count of Academic-type days across the year.
CREATE TABLE IF NOT EXISTS academic_days (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
academic_week_id UUID NOT NULL REFERENCES academic_weeks(id) ON DELETE CASCADE,
academic_term_id UUID NOT NULL REFERENCES academic_terms(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
date DATE NOT NULL,
day_of_week TEXT NOT NULL,
day_type TEXT NOT NULL DEFAULT 'Academic'
CHECK (day_type IN ('Academic', 'Holiday', 'Staff', 'OffTimetable')),
academic_day_number INTEGER, -- null for non-Academic days
excluded_period_codes TEXT[] NOT NULL DEFAULT '{}',
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (institute_id, date)
);
-- ─── 6. teacher_timetables ───────────────────────────────────────────────────
-- One per teacher per academic year.
CREATE TABLE IF NOT EXISTS teacher_timetables (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
school_timetable_id UUID NOT NULL REFERENCES school_timetables(id) ON DELETE CASCADE,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (profile_id, school_timetable_id)
);
-- ─── 7. teacher_timetable_slots ──────────────────────────────────────────────
-- Weekly recurring slot assignments (day + period → subject class).
CREATE TABLE IF NOT EXISTS teacher_timetable_slots (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
teacher_timetable_id UUID NOT NULL REFERENCES teacher_timetables(id) ON DELETE CASCADE,
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
day_of_week TEXT NOT NULL,
period_code TEXT NOT NULL,
subject_class TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (teacher_timetable_id, day_of_week, period_code)
);
-- ============================================================
-- Indexes
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_school_tt_institute ON school_timetables(institute_id);
CREATE INDEX IF NOT EXISTS idx_academic_years_tt ON academic_years(school_timetable_id);
CREATE INDEX IF NOT EXISTS idx_academic_years_inst ON academic_years(institute_id);
CREATE INDEX IF NOT EXISTS idx_academic_terms_year ON academic_terms(academic_year_id);
CREATE INDEX IF NOT EXISTS idx_academic_terms_inst ON academic_terms(institute_id);
CREATE INDEX IF NOT EXISTS idx_academic_weeks_term ON academic_weeks(academic_term_id);
CREATE INDEX IF NOT EXISTS idx_academic_weeks_inst ON academic_weeks(institute_id);
CREATE INDEX IF NOT EXISTS idx_academic_days_week ON academic_days(academic_week_id);
CREATE INDEX IF NOT EXISTS idx_academic_days_term ON academic_days(academic_term_id);
CREATE INDEX IF NOT EXISTS idx_academic_days_inst_date ON academic_days(institute_id, date);
CREATE INDEX IF NOT EXISTS idx_teacher_tt_profile ON teacher_timetables(profile_id);
CREATE INDEX IF NOT EXISTS idx_teacher_tt_inst ON teacher_timetables(institute_id);
CREATE INDEX IF NOT EXISTS idx_tt_slots_timetable ON teacher_timetable_slots(teacher_timetable_id);
CREATE INDEX IF NOT EXISTS idx_tt_slots_profile ON teacher_timetable_slots(profile_id);
-- ============================================================
-- updated_at triggers (tables that have updated_at)
-- ============================================================
DO $$ DECLARE t TEXT; BEGIN
FOREACH t IN ARRAY ARRAY[
'school_timetables', 'teacher_timetables', 'teacher_timetable_slots'
] LOOP
EXECUTE format(
'DROP TRIGGER IF EXISTS trg_updated_at ON %I;
CREATE TRIGGER trg_updated_at
BEFORE UPDATE ON %I
FOR EACH ROW EXECUTE FUNCTION set_updated_at();',
t, t
);
END LOOP;
END $$;
-- ============================================================
-- Row Level Security
-- ============================================================
ALTER TABLE school_timetables ENABLE ROW LEVEL SECURITY;
ALTER TABLE academic_years ENABLE ROW LEVEL SECURITY;
ALTER TABLE academic_terms ENABLE ROW LEVEL SECURITY;
ALTER TABLE academic_weeks ENABLE ROW LEVEL SECURITY;
ALTER TABLE academic_days ENABLE ROW LEVEL SECURITY;
ALTER TABLE teacher_timetables ENABLE ROW LEVEL SECURITY;
ALTER TABLE teacher_timetable_slots ENABLE ROW LEVEL SECURITY;
-- school_timetables: institute members can read
CREATE POLICY "stt_inst_read" ON school_timetables FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "stt_service" ON school_timetables FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- academic_years
CREATE POLICY "ay_inst_read" ON academic_years FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "ay_service" ON academic_years FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- academic_terms
CREATE POLICY "at_inst_read" ON academic_terms FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "at_service" ON academic_terms FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- academic_weeks
CREATE POLICY "aw_inst_read" ON academic_weeks FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "aw_service" ON academic_weeks FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- academic_days
CREATE POLICY "ad_inst_read" ON academic_days FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "ad_service" ON academic_days FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- teacher_timetables: own row
CREATE POLICY "tcht_own_read" ON teacher_timetables FOR SELECT
USING (profile_id = auth.uid());
CREATE POLICY "tcht_service" ON teacher_timetables FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- teacher_timetable_slots: own rows
CREATE POLICY "tchts_own_read" ON teacher_timetable_slots FOR SELECT
USING (profile_id = auth.uid());
CREATE POLICY "tchts_service" ON teacher_timetable_slots FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');

View File

@ -1,20 +0,0 @@
-- Storage policies configuration for Supabase
-- Note: Storage bucket policies are managed by Supabase internally
-- This file provides guidance on what should be configured
-- Storage bucket policies should be configured through:
-- 1. Supabase Dashboard > Storage > Policies
-- 2. Or via SQL with proper permissions (requires service_role or owner access)
-- Recommended policies for storage.buckets:
-- - Super admin has full access to buckets
-- - Users can create their own buckets
-- - Users can view their own buckets or public buckets
-- Recommended policies for storage.objects:
-- - Users can upload to buckets they own
-- - Users can view objects in public buckets
-- - Users can manage objects in buckets they own
-- Note: These policies require the service_role or appropriate permissions
-- to be applied to the storage schema tables

View File

@ -0,0 +1,266 @@
-- ============================================================
-- Classroom Copilot — Extended Schema
-- Migration 004: academic_term_breaks, academic_periods,
-- taught_lessons, invitations + ALTER extensions
-- Run after: 003_academic_calendar.sql
-- ============================================================
-- ─── admin_profiles: add updated_at trigger (table already exists) ───────────
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgname = 'trg_updated_at'
AND tgrelid = 'public.admin_profiles'::regclass
) THEN
EXECUTE 'CREATE TRIGGER trg_updated_at
BEFORE UPDATE ON admin_profiles
FOR EACH ROW EXECUTE FUNCTION set_updated_at()';
END IF;
END $$;
-- ─── 1. academic_term_breaks ─────────────────────────────────────────────────
-- Explicit named holiday periods between terms.
-- Admins name and date these; agents can look them up or even populate them.
CREATE TABLE IF NOT EXISTS academic_term_breaks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
school_timetable_id UUID NOT NULL REFERENCES school_timetables(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
break_name TEXT NOT NULL, -- e.g. "Christmas Break", "Easter Break"
start_date DATE NOT NULL,
end_date DATE NOT NULL,
notes TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (school_timetable_id, break_name)
);
-- ─── 2. academic_periods ─────────────────────────────────────────────────────
-- One row per period per ACADEMIC day (not holiday/staff days).
-- Instantiated at timetable setup time from school_timetables.periods_template.
-- Enables per-period notes, room assignments, and substitutions.
CREATE TABLE IF NOT EXISTS academic_periods (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
academic_day_id UUID NOT NULL REFERENCES academic_days(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
period_code TEXT NOT NULL, -- e.g. "1", "2", "Reg", "Break1"
period_name TEXT NOT NULL, -- e.g. "Period 1", "Registration"
period_type TEXT NOT NULL CHECK (period_type IN ('lesson','break','registration','offtimetable')),
start_time TIME NOT NULL,
end_time TIME NOT NULL,
room_code TEXT, -- default room; overridden per taught_lesson
notes TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (academic_day_id, period_code)
);
-- ─── 3. invitations ──────────────────────────────────────────────────────────
-- Tracks all staff and student invitations. Created by school admins.
-- API calls Supabase magic link on creation; status updated on acceptance.
-- metadata: year_group for students, subject/department for staff, etc.
CREATE TABLE IF NOT EXISTS invitations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('teacher','student','school_admin','department_head')),
invited_by UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
token UUID NOT NULL DEFAULT uuid_generate_v4(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '7 days'),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','accepted','expired','cancelled')),
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Only one pending invitation per (institute, email) at a time.
-- After acceptance/expiry/cancellation a new one may be issued.
CREATE UNIQUE INDEX IF NOT EXISTS idx_invitations_pending_unique
ON invitations (institute_id, email)
WHERE (status = 'pending');
-- ─── 4. taught_lessons ───────────────────────────────────────────────────────
-- One row per actual lesson occurrence, materialized from the teacher's
-- timetable slot template × matching academic_periods across the year.
-- School admin controls the frame (periods, rooms, substitutions).
-- Teachers control the content (lesson_plan, notes, tags, status).
CREATE TABLE IF NOT EXISTS taught_lessons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
academic_period_id UUID NOT NULL REFERENCES academic_periods(id) ON DELETE CASCADE,
teacher_timetable_slot_id UUID REFERENCES teacher_timetable_slots(id) ON DELETE SET NULL,
class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
teacher_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
institute_id UUID NOT NULL REFERENCES institutes(id) ON DELETE CASCADE,
-- Denormalized for fast timeline queries (avoids 4-table joins)
date DATE NOT NULL,
period_code TEXT NOT NULL,
week_cycle TEXT NOT NULL DEFAULT '',
day_of_week TEXT NOT NULL,
-- Teacher-owned content
lesson_plan JSONB NOT NULL DEFAULT '{}',
whiteboard_room_id UUID REFERENCES whiteboard_rooms(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned','in_progress','completed','cancelled','substituted')),
substitute_teacher_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
notes TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
neo4j_node_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (academic_period_id, teacher_id)
);
-- ─── 5. Extend existing tables with notes + tags ──────────────────────────────
-- ADD COLUMN IF NOT EXISTS is idempotent — safe to re-run.
ALTER TABLE academic_terms ADD COLUMN IF NOT EXISTS notes TEXT;
ALTER TABLE academic_terms ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE academic_weeks ADD COLUMN IF NOT EXISTS notes TEXT;
ALTER TABLE academic_weeks ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE academic_days ADD COLUMN IF NOT EXISTS notes TEXT;
ALTER TABLE academic_days ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
-- week_cycle on teacher_timetable_slots: '' = applies both weeks, 'A'/'B' = specific cycle.
ALTER TABLE teacher_timetable_slots ADD COLUMN IF NOT EXISTS week_cycle TEXT NOT NULL DEFAULT '';
-- Drop old UNIQUE and replace with cycle-aware version.
-- The old constraint was (teacher_timetable_id, day_of_week, period_code).
-- PostgreSQL's generated name may differ/truncate across bootstrap history, so detect
-- the actual constraint by constrained column names instead of a stale hard-coded name.
DO $$
DECLARE
old_constraint_name TEXT;
BEGIN
SELECT con.conname INTO old_constraint_name
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
WHERE nsp.nspname = 'public'
AND rel.relname = 'teacher_timetable_slots'
AND con.contype = 'u'
AND (
SELECT array_agg(att.attname::text ORDER BY ord.ordinality)
FROM unnest(con.conkey) WITH ORDINALITY AS ord(attnum, ordinality)
JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = ord.attnum
) = ARRAY['teacher_timetable_id', 'day_of_week', 'period_code']::text[]
LIMIT 1;
IF old_constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE public.teacher_timetable_slots DROP CONSTRAINT %I', old_constraint_name);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tts_unique_slot'
AND conrelid = 'public.teacher_timetable_slots'::regclass
) THEN
ALTER TABLE public.teacher_timetable_slots
ADD CONSTRAINT tts_unique_slot UNIQUE (teacher_timetable_id, week_cycle, day_of_week, period_code);
END IF;
END $$;
-- ─── 6. Indexes ───────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_term_breaks_tt ON academic_term_breaks(school_timetable_id);
CREATE INDEX IF NOT EXISTS idx_term_breaks_inst ON academic_term_breaks(institute_id);
CREATE INDEX IF NOT EXISTS idx_ap_day ON academic_periods(academic_day_id);
CREATE INDEX IF NOT EXISTS idx_ap_inst ON academic_periods(institute_id);
CREATE INDEX IF NOT EXISTS idx_ap_type ON academic_periods(period_type);
CREATE INDEX IF NOT EXISTS idx_inv_inst ON invitations(institute_id);
CREATE INDEX IF NOT EXISTS idx_inv_email ON invitations(email);
CREATE INDEX IF NOT EXISTS idx_inv_token ON invitations(token);
CREATE INDEX IF NOT EXISTS idx_inv_status ON invitations(status);
CREATE INDEX IF NOT EXISTS idx_tl_period ON taught_lessons(academic_period_id);
CREATE INDEX IF NOT EXISTS idx_tl_teacher ON taught_lessons(teacher_id);
CREATE INDEX IF NOT EXISTS idx_tl_class ON taught_lessons(class_id);
CREATE INDEX IF NOT EXISTS idx_tl_inst ON taught_lessons(institute_id);
CREATE INDEX IF NOT EXISTS idx_tl_date ON taught_lessons(date);
CREATE INDEX IF NOT EXISTS idx_tl_inst_date ON taught_lessons(institute_id, date);
-- ─── 7. updated_at trigger ────────────────────────────────────────────────────
DO $$ DECLARE t TEXT; BEGIN
FOREACH t IN ARRAY ARRAY['taught_lessons'] LOOP
EXECUTE format(
'DROP TRIGGER IF EXISTS trg_updated_at ON %I;
CREATE TRIGGER trg_updated_at BEFORE UPDATE ON %I
FOR EACH ROW EXECUTE FUNCTION set_updated_at();',
t, t
);
END LOOP;
END $$;
-- ─── 8. Row Level Security ────────────────────────────────────────────────────
ALTER TABLE academic_term_breaks ENABLE ROW LEVEL SECURITY;
ALTER TABLE academic_periods ENABLE ROW LEVEL SECURITY;
ALTER TABLE invitations ENABLE ROW LEVEL SECURITY;
ALTER TABLE taught_lessons ENABLE ROW LEVEL SECURITY;
-- ── academic_term_breaks ──────────────────────────────────────────────────────
-- Any institute member can read; all writes via service_role (API).
CREATE POLICY "atb_inst_read" ON academic_term_breaks FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "atb_service" ON academic_term_breaks FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- ── academic_periods ──────────────────────────────────────────────────────────
-- Any institute member can read; all writes via service_role (API).
CREATE POLICY "ap_inst_read" ON academic_periods FOR SELECT
USING (institute_id IN (
SELECT institute_id FROM institute_memberships WHERE profile_id = auth.uid()
));
CREATE POLICY "ap_service" ON academic_periods FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- ── invitations ───────────────────────────────────────────────────────────────
-- School admins and the inviter can view their school's invitations.
-- All mutations via service_role (invitations created server-side only).
CREATE POLICY "inv_admin_read" ON invitations FOR SELECT
USING (
invited_by = auth.uid()
OR institute_id IN (
SELECT institute_id FROM institute_memberships
WHERE profile_id = auth.uid()
AND role IN ('school_admin', 'department_head')
)
);
CREATE POLICY "inv_service" ON invitations FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- ── taught_lessons ────────────────────────────────────────────────────────────
-- Teachers read their own lessons; school admins read all in their school.
-- Teachers can UPDATE their own lesson content (plan, notes, tags, status).
-- Frame changes (room, substitute) and lesson creation: service_role only.
CREATE POLICY "tl_read" ON taught_lessons FOR SELECT
USING (
teacher_id = auth.uid()
OR institute_id IN (
SELECT institute_id FROM institute_memberships
WHERE profile_id = auth.uid()
AND role IN ('school_admin', 'department_head')
)
);
CREATE POLICY "tl_teacher_update" ON taught_lessons FOR UPDATE
USING (teacher_id = auth.uid())
WITH CHECK (teacher_id = auth.uid());
CREATE POLICY "tl_service" ON taught_lessons FOR ALL
USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');

View File

@ -1,20 +0,0 @@
-- Initial admin setup for ClassroomCopilot
-- This file handles basic database setup and permissions
-- Ensure uuid-ossp extension is enabled
create extension if not exists "uuid-ossp" schema extensions;
-- Grant basic permissions to authenticated users for public schema
-- Note: These permissions are granted to allow users to work with the application
grant usage on schema public to authenticated;
grant all on all tables in schema public to authenticated;
grant all on all sequences in schema public to authenticated;
grant all on all functions in schema public to authenticated;
-- Set default privileges for future objects
alter default privileges in schema public grant all on tables to authenticated;
alter default privileges in schema public grant all on sequences to authenticated;
alter default privileges in schema public grant all on functions to authenticated;
-- Note: The setup_initial_admin function is defined in 62-functions-triggers.sql
-- and should be called with an admin email parameter when needed

View File

@ -1,95 +0,0 @@
-- Files table augments and storage GC hooks
-- 1) Add columns to files if missing
do $$
begin
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='files' and column_name='uploaded_by'
) then
alter table public.files add column uploaded_by uuid references public.profiles(id);
end if;
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='files' and column_name='size_bytes'
) then
alter table public.files add column size_bytes bigint;
end if;
if not exists (
select 1 from information_schema.columns
where table_schema='public' and table_name='files' and column_name='source'
) then
alter table public.files add column source text default 'uploader-web';
end if;
end $$;
-- 2) Unique index for cabinet/path combo
create unique index if not exists uq_files_cabinet_path on public.files(cabinet_id, path);
-- 3) Storage GC helpers (ported from neoFS with storage schema)
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;
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._storage_gc_sql()
returns trigger
language plpgsql security definer
set search_path to 'public', 'storage'
as $$
begin
if tg_op = 'DELETE' then
perform public._delete_storage_objects(old.bucket, old.path);
elsif tg_op = 'UPDATE' then
if (old.bucket is distinct from new.bucket) or (old.path is distinct from new.path) then
perform public._delete_storage_objects(old.bucket, old.path);
end if;
end if;
return null;
end
$$;
-- 4) Attach GC trigger to files bucket/path changes
drop trigger if exists trg_files_gc on public.files;
create trigger trg_files_gc
after delete or update of bucket, path on public.files
for each row execute function public._storage_gc_sql();
-- 5) Document artefacts GC: remove artefact objects from storage when rows change/delete
create or replace function public._artefact_gc_sql()
returns trigger
language plpgsql security definer
set search_path to 'public', 'storage'
as $$
declare
v_bucket text;
begin
if tg_op = 'DELETE' then
select f.bucket into v_bucket from public.files f where f.id = old.file_id;
perform public._delete_storage_objects(v_bucket, old.rel_path);
return old;
elsif tg_op = 'UPDATE' then
if (old.rel_path is distinct from new.rel_path) or (old.file_id is distinct from new.file_id) then
select f.bucket into v_bucket from public.files f where f.id = old.file_id;
perform public._delete_storage_objects(v_bucket, old.rel_path);
end if;
return new;
end if;
end
$$;
drop trigger if exists trg_document_artefacts_gc on public.document_artefacts;
create trigger trg_document_artefacts_gc
before delete or update of file_id, rel_path on public.document_artefacts
for each row execute function public._artefact_gc_sql();

View File

@ -0,0 +1,53 @@
-- ============================================================
-- Classroom Copilot — Phase C Migration
-- 003: Clean schema + lesson planning tables
-- Run after: 002_schema.sql
-- ============================================================
-- ============================================================
-- 1. Drop legacy tables (Neo4j-era, replaced by Phase B/C design)
-- ============================================================
DROP TABLE IF EXISTS lesson_whiteboards CASCADE;
DROP TABLE IF EXISTS timetable_lessons CASCADE;
DROP TABLE IF EXISTS timetable_classes CASCADE;
DROP TABLE IF EXISTS timetables CASCADE;
DROP TABLE IF EXISTS lessons CASCADE;
DROP TABLE IF EXISTS audit_logs CASCADE;
DROP TABLE IF EXISTS function_logs CASCADE;
-- ============================================================
-- 2. planned_lessons — drop Neo4j-era field, add course support
-- ============================================================
-- Drop stale Neo4j reference field and its index
DROP INDEX IF EXISTS idx_pl_timetable_period;
ALTER TABLE planned_lessons DROP COLUMN IF EXISTS timetable_period_id;
-- Course support (nullable — populated when courses feature ships)
ALTER TABLE planned_lessons
ADD COLUMN IF NOT EXISTS course_id UUID,
ADD COLUMN IF NOT EXISTS sequence_number INTEGER;
CREATE INDEX IF NOT EXISTS idx_pl_course ON planned_lessons(course_id) WHERE course_id IS NOT NULL;
-- ============================================================
-- 3. lesson_deliveries — link to taught_lessons
-- ============================================================
ALTER TABLE lesson_deliveries
ADD COLUMN IF NOT EXISTS taught_lesson_id UUID
REFERENCES taught_lessons(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_ld_taught_lesson ON lesson_deliveries(taught_lesson_id)
WHERE taught_lesson_id IS NOT NULL;
-- ============================================================
-- 4. Verify Phase C tables exist (idempotent — safe to re-run)
-- These are defined in 002_schema.sql; IF NOT EXISTS means
-- running 002 first is sufficient, but listed here for clarity.
-- ============================================================
-- planned_lessons, lesson_collaborators, lesson_deliveries
-- curriculum_topics
-- All present in 002_schema.sql — no action needed here.

View File

@ -1,84 +0,0 @@
-- Enable RLS and define policies for filesystem tables
-- 1) Enable RLS
alter table if exists public.file_cabinets enable row level security;
alter table if exists public.files enable row level security;
alter table if exists public.brain_files enable row level security;
alter table if exists public.document_artefacts enable row level security;
drop policy if exists "User can access own cabinets" on public.file_cabinets;
create policy "User can access own cabinets" on public.file_cabinets
using (user_id = auth.uid())
with check (user_id = auth.uid());
drop policy if exists "User can access files in own cabinet" on public.files;
create policy "User can access files in own cabinet" on public.files
using (exists (
select 1 from public.file_cabinets c
where c.id = files.cabinet_id and c.user_id = auth.uid()
))
with check (exists (
select 1 from public.file_cabinets c
where c.id = files.cabinet_id and c.user_id = auth.uid()
));
drop policy if exists "User can insert files into own cabinet" on public.files;
create policy "User can insert files into own cabinet" on public.files for insert to authenticated
with check (exists (
select 1 from public.file_cabinets c
where c.id = files.cabinet_id and c.user_id = auth.uid()
));
drop policy if exists "User can update files in own cabinet" on public.files;
create policy "User can update files in own cabinet" on public.files for update to authenticated
using (exists (
select 1 from public.file_cabinets c
where c.id = files.cabinet_id and c.user_id = auth.uid()
))
with check (exists (
select 1 from public.file_cabinets c
where c.id = files.cabinet_id and c.user_id = auth.uid()
));
drop policy if exists "User can delete files from own cabinet" on public.files;
create policy "User can delete files from own cabinet" on public.files for delete
using (exists (
select 1 from public.file_cabinets c
where c.id = files.cabinet_id and c.user_id = auth.uid()
));
-- 4) Brain-files: allow linking owned files to owned brains
drop policy if exists "User can link files they own to their brains" on public.brain_files;
create policy "User can link files they own to their brains" on public.brain_files
using (
exists (select 1 from public.brains b where b.id = brain_files.brain_id and b.user_id = auth.uid())
and exists (
select 1 from public.files f join public.file_cabinets c on f.cabinet_id = c.id
where f.id = brain_files.file_id and c.user_id = auth.uid()
)
)
with check (true);
-- 5) Document artefacts: allow reads to owners via file cabinet, writes via service_role
drop policy if exists "artefacts_read_by_owner" on public.document_artefacts;
create policy "artefacts_read_by_owner" on public.document_artefacts for select to authenticated
using (exists (
select 1 from public.files f join public.file_cabinets c on f.cabinet_id = c.id
where f.id = document_artefacts.file_id and c.user_id = auth.uid()
));
drop policy if exists "artefacts_rw_service" on public.document_artefacts;
create policy "artefacts_rw_service" on public.document_artefacts to service_role
using (true) with check (true);
-- Allow owners to delete their artefacts (needed for cascades under RLS)
drop policy if exists "artefacts_delete_by_owner" on public.document_artefacts;
create policy "artefacts_delete_by_owner" on public.document_artefacts for delete to authenticated
using (exists (
select 1 from public.files f join public.file_cabinets c on f.cabinet_id = c.id
where f.id = document_artefacts.file_id and c.user_id = auth.uid()
));
-- File vectors RLS and policies are defined in 67-vectors.sql after the table is created

View File

@ -0,0 +1,15 @@
-- ============================================================
-- Migration 005: taught_lessons nullable class_id
-- + class_id FK on teacher_timetable_slots
-- Run after: 004_extended_schema.sql
-- ============================================================
-- taught_lessons.class_id: allow null so slots without a matched class can still materialize
ALTER TABLE taught_lessons ALTER COLUMN class_id DROP NOT NULL;
-- teacher_timetable_slots: add proper class FK alongside existing subject_class text
ALTER TABLE teacher_timetable_slots
ADD COLUMN IF NOT EXISTS class_id UUID REFERENCES classes(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_tl_class_id ON taught_lessons(class_id) WHERE class_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_tts_class_id ON teacher_timetable_slots(class_id) WHERE class_id IS NOT NULL;

View File

@ -0,0 +1,220 @@
-- ============================================================
-- Classroom Copilot — deterministic development seed
-- Migration 067: small, repeatable fixtures for Supabase dev/staging
-- Run after: 066-taught-lessons-nullable.sql
--
-- This intentionally excludes the full GAIS open-data import. It creates a
-- compact school, users, classes, timetable, lessons, and storage bucket
-- fixtures suitable for local/dev smoke tests without sensitive live data.
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Stable dev identities. Password for all fixture users is "devpassword".
INSERT INTO auth.users (
instance_id, id, aud, role, email, encrypted_password,
email_confirmed_at, raw_app_meta_data, raw_user_meta_data,
created_at, updated_at, confirmation_token, recovery_token, email_change_token_new, email_change
)
VALUES
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated', 'platform.admin@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Platform Admin","user_type":"admin"}', now(), now(), '', '', '', ''),
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000002', 'authenticated', 'authenticated', 'school.admin@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"School Admin","user_type":"admin"}', now(), now(), '', '', '', ''),
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000011', 'authenticated', 'authenticated', 'ada.teacher@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Ada Teacher","user_type":"teacher"}', now(), now(), '', '', '', ''),
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000012', 'authenticated', 'authenticated', 'alan.teacher@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Alan Teacher","user_type":"teacher"}', now(), now(), '', '', '', ''),
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000101', 'authenticated', 'authenticated', 's1.student@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Student One","user_type":"student"}', now(), now(), '', '', '', ''),
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000102', 'authenticated', 'authenticated', 's2.student@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Student Two","user_type":"student"}', now(), now(), '', '', '', ''),
('00000000-0000-0000-0000-000000000000', '00000000-0000-4000-8000-000000000103', 'authenticated', 'authenticated', 's3.student@classroomcopilot.dev', crypt('devpassword', gen_salt('bf')), now(), '{"provider":"email","providers":["email"]}', '{"display_name":"Student Three","user_type":"student"}', now(), now(), '', '', '', '')
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.profiles (id, email, user_type, username, full_name, display_name, school_id, metadata)
VALUES
('00000000-0000-4000-8000-000000000001', 'platform.admin@classroomcopilot.dev', 'admin', 'platform-admin', 'Platform Admin', 'Platform Admin', NULL, '{"seed":"dev","role":"platform_admin"}'),
('00000000-0000-4000-8000-000000000002', 'school.admin@classroomcopilot.dev', 'admin', 'school-admin', 'School Admin', 'School Admin', NULL, '{"seed":"dev","role":"school_admin"}'),
('00000000-0000-4000-8000-000000000011', 'ada.teacher@classroomcopilot.dev', 'teacher', 'ada-teacher', 'Ada Teacher', 'Ada Teacher', NULL, '{"seed":"dev","department":"Science"}'),
('00000000-0000-4000-8000-000000000012', 'alan.teacher@classroomcopilot.dev', 'teacher', 'alan-teacher', 'Alan Teacher', 'Alan Teacher', NULL, '{"seed":"dev","department":"Science"}'),
('00000000-0000-4000-8000-000000000101', 's1.student@classroomcopilot.dev', 'student', 'student-one', 'Student One', 'Student One', NULL, '{"seed":"dev","year_group":"9"}'),
('00000000-0000-4000-8000-000000000102', 's2.student@classroomcopilot.dev', 'student', 'student-two', 'Student Two', 'Student Two', NULL, '{"seed":"dev","year_group":"9"}'),
('00000000-0000-4000-8000-000000000103', 's3.student@classroomcopilot.dev', 'student', 'student-three', 'Student Three', 'Student Three', NULL, '{"seed":"dev","year_group":"10"}')
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
user_type = EXCLUDED.user_type,
username = EXCLUDED.username,
full_name = EXCLUDED.full_name,
display_name = EXCLUDED.display_name,
school_id = EXCLUDED.school_id,
metadata = EXCLUDED.metadata;
INSERT INTO public.admin_profiles (id, email, display_name, admin_role, is_super_admin, metadata)
VALUES ('00000000-0000-4000-8000-000000000001', 'platform.admin@classroomcopilot.dev', 'Platform Admin', 'platform_admin', true, '{"seed":"dev"}')
ON CONFLICT (id) DO UPDATE SET is_super_admin = true, metadata = EXCLUDED.metadata;
INSERT INTO public.institutes (id, name, urn, status, address, website, metadata, geo_coordinates)
VALUES (
'00000000-0000-4000-8000-000000000201',
'Classroom Copilot Dev School',
'DEV0001',
'active',
'{"line1":"1 Fixture Road","town":"Dev Town","postcode":"CC1 1DV","country":"GB"}',
'https://classroomcopilot.dev',
'{"seed":"dev","local_authority":"Fixture LA"}',
'{"lat":51.5007,"lon":-0.1246}'
)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, metadata = EXCLUDED.metadata;
UPDATE public.profiles
SET school_id = '00000000-0000-4000-8000-000000000201'
WHERE id <> '00000000-0000-4000-8000-000000000001';
INSERT INTO public.institute_memberships (profile_id, institute_id, role, metadata)
VALUES
('00000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000201', 'school_admin', '{"seed":"dev"}'),
('00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'teacher', '{"seed":"dev"}'),
('00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', 'teacher', '{"seed":"dev"}'),
('00000000-0000-4000-8000-000000000101', '00000000-0000-4000-8000-000000000201', 'student', '{"seed":"dev"}'),
('00000000-0000-4000-8000-000000000102', '00000000-0000-4000-8000-000000000201', 'student', '{"seed":"dev"}'),
('00000000-0000-4000-8000-000000000103', '00000000-0000-4000-8000-000000000201', 'student', '{"seed":"dev"}')
ON CONFLICT (profile_id, institute_id) DO UPDATE SET role = EXCLUDED.role, metadata = EXCLUDED.metadata;
INSERT INTO public.classes (id, institute_id, name, class_code, subject, key_stage, year_group, academic_year, description, created_by)
VALUES
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000201', '9 Science A', '9SCI-A', 'Science', '3', '9', '2026-2027', 'Deterministic dev Year 9 science class', '00000000-0000-4000-8000-000000000002'),
('00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000201', '10 Physics B', '10PHY-B', 'Physics', '4', '10', '2026-2027', 'Deterministic dev Year 10 physics class', '00000000-0000-4000-8000-000000000002')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, class_code = EXCLUDED.class_code;
INSERT INTO public.class_teachers (class_id, teacher_id, is_primary, assigned_by)
VALUES
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000011', true, '00000000-0000-4000-8000-000000000002'),
('00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000012', true, '00000000-0000-4000-8000-000000000002')
ON CONFLICT (class_id, teacher_id) DO UPDATE SET is_primary = EXCLUDED.is_primary;
INSERT INTO public.class_students (class_id, student_id, enrolled_by)
VALUES
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000101', '00000000-0000-4000-8000-000000000002'),
('00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000102', '00000000-0000-4000-8000-000000000002'),
('00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000103', '00000000-0000-4000-8000-000000000002')
ON CONFLICT (class_id, student_id) DO UPDATE SET status = 'active';
INSERT INTO public.whiteboard_rooms (id, user_id, institute_id, name, context_type, context_id, is_default, storage_path, node_type)
VALUES
('00000000-0000-4000-8000-000000000401', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'Ada Teacher Workspace', 'profile', '00000000-0000-4000-8000-000000000011', true, 'cc.users/00000000-0000-4000-8000-000000000011/tldraw/default.json', 'profile_workspace'),
('00000000-0000-4000-8000-000000000402', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', 'Alan Teacher Workspace', 'profile', '00000000-0000-4000-8000-000000000012', true, 'cc.users/00000000-0000-4000-8000-000000000012/tldraw/default.json', 'profile_workspace')
ON CONFLICT (id) DO UPDATE SET storage_path = EXCLUDED.storage_path, is_default = EXCLUDED.is_default;
INSERT INTO public.school_timetables (id, institute_id, year_label, start_date, end_date, periods_template)
VALUES (
'00000000-0000-4000-8000-000000000501',
'00000000-0000-4000-8000-000000000201',
'2026-2027',
'2026-09-01',
'2026-09-07',
'[{"code":"P1","name":"Period 1","type":"lesson","start_time":"09:00","end_time":"10:00"},{"code":"P2","name":"Period 2","type":"lesson","start_time":"10:05","end_time":"11:05"},{"code":"BR","name":"Break","type":"break","start_time":"11:05","end_time":"11:25"},{"code":"P3","name":"Period 3","type":"lesson","start_time":"11:25","end_time":"12:25"}]'
)
ON CONFLICT (institute_id, year_label) DO UPDATE SET periods_template = EXCLUDED.periods_template;
INSERT INTO public.academic_years (id, school_timetable_id, institute_id, year_label)
VALUES ('00000000-0000-4000-8000-000000000511', '00000000-0000-4000-8000-000000000501', '00000000-0000-4000-8000-000000000201', '2026-2027')
ON CONFLICT (school_timetable_id, year_label) DO NOTHING;
INSERT INTO public.academic_terms (id, academic_year_id, institute_id, term_name, term_number, start_date, end_date, notes, tags)
VALUES ('00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000511', '00000000-0000-4000-8000-000000000201', 'Autumn fixture week', 1, '2026-09-01', '2026-09-07', 'Single deterministic week for dev smoke tests', '{dev}')
ON CONFLICT (academic_year_id, term_number) DO UPDATE SET start_date = EXCLUDED.start_date, end_date = EXCLUDED.end_date;
INSERT INTO public.academic_weeks (id, academic_term_id, institute_id, week_number, start_date, week_cycle, notes, tags)
VALUES ('00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', 1, '2026-09-01', 'A', 'Deterministic week A', '{dev}')
ON CONFLICT (academic_term_id, week_number) DO UPDATE SET week_cycle = EXCLUDED.week_cycle;
INSERT INTO public.academic_days (id, academic_week_id, academic_term_id, institute_id, date, day_of_week, academic_day_number, notes, tags)
VALUES
('00000000-0000-4000-8000-000000000541', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-01', 'Tuesday', 1, 'Fixture day 1', '{dev}'),
('00000000-0000-4000-8000-000000000542', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-02', 'Wednesday', 2, 'Fixture day 2', '{dev}'),
('00000000-0000-4000-8000-000000000543', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-03', 'Thursday', 3, 'Fixture day 3', '{dev}'),
('00000000-0000-4000-8000-000000000544', '00000000-0000-4000-8000-000000000531', '00000000-0000-4000-8000-000000000521', '00000000-0000-4000-8000-000000000201', '2026-09-04', 'Friday', 4, 'Fixture day 4', '{dev}')
ON CONFLICT (institute_id, date) DO UPDATE SET academic_day_number = EXCLUDED.academic_day_number;
INSERT INTO public.academic_periods (id, academic_day_id, institute_id, period_code, period_name, period_type, start_time, end_time)
SELECT
('00000000-0000-4000-8000-' || lpad((600 + d.day_no * 10 + p.period_no)::text, 12, '0'))::uuid,
d.id,
'00000000-0000-4000-8000-000000000201'::uuid,
p.period_code,
p.period_name,
p.period_type,
p.start_time::time,
p.end_time::time
FROM (VALUES
('00000000-0000-4000-8000-000000000541'::uuid, 1),
('00000000-0000-4000-8000-000000000542'::uuid, 2),
('00000000-0000-4000-8000-000000000543'::uuid, 3),
('00000000-0000-4000-8000-000000000544'::uuid, 4)
) AS d(id, day_no)
CROSS JOIN (VALUES
(1, 'P1', 'Period 1', 'lesson', '09:00', '10:00'),
(2, 'P2', 'Period 2', 'lesson', '10:05', '11:05'),
(3, 'BR', 'Break', 'break', '11:05', '11:25'),
(4, 'P3', 'Period 3', 'lesson', '11:25', '12:25')
) AS p(period_no, period_code, period_name, period_type, start_time, end_time)
ON CONFLICT (academic_day_id, period_code) DO UPDATE SET period_name = EXCLUDED.period_name;
INSERT INTO public.teacher_timetables (id, profile_id, institute_id, school_timetable_id, start_date, end_date)
VALUES
('00000000-0000-4000-8000-000000000701', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000501', '2026-09-01', '2026-09-07'),
('00000000-0000-4000-8000-000000000702', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000501', '2026-09-01', '2026-09-07')
ON CONFLICT (profile_id, school_timetable_id) DO UPDATE SET start_date = EXCLUDED.start_date, end_date = EXCLUDED.end_date;
INSERT INTO public.teacher_timetable_slots (id, teacher_timetable_id, profile_id, institute_id, day_of_week, period_code, subject_class, start_time, end_time, week_cycle, class_id)
VALUES
('00000000-0000-4000-8000-000000000711', '00000000-0000-4000-8000-000000000701', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'Tuesday', 'P1', '9 Science A', '09:00', '10:00', 'A', '00000000-0000-4000-8000-000000000301'),
('00000000-0000-4000-8000-000000000712', '00000000-0000-4000-8000-000000000701', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', 'Wednesday', 'P2', '9 Science A', '10:05', '11:05', 'A', '00000000-0000-4000-8000-000000000301'),
('00000000-0000-4000-8000-000000000713', '00000000-0000-4000-8000-000000000702', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', 'Thursday', 'P1', '10 Physics B', '09:00', '10:00', 'A', '00000000-0000-4000-8000-000000000302')
ON CONFLICT (teacher_timetable_id, week_cycle, day_of_week, period_code) DO UPDATE SET class_id = EXCLUDED.class_id, subject_class = EXCLUDED.subject_class;
INSERT INTO public.taught_lessons (id, academic_period_id, teacher_timetable_slot_id, class_id, teacher_id, institute_id, date, period_code, week_cycle, day_of_week, lesson_plan, whiteboard_room_id, status, notes, tags)
VALUES
('00000000-0000-4000-8000-000000000801', '00000000-0000-4000-8000-000000000611', '00000000-0000-4000-8000-000000000711', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '2026-09-01', 'P1', 'A', 'Tuesday', '{"title":"Forces baseline","objectives":["Describe balanced and unbalanced forces"]}', '00000000-0000-4000-8000-000000000401', 'planned', 'Dev fixture taught lesson', '{dev,science}'),
('00000000-0000-4000-8000-000000000802', '00000000-0000-4000-8000-000000000622', '00000000-0000-4000-8000-000000000712', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '2026-09-02', 'P2', 'A', 'Wednesday', '{"title":"Particle model recap","objectives":["Compare solids liquids and gases"]}', '00000000-0000-4000-8000-000000000401', 'planned', 'Dev fixture taught lesson', '{dev,science}'),
('00000000-0000-4000-8000-000000000803', '00000000-0000-4000-8000-000000000631', '00000000-0000-4000-8000-000000000713', '00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', '2026-09-03', 'P1', 'A', 'Thursday', '{"title":"Energy stores","objectives":["Identify energy stores in examples"]}', '00000000-0000-4000-8000-000000000402', 'planned', 'Dev fixture taught lesson', '{dev,physics}')
ON CONFLICT (academic_period_id, teacher_id) DO UPDATE SET lesson_plan = EXCLUDED.lesson_plan, status = EXCLUDED.status;
INSERT INTO public.curriculum_topics (id, title, subject, key_stage, year_group, topic_type, total_lessons, department)
VALUES
('DEV-SCI-FORCES', 'Forces baseline', 'Science', '3', '9', 'Standard', 1, 'Science'),
('DEV-PHY-ENERGY', 'Energy stores', 'Physics', '4', '10', 'Standard', 1, 'Science')
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title;
INSERT INTO public.planned_lessons (id, created_by, institute_id, class_id, whiteboard_room_id, topic_code, title, subject, year_group, estimated_duration_minutes, objectives, activities, status, tags)
VALUES
('00000000-0000-4000-8000-000000000901', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000401', 'DEV-SCI-FORCES', 'Forces baseline planned lesson', 'Science', '9', 60, '["Describe balanced and unbalanced forces"]', '[{"type":"starter","title":"Force diagrams"}]', 'ready', '{dev,science}'),
('00000000-0000-4000-8000-000000000902', '00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000302', '00000000-0000-4000-8000-000000000402', 'DEV-PHY-ENERGY', 'Energy stores planned lesson', 'Physics', '10', 60, '["Identify energy stores"]', '[{"type":"main","title":"Energy transfer circus"}]', 'ready', '{dev,physics}')
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, objectives = EXCLUDED.objectives;
INSERT INTO public.lesson_deliveries (id, planned_lesson_id, taught_lesson_id, delivered_by, class_id, institute_id, whiteboard_room_id, started_at, ended_at, notes)
VALUES
('00000000-0000-4000-8000-000000000911', '00000000-0000-4000-8000-000000000901', '00000000-0000-4000-8000-000000000801', '00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000301', '00000000-0000-4000-8000-000000000201', '00000000-0000-4000-8000-000000000401', '2026-09-01 09:00:00+00', '2026-09-01 10:00:00+00', 'Delivered lesson fixture')
ON CONFLICT (id) DO UPDATE SET taught_lesson_id = EXCLUDED.taught_lesson_id;
-- Dev storage buckets expected by app/API. Object rows are intentionally omitted;
-- TLDraw paths above point at where empty/default snapshots should be written.
INSERT INTO storage.buckets (id, name, public)
VALUES
('cc.users', 'cc.users', false),
('cc.public.snapshots', 'cc.public.snapshots', false),
('cc.examboards', 'cc.examboards', true)
ON CONFLICT (id) DO UPDATE SET public = EXCLUDED.public;
-- Lightweight verification breadcrumbs for SQL-level smoke checks.
DO $$
DECLARE
profile_count integer;
institute_count integer;
class_count integer;
taught_count integer;
BEGIN
SELECT count(*) INTO profile_count FROM public.profiles WHERE metadata->>'seed' = 'dev';
SELECT count(*) INTO institute_count FROM public.institutes WHERE metadata->>'seed' = 'dev';
SELECT count(*) INTO class_count FROM public.classes WHERE academic_year = '2026-2027';
SELECT count(*) INTO taught_count FROM public.taught_lessons WHERE tags @> ARRAY['dev'];
IF profile_count <> 7 OR institute_count <> 1 OR class_count <> 2 OR taught_count <> 3 THEN
RAISE EXCEPTION 'Dev seed verification failed: profiles=%, institutes=%, classes=%, taught_lessons=%', profile_count, institute_count, class_count, taught_count;
END IF;
END $$;

View File

@ -1,79 +0,0 @@
-- Vectors: file_vectors table and similarity search function
-- 1) Ensure pgvector extension is available
create extension if not exists vector;
-- 2) File vectors table
create table if not exists public.file_vectors (
id bigint generated by default as identity primary key,
created_at timestamp with time zone default now() not null,
embedding public.vector,
metadata jsonb,
content text
);
-- 3) ANN index (skipped until embedding dimension is fixed)
-- To enable: set column type to public.vector(<dim>) and uncomment:
-- create index if not exists file_vectors_embedding_idx
-- on public.file_vectors using ivfflat (embedding public.vector_cosine_ops)
-- with (lists='100');
-- 3b) Enable RLS and set policies (moved here to avoid ordering issues)
alter table if exists public.file_vectors enable row level security;
drop policy if exists "vectors_read_by_owner" on public.file_vectors;
create policy "vectors_read_by_owner" on public.file_vectors for select to authenticated
using (coalesce((metadata->>'file_id')::uuid, null) is null or exists (
select 1 from public.files f join public.file_cabinets c on f.cabinet_id = c.id
where f.id = (metadata->>'file_id')::uuid and c.user_id = auth.uid()
));
drop policy if exists "vectors_rw_service" on public.file_vectors;
create policy "vectors_rw_service" on public.file_vectors to service_role
using (true) with check (true);
-- 4) Match function mirrored from neoFS (generic metadata mapping)
create or replace function public.match_file_vectors(
filter jsonb,
match_count integer,
query_embedding public.vector
)
returns table (
id bigint,
file_id uuid,
cabinet_id uuid,
artefact_type text,
artefact_is text,
original_path_prefix text,
original_filename text,
content text,
metadata jsonb,
similarity double precision
)
language sql stable as $$
select
fv.id,
nullif(fv.metadata->>'file_id','')::uuid as file_id,
nullif(fv.metadata->>'cabinet_id','')::uuid as cabinet_id,
nullif(fv.metadata->>'artefact_type','') as artefact_type,
nullif(fv.metadata->>'artefact_is','') as artefact_is,
nullif(fv.metadata->>'original_path_prefix','') as original_path_prefix,
nullif(fv.metadata->>'original_filename','') as original_filename,
fv.content,
fv.metadata,
1 - (fv.embedding <=> query_embedding) as similarity
from public.file_vectors fv
where
(coalesce(filter ? 'file_id', false) = false or (fv.metadata->>'file_id')::uuid = (filter->>'file_id')::uuid)
and (coalesce(filter ? 'cabinet_id', false) = false or (fv.metadata->>'cabinet_id')::uuid = (filter->>'cabinet_id')::uuid)
and (coalesce(filter ? 'artefact_type', false) = false or (fv.metadata->>'artefact_type') = (filter->>'artefact_type'))
and (coalesce(filter ? 'artefact_id', false) = false or (fv.metadata->>'artefact_id') = (filter->>'artefact_id'))
and (coalesce(filter ? 'original_path_prefix', false) = false or (fv.metadata->>'original_path_prefix') like (filter->>'original_path_prefix') || '%')
and (coalesce(filter ? 'original_path_prefix_ilike', false)= false or (fv.metadata->>'original_path_prefix') ilike (filter->>'original_path_prefix_ilike') || '%')
and (coalesce(filter ? 'original_filename', false) = false or (fv.metadata->>'original_filename') = (filter->>'original_filename'))
and (coalesce(filter ? 'original_filename_ilike', false)= false or (fv.metadata->>'original_filename') ilike (filter->>'original_filename_ilike'))
order by fv.embedding <=> query_embedding
limit greatest(coalesce(match_count, 10), 1)
$$;

View File

@ -1,73 +0,0 @@
-- Cabinet memberships for sharing access
create table if not exists public.cabinet_memberships (
id uuid default uuid_generate_v4() primary key,
cabinet_id uuid not null references public.file_cabinets(id) on delete cascade,
profile_id uuid not null references public.profiles(id) on delete cascade,
role text not null check (role in ('owner','editor','viewer')),
created_at timestamp with time zone default timezone('utc'::text, now()),
updated_at timestamp with time zone default timezone('utc'::text, now()),
unique(cabinet_id, profile_id)
);
create index if not exists idx_cabinet_memberships_cabinet on public.cabinet_memberships(cabinet_id);
create index if not exists idx_cabinet_memberships_profile on public.cabinet_memberships(profile_id);
-- Updated at trigger
drop trigger if exists trg_cabinet_memberships_updated_at on public.cabinet_memberships;
create trigger trg_cabinet_memberships_updated_at
before update on public.cabinet_memberships
for each row execute function public.handle_updated_at();
-- RLS and policies
alter table if exists public.cabinet_memberships enable row level security;
-- Members can select their own memberships; cabinet owners can also see memberships
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 exists (
select 1 from public.file_cabinets c where c.id = cabinet_memberships.cabinet_id and c.user_id = auth.uid()
)
);
-- Cabinet owners can insert memberships
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 (exists (
select 1 from public.file_cabinets c where c.id = cabinet_memberships.cabinet_id and c.user_id = auth.uid()
));
-- Cabinet owners can update memberships (e.g., role)
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 (exists (
select 1 from public.file_cabinets c where c.id = cabinet_memberships.cabinet_id and c.user_id = auth.uid()
))
with check (exists (
select 1 from public.file_cabinets c where c.id = cabinet_memberships.cabinet_id and c.user_id = auth.uid()
));
-- Cabinet owners can delete memberships
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 (exists (
select 1 from public.file_cabinets c where c.id = cabinet_memberships.cabinet_id and c.user_id = auth.uid()
));
-- Extend access to cabinets/files for members (after table exists)
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 (exists (
select 1 from public.cabinet_memberships m
where m.cabinet_id = file_cabinets.id and m.profile_id = auth.uid()
));
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 (exists (
select 1 from public.cabinet_memberships m
where m.cabinet_id = files.cabinet_id and m.profile_id = auth.uid()
));

View File

@ -1,48 +0,0 @@
-- Ensure storage objects for all artefacts are removed when a file is deleted
-- by deleting the entire "cabinet_id/file_id" directory prefix in Storage.
-- Helper to delete all objects under a prefix
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;
-- 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
$$;
-- Update file-level GC to also delete the parent directory prefix (cabinet_id/file_id)
create or replace function public._storage_gc_sql()
returns trigger
language plpgsql security definer
set search_path to 'public', 'storage'
as $$
declare
v_prefix text;
begin
-- Derive directory prefix from the file path by removing the last segment (filename)
-- Example: 'cabinet_id/file_id/filename.ext' -> 'cabinet_id/file_id'
v_prefix := regexp_replace(old.path, '/[^/]+$', '');
if tg_op = 'DELETE' then
-- Delete the original object and any artefacts under the file's directory
perform public._delete_storage_objects(old.bucket, old.path);
perform public._delete_storage_prefix(old.bucket, v_prefix);
elsif tg_op = 'UPDATE' then
if (old.bucket is distinct from new.bucket) or (old.path is distinct from new.path) then
perform public._delete_storage_objects(old.bucket, old.path);
perform public._delete_storage_prefix(old.bucket, v_prefix);
end if;
end if;
return null;
end
$$;

View File

@ -1,41 +0,0 @@
-- Add directory support to files table
-- Migration: Add directory support for folder uploads
-- Add new columns to files table
ALTER TABLE files
ADD COLUMN IF NOT EXISTS is_directory BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS parent_directory_id UUID REFERENCES files(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS relative_path TEXT,
ADD COLUMN IF NOT EXISTS directory_manifest JSONB,
ADD COLUMN IF NOT EXISTS upload_session_id UUID,
ADD COLUMN IF NOT EXISTS processing_status TEXT DEFAULT 'uploaded' CHECK (processing_status IN ('uploaded', 'processing', 'completed', 'failed', 'queued'));
-- Create index for efficient directory queries
CREATE INDEX IF NOT EXISTS idx_files_parent_directory ON files(parent_directory_id);
CREATE INDEX IF NOT EXISTS idx_files_upload_session ON files(upload_session_id);
CREATE INDEX IF NOT EXISTS idx_files_processing_status ON files(processing_status);
CREATE INDEX IF NOT EXISTS idx_files_is_directory ON files(is_directory);
-- Create directory manifest structure
COMMENT ON COLUMN files.is_directory IS 'True if this record represents a directory/folder';
COMMENT ON COLUMN files.parent_directory_id IS 'ID of parent directory if this file is inside an uploaded folder';
COMMENT ON COLUMN files.relative_path IS 'Relative path within the uploaded directory structure';
COMMENT ON COLUMN files.directory_manifest IS 'JSON manifest of directory contents including file count, total size, structure';
COMMENT ON COLUMN files.upload_session_id IS 'Groups files uploaded together in a single directory upload session';
COMMENT ON COLUMN files.processing_status IS 'Simple status tracking without auto-processing';
-- Example directory_manifest structure:
-- {
-- "total_files": 15,
-- "total_size_bytes": 12345678,
-- "directory_structure": {
-- "documents/": {
-- "file1.pdf": {"size": 123456, "mime_type": "application/pdf"},
-- "subdirectory/": {
-- "file2.docx": {"size": 234567, "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}
-- }
-- }
-- },
-- "upload_timestamp": "2024-09-23T12:00:00Z",
-- "upload_method": "directory_picker"
-- }

View File

@ -1,184 +0,0 @@
-- 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));

View File

@ -1,238 +0,0 @@
-- 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()));

View File

@ -1,42 +0,0 @@
-- 73-exam-marker-regions.sql
-- Extends the exam-marker physical model (72-exam-marker.sql) for the locked S4-9 shape taxonomy
-- (user discussion 2026-06-06; see ~/cc/ideas/2026-06-06-s4-9-design-answers-from-transcript.md).
--
-- Taxonomy: Boundary carves main Questions; a teacher draws a box around each Part; inside a Part
-- live bounded regions of several KINDS. "Band"/"span" are retired. Idempotent (IF NOT EXISTS /
-- drop-and-re-add the named CHECK). Additive on top of 72; safe to re-run.
--==========================================================================================
-- 1. exam_questions: geometry for the drawn Part box (and optional main-question region)
--==========================================================================================
-- Geometry previously lived only on response areas. A Part is now a drawn box, so the question
-- row carries its own bounds + page. Nullable: main questions are derived between their boundaries.
alter table public.exam_questions add column if not exists bounds jsonb; -- {x,y,w,h}
alter table public.exam_questions add column if not exists page int;
comment on column public.exam_questions.bounds is 'Drawn box for a Part (leaf); null for a derived main question';
comment on column public.exam_questions.page is 'Page index the Part box sits on; null for derived main questions';
--==========================================================================================
-- 2. exam_response_areas: more region kinds + context differentiation
--==========================================================================================
-- v1 keeps one generic Context but plans subject-specific differentiation later
-- (graph, chart, data_table, diagram, code_block, passage, …). Nullable now.
alter table public.exam_response_areas add column if not exists context_type text;
comment on column public.exam_response_areas.context_type is
'Optional Context differentiation (v1 generic); future: graph|chart|data_table|diagram|code_block|passage';
-- Extend the kind enum. Region kinds now:
-- response - where the student writes (uses response_form)
-- context - stimulus the question/part draws on (uses context_type)
-- question_number - bounds the printed label "01" / "2.1" (physical metadata for OCR/AI)
-- mark_area - bounds the printed marks "[2]" / "Total for Question X is N marks"
-- reference - formulae/data sheets, appendices the student uses (kept, NOT ignored)
-- furniture - margins, page numbers, blank space, decoration (explicitly excluded)
alter table public.exam_response_areas drop constraint if exists exam_response_areas_kind_check;
alter table public.exam_response_areas add constraint exam_response_areas_kind_check
check (kind in ('response','context','question_number','mark_area','reference','furniture'));
comment on column public.exam_response_areas.kind is
'response|context|question_number|mark_area|reference|furniture (see 73-exam-marker-regions.sql)';

View File

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

View File

@ -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;

View File

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

View File

@ -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.';