supabase/volumes/db/cc/64-extended-schema.sql

267 lines
14 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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