feat(templates): media headers + URL/copy-code button params on send

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>
This commit is contained in:
Arnas Donauskas 2026-05-29 16:38:15 +02:00
parent a8d430f3e9
commit 11475840e1
No known key found for this signature in database
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 { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption'
import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder'
import {
sanitizePhoneForMeta,
isValidE164,
@ -45,7 +46,15 @@ interface BroadcastResult {
*/
interface NewRecipient {
phone: string
/** Body variable values, one per {{N}}. Legacy field. */
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) {
@ -125,6 +134,17 @@ export async function POST(request: Request) {
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[] = []
let sentCount = 0
let failedCount = 0
@ -156,6 +176,8 @@ export async function POST(request: Request) {
to: variant,
templateName: template_name,
language: template_language || 'en_US',
template: templateRow ?? undefined,
messageParams: recipient.messageParams,
params: recipient.params ?? [],
})
sentMessageId = result.messageId

View File

@ -14,6 +14,7 @@ import {
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit'
import type { MessageTemplate } from '@/types'
export async function POST(request: Request) {
try {
@ -45,7 +46,9 @@ export async function POST(request: Request) {
content_text,
media_url,
template_name,
template_language,
template_params,
template_message_params,
reply_to_message_id,
} = body
@ -177,6 +180,24 @@ export async function POST(request: Request) {
let waMessageId = ''
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> => {
if (message_type === 'template') {
const result = await sendTemplateMessage({
@ -184,6 +205,11 @@ export async function POST(request: Request) {
accessToken,
to: phone,
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 || [],
contextMessageId,
})

View File

@ -493,10 +493,17 @@ export function MessageThread({
}, []);
const handleSendTemplate = useCallback(
async (template: MessageTemplate, params: string[]) => {
async (
template: MessageTemplate,
values: {
body: string[];
headerText?: string;
buttonParams?: Record<number, string>;
},
) => {
if (!conversation) return;
const renderedBody = renderTemplateBody(template.body_text, params);
const renderedBody = renderTemplateBody(template.body_text, values.body);
const tempId = `temp-${Date.now()}`;
const optimisticMsg: Message = {
@ -519,7 +526,17 @@ export function MessageThread({
conversation_id: conversation.id,
message_type: "template",
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,
}),
});

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import type { MessageTemplate } from "@/types";
import { Button } from "@/components/ui/button";
@ -21,23 +21,18 @@ import {
LayoutTemplate,
Loader2,
} from "lucide-react";
import { extractVariableIndices } from "@/lib/whatsapp/template-validators";
export interface TemplateSendValues {
body: string[];
headerText?: string;
buttonParams?: Record<number, string>;
}
interface TemplatePickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (template: MessageTemplate, params: string[]) => 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);
onSelect: (template: MessageTemplate, values: TemplateSendValues) => void;
}
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({
open,
onOpenChange,
@ -57,6 +82,8 @@ export function TemplatePicker({
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<MessageTemplate | null>(null);
const [params, setParams] = useState<string[]>([]);
const [headerText, setHeaderText] = useState<string>("");
const [buttonParams, setButtonParams] = useState<Record<number, string>>({});
useEffect(() => {
if (!open) return;
@ -77,9 +104,6 @@ export function TemplatePicker({
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
.from("message_templates")
.select("*")
@ -102,35 +126,60 @@ export function TemplatePicker({
};
}, [open]);
function handleOpenChange(next: boolean) {
if (!next) {
function resetSelection() {
setSelected(null);
setParams([]);
setHeaderText("");
setButtonParams({});
}
function handleOpenChange(next: boolean) {
if (!next) resetSelection();
onOpenChange(next);
}
function pickTemplate(template: MessageTemplate) {
const vars = extractVariables(template.body_text);
if (vars.length === 0) {
onSelect(template, []);
const slots = collectVariableSlots(template);
const noInputsNeeded =
slots.bodyVars.length === 0 &&
slots.headerVarCount === 0 &&
slots.urlButtonSlots.length === 0;
if (noInputsNeeded) {
onSelect(template, { body: [] });
handleOpenChange(false);
return;
}
setSelected(template);
setParams(new Array(vars.length).fill(""));
setParams(new Array(slots.bodyVars.length).fill(""));
setHeaderText("");
setButtonParams({});
}
function confirm() {
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);
}
const variables = selected ? extractVariables(selected.body_text) : [];
const slots = useMemo(
() => (selected ? collectVariableSlots(selected) : null),
[selected],
);
const canConfirm =
!!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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
@ -207,9 +256,22 @@ export function TemplatePicker({
</p>
)}
</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">
<Label className="text-xs text-slate-300">{`Variable {{${v}}}`}</Label>
<Label className="text-xs text-slate-300">{`Body {{${v}}}`}</Label>
<Input
value={params[i] ?? ""}
onChange={(e) => {
@ -222,6 +284,27 @@ export function TemplatePicker({
/>
</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>
)}
@ -230,10 +313,7 @@ export function TemplatePicker({
<>
<Button
variant="outline"
onClick={() => {
setSelected(null);
setParams([]);
}}
onClick={resetSelection}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
<ArrowLeft className="h-4 w-4" />

View File

@ -113,13 +113,39 @@ export async function sendTextMessage(
return { messageId: data.messages[0].id }
}
import type { MessageTemplate } from '@/types'
import {
buildSendComponents,
type SendTimeParams,
} from './template-send-builder'
export interface SendTemplateMessageArgs {
phoneNumberId: string
accessToken: string
to: string
templateName: 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[]
/**
* 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. */
contextMessageId?: string
}
@ -127,6 +153,13 @@ export interface SendTemplateMessageArgs {
/**
* Send a pre-approved WhatsApp message template. Required outside
* 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(
args: SendTemplateMessageArgs
@ -138,17 +171,33 @@ export async function sendTemplateMessage(
templateName,
language = 'en_US',
params,
template,
messageParams,
contextMessageId,
} = args
const url = `${META_API_BASE}/${phoneNumberId}/messages`
const template: Record<string, unknown> = {
const templatePayload: Record<string, unknown> = {
name: templateName,
language: { code: language },
}
if (params && params.length > 0) {
template.components = [
if (template) {
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',
parameters: params.map((p) => ({ type: 'text', text: String(p) })),
@ -161,7 +210,7 @@ export async function sendTemplateMessage(
recipient_type: 'individual',
to,
type: 'template',
template,
template: templatePayload,
}
if (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;
}