From ed2aa101ec6c907aaf08493db5e05d1885887429 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 7 Aug 2026 04:45:06 +0000 Subject: [PATCH] Modified by www.SourceFiles.app --- .../20260807000900_tasks_and_calendar.sql | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 supabase/migrations/20260807000900_tasks_and_calendar.sql diff --git a/supabase/migrations/20260807000900_tasks_and_calendar.sql b/supabase/migrations/20260807000900_tasks_and_calendar.sql new file mode 100644 index 0000000..d4e10d4 --- /dev/null +++ b/supabase/migrations/20260807000900_tasks_and_calendar.sql @@ -0,0 +1,215 @@ +-- Calendars, reminders and task management — spec section 13. +-- +-- Section 3 requires Management to have "a separate dashboard and +-- administrative calendar with reminders, deadlines, follow-ups, and +-- campus-wide tasks". The existing calendar_events table is a single +-- org-wide list readable by everyone, so it is extended with a campus, an +-- event type and an audience rather than replaced. + +-- ============================================================================ +-- 1. CALENDAR EXTENSIONS +-- ============================================================================ + +ALTER TABLE public.calendar_events + ADD COLUMN IF NOT EXISTS campus_id UUID REFERENCES public.campuses(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS event_type TEXT NOT NULL DEFAULT 'general', + ADD COLUMN IF NOT EXISTS is_admin_only BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS visible_to_roles app_role[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS all_day BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS start_time TIME, + ADD COLUMN IF NOT EXISTS end_time TIME; + +DO $$ BEGIN + ALTER TABLE public.calendar_events ADD CONSTRAINT cal_event_type_valid + CHECK (event_type IN ('general','holiday','closure','deadline','meeting', + 'professional_development','billing','enrollment','other')); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE INDEX IF NOT EXISTS cal_campus_idx ON public.calendar_events (campus_id); +CREATE INDEX IF NOT EXISTS cal_date_idx ON public.calendar_events (date); +CREATE INDEX IF NOT EXISTS cal_admin_idx ON public.calendar_events (date) WHERE is_admin_only; + +-- The original policy published every event to every authenticated user, which +-- would leak the new administrative calendar. Replace it with an +-- audience-aware read; the admin manage policy from the first migration is +-- left untouched. +DROP POLICY IF EXISTS "calendar read" ON public.calendar_events; +CREATE POLICY "calendar read" ON public.calendar_events FOR SELECT TO authenticated + USING ( + public.is_org_admin() + OR ( + NOT is_admin_only + AND (cardinality(visible_to_roles) = 0 OR public.current_user_has_any_role(visible_to_roles)) + AND (campus_id IS NULL OR public.has_campus_access(campus_id) OR public.current_user_has_role('parent')) + ) + OR (is_admin_only AND public.is_management()) + ); + +-- ============================================================================ +-- 2. TASKS +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS public.tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT, + + assigned_to UUID REFERENCES auth.users(id) ON DELETE SET NULL, + -- NULL campus means an organization-wide task. + campus_id UUID REFERENCES public.campuses(id) ON DELETE CASCADE, + + due_date DATE, + priority TEXT NOT NULL DEFAULT 'normal', + status TEXT NOT NULL DEFAULT 'open', + + -- What this task is about, when it is about something. + related_student_id UUID REFERENCES public.students(id) ON DELETE CASCADE, + related_invoice_id UUID REFERENCES public.invoices(id) ON DELETE SET NULL, + related_note_id UUID REFERENCES public.student_notes(id) ON DELETE SET NULL, + related_applicant_id UUID REFERENCES public.applicants(id) ON DELETE SET NULL, + + visible_to_roles app_role[] NOT NULL DEFAULT '{}', + + completed_at TIMESTAMPTZ, + completed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + + created_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 task_priority_valid CHECK (priority IN ('low','normal','high','urgent')), + CONSTRAINT task_status_valid CHECK (status IN ('open','in_progress','blocked','done','cancelled')) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.tasks TO authenticated; +GRANT ALL ON public.tasks TO service_role; +ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY; + +CREATE INDEX IF NOT EXISTS task_assigned_idx ON public.tasks (assigned_to, due_date); +CREATE INDEX IF NOT EXISTS task_campus_idx ON public.tasks (campus_id); +CREATE INDEX IF NOT EXISTS task_student_idx ON public.tasks (related_student_id); +CREATE INDEX IF NOT EXISTS task_invoice_idx ON public.tasks (related_invoice_id); +CREATE INDEX IF NOT EXISTS task_note_idx ON public.tasks (related_note_id); +CREATE INDEX IF NOT EXISTS task_applicant_idx ON public.tasks (related_applicant_id); +CREATE INDEX IF NOT EXISTS task_created_by_idx ON public.tasks (created_by); +-- The management dashboard's "what is outstanding" query. +CREATE INDEX IF NOT EXISTS task_open_idx ON public.tasks (due_date) + WHERE status IN ('open','in_progress','blocked'); + +DROP TRIGGER IF EXISTS trg_task_upd ON public.tasks; +CREATE TRIGGER trg_task_upd BEFORE UPDATE ON public.tasks + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- Stamp completion rather than trusting the client to send both fields. +CREATE OR REPLACE FUNCTION public.stamp_task_completion() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + IF NEW.status = 'done' AND (OLD.status IS DISTINCT FROM 'done') THEN + NEW.completed_at := now(); + NEW.completed_by := (SELECT auth.uid()); + ELSIF NEW.status <> 'done' THEN + NEW.completed_at := NULL; + NEW.completed_by := NULL; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_task_completion ON public.tasks; +CREATE TRIGGER trg_task_completion BEFORE UPDATE ON public.tasks + FOR EACH ROW EXECUTE FUNCTION public.stamp_task_completion(); + +-- ============================================================================ +-- 3. REMINDERS +-- ============================================================================ +-- Delivery is left to the application: a worker polls for due, unsent rows and +-- marks them sent. Keeping that state here means a reminder survives restarts +-- and can be audited. + +CREATE TABLE IF NOT EXISTS public.reminders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body TEXT, + + remind_at TIMESTAMPTZ NOT NULL, + channel TEXT NOT NULL DEFAULT 'in_app', + + task_id UUID REFERENCES public.tasks(id) ON DELETE CASCADE, + related_student_id UUID REFERENCES public.students(id) ON DELETE CASCADE, + related_invoice_id UUID REFERENCES public.invoices(id) ON DELETE SET NULL, + related_certification_id UUID REFERENCES public.staff_certifications(id) ON DELETE CASCADE, + + sent_at TIMESTAMPTZ, + dismissed_at TIMESTAMPTZ, + created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT rem_channel_valid CHECK (channel IN ('in_app','email','sms')) +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.reminders TO authenticated; +GRANT ALL ON public.reminders TO service_role; +ALTER TABLE public.reminders ENABLE ROW LEVEL SECURITY; + +CREATE INDEX IF NOT EXISTS rem_user_idx ON public.reminders (user_id, remind_at); +CREATE INDEX IF NOT EXISTS rem_task_idx ON public.reminders (task_id); +CREATE INDEX IF NOT EXISTS rem_student_idx ON public.reminders (related_student_id); +CREATE INDEX IF NOT EXISTS rem_invoice_idx ON public.reminders (related_invoice_id); +CREATE INDEX IF NOT EXISTS rem_cert_idx ON public.reminders (related_certification_id); +-- The delivery worker's query: due and not yet sent. +CREATE INDEX IF NOT EXISTS rem_pending_idx ON public.reminders (remind_at) WHERE sent_at IS NULL; + +-- ============================================================================ +-- 4. POLICIES +-- ============================================================================ + +-- A task is visible to its assignee, its author, management, and campus +-- administrators of the task's campus. +DROP POLICY IF EXISTS "tasks read" ON public.tasks; +CREATE POLICY "tasks read" ON public.tasks FOR SELECT TO authenticated + USING ( + public.is_management() OR public.is_auditor() + OR assigned_to = (SELECT auth.uid()) + OR created_by = (SELECT auth.uid()) + OR (cardinality(visible_to_roles) > 0 AND public.current_user_has_any_role(visible_to_roles)) + OR (campus_id IS NOT NULL + AND public.current_user_has_any_role(ARRAY['campus_admin']::app_role[]) + AND campus_id IN (SELECT public.user_campus_ids())) + ); + +DROP POLICY IF EXISTS "tasks insert" ON public.tasks; +CREATE POLICY "tasks insert" ON public.tasks FOR INSERT TO authenticated + WITH CHECK (created_by = (SELECT auth.uid())); + +-- Assignees may progress their own work; management and the author may edit +-- anything about it. +DROP POLICY IF EXISTS "tasks update" ON public.tasks; +CREATE POLICY "tasks update" ON public.tasks FOR UPDATE TO authenticated + USING (public.is_management() OR created_by = (SELECT auth.uid()) + OR assigned_to = (SELECT auth.uid())) + WITH CHECK (public.is_management() OR created_by = (SELECT auth.uid()) + OR assigned_to = (SELECT auth.uid())); + +DROP POLICY IF EXISTS "tasks delete" ON public.tasks; +CREATE POLICY "tasks delete" ON public.tasks FOR DELETE TO authenticated + USING (public.is_management() OR created_by = (SELECT auth.uid())); + +-- Reminders are personal. Management may create them for others (deadline +-- assignment) but may not read another user's reminder list. +DROP POLICY IF EXISTS "reminders read" ON public.reminders; +CREATE POLICY "reminders read" ON public.reminders FOR SELECT TO authenticated + USING (user_id = (SELECT auth.uid()) OR created_by = (SELECT auth.uid())); + +DROP POLICY IF EXISTS "reminders insert" ON public.reminders; +CREATE POLICY "reminders insert" ON public.reminders FOR INSERT TO authenticated + WITH CHECK (user_id = (SELECT auth.uid()) OR public.is_management()); + +DROP POLICY IF EXISTS "reminders update" ON public.reminders; +CREATE POLICY "reminders update" ON public.reminders FOR UPDATE TO authenticated + USING (user_id = (SELECT auth.uid()) OR created_by = (SELECT auth.uid())) + WITH CHECK (user_id = (SELECT auth.uid()) OR created_by = (SELECT auth.uid())); + +DROP POLICY IF EXISTS "reminders delete" ON public.reminders; +CREATE POLICY "reminders delete" ON public.reminders FOR DELETE TO authenticated + USING (user_id = (SELECT auth.uid()) OR created_by = (SELECT auth.uid()));