From b6845f5bb53294ca90adfacac0f7f4388404882a Mon Sep 17 00:00:00 2001 From: weeihan Date: Sat, 11 Jul 2026 18:43:57 +0800 Subject: [PATCH] feat: WhatsApp notification helper + phase 4 migration (recheck_round, WhatsApp settings) --- app/api/settings/route.ts | 7 ++- lib/notifications/whatsapp.ts | 42 ++++++++++++++ supabase/migrations/20260711000015_phase4.sql | 10 ++++ tests/lib/notifications/whatsapp.test.ts | 58 +++++++++++++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 lib/notifications/whatsapp.ts create mode 100644 supabase/migrations/20260711000015_phase4.sql create mode 100644 tests/lib/notifications/whatsapp.test.ts 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/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/supabase/migrations/20260711000015_phase4.sql b/supabase/migrations/20260711000015_phase4.sql new file mode 100644 index 0000000..48760ac --- /dev/null +++ b/supabase/migrations/20260711000015_phase4.sql @@ -0,0 +1,10 @@ +-- Add effectiveness recheck round tracking to capa_actions +ALTER TABLE capa_actions + ADD COLUMN IF NOT EXISTS effectiveness_recheck_round INT NOT NULL DEFAULT 0; + +-- WhatsApp and effectiveness recheck credentials for Settings UI +INSERT INTO app_settings (key, value, updated_at, updated_by) +VALUES + ('META_WHATSAPP_PHONE_NUMBER_ID', '', now(), NULL), + ('META_WHATSAPP_ACCESS_TOKEN', '', now(), NULL) +ON CONFLICT (key) DO NOTHING; 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') + }) +})