mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
318085ee9e
commit
3113738c7e
@ -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
|
||||
|
||||
202
src/app/api/whatsapp/templates/submit/route.ts
Normal file
202
src/app/api/whatsapp/templates/submit/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 "Sync from Meta" to pull your approved list.
|
||||
Create message templates and submit them to Meta for approval. Use
|
||||
"Sync from Meta" 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>
|
||||
|
||||
@ -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
|
||||
// ============================================================
|
||||
|
||||
126
src/lib/whatsapp/template-components.test.ts
Normal file
126
src/lib/whatsapp/template-components.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
155
src/lib/whatsapp/template-components.ts
Normal file
155
src/lib/whatsapp/template-components.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
20
src/lib/whatsapp/template-status-normalize.test.ts
Normal file
20
src/lib/whatsapp/template-status-normalize.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
28
src/lib/whatsapp/template-status-normalize.ts
Normal file
28
src/lib/whatsapp/template-status-normalize.ts
Normal 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'
|
||||
}
|
||||
277
src/lib/whatsapp/template-validators.test.ts
Normal file
277
src/lib/whatsapp/template-validators.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
338
src/lib/whatsapp/template-validators.ts
Normal file
338
src/lib/whatsapp/template-validators.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user