mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Closes the last hole left by PR 3 — until now, sendTemplateMessage
only emitted a body component, so templates with image/video/
document headers or URL-with-{{1}} buttons failed at send time
with Meta's "missing component" 400. After this PR, every
APPROVED template that wacrm can submit is also fully sendable.
New helper
----------
src/lib/whatsapp/template-send-builder.ts builds the per-send
Meta components array from a MessageTemplate row + caller-supplied
SendTimeParams. It's a different beast from template-components.ts
(which builds the SUBMISSION shape) because the send-time payload
has a different schema: header type-specific parameter objects,
button components with sub_type + index, body_text without a 2D
example wrapper, etc.
Auto-fills as much as possible from the template:
* static IMAGE/VIDEO/DOCUMENT headers ride along using the row's
header_media_url (or header_handle when present — preferred)
* URL buttons without {{1}} need no per-send payload
* QUICK_REPLY / PHONE_NUMBER buttons need nothing
* COPY_CODE buttons fall back to the template's example so the
most common "static promo code" case Just Works without UI
Variable-bearing fields require per-send values:
* TEXT header with {{1}} → messageParams.headerText
* Body variables → messageParams.body[]
* URL button with {{1}} → messageParams.buttonParams[i]
* COPY_CODE override → messageParams.buttonParams[i]
19 unit tests assert the wire shape per scenario — header types,
URL button at correct index when QR buttons precede it, example
fallback, and the throw-with-specific-message paths.
sendTemplateMessage extension
-----------------------------
src/lib/whatsapp/meta-api.ts gains optional `template` +
`messageParams` args. When `template` is provided, the full
components array is built via the new helper. Legacy `params:
string[]` (body only) path is preserved as-is — old callers
behave identically. When both shapes are present, structured
messageParams.body wins.
Route + UI wiring
-----------------
POST /api/whatsapp/send and /api/whatsapp/broadcast now load the
template row by (user_id, name, language) and pass it through.
Broadcast loads once per request (not per recipient — avoids N+1).
Both accept `template_message_params` / per-recipient
`messageParams` from the client.
src/components/inbox/template-picker.tsx scans the selected
template for variable-bearing fields and renders an input per
need: header text variable, body variables (existing), URL
button suffixes. Per-button preview shows the final URL with
the substitution so the user sees what the recipient will see.
The callback shape changes to `(template, { body, headerText?,
buttonParams? })`; message-thread.tsx updated accordingly.
Out of scope
------------
Broadcast composer doesn't yet collect per-recipient header /
button params — the legacy body-only flow continues to work for
broadcasts. Templates with header / URL-button variables broadcast
fine if every recipient gets the same value via API call shape;
the UI for varying it per recipient lands in a follow-up.
This is the last PR of the Issue #147 overhaul sequence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
265 lines
7.7 KiB
TypeScript
265 lines
7.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { buildSendComponents } from './template-send-builder';
|
|
import type { MessageTemplate } from '@/types';
|
|
|
|
function row(overrides: Partial<MessageTemplate> = {}): MessageTemplate {
|
|
return {
|
|
id: 'row-1',
|
|
user_id: 'user-1',
|
|
name: 'order_confirmation',
|
|
category: 'Utility',
|
|
language: 'en_US',
|
|
body_text: 'Your order is on its way.',
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('buildSendComponents — body', () => {
|
|
it('returns [] for a fully-static template (no vars, no media header)', () => {
|
|
expect(buildSendComponents(row())).toEqual([]);
|
|
});
|
|
|
|
it('emits a body component when the template has variables', () => {
|
|
const components = buildSendComponents(
|
|
row({ body_text: 'Hi {{1}}, order {{2}} confirmed.' }),
|
|
{ body: ['John', 'ORD-42'] },
|
|
);
|
|
expect(components).toEqual([
|
|
{
|
|
type: 'body',
|
|
parameters: [
|
|
{ type: 'text', text: 'John' },
|
|
{ type: 'text', text: 'ORD-42' },
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('throws when body has variables but caller supplied too few values', () => {
|
|
expect(() =>
|
|
buildSendComponents(
|
|
row({ body_text: 'Hi {{1}} {{2}}' }),
|
|
{ body: ['just one'] },
|
|
),
|
|
).toThrow(/2 variable\(s\) but only 1/);
|
|
});
|
|
|
|
it('trims extra body values silently (legacy callers may overshoot)', () => {
|
|
const components = buildSendComponents(
|
|
row({ body_text: 'Hi {{1}}' }),
|
|
{ body: ['John', 'extra', 'extra2'] },
|
|
);
|
|
expect(components).toEqual([
|
|
{ type: 'body', parameters: [{ type: 'text', text: 'John' }] },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('buildSendComponents — header', () => {
|
|
it('skips static TEXT headers (template carries them)', () => {
|
|
expect(
|
|
buildSendComponents(
|
|
row({ header_type: 'text', header_content: 'Order Confirmation' }),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('emits a TEXT header component when {{1}} is present', () => {
|
|
const components = buildSendComponents(
|
|
row({ header_type: 'text', header_content: 'Hello {{1}}' }),
|
|
{ headerText: 'Sara' },
|
|
);
|
|
expect(components).toEqual([
|
|
{ type: 'header', parameters: [{ type: 'text', text: 'Sara' }] },
|
|
]);
|
|
});
|
|
|
|
it('throws when TEXT header has {{1}} but no value was supplied', () => {
|
|
expect(() =>
|
|
buildSendComponents(
|
|
row({ header_type: 'text', header_content: 'Hello {{1}}' }),
|
|
),
|
|
).toThrow(/Header text variable \{\{1\}\}/);
|
|
});
|
|
|
|
it('auto-includes IMAGE header from the stored sample URL', () => {
|
|
const components = buildSendComponents(
|
|
row({
|
|
header_type: 'image',
|
|
header_media_url: 'https://example.com/sample.jpg',
|
|
}),
|
|
);
|
|
expect(components).toEqual([
|
|
{
|
|
type: 'header',
|
|
parameters: [
|
|
{ type: 'image', image: { link: 'https://example.com/sample.jpg' } },
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('prefers caller override URL over template sample', () => {
|
|
const components = buildSendComponents(
|
|
row({
|
|
header_type: 'video',
|
|
header_media_url: 'https://example.com/default.mp4',
|
|
}),
|
|
{ headerMediaUrl: 'https://example.com/custom.mp4' },
|
|
);
|
|
expect(components[0]).toEqual({
|
|
type: 'header',
|
|
parameters: [
|
|
{ type: 'video', video: { link: 'https://example.com/custom.mp4' } },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('prefers media id over url when both are available', () => {
|
|
const components = buildSendComponents(
|
|
row({
|
|
header_type: 'document',
|
|
header_handle: '4::aBc',
|
|
header_media_url: 'https://x.com/doc.pdf',
|
|
}),
|
|
);
|
|
expect(components[0]).toEqual({
|
|
type: 'header',
|
|
parameters: [{ type: 'document', document: { id: '4::aBc' } }],
|
|
});
|
|
});
|
|
|
|
it('throws on media header with no link OR id available', () => {
|
|
expect(() =>
|
|
buildSendComponents(row({ header_type: 'image' })),
|
|
).toThrow(/requires a media link or id/);
|
|
});
|
|
});
|
|
|
|
describe('buildSendComponents — buttons', () => {
|
|
it('omits URL buttons without variables (template carries the URL)', () => {
|
|
const components = buildSendComponents(
|
|
row({
|
|
buttons: [
|
|
{ type: 'URL', text: 'Visit', url: 'https://example.com' },
|
|
],
|
|
}),
|
|
);
|
|
expect(components).toEqual([]);
|
|
});
|
|
|
|
it('emits a URL button component when the URL has {{1}}', () => {
|
|
const components = buildSendComponents(
|
|
row({
|
|
buttons: [
|
|
{ type: 'URL', text: 'Track', url: 'https://x.com/{{1}}' },
|
|
],
|
|
}),
|
|
{ buttonParams: { 0: 'ORD-42' } },
|
|
);
|
|
expect(components).toEqual([
|
|
{
|
|
type: 'button',
|
|
sub_type: 'url',
|
|
index: '0',
|
|
parameters: [{ type: 'text', text: 'ORD-42' }],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('throws when URL button has {{1}} but no buttonParam was provided', () => {
|
|
expect(() =>
|
|
buildSendComponents(
|
|
row({
|
|
buttons: [
|
|
{ type: 'URL', text: 'Track', url: 'https://x.com/{{1}}' },
|
|
],
|
|
}),
|
|
),
|
|
).toThrow(/URL button #1 uses \{\{1\}\}/);
|
|
});
|
|
|
|
it('uses the correct index when QR buttons precede the URL button', () => {
|
|
// sub_type:url at index "2" because two QUICK_REPLY buttons came first.
|
|
const components = buildSendComponents(
|
|
row({
|
|
buttons: [
|
|
{ type: 'QUICK_REPLY', text: 'Yes' },
|
|
{ type: 'QUICK_REPLY', text: 'No' },
|
|
{ type: 'URL', text: 'Open', url: 'https://x.com/{{1}}' },
|
|
],
|
|
}),
|
|
{ buttonParams: { 2: 'ORD-42' } },
|
|
);
|
|
const urlBtn = components.find((c) => c.type === 'button');
|
|
expect(urlBtn).toEqual({
|
|
type: 'button',
|
|
sub_type: 'url',
|
|
index: '2',
|
|
parameters: [{ type: 'text', text: 'ORD-42' }],
|
|
});
|
|
});
|
|
|
|
it('falls back to the template example for COPY_CODE buttons', () => {
|
|
const components = buildSendComponents(
|
|
row({
|
|
buttons: [
|
|
{ type: 'COPY_CODE', text: 'Copy', example: 'SUMMER20' },
|
|
],
|
|
}),
|
|
);
|
|
expect(components).toEqual([
|
|
{
|
|
type: 'button',
|
|
sub_type: 'copy_code',
|
|
index: '0',
|
|
parameters: [{ type: 'coupon_code', coupon_code: 'SUMMER20' }],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('overrides COPY_CODE code when caller supplies one', () => {
|
|
const components = buildSendComponents(
|
|
row({
|
|
buttons: [{ type: 'COPY_CODE', text: 'Copy', example: 'STATIC' }],
|
|
}),
|
|
{ buttonParams: { 0: 'PERSONAL_CODE' } },
|
|
);
|
|
expect((components[0] as { parameters: { coupon_code: string }[] })
|
|
.parameters[0].coupon_code).toBe('PERSONAL_CODE');
|
|
});
|
|
|
|
it('skips PHONE_NUMBER buttons entirely (no send-time params allowed)', () => {
|
|
const components = buildSendComponents(
|
|
row({
|
|
buttons: [
|
|
{ type: 'PHONE_NUMBER', text: 'Call', phone_number: '+15551234567' },
|
|
],
|
|
}),
|
|
);
|
|
expect(components).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('buildSendComponents — end-to-end mix', () => {
|
|
it('orders components header → body → buttons and includes all', () => {
|
|
const components = buildSendComponents(
|
|
row({
|
|
header_type: 'image',
|
|
header_media_url: 'https://x.com/img.jpg',
|
|
body_text: 'Hi {{1}}',
|
|
buttons: [
|
|
{ type: 'QUICK_REPLY', text: 'Yes' },
|
|
{ type: 'URL', text: 'Track', url: 'https://x.com/{{1}}' },
|
|
],
|
|
}),
|
|
{ body: ['John'], buttonParams: { 1: 'abc' } },
|
|
);
|
|
expect(components.map((c) => c.type)).toEqual(['header', 'body', 'button']);
|
|
// QUICK_REPLY at index 0 doesn't need send-time params, so only the
|
|
// URL button at index 1 emits a component.
|
|
expect((components[2] as { index: string }).index).toBe('1');
|
|
});
|
|
});
|