feat: WhatsApp notifications — new incident alert and CAPA overdue escalation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
This commit is contained in:
@@ -3,6 +3,8 @@ import { createClient } from '@/lib/supabase/server'
|
|||||||
import { validateIncidentInput, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate'
|
import { validateIncidentInput, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate'
|
||||||
import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage'
|
import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage'
|
||||||
import { sendNewIncidentEmail } from '@/lib/notifications/email'
|
import { sendNewIncidentEmail } from '@/lib/notifications/email'
|
||||||
|
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||||
|
import { getApiKey } from '@/lib/settings'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -109,6 +111,35 @@ export async function POST(request: Request) {
|
|||||||
sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type)
|
sendNewIncidentEmail(incident.id, zone.site_id, incident.reference_no ?? '', input.incident_type)
|
||||||
.catch(err => console.error('email notification failed:', err))
|
.catch(err => console.error('email notification failed:', err))
|
||||||
|
|
||||||
|
// WhatsApp alert — fire-and-forget alongside email
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const phoneNumberId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
|
||||||
|
const accessToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
|
||||||
|
const { data: siteData } = await supabase
|
||||||
|
.from('sites').select('name').eq('id', zone.site_id).single()
|
||||||
|
const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown'
|
||||||
|
const { data: recipients } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('phone')
|
||||||
|
.in('role', ['supervisor', 'hse'])
|
||||||
|
.eq('site_id', zone.site_id)
|
||||||
|
for (const r of recipients ?? []) {
|
||||||
|
const phone = (r as { phone: string | null }).phone ?? ''
|
||||||
|
if (!phone) continue
|
||||||
|
await sendWhatsAppMessage(
|
||||||
|
phone,
|
||||||
|
'ims_incident_alert',
|
||||||
|
[incident.reference_no ?? '', input.incident_type, siteName],
|
||||||
|
phoneNumberId,
|
||||||
|
accessToken,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WhatsApp incident alert error:', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
// Embed description asynchronously for future similarity search
|
// Embed description asynchronously for future similarity search
|
||||||
const supabaseForEmbed = supabase
|
const supabaseForEmbed = supabase
|
||||||
import('@/lib/settings').then(({ getApiKey }) =>
|
import('@/lib/settings').then(({ getApiKey }) =>
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ export const dynamic = 'force-dynamic'
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
|
||||||
const ALLOWED_KEYS = ['ANTHROPIC_API_KEY', 'VOYAGE_API_KEY'] as const
|
const ALLOWED_KEYS = [
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'VOYAGE_API_KEY',
|
||||||
|
'META_WHATSAPP_PHONE_NUMBER_ID',
|
||||||
|
'META_WHATSAPP_ACCESS_TOKEN',
|
||||||
|
] as const
|
||||||
type SettingKey = typeof ALLOWED_KEYS[number]
|
type SettingKey = typeof ALLOWED_KEYS[number]
|
||||||
|
|
||||||
async function requireAdmin(supabase: Awaited<ReturnType<typeof createClient>>) {
|
async function requireAdmin(supabase: Awaited<ReturnType<typeof createClient>>) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||||
|
import { getApiKey } from '@/lib/settings'
|
||||||
|
|
||||||
export type EscalationThreshold = 'warning_3d' | 'due_today' | 'overdue_3d' | 'overdue_7d'
|
export type EscalationThreshold = 'warning_3d' | 'due_today' | 'overdue_3d' | 'overdue_7d'
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ export async function escalateOverdueCapa(
|
|||||||
.select(`
|
.select(`
|
||||||
id, description, due_date, incident_id,
|
id, description, due_date, incident_id,
|
||||||
incidents (reference_no, site_id),
|
incidents (reference_no, site_id),
|
||||||
owner:users!owner_user_id (email, name)
|
owner:users!owner_user_id (email, name, phone)
|
||||||
`)
|
`)
|
||||||
.not('status', 'in', '(verified,closed)')
|
.not('status', 'in', '(verified,closed)')
|
||||||
|
|
||||||
@@ -91,6 +93,26 @@ export async function escalateOverdueCapa(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WhatsApp for urgent thresholds only (owner must have a phone number)
|
||||||
|
if (['overdue_3d', 'overdue_7d'].includes(threshold)) {
|
||||||
|
const ownerPhone = (capa.owner as unknown as { phone: string | null } | null)?.phone ?? ''
|
||||||
|
if (ownerPhone) {
|
||||||
|
try {
|
||||||
|
const phoneNumberId = await getApiKey(supabase, 'META_WHATSAPP_PHONE_NUMBER_ID')
|
||||||
|
const accessToken = await getApiKey(supabase, 'META_WHATSAPP_ACCESS_TOKEN')
|
||||||
|
await sendWhatsAppMessage(
|
||||||
|
ownerPhone,
|
||||||
|
'ims_capa_overdue',
|
||||||
|
[incidentRef, (capa as { description: string }).description, (capa as { due_date: string }).due_date],
|
||||||
|
phoneNumberId,
|
||||||
|
accessToken,
|
||||||
|
)
|
||||||
|
} catch (waErr) {
|
||||||
|
console.error('WhatsApp escalation error:', waErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await supabase.from('notifications_log').insert({
|
await supabase.from('notifications_log').insert({
|
||||||
capa_id: capa.id,
|
capa_id: capa.id,
|
||||||
channel: 'email',
|
channel: 'email',
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export async function sendWhatsAppMessage(
|
||||||
|
phoneNumber: string,
|
||||||
|
templateName: string,
|
||||||
|
parameters: string[],
|
||||||
|
phoneNumberId: string,
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const sanitized = phoneNumber.replace(/[^0-9]/g, '')
|
||||||
|
if (!sanitized) return
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
messaging_product: 'whatsapp',
|
||||||
|
to: sanitized,
|
||||||
|
type: 'template',
|
||||||
|
template: {
|
||||||
|
name: templateName,
|
||||||
|
language: { code: 'en_US' },
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 'body',
|
||||||
|
parameters: parameters.map(text => ({ type: 'text', text })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`https://graph.facebook.com/v19.0/${phoneNumberId}/messages`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`WhatsApp API error: ${res.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,21 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { getEscalationThreshold } from '@/lib/notifications/capa-escalation'
|
import { getEscalationThreshold, escalateOverdueCapa } from '@/lib/notifications/capa-escalation'
|
||||||
|
|
||||||
|
vi.mock('resend', () => {
|
||||||
|
const sendMock = vi.fn().mockResolvedValue({ data: { id: 'email-id' }, error: null })
|
||||||
|
function ResendMock() {
|
||||||
|
return { emails: { send: sendMock } }
|
||||||
|
}
|
||||||
|
return { Resend: ResendMock }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/notifications/whatsapp', () => ({
|
||||||
|
sendWhatsAppMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/settings', () => ({
|
||||||
|
getApiKey: vi.fn().mockResolvedValue('test-cred'),
|
||||||
|
}))
|
||||||
|
|
||||||
describe('getEscalationThreshold', () => {
|
describe('getEscalationThreshold', () => {
|
||||||
function daysFromNow(n: number): string {
|
function daysFromNow(n: number): string {
|
||||||
@@ -32,3 +48,107 @@ describe('getEscalationThreshold', () => {
|
|||||||
expect(getEscalationThreshold(daysFromNow(4))).toBeNull()
|
expect(getEscalationThreshold(daysFromNow(4))).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('escalateOverdueCapa — WhatsApp', () => {
|
||||||
|
function daysFromNow(n: number): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + n)
|
||||||
|
return d.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSupabaseMock(overrides: {
|
||||||
|
capas?: unknown[]
|
||||||
|
alreadySent?: unknown
|
||||||
|
hseUsers?: unknown[]
|
||||||
|
}) {
|
||||||
|
const { capas = [], alreadySent = null, hseUsers = [] } = overrides
|
||||||
|
|
||||||
|
// Each .from() call returns a fresh chainable builder
|
||||||
|
// We track call order: first from('capa_actions'), then from('notifications_log'), then from('users'), then from('notifications_log').insert
|
||||||
|
let fromCallIndex = 0
|
||||||
|
|
||||||
|
const makeChain = (resolvedValue: unknown) => {
|
||||||
|
const chain: Record<string, unknown> = {}
|
||||||
|
const methods = ['select', 'not', 'eq', 'in', 'limit', 'maybeSingle', 'insert']
|
||||||
|
for (const m of methods) {
|
||||||
|
chain[m] = vi.fn(() => chain)
|
||||||
|
}
|
||||||
|
// Terminal: awaiting the chain resolves to resolvedValue
|
||||||
|
Object.defineProperty(chain, 'then', {
|
||||||
|
get() {
|
||||||
|
return (resolve: (v: unknown) => unknown) => Promise.resolve(resolvedValue).then(resolve)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = {
|
||||||
|
from: vi.fn(() => {
|
||||||
|
const index = fromCallIndex++
|
||||||
|
if (index === 0) return makeChain({ data: capas, error: null }) // capa_actions
|
||||||
|
if (index === 1) return makeChain({ data: alreadySent, error: null }) // notifications_log check
|
||||||
|
if (index === 2) return makeChain({ data: hseUsers, error: null }) // hse users for CC
|
||||||
|
return makeChain({ data: null, error: null }) // notifications_log insert
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return supabase as unknown as import('@supabase/supabase-js').SupabaseClient
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends WhatsApp when threshold is overdue_7d and owner has phone', async () => {
|
||||||
|
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
|
||||||
|
|
||||||
|
const supabase = makeSupabaseMock({
|
||||||
|
capas: [
|
||||||
|
{
|
||||||
|
id: 'capa-1',
|
||||||
|
description: 'Fix safety barrier',
|
||||||
|
due_date: daysFromNow(-7),
|
||||||
|
incident_id: 'inc-1',
|
||||||
|
incidents: { reference_no: 'KL-202501-0001', site_id: 'site-1' },
|
||||||
|
owner: { email: 'owner@test.com', name: 'Alice', phone: '60123456789' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
alreadySent: null,
|
||||||
|
hseUsers: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
await escalateOverdueCapa(supabase)
|
||||||
|
|
||||||
|
expect(mockWA).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^\d+$/),
|
||||||
|
'ims_capa_overdue',
|
||||||
|
expect.arrayContaining([expect.any(String)]),
|
||||||
|
'test-cred',
|
||||||
|
'test-cred',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT send WhatsApp when threshold is warning_3d', async () => {
|
||||||
|
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
const supabase = makeSupabaseMock({
|
||||||
|
capas: [
|
||||||
|
{
|
||||||
|
id: 'capa-2',
|
||||||
|
description: 'Inspect equipment',
|
||||||
|
due_date: daysFromNow(3),
|
||||||
|
incident_id: 'inc-2',
|
||||||
|
incidents: { reference_no: 'KL-202501-0002', site_id: 'site-1' },
|
||||||
|
owner: { email: 'owner@test.com', name: 'Bob', phone: '60129876543' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
alreadySent: null,
|
||||||
|
hseUsers: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
await escalateOverdueCapa(supabase)
|
||||||
|
|
||||||
|
expect(mockWA).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||||
|
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
describe('sendWhatsAppMessage', () => {
|
||||||
|
beforeEach(() => fetchMock.mockClear())
|
||||||
|
|
||||||
|
it('posts to Meta Graph API with correct template structure', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
new Response('{"messages":[{"id":"wamid.abc"}]}', { status: 200 })
|
||||||
|
)
|
||||||
|
await sendWhatsAppMessage(
|
||||||
|
'60123456789',
|
||||||
|
'ims_incident_alert',
|
||||||
|
['SETIA-202407-0001', 'injury', 'Warehouse A'],
|
||||||
|
'test-phone-id',
|
||||||
|
'test-token'
|
||||||
|
)
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce()
|
||||||
|
const [url, opts] = fetchMock.mock.calls[0]
|
||||||
|
expect(url).toBe('https://graph.facebook.com/v19.0/test-phone-id/messages')
|
||||||
|
expect(opts.method).toBe('POST')
|
||||||
|
expect(opts.headers['Authorization']).toBe('Bearer test-token')
|
||||||
|
const body = JSON.parse(opts.body)
|
||||||
|
expect(body.messaging_product).toBe('whatsapp')
|
||||||
|
expect(body.to).toBe('60123456789')
|
||||||
|
expect(body.type).toBe('template')
|
||||||
|
expect(body.template.name).toBe('ims_incident_alert')
|
||||||
|
expect(body.template.language.code).toBe('en_US')
|
||||||
|
expect(body.template.components[0].parameters).toHaveLength(3)
|
||||||
|
expect(body.template.components[0].parameters[0]).toEqual({ type: 'text', text: 'SETIA-202407-0001' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on non-2xx response', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
new Response('{"error":{"message":"Invalid token"}}', { status: 400 })
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
sendWhatsAppMessage('60123456789', 'ims_incident_alert', ['a'], 'pid', 'tok')
|
||||||
|
).rejects.toThrow('WhatsApp API error: 400')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns immediately without calling fetch when phoneNumber is empty', async () => {
|
||||||
|
await sendWhatsAppMessage('', 'ims_incident_alert', ['a'], 'pid', 'tok')
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizes phone number — strips spaces, dashes, and plus sign', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
new Response('{"messages":[{"id":"x"}]}', { status: 200 })
|
||||||
|
)
|
||||||
|
await sendWhatsAppMessage('+60 12-345 6789', 'ims_incident_alert', ['a'], 'pid', 'tok')
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||||
|
expect(body.to).toBe('60123456789')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user