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