Terminal
This commit is contained in:
@@ -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));
|
|
||||||
Reference in New Issue
Block a user