supabase/volumes/db/cc/62-application-schema.sql

764 lines
36 KiB
PL/PgSQL

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