tasq/supabase/functions/process_scheduled_notifications/index.ts
Marc Rejohn Castillano d484f62cbd Implement push notification reminder system with 9 notification types
Adds comprehensive push notification reminders using pg_cron + pg_net:
- Shift check-in reminder (15 min before, with countdown banner)
- Shift check-out reminder (hourly, persistent until checkout)
- Overtime idle reminder (15 min without task)
- Overtime checkout reminder (30 min after task completion)
- IT service request event reminder (1 hour before event)
- IT service request evidence reminder (daily)
- Paused task reminder (daily)
- Backlog reminder (15 min before shift end)
- Pass slip expiry reminder (15 min before 1-hour limit, with countdown banner)

Database: Extended scheduled_notifications table to support polymorphic references
(schedule_id, task_id, it_service_request_id, pass_slip_id) with unique constraint
and epoch column for deduplication. Implemented 8 enqueue functions + master dispatcher.
Uses pg_cron every minute to enqueue and pg_net to trigger process_scheduled_notifications
edge function, eliminating need for external cron job. Credentials stored in vault with
GUC fallback for flexibility.

Flutter: Added ShiftCountdownBanner and PassSlipCountdownBanner widgets that display
persistent countdown timers for active shifts and pass slips. Both auto-dismiss when
user completes the action. FCM handler triggers shift countdown on start_15 messages.
navigate_to field in data payload enables flexible routing to any screen.

Edge function: Updated process_scheduled_notifications to handle all 9 types with
appropriate titles, bodies, and routing. Includes pass_slip_id in idempotency tracking.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-20 18:26:48 +08:00

199 lines
7.5 KiB
TypeScript

import { createClient } from 'npm:@supabase/supabase-js@2'
// Minimal Deno Edge Function to process queued scheduled_notifications
// Environment variables required:
// SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SEND_FCM_URL
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'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 SEND_FCM_URL = Deno.env.get('SEND_FCM_URL')!
const BATCH_SIZE = Number(Deno.env.get('PROCESSOR_BATCH_SIZE') || '50')
// deterministic UUIDv5-like from a name using SHA-1
async function uuidFromName(name: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(name)
const hashBuffer = await crypto.subtle.digest('SHA-1', data)
const hash = new Uint8Array(hashBuffer)
// use first 16 bytes of SHA-1
const bytes = hash.slice(0, 16)
// set version (5) and variant (RFC 4122)
bytes[6] = (bytes[6] & 0x0f) | 0x50 // version 5
bytes[8] = (bytes[8] & 0x3f) | 0x80 // variant
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20,32)}`
}
async function processBatch() {
const nowIso = new Date().toISOString()
const { data: rows, error } = await supabase
.from('scheduled_notifications')
.select('*')
.eq('processed', false)
.lte('scheduled_for', nowIso)
.order('scheduled_for', { ascending: true })
.limit(BATCH_SIZE)
if (error) {
console.error('Failed to fetch scheduled_notifications', error)
return
}
if (!rows || rows.length === 0) {
console.log('No scheduled rows to process')
return
}
for (const r of rows) {
try {
const scheduleId = r.schedule_id
const userId = r.user_id
const notifyType = r.notify_type
const rowId = r.id
// Build a unique ID that accounts for all reference columns + epoch
const idSource = `${scheduleId || ''}-${r.task_id || ''}-${r.it_service_request_id || ''}-${r.pass_slip_id || ''}-${userId}-${notifyType}-${r.epoch || 0}`
const notificationId = await uuidFromName(idSource)
// Attempt to mark idempotent push
const { data: markData, error: markErr } = await supabase.rpc('try_mark_notification_pushed', { p_notification_id: notificationId })
if (markErr) {
console.warn('try_mark_notification_pushed error', markErr)
// do not mark processed; increment retry
await supabase.from('scheduled_notifications').update({ retry_count: r.retry_count + 1, last_error: String(markErr) }).eq('id', rowId)
continue
}
if (markData === false) {
console.log('Notification already pushed, skipping', notificationId)
await supabase.from('scheduled_notifications').update({ processed: true, processed_at: new Date().toISOString() }).eq('id', rowId)
continue
}
// Prepare message based on notify_type
let title = ''
let body = ''
const data: Record<string, string> = {
notification_id: notificationId,
type: notifyType,
}
// Include reference IDs in data payload
if (scheduleId) data.schedule_id = scheduleId
if (r.task_id) data.task_id = r.task_id
if (r.it_service_request_id) data.it_service_request_id = r.it_service_request_id
if (r.pass_slip_id) data.pass_slip_id = r.pass_slip_id
switch (notifyType) {
case 'start_15':
title = 'Shift starting soon'
body = "Your shift starts in 15 minutes. Don't forget to check in."
data.navigate_to = '/attendance'
break
case 'end':
title = 'Shift ended'
body = "Your shift has ended. Please remember to check out."
data.navigate_to = '/attendance'
break
case 'end_hourly':
title = 'Check-out reminder'
body = "You haven't checked out yet. Please check out when done."
data.navigate_to = '/attendance'
break
case 'overtime_idle_15':
title = 'No active task'
body = "You've been on overtime for 15 minutes without an active task or IT service request."
data.navigate_to = '/tasks'
break
case 'overtime_checkout_30':
title = 'Overtime check-out reminder'
body = "It's been 30 minutes since your last task ended. Consider checking out if you're done."
data.navigate_to = '/attendance'
break
case 'isr_event_60':
title = 'IT Service Request event soon'
body = 'An IT service request event starts in 1 hour.'
data.navigate_to = `/it-service-requests/${r.it_service_request_id}`
break
case 'isr_evidence_daily':
title = 'Evidence upload reminder'
body = 'Please upload evidence and action taken for your IT service request.'
data.navigate_to = `/it-service-requests/${r.it_service_request_id}`
break
case 'task_paused_daily':
title = 'Paused task reminder'
body = 'You have a paused task that needs attention.'
data.navigate_to = `/tasks/${r.task_id}`
break
case 'backlog_15':
title = 'Pending tasks reminder'
body = 'Your shift ends in 15 minutes and you still have pending tasks.'
data.navigate_to = '/tasks'
break
case 'pass_slip_expiry_15':
title = 'Pass slip expiring soon'
body = 'Your pass slip expires in 15 minutes. Please return and complete it.'
data.navigate_to = '/attendance'
break
default:
title = 'Reminder'
body = 'You have a pending notification.'
}
// Call send_fcm endpoint to deliver push (reuses existing implementation)
const payload = {
user_ids: [userId],
title,
body,
data,
}
const res = await fetch(SEND_FCM_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
console.error('send_fcm failed', res.status, text)
await supabase.from('scheduled_notifications').update({ retry_count: r.retry_count + 1, last_error: `send_fcm ${res.status}: ${text}` }).eq('id', rowId)
continue
}
// Mark processed
await supabase.from('scheduled_notifications').update({ processed: true, processed_at: new Date().toISOString() }).eq('id', rowId)
console.log('Processed scheduled notification', rowId, notificationId)
} catch (err) {
console.error('Error processing row', r, err)
try {
await supabase.from('scheduled_notifications').update({ retry_count: r.retry_count + 1, last_error: String(err) }).eq('id', r.id)
} catch (e) {
console.error('Failed to update retry_count', e)
}
}
}
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
// Allow manual triggering via POST; also allow GET for quick check
if (req.method === 'POST' || req.method === 'GET') {
await processBatch()
return new Response('ok', { headers: corsHeaders })
}
return new Response('method not allowed', { status: 405, headers: corsHeaders })
} catch (err) {
console.error('Processor error', err)
return new Response(JSON.stringify({ error: String(err) }), { status: 500, headers: corsHeaders })
}
})