tasq/supabase/migrations/20260322_finalize_notification_functions.sql

452 lines
18 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: Finalize all notification enqueue functions
-- Supersedes the function definitions in 20260321_extend_scheduled_notifications.sql.
-- Schema changes (ALTER TABLE, indexes, constraints) remain in that file.
--
-- Bugs fixed in this migration:
-- 1. duty_schedules status 'active' was not a valid enum value → corrected to IN ('arrival', 'late')
-- 2. end_hourly had no time cap → now only enqueues for shifts that ended within 24 hours
-- 3. pass_slip_expiry used a narrow ±90s window → now uses the full 4560 min range
-- so the function doesn't need to be called at an exact second to work
-- ============================================================================
-- ENQUEUE FUNCTIONS
-- ============================================================================
-- ----------------------------------------------------------------------------
-- enqueue_due_shift_notifications()
-- start_15 — 15 min before shift start (narrow ±90s window, send once)
-- end — at exact shift end time (narrow ±90s window, send once)
-- end_hourly — every hour after shift end until user checks out
-- (only for shifts that ended within the last 24 hours)
-- ----------------------------------------------------------------------------
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
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
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
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 (shifts ended within the last 24 hours only)
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);
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 reminder sent yet, 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;
$$;
-- ----------------------------------------------------------------------------
-- enqueue_overtime_idle_notifications()
-- 15 minutes into overtime with no 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'
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'
)
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;
$$;
-- ----------------------------------------------------------------------------
-- enqueue_overtime_checkout_notifications()
-- 30 min after last task completion during overtime, no new task started,
-- 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'
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
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;
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;
$$;
-- ----------------------------------------------------------------------------
-- 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;
$$;
-- ----------------------------------------------------------------------------
-- enqueue_isr_evidence_notifications()
-- Daily reminder for assigned users missing evidence/action upload,
-- only 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 (
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
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) != ''
)
)
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;
$$;
-- ----------------------------------------------------------------------------
-- enqueue_paused_task_notifications()
-- Daily reminder for paused tasks, only after user has checked in today
-- ----------------------------------------------------------------------------
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'
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')
)
)
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;
$$;
-- ----------------------------------------------------------------------------
-- enqueue_backlog_notifications()
-- 15 min before shift end, for users with pending non-paused tasks
-- ----------------------------------------------------------------------------
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')
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')
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;
$$;
-- ----------------------------------------------------------------------------
-- enqueue_pass_slip_expiry_notifications()
-- Notifies when an active pass slip is in its last 15 minutes (4560 min old).
-- Uses a 15-minute window instead of a narrow ±90s window so the function
-- doesn't need to be called at an exact second. ON CONFLICT DO NOTHING
-- ensures only one notification is inserted per pass slip.
-- ----------------------------------------------------------------------------
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' <= now()
AND ps.slip_start + interval '60 minutes' > now()
ON CONFLICT DO NOTHING;
END;
$$;
-- ============================================================================
-- 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;
$$;
-- ============================================================================
-- PG_NET DISPATCHER (called by pg_cron)
-- ============================================================================
-- The edge function (process_scheduled_notifications) calls enqueue_all_notifications()
-- via RPC internally, so a single HTTP call handles both enqueue and process.
--
-- ONE-TIME VAULT SETUP (run once, not part of migration):
-- SELECT vault.create_secret('https://YOUR_PROJECT.supabase.co', 'supabase_url');
-- SELECT vault.create_secret('YOUR_SERVICE_ROLE_KEY', 'service_role_key');
--
-- ALTERNATIVE: Ubuntu server crontab (no pg_cron needed):
-- */1 * * * * curl -sS -X POST "https://YOUR_PROJECT.supabase.co/functions/v1/process_scheduled_notifications" \
-- -H "Authorization: Bearer YOUR_SERVICE_ROLE_KEY" \
-- -H "Content-Type: application/json" -d '{}'
CREATE OR REPLACE FUNCTION public.process_notification_queue()
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
v_base_url text;
v_service_key text;
BEGIN
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
NULL;
END;
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;
IF v_base_url IS NULL OR v_service_key IS NULL THEN
RAISE WARNING 'process_notification_queue: vault secrets not configured. Use external cron instead.';
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;
$$;
-- ============================================================================
-- PG_CRON JOBS (requires pg_cron extension enabled in Supabase Dashboard)
-- If pg_cron is not available, use the Ubuntu crontab approach above instead.
-- ============================================================================
DO $$
BEGIN
-- Clean up any old job names
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 internally
PERFORM cron.schedule(
'notification_process_every_min',
'*/1 * * * *',
'SELECT public.process_notification_queue();'
);
-- Daily cleanup of old processed rows
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 not enabled (%). Use the Ubuntu crontab approach instead.', SQLERRM;
END $$;