diff --git a/docker-compose.yml b/docker-compose.yml index d001442..1e9879c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -355,17 +355,16 @@ services: - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/59-logs.sql:Z # Changes required for Pooler support - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/59-pooler.sql:Z - # ClassroomCopilot changes - - ./volumes/db/cc/61-core-schema.sql:/docker-entrypoint-initdb.d/migrations/61-core-schema.sql:Z - - ./volumes/db/cc/62-functions-triggers.sql:/docker-entrypoint-initdb.d/migrations/62-functions-triggers.sql:Z - - ./volumes/db/cc/63-storage-policies.sql:/docker-entrypoint-initdb.d/migrations/63-storage-policies.sql:Z - - ./volumes/db/cc/64-initial-admin.sql:/docker-entrypoint-initdb.d/migrations/64-initial-admin.sql:Z - - ./volumes/db/cc/65-filesystem-augments.sql:/docker-entrypoint-initdb.d/migrations/65-filesystem-augments.sql:Z - - ./volumes/db/cc/66-rls-policies.sql:/docker-entrypoint-initdb.d/migrations/66-rls-policies.sql:Z - - ./volumes/db/cc/67-vectors.sql:/docker-entrypoint-initdb.d/migrations/67-vectors.sql:Z - - ./volumes/db/cc/68-cabinet-memberships.sql:/docker-entrypoint-initdb.d/migrations/68-cabinet-memberships.sql:Z - - ./volumes/db/cc/69-gc-prefix-cleanup.sql:/docker-entrypoint-initdb.d/migrations/69-gc-prefix-cleanup.sql:Z - - ./volumes/db/cc/70-add-directory-support.sql:/docker-entrypoint-initdb.d/migrations/70-add-directory-support.sql:Z + # Classroom Copilot consolidated application schema and deterministic dev seed. + # Keep this chain ordered; GAIS reference tables are schema-only here, while + # full GAIS open-data bulk loads remain outside the small dev seed. + - ./volumes/db/cc/61-gais-reference.sql:/docker-entrypoint-initdb.d/migrations/61-gais-reference.sql:Z + - ./volumes/db/cc/62-application-schema.sql:/docker-entrypoint-initdb.d/migrations/62-application-schema.sql:Z + - ./volumes/db/cc/63-academic-calendar.sql:/docker-entrypoint-initdb.d/migrations/63-academic-calendar.sql:Z + - ./volumes/db/cc/64-extended-schema.sql:/docker-entrypoint-initdb.d/migrations/64-extended-schema.sql:Z + - ./volumes/db/cc/65-phase-c.sql:/docker-entrypoint-initdb.d/migrations/65-phase-c.sql:Z + - ./volumes/db/cc/66-taught-lessons-nullable.sql:/docker-entrypoint-initdb.d/migrations/66-taught-lessons-nullable.sql:Z + - ./volumes/db/cc/67-dev-seed.sql:/docker-entrypoint-initdb.d/migrations/67-dev-seed.sql:Z # PGDATA directory - persists database files between restarts - ./volumes/db-data:/var/lib/postgresql/data:Z # Use named volume to persist pgsodium decryption key between restarts diff --git a/docs/migrations-and-dev-seed.md b/docs/migrations-and-dev-seed.md new file mode 100644 index 0000000..10254c0 --- /dev/null +++ b/docs/migrations-and-dev-seed.md @@ -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_ +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. diff --git a/volumes/db/cc/61-core-schema.sql b/volumes/db/cc/61-core-schema.sql deleted file mode 100644 index d49d7b6..0000000 --- a/volumes/db/cc/61-core-schema.sql +++ /dev/null @@ -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(); diff --git a/volumes/db/cc/61-gais-reference.sql b/volumes/db/cc/61-gais-reference.sql new file mode 100644 index 0000000..ea04d7b --- /dev/null +++ b/volumes/db/cc/61-gais-reference.sql @@ -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); diff --git a/volumes/db/cc/62-application-schema.sql b/volumes/db/cc/62-application-schema.sql new file mode 100644 index 0000000..8c39518 --- /dev/null +++ b/volumes/db/cc/62-application-schema.sql @@ -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())); diff --git a/volumes/db/cc/62-functions-triggers.sql b/volumes/db/cc/62-functions-triggers.sql deleted file mode 100644 index 8c13d65..0000000 --- a/volumes/db/cc/62-functions-triggers.sql +++ /dev/null @@ -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(); \ No newline at end of file diff --git a/volumes/db/cc/63-academic-calendar.sql b/volumes/db/cc/63-academic-calendar.sql new file mode 100644 index 0000000..ba0b289 --- /dev/null +++ b/volumes/db/cc/63-academic-calendar.sql @@ -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 (Mon–Fri 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'); diff --git a/volumes/db/cc/63-storage-policies.sql b/volumes/db/cc/63-storage-policies.sql deleted file mode 100644 index 952e9b4..0000000 --- a/volumes/db/cc/63-storage-policies.sql +++ /dev/null @@ -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 \ No newline at end of file diff --git a/volumes/db/cc/64-extended-schema.sql b/volumes/db/cc/64-extended-schema.sql new file mode 100644 index 0000000..f1a42fd --- /dev/null +++ b/volumes/db/cc/64-extended-schema.sql @@ -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'); diff --git a/volumes/db/cc/64-initial-admin.sql b/volumes/db/cc/64-initial-admin.sql deleted file mode 100644 index d4aa11f..0000000 --- a/volumes/db/cc/64-initial-admin.sql +++ /dev/null @@ -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 \ No newline at end of file diff --git a/volumes/db/cc/65-filesystem-augments.sql b/volumes/db/cc/65-filesystem-augments.sql deleted file mode 100644 index bb07296..0000000 --- a/volumes/db/cc/65-filesystem-augments.sql +++ /dev/null @@ -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(); - - diff --git a/volumes/db/cc/65-phase-c.sql b/volumes/db/cc/65-phase-c.sql new file mode 100644 index 0000000..9f8a2d4 --- /dev/null +++ b/volumes/db/cc/65-phase-c.sql @@ -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. diff --git a/volumes/db/cc/66-rls-policies.sql b/volumes/db/cc/66-rls-policies.sql deleted file mode 100644 index d76126d..0000000 --- a/volumes/db/cc/66-rls-policies.sql +++ /dev/null @@ -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 - - diff --git a/volumes/db/cc/66-taught-lessons-nullable.sql b/volumes/db/cc/66-taught-lessons-nullable.sql new file mode 100644 index 0000000..0f6932d --- /dev/null +++ b/volumes/db/cc/66-taught-lessons-nullable.sql @@ -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; diff --git a/volumes/db/cc/67-dev-seed.sql b/volumes/db/cc/67-dev-seed.sql new file mode 100644 index 0000000..3d5e588 --- /dev/null +++ b/volumes/db/cc/67-dev-seed.sql @@ -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 $$; diff --git a/volumes/db/cc/67-vectors.sql b/volumes/db/cc/67-vectors.sql deleted file mode 100644 index bf9a172..0000000 --- a/volumes/db/cc/67-vectors.sql +++ /dev/null @@ -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() 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) -$$; - - diff --git a/volumes/db/cc/68-cabinet-memberships.sql b/volumes/db/cc/68-cabinet-memberships.sql deleted file mode 100644 index 0d0ef3e..0000000 --- a/volumes/db/cc/68-cabinet-memberships.sql +++ /dev/null @@ -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() -)); - - diff --git a/volumes/db/cc/69-gc-prefix-cleanup.sql b/volumes/db/cc/69-gc-prefix-cleanup.sql deleted file mode 100644 index 1f53f1a..0000000 --- a/volumes/db/cc/69-gc-prefix-cleanup.sql +++ /dev/null @@ -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 -$$; - - diff --git a/volumes/db/cc/70-add-directory-support.sql b/volumes/db/cc/70-add-directory-support.sql deleted file mode 100644 index 8f42b9b..0000000 --- a/volumes/db/cc/70-add-directory-support.sql +++ /dev/null @@ -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" --- }