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