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