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
# See docs/automations-and-cron.md.
# 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 { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption'
import type {
MessageTemplateStatus,
TemplateButton,
TemplateSampleValues,
} from '@/types'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
import type { TemplateButton, TemplateSampleValues } from '@/types'
/**
* Sync message templates from Meta → local message_templates table.
@ -62,27 +59,6 @@ function normalizeCategory(
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(
raw: MetaTemplate['quality_score'],
): 'GREEN' | 'YELLOW' | 'RED' | null {

View File

@ -29,9 +29,13 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
async function fetchTemplates() {
try {
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
.from('message_templates')
.select('*')
.eq('status', 'APPROVED')
.order('created_at', { ascending: false });
if (fetchError) throw fetchError;

View File

@ -1,8 +1,15 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
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 { useAuth } from '@/hooks/use-auth';
import { Button } from '@/components/ui/button';
@ -10,7 +17,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
@ -26,11 +33,20 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { MessageTemplate } from '@/types';
import type {
MessageTemplate,
TemplateButton,
TemplateSampleValues,
} from '@/types';
import { templateStatusConfig } from '@/lib/template-status';
import {
extractVariableIndices,
TEMPLATE_LIMITS,
} from '@/lib/whatsapp/template-validators';
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> = {
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',
};
interface TemplateFormData {
name: string;
category: MessageTemplate['category'];
language: string;
header_format: HeaderFormat;
header_content: string;
header_media_url: string;
header_sample: string;
body_text: string;
header_type: string;
body_samples: 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 = {
name: '',
category: 'Marketing',
language: 'en_US',
header_format: 'none',
header_content: '',
header_media_url: '',
header_sample: '',
body_text: '',
header_type: '',
body_samples: [],
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 = [
'en_US',
'en_GB',
@ -85,6 +102,19 @@ const COMMON_LANGUAGE_CODES = [
'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() {
const supabase = createClient();
const { user, loading: authLoading } = useAuth();
@ -92,10 +122,36 @@ export function TemplateManager() {
const [loading, setLoading] = useState(true);
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [syncing, setSyncing] = useState(false);
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(() => {
if (authLoading) return;
if (!user) {
@ -109,13 +165,11 @@ export function TemplateManager() {
async function fetchTemplates(userId: string) {
try {
setLoading(true);
const { data, error } = await supabase
.from('message_templates')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false });
if (error) throw error;
setTemplates(data || []);
} catch (err) {
@ -126,65 +180,73 @@ export function TemplateManager() {
}
}
async function handleSave() {
if (!form.name.trim()) {
toast.error('Template name is required');
return;
function buildSubmitPayload() {
const sample_values: TemplateSampleValues = {};
if (form.body_samples.some((v) => v.trim())) {
sample_values.body = form.body_samples.map((v) => v.trim());
}
if (!form.body_text.trim()) {
toast.error('Body text is required');
return;
if (form.header_format === 'text' && form.header_sample.trim()) {
sample_values.header = [form.header_sample.trim()];
}
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 {
setSaving(true);
if (!user) {
toast.error('Not authenticated');
return;
setSubmitting(true);
const res = await fetch('/api/whatsapp/templates/submit', {
method: 'POST',
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})`);
}
const payload = {
user_id: user.id,
name: form.name.trim(),
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');
toast.success(
data.dry_run
? 'Template saved (dry-run — no Meta call)'
: 'Submitted to Meta — status will update once approved.',
);
setDialogOpen(false);
setForm(emptyForm);
if (user) await fetchTemplates(user.id);
} catch (err) {
console.error('Save error:', err);
toast.error('Failed to create template');
console.error('Submit error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to submit');
} 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() {
if (!user) return;
setSyncing(true);
try {
const res = await fetch('/api/whatsapp/templates/sync', {
method: 'POST',
});
const res = await fetch('/api/whatsapp/templates/sync', { method: 'POST' });
const data = await res.json();
if (!res.ok) {
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) {
// Surface per-template failures so users don't trust a green
// toast that hides silent drift.
const preview = data.errors.slice(0, 3).map(
(e: { name: string; language: string; message: string }) =>
`${e.name} (${e.language})`,
@ -214,9 +274,7 @@ export function TemplateManager() {
await fetchTemplates(user.id);
} catch (err) {
console.error('Template sync error:', err);
toast.error(
err instanceof Error ? err.message : 'Failed to sync templates',
);
toast.error(err instanceof Error ? err.message : 'Failed to sync templates');
} finally {
setSyncing(false);
}
@ -228,7 +286,6 @@ export function TemplateManager() {
.from('message_templates')
.delete()
.eq('id', id);
if (error) throw error;
toast.success('Template deleted');
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) {
return (
<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 (
<div className="space-y-4 mt-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-lg font-semibold text-white">Message Templates</h2>
<p className="text-sm text-slate-400">
Create and manage your WhatsApp message templates. Meta requires
every template to be approved in the WhatsApp Manager before it can
be sent — use &quot;Sync from Meta&quot; to pull your approved list.
Create message templates and submit them to Meta for approval. Use
&quot;Sync from Meta&quot; to pull templates approved elsewhere.
</p>
</div>
<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"
title="Pull approved templates from your Meta WhatsApp Business Account"
>
<RefreshCw
className={`size-4 ${syncing ? 'animate-spin' : ''}`}
/>
<RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Sync from Meta'}
</Button>
<Button
@ -287,60 +375,107 @@ export function TemplateManager() {
<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">
<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>
</Card>
) : (
<div className="grid gap-3">
{templates.map((template) => (
<Card key={template.id} className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
<CardContent className="flex items-start justify-between pt-4">
<div className="space-y-2 min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-medium text-white">{template.name}</h3>
<Badge
className={`text-xs border ${categoryColors[template.category] || ''}`}
>
{template.category}
</Badge>
<Badge
className={`text-xs border ${templateStatusConfig[template.status || 'DRAFT'].classes}`}
>
{templateStatusConfig[template.status || 'DRAFT'].label}
</Badge>
{template.language && (
<span className="text-xs text-slate-500 uppercase">{template.language}</span>
{templates.map((template) => {
const statusKey = template.status || 'DRAFT';
const status = templateStatusConfig[statusKey];
return (
<Card
key={template.id}
className="bg-slate-900 border-slate-700 ring-0 ring-transparent"
>
<CardContent className="flex items-start justify-between pt-4">
<div className="space-y-2 min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-medium text-white">{template.name}</h3>
<Badge
className={`text-xs border ${categoryColors[template.category] || ''}`}
>
{template.category}
</Badge>
<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>
<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>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(template.id)}
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 shrink-0 ml-2"
>
<Trash2 className="size-4" />
</Button>
</CardContent>
</Card>
))}
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(template.id)}
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 shrink-0 ml-2"
>
<Trash2 className="size-4" />
</Button>
</CardContent>
</Card>
);
})}
</div>
)}
{/* New Template Dialog */}
<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>
<DialogTitle className="text-white">New Message Template</DialogTitle>
<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>
</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-2">
<Label className="text-slate-300">Template Name</Label>
@ -350,6 +485,9 @@ export function TemplateManager() {
onChange={(e) => setForm({ ...form, name: e.target.value })}
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 className="grid grid-cols-2 gap-4">
@ -358,7 +496,10 @@ export function TemplateManager() {
<Select
value={form.category}
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">
@ -366,7 +507,11 @@ export function TemplateManager() {
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{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}
</SelectItem>
))}
@ -380,7 +525,9 @@ export function TemplateManager() {
list="template-language-codes"
placeholder="en_US"
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"
/>
<datalist id="template-language-codes">
@ -389,62 +536,270 @@ export function TemplateManager() {
))}
</datalist>
<p className="text-[11px] text-slate-500">
Must match the exact language code the template is approved
under on Meta — e.g. <code>en_US</code> and <code>en</code>{' '}
are distinct.
Must match the exact code on Meta — <code>en_US</code> and{' '}
<code>en</code> are distinct.
</p>
</div>
</div>
<div className="space-y-2">
<Label className="text-slate-300">Header Type</Label>
<Label className="text-slate-300">Header</Label>
<Select
value={form.header_type}
value={form.header_format}
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({
...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">
<SelectValue placeholder="None" />
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="none" className="text-white focus:bg-slate-700 focus:text-white">
None
</SelectItem>
{HEADER_TYPES.map((type) => (
<SelectItem key={type} value={type} className="text-white focus:bg-slate-700 focus:text-white">
{type.charAt(0).toUpperCase() + type.slice(1)}
{HEADER_FORMATS.map((type) => (
<SelectItem
key={type}
value={type}
className="text-white focus:bg-slate-700 focus:text-white"
>
{type === 'none'
? 'None'
: type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectContent>
</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 className="space-y-2">
<Label className="text-slate-300">Body Text</Label>
<Textarea
placeholder="Enter your template message body. Use {{1}}, {{2}} for variables."
placeholder="Hello {{1}}, your order {{2}} is confirmed."
value={form.body_text}
onChange={(e) => setForm({ ...form, body_text: e.target.value })}
onChange={(e) =>
setForm({ ...form, body_text: e.target.value })
}
rows={4}
maxLength={TEMPLATE_LIMITS.bodyMaxLength}
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 className="space-y-2">
<Label className="text-slate-300">Footer Text</Label>
<Input
placeholder="Optional footer text"
value={form.footer_text}
onChange={(e) => setForm({ ...form, footer_text: e.target.value })}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
<div className="flex items-center justify-between">
<Label className="text-slate-300">Buttons (optional)</Label>
<Button
type="button"
variant="outline"
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>
@ -457,17 +812,17 @@ export function TemplateManager() {
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving}
onClick={handleSubmit}
disabled={submitting || form.category === 'Authentication'}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{saving ? (
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating...
Submitting…
</>
) : (
'Create Template'
'Submit for Approval'
)}
</Button>
</DialogFooter>

View File

@ -182,6 +182,64 @@ export async function sendTemplateMessage(
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
// ============================================================

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,
};
}