Merge pull request #153 from ArnasDon/feat/template-send-media-buttons

feat(templates): media headers + URL/copy-code button params on send
This commit is contained in:
Arnas Donauskas 2026-05-29 16:41:30 +02:00 committed by GitHub
commit e60fea701e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 729 additions and 41 deletions

View File

@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api' import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption' import { decrypt } from '@/lib/whatsapp/encryption'
import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder'
import { import {
sanitizePhoneForMeta, sanitizePhoneForMeta,
isValidE164, isValidE164,
@ -45,7 +46,15 @@ interface BroadcastResult {
*/ */
interface NewRecipient { interface NewRecipient {
phone: string phone: string
/** Body variable values, one per {{N}}. Legacy field. */
params?: string[] params?: string[]
/**
* Structured per-send values (header text variable, media URL
* override, URL/COPY_CODE button values). When set, takes
* precedence over `params` for the body too — see
* sendTemplateMessage for the merge rules.
*/
messageParams?: SendTimeParams
} }
export async function POST(request: Request) { export async function POST(request: Request) {
@ -125,6 +134,17 @@ export async function POST(request: Request) {
const accessToken = decrypt(config.access_token) const accessToken = decrypt(config.access_token)
// Load the template row once so sendTemplateMessage can build
// header + button components on each iteration. Loading inside
// the loop would N+1 against Supabase for every recipient.
const { data: templateRow } = await supabase
.from('message_templates')
.select('*')
.eq('user_id', user.id)
.eq('name', template_name)
.eq('language', template_language || 'en_US')
.maybeSingle()
const results: BroadcastResult[] = [] const results: BroadcastResult[] = []
let sentCount = 0 let sentCount = 0
let failedCount = 0 let failedCount = 0
@ -156,6 +176,8 @@ export async function POST(request: Request) {
to: variant, to: variant,
templateName: template_name, templateName: template_name,
language: template_language || 'en_US', language: template_language || 'en_US',
template: templateRow ?? undefined,
messageParams: recipient.messageParams,
params: recipient.params ?? [], params: recipient.params ?? [],
}) })
sentMessageId = result.messageId sentMessageId = result.messageId

View File

@ -14,6 +14,7 @@ import {
rateLimitResponse, rateLimitResponse,
RATE_LIMITS, RATE_LIMITS,
} from '@/lib/rate-limit' } from '@/lib/rate-limit'
import type { MessageTemplate } from '@/types'
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@ -45,7 +46,9 @@ export async function POST(request: Request) {
content_text, content_text,
media_url, media_url,
template_name, template_name,
template_language,
template_params, template_params,
template_message_params,
reply_to_message_id, reply_to_message_id,
} = body } = body
@ -177,6 +180,24 @@ export async function POST(request: Request) {
let waMessageId = '' let waMessageId = ''
let workingPhone = sanitizedPhone let workingPhone = sanitizedPhone
// For template sends, load the row so sendTemplateMessage can
// build header + button components from the template definition.
// Match on (user_id, name, language) — same triple the unique
// index enforces — so multi-language templates work correctly.
// Missing template falls through with `templateRow = null` and
// the legacy body-only path runs.
let templateRow: MessageTemplate | null = null
if (message_type === 'template' && template_name) {
const { data } = await supabase
.from('message_templates')
.select('*')
.eq('user_id', user.id)
.eq('name', template_name)
.eq('language', template_language || 'en_US')
.maybeSingle()
templateRow = (data ?? null) as MessageTemplate | null
}
const attempt = async (phone: string): Promise<string> => { const attempt = async (phone: string): Promise<string> => {
if (message_type === 'template') { if (message_type === 'template') {
const result = await sendTemplateMessage({ const result = await sendTemplateMessage({
@ -184,6 +205,11 @@ export async function POST(request: Request) {
accessToken, accessToken,
to: phone, to: phone,
templateName: template_name, templateName: template_name,
language: template_language || 'en_US',
template: templateRow ?? undefined,
messageParams: template_message_params ?? undefined,
// Legacy body-only fallback — only consulted when
// messageParams.body isn't set.
params: template_params || [], params: template_params || [],
contextMessageId, contextMessageId,
}) })

View File

@ -493,10 +493,17 @@ export function MessageThread({
}, []); }, []);
const handleSendTemplate = useCallback( const handleSendTemplate = useCallback(
async (template: MessageTemplate, params: string[]) => { async (
template: MessageTemplate,
values: {
body: string[];
headerText?: string;
buttonParams?: Record<number, string>;
},
) => {
if (!conversation) return; if (!conversation) return;
const renderedBody = renderTemplateBody(template.body_text, params); const renderedBody = renderTemplateBody(template.body_text, values.body);
const tempId = `temp-${Date.now()}`; const tempId = `temp-${Date.now()}`;
const optimisticMsg: Message = { const optimisticMsg: Message = {
@ -519,7 +526,17 @@ export function MessageThread({
conversation_id: conversation.id, conversation_id: conversation.id,
message_type: "template", message_type: "template",
template_name: template.name, template_name: template.name,
template_params: params, template_language: template.language,
// Structured params drive the new send-builder path
// (header media + URL button substitution). Body values
// are mirrored under both shapes so the route can fall
// back if the template row isn't found locally.
template_message_params: {
body: values.body,
headerText: values.headerText,
buttonParams: values.buttonParams,
},
template_params: values.body,
content_text: renderedBody, content_text: renderedBody,
}), }),
}); });

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import type { MessageTemplate } from "@/types"; import type { MessageTemplate } from "@/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -21,23 +21,18 @@ import {
LayoutTemplate, LayoutTemplate,
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { extractVariableIndices } from "@/lib/whatsapp/template-validators";
export interface TemplateSendValues {
body: string[];
headerText?: string;
buttonParams?: Record<number, string>;
}
interface TemplatePickerProps { interface TemplatePickerProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSelect: (template: MessageTemplate, params: string[]) => void; onSelect: (template: MessageTemplate, values: TemplateSendValues) => void;
}
// Meta numbers template placeholders from 1 ({{1}}, {{2}}, …) and the
// indices passed to the Graph API must be contiguous starting at 1.
// We sort + dedupe here so a body using only {{2}} still drives a single
// input slot, and so render-order matches send-order.
function extractVariables(body: string): number[] {
const ids = new Set<number>();
for (const m of body.matchAll(/\{\{(\d+)\}\}/g)) {
ids.add(Number(m[1]));
}
return Array.from(ids).sort((a, b) => a - b);
} }
function renderBodyPreview(body: string, params: string[]): string { function renderBodyPreview(body: string, params: string[]): string {
@ -48,6 +43,36 @@ function renderBodyPreview(body: string, params: string[]): string {
}); });
} }
interface UrlButtonSlot {
index: number;
text: string;
url: string;
}
/**
* Templates may need values for: body variables, a text-header
* variable, and per-URL-button suffixes. Collect them all so the
* send-message path doesn't 400 on missing parameters.
*/
function collectVariableSlots(template: MessageTemplate): {
bodyVars: number[];
headerVarCount: number;
urlButtonSlots: UrlButtonSlot[];
} {
const bodyVars = extractVariableIndices(template.body_text);
const headerVarCount =
template.header_type === "text" && template.header_content
? extractVariableIndices(template.header_content).length
: 0;
const urlButtonSlots: UrlButtonSlot[] = [];
(template.buttons ?? []).forEach((b, i) => {
if (b.type === "URL" && extractVariableIndices(b.url).length > 0) {
urlButtonSlots.push({ index: i, text: b.text, url: b.url });
}
});
return { bodyVars, headerVarCount, urlButtonSlots };
}
export function TemplatePicker({ export function TemplatePicker({
open, open,
onOpenChange, onOpenChange,
@ -57,6 +82,8 @@ export function TemplatePicker({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<MessageTemplate | null>(null); const [selected, setSelected] = useState<MessageTemplate | null>(null);
const [params, setParams] = useState<string[]>([]); const [params, setParams] = useState<string[]>([]);
const [headerText, setHeaderText] = useState<string>("");
const [buttonParams, setButtonParams] = useState<Record<number, string>>({});
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -77,9 +104,6 @@ export function TemplatePicker({
return; return;
} }
// Only Approved templates are sendable through Meta — anything else
// would 400 on the send route. Hide them rather than letting the
// user pick a template that will be rejected.
const { data, error } = await supabase const { data, error } = await supabase
.from("message_templates") .from("message_templates")
.select("*") .select("*")
@ -102,35 +126,60 @@ export function TemplatePicker({
}; };
}, [open]); }, [open]);
function handleOpenChange(next: boolean) { function resetSelection() {
if (!next) {
setSelected(null); setSelected(null);
setParams([]); setParams([]);
setHeaderText("");
setButtonParams({});
} }
function handleOpenChange(next: boolean) {
if (!next) resetSelection();
onOpenChange(next); onOpenChange(next);
} }
function pickTemplate(template: MessageTemplate) { function pickTemplate(template: MessageTemplate) {
const vars = extractVariables(template.body_text); const slots = collectVariableSlots(template);
if (vars.length === 0) { const noInputsNeeded =
onSelect(template, []); slots.bodyVars.length === 0 &&
slots.headerVarCount === 0 &&
slots.urlButtonSlots.length === 0;
if (noInputsNeeded) {
onSelect(template, { body: [] });
handleOpenChange(false); handleOpenChange(false);
return; return;
} }
setSelected(template); setSelected(template);
setParams(new Array(vars.length).fill("")); setParams(new Array(slots.bodyVars.length).fill(""));
setHeaderText("");
setButtonParams({});
} }
function confirm() { function confirm() {
if (!selected) return; if (!selected) return;
onSelect(selected, params); const values: TemplateSendValues = { body: params };
if (headerText.trim()) values.headerText = headerText.trim();
if (Object.keys(buttonParams).length > 0) {
values.buttonParams = Object.fromEntries(
Object.entries(buttonParams).map(([k, v]) => [Number(k), v.trim()]),
);
}
onSelect(selected, values);
handleOpenChange(false); handleOpenChange(false);
} }
const variables = selected ? extractVariables(selected.body_text) : []; const slots = useMemo(
() => (selected ? collectVariableSlots(selected) : null),
[selected],
);
const canConfirm = const canConfirm =
!!selected && !!selected &&
variables.every((_, i) => (params[i] ?? "").trim().length > 0); !!slots &&
slots.bodyVars.every((_, i) => (params[i] ?? "").trim().length > 0) &&
(slots.headerVarCount === 0 || headerText.trim().length > 0) &&
slots.urlButtonSlots.every(
(s) => (buttonParams[s.index] ?? "").trim().length > 0,
);
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
@ -207,9 +256,22 @@ export function TemplatePicker({
</p> </p>
)} )}
</div> </div>
{variables.map((v, i) => ( {slots && slots.headerVarCount > 0 && (
<div className="space-y-1">
<Label className="text-xs text-slate-300">
{`Header {{1}}`}
</Label>
<Input
value={headerText}
onChange={(e) => setHeaderText(e.target.value)}
placeholder="Value for the header variable"
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
/>
</div>
)}
{slots?.bodyVars.map((v, i) => (
<div key={v} className="space-y-1"> <div key={v} className="space-y-1">
<Label className="text-xs text-slate-300">{`Variable {{${v}}}`}</Label> <Label className="text-xs text-slate-300">{`Body {{${v}}}`}</Label>
<Input <Input
value={params[i] ?? ""} value={params[i] ?? ""}
onChange={(e) => { onChange={(e) => {
@ -222,6 +284,27 @@ export function TemplatePicker({
/> />
</div> </div>
))} ))}
{slots?.urlButtonSlots.map((slot) => (
<div key={slot.index} className="space-y-1">
<Label className="text-xs text-slate-300">
{`URL button "${slot.text}" — value for `}{`{{1}}`}
</Label>
<Input
value={buttonParams[slot.index] ?? ""}
onChange={(e) =>
setButtonParams((prev) => ({
...prev,
[slot.index]: e.target.value,
}))
}
placeholder="URL suffix value"
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
/>
<p className="text-[10px] text-slate-500 break-all">
Final URL: {slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}")}
</p>
</div>
))}
</div> </div>
)} )}
@ -230,10 +313,7 @@ export function TemplatePicker({
<> <>
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={resetSelection}
setSelected(null);
setParams([]);
}}
className="border-slate-700 text-slate-300 hover:bg-slate-800" className="border-slate-700 text-slate-300 hover:bg-slate-800"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />

View File

@ -113,13 +113,39 @@ export async function sendTextMessage(
return { messageId: data.messages[0].id } return { messageId: data.messages[0].id }
} }
import type { MessageTemplate } from '@/types'
import {
buildSendComponents,
type SendTimeParams,
} from './template-send-builder'
export interface SendTemplateMessageArgs { export interface SendTemplateMessageArgs {
phoneNumberId: string phoneNumberId: string
accessToken: string accessToken: string
to: string to: string
templateName: string templateName: string
language?: string language?: string
/**
* Legacy body-only params. Kept for backward compat with callers
* that haven't migrated to the structured `template` + `messageParams`
* pair below. New callers should pass `template` so media headers
* and URL buttons land on the send.
*/
params?: string[] params?: string[]
/**
* The template row from message_templates. When provided, the helper
* builds the full components array (header + body + buttons) via
* buildSendComponents — that's the only way image/video/document
* headers and URL-with-variable buttons actually reach the recipient.
*/
template?: MessageTemplate
/**
* Structured per-send values. Body variables go in `body`; header
* text variables in `headerText`; media overrides in
* `headerMediaUrl` / `headerMediaId`; URL/COPY_CODE button values
* in `buttonParams` keyed by index.
*/
messageParams?: SendTimeParams
/** Meta's message_id of the message being replied to. */ /** Meta's message_id of the message being replied to. */
contextMessageId?: string contextMessageId?: string
} }
@ -127,6 +153,13 @@ export interface SendTemplateMessageArgs {
/** /**
* Send a pre-approved WhatsApp message template. Required outside * Send a pre-approved WhatsApp message template. Required outside
* the 24-hour window and for any first-touch messaging. * the 24-hour window and for any first-touch messaging.
*
* Caller paths:
* - Legacy: pass `params: string[]` (body only). Same behaviour as
* before this helper learned about media + buttons.
* - Structured: pass `template` (and optionally `messageParams`).
* The full components array is built from the row so media
* headers + URL buttons land correctly.
*/ */
export async function sendTemplateMessage( export async function sendTemplateMessage(
args: SendTemplateMessageArgs args: SendTemplateMessageArgs
@ -138,17 +171,33 @@ export async function sendTemplateMessage(
templateName, templateName,
language = 'en_US', language = 'en_US',
params, params,
template,
messageParams,
contextMessageId, contextMessageId,
} = args } = args
const url = `${META_API_BASE}/${phoneNumberId}/messages` const url = `${META_API_BASE}/${phoneNumberId}/messages`
const template: Record<string, unknown> = { const templatePayload: Record<string, unknown> = {
name: templateName, name: templateName,
language: { code: language }, language: { code: language },
} }
if (params && params.length > 0) { if (template) {
template.components = [ const components = buildSendComponents(template, {
// Legacy callers pass body values in `params`; fold them into
// `messageParams.body` so the new path covers them too.
body: messageParams?.body ?? params,
headerText: messageParams?.headerText,
headerMediaUrl: messageParams?.headerMediaUrl,
headerMediaId: messageParams?.headerMediaId,
buttonParams: messageParams?.buttonParams,
})
if (components.length > 0) {
templatePayload.components = components
}
} else if (params && params.length > 0) {
// Legacy body-only path — no template row available.
templatePayload.components = [
{ {
type: 'body', type: 'body',
parameters: params.map((p) => ({ type: 'text', text: String(p) })), parameters: params.map((p) => ({ type: 'text', text: String(p) })),
@ -161,7 +210,7 @@ export async function sendTemplateMessage(
recipient_type: 'individual', recipient_type: 'individual',
to, to,
type: 'template', type: 'template',
template, template: templatePayload,
} }
if (contextMessageId) { if (contextMessageId) {
body.context = { message_id: contextMessageId } body.context = { message_id: contextMessageId }

View File

@ -0,0 +1,264 @@
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');
});
});

View File

@ -0,0 +1,230 @@
/**
* Build the Meta `components` array used by POST /{phone_number_id}/messages
* when sending an APPROVED template.
*
* Distinct from `template-components.ts` — that module builds the
* `components` for TEMPLATE CREATION (where you describe headers,
* footers, buttons, examples). This module builds the per-send
* `components` (where you fill in variable values and supply the
* actual media link or button URL suffix for THIS specific delivery).
*
* Auto-fills as much as possible from the template row so callers
* only need to supply values for the variable-bearing fields:
*
* - Static IMAGE/VIDEO/DOCUMENT headers ride along automatically
* using the template's `header_media_url` (or `header_handle`).
* Meta requires the media component on every send even though
* the URL hasn't changed since approval.
* - TEXT headers with `{{1}}` need `headerText` from the caller.
* - Body variables come in as `body: string[]`, indexed by {{N}}.
* - URL buttons with `{{1}}` need `buttonUrlParams[i]` keyed by
* button index. URL buttons without variables, plus QUICK_REPLY
* and PHONE_NUMBER buttons, don't need send-time parameters.
* - COPY_CODE buttons need the actual code to display. We fall
* back to the template's `example` value if the caller doesn't
* override — that matches the most common use case (a static
* promo code) without forcing UI work.
*
* Validation throws here (not at the Meta API boundary) so a missing
* sample surfaces as "Header text variable {{1}} requires a value",
* not a 400 from Meta that doesn't say which field broke.
*/
import type { MessageTemplate, TemplateButton } from '@/types';
import { extractVariableIndices } from './template-validators';
export interface SendTimeParams {
/** Values for body {{1}}, {{2}}, … indexed by variable position. */
body?: string[];
/** Value for TEXT-header {{1}}, when the header has a variable. */
headerText?: string;
/** Override the template's static media URL for this send. */
headerMediaUrl?: string;
/** Alternative: send the media by Meta media id (from prior upload). */
headerMediaId?: string;
/**
* Per-button overrides keyed by the button's index in the
* template's `buttons` array. Used for URL buttons with a {{1}}
* suffix and for COPY_CODE buttons whose example you want to
* override at send time.
*/
buttonParams?: Record<number, string>;
}
export type MetaSendComponent =
| { type: 'header'; parameters: MetaSendParameter[] }
| { type: 'body'; parameters: MetaSendParameter[] }
| {
type: 'button';
sub_type: 'url' | 'quick_reply' | 'copy_code';
index: string;
parameters: MetaSendParameter[];
};
type MetaSendParameter =
| { type: 'text'; text: string }
| { type: 'image'; image: { link?: string; id?: string } }
| { type: 'video'; video: { link?: string; id?: string } }
| { type: 'document'; document: { link?: string; id?: string } }
| { type: 'coupon_code'; coupon_code: string }
| { type: 'payload'; payload: string };
function buildHeaderComponent(
template: MessageTemplate,
params: SendTimeParams,
): MetaSendComponent | null {
const headerType = template.header_type;
if (!headerType) return null;
if (headerType === 'text') {
// TEXT header with {{1}} → need a value. Static text headers
// (no variables) just ride along inside the template itself; no
// header component required on send.
const varCount = extractVariableIndices(template.header_content ?? '').length;
if (varCount === 0) return null;
const value = params.headerText;
if (!value || !value.trim()) {
throw new Error(
'Header text variable {{1}} requires a value — pass headerText.',
);
}
return {
type: 'header',
parameters: [{ type: 'text', text: value }],
};
}
// image / video / document — Meta requires the media component on
// every send. Prefer the caller's explicit override; fall back to
// the template's stored sample.
const link = params.headerMediaUrl ?? template.header_media_url;
const id = params.headerMediaId ?? template.header_handle;
if (!link && !id) {
throw new Error(
`${headerType} header requires a media link or id at send time — set header_media_url on the template or pass headerMediaUrl/headerMediaId.`,
);
}
const mediaPayload: { link?: string; id?: string } = id ? { id } : { link };
return {
type: 'header',
parameters: [
headerType === 'image'
? { type: 'image', image: mediaPayload }
: headerType === 'video'
? { type: 'video', video: mediaPayload }
: { type: 'document', document: mediaPayload },
],
};
}
function buildBodyComponent(
template: MessageTemplate,
params: SendTimeParams,
): MetaSendComponent | null {
const varCount = extractVariableIndices(template.body_text).length;
const body = params.body ?? [];
if (varCount === 0 && body.length === 0) return null;
if (body.length < varCount) {
throw new Error(
`Body has ${varCount} variable(s) but only ${body.length} value(s) were supplied.`,
);
}
// Trim to the variable count — extra values are dropped silently so
// a legacy caller that passes too many doesn't error out.
const values = body.slice(0, varCount);
return {
type: 'body',
parameters: values.map((text) => ({ type: 'text', text: String(text) })),
};
}
function buttonNeedsSendParam(
button: TemplateButton,
override: string | undefined,
): boolean {
switch (button.type) {
case 'URL':
return extractVariableIndices(button.url).length > 0;
case 'COPY_CODE':
// We always emit a button param for COPY_CODE so the customer
// gets a real code (either the caller's override or the
// template's example as a default).
return true;
case 'QUICK_REPLY':
case 'PHONE_NUMBER':
return override !== undefined;
}
}
function buildButtonComponent(
button: TemplateButton,
index: number,
override: string | undefined,
): MetaSendComponent | null {
if (!buttonNeedsSendParam(button, override)) return null;
switch (button.type) {
case 'URL': {
// Each URL button is its own component with sub_type=url and
// the button's index in the template's buttons array.
if (!override || !override.trim()) {
throw new Error(
`URL button #${index + 1} uses {{1}} — requires a buttonParams[${index}] value.`,
);
}
return {
type: 'button',
sub_type: 'url',
index: String(index),
parameters: [{ type: 'text', text: override }],
};
}
case 'COPY_CODE': {
const code = override?.trim() || button.example;
return {
type: 'button',
sub_type: 'copy_code',
index: String(index),
parameters: [{ type: 'coupon_code', coupon_code: code }],
};
}
case 'QUICK_REPLY': {
// Only included when the caller explicitly overrides the
// payload (rare — usually QR buttons use their default text).
return {
type: 'button',
sub_type: 'quick_reply',
index: String(index),
parameters: [{ type: 'payload', payload: override! }],
};
}
case 'PHONE_NUMBER':
// PHONE_NUMBER buttons never accept send-time params per Meta —
// return null even if an override snuck through.
return null;
}
}
/**
* Build the full `components` array for the send-message payload.
* Returns an empty array when the template is fully static (no
* variables, no media header), which is a valid Meta request.
*/
export function buildSendComponents(
template: MessageTemplate,
params: SendTimeParams = {},
): MetaSendComponent[] {
const out: MetaSendComponent[] = [];
const header = buildHeaderComponent(template, params);
if (header) out.push(header);
const body = buildBodyComponent(template, params);
if (body) out.push(body);
if (template.buttons?.length) {
template.buttons.forEach((btn, i) => {
const override = params.buttonParams?.[i];
const component = buildButtonComponent(btn, i, override);
if (component) out.push(component);
});
}
return out;
}