Modified by www.SourceFiles.app
This commit is contained in:
@@ -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()));
|
||||
Reference in New Issue
Block a user