-- 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()));