finalized reminder notification system migrations
This commit is contained in:
parent
b2c3202317
commit
7d9096963a
25
supabase/cron_fallback.sh
Normal file
25
supabase/cron_fallback.sh
Normal file
|
|
@ -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"
|
||||||
|
|
@ -10,9 +10,11 @@ const corsHeaders = {
|
||||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
'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')
|
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')
|
const BATCH_SIZE = Number(Deno.env.get('PROCESSOR_BATCH_SIZE') || '50')
|
||||||
|
|
||||||
// deterministic UUIDv5-like from a name using SHA-1
|
// deterministic UUIDv5-like from a name using SHA-1
|
||||||
|
|
@ -31,6 +33,18 @@ async function uuidFromName(name: string): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processBatch() {
|
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 nowIso = new Date().toISOString()
|
||||||
const { data: rows, error } = await supabase
|
const { data: rows, error } = await supabase
|
||||||
.from('scheduled_notifications')
|
.from('scheduled_notifications')
|
||||||
|
|
@ -144,7 +158,10 @@ async function processBatch() {
|
||||||
|
|
||||||
const res = await fetch(SEND_FCM_URL, {
|
const res = await fetch(SEND_FCM_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${SERVICE_ROLE_KEY}`,
|
||||||
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ BEGIN
|
||||||
SELECT id AS schedule_id, user_id AS user_id, start_time AS start_at
|
SELECT id AS schedule_id, user_id AS user_id, start_time AS start_at
|
||||||
FROM public.duty_schedules
|
FROM public.duty_schedules
|
||||||
WHERE start_time BETWEEN now() + interval '15 minutes' - interval '30 seconds' AND now() + interval '15 minutes' + interval '30 seconds'
|
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
|
LOOP
|
||||||
-- Skip if user already checked in for this duty (attendance_logs references duty_schedule_id)
|
-- 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
|
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
|
SELECT id AS schedule_id, user_id AS user_id, end_time AS end_at
|
||||||
FROM public.duty_schedules
|
FROM public.duty_schedules
|
||||||
WHERE end_time BETWEEN now() - interval '30 seconds' AND now() + interval '30 seconds'
|
WHERE end_time BETWEEN now() - interval '30 seconds' AND now() + interval '30 seconds'
|
||||||
AND status = 'active'
|
AND status IN ('arrival', 'late')
|
||||||
LOOP
|
LOOP
|
||||||
INSERT INTO public.scheduled_notifications (schedule_id, user_id, notify_type, scheduled_for)
|
INSERT INTO public.scheduled_notifications (schedule_id, user_id, notify_type, scheduled_for)
|
||||||
VALUES (rec.schedule_id, rec.user_id, 'end', rec.end_at)
|
VALUES (rec.schedule_id, rec.user_id, 'end', rec.end_at)
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ BEGIN
|
||||||
FROM public.duty_schedules
|
FROM public.duty_schedules
|
||||||
WHERE start_time BETWEEN now() + interval '15 minutes' - interval '90 seconds'
|
WHERE start_time BETWEEN now() + interval '15 minutes' - interval '90 seconds'
|
||||||
AND now() + interval '15 minutes' + interval '90 seconds'
|
AND now() + interval '15 minutes' + interval '90 seconds'
|
||||||
AND status = 'active'
|
AND status IN ('arrival', 'late')
|
||||||
LOOP
|
LOOP
|
||||||
-- Skip if user already checked in for this duty
|
-- Skip if user already checked in for this duty
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
|
|
@ -106,6 +106,7 @@ BEGIN
|
||||||
FROM public.duty_schedules ds
|
FROM public.duty_schedules ds
|
||||||
JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id
|
JOIN public.attendance_logs al ON al.duty_schedule_id = ds.id AND al.user_id = ds.user_id
|
||||||
WHERE ds.end_time < now()
|
WHERE ds.end_time < now()
|
||||||
|
AND ds.end_time >= now() - interval '24 hours'
|
||||||
AND ds.status IN ('arrival', 'late')
|
AND ds.status IN ('arrival', 'late')
|
||||||
AND al.check_in_at IS NOT NULL
|
AND al.check_in_at IS NOT NULL
|
||||||
AND al.check_out_at IS NULL
|
AND al.check_out_at IS NULL
|
||||||
|
|
@ -388,14 +389,18 @@ $$;
|
||||||
CREATE OR REPLACE FUNCTION public.enqueue_pass_slip_expiry_notifications()
|
CREATE OR REPLACE FUNCTION public.enqueue_pass_slip_expiry_notifications()
|
||||||
RETURNS void LANGUAGE plpgsql AS $$
|
RETURNS void LANGUAGE plpgsql AS $$
|
||||||
BEGIN
|
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)
|
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()
|
SELECT ps.id, ps.user_id, 'pass_slip_expiry_15', now()
|
||||||
FROM public.pass_slips ps
|
FROM public.pass_slips ps
|
||||||
WHERE ps.status = 'approved'
|
WHERE ps.status = 'approved'
|
||||||
AND ps.slip_end IS NULL
|
AND ps.slip_end IS NULL
|
||||||
AND ps.slip_start IS NOT NULL
|
AND ps.slip_start IS NOT NULL
|
||||||
AND ps.slip_start + interval '45 minutes'
|
AND ps.slip_start + interval '45 minutes' <= now()
|
||||||
BETWEEN now() - interval '90 seconds' AND now() + interval '90 seconds'
|
AND ps.slip_start + interval '60 minutes' > now()
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
@ -420,8 +425,18 @@ $$;
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- 4. PG_NET + PG_CRON SCHEDULING
|
-- 4. PG_NET + PG_CRON SCHEDULING
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- Uses pg_net to call the process_scheduled_notifications edge function
|
-- The edge function (process_scheduled_notifications) now calls
|
||||||
-- from within PostgreSQL, eliminating the need for external cron jobs.
|
-- 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;
|
CREATE EXTENSION IF NOT EXISTS pg_net;
|
||||||
|
|
||||||
|
|
@ -432,7 +447,7 @@ DECLARE
|
||||||
v_base_url text;
|
v_base_url text;
|
||||||
v_service_key text;
|
v_service_key text;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Try Supabase vault first (standard in self-hosted Supabase)
|
-- Try Supabase vault first (works on both cloud and self-hosted)
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT decrypted_secret INTO v_service_key
|
SELECT decrypted_secret INTO v_service_key
|
||||||
FROM vault.decrypted_secrets
|
FROM vault.decrypted_secrets
|
||||||
|
|
@ -448,7 +463,7 @@ BEGIN
|
||||||
NULL;
|
NULL;
|
||||||
END;
|
END;
|
||||||
|
|
||||||
-- Fall back to app.settings GUC variables
|
-- Fall back to app.settings GUC variables (self-hosted)
|
||||||
IF v_base_url IS NULL THEN
|
IF v_base_url IS NULL THEN
|
||||||
v_base_url := current_setting('app.settings.supabase_url', true);
|
v_base_url := current_setting('app.settings.supabase_url', true);
|
||||||
END IF;
|
END IF;
|
||||||
|
|
@ -458,10 +473,11 @@ BEGIN
|
||||||
|
|
||||||
-- Guard: skip if config is missing
|
-- Guard: skip if config is missing
|
||||||
IF v_base_url IS NULL OR v_service_key IS NULL THEN
|
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;
|
RETURN;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
-- The edge function handles both enqueue + process internally
|
||||||
PERFORM net.http_post(
|
PERFORM net.http_post(
|
||||||
url := v_base_url || '/functions/v1/process_scheduled_notifications',
|
url := v_base_url || '/functions/v1/process_scheduled_notifications',
|
||||||
headers := jsonb_build_object(
|
headers := jsonb_build_object(
|
||||||
|
|
@ -485,31 +501,25 @@ BEGIN
|
||||||
PERFORM cron.unschedule('notification_reminders_every_min');
|
PERFORM cron.unschedule('notification_reminders_every_min');
|
||||||
EXCEPTION WHEN others THEN NULL;
|
EXCEPTION WHEN others THEN NULL;
|
||||||
END;
|
END;
|
||||||
|
BEGIN
|
||||||
|
PERFORM cron.unschedule('notification_enqueue_every_min');
|
||||||
|
EXCEPTION WHEN others THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
-- Job 1: Enqueue notifications every minute
|
-- Single job: triggers edge function which handles enqueue + process
|
||||||
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(
|
PERFORM cron.schedule(
|
||||||
'notification_process_every_min',
|
'notification_process_every_min',
|
||||||
'*/1 * * * *',
|
'*/1 * * * *',
|
||||||
'SELECT public.process_notification_queue();'
|
'SELECT public.process_notification_queue();'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Job 3: Daily cleanup of old processed notifications
|
-- Daily cleanup of old processed notifications
|
||||||
PERFORM cron.schedule(
|
PERFORM cron.schedule(
|
||||||
'cleanup_old_notifications',
|
'cleanup_old_notifications',
|
||||||
'0 3 * * *',
|
'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
|
EXCEPTION WHEN others THEN
|
||||||
RAISE NOTICE 'pg_cron/pg_net not available. After enabling them, run these manually:';
|
RAISE NOTICE 'pg_cron scheduling failed: %. Set up cron jobs manually or use external cron.', SQLERRM;
|
||||||
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 $$;
|
END $$;
|
||||||
|
|
|
||||||
451
supabase/migrations/20260322_finalize_notification_functions.sql
Normal file
451
supabase/migrations/20260322_finalize_notification_functions.sql
Normal file
|
|
@ -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 $$;
|
||||||
Loading…
Reference in New Issue
Block a user