diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts index f772fc4..c55dc7b 100644 --- a/app/api/incidents/route.ts +++ b/app/api/incidents/route.ts @@ -3,6 +3,8 @@ import { createClient } from '@/lib/supabase/server' import { validateIncidentInput, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate' import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage' import { sendNewIncidentEmail } from '@/lib/notifications/email' +import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp' +import { getApiKey } from '@/lib/settings' 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) .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 const supabaseForEmbed = supabase import('@/lib/settings').then(({ getApiKey }) => diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 2f6e6d0..08f1aaa 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -3,7 +3,12 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/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] async function requireAdmin(supabase: Awaited>) { diff --git a/lib/notifications/capa-escalation.ts b/lib/notifications/capa-escalation.ts index eff6597..6764c87 100644 --- a/lib/notifications/capa-escalation.ts +++ b/lib/notifications/capa-escalation.ts @@ -1,5 +1,7 @@ import { Resend } from 'resend' 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' @@ -32,7 +34,7 @@ export async function escalateOverdueCapa( .select(` id, description, due_date, incident_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)') @@ -91,6 +93,26 @@ export async function escalateOverdueCapa( 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({ capa_id: capa.id, channel: 'email', diff --git a/lib/notifications/whatsapp.ts b/lib/notifications/whatsapp.ts new file mode 100644 index 0000000..fb6a6bb --- /dev/null +++ b/lib/notifications/whatsapp.ts @@ -0,0 +1,42 @@ +export async function sendWhatsAppMessage( + phoneNumber: string, + templateName: string, + parameters: string[], + phoneNumberId: string, + accessToken: string, +): Promise { + 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}`) + } +} diff --git a/tests/lib/notifications/capa-escalation.test.ts b/tests/lib/notifications/capa-escalation.test.ts index 5ad35cb..96138f0 100644 --- a/tests/lib/notifications/capa-escalation.test.ts +++ b/tests/lib/notifications/capa-escalation.test.ts @@ -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 = {} + 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() + }) +}) diff --git a/tests/lib/notifications/whatsapp.test.ts b/tests/lib/notifications/whatsapp.test.ts new file mode 100644 index 0000000..58977e4 --- /dev/null +++ b/tests/lib/notifications/whatsapp.test.ts @@ -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') + }) +})