feat(templates): submit-to-Meta endpoint + component-based form builder

Closes the second half of #147. Users can now create a template in
wacrm and submit it to Meta for approval without leaving the app —
no more dummy local rows that fail with Meta error #132001 on send.

API surface
-----------

POST /api/whatsapp/templates/submit
  Auth → fetch whatsapp_config → validate payload → build Meta
  components → POST to /{waba_id}/message_templates → persist row
  with meta_template_id + status + sample_values +
  last_submitted_at. Per-field validators throw with specific
  messages before the network call, so misconfigured templates
  fail with "button #3 missing url" instead of a 400 from Meta.
  429s are detected and surfaced as "rate limit hit (100/hr)".

WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and
issues a synthetic `dry-run-<uuid>` meta_template_id so CI and
local dev can exercise the full form without a real WABA.

New modules
-----------

src/lib/whatsapp/template-validators.ts
  Pure functions encoding Meta's caps: 1024-char body, 60-char
  footer/header, 25-char button text, 10 buttons max, 2 URL / 1
  PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL
  example required when {{1}} is present, COPY_CODE example
  required, header media URL must be http(s), variables must be
  contiguous starting at {{1}}. 41 unit tests cover each rule.

src/lib/whatsapp/template-components.ts
  Translates our local row shape into Meta's components array
  (HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D
  array per spec, header_handle preferred over header_url when
  both are present). Snapshot tests assert the wire shape.

src/lib/whatsapp/template-status-normalize.ts
  Single source of truth for upstream→local status mapping.
  Replaces the duplicated normalizer in sync/route.ts; the
  upcoming webhook handler will share it too.

src/lib/whatsapp/meta-api.ts
  New submitMessageTemplate() helper following the existing
  named-args + throwMetaError pattern.

Form rewrite
------------

src/components/settings/template-manager.tsx replaces the flat
form with a component-based builder:

  * Header: format select (None / Text / Image / Video / Document)
    + conditional inputs — text + variable-aware sample row for
    TEXT, URL paste for media (file upload slot ready for the
    Resumable Upload follow-up)
  * Body: textarea with live {{N}} extraction → sample-value rows
    auto-appear and shrink as variables are added/removed
  * Footer: 60-char capped input
  * Buttons editor: add up to 10, per-row type picker
    (Quick Reply / URL / Phone / Copy Code), conditional URL +
    example + phone + code fields, X to remove
  * Card display now surfaces rejection_reason / submission_error
    (red banner) and quality_score (GREEN/YELLOW/RED chip)
  * AUTHENTICATION shows a "create in Meta Manager + Sync" banner
    and disables Submit — that category has a different shape
    (fixed body, mandatory OTP button) and lands in a follow-up

Side effects
------------

  * Broadcast Step 1 picker (step1-choose-template.tsx) now
    filters to status='APPROVED' to match the inbox picker —
    selecting a non-approved template would 400 on send.
  * .env.local.example documents META_APP_ID (Resumable Upload
    follow-up) and WHATSAPP_TEMPLATES_DRY_RUN.

Out of scope (subsequent PRs):
  * Edit / resubmit / delete endpoints (PR 4)
  * Webhook handler for real-time status (PR 5)
  * sendTemplateMessage media + button param support (PR 6)
  * Resumable Upload + AUTHENTICATION builder (follow-up)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-29 15:34:58 +02:00
parent 318085ee9e
commit 3113738c7e
No known key found for this signature in database
12 changed files with 1721 additions and 170 deletions

View File

@ -42,3 +42,15 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com
# openssl rand -hex 32 # openssl rand -hex 32
# See docs/automations-and-cron.md. # See docs/automations-and-cron.md.
# AUTOMATION_CRON_SECRET=generate-a-long-random-string # AUTOMATION_CRON_SECRET=generate-a-long-random-string
# Meta App ID (Meta for Developers → App Settings → Basic). Required
# only for the (planned) Resumable Upload flow that lets users attach
# local media files as template header samples. Submitting templates
# with a public sample URL works without it. Pair with META_APP_SECRET.
# META_APP_ID=your-meta-app-id
# When "true", POST /api/whatsapp/templates/submit skips the Meta call
# and stores the row with a synthetic `dry-run-<uuid>` meta_template_id.
# Set this in CI and local development so you can exercise the full
# template UI without a real WABA. Leave unset (or "false") in prod.
# WHATSAPP_TEMPLATES_DRY_RUN=true

View File

@ -0,0 +1,202 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption'
import { submitMessageTemplate } from '@/lib/whatsapp/meta-api'
import {
validateTemplatePayload,
type TemplatePayload,
} from '@/lib/whatsapp/template-validators'
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
/**
* Submit a template to Meta for approval AND persist it locally.
*
* Auth → fetch whatsapp_config → validate → (DRY_RUN short-circuit) →
* POST to Meta → upsert local row by (user_id, name, language) with
* status, meta_template_id, sample_values, last_submitted_at.
*
* When WHATSAPP_TEMPLATES_DRY_RUN=true, we skip the network call and
* insert a row with a synthetic `dry-run-<uuid>` meta_template_id so
* CI / local dev can exercise the full UI without a real Meta App.
*
* On the Meta side this is a one-way trip — a row can only be
* submitted; editing or deleting requires hsm_id and lives in PR 4.
*/
export async function POST(request: Request) {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let payload: TemplatePayload
try {
payload = (await request.json()) as TemplatePayload
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 })
}
if (payload.category === 'Authentication') {
return NextResponse.json(
{
error:
'AUTHENTICATION templates are not yet supported here — create them in Meta WhatsApp Manager and use "Sync from Meta".',
},
{ status: 400 },
)
}
try {
validateTemplatePayload(payload)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Validation failed.' },
{ status: 400 },
)
}
const metaPayload = buildMetaTemplatePayload(payload)
const dryRun =
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
let metaTemplateId: string
let metaStatus: string
if (dryRun) {
metaTemplateId = `dry-run-${crypto.randomUUID()}`
metaStatus = 'PENDING'
} else {
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('user_id', user.id)
.single()
if (configError || !config) {
return NextResponse.json(
{
error:
'WhatsApp not configured. Connect your WhatsApp Business account in Settings first.',
},
{ status: 400 },
)
}
if (!config.waba_id) {
return NextResponse.json(
{
error:
'WABA (WhatsApp Business Account) ID missing. Re-connect your account in Settings.',
},
{ status: 400 },
)
}
const accessToken = decrypt(config.access_token)
try {
const meta = await submitMessageTemplate({
wabaId: config.waba_id,
accessToken,
payload: metaPayload,
})
metaTemplateId = meta.id
metaStatus = meta.status
} catch (e) {
const message = e instanceof Error ? e.message : 'Meta submit failed.'
// Persist the failure so the user can retry; tag with a 429
// hint if Meta's rate-limit response leaked through.
await supabase
.from('message_templates')
.upsert(
{
user_id: user.id,
name: payload.name,
category: payload.category,
language: payload.language,
header_type: payload.header_type ?? null,
header_content: payload.header_content ?? null,
header_media_url: payload.header_media_url ?? null,
header_handle: payload.header_handle ?? null,
body_text: payload.body_text,
footer_text: payload.footer_text ?? null,
buttons: payload.buttons ?? null,
sample_values: payload.sample_values ?? null,
status: 'DRAFT',
submission_error: message,
last_submitted_at: new Date().toISOString(),
},
{ onConflict: 'user_id,name,language' },
)
const isRateLimit = /\b429\b/.test(message)
return NextResponse.json(
{
error: isRateLimit
? 'Meta rate limit hit (100 template creates per hour). Try again later.'
: message,
},
{ status: isRateLimit ? 429 : 502 },
)
}
}
const { data: row, error: upsertErr } = await supabase
.from('message_templates')
.upsert(
{
user_id: user.id,
name: payload.name,
category: payload.category,
language: payload.language,
header_type: payload.header_type ?? null,
header_content: payload.header_content ?? null,
header_media_url: payload.header_media_url ?? null,
header_handle: payload.header_handle ?? null,
body_text: payload.body_text,
footer_text: payload.footer_text ?? null,
buttons: payload.buttons ?? null,
sample_values: payload.sample_values ?? null,
status: normalizeStatus(metaStatus),
meta_template_id: metaTemplateId,
submission_error: null,
rejection_reason: null,
last_submitted_at: new Date().toISOString(),
},
{ onConflict: 'user_id,name,language' },
)
.select()
.single()
if (upsertErr) {
// The submit succeeded on Meta's side but we failed to persist
// locally. That's a data-drift state — surface the meta_template_id
// so the user can recover via "Sync from Meta".
return NextResponse.json(
{
error: `Submitted to Meta but failed to save locally: ${upsertErr.message}. Run "Sync from Meta" to recover.`,
meta_template_id: metaTemplateId,
},
{ status: 500 },
)
}
return NextResponse.json({
success: true,
template: row,
dry_run: dryRun,
})
} catch (error) {
console.error('Error submitting template:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to submit template.',
},
{ status: 500 },
)
}
}

View File

@ -1,11 +1,8 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption' import { decrypt } from '@/lib/whatsapp/encryption'
import type { import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
MessageTemplateStatus, import type { TemplateButton, TemplateSampleValues } from '@/types'
TemplateButton,
TemplateSampleValues,
} from '@/types'
/** /**
* Sync message templates from Meta → local message_templates table. * Sync message templates from Meta → local message_templates table.
@ -62,27 +59,6 @@ function normalizeCategory(
return 'Marketing' return 'Marketing'
} }
// Meta sometimes returns PENDING_REVIEW where the docs say PENDING; map
// it through. Anything we don't recognise falls back to PENDING so the
// row is visible to the user rather than silently dropped.
function normalizeStatus(meta: string): MessageTemplateStatus {
const upper = meta.toUpperCase()
if (upper === 'PENDING_REVIEW') return 'PENDING'
const allowed: MessageTemplateStatus[] = [
'DRAFT',
'PENDING',
'APPROVED',
'REJECTED',
'PAUSED',
'DISABLED',
'IN_APPEAL',
'PENDING_DELETION',
]
return (allowed as string[]).includes(upper)
? (upper as MessageTemplateStatus)
: 'PENDING'
}
function normalizeQualityScore( function normalizeQualityScore(
raw: MetaTemplate['quality_score'], raw: MetaTemplate['quality_score'],
): 'GREEN' | 'YELLOW' | 'RED' | null { ): 'GREEN' | 'YELLOW' | 'RED' | null {

View File

@ -29,9 +29,13 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
async function fetchTemplates() { async function fetchTemplates() {
try { try {
const supabase = createClient(); const supabase = createClient();
// Only APPROVED templates can be sent via Meta — anything else
// would 400 at broadcast time. Hide them rather than letting
// the user pick a template that will fail.
const { data, error: fetchError } = await supabase const { data, error: fetchError } = await supabase
.from('message_templates') .from('message_templates')
.select('*') .select('*')
.eq('status', 'APPROVED')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (fetchError) throw fetchError; if (fetchError) throw fetchError;

View File

@ -1,8 +1,15 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Plus, Trash2, Loader2, RefreshCw } from 'lucide-react'; import {
Plus,
Trash2,
Loader2,
RefreshCw,
AlertCircle,
X,
} from 'lucide-react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -10,7 +17,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -26,11 +33,20 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import type { MessageTemplate } from '@/types'; import type {
MessageTemplate,
TemplateButton,
TemplateSampleValues,
} from '@/types';
import { templateStatusConfig } from '@/lib/template-status'; import { templateStatusConfig } from '@/lib/template-status';
import {
extractVariableIndices,
TEMPLATE_LIMITS,
} from '@/lib/whatsapp/template-validators';
const CATEGORIES = ['Marketing', 'Utility', 'Authentication'] as const; const CATEGORIES = ['Marketing', 'Utility', 'Authentication'] as const;
const HEADER_TYPES = ['text', 'image', 'video', 'document'] as const; type HeaderFormat = 'none' | 'text' | 'image' | 'video' | 'document';
const HEADER_FORMATS: HeaderFormat[] = ['none', 'text', 'image', 'video', 'document'];
const categoryColors: Record<string, string> = { const categoryColors: Record<string, string> = {
Marketing: 'bg-purple-600/20 text-purple-400 border-purple-600/30', Marketing: 'bg-purple-600/20 text-purple-400 border-purple-600/30',
@ -38,33 +54,34 @@ const categoryColors: Record<string, string> = {
Authentication: 'bg-amber-600/20 text-amber-400 border-amber-600/30', Authentication: 'bg-amber-600/20 text-amber-400 border-amber-600/30',
}; };
interface TemplateFormData { interface TemplateFormData {
name: string; name: string;
category: MessageTemplate['category']; category: MessageTemplate['category'];
language: string; language: string;
header_format: HeaderFormat;
header_content: string;
header_media_url: string;
header_sample: string;
body_text: string; body_text: string;
header_type: string; body_samples: string[];
footer_text: string; footer_text: string;
buttons: TemplateButton[];
} }
// Meta's language codes are exact — "en" and "en_US" are distinct and a
// template approved under one will be rejected if you send with the other
// (Graph API error #132001 "Template name does not exist in the
// translation"). Default to en_US to match the DB default on
// message_templates.language and the broadcasts sender's fallback.
const emptyForm: TemplateFormData = { const emptyForm: TemplateFormData = {
name: '', name: '',
category: 'Marketing', category: 'Marketing',
language: 'en_US', language: 'en_US',
header_format: 'none',
header_content: '',
header_media_url: '',
header_sample: '',
body_text: '', body_text: '',
header_type: '', body_samples: [],
footer_text: '', footer_text: '',
buttons: [],
}; };
// Common Meta template language codes. The field still accepts any
// string — this just offers autocomplete for the usual suspects. Full
// list: https://developers.facebook.com/docs/whatsapp/api/messages/message-templates#supported-languages
const COMMON_LANGUAGE_CODES = [ const COMMON_LANGUAGE_CODES = [
'en_US', 'en_US',
'en_GB', 'en_GB',
@ -85,6 +102,19 @@ const COMMON_LANGUAGE_CODES = [
'lt', 'lt',
]; ];
function emptyButton(type: TemplateButton['type']): TemplateButton {
switch (type) {
case 'QUICK_REPLY':
return { type: 'QUICK_REPLY', text: '' };
case 'URL':
return { type: 'URL', text: '', url: '' };
case 'PHONE_NUMBER':
return { type: 'PHONE_NUMBER', text: '', phone_number: '' };
case 'COPY_CODE':
return { type: 'COPY_CODE', text: '', example: '' };
}
}
export function TemplateManager() { export function TemplateManager() {
const supabase = createClient(); const supabase = createClient();
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
@ -92,10 +122,36 @@ export function TemplateManager() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [templates, setTemplates] = useState<MessageTemplate[]>([]); const [templates, setTemplates] = useState<MessageTemplate[]>([]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false); const [submitting, setSubmitting] = useState(false);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [form, setForm] = useState<TemplateFormData>(emptyForm); const [form, setForm] = useState<TemplateFormData>(emptyForm);
// Body variable indices — `[1, 2, 3]` for "{{1}} {{2}} {{3}}". We
// re-run the extractor on every render to keep the sample-value rows
// in sync with what the user typed.
const bodyVarCount = useMemo(
() => extractVariableIndices(form.body_text).length,
[form.body_text],
);
const headerVarCount = useMemo(
() =>
form.header_format === 'text'
? extractVariableIndices(form.header_content).length
: 0,
[form.header_format, form.header_content],
);
// Resize body_samples so it always has exactly bodyVarCount entries.
// (We mutate via setForm in an effect so React owns the state.)
useEffect(() => {
setForm((prev) => {
if (prev.body_samples.length === bodyVarCount) return prev;
const next = prev.body_samples.slice(0, bodyVarCount);
while (next.length < bodyVarCount) next.push('');
return { ...prev, body_samples: next };
});
}, [bodyVarCount]);
useEffect(() => { useEffect(() => {
if (authLoading) return; if (authLoading) return;
if (!user) { if (!user) {
@ -109,13 +165,11 @@ export function TemplateManager() {
async function fetchTemplates(userId: string) { async function fetchTemplates(userId: string) {
try { try {
setLoading(true); setLoading(true);
const { data, error } = await supabase const { data, error } = await supabase
.from('message_templates') .from('message_templates')
.select('*') .select('*')
.eq('user_id', userId) .eq('user_id', userId)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (error) throw error; if (error) throw error;
setTemplates(data || []); setTemplates(data || []);
} catch (err) { } catch (err) {
@ -126,65 +180,73 @@ export function TemplateManager() {
} }
} }
async function handleSave() { function buildSubmitPayload() {
if (!form.name.trim()) { const sample_values: TemplateSampleValues = {};
toast.error('Template name is required'); if (form.body_samples.some((v) => v.trim())) {
return; sample_values.body = form.body_samples.map((v) => v.trim());
} }
if (!form.body_text.trim()) { if (form.header_format === 'text' && form.header_sample.trim()) {
toast.error('Body text is required'); sample_values.header = [form.header_sample.trim()];
return;
} }
return {
name: form.name.trim(),
category: form.category,
language: form.language.trim() || 'en_US',
header_type: form.header_format === 'none' ? undefined : form.header_format,
header_content:
form.header_format === 'text' ? form.header_content.trim() : undefined,
header_media_url:
form.header_format !== 'none' && form.header_format !== 'text'
? form.header_media_url.trim() || undefined
: undefined,
body_text: form.body_text.trim(),
footer_text: form.footer_text.trim() || undefined,
buttons: form.buttons.length > 0 ? form.buttons : undefined,
sample_values:
Object.keys(sample_values).length > 0 ? sample_values : undefined,
};
}
async function handleSubmit() {
if (form.category === 'Authentication') {
toast.error(
'AUTHENTICATION templates aren’t supported here yet — create them in Meta WhatsApp Manager and use Sync from Meta.',
);
return;
}
try { try {
setSaving(true); setSubmitting(true);
if (!user) { const res = await fetch('/api/whatsapp/templates/submit', {
toast.error('Not authenticated'); method: 'POST',
return; headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildSubmitPayload()),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error || `Submit failed (HTTP ${res.status})`);
} }
toast.success(
const payload = { data.dry_run
user_id: user.id, ? 'Template saved (dry-run — no Meta call)'
name: form.name.trim(), : 'Submitted to Meta — status will update once approved.',
category: form.category, );
language: form.language.trim() || 'en_US',
body_text: form.body_text.trim(),
header_type: form.header_type || null,
footer_text: form.footer_text.trim() || null,
status: 'DRAFT' as const,
};
const { error } = await supabase
.from('message_templates')
.insert(payload);
if (error) throw error;
toast.success('Template created successfully');
setDialogOpen(false); setDialogOpen(false);
setForm(emptyForm); setForm(emptyForm);
if (user) await fetchTemplates(user.id); if (user) await fetchTemplates(user.id);
} catch (err) { } catch (err) {
console.error('Save error:', err); console.error('Submit error:', err);
toast.error('Failed to create template'); toast.error(err instanceof Error ? err.message : 'Failed to submit');
} finally { } finally {
setSaving(false); setSubmitting(false);
} }
} }
/**
* Pull approved templates from Meta and upsert them into the local
* catalog. After this runs, every local row is guaranteed to match
* something Meta will actually accept on send — stops users getting
* stuck on error #132001 "Template name does not exist".
*/
async function handleSyncFromMeta() { async function handleSyncFromMeta() {
if (!user) return; if (!user) return;
setSyncing(true); setSyncing(true);
try { try {
const res = await fetch('/api/whatsapp/templates/sync', { const res = await fetch('/api/whatsapp/templates/sync', { method: 'POST' });
method: 'POST',
});
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(data?.error || `Sync failed (HTTP ${res.status})`); throw new Error(data?.error || `Sync failed (HTTP ${res.status})`);
@ -196,8 +258,6 @@ export function TemplateManager() {
: ''), : ''),
); );
if (Array.isArray(data.errors) && data.errors.length > 0) { if (Array.isArray(data.errors) && data.errors.length > 0) {
// Surface per-template failures so users don't trust a green
// toast that hides silent drift.
const preview = data.errors.slice(0, 3).map( const preview = data.errors.slice(0, 3).map(
(e: { name: string; language: string; message: string }) => (e: { name: string; language: string; message: string }) =>
`${e.name} (${e.language})`, `${e.name} (${e.language})`,
@ -214,9 +274,7 @@ export function TemplateManager() {
await fetchTemplates(user.id); await fetchTemplates(user.id);
} catch (err) { } catch (err) {
console.error('Template sync error:', err); console.error('Template sync error:', err);
toast.error( toast.error(err instanceof Error ? err.message : 'Failed to sync templates');
err instanceof Error ? err.message : 'Failed to sync templates',
);
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
@ -228,7 +286,6 @@ export function TemplateManager() {
.from('message_templates') .from('message_templates')
.delete() .delete()
.eq('id', id); .eq('id', id);
if (error) throw error; if (error) throw error;
toast.success('Template deleted'); toast.success('Template deleted');
setTemplates((prev) => prev.filter((t) => t.id !== id)); setTemplates((prev) => prev.filter((t) => t.id !== id));
@ -238,6 +295,37 @@ export function TemplateManager() {
} }
} }
function updateButton(index: number, patch: Partial<TemplateButton>) {
setForm((prev) => {
const next = [...prev.buttons];
next[index] = { ...next[index], ...patch } as TemplateButton;
return { ...prev, buttons: next };
});
}
function changeButtonType(index: number, type: TemplateButton['type']) {
setForm((prev) => {
const next = [...prev.buttons];
next[index] = emptyButton(type);
return { ...prev, buttons: next };
});
}
function removeButton(index: number) {
setForm((prev) => ({
...prev,
buttons: prev.buttons.filter((_, i) => i !== index),
}));
}
function addButton() {
if (form.buttons.length >= TEMPLATE_LIMITS.maxButtonsTotal) return;
setForm((prev) => ({
...prev,
buttons: [...prev.buttons, emptyButton('QUICK_REPLY')],
}));
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@ -246,15 +334,17 @@ export function TemplateManager() {
); );
} }
const headerNeedsMedia =
form.header_format !== 'none' && form.header_format !== 'text';
return ( return (
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<h2 className="text-lg font-semibold text-white">Message Templates</h2> <h2 className="text-lg font-semibold text-white">Message Templates</h2>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
Create and manage your WhatsApp message templates. Meta requires Create message templates and submit them to Meta for approval. Use
every template to be approved in the WhatsApp Manager before it can &quot;Sync from Meta&quot; to pull templates approved elsewhere.
be sent — use &quot;Sync from Meta&quot; to pull your approved list.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -265,9 +355,7 @@ export function TemplateManager() {
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800" className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
title="Pull approved templates from your Meta WhatsApp Business Account" title="Pull approved templates from your Meta WhatsApp Business Account"
> >
<RefreshCw <RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
className={`size-4 ${syncing ? 'animate-spin' : ''}`}
/>
{syncing ? 'Syncing…' : 'Sync from Meta'} {syncing ? 'Syncing…' : 'Sync from Meta'}
</Button> </Button>
<Button <Button
@ -287,60 +375,107 @@ export function TemplateManager() {
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent"> <Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-slate-400 text-sm">No templates yet.</p> <p className="text-slate-400 text-sm">No templates yet.</p>
<p className="text-slate-500 text-xs mt-1">Create your first message template to get started.</p> <p className="text-slate-500 text-xs mt-1">
Create your first message template to get started.
</p>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-3"> <div className="grid gap-3">
{templates.map((template) => ( {templates.map((template) => {
<Card key={template.id} className="bg-slate-900 border-slate-700 ring-0 ring-transparent"> const statusKey = template.status || 'DRAFT';
<CardContent className="flex items-start justify-between pt-4"> const status = templateStatusConfig[statusKey];
<div className="space-y-2 min-w-0 flex-1"> return (
<div className="flex items-center gap-2 flex-wrap"> <Card
<h3 className="font-medium text-white">{template.name}</h3> key={template.id}
<Badge className="bg-slate-900 border-slate-700 ring-0 ring-transparent"
className={`text-xs border ${categoryColors[template.category] || ''}`} >
> <CardContent className="flex items-start justify-between pt-4">
{template.category} <div className="space-y-2 min-w-0 flex-1">
</Badge> <div className="flex items-center gap-2 flex-wrap">
<Badge <h3 className="font-medium text-white">{template.name}</h3>
className={`text-xs border ${templateStatusConfig[template.status || 'DRAFT'].classes}`} <Badge
> className={`text-xs border ${categoryColors[template.category] || ''}`}
{templateStatusConfig[template.status || 'DRAFT'].label} >
</Badge> {template.category}
{template.language && ( </Badge>
<span className="text-xs text-slate-500 uppercase">{template.language}</span> <Badge className={`text-xs border ${status.classes}`}>
{status.label}
</Badge>
{template.language && (
<span className="text-xs text-slate-500 uppercase">
{template.language}
</span>
)}
{template.quality_score && (
<span
className={`text-[10px] uppercase font-medium ${
template.quality_score === 'GREEN'
? 'text-emerald-400'
: template.quality_score === 'YELLOW'
? 'text-yellow-400'
: 'text-red-400'
}`}
title="Meta quality score"
>
{template.quality_score}
</span>
)}
</div>
<p className="text-sm text-slate-400 line-clamp-2">
{template.body_text}
</p>
{template.footer_text && (
<p className="text-xs text-slate-500 italic">
{template.footer_text}
</p>
)}
{(template.rejection_reason || template.submission_error) && (
<div className="flex items-start gap-1.5 text-xs text-red-400 bg-red-950/20 border border-red-900/40 rounded px-2 py-1.5">
<AlertCircle className="size-3.5 mt-0.5 shrink-0" />
<span>
{template.rejection_reason || template.submission_error}
</span>
</div>
)} )}
</div> </div>
<p className="text-sm text-slate-400 line-clamp-2">{template.body_text}</p> <Button
{template.footer_text && ( variant="ghost"
<p className="text-xs text-slate-500 italic">{template.footer_text}</p> size="icon"
)} onClick={() => handleDelete(template.id)}
</div> className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 shrink-0 ml-2"
<Button >
variant="ghost" <Trash2 className="size-4" />
size="icon" </Button>
onClick={() => handleDelete(template.id)} </CardContent>
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 shrink-0 ml-2" </Card>
> );
<Trash2 className="size-4" /> })}
</Button>
</CardContent>
</Card>
))}
</div> </div>
)} )}
{/* New Template Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-lg"> <DialogContent className="bg-slate-900 border-slate-700 sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-white">New Message Template</DialogTitle> <DialogTitle className="text-white">New Message Template</DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-slate-400">
Create a new WhatsApp message template. Build a template and submit it to Meta for approval. Once
approved, you can use it in broadcasts and the inbox.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{form.category === 'Authentication' && (
<div className="flex items-start gap-2 rounded border border-amber-700/40 bg-amber-950/30 px-3 py-2 text-xs text-amber-300">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p>
AUTHENTICATION templates have a fixed body + OTP button shape
that needs a different builder. Create them in Meta WhatsApp
Manager for now and use <strong>Sync from Meta</strong> to
bring them in.
</p>
</div>
)}
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-slate-300">Template Name</Label> <Label className="text-slate-300">Template Name</Label>
@ -350,6 +485,9 @@ export function TemplateManager() {
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/> />
<p className="text-[11px] text-slate-500">
Lowercase letters, digits, and underscores only.
</p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -358,7 +496,10 @@ export function TemplateManager() {
<Select <Select
value={form.category} value={form.category}
onValueChange={(val) => onValueChange={(val) =>
setForm({ ...form, category: val as MessageTemplate['category'] }) setForm({
...form,
category: val as MessageTemplate['category'],
})
} }
> >
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white"> <SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
@ -366,7 +507,11 @@ export function TemplateManager() {
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700"> <SelectContent className="bg-slate-800 border-slate-700">
{CATEGORIES.map((cat) => ( {CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat} className="text-white focus:bg-slate-700 focus:text-white"> <SelectItem
key={cat}
value={cat}
className="text-white focus:bg-slate-700 focus:text-white"
>
{cat} {cat}
</SelectItem> </SelectItem>
))} ))}
@ -380,7 +525,9 @@ export function TemplateManager() {
list="template-language-codes" list="template-language-codes"
placeholder="en_US" placeholder="en_US"
value={form.language} value={form.language}
onChange={(e) => setForm({ ...form, language: e.target.value })} onChange={(e) =>
setForm({ ...form, language: e.target.value })
}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/> />
<datalist id="template-language-codes"> <datalist id="template-language-codes">
@ -389,62 +536,270 @@ export function TemplateManager() {
))} ))}
</datalist> </datalist>
<p className="text-[11px] text-slate-500"> <p className="text-[11px] text-slate-500">
Must match the exact language code the template is approved Must match the exact code on Meta — <code>en_US</code> and{' '}
under on Meta — e.g. <code>en_US</code> and <code>en</code>{' '} <code>en</code> are distinct.
are distinct.
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-slate-300">Header Type</Label> <Label className="text-slate-300">Header</Label>
<Select <Select
value={form.header_type} value={form.header_format}
onValueChange={(val) => onValueChange={(val) =>
// "none" is a sentinel (the Select disallows value="")
// — normalize it back to empty string so the DB CHECK
// constraint (text|image|video|document) doesn't reject it.
setForm({ setForm({
...form, ...form,
header_type: !val || val === 'none' ? '' : val, header_format: (val || 'none') as HeaderFormat,
header_content: val === 'text' ? form.header_content : '',
header_media_url:
val && val !== 'none' && val !== 'text'
? form.header_media_url
: '',
header_sample: val === 'text' ? form.header_sample : '',
}) })
} }
> >
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white"> <SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
<SelectValue placeholder="None" /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700"> <SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="none" className="text-white focus:bg-slate-700 focus:text-white"> {HEADER_FORMATS.map((type) => (
None <SelectItem
</SelectItem> key={type}
{HEADER_TYPES.map((type) => ( value={type}
<SelectItem key={type} value={type} className="text-white focus:bg-slate-700 focus:text-white"> className="text-white focus:bg-slate-700 focus:text-white"
{type.charAt(0).toUpperCase() + type.slice(1)} >
{type === 'none'
? 'None'
: type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{form.header_format === 'text' && (
<div className="space-y-2 mt-2">
<Input
placeholder="Header text (max 60 chars, optional {{1}})"
value={form.header_content}
onChange={(e) =>
setForm({ ...form, header_content: e.target.value })
}
maxLength={TEMPLATE_LIMITS.headerTextMaxLength}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
{headerVarCount > 0 && (
<Input
placeholder="Sample value for {{1}} (required for Meta review)"
value={form.header_sample}
onChange={(e) =>
setForm({ ...form, header_sample: e.target.value })
}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
)}
</div>
)}
{headerNeedsMedia && (
<div className="space-y-2 mt-2">
<Input
placeholder={`Public URL to a sample ${form.header_format} (https://…)`}
value={form.header_media_url}
onChange={(e) =>
setForm({ ...form, header_media_url: e.target.value })
}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
<p className="text-[11px] text-slate-500">
Meta fetches this once for the approval review. Direct file
upload is coming in a follow-up.
</p>
</div>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-slate-300">Body Text</Label> <Label className="text-slate-300">Body Text</Label>
<Textarea <Textarea
placeholder="Enter your template message body. Use {{1}}, {{2}} for variables." placeholder="Hello {{1}}, your order {{2}} is confirmed."
value={form.body_text} value={form.body_text}
onChange={(e) => setForm({ ...form, body_text: e.target.value })} onChange={(e) =>
setForm({ ...form, body_text: e.target.value })
}
rows={4} rows={4}
maxLength={TEMPLATE_LIMITS.bodyMaxLength}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 resize-none" className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 resize-none"
/> />
<p className="text-[11px] text-slate-500">
Use {`{{1}}`}, {`{{2}}`} for variables (must be contiguous
starting at {`{{1}}`}).
</p>
{bodyVarCount > 0 && (
<div className="space-y-1.5 pt-1">
<Label className="text-[11px] text-slate-400">
Sample values (Meta uses these to review your template)
</Label>
{form.body_samples.map((val, i) => (
<Input
key={i}
placeholder={`Sample for {{${i + 1}}}`}
value={val}
onChange={(e) => {
const next = [...form.body_samples];
next[i] = e.target.value;
setForm({ ...form, body_samples: next });
}}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-slate-300">Footer (optional)</Label>
<Input
placeholder="Optional footer text (max 60 chars)"
value={form.footer_text}
onChange={(e) =>
setForm({ ...form, footer_text: e.target.value })
}
maxLength={TEMPLATE_LIMITS.footerMaxLength}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-slate-300">Footer Text</Label> <div className="flex items-center justify-between">
<Input <Label className="text-slate-300">Buttons (optional)</Label>
placeholder="Optional footer text" <Button
value={form.footer_text} type="button"
onChange={(e) => setForm({ ...form, footer_text: e.target.value })} variant="outline"
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" size="sm"
/> onClick={addButton}
disabled={form.buttons.length >= TEMPLATE_LIMITS.maxButtonsTotal}
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800 h-7 text-xs"
>
<Plus className="size-3" />
Add Button
</Button>
</div>
{form.buttons.length === 0 ? (
<p className="text-[11px] text-slate-500">
Up to {TEMPLATE_LIMITS.maxButtonsTotal} buttons. QUICK_REPLY
buttons must come before URL / phone / copy-code buttons.
</p>
) : (
<div className="space-y-2">
{form.buttons.map((btn, i) => (
<div
key={i}
className="space-y-2 rounded border border-slate-700 bg-slate-800/50 p-2"
>
<div className="flex items-center gap-2">
<Select
value={btn.type}
onValueChange={(val) =>
changeButtonType(i, val as TemplateButton['type'])
}
>
<SelectTrigger className="w-40 bg-slate-800 border-slate-700 text-white h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem
value="QUICK_REPLY"
className="text-white focus:bg-slate-700 focus:text-white"
>
Quick Reply
</SelectItem>
<SelectItem
value="URL"
className="text-white focus:bg-slate-700 focus:text-white"
>
URL
</SelectItem>
<SelectItem
value="PHONE_NUMBER"
className="text-white focus:bg-slate-700 focus:text-white"
>
Phone
</SelectItem>
<SelectItem
value="COPY_CODE"
className="text-white focus:bg-slate-700 focus:text-white"
>
Copy Code
</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="Button label"
value={btn.text}
maxLength={TEMPLATE_LIMITS.buttonTextMaxLength}
onChange={(e) =>
updateButton(i, { text: e.target.value })
}
className="flex-1 bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 h-8 text-xs"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeButton(i)}
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 size-7"
>
<X className="size-3.5" />
</Button>
</div>
{btn.type === 'URL' && (
<div className="space-y-1 pl-1">
<Input
placeholder="https://example.com/path or with {{1}} suffix"
value={btn.url}
onChange={(e) =>
updateButton(i, { url: e.target.value })
}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 h-8 text-xs"
/>
{extractVariableIndices(btn.url).length > 0 && (
<Input
placeholder="Example value for {{1}} (required when URL has a variable)"
value={btn.example ?? ''}
onChange={(e) =>
updateButton(i, { example: e.target.value })
}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 h-8 text-xs"
/>
)}
</div>
)}
{btn.type === 'PHONE_NUMBER' && (
<Input
placeholder="+15551234567"
value={btn.phone_number}
onChange={(e) =>
updateButton(i, { phone_number: e.target.value })
}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 h-8 text-xs"
/>
)}
{btn.type === 'COPY_CODE' && (
<Input
placeholder="Example code (e.g. SUMMER20)"
value={btn.example}
onChange={(e) =>
updateButton(i, { example: e.target.value })
}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 h-8 text-xs"
/>
)}
</div>
))}
</div>
)}
</div> </div>
</div> </div>
@ -457,17 +812,17 @@ export function TemplateManager() {
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSubmit}
disabled={saving} disabled={submitting || form.category === 'Authentication'}
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
{saving ? ( {submitting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Creating... Submitting…
</> </>
) : ( ) : (
'Create Template' 'Submit for Approval'
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -182,6 +182,64 @@ export async function sendTemplateMessage(
return { messageId: data.messages[0].id } return { messageId: data.messages[0].id }
} }
// ============================================================
// Template submission (Business Management API)
// ============================================================
import type { MetaTemplateSubmitPayload } from './template-components'
export interface SubmitMessageTemplateArgs {
wabaId: string
accessToken: string
payload: MetaTemplateSubmitPayload
}
export interface SubmitMessageTemplateResult {
id: string
status: string
category?: string
}
/**
* Submit a message template to Meta for approval.
*
* Returns Meta's assigned template id + initial status (typically
* PENDING). Caller persists `id` as `meta_template_id` so the
* upcoming edit/delete flows can scope to this exact template (and
* language variant) via `hsm_id`, rather than nuking every variant
* with the same name.
*
* 429s from Meta (rate limit: 100 creates/hour/WABA) surface as a
* regular `Error('Meta API error: 429')`. The route handler
* distinguishes 429 and shows a more actionable toast.
*/
export async function submitMessageTemplate(
args: SubmitMessageTemplateArgs
): Promise<SubmitMessageTemplateResult> {
const { wabaId, accessToken, payload } = args
const url = `${META_API_BASE}/${wabaId}/message_templates`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(payload),
})
if (!response.ok) {
await throwMetaError(response, `Meta API error: ${response.status}`)
}
const data = await response.json()
if (!data?.id) {
throw new Error('Meta accepted the template but returned no id.')
}
return {
id: String(data.id),
status: typeof data.status === 'string' ? data.status : 'PENDING',
category: typeof data.category === 'string' ? data.category : undefined,
}
}
// ============================================================ // ============================================================
// Reactions // Reactions
// ============================================================ // ============================================================

View File

@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import { buildMetaTemplatePayload } from './template-components';
import type { TemplatePayload } from './template-validators';
const base: TemplatePayload = {
name: 'order_confirmation',
category: 'Utility',
language: 'en_US',
body_text: 'Your order is on its way.',
};
describe('buildMetaTemplatePayload', () => {
it('upcases category and produces minimal components (body only)', () => {
const payload = buildMetaTemplatePayload(base);
expect(payload).toEqual({
name: 'order_confirmation',
category: 'UTILITY',
language: 'en_US',
components: [
{ type: 'BODY', text: 'Your order is on its way.' },
],
});
});
it('includes body_text example as a 2D array (Meta spec)', () => {
const payload = buildMetaTemplatePayload({
...base,
body_text: 'Hi {{1}}, order {{2}}.',
sample_values: { body: ['John', 'ORD-42'] },
});
const body = payload.components.find((c) => c.type === 'BODY');
expect(body?.example?.body_text).toEqual([['John', 'ORD-42']]);
});
it('emits TEXT header in canonical first position', () => {
const payload = buildMetaTemplatePayload({
...base,
header_type: 'text',
header_content: 'Hello {{1}}',
sample_values: { header: ['Sara'] },
});
expect(payload.components[0]).toEqual({
type: 'HEADER',
format: 'TEXT',
text: 'Hello {{1}}',
example: { header_text: ['Sara'] },
});
});
it('uses header_url for media headers when no handle is set', () => {
const payload = buildMetaTemplatePayload({
...base,
header_type: 'image',
header_media_url: 'https://example.com/img.jpg',
});
expect(payload.components[0]).toEqual({
type: 'HEADER',
format: 'IMAGE',
example: { header_url: ['https://example.com/img.jpg'] },
});
});
it('prefers header_handle over header_media_url', () => {
const payload = buildMetaTemplatePayload({
...base,
header_type: 'video',
header_handle: '4::aW1...',
header_media_url: 'https://example.com/v.mp4',
});
expect(payload.components[0]).toEqual({
type: 'HEADER',
format: 'VIDEO',
example: { header_handle: ['4::aW1...'] },
});
});
it('emits footer when present, skips when empty', () => {
const withFooter = buildMetaTemplatePayload({
...base,
footer_text: 'Reply STOP to opt out',
});
expect(
withFooter.components.some(
(c) => c.type === 'FOOTER' && c.text === 'Reply STOP to opt out',
),
).toBe(true);
const withoutFooter = buildMetaTemplatePayload({ ...base, footer_text: '' });
expect(withoutFooter.components.some((c) => c.type === 'FOOTER')).toBe(false);
});
it('emits the buttons component with correct per-type fields', () => {
const payload = buildMetaTemplatePayload({
...base,
buttons: [
{ type: 'QUICK_REPLY', text: 'Yes' },
{ type: 'URL', text: 'Track', url: 'https://x/{{1}}', example: 'abc' },
{ type: 'PHONE_NUMBER', text: 'Call', phone_number: '+15551234567' },
{ type: 'COPY_CODE', text: 'Copy', example: 'SUMMER20' },
],
});
const buttons = payload.components.find((c) => c.type === 'BUTTONS');
expect(buttons?.buttons).toEqual([
{ type: 'QUICK_REPLY', text: 'Yes' },
{ type: 'URL', text: 'Track', url: 'https://x/{{1}}', example: ['abc'] },
{ type: 'PHONE_NUMBER', text: 'Call', phone_number: '+15551234567' },
{ type: 'COPY_CODE', text: 'Copy', example: ['SUMMER20'] },
]);
});
it('orders components HEADER → BODY → FOOTER → BUTTONS', () => {
const payload = buildMetaTemplatePayload({
...base,
header_type: 'text',
header_content: 'Hi',
footer_text: 'Footer',
buttons: [{ type: 'QUICK_REPLY', text: 'Yes' }],
});
expect(payload.components.map((c) => c.type)).toEqual([
'HEADER',
'BODY',
'FOOTER',
'BUTTONS',
]);
});
});

View File

@ -0,0 +1,155 @@
/**
* Translate our local template row shape into the `components` array
* shape that Meta's POST /{waba_id}/message_templates endpoint expects.
*
* Keep this function pure and JSON-shaped — the submit route and the
* (future) edit route both call it, and unit tests assert the exact
* payload by snapshot.
*
* Spec reference:
* https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components
*/
import type { TemplatePayload } from './template-validators';
import type { TemplateButton } from '@/types';
export interface MetaComponent {
type: 'HEADER' | 'BODY' | 'FOOTER' | 'BUTTONS';
format?: 'TEXT' | 'IMAGE' | 'VIDEO' | 'DOCUMENT';
text?: string;
buttons?: MetaButtonPayload[];
example?: {
header_text?: string[];
header_url?: string[];
header_handle?: string[];
body_text?: string[][];
};
}
interface MetaButtonPayload {
type: 'QUICK_REPLY' | 'URL' | 'PHONE_NUMBER' | 'COPY_CODE';
text: string;
url?: string;
phone_number?: string;
example?: string[];
}
function buildHeaderComponent(payload: TemplatePayload): MetaComponent | null {
const { header_type, header_content, header_media_url, header_handle } = payload;
if (!header_type) return null;
if (header_type === 'text') {
const headerSample = payload.sample_values?.header;
const component: MetaComponent = {
type: 'HEADER',
format: 'TEXT',
text: header_content,
};
if (headerSample && headerSample.length > 0) {
component.example = { header_text: headerSample };
}
return component;
}
const format =
header_type === 'image'
? 'IMAGE'
: header_type === 'video'
? 'VIDEO'
: 'DOCUMENT';
const component: MetaComponent = { type: 'HEADER', format };
if (header_handle) {
component.example = { header_handle: [header_handle] };
} else if (header_media_url) {
component.example = { header_url: [header_media_url] };
}
return component;
}
function buildBodyComponent(payload: TemplatePayload): MetaComponent {
const component: MetaComponent = {
type: 'BODY',
text: payload.body_text,
};
const bodySample = payload.sample_values?.body;
if (bodySample && bodySample.length > 0) {
// Meta expects body_text as a 2D array — outer is "examples",
// inner is the values for each variable. We submit a single
// example row.
component.example = { body_text: [bodySample] };
}
return component;
}
function buildFooterComponent(payload: TemplatePayload): MetaComponent | null {
if (!payload.footer_text?.trim()) return null;
return { type: 'FOOTER', text: payload.footer_text };
}
function buildButtonPayload(b: TemplateButton): MetaButtonPayload {
switch (b.type) {
case 'QUICK_REPLY':
return { type: 'QUICK_REPLY', text: b.text };
case 'URL': {
const payload: MetaButtonPayload = {
type: 'URL',
text: b.text,
url: b.url,
};
if (b.example) payload.example = [b.example];
return payload;
}
case 'PHONE_NUMBER':
return { type: 'PHONE_NUMBER', text: b.text, phone_number: b.phone_number };
case 'COPY_CODE':
return { type: 'COPY_CODE', text: b.text, example: [b.example] };
}
}
function buildButtonsComponent(payload: TemplatePayload): MetaComponent | null {
if (!payload.buttons || payload.buttons.length === 0) return null;
return {
type: 'BUTTONS',
buttons: payload.buttons.map(buildButtonPayload),
};
}
export interface MetaTemplateSubmitPayload {
name: string;
category: 'MARKETING' | 'UTILITY' | 'AUTHENTICATION';
language: string;
components: MetaComponent[];
}
const CATEGORY_TO_META: Record<
'Marketing' | 'Utility' | 'Authentication',
MetaTemplateSubmitPayload['category']
> = {
Marketing: 'MARKETING',
Utility: 'UTILITY',
Authentication: 'AUTHENTICATION',
};
/**
* Assemble the full submit payload (name + category + language +
* components in canonical order: HEADER → BODY → FOOTER → BUTTONS).
*/
export function buildMetaTemplatePayload(
payload: TemplatePayload,
): MetaTemplateSubmitPayload {
const components: MetaComponent[] = [];
const header = buildHeaderComponent(payload);
if (header) components.push(header);
components.push(buildBodyComponent(payload));
const footer = buildFooterComponent(payload);
if (footer) components.push(footer);
const buttons = buildButtonsComponent(payload);
if (buttons) components.push(buttons);
return {
name: payload.name,
category: CATEGORY_TO_META[payload.category],
language: payload.language,
components,
};
}

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { normalizeStatus } from './template-status-normalize';
describe('normalizeStatus', () => {
it('passes through known Meta statuses verbatim', () => {
expect(normalizeStatus('APPROVED')).toBe('APPROVED');
expect(normalizeStatus('PAUSED')).toBe('PAUSED');
expect(normalizeStatus('IN_APPEAL')).toBe('IN_APPEAL');
});
it('uppercases lowercase input', () => {
expect(normalizeStatus('approved')).toBe('APPROVED');
});
it('maps PENDING_REVIEW → PENDING', () => {
expect(normalizeStatus('PENDING_REVIEW')).toBe('PENDING');
});
it('falls back to PENDING for unknown values (so the row is still visible)', () => {
expect(normalizeStatus('SOMETHING_NEW')).toBe('PENDING');
expect(normalizeStatus('')).toBe('PENDING');
});
});

View File

@ -0,0 +1,28 @@
import type { MessageTemplateStatus } from '@/types'
const ALLOWED: ReadonlyArray<MessageTemplateStatus> = [
'DRAFT',
'PENDING',
'APPROVED',
'REJECTED',
'PAUSED',
'DISABLED',
'IN_APPEAL',
'PENDING_DELETION',
]
/**
* Normalize an upstream status string (from the sync poll, submit
* response, or webhook) into our `MessageTemplateStatus` enum.
*
* Meta sometimes returns `PENDING_REVIEW` where the docs say `PENDING`;
* map it through. Anything we don't recognise falls back to `PENDING`
* so the row is still visible to the user instead of silently dropped.
*/
export function normalizeStatus(raw: string): MessageTemplateStatus {
const upper = (raw ?? '').toUpperCase()
if (upper === 'PENDING_REVIEW') return 'PENDING'
return (ALLOWED as readonly string[]).includes(upper)
? (upper as MessageTemplateStatus)
: 'PENDING'
}

View File

@ -0,0 +1,277 @@
import { describe, expect, it } from 'vitest';
import {
extractVariableIndices,
TEMPLATE_LIMITS,
validateBody,
validateButtons,
validateFooter,
validateHeader,
validateSampleValues,
validateTemplateName,
validateTemplatePayload,
type TemplatePayload,
} from './template-validators';
const baseValid: TemplatePayload = {
name: 'order_confirmation',
category: 'Utility',
language: 'en_US',
body_text: 'Your order is confirmed.',
};
describe('extractVariableIndices', () => {
it('returns sorted unique 1-based indices', () => {
expect(extractVariableIndices('Hi {{2}} and {{1}} {{2}}')).toEqual([1, 2]);
});
it('returns empty array for no variables', () => {
expect(extractVariableIndices('No vars here')).toEqual([]);
});
});
describe('validateTemplateName', () => {
it('accepts lowercase + digits + underscore', () => {
expect(() => validateTemplateName('order_v2')).not.toThrow();
});
it('rejects uppercase', () => {
expect(() => validateTemplateName('OrderV2')).toThrow(/lowercase/);
});
it('rejects empty', () => {
expect(() => validateTemplateName('')).toThrow(/required/);
});
it('rejects spaces and dashes', () => {
expect(() => validateTemplateName('order v2')).toThrow();
expect(() => validateTemplateName('order-v2')).toThrow();
});
});
describe('validateBody', () => {
it('rejects empty', () => {
expect(() => validateBody(' ')).toThrow(/required/);
});
it('rejects > 1024 chars', () => {
expect(() => validateBody('x'.repeat(TEMPLATE_LIMITS.bodyMaxLength + 1))).toThrow(
/exceeds 1024/,
);
});
it('rejects non-contiguous variables', () => {
expect(() => validateBody('Hi {{1}} {{3}}')).toThrow(/contiguous/);
});
it('accepts contiguous variables', () => {
expect(validateBody('Hi {{1}} {{2}}')).toEqual([1, 2]);
});
});
describe('validateFooter', () => {
it('accepts undefined', () => {
expect(() => validateFooter(undefined)).not.toThrow();
});
it('rejects > 60 chars', () => {
expect(() => validateFooter('x'.repeat(61))).toThrow(/60 chars/);
});
it('rejects variables in footer', () => {
expect(() => validateFooter('Powered by {{1}}')).toThrow(/cannot contain/);
});
});
describe('validateHeader', () => {
it('text header requires content', () => {
expect(() =>
validateHeader({ header_type: 'text', header_content: '' }),
).toThrow(/requires header_content/);
});
it('text header rejects > 60 chars', () => {
expect(() =>
validateHeader({ header_type: 'text', header_content: 'x'.repeat(61) }),
).toThrow(/60 chars/);
});
it('text header rejects more than one variable', () => {
expect(() =>
validateHeader({ header_type: 'text', header_content: '{{1}} {{2}}' }),
).toThrow(/at most one variable/);
});
it('text header requires variable to be {{1}}', () => {
expect(() =>
validateHeader({ header_type: 'text', header_content: 'Hello {{2}}' }),
).toThrow(/must be \{\{1\}\}/);
});
it('image header requires a URL or handle', () => {
expect(() => validateHeader({ header_type: 'image' })).toThrow(
/requires either/,
);
});
it('image header accepts a URL', () => {
expect(() =>
validateHeader({
header_type: 'image',
header_media_url: 'https://example.com/img.jpg',
}),
).not.toThrow();
});
it('image header rejects a non-URL string', () => {
expect(() =>
validateHeader({
header_type: 'image',
header_media_url: 'not a url',
}),
).toThrow(/valid URL/);
});
});
describe('validateButtons', () => {
it('accepts undefined / empty', () => {
expect(() => validateButtons(undefined)).not.toThrow();
expect(() => validateButtons([])).not.toThrow();
});
it('rejects > 10 buttons', () => {
const tooMany = Array.from({ length: 11 }, () => ({
type: 'QUICK_REPLY' as const,
text: 'Hi',
}));
expect(() => validateButtons(tooMany)).toThrow(/at most 10 buttons/);
});
it('rejects > 2 URL buttons', () => {
expect(() =>
validateButtons([
{ type: 'URL', text: 'a', url: 'https://x' },
{ type: 'URL', text: 'b', url: 'https://x' },
{ type: 'URL', text: 'c', url: 'https://x' },
]),
).toThrow(/At most 2 URL/);
});
it('rejects > 1 PHONE_NUMBER', () => {
expect(() =>
validateButtons([
{ type: 'PHONE_NUMBER', text: 'a', phone_number: '+1' },
{ type: 'PHONE_NUMBER', text: 'b', phone_number: '+2' },
]),
).toThrow(/At most 1 PHONE_NUMBER/);
});
it('rejects > 1 COPY_CODE', () => {
expect(() =>
validateButtons([
{ type: 'COPY_CODE', text: 'a', example: 'X' },
{ type: 'COPY_CODE', text: 'b', example: 'Y' },
]),
).toThrow(/At most 1 COPY_CODE/);
});
it('rejects QUICK_REPLY interleaved with CTA buttons', () => {
expect(() =>
validateButtons([
{ type: 'QUICK_REPLY', text: 'A' },
{ type: 'URL', text: 'B', url: 'https://x' },
{ type: 'QUICK_REPLY', text: 'C' },
]),
).toThrow(/cannot be interleaved/);
});
it('accepts QUICK_REPLY then CTA in correct order', () => {
expect(() =>
validateButtons([
{ type: 'QUICK_REPLY', text: 'A' },
{ type: 'QUICK_REPLY', text: 'B' },
{ type: 'URL', text: 'C', url: 'https://x' },
]),
).not.toThrow();
});
it('rejects empty button text', () => {
expect(() =>
validateButtons([{ type: 'QUICK_REPLY', text: '' }]),
).toThrow(/missing text/);
});
it('rejects URL button without url', () => {
expect(() =>
validateButtons([{ type: 'URL', text: 'Go', url: '' }]),
).toThrow(/missing url/);
});
it('rejects URL button with invalid url', () => {
expect(() =>
validateButtons([{ type: 'URL', text: 'Go', url: 'not-a-url' }]),
).toThrow(/invalid url/);
});
it('rejects URL with {{1}} but no example', () => {
expect(() =>
validateButtons([
{ type: 'URL', text: 'Go', url: 'https://x/{{1}}' },
]),
).toThrow(/Meta requires an example/);
});
it('rejects URL with non-{{1}} variable', () => {
expect(() =>
validateButtons([
{
type: 'URL',
text: 'Go',
url: 'https://x/{{2}}',
example: 'foo',
},
]),
).toThrow(/must be \{\{1\}\}/);
});
it('rejects PHONE_NUMBER without phone_number', () => {
expect(() =>
validateButtons([
{ type: 'PHONE_NUMBER', text: 'Call', phone_number: '' },
]),
).toThrow(/missing phone_number/);
});
it('rejects COPY_CODE without example', () => {
expect(() =>
validateButtons([{ type: 'COPY_CODE', text: 'Copy', example: '' }]),
).toThrow(/missing example/);
});
});
describe('validateSampleValues', () => {
it('rejects mismatched body sample count', () => {
expect(() =>
validateSampleValues(
{ ...baseValid, body_text: 'Hi {{1}}', sample_values: { body: [] } },
1,
0,
),
).toThrow(/exactly 1 sample/);
});
it('rejects empty sample values', () => {
expect(() =>
validateSampleValues(
{ ...baseValid, sample_values: { body: [' '] } },
1,
0,
),
).toThrow(/empty/);
});
it('accepts matching counts', () => {
expect(() =>
validateSampleValues(
{ ...baseValid, sample_values: { body: ['John'] } },
1,
0,
),
).not.toThrow();
});
});
describe('validateTemplatePayload — integration', () => {
it('passes for a minimal valid payload', () => {
expect(validateTemplatePayload(baseValid)).toEqual({
bodyVarCount: 0,
headerVarCount: 0,
});
});
it('passes with body variables + matching samples', () => {
expect(
validateTemplatePayload({
...baseValid,
body_text: 'Hi {{1}}, order {{2}} confirmed.',
sample_values: { body: ['John', 'ORD-42'] },
}),
).toEqual({ bodyVarCount: 2, headerVarCount: 0 });
});
it('throws on missing samples for body variables', () => {
expect(() =>
validateTemplatePayload({
...baseValid,
body_text: 'Hi {{1}}',
}),
).toThrow(/exactly 1 sample/);
});
});

View File

@ -0,0 +1,338 @@
/**
* Pure validators for message templates, run BEFORE the Meta submit
* call so a misconfigured template fails at save time (with a specific
* field-level error) rather than at the Meta API boundary (where the
* error is a generic 400 + opaque rejection_reason hours later).
*
* Every validator throws `Error(message)` — callers catch and surface
* to the UI. Caps follow Meta's published limits for the Cloud API
* template surface (v21.0):
* https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates
*
* Per-element button validation lives here rather than as a JSONB CHECK
* because Postgres CHECK constraints can't contain subqueries, and
* generic CHECK violations don't give users an actionable error
* ("button #3 has no `text`" beats "constraint violated").
*/
import type {
MessageTemplate,
TemplateButton,
TemplateSampleValues,
} from '@/types';
export const TEMPLATE_LIMITS = {
bodyMaxLength: 1024,
footerMaxLength: 60,
headerTextMaxLength: 60,
buttonTextMaxLength: 25,
maxButtonsTotal: 10,
maxUrlButtons: 2,
maxPhoneButtons: 1,
maxCopyCodeButtons: 1,
/** Meta: lowercase a-z, digits, underscore. Up to 512 chars. */
nameRegex: /^[a-z0-9_]{1,512}$/,
} as const;
export interface TemplatePayload {
name: string;
category: MessageTemplate['category'];
language: string;
header_type?: MessageTemplate['header_type'];
header_content?: string;
header_media_url?: string;
header_handle?: string;
body_text: string;
footer_text?: string;
buttons?: TemplateButton[];
sample_values?: TemplateSampleValues;
}
export function validateTemplateName(name: string): void {
if (!name) throw new Error('Template name is required.');
if (!TEMPLATE_LIMITS.nameRegex.test(name)) {
throw new Error(
'Template name must use only lowercase letters, digits, and underscores (1-512 chars).',
);
}
}
/**
* Extract sorted, deduplicated {{N}} indices from a string. Returns
* `[1, 2, 4]` for `"Hi {{1}} {{2}}, item {{4}}"`.
*/
export function extractVariableIndices(text: string): number[] {
const matches = text.matchAll(/\{\{(\d+)\}\}/g);
const set = new Set<number>();
for (const m of matches) {
const n = Number(m[1]);
if (Number.isFinite(n) && n >= 1) set.add(n);
}
return [...set].sort((a, b) => a - b);
}
/**
* Meta requires contiguous, 1-indexed variables. `{{1}} {{3}}` is
* invalid — it must be `{{1}} {{2}}`.
*/
function assertContiguous(indices: number[], where: string): void {
for (let i = 0; i < indices.length; i++) {
if (indices[i] !== i + 1) {
throw new Error(
`${where} variables must be contiguous starting at {{1}} — found ${indices
.map((n) => `{{${n}}}`)
.join(', ')}.`,
);
}
}
}
export function validateBody(bodyText: string): number[] {
if (!bodyText.trim()) throw new Error('Body text is required.');
if (bodyText.length > TEMPLATE_LIMITS.bodyMaxLength) {
throw new Error(
`Body text exceeds ${TEMPLATE_LIMITS.bodyMaxLength} chars (got ${bodyText.length}).`,
);
}
const indices = extractVariableIndices(bodyText);
assertContiguous(indices, 'Body');
return indices;
}
export function validateFooter(footerText: string | undefined): void {
if (!footerText) return;
if (footerText.length > TEMPLATE_LIMITS.footerMaxLength) {
throw new Error(
`Footer text exceeds ${TEMPLATE_LIMITS.footerMaxLength} chars (got ${footerText.length}).`,
);
}
if (extractVariableIndices(footerText).length > 0) {
throw new Error('Footer text cannot contain {{N}} variables (Meta rule).');
}
}
export interface HeaderValidationResult {
/** number of {{N}} placeholders in a TEXT header — 0 or 1. */
variableCount: number;
}
export function validateHeader(
payload: Pick<
TemplatePayload,
'header_type' | 'header_content' | 'header_media_url' | 'header_handle'
>,
): HeaderValidationResult {
const { header_type, header_content, header_media_url, header_handle } = payload;
if (!header_type) return { variableCount: 0 };
if (header_type === 'text') {
if (!header_content || !header_content.trim()) {
throw new Error('Text header requires header_content.');
}
if (header_content.length > TEMPLATE_LIMITS.headerTextMaxLength) {
throw new Error(
`Header text exceeds ${TEMPLATE_LIMITS.headerTextMaxLength} chars (got ${header_content.length}).`,
);
}
const indices = extractVariableIndices(header_content);
if (indices.length > 1) {
throw new Error(
`Text header supports at most one variable — found ${indices.length} (Meta rule).`,
);
}
if (indices.length === 1 && indices[0] !== 1) {
throw new Error('Text header variable must be {{1}} (Meta rule).');
}
return { variableCount: indices.length };
}
// image / video / document need either a public URL or a Resumable
// Upload handle. Either one — Meta accepts both example forms.
if (!header_media_url && !header_handle) {
throw new Error(
`${header_type} header requires either a public sample URL (header_media_url) or a Resumable Upload handle (header_handle).`,
);
}
if (header_media_url) {
try {
const u = new URL(header_media_url);
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
throw new Error('header_media_url must use http(s) scheme.');
}
} catch {
throw new Error('header_media_url must be a valid URL.');
}
}
return { variableCount: 0 };
}
function countButtonsByType(
buttons: TemplateButton[],
): Record<TemplateButton['type'], number> {
const counts: Record<TemplateButton['type'], number> = {
QUICK_REPLY: 0,
URL: 0,
PHONE_NUMBER: 0,
COPY_CODE: 0,
};
for (const b of buttons) counts[b.type]++;
return counts;
}
export function validateButtons(buttons: TemplateButton[] | undefined): void {
if (!buttons || buttons.length === 0) return;
if (buttons.length > TEMPLATE_LIMITS.maxButtonsTotal) {
throw new Error(
`Templates can have at most ${TEMPLATE_LIMITS.maxButtonsTotal} buttons (got ${buttons.length}).`,
);
}
const counts = countButtonsByType(buttons);
if (counts.URL > TEMPLATE_LIMITS.maxUrlButtons) {
throw new Error(
`At most ${TEMPLATE_LIMITS.maxUrlButtons} URL buttons allowed (got ${counts.URL}).`,
);
}
if (counts.PHONE_NUMBER > TEMPLATE_LIMITS.maxPhoneButtons) {
throw new Error(
`At most ${TEMPLATE_LIMITS.maxPhoneButtons} PHONE_NUMBER button allowed (got ${counts.PHONE_NUMBER}).`,
);
}
if (counts.COPY_CODE > TEMPLATE_LIMITS.maxCopyCodeButtons) {
throw new Error(
`At most ${TEMPLATE_LIMITS.maxCopyCodeButtons} COPY_CODE button allowed (got ${counts.COPY_CODE}).`,
);
}
// Meta rule: QUICK_REPLY buttons must be contiguous — they can't be
// interleaved with CTA buttons. Easiest check: walk the array; once
// we leave the QUICK_REPLY block, we must not see another.
let sawNonQR = false;
for (const b of buttons) {
if (b.type === 'QUICK_REPLY') {
if (sawNonQR) {
throw new Error(
'QUICK_REPLY buttons cannot be interleaved with URL / PHONE_NUMBER / COPY_CODE buttons — group them at the start.',
);
}
} else {
sawNonQR = true;
}
}
for (let i = 0; i < buttons.length; i++) {
const b = buttons[i];
if (!b.text?.trim()) {
throw new Error(`Button #${i + 1} (${b.type}) is missing text.`);
}
if (b.text.length > TEMPLATE_LIMITS.buttonTextMaxLength) {
throw new Error(
`Button #${i + 1} text exceeds ${TEMPLATE_LIMITS.buttonTextMaxLength} chars.`,
);
}
switch (b.type) {
case 'URL': {
if (!b.url?.trim()) {
throw new Error(`URL button #${i + 1} is missing url.`);
}
try {
new URL(b.url);
} catch {
throw new Error(`URL button #${i + 1} has an invalid url.`);
}
const urlVars = extractVariableIndices(b.url);
if (urlVars.length > 1) {
throw new Error(
`URL button #${i + 1} can have at most one variable (Meta rule).`,
);
}
if (urlVars.length === 1) {
if (urlVars[0] !== 1) {
throw new Error(
`URL button #${i + 1} variable must be {{1}} (Meta rule).`,
);
}
if (!b.example?.trim()) {
throw new Error(
`URL button #${i + 1} uses {{1}} — Meta requires an example value.`,
);
}
}
break;
}
case 'PHONE_NUMBER':
if (!b.phone_number?.trim()) {
throw new Error(
`PHONE_NUMBER button #${i + 1} is missing phone_number.`,
);
}
break;
case 'COPY_CODE':
if (!b.example?.trim()) {
throw new Error(
`COPY_CODE button #${i + 1} is missing example value.`,
);
}
break;
}
}
}
/**
* Sample values must be supplied 1:1 with the variables in the body
* (and header, if it has one). Meta uses these for human review.
*/
export function validateSampleValues(
payload: TemplatePayload,
bodyVarCount: number,
headerVarCount: number,
): void {
const samples = payload.sample_values ?? {};
const body = samples.body ?? [];
const header = samples.header ?? [];
if (body.length !== bodyVarCount) {
throw new Error(
`Body has ${bodyVarCount} variable(s) — supply exactly ${bodyVarCount} sample value(s) (got ${body.length}).`,
);
}
if (header.length !== headerVarCount) {
throw new Error(
`Header has ${headerVarCount} variable(s) — supply exactly ${headerVarCount} sample value(s) (got ${header.length}).`,
);
}
for (let i = 0; i < body.length; i++) {
if (!body[i] || !body[i].trim()) {
throw new Error(`Body sample value #${i + 1} is empty.`);
}
}
for (let i = 0; i < header.length; i++) {
if (!header[i] || !header[i].trim()) {
throw new Error(`Header sample value #${i + 1} is empty.`);
}
}
}
/**
* Run every validator. Throws on the first failure with a specific,
* field-level message. Returns the variable counts so callers can
* reuse them when building the Meta components payload.
*/
export function validateTemplatePayload(payload: TemplatePayload): {
bodyVarCount: number;
headerVarCount: number;
} {
validateTemplateName(payload.name);
if (!payload.language?.trim()) {
throw new Error('Language is required.');
}
const bodyVars = validateBody(payload.body_text);
validateFooter(payload.footer_text);
const headerResult = validateHeader(payload);
validateButtons(payload.buttons);
validateSampleValues(payload, bodyVars.length, headerResult.variableCount);
return {
bodyVarCount: bodyVars.length,
headerVarCount: headerResult.variableCount,
};
}