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