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:
2026-07-11 18:50:25 +08:00
co-authored by Claude Sonnet 4.6
parent 121433ddf8
commit a47f3aabc9
6 changed files with 282 additions and 4 deletions
+122 -2
View File
@@ -1,5 +1,21 @@
import { describe, it, expect } from 'vitest'
import { getEscalationThreshold } from '@/lib/notifications/capa-escalation'
import { describe, it, expect, vi, beforeEach } from 'vitest'
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', () => {
function daysFromNow(n: number): string {
@@ -32,3 +48,107 @@ describe('getEscalationThreshold', () => {
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()
})
})
+58
View File
@@ -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')
})
})