267 lines
14 KiB
SQL
267 lines
14 KiB
SQL
-- ============================================================
|
||
-- 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');
|