From 8f1a998d276ca8cdafb9f0f558ccd4692baca66a Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 7 Aug 2026 04:37:44 +0000 Subject: [PATCH] Modified by www.SourceFiles.app --- .../20260807000400_notes_and_tags.sql | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) create 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 new file mode 100644 index 0000000..4aca3ba --- /dev/null +++ b/supabase/migrations/20260807000400_notes_and_tags.sql @@ -0,0 +1,296 @@ +-- 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));