mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
feat(templates): webhook handler for real-time status / quality updates
Wires Meta's three template-lifecycle webhook events into the
existing /api/whatsapp/webhook route so APPROVED / REJECTED /
PAUSED transitions land locally without a manual Sync click.
Handled events
--------------
message_template_status_update
→ flip status to the raw Meta enum (re-using normalizeStatus
from PR 3 so PENDING_REVIEW collapses to PENDING etc.)
→ persist `reason` to rejection_reason on REJECTED so the
red banner in template-manager shows what to fix
→ clear rejection_reason + submission_error on any other
transition so a stale REJECTED message doesn't linger
after re-approval
message_template_quality_update
→ set quality_score (GREEN/YELLOW/RED). Unknown values land
as NULL so the chip just disappears.
message_template_components_update
→ info-log only for v1. Auto-persisting Meta's modified
components would silently change what the user thought
they submitted; instead we point them to "Sync from Meta"
in the log line. A follow-up PR could surface a banner.
Lookup by meta_template_id (indexed in migration 014) — globally
unique per WABA, so no user_id filter needed. Unknown rows log a
warning instead of failing; >1 matches log a warning too in case
of cross-tenant drift.
New module
----------
src/lib/whatsapp/template-webhook.ts isolates the dispatch + each
handler. The route handler just calls
`handleTemplateWebhookChange(change, supabaseAdmin())` when
`isTemplateWebhookField(change.field)` matches, and the existing
messaging branches are short-circuited so a template event doesn't
try to read message-shaped fields off the value.
12 new tests cover: status APPROVED happy path, REJECTED with
reason, REJECTED missing reason → generic fallback, PENDING_REVIEW
normalization, missing meta_template_id no-op, unknown row warning,
quality score update, invalid quality value → NULL, components
update no-op, unknown field defensive no-op, and the field detector.
Setup requirement
-----------------
Subscribing to these fields is a one-time manual step in Meta App
Dashboard → WhatsApp → Configuration → Webhooks (no API for Cloud
API apps). Documented in a top-of-file comment block in
template-webhook.ts. Until it's done, status updates still arrive
via the manual "Sync from Meta" button — that fallback stays.
Out of scope (final PR in sequence):
* sendTemplateMessage media + button param support (PR 6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
26d65303e5
commit
c7d780631b
@ -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
|
||||
|
||||
252
src/lib/whatsapp/template-webhook.test.ts
Normal file
252
src/lib/whatsapp/template-webhook.test.ts
Normal file
@ -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<string, unknown>;
|
||||
filter?: { column: string; value: unknown };
|
||||
}[] = [];
|
||||
|
||||
const stub = {
|
||||
from(table: string) {
|
||||
const entry: (typeof calls)[number] = { table };
|
||||
calls.push(entry);
|
||||
return {
|
||||
update(payload: Record<string, unknown>) {
|
||||
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<typeof makeSupabaseStub>['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);
|
||||
});
|
||||
});
|
||||
215
src/lib/whatsapp/template-webhook.ts
Normal file
215
src/lib/whatsapp/template-webhook.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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<string, unknown> = {
|
||||
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<void> {
|
||||
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.',
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user