From 1b918418bcc5115f5895fa06baba5bae6a268fa4 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 22 Aug 2026 23:10:58 +0000 Subject: [PATCH] Terminal --- .../20260807000400_notes_and_tags.sql | 296 ------------------ 1 file changed, 296 deletions(-) delete mode 100644 supabase/migrations/20260807000400_notes_and_tags.sql diff --git a/supabase/migrations/20260807000400_notes_and_tags.sql b/supabase/migrations/20260807000400_notes_and_tags.sql deleted file mode 100644 index 4aca3ba..0000000 --- a/supabase/migrations/20260807000400_notes_and_tags.sql +++ /dev/null @@ -1,296 +0,0 @@ --- Structured student notes and configurable tags — spec sections 4 and 7. --- --- Categories and tags are tables, not enums, because the spec requires --- administrators to add their own. Both ship seeded with the lists the --- directive names. --- --- The permission requirement is the interesting part: "teachers should not --- automatically see confidential management, billing, medical, legal, or --- employee-related notes". Each category therefore carries a default audience, --- and each note may narrow it further. - --- ============================================================================ --- 1. NOTE CATEGORIES --- ============================================================================ - -CREATE TABLE IF NOT EXISTS public.note_categories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - slug TEXT NOT NULL UNIQUE, - description TEXT, - -- Audience a new note in this category gets unless the author narrows it. - default_visible_to_roles app_role[] NOT NULL - DEFAULT ARRAY['admin','org_admin','super_admin','campus_admin','management','teacher','staff']::app_role[], - is_confidential BOOLEAN NOT NULL DEFAULT FALSE, - sort_order INTEGER NOT NULL DEFAULT 100, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -GRANT SELECT, INSERT, UPDATE, DELETE ON public.note_categories TO authenticated; -GRANT ALL ON public.note_categories TO service_role; -ALTER TABLE public.note_categories ENABLE ROW LEVEL SECURITY; - --- Seeded from the spec's suggested topic list. Categories that are confidential --- by default exclude teachers and general staff from the outset. -INSERT INTO public.note_categories (name, slug, is_confidential, sort_order, default_visible_to_roles) VALUES - ('Academic', 'academic', FALSE, 10, - ARRAY['admin','org_admin','super_admin','campus_admin','management','teacher','staff']::app_role[]), - ('Behavioral', 'behavioral', FALSE, 20, - ARRAY['admin','org_admin','super_admin','campus_admin','management','teacher','staff']::app_role[]), - ('Attendance', 'attendance', FALSE, 30, - ARRAY['admin','org_admin','super_admin','campus_admin','management','teacher','staff']::app_role[]), - ('Parent communication', 'parent_communication', FALSE, 40, - ARRAY['admin','org_admin','super_admin','campus_admin','management','teacher','staff']::app_role[]), - ('Medical', 'medical', TRUE, 50, - ARRAY['admin','org_admin','super_admin','campus_admin','management']::app_role[]), - ('Billing', 'billing', TRUE, 60, - ARRAY['admin','org_admin','super_admin','billing_admin','management']::app_role[]), - ('Transportation', 'transportation', FALSE, 70, - ARRAY['admin','org_admin','super_admin','campus_admin','management','teacher','staff']::app_role[]), - ('Support services', 'support_services', FALSE, 80, - ARRAY['admin','org_admin','super_admin','campus_admin','management','teacher']::app_role[]), - ('Pickup or dismissal', 'pickup_dismissal', FALSE, 90, - ARRAY['admin','org_admin','super_admin','campus_admin','management','teacher','staff']::app_role[]), - ('Administrative', 'administrative', TRUE, 100, - ARRAY['admin','org_admin','super_admin','campus_admin','management']::app_role[]), - ('Incident', 'incident', FALSE, 110, - ARRAY['admin','org_admin','super_admin','campus_admin','management','teacher','staff']::app_role[]), - ('Legal', 'legal', TRUE, 120, - ARRAY['admin','org_admin','super_admin','management']::app_role[]), - ('General', 'general', FALSE, 130, - ARRAY['admin','org_admin','super_admin','campus_admin','management','teacher','staff']::app_role[]) -ON CONFLICT (slug) DO NOTHING; - --- ============================================================================ --- 2. STUDENT NOTES --- ============================================================================ - -CREATE TABLE IF NOT EXISTS public.student_notes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, - category_id UUID NOT NULL REFERENCES public.note_categories(id) ON DELETE RESTRICT, - - title TEXT NOT NULL, - body TEXT NOT NULL, - - -- The contact this note concerns, when it involves a guardian. - household_member_id UUID REFERENCES public.household_members(id) ON DELETE SET NULL, - campus_id UUID REFERENCES public.campuses(id) ON DELETE SET NULL, - - author_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), - follow_up_date DATE, - - -- Cross-links the spec asks for. An "incident" is itself a note filed under - -- the Incident category, so relatedness is expressed as a self-reference - -- rather than a separate table. - related_note_id UUID REFERENCES public.student_notes(id) ON DELETE SET NULL, - related_thread_id UUID REFERENCES public.message_threads(id) ON DELETE SET NULL, - related_attendance_id UUID REFERENCES public.attendance(id) ON DELETE SET NULL, - related_ledger_entry_id UUID REFERENCES public.ledger_entries(id) ON DELETE SET NULL, - related_alert_id UUID REFERENCES public.student_alerts(id) ON DELETE SET NULL, - - visible_to_roles app_role[] NOT NULL DEFAULT '{}', - parent_visible BOOLEAN NOT NULL DEFAULT FALSE, - - resolution_status TEXT NOT NULL DEFAULT 'open', - resolved_at TIMESTAMPTZ, - resolved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, - - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - CONSTRAINT sn_resolution_valid - CHECK (resolution_status IN ('open','in_progress','resolved','no_action_needed')) -); - -GRANT SELECT, INSERT, UPDATE, DELETE ON public.student_notes TO authenticated; -GRANT ALL ON public.student_notes TO service_role; -ALTER TABLE public.student_notes ENABLE ROW LEVEL SECURITY; - -CREATE INDEX IF NOT EXISTS sn_student_idx ON public.student_notes (student_id, occurred_at DESC); -CREATE INDEX IF NOT EXISTS sn_category_idx ON public.student_notes (category_id); -CREATE INDEX IF NOT EXISTS sn_author_idx ON public.student_notes (author_id); -CREATE INDEX IF NOT EXISTS sn_campus_idx ON public.student_notes (campus_id); -CREATE INDEX IF NOT EXISTS sn_member_idx ON public.student_notes (household_member_id); -CREATE INDEX IF NOT EXISTS sn_related_note_idx ON public.student_notes (related_note_id); -CREATE INDEX IF NOT EXISTS sn_related_thread_idx ON public.student_notes (related_thread_id); -CREATE INDEX IF NOT EXISTS sn_related_attendance_idx ON public.student_notes (related_attendance_id); -CREATE INDEX IF NOT EXISTS sn_related_ledger_idx ON public.student_notes (related_ledger_entry_id); -CREATE INDEX IF NOT EXISTS sn_related_alert_idx ON public.student_notes (related_alert_id); --- Follow-up queue: open items with a date, the "what needs chasing" screen. -CREATE INDEX IF NOT EXISTS sn_followup_idx ON public.student_notes (follow_up_date) - WHERE follow_up_date IS NOT NULL AND resolution_status IN ('open','in_progress'); - -DROP TRIGGER IF EXISTS trg_sn_upd ON public.student_notes; -CREATE TRIGGER trg_sn_upd BEFORE UPDATE ON public.student_notes - FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); - --- An empty visible_to_roles means "use the category default". Filling it in at --- insert time freezes the audience, so later edits to a category cannot --- retroactively widen who can read notes already written. -CREATE OR REPLACE FUNCTION public.apply_note_default_visibility() -RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ -BEGIN - IF NEW.visible_to_roles IS NULL OR cardinality(NEW.visible_to_roles) = 0 THEN - SELECT default_visible_to_roles INTO NEW.visible_to_roles - FROM public.note_categories WHERE id = NEW.category_id; - END IF; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS trg_sn_visibility ON public.student_notes; -CREATE TRIGGER trg_sn_visibility BEFORE INSERT ON public.student_notes - FOR EACH ROW EXECUTE FUNCTION public.apply_note_default_visibility(); - -CREATE TABLE IF NOT EXISTS public.student_note_attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - note_id UUID NOT NULL REFERENCES public.student_notes(id) ON DELETE CASCADE, - file_path TEXT NOT NULL, - file_name TEXT, - content_type TEXT, - uploaded_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -GRANT SELECT, INSERT, UPDATE, DELETE ON public.student_note_attachments TO authenticated; -GRANT ALL ON public.student_note_attachments TO service_role; -ALTER TABLE public.student_note_attachments ENABLE ROW LEVEL SECURITY; -CREATE INDEX IF NOT EXISTS sna_note_idx ON public.student_note_attachments (note_id); - --- ============================================================================ --- 3. STUDENT TAGS --- ============================================================================ - -CREATE TABLE IF NOT EXISTS public.student_tags ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL UNIQUE, - slug TEXT NOT NULL UNIQUE, - description TEXT, - color TEXT, - -- System tags are seeded and referenced by billing rules; administrators may - -- add their own but should not delete these. - is_system BOOLEAN NOT NULL DEFAULT FALSE, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - sort_order INTEGER NOT NULL DEFAULT 100, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -GRANT SELECT, INSERT, UPDATE, DELETE ON public.student_tags TO authenticated; -GRANT ALL ON public.student_tags TO service_role; -ALTER TABLE public.student_tags ENABLE ROW LEVEL SECURITY; - -INSERT INTO public.student_tags (name, slug, is_system, sort_order) VALUES - ('Full-time', 'full_time', TRUE, 10), - ('Part-time', 'part_time', TRUE, 20), - ('Support student', 'support_student', TRUE, 30), - ('Scholarship student', 'scholarship_student', TRUE, 40), - ('Year-round', 'year_round', TRUE, 50), - ('Payment plan', 'payment_plan', TRUE, 60), - ('Medical alert', 'medical_alert', TRUE, 70), - ('Custody restriction', 'custody_restriction', TRUE, 80), - ('Transportation', 'transportation', TRUE, 90), - ('Early drop-off', 'early_dropoff', TRUE, 100), - ('Late pick-up', 'late_pickup', TRUE, 110), - ('Campus transfer', 'campus_transfer', TRUE, 120), - ('Administrative review required','admin_review', TRUE, 130) -ON CONFLICT (slug) DO NOTHING; - -CREATE TABLE IF NOT EXISTS public.student_tag_assignments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE, - tag_id UUID NOT NULL REFERENCES public.student_tags(id) ON DELETE CASCADE, - assigned_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, - assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), - notes TEXT, - UNIQUE (student_id, tag_id) -); - -GRANT SELECT, INSERT, UPDATE, DELETE ON public.student_tag_assignments TO authenticated; -GRANT ALL ON public.student_tag_assignments TO service_role; -ALTER TABLE public.student_tag_assignments ENABLE ROW LEVEL SECURITY; -CREATE INDEX IF NOT EXISTS sta_student_idx ON public.student_tag_assignments (student_id); -CREATE INDEX IF NOT EXISTS sta_tag_idx ON public.student_tag_assignments (tag_id); - -CREATE OR REPLACE FUNCTION public.student_has_tag(_student UUID, _slug TEXT) -RETURNS BOOLEAN -LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public -AS $$ - SELECT EXISTS ( - SELECT 1 FROM public.student_tag_assignments a - JOIN public.student_tags t ON t.id = a.tag_id - WHERE a.student_id = _student AND t.slug = _slug AND t.is_active - ) -$$; - --- ============================================================================ --- 4. POLICIES --- ============================================================================ - -DROP POLICY IF EXISTS "note_categories read" ON public.note_categories; -CREATE POLICY "note_categories read" ON public.note_categories FOR SELECT TO authenticated USING (TRUE); -DROP POLICY IF EXISTS "note_categories manage" ON public.note_categories; -CREATE POLICY "note_categories manage" ON public.note_categories FOR ALL TO authenticated - USING (public.is_org_admin()) WITH CHECK (public.is_org_admin()); - --- A note is readable when the caller can reach the student AND the note's --- audience includes one of their roles. Authors always retain access to what --- they wrote; parents only see notes explicitly marked parent_visible. -DROP POLICY IF EXISTS "student_notes read" ON public.student_notes; -CREATE POLICY "student_notes read" ON public.student_notes FOR SELECT TO authenticated - USING ( - public.is_org_admin() - OR public.is_auditor() - OR author_id = (SELECT auth.uid()) - OR (public.can_access_student(student_id) AND public.current_user_has_any_role(visible_to_roles)) - OR (parent_visible AND public.is_parent_of(student_id)) - ); - --- Any staff member who can reach the student may file a note, but only as --- themselves. Editing is limited to the author and org admins. -DROP POLICY IF EXISTS "student_notes insert" ON public.student_notes; -CREATE POLICY "student_notes insert" ON public.student_notes FOR INSERT TO authenticated - WITH CHECK (author_id = (SELECT auth.uid()) AND public.can_access_student(student_id)); - -DROP POLICY IF EXISTS "student_notes update" ON public.student_notes; -CREATE POLICY "student_notes update" ON public.student_notes FOR UPDATE TO authenticated - USING (public.is_org_admin() OR author_id = (SELECT auth.uid())) - WITH CHECK (public.is_org_admin() OR author_id = (SELECT auth.uid())); - -DROP POLICY IF EXISTS "student_notes delete" ON public.student_notes; -CREATE POLICY "student_notes delete" ON public.student_notes FOR DELETE TO authenticated - USING (public.is_org_admin()); - --- Attachments inherit the readability of their note. -DROP POLICY IF EXISTS "note attachments read" ON public.student_note_attachments; -CREATE POLICY "note attachments read" ON public.student_note_attachments FOR SELECT TO authenticated - USING (EXISTS (SELECT 1 FROM public.student_notes n WHERE n.id = note_id)); - -DROP POLICY IF EXISTS "note attachments write" ON public.student_note_attachments; -CREATE POLICY "note attachments write" ON public.student_note_attachments FOR ALL TO authenticated - USING ( - public.is_org_admin() - OR EXISTS (SELECT 1 FROM public.student_notes n - WHERE n.id = note_id AND n.author_id = (SELECT auth.uid())) - ) - WITH CHECK ( - public.is_org_admin() - OR EXISTS (SELECT 1 FROM public.student_notes n - WHERE n.id = note_id AND n.author_id = (SELECT auth.uid())) - ); - -DROP POLICY IF EXISTS "student_tags read" ON public.student_tags; -CREATE POLICY "student_tags read" ON public.student_tags FOR SELECT TO authenticated USING (TRUE); -DROP POLICY IF EXISTS "student_tags manage" ON public.student_tags; -CREATE POLICY "student_tags manage" ON public.student_tags FOR ALL TO authenticated - USING (public.is_org_admin()) WITH CHECK (public.is_org_admin()); - -DROP POLICY IF EXISTS "tag assignments read" ON public.student_tag_assignments; -CREATE POLICY "tag assignments read" ON public.student_tag_assignments FOR SELECT TO authenticated - USING (public.can_access_student(student_id)); - -DROP POLICY IF EXISTS "tag assignments manage" ON public.student_tag_assignments; -CREATE POLICY "tag assignments manage" ON public.student_tag_assignments FOR ALL TO authenticated - USING (public.can_manage_student(student_id)) WITH CHECK (public.can_manage_student(student_id));