diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 2c4c7d5..af36981 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -6,6 +6,10 @@ import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils' import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature' import { runAutomationsForTrigger } from '@/lib/automations/engine' import { dispatchInboundToFlows } from '@/lib/flows/engine' +import { + handleTemplateWebhookChange, + isTemplateWebhookField, +} from '@/lib/whatsapp/template-webhook' // Lazy-initialized to avoid build-time crash when env vars are missing // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -190,6 +194,19 @@ async function processWebhook(body: { entry?: WhatsAppWebhookEntry[] }) { for (const entry of body.entry) { for (const change of entry.changes) { + // Template-lifecycle events (status / quality / components + // updates from Meta) come in on a different change.field and + // have a different value shape — route them through the + // dedicated handler. Skip the messaging branches below so we + // don't try to read message-shaped fields off a template event. + if (isTemplateWebhookField(change.field)) { + await handleTemplateWebhookChange( + { field: change.field, value: change.value as unknown }, + supabaseAdmin(), + ) + continue + } + const value = change.value // Handle status updates diff --git a/src/lib/whatsapp/template-webhook.test.ts b/src/lib/whatsapp/template-webhook.test.ts new file mode 100644 index 0000000..b1554d9 --- /dev/null +++ b/src/lib/whatsapp/template-webhook.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import { + handleTemplateWebhookChange, + isTemplateWebhookField, +} from './template-webhook'; + +// Tiny mock that records the .update payload and the .eq filter for +// inspection. Mirrors the surface this module actually uses on the +// Supabase client (.from().update().eq().select()) — anything beyond +// throws, so unintended calls fail loudly. +function makeSupabaseStub( + selectResult: { data: { id: string }[] | null; error: { message: string } | null } = { + data: [{ id: 'row-1' }], + error: null, + }, +) { + const calls: { + table: string; + update?: Record; + filter?: { column: string; value: unknown }; + }[] = []; + + const stub = { + from(table: string) { + const entry: (typeof calls)[number] = { table }; + calls.push(entry); + return { + update(payload: Record) { + entry.update = payload; + return { + eq(column: string, value: unknown) { + entry.filter = { column, value }; + return { + select() { + return Promise.resolve(selectResult); + }, + then( + onFulfilled: ( + v: { error: { message: string } | null }, + ) => unknown, + ) { + // Allow `await supabase.update().eq()` (no .select()). + return Promise.resolve({ error: selectResult.error }).then( + onFulfilled, + ); + }, + }; + }, + }; + }, + }; + }, + }; + + return { stub: stub as unknown as SupabaseClient, calls }; +} + +describe('isTemplateWebhookField', () => { + it('recognises the three template fields', () => { + expect(isTemplateWebhookField('message_template_status_update')).toBe(true); + expect(isTemplateWebhookField('message_template_quality_update')).toBe(true); + expect(isTemplateWebhookField('message_template_components_update')).toBe( + true, + ); + }); + it('rejects messaging fields', () => { + expect(isTemplateWebhookField('messages')).toBe(false); + expect(isTemplateWebhookField('message_status')).toBe(false); + }); +}); + +describe('handleTemplateWebhookChange — status update', () => { + let supabaseCalls: ReturnType['calls']; + + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('flips status to APPROVED and clears any rejection_reason', async () => { + const { stub, calls } = makeSupabaseStub(); + supabaseCalls = calls; + await handleTemplateWebhookChange( + { + field: 'message_template_status_update', + value: { + event: 'APPROVED', + message_template_id: 12345, + message_template_name: 'order_confirmation', + message_template_language: 'en_US', + }, + }, + stub, + ); + expect(supabaseCalls).toHaveLength(1); + expect(supabaseCalls[0].table).toBe('message_templates'); + expect(supabaseCalls[0].filter).toEqual({ + column: 'meta_template_id', + value: '12345', // coerced to string so the .eq matches the TEXT column + }); + expect(supabaseCalls[0].update).toEqual({ + status: 'APPROVED', + rejection_reason: null, + submission_error: null, + }); + }); + + it('persists the reason field on REJECTED', async () => { + const { stub, calls } = makeSupabaseStub(); + await handleTemplateWebhookChange( + { + field: 'message_template_status_update', + value: { + event: 'REJECTED', + message_template_id: 'TMPL_99', + reason: 'Template uses non-compliant language.', + }, + }, + stub, + ); + expect(calls[0].update?.status).toBe('REJECTED'); + expect(calls[0].update?.rejection_reason).toBe( + 'Template uses non-compliant language.', + ); + }); + + it('falls back to a generic reason when REJECTED has no `reason`', async () => { + const { stub, calls } = makeSupabaseStub(); + await handleTemplateWebhookChange( + { + field: 'message_template_status_update', + value: { event: 'REJECTED', message_template_id: '7' }, + }, + stub, + ); + expect(calls[0].update?.rejection_reason).toBe('Rejected by Meta'); + }); + + it('normalises PENDING_REVIEW → PENDING (via shared normalizeStatus)', async () => { + const { stub, calls } = makeSupabaseStub(); + await handleTemplateWebhookChange( + { + field: 'message_template_status_update', + value: { event: 'PENDING_REVIEW', message_template_id: '1' }, + }, + stub, + ); + expect(calls[0].update?.status).toBe('PENDING'); + }); + + it('logs and exits when meta_template_id is missing (no UPDATE issued)', async () => { + const { stub, calls } = makeSupabaseStub(); + await handleTemplateWebhookChange( + { + field: 'message_template_status_update', + value: { event: 'APPROVED' }, + }, + stub, + ); + expect(calls).toHaveLength(0); + }); + + it('logs a warning when the row is unknown locally (zero matches)', async () => { + const warn = vi.spyOn(console, 'warn'); + const { stub } = makeSupabaseStub({ data: [], error: null }); + await handleTemplateWebhookChange( + { + field: 'message_template_status_update', + value: { + event: 'APPROVED', + message_template_id: 'NEVER_SEEN', + message_template_name: 'mystery', + }, + }, + stub, + ); + expect(warn).toHaveBeenCalled(); + }); +}); + +describe('handleTemplateWebhookChange — quality update', () => { + it('sets quality_score from new_quality_score', async () => { + const { stub, calls } = makeSupabaseStub(); + await handleTemplateWebhookChange( + { + field: 'message_template_quality_update', + value: { + message_template_id: '99', + previous_quality_score: 'GREEN', + new_quality_score: 'YELLOW', + }, + }, + stub, + ); + expect(calls[0].update).toEqual({ quality_score: 'YELLOW' }); + expect(calls[0].filter).toEqual({ + column: 'meta_template_id', + value: '99', + }); + }); + + it('stores null for unrecognised quality scores', async () => { + const { stub, calls } = makeSupabaseStub(); + await handleTemplateWebhookChange( + { + field: 'message_template_quality_update', + value: { + message_template_id: '99', + new_quality_score: 'PURPLE', // not a real Meta value + }, + }, + stub, + ); + expect(calls[0].update).toEqual({ quality_score: null }); + }); +}); + +describe('handleTemplateWebhookChange — components update', () => { + it('is an info-log no-op (does not write to DB)', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + const { stub, calls } = makeSupabaseStub(); + await handleTemplateWebhookChange( + { + field: 'message_template_components_update', + value: { + message_template_id: '5', + message_template_name: 'x', + }, + }, + stub, + ); + expect(calls).toHaveLength(0); + expect(info).toHaveBeenCalled(); + }); +}); + +describe('handleTemplateWebhookChange — unknown field', () => { + it('is a defensive no-op', async () => { + const { stub, calls } = makeSupabaseStub(); + await handleTemplateWebhookChange( + // Pretend Meta added a new template_* field we don't know about. + // The route handler pre-filters via isTemplateWebhookField, but + // the dispatch should still be safe if the filter is bypassed. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { field: 'message_template_future_field' as any, value: {} }, + stub, + ); + expect(calls).toHaveLength(0); + }); +}); diff --git a/src/lib/whatsapp/template-webhook.ts b/src/lib/whatsapp/template-webhook.ts new file mode 100644 index 0000000..fa5f646 --- /dev/null +++ b/src/lib/whatsapp/template-webhook.ts @@ -0,0 +1,215 @@ +/** + * Handlers for Meta's template-lifecycle webhook events. + * + * Meta delivers three template-related webhook fields, each with a + * different `value` shape: + * + * - message_template_status_update — APPROVED / REJECTED / PAUSED / etc. + * - message_template_quality_update — GREEN / YELLOW / RED quality score + * - message_template_components_update — Meta auto-modified the template + * + * The route handler at /api/whatsapp/webhook receives every change and + * delegates here when `change.field` starts with `message_template_`. + * + * ─── Setup requirement (out-of-band) ────────────────────────────── + * These fields are NOT subscribed to by default. In Meta App Dashboard + * → WhatsApp → Configuration → Webhooks, you must explicitly toggle + * each of the three fields above. There is no API to do this for + * Cloud API apps — it's a one-time manual step per app. Until that's + * done, status updates only land via the manual "Sync from Meta" + * button (the legacy fallback, intentionally preserved). + * + * ─── Multi-tenant note ──────────────────────────────────────────── + * `meta_template_id` is globally unique per WABA — the lookup doesn't + * filter by user_id. If two wacrm tenants somehow ended up with the + * same id (impossible in practice, but a theoretical race during + * cross-tenant moves), the handler updates both rows and logs a + * warning so operators can investigate. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import { normalizeStatus } from './template-status-normalize' + +const TEMPLATE_WEBHOOK_FIELDS = new Set([ + 'message_template_status_update', + 'message_template_quality_update', + 'message_template_components_update', +]) + +export function isTemplateWebhookField(field: string): boolean { + return TEMPLATE_WEBHOOK_FIELDS.has(field) +} + +interface TemplateStatusUpdateValue { + event?: string + message_template_id?: string | number + message_template_name?: string + message_template_language?: string + reason?: string +} + +interface TemplateQualityUpdateValue { + message_template_id?: string | number + message_template_name?: string + message_template_language?: string + previous_quality_score?: string + new_quality_score?: string +} + +interface TemplateComponentsUpdateValue { + message_template_id?: string | number + message_template_name?: string + message_template_language?: string +} + +export interface TemplateWebhookChange { + field: string + value: unknown +} + +/** + * Dispatch a single change record to the matching handler. Returns + * silently on unrecognised fields — the caller already pre-filtered + * via isTemplateWebhookField, but treat unknown values as no-ops + * defensively in case Meta adds new template fields later. + */ +export async function handleTemplateWebhookChange( + change: TemplateWebhookChange, + // SupabaseClient typed loosely — the webhook route lazy-initialises + // the admin client and exposes it as `any`. Type as the generic + // SupabaseClient here so this module is testable in isolation. + supabase: SupabaseClient, +): Promise { + switch (change.field) { + case 'message_template_status_update': + await handleStatusUpdate( + change.value as TemplateStatusUpdateValue, + supabase, + ) + return + case 'message_template_quality_update': + await handleQualityUpdate( + change.value as TemplateQualityUpdateValue, + supabase, + ) + return + case 'message_template_components_update': + handleComponentsUpdate( + change.value as TemplateComponentsUpdateValue, + ) + return + } +} + +async function handleStatusUpdate( + value: TemplateStatusUpdateValue, + supabase: SupabaseClient, +): Promise { + const metaTemplateId = + value.message_template_id !== undefined + ? String(value.message_template_id) + : null + if (!metaTemplateId || !value.event) { + console.warn( + '[template-webhook] status update missing message_template_id or event:', + value, + ) + return + } + + const status = normalizeStatus(value.event) + + // Persist the rejection reason on REJECTED — that's the only event + // where Meta sends a human-readable explanation. Clear it on any + // other status flip so the UI doesn't show a stale REJECTED banner + // after Meta re-approves a resubmitted template. + const update: Record = { + status, + rejection_reason: + status === 'REJECTED' ? value.reason ?? 'Rejected by Meta' : null, + submission_error: null, + } + + const { data, error } = await supabase + .from('message_templates') + .update(update) + .eq('meta_template_id', metaTemplateId) + .select('id') + + if (error) { + console.error( + '[template-webhook] status update failed for meta_template_id', + metaTemplateId, + error.message, + ) + return + } + if (!data || data.length === 0) { + console.warn( + '[template-webhook] status update received for unknown template:', + metaTemplateId, + value.message_template_name, + ) + return + } + if (data.length > 1) { + console.warn( + `[template-webhook] status update matched ${data.length} rows for meta_template_id ${metaTemplateId} — investigate.`, + ) + } +} + +async function handleQualityUpdate( + value: TemplateQualityUpdateValue, + supabase: SupabaseClient, +): Promise { + const metaTemplateId = + value.message_template_id !== undefined + ? String(value.message_template_id) + : null + if (!metaTemplateId) { + console.warn( + '[template-webhook] quality update missing message_template_id:', + value, + ) + return + } + + const raw = value.new_quality_score + const score = + raw && ['GREEN', 'YELLOW', 'RED'].includes(raw.toUpperCase()) + ? (raw.toUpperCase() as 'GREEN' | 'YELLOW' | 'RED') + : null + + const { error } = await supabase + .from('message_templates') + .update({ quality_score: score }) + .eq('meta_template_id', metaTemplateId) + + if (error) { + console.error( + '[template-webhook] quality update failed for meta_template_id', + metaTemplateId, + error.message, + ) + } +} + +/** + * Meta auto-modified the template (typically a category reclassification + * — e.g. Marketing → Utility after content review). + * + * For v1 we just log and let the user pull updated components via the + * existing "Sync from Meta" button — persisting Meta's modified + * components without showing the user would silently change what they + * thought they submitted. A future PR could mark the row with a + * "Meta modified this template" banner. + */ +function handleComponentsUpdate(value: TemplateComponentsUpdateValue): void { + console.info( + '[template-webhook] components updated by Meta for template', + value.message_template_id, + value.message_template_name, + '— run "Sync from Meta" in Settings to pull the new components.', + ) +}