Consolidate Supabase migrations and dev seed

This commit is contained in:
kcar 2026-05-28 11:51:27 +00:00
parent eab7c01f46
commit 02f68387e8
19 changed files with 1728 additions and 1026 deletions

View File

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

@ -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"
-- }