Files
info-share-spot/supabase/migrations/20260807000900_tasks_and_calendar.sql
T
2026-08-07 04:45:06 +00:00

216 lines
10 KiB
PL/PgSQL

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