diff --git a/supabase/cron_fallback.sh b/supabase/cron_fallback.sh new file mode 100644 index 00000000..f3a333f4 --- /dev/null +++ b/supabase/cron_fallback.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Fallback notification processor — use if pg_cron + pg_net aren't working. +# +# The edge function handles BOTH enqueue and process internally, +# so this single call is all that's needed. +# +# Setup: +# 1. Set environment variables (or replace inline): +# export SUPABASE_URL="https://YOUR_PROJECT.supabase.co" +# export SUPABASE_SERVICE_ROLE_KEY="your-service-role-key" +# +# 2. Add to crontab (runs every minute): +# */1 * * * * /path/to/supabase/cron_fallback.sh >> /var/log/tasq_notifications.log 2>&1 + +if [ -z "$SUPABASE_URL" ] || [ -z "$SUPABASE_SERVICE_ROLE_KEY" ]; then + echo "$(date -Iseconds) ERROR: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set" + exit 1 +fi + +curl -sS -X POST \ + "${SUPABASE_URL}/functions/v1/process_scheduled_notifications" \ + -H "Authorization: Bearer ${SUPABASE_SERVICE_ROLE_KEY}" \ + -H "Content-Type: application/json" \ + -d '{}' \ + -o /dev/null -w "$(date -Iseconds) status=%{http_code}\n" diff --git a/supabase/functions/process_scheduled_notifications/index.ts b/supabase/functions/process_scheduled_notifications/index.ts index e138f320..ddb249cc 100644 --- a/supabase/functions/process_scheduled_notifications/index.ts +++ b/supabase/functions/process_scheduled_notifications/index.ts @@ -10,9 +10,11 @@ const corsHeaders = { 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', } -const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!) +const SUPABASE_URL = Deno.env.get('SUPABASE_URL')! +const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! +const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY) const SEND_FCM_URL = Deno.env.get('SEND_FCM_URL') - || `${Deno.env.get('SUPABASE_URL')!}/functions/v1/send_fcm` + || `${SUPABASE_URL}/functions/v1/send_fcm` const BATCH_SIZE = Number(Deno.env.get('PROCESSOR_BATCH_SIZE') || '50') // deterministic UUIDv5-like from a name using SHA-1 @@ -31,6 +33,18 @@ async function uuidFromName(name: string): Promise { } async function processBatch() { + // Step 1: Enqueue any due notifications via RPC. + // This makes the edge function self-sufficient — a single trigger + // (pg_cron, external cron, or manual curl) handles both enqueue + process. + console.log('Calling enqueue_all_notifications RPC...') + const { error: enqueueErr, data: enqueueData } = await supabase.rpc('enqueue_all_notifications') + if (enqueueErr) { + console.error('❌ enqueue_all_notifications RPC FAILED:', JSON.stringify(enqueueErr)) + } else { + console.log('✓ enqueue_all_notifications completed successfully', enqueueData) + } + + // Step 2: Process enqueued rows const nowIso = new Date().toISOString() const { data: rows, error } = await supabase .from('scheduled_notifications') @@ -144,7 +158,10 @@ async function processBatch() { const res = await fetch(SEND_FCM_URL, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${SERVICE_ROLE_KEY}`, + }, body: JSON.stringify(payload), }) diff --git a/supabase/migrations/20260318_add_scheduled_notifications.sql b/supabase/migrations/20260318_add_scheduled_notifications.sql index 2ef5a808..d8fedb54 100644 --- a/supabase/migrations/20260318_add_scheduled_notifications.sql +++ b/supabase/migrations/20260318_add_scheduled_notifications.sql @@ -45,7 +45,7 @@ BEGIN SELECT id AS schedule_id, user_id AS 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' + AND status IN ('arrival', 'late') LOOP -- Skip if user already checked in for this duty (attendance_logs references duty_schedule_id) 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 @@ -62,7 +62,7 @@ BEGIN SELECT id AS schedule_id, user_id AS 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 = 'active' + 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) diff --git a/supabase/migrations/20260321_extend_scheduled_notifications.sql b/supabase/migrations/20260321_extend_scheduled_notifications.sql index 1d5afab4..ee6e01b2 100644 --- a/supabase/migrations/20260321_extend_scheduled_notifications.sql +++ b/supabase/migrations/20260321_extend_scheduled_notifications.sql @@ -73,7 +73,7 @@ BEGIN 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' + AND status IN ('arrival', 'late') LOOP -- Skip if user already checked in for this duty IF EXISTS ( @@ -106,6 +106,7 @@ BEGIN 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 @@ -388,14 +389,18 @@ $$; 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 45–60 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' - BETWEEN now() - interval '90 seconds' AND now() + interval '90 seconds' + AND ps.slip_start + interval '45 minutes' <= now() + AND ps.slip_start + interval '60 minutes' > now() ON CONFLICT DO NOTHING; END; $$; @@ -420,8 +425,18 @@ $$; -- ============================================================================ -- 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. +-- 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; @@ -432,7 +447,7 @@ DECLARE v_base_url text; v_service_key text; BEGIN - -- Try Supabase vault first (standard in self-hosted Supabase) + -- Try Supabase vault first (works on both cloud and self-hosted) BEGIN SELECT decrypted_secret INTO v_service_key FROM vault.decrypted_secrets @@ -448,7 +463,7 @@ BEGIN NULL; END; - -- Fall back to app.settings GUC variables + -- 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; @@ -458,10 +473,11 @@ BEGIN -- 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'; + 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( @@ -485,31 +501,25 @@ 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; - -- 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 + -- Single job: triggers edge function which handles enqueue + process PERFORM cron.schedule( 'notification_process_every_min', '*/1 * * * *', 'SELECT public.process_notification_queue();' ); - -- Job 3: Daily cleanup of old processed notifications + -- 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') || ';' + '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(%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') || ';'; + RAISE NOTICE 'pg_cron scheduling failed: %. Set up cron jobs manually or use external cron.', SQLERRM; END $$; diff --git a/supabase/migrations/20260322_finalize_notification_functions.sql b/supabase/migrations/20260322_finalize_notification_functions.sql new file mode 100644 index 00000000..9a568a08 --- /dev/null +++ b/supabase/migrations/20260322_finalize_notification_functions.sql @@ -0,0 +1,451 @@ +-- 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 45–60 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 (45–60 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 $$;