tasq/supabase/migrations/20260321_extend_scheduled_notifications.sql

526 lines
21 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- Migration: Extend scheduled_notifications for 9 push notification reminder types
-- Adds support for task-based, ISR-based, and pass-slip-based notifications alongside existing shift reminders.
-- Creates modular enqueue functions for each notification type, called by a master dispatcher.
-- Uses pg_cron + pg_net to fully automate enqueue and processing without external cron.
-- ============================================================================
-- 1. SCHEMA CHANGES
-- ============================================================================
-- 1a. Make schedule_id nullable (non-shift notifications don't reference a duty schedule)
ALTER TABLE public.scheduled_notifications ALTER COLUMN schedule_id DROP NOT NULL;
-- 1b. Add reference columns for tasks, IT service requests, and pass slips
ALTER TABLE public.scheduled_notifications
ADD COLUMN IF NOT EXISTS task_id uuid REFERENCES public.tasks(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS it_service_request_id uuid REFERENCES public.it_service_requests(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS pass_slip_id uuid REFERENCES public.pass_slips(id) ON DELETE CASCADE;
-- 1c. Add epoch column for recurring notifications (hourly checkout, daily reminders)
ALTER TABLE public.scheduled_notifications
ADD COLUMN IF NOT EXISTS epoch int NOT NULL DEFAULT 0;
-- 1d. Drop old unique constraint and create new one that supports all reference types + epoch
ALTER TABLE public.scheduled_notifications DROP CONSTRAINT IF EXISTS uniq_sched_user_type;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_sched_notif ON public.scheduled_notifications (
COALESCE(schedule_id, '00000000-0000-0000-0000-000000000000'),
COALESCE(task_id, '00000000-0000-0000-0000-000000000000'),
COALESCE(it_service_request_id, '00000000-0000-0000-0000-000000000000'),
COALESCE(pass_slip_id, '00000000-0000-0000-0000-000000000000'),
user_id,
notify_type,
epoch
);
-- 1e. CHECK: at least one reference must be non-null
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON c.conrelid = t.oid
WHERE c.conname = 'chk_at_least_one_ref' AND t.relname = 'scheduled_notifications'
) THEN
ALTER TABLE public.scheduled_notifications
ADD CONSTRAINT chk_at_least_one_ref
CHECK (schedule_id IS NOT NULL OR task_id IS NOT NULL OR it_service_request_id IS NOT NULL OR pass_slip_id IS NOT NULL);
END IF;
END$$;
-- 1f. Add indexes for new columns
CREATE INDEX IF NOT EXISTS idx_sched_notif_task ON public.scheduled_notifications(task_id) WHERE task_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sched_notif_isr ON public.scheduled_notifications(it_service_request_id) WHERE it_service_request_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sched_notif_pass_slip ON public.scheduled_notifications(pass_slip_id) WHERE pass_slip_id IS NOT NULL;
-- ============================================================================
-- 2. ENQUEUE FUNCTIONS
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 2a. enqueue_due_shift_notifications() — REPLACE existing
-- Now handles: start_15, end, AND end_hourly (recurring hourly checkout)
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enqueue_due_shift_notifications()
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
rec RECORD;
v_hours_since_end int;
v_latest_hourly timestamptz;
BEGIN
-- 15-minute-before reminders (existing logic)
FOR rec IN
SELECT id AS schedule_id, user_id, start_time AS start_at
FROM public.duty_schedules
WHERE start_time BETWEEN now() + interval '15 minutes' - interval '90 seconds'
AND now() + interval '15 minutes' + interval '90 seconds'
AND status IN ('arrival', 'late')
LOOP
-- Skip if user already checked in for this duty
IF EXISTS (
SELECT 1 FROM public.attendance_logs al
WHERE al.duty_schedule_id = rec.schedule_id AND al.check_in_at IS NOT NULL
) THEN
CONTINUE;
END IF;
INSERT INTO public.scheduled_notifications (schedule_id, user_id, notify_type, scheduled_for)
VALUES (rec.schedule_id, rec.user_id, 'start_15', rec.start_at)
ON CONFLICT DO NOTHING;
END LOOP;
-- End-of-shift reminders at exact end time (existing logic)
FOR rec IN
SELECT id AS schedule_id, user_id, end_time AS end_at
FROM public.duty_schedules
WHERE end_time BETWEEN now() - interval '90 seconds' AND now() + interval '90 seconds'
AND status IN ('arrival', 'late')
LOOP
INSERT INTO public.scheduled_notifications (schedule_id, user_id, notify_type, scheduled_for)
VALUES (rec.schedule_id, rec.user_id, 'end', rec.end_at)
ON CONFLICT DO NOTHING;
END LOOP;
-- Hourly checkout reminders: for users whose shift ended and have NOT checked out
FOR rec IN
SELECT ds.id AS schedule_id, ds.user_id, ds.end_time
FROM public.duty_schedules ds
JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id
WHERE ds.end_time < now()
AND ds.end_time >= now() - interval '24 hours'
AND ds.status IN ('arrival', 'late')
AND al.check_in_at IS NOT NULL
AND al.check_out_at IS NULL
AND ds.shift_type != 'overtime'
LOOP
v_hours_since_end := GREATEST(1, EXTRACT(EPOCH FROM (now() - rec.end_time))::int / 3600);
-- Check if we already sent a notification for this hour
SELECT MAX(scheduled_for) INTO v_latest_hourly
FROM public.scheduled_notifications
WHERE schedule_id = rec.schedule_id
AND user_id = rec.user_id
AND notify_type = 'end_hourly';
-- Only enqueue if no hourly was sent, or the last one was >55 min ago
IF v_latest_hourly IS NULL OR v_latest_hourly < now() - interval '55 minutes' THEN
INSERT INTO public.scheduled_notifications
(schedule_id, user_id, notify_type, scheduled_for, epoch)
VALUES
(rec.schedule_id, rec.user_id, 'end_hourly', now(), v_hours_since_end)
ON CONFLICT DO NOTHING;
END IF;
END LOOP;
END;
$$;
-- ----------------------------------------------------------------------------
-- 2b. enqueue_overtime_idle_notifications()
-- 15 minutes into overtime without an active task or ISR
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enqueue_overtime_idle_notifications()
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
rec RECORD;
BEGIN
FOR rec IN
SELECT ds.id AS schedule_id, ds.user_id
FROM public.duty_schedules ds
JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id
WHERE ds.shift_type = 'overtime'
AND ds.status IN ('arrival', 'late')
AND al.check_in_at IS NOT NULL
AND al.check_out_at IS NULL
AND al.check_in_at <= now() - interval '15 minutes'
-- No in-progress task
AND NOT EXISTS (
SELECT 1 FROM public.task_assignments ta
JOIN public.tasks t ON t.id = ta.task_id
WHERE ta.user_id = ds.user_id AND t.status = 'in_progress'
)
-- No in-progress IT service request
AND NOT EXISTS (
SELECT 1 FROM public.it_service_request_assignments isra
JOIN public.it_service_requests isr ON isr.id = isra.request_id
WHERE isra.user_id = ds.user_id AND isr.status IN ('in_progress', 'in_progress_dry_run')
)
LOOP
INSERT INTO public.scheduled_notifications
(schedule_id, user_id, notify_type, scheduled_for)
VALUES
(rec.schedule_id, rec.user_id, 'overtime_idle_15', now())
ON CONFLICT DO NOTHING;
END LOOP;
END;
$$;
-- ----------------------------------------------------------------------------
-- 2c. enqueue_overtime_checkout_notifications()
-- 30 min after last task completion during overtime, no new task,
-- and at least 1 hour since overtime check-in
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enqueue_overtime_checkout_notifications()
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
rec RECORD;
v_last_completed timestamptz;
BEGIN
FOR rec IN
SELECT ds.id AS schedule_id, ds.user_id, al.check_in_at
FROM public.duty_schedules ds
JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id
WHERE ds.shift_type = 'overtime'
AND ds.status IN ('arrival', 'late')
AND al.check_in_at IS NOT NULL
AND al.check_out_at IS NULL
AND al.check_in_at <= now() - interval '1 hour'
-- No in-progress tasks
AND NOT EXISTS (
SELECT 1 FROM public.task_assignments ta
JOIN public.tasks t ON t.id = ta.task_id
WHERE ta.user_id = ds.user_id AND t.status = 'in_progress'
)
LOOP
-- Find most recently completed task for this user
SELECT MAX(t.completed_at) INTO v_last_completed
FROM public.task_assignments ta
JOIN public.tasks t ON t.id = ta.task_id
WHERE ta.user_id = rec.user_id
AND t.status IN ('completed', 'closed')
AND t.completed_at IS NOT NULL
AND t.completed_at >= rec.check_in_at; -- Only tasks completed during this overtime
-- Only notify if a task was completed >=30 min ago
IF v_last_completed IS NOT NULL AND v_last_completed <= now() - interval '30 minutes' THEN
INSERT INTO public.scheduled_notifications
(schedule_id, user_id, notify_type, scheduled_for)
VALUES
(rec.schedule_id, rec.user_id, 'overtime_checkout_30', now())
ON CONFLICT DO NOTHING;
END IF;
END LOOP;
END;
$$;
-- ----------------------------------------------------------------------------
-- 2d. enqueue_isr_event_notifications()
-- 1 hour before IT service request event_date
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enqueue_isr_event_notifications()
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
rec RECORD;
BEGIN
FOR rec IN
SELECT isr.id AS request_id, isr.event_date, isra.user_id
FROM public.it_service_requests isr
JOIN public.it_service_request_assignments isra ON isra.request_id = isr.id
WHERE isr.status IN ('scheduled', 'in_progress_dry_run')
AND isr.event_date IS NOT NULL
AND isr.event_date BETWEEN now() + interval '60 minutes' - interval '90 seconds'
AND now() + interval '60 minutes' + interval '90 seconds'
LOOP
INSERT INTO public.scheduled_notifications
(it_service_request_id, user_id, notify_type, scheduled_for)
VALUES
(rec.request_id, rec.user_id, 'isr_event_60', rec.event_date - interval '1 hour')
ON CONFLICT DO NOTHING;
END LOOP;
END;
$$;
-- ----------------------------------------------------------------------------
-- 2e. enqueue_isr_evidence_notifications()
-- Daily reminder to assigned users who have NOT uploaded evidence or action
-- Only triggers after user has checked in today
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enqueue_isr_evidence_notifications()
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
rec RECORD;
v_today_doy int := EXTRACT(DOY FROM now())::int;
BEGIN
FOR rec IN
SELECT isr.id AS request_id, isra.user_id
FROM public.it_service_requests isr
JOIN public.it_service_request_assignments isra ON isra.request_id = isr.id
WHERE isr.status IN ('completed', 'in_progress')
AND (
-- User has NOT uploaded evidence
NOT EXISTS (
SELECT 1 FROM public.it_service_request_evidence e
WHERE e.request_id = isr.id AND e.user_id = isra.user_id
)
OR
-- User has NOT submitted action taken
NOT EXISTS (
SELECT 1 FROM public.it_service_request_actions a
WHERE a.request_id = isr.id AND a.user_id = isra.user_id
AND a.action_taken IS NOT NULL AND TRIM(a.action_taken) != ''
)
)
-- User must be checked in today
AND EXISTS (
SELECT 1 FROM public.attendance_logs al
WHERE al.user_id = isra.user_id
AND al.check_in_at::date = now()::date
)
LOOP
INSERT INTO public.scheduled_notifications
(it_service_request_id, user_id, notify_type, scheduled_for, epoch)
VALUES
(rec.request_id, rec.user_id, 'isr_evidence_daily', now(), v_today_doy)
ON CONFLICT DO NOTHING;
END LOOP;
END;
$$;
-- ----------------------------------------------------------------------------
-- 2f. enqueue_paused_task_notifications()
-- Daily reminder for paused in-progress tasks after user checks in
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enqueue_paused_task_notifications()
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
rec RECORD;
v_today_doy int := EXTRACT(DOY FROM now())::int;
BEGIN
FOR rec IN
SELECT t.id AS task_id, ta.user_id
FROM public.tasks t
JOIN public.task_assignments ta ON ta.task_id = t.id
WHERE t.status = 'in_progress'
-- Latest activity log for this task is 'paused'
AND EXISTS (
SELECT 1 FROM public.task_activity_logs tal
WHERE tal.task_id = t.id
AND tal.action_type = 'paused'
AND NOT EXISTS (
SELECT 1 FROM public.task_activity_logs tal2
WHERE tal2.task_id = t.id
AND tal2.created_at > tal.created_at
AND tal2.action_type IN ('resumed', 'completed', 'cancelled')
)
)
-- User must be checked in today
AND EXISTS (
SELECT 1 FROM public.attendance_logs al
WHERE al.user_id = ta.user_id
AND al.check_in_at::date = now()::date
)
LOOP
INSERT INTO public.scheduled_notifications
(task_id, user_id, notify_type, scheduled_for, epoch)
VALUES
(rec.task_id, rec.user_id, 'task_paused_daily', now(), v_today_doy)
ON CONFLICT DO NOTHING;
END LOOP;
END;
$$;
-- ----------------------------------------------------------------------------
-- 2g. enqueue_backlog_notifications()
-- 15 min before shift end, users with pending tasks (queued/in_progress, not paused)
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enqueue_backlog_notifications()
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
rec RECORD;
BEGIN
FOR rec IN
SELECT ds.id AS schedule_id, ds.user_id, ds.end_time
FROM public.duty_schedules ds
WHERE ds.end_time BETWEEN now() + interval '15 minutes' - interval '90 seconds'
AND now() + interval '15 minutes' + interval '90 seconds'
AND ds.status IN ('arrival', 'late')
-- User has pending (non-paused) tasks
AND EXISTS (
SELECT 1
FROM public.task_assignments ta
JOIN public.tasks t ON t.id = ta.task_id
WHERE ta.user_id = ds.user_id
AND t.status IN ('queued', 'in_progress')
-- Exclude paused tasks
AND NOT EXISTS (
SELECT 1 FROM public.task_activity_logs tal
WHERE tal.task_id = t.id
AND tal.action_type = 'paused'
AND NOT EXISTS (
SELECT 1 FROM public.task_activity_logs tal2
WHERE tal2.task_id = t.id
AND tal2.created_at > tal.created_at
AND tal2.action_type IN ('resumed', 'completed', 'cancelled')
)
)
)
LOOP
INSERT INTO public.scheduled_notifications
(schedule_id, user_id, notify_type, scheduled_for)
VALUES
(rec.schedule_id, rec.user_id, 'backlog_15', rec.end_time - interval '15 minutes')
ON CONFLICT DO NOTHING;
END LOOP;
END;
$$;
-- ----------------------------------------------------------------------------
-- 2h. enqueue_pass_slip_expiry_notifications()
-- 15 min before pass slip reaches its 1-hour limit
-- ----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.enqueue_pass_slip_expiry_notifications()
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
-- Match any active pass slip in the last 15 minutes (between 4560 min old).
-- Unlike narrow ±90s windows used for shift reminders, pass slips use a
-- 15-minute window so this function doesn't need to be called at an exact
-- second. ON CONFLICT DO NOTHING prevents duplicate notifications.
INSERT INTO public.scheduled_notifications (pass_slip_id, user_id, notify_type, scheduled_for)
SELECT ps.id, ps.user_id, 'pass_slip_expiry_15', now()
FROM public.pass_slips ps
WHERE ps.status = 'approved'
AND ps.slip_end IS NULL
AND ps.slip_start IS NOT NULL
AND ps.slip_start + interval '45 minutes' <= now()
AND ps.slip_start + interval '60 minutes' > now()
ON CONFLICT DO NOTHING;
END;
$$;
-- ============================================================================
-- 3. MASTER DISPATCHER
-- ============================================================================
CREATE OR REPLACE FUNCTION public.enqueue_all_notifications()
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
PERFORM public.enqueue_due_shift_notifications();
PERFORM public.enqueue_overtime_idle_notifications();
PERFORM public.enqueue_overtime_checkout_notifications();
PERFORM public.enqueue_isr_event_notifications();
PERFORM public.enqueue_isr_evidence_notifications();
PERFORM public.enqueue_paused_task_notifications();
PERFORM public.enqueue_backlog_notifications();
PERFORM public.enqueue_pass_slip_expiry_notifications();
END;
$$;
-- ============================================================================
-- 4. PG_NET + PG_CRON SCHEDULING
-- ============================================================================
-- The edge function (process_scheduled_notifications) now calls
-- enqueue_all_notifications() via RPC internally, so only ONE pg_cron job
-- is needed to trigger the edge function. This eliminates the fragile
-- dependency on two independent cron jobs both working.
--
-- Credential lookup order:
-- 1. vault.decrypted_secrets (Supabase cloud + self-hosted)
-- 2. app.settings GUC variables (self-hosted fallback)
--
-- ONE-TIME SETUP (run once after migration):
-- SELECT vault.create_secret('https://YOUR_PROJECT.supabase.co', 'supabase_url');
-- SELECT vault.create_secret('YOUR_SERVICE_ROLE_KEY', 'service_role_key');
CREATE EXTENSION IF NOT EXISTS pg_net;
-- Helper function: invokes the process_scheduled_notifications edge function via pg_net
CREATE OR REPLACE FUNCTION public.process_notification_queue()
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
v_base_url text;
v_service_key text;
BEGIN
-- Try Supabase vault first (works on both cloud and self-hosted)
BEGIN
SELECT decrypted_secret INTO v_service_key
FROM vault.decrypted_secrets
WHERE name = 'service_role_key'
LIMIT 1;
SELECT decrypted_secret INTO v_base_url
FROM vault.decrypted_secrets
WHERE name = 'supabase_url'
LIMIT 1;
EXCEPTION WHEN others THEN
-- vault schema may not exist
NULL;
END;
-- Fall back to app.settings GUC variables (self-hosted)
IF v_base_url IS NULL THEN
v_base_url := current_setting('app.settings.supabase_url', true);
END IF;
IF v_service_key IS NULL THEN
v_service_key := current_setting('app.settings.service_role_key', true);
END IF;
-- Guard: skip if config is missing
IF v_base_url IS NULL OR v_service_key IS NULL THEN
RAISE WARNING 'process_notification_queue: missing vault secrets. Run: SELECT vault.create_secret(''https://YOUR_PROJECT.supabase.co'', ''supabase_url''); SELECT vault.create_secret(''YOUR_KEY'', ''service_role_key'');';
RETURN;
END IF;
-- The edge function handles both enqueue + process internally
PERFORM net.http_post(
url := v_base_url || '/functions/v1/process_scheduled_notifications',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || v_service_key
),
body := '{}'::jsonb
);
END;
$$;
-- Schedule pg_cron jobs (wrapped in exception block for safety)
DO $$
BEGIN
-- Unschedule old jobs if they exist
BEGIN
PERFORM cron.unschedule('shift_reminders_every_min');
EXCEPTION WHEN others THEN NULL;
END;
BEGIN
PERFORM cron.unschedule('notification_reminders_every_min');
EXCEPTION WHEN others THEN NULL;
END;
BEGIN
PERFORM cron.unschedule('notification_enqueue_every_min');
EXCEPTION WHEN others THEN NULL;
END;
-- Single job: triggers edge function which handles enqueue + process
PERFORM cron.schedule(
'notification_process_every_min',
'*/1 * * * *',
'SELECT public.process_notification_queue();'
);
-- Daily cleanup of old processed notifications
PERFORM cron.schedule(
'cleanup_old_notifications',
'0 3 * * *',
'DELETE FROM scheduled_notifications WHERE processed = true AND processed_at < now() - interval ''7 days''; DELETE FROM notification_pushes WHERE pushed_at < now() - interval ''7 days'';'
);
EXCEPTION WHEN others THEN
RAISE NOTICE 'pg_cron scheduling failed: %. Set up cron jobs manually or use external cron.', SQLERRM;
END $$;