Implement push notification reminder system with 9 notification types
Adds comprehensive push notification reminders using pg_cron + pg_net: - Shift check-in reminder (15 min before, with countdown banner) - Shift check-out reminder (hourly, persistent until checkout) - Overtime idle reminder (15 min without task) - Overtime checkout reminder (30 min after task completion) - IT service request event reminder (1 hour before event) - IT service request evidence reminder (daily) - Paused task reminder (daily) - Backlog reminder (15 min before shift end) - Pass slip expiry reminder (15 min before 1-hour limit, with countdown banner) Database: Extended scheduled_notifications table to support polymorphic references (schedule_id, task_id, it_service_request_id, pass_slip_id) with unique constraint and epoch column for deduplication. Implemented 8 enqueue functions + master dispatcher. Uses pg_cron every minute to enqueue and pg_net to trigger process_scheduled_notifications edge function, eliminating need for external cron job. Credentials stored in vault with GUC fallback for flexibility. Flutter: Added ShiftCountdownBanner and PassSlipCountdownBanner widgets that display persistent countdown timers for active shifts and pass slips. Both auto-dismiss when user completes the action. FCM handler triggers shift countdown on start_15 messages. navigate_to field in data payload enables flexible routing to any screen. Edge function: Updated process_scheduled_notifications to handle all 9 types with appropriate titles, bodies, and routing. Includes pass_slip_id in idempotency tracking. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,9 @@ async function processBatch() {
|
||||
const notifyType = r.notify_type
|
||||
const rowId = r.id
|
||||
|
||||
const notificationId = await uuidFromName(`${scheduleId}-${userId}-${notifyType}`)
|
||||
// Build a unique ID that accounts for all reference columns + epoch
|
||||
const idSource = `${scheduleId || ''}-${r.task_id || ''}-${r.it_service_request_id || ''}-${r.pass_slip_id || ''}-${userId}-${notifyType}-${r.epoch || 0}`
|
||||
const notificationId = await uuidFromName(idSource)
|
||||
|
||||
// Attempt to mark idempotent push
|
||||
const { data: markData, error: markErr } = await supabase.rpc('try_mark_notification_pushed', { p_notification_id: notificationId })
|
||||
@@ -71,18 +73,74 @@ async function processBatch() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Prepare message
|
||||
// Prepare message based on notify_type
|
||||
let title = ''
|
||||
let body = ''
|
||||
if (notifyType === 'start_15') {
|
||||
title = 'Shift starting soon'
|
||||
body = 'Your shift starts in 15 minutes. Don\'t forget to check in.'
|
||||
} else if (notifyType === 'end') {
|
||||
title = 'Shift ended'
|
||||
body = 'Your shift has ended. Please remember to check out if you haven\'t.'
|
||||
} else {
|
||||
title = 'Shift reminder'
|
||||
body = 'Reminder about your shift.'
|
||||
const data: Record<string, string> = {
|
||||
notification_id: notificationId,
|
||||
type: notifyType,
|
||||
}
|
||||
|
||||
// Include reference IDs in data payload
|
||||
if (scheduleId) data.schedule_id = scheduleId
|
||||
if (r.task_id) data.task_id = r.task_id
|
||||
if (r.it_service_request_id) data.it_service_request_id = r.it_service_request_id
|
||||
if (r.pass_slip_id) data.pass_slip_id = r.pass_slip_id
|
||||
|
||||
switch (notifyType) {
|
||||
case 'start_15':
|
||||
title = 'Shift starting soon'
|
||||
body = "Your shift starts in 15 minutes. Don't forget to check in."
|
||||
data.navigate_to = '/attendance'
|
||||
break
|
||||
case 'end':
|
||||
title = 'Shift ended'
|
||||
body = "Your shift has ended. Please remember to check out."
|
||||
data.navigate_to = '/attendance'
|
||||
break
|
||||
case 'end_hourly':
|
||||
title = 'Check-out reminder'
|
||||
body = "You haven't checked out yet. Please check out when done."
|
||||
data.navigate_to = '/attendance'
|
||||
break
|
||||
case 'overtime_idle_15':
|
||||
title = 'No active task'
|
||||
body = "You've been on overtime for 15 minutes without an active task or IT service request."
|
||||
data.navigate_to = '/tasks'
|
||||
break
|
||||
case 'overtime_checkout_30':
|
||||
title = 'Overtime check-out reminder'
|
||||
body = "It's been 30 minutes since your last task ended. Consider checking out if you're done."
|
||||
data.navigate_to = '/attendance'
|
||||
break
|
||||
case 'isr_event_60':
|
||||
title = 'IT Service Request event soon'
|
||||
body = 'An IT service request event starts in 1 hour.'
|
||||
data.navigate_to = `/it-service-requests/${r.it_service_request_id}`
|
||||
break
|
||||
case 'isr_evidence_daily':
|
||||
title = 'Evidence upload reminder'
|
||||
body = 'Please upload evidence and action taken for your IT service request.'
|
||||
data.navigate_to = `/it-service-requests/${r.it_service_request_id}`
|
||||
break
|
||||
case 'task_paused_daily':
|
||||
title = 'Paused task reminder'
|
||||
body = 'You have a paused task that needs attention.'
|
||||
data.navigate_to = `/tasks/${r.task_id}`
|
||||
break
|
||||
case 'backlog_15':
|
||||
title = 'Pending tasks reminder'
|
||||
body = 'Your shift ends in 15 minutes and you still have pending tasks.'
|
||||
data.navigate_to = '/tasks'
|
||||
break
|
||||
case 'pass_slip_expiry_15':
|
||||
title = 'Pass slip expiring soon'
|
||||
body = 'Your pass slip expires in 15 minutes. Please return and complete it.'
|
||||
data.navigate_to = '/attendance'
|
||||
break
|
||||
default:
|
||||
title = 'Reminder'
|
||||
body = 'You have a pending notification.'
|
||||
}
|
||||
|
||||
// Call send_fcm endpoint to deliver push (reuses existing implementation)
|
||||
@@ -90,11 +148,7 @@ async function processBatch() {
|
||||
user_ids: [userId],
|
||||
title,
|
||||
body,
|
||||
data: {
|
||||
notification_id: notificationId,
|
||||
schedule_id: scheduleId,
|
||||
type: notifyType,
|
||||
},
|
||||
data,
|
||||
}
|
||||
|
||||
const res = await fetch(SEND_FCM_URL, {
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
-- 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 '30 seconds'
|
||||
AND now() + interval '15 minutes' + interval '30 seconds'
|
||||
AND status = 'active'
|
||||
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 '30 seconds' AND now() + interval '30 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.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 '30 seconds'
|
||||
AND now() + interval '60 minutes' + interval '30 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 '30 seconds'
|
||||
AND now() + interval '15 minutes' + interval '30 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
|
||||
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'
|
||||
BETWEEN now() - interval '30 seconds' AND now() + interval '30 seconds'
|
||||
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
|
||||
-- ============================================================================
|
||||
-- Uses pg_net to call the process_scheduled_notifications edge function
|
||||
-- from within PostgreSQL, eliminating the need for external cron jobs.
|
||||
|
||||
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 (standard in self-hosted Supabase)
|
||||
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
|
||||
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 supabase_url or service_role_key config';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
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;
|
||||
|
||||
-- Job 1: Enqueue notifications every minute
|
||||
PERFORM cron.schedule(
|
||||
'notification_enqueue_every_min',
|
||||
'*/1 * * * *',
|
||||
$$SELECT public.enqueue_all_notifications();$$
|
||||
);
|
||||
|
||||
-- Job 2: Process notification queue via pg_net every minute
|
||||
PERFORM cron.schedule(
|
||||
'notification_process_every_min',
|
||||
'*/1 * * * *',
|
||||
$$SELECT public.process_notification_queue();$$
|
||||
);
|
||||
|
||||
-- Job 3: 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/pg_net not available. After enabling them, run these manually:';
|
||||
RAISE NOTICE ' SELECT cron.schedule("notification_enqueue_every_min", "*/1 * * * *", $QUERY$SELECT public.enqueue_all_notifications();$QUERY$);';
|
||||
RAISE NOTICE ' SELECT cron.schedule("notification_process_every_min", "*/1 * * * *", $QUERY$SELECT public.process_notification_queue();$QUERY$);';
|
||||
RAISE NOTICE ' SELECT cron.schedule("cleanup_old_notifications", "0 3 * * *", $QUERY$DELETE FROM scheduled_notifications WHERE processed = true AND processed_at < now() - interval ' || quote_literal('7 days') || '; DELETE FROM notification_pushes WHERE pushed_at < now() - interval ' || quote_literal('7 days') || ';$QUERY$);';
|
||||
END $$;
|
||||
Reference in New Issue
Block a user