diff --git a/.env.local.example b/.env.local.example index db31ece..1d78a41 100644 --- a/.env.local.example +++ b/.env.local.example @@ -42,3 +42,15 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com # openssl rand -hex 32 # See docs/automations-and-cron.md. # AUTOMATION_CRON_SECRET=generate-a-long-random-string + +# Meta App ID (Meta for Developers → App Settings → Basic). Required +# only for the (planned) Resumable Upload flow that lets users attach +# local media files as template header samples. Submitting templates +# with a public sample URL works without it. Pair with META_APP_SECRET. +# META_APP_ID=your-meta-app-id + +# When "true", POST /api/whatsapp/templates/submit skips the Meta call +# and stores the row with a synthetic `dry-run-` 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 diff --git a/src/app/api/whatsapp/templates/submit/route.ts b/src/app/api/whatsapp/templates/submit/route.ts new file mode 100644 index 0000000..9ab5d4e --- /dev/null +++ b/src/app/api/whatsapp/templates/submit/route.ts @@ -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-` 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 }, + ) + } +} diff --git a/src/app/api/whatsapp/templates/sync/route.ts b/src/app/api/whatsapp/templates/sync/route.ts index f267f93..cc17dce 100644 --- a/src/app/api/whatsapp/templates/sync/route.ts +++ b/src/app/api/whatsapp/templates/sync/route.ts @@ -1,11 +1,8 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { decrypt } from '@/lib/whatsapp/encryption' -import type { - MessageTemplateStatus, - TemplateButton, - TemplateSampleValues, -} from '@/types' +import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize' +import type { TemplateButton, TemplateSampleValues } from '@/types' /** * Sync message templates from Meta → local message_templates table. @@ -62,27 +59,6 @@ function normalizeCategory( return 'Marketing' } -// Meta sometimes returns PENDING_REVIEW where the docs say PENDING; map -// it through. Anything we don't recognise falls back to PENDING so the -// row is visible to the user rather than silently dropped. -function normalizeStatus(meta: string): MessageTemplateStatus { - const upper = meta.toUpperCase() - if (upper === 'PENDING_REVIEW') return 'PENDING' - const allowed: MessageTemplateStatus[] = [ - 'DRAFT', - 'PENDING', - 'APPROVED', - 'REJECTED', - 'PAUSED', - 'DISABLED', - 'IN_APPEAL', - 'PENDING_DELETION', - ] - return (allowed as string[]).includes(upper) - ? (upper as MessageTemplateStatus) - : 'PENDING' -} - function normalizeQualityScore( raw: MetaTemplate['quality_score'], ): 'GREEN' | 'YELLOW' | 'RED' | null { diff --git a/src/components/broadcasts/step1-choose-template.tsx b/src/components/broadcasts/step1-choose-template.tsx index e3fd476..8c2afd8 100644 --- a/src/components/broadcasts/step1-choose-template.tsx +++ b/src/components/broadcasts/step1-choose-template.tsx @@ -29,9 +29,13 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack async function fetchTemplates() { try { const supabase = createClient(); + // Only APPROVED templates can be sent via Meta — anything else + // would 400 at broadcast time. Hide them rather than letting + // the user pick a template that will fail. const { data, error: fetchError } = await supabase .from('message_templates') .select('*') + .eq('status', 'APPROVED') .order('created_at', { ascending: false }); if (fetchError) throw fetchError; diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx index a4e557c..c3e72eb 100644 --- a/src/components/settings/template-manager.tsx +++ b/src/components/settings/template-manager.tsx @@ -1,8 +1,15 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { Plus, Trash2, Loader2, RefreshCw } from 'lucide-react'; +import { + Plus, + Trash2, + Loader2, + RefreshCw, + AlertCircle, + X, +} from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; @@ -10,7 +17,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Card, CardContent } from '@/components/ui/card'; import { Dialog, DialogContent, @@ -26,11 +33,20 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import type { MessageTemplate } from '@/types'; +import type { + MessageTemplate, + TemplateButton, + TemplateSampleValues, +} from '@/types'; import { templateStatusConfig } from '@/lib/template-status'; +import { + extractVariableIndices, + TEMPLATE_LIMITS, +} from '@/lib/whatsapp/template-validators'; const CATEGORIES = ['Marketing', 'Utility', 'Authentication'] as const; -const HEADER_TYPES = ['text', 'image', 'video', 'document'] as const; +type HeaderFormat = 'none' | 'text' | 'image' | 'video' | 'document'; +const HEADER_FORMATS: HeaderFormat[] = ['none', 'text', 'image', 'video', 'document']; const categoryColors: Record = { Marketing: 'bg-purple-600/20 text-purple-400 border-purple-600/30', @@ -38,33 +54,34 @@ const categoryColors: Record = { Authentication: 'bg-amber-600/20 text-amber-400 border-amber-600/30', }; - interface TemplateFormData { name: string; category: MessageTemplate['category']; language: string; + header_format: HeaderFormat; + header_content: string; + header_media_url: string; + header_sample: string; body_text: string; - header_type: string; + body_samples: string[]; footer_text: string; + buttons: TemplateButton[]; } -// Meta's language codes are exact — "en" and "en_US" are distinct and a -// template approved under one will be rejected if you send with the other -// (Graph API error #132001 "Template name does not exist in the -// translation"). Default to en_US to match the DB default on -// message_templates.language and the broadcasts sender's fallback. const emptyForm: TemplateFormData = { name: '', category: 'Marketing', language: 'en_US', + header_format: 'none', + header_content: '', + header_media_url: '', + header_sample: '', body_text: '', - header_type: '', + body_samples: [], footer_text: '', + buttons: [], }; -// Common Meta template language codes. The field still accepts any -// string — this just offers autocomplete for the usual suspects. Full -// list: https://developers.facebook.com/docs/whatsapp/api/messages/message-templates#supported-languages const COMMON_LANGUAGE_CODES = [ 'en_US', 'en_GB', @@ -85,6 +102,19 @@ const COMMON_LANGUAGE_CODES = [ 'lt', ]; +function emptyButton(type: TemplateButton['type']): TemplateButton { + switch (type) { + case 'QUICK_REPLY': + return { type: 'QUICK_REPLY', text: '' }; + case 'URL': + return { type: 'URL', text: '', url: '' }; + case 'PHONE_NUMBER': + return { type: 'PHONE_NUMBER', text: '', phone_number: '' }; + case 'COPY_CODE': + return { type: 'COPY_CODE', text: '', example: '' }; + } +} + export function TemplateManager() { const supabase = createClient(); const { user, loading: authLoading } = useAuth(); @@ -92,10 +122,36 @@ export function TemplateManager() { const [loading, setLoading] = useState(true); const [templates, setTemplates] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); - const [saving, setSaving] = useState(false); + const [submitting, setSubmitting] = useState(false); const [syncing, setSyncing] = useState(false); const [form, setForm] = useState(emptyForm); + // Body variable indices — `[1, 2, 3]` for "{{1}} {{2}} {{3}}". We + // re-run the extractor on every render to keep the sample-value rows + // in sync with what the user typed. + const bodyVarCount = useMemo( + () => extractVariableIndices(form.body_text).length, + [form.body_text], + ); + const headerVarCount = useMemo( + () => + form.header_format === 'text' + ? extractVariableIndices(form.header_content).length + : 0, + [form.header_format, form.header_content], + ); + + // Resize body_samples so it always has exactly bodyVarCount entries. + // (We mutate via setForm in an effect so React owns the state.) + useEffect(() => { + setForm((prev) => { + if (prev.body_samples.length === bodyVarCount) return prev; + const next = prev.body_samples.slice(0, bodyVarCount); + while (next.length < bodyVarCount) next.push(''); + return { ...prev, body_samples: next }; + }); + }, [bodyVarCount]); + useEffect(() => { if (authLoading) return; if (!user) { @@ -109,13 +165,11 @@ export function TemplateManager() { async function fetchTemplates(userId: string) { try { setLoading(true); - const { data, error } = await supabase .from('message_templates') .select('*') .eq('user_id', userId) .order('created_at', { ascending: false }); - if (error) throw error; setTemplates(data || []); } catch (err) { @@ -126,65 +180,73 @@ export function TemplateManager() { } } - async function handleSave() { - if (!form.name.trim()) { - toast.error('Template name is required'); - return; + function buildSubmitPayload() { + const sample_values: TemplateSampleValues = {}; + if (form.body_samples.some((v) => v.trim())) { + sample_values.body = form.body_samples.map((v) => v.trim()); } - if (!form.body_text.trim()) { - toast.error('Body text is required'); - return; + if (form.header_format === 'text' && form.header_sample.trim()) { + sample_values.header = [form.header_sample.trim()]; } + return { + name: form.name.trim(), + category: form.category, + language: form.language.trim() || 'en_US', + header_type: form.header_format === 'none' ? undefined : form.header_format, + header_content: + form.header_format === 'text' ? form.header_content.trim() : undefined, + header_media_url: + form.header_format !== 'none' && form.header_format !== 'text' + ? form.header_media_url.trim() || undefined + : undefined, + body_text: form.body_text.trim(), + footer_text: form.footer_text.trim() || undefined, + buttons: form.buttons.length > 0 ? form.buttons : undefined, + sample_values: + Object.keys(sample_values).length > 0 ? sample_values : undefined, + }; + } + + async function handleSubmit() { + if (form.category === 'Authentication') { + toast.error( + 'AUTHENTICATION templates aren’t supported here yet — create them in Meta WhatsApp Manager and use Sync from Meta.', + ); + return; + } try { - setSaving(true); - if (!user) { - toast.error('Not authenticated'); - return; + setSubmitting(true); + const res = await fetch('/api/whatsapp/templates/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(buildSubmitPayload()), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data?.error || `Submit failed (HTTP ${res.status})`); } - - const payload = { - user_id: user.id, - name: form.name.trim(), - category: form.category, - language: form.language.trim() || 'en_US', - body_text: form.body_text.trim(), - header_type: form.header_type || null, - footer_text: form.footer_text.trim() || null, - status: 'DRAFT' as const, - }; - - const { error } = await supabase - .from('message_templates') - .insert(payload); - - if (error) throw error; - - toast.success('Template created successfully'); + toast.success( + data.dry_run + ? 'Template saved (dry-run — no Meta call)' + : 'Submitted to Meta — status will update once approved.', + ); setDialogOpen(false); setForm(emptyForm); if (user) await fetchTemplates(user.id); } catch (err) { - console.error('Save error:', err); - toast.error('Failed to create template'); + console.error('Submit error:', err); + toast.error(err instanceof Error ? err.message : 'Failed to submit'); } finally { - setSaving(false); + setSubmitting(false); } } - /** - * Pull approved templates from Meta and upsert them into the local - * catalog. After this runs, every local row is guaranteed to match - * something Meta will actually accept on send — stops users getting - * stuck on error #132001 "Template name does not exist". - */ async function handleSyncFromMeta() { if (!user) return; setSyncing(true); try { - const res = await fetch('/api/whatsapp/templates/sync', { - method: 'POST', - }); + const res = await fetch('/api/whatsapp/templates/sync', { method: 'POST' }); const data = await res.json(); if (!res.ok) { throw new Error(data?.error || `Sync failed (HTTP ${res.status})`); @@ -196,8 +258,6 @@ export function TemplateManager() { : ''), ); if (Array.isArray(data.errors) && data.errors.length > 0) { - // Surface per-template failures so users don't trust a green - // toast that hides silent drift. const preview = data.errors.slice(0, 3).map( (e: { name: string; language: string; message: string }) => `${e.name} (${e.language})`, @@ -214,9 +274,7 @@ export function TemplateManager() { await fetchTemplates(user.id); } catch (err) { console.error('Template sync error:', err); - toast.error( - err instanceof Error ? err.message : 'Failed to sync templates', - ); + toast.error(err instanceof Error ? err.message : 'Failed to sync templates'); } finally { setSyncing(false); } @@ -228,7 +286,6 @@ export function TemplateManager() { .from('message_templates') .delete() .eq('id', id); - if (error) throw error; toast.success('Template deleted'); setTemplates((prev) => prev.filter((t) => t.id !== id)); @@ -238,6 +295,37 @@ export function TemplateManager() { } } + function updateButton(index: number, patch: Partial) { + setForm((prev) => { + const next = [...prev.buttons]; + next[index] = { ...next[index], ...patch } as TemplateButton; + return { ...prev, buttons: next }; + }); + } + + function changeButtonType(index: number, type: TemplateButton['type']) { + setForm((prev) => { + const next = [...prev.buttons]; + next[index] = emptyButton(type); + return { ...prev, buttons: next }; + }); + } + + function removeButton(index: number) { + setForm((prev) => ({ + ...prev, + buttons: prev.buttons.filter((_, i) => i !== index), + })); + } + + function addButton() { + if (form.buttons.length >= TEMPLATE_LIMITS.maxButtonsTotal) return; + setForm((prev) => ({ + ...prev, + buttons: [...prev.buttons, emptyButton('QUICK_REPLY')], + })); + } + if (loading) { return (
@@ -246,15 +334,17 @@ export function TemplateManager() { ); } + const headerNeedsMedia = + form.header_format !== 'none' && form.header_format !== 'text'; + return (

Message Templates

- Create and manage your WhatsApp message templates. Meta requires - every template to be approved in the WhatsApp Manager before it can - be sent — use "Sync from Meta" to pull your approved list. + Create message templates and submit them to Meta for approval. Use + "Sync from Meta" to pull templates approved elsewhere.

@@ -265,9 +355,7 @@ export function TemplateManager() { className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800" title="Pull approved templates from your Meta WhatsApp Business Account" > - + {syncing ? 'Syncing…' : 'Sync from Meta'} - - - ))} + + + + ); + })}
)} - {/* New Template Dialog */} - + New Message Template - 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. + {form.category === 'Authentication' && ( +
+ +

+ AUTHENTICATION templates have a fixed body + OTP button shape + that needs a different builder. Create them in Meta WhatsApp + Manager for now and use Sync from Meta to + bring them in. +

+
+ )} +
@@ -350,6 +485,9 @@ export function TemplateManager() { onChange={(e) => setForm({ ...form, name: e.target.value })} className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" /> +

+ Lowercase letters, digits, and underscores only. +

@@ -358,7 +496,10 @@ export function TemplateManager() { - // "none" is a sentinel (the Select disallows value="") - // — normalize it back to empty string so the DB CHECK - // constraint (text|image|video|document) doesn't reject it. setForm({ ...form, - header_type: !val || val === 'none' ? '' : val, + header_format: (val || 'none') as HeaderFormat, + header_content: val === 'text' ? form.header_content : '', + header_media_url: + val && val !== 'none' && val !== 'text' + ? form.header_media_url + : '', + header_sample: val === 'text' ? form.header_sample : '', }) } > - + - - None - - {HEADER_TYPES.map((type) => ( - - {type.charAt(0).toUpperCase() + type.slice(1)} + {HEADER_FORMATS.map((type) => ( + + {type === 'none' + ? 'None' + : type.charAt(0).toUpperCase() + type.slice(1)} ))} + + {form.header_format === 'text' && ( +
+ + 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 && ( + + setForm({ ...form, header_sample: e.target.value }) + } + className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" + /> + )} +
+ )} + + {headerNeedsMedia && ( +
+ + setForm({ ...form, header_media_url: e.target.value }) + } + className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500" + /> +

+ Meta fetches this once for the approval review. Direct file + upload is coming in a follow-up. +

+
+ )}