-- 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 = '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 '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.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 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 '90 seconds' AND now() + interval '90 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 ' || quote_literal('7 days') || '; DELETE FROM notification_pushes WHERE pushed_at < now() - interval ' || quote_literal('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(%L, %L, %L);', 'notification_enqueue_every_min', '*/1 * * * *', 'SELECT public.enqueue_all_notifications();'; RAISE NOTICE ' SELECT cron.schedule(%L, %L, %L);', 'notification_process_every_min', '*/1 * * * *', 'SELECT public.process_notification_queue();'; RAISE NOTICE ' SELECT cron.schedule(%L, %L, %L);', 'cleanup_old_notifications', '0 3 * * *', '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') || ';'; END $$;