From b37229f6a969e15226ff55687d0ab9d4c739d6c8 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 17 Apr 2026 17:08:33 +0300 Subject: [PATCH] feat(broadcasts): delete campaign + sync templates from Meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two recurring issues: 1) Delete broadcast campaign Broadcast detail page gets a Delete button (red, in the header, next to the status badge) with an inline confirm pattern matching pipeline-settings. Disabled while status='sending' — we don't want to orphan in-flight Meta messages. On confirm, supabase delete cascades broadcast_recipients and redirects to /broadcasts. 2) Persistent Meta error #132001 "Template name does not exist in the translation" Root cause that wasn't fixed by PR #27's language default: the local template manager never actually submitted templates to Meta. Users would create welcome_message locally, try to broadcast, and get rejected because Meta had never seen the template. Meta template submission requires a human review flow in Meta's WhatsApp Manager — we can't automate that from inside WaCRM. What we CAN do is pull Meta's approved list so the local catalog never drifts from reality. New: POST /api/whatsapp/templates/sync - Reads whatsapp_config for waba_id + access_token (decrypted) - GET /v21.0/{waba_id}/message_templates?limit=100 with BODY/HEADER/ FOOTER components - Normalizes Meta's UPPER_SNAKE category/status to our TitleCase - Upserts each row in message_templates by (user_id, name, language) - Locally-created rows with no Meta counterpart stay visible so the user can notice drift and clean up Template manager UI: "Sync from Meta" button next to New Template, with a spinning refresh icon while in flight and a toast summarizing how many inserted / updated. Header copy clarifies that approval happens in Meta's WhatsApp Manager. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(dashboard)/broadcasts/[id]/page.tsx | 69 +++++- src/app/api/whatsapp/templates/sync/route.ts | 210 +++++++++++++++++++ src/components/settings/template-manager.tsx | 79 +++++-- 3 files changed, 344 insertions(+), 14 deletions(-) create mode 100644 src/app/api/whatsapp/templates/sync/route.ts diff --git a/src/app/(dashboard)/broadcasts/[id]/page.tsx b/src/app/(dashboard)/broadcasts/[id]/page.tsx index 8ded950..ba011a4 100644 --- a/src/app/(dashboard)/broadcasts/[id]/page.tsx +++ b/src/app/(dashboard)/broadcasts/[id]/page.tsx @@ -31,7 +31,9 @@ import { Filter, Download, ChevronDown, + Trash2, } from 'lucide-react'; +import { toast } from 'sonner'; import { getBroadcastStatus, getRecipientStatus, @@ -151,6 +153,8 @@ export default function BroadcastDetailPage() { const [statusFilter, setStatusFilter] = useState( 'all', ); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleting, setDeleting] = useState(false); useEffect(() => { async function fetchData() { @@ -219,6 +223,26 @@ export default function BroadcastDetailPage() { downloadBlob(`broadcast-${safeName}-${broadcastId.slice(0, 8)}.csv`, csv); } + async function handleDelete() { + setDeleting(true); + const supabase = createClient(); + // broadcast_recipients cascades on broadcasts.id (migration 001), so a + // single delete is sufficient — the aggregate trigger in migration 003 + // is defined on broadcast_recipients but fires only on its own row + // changes, not on a cascaded drop of the parent row. + const { error: delErr } = await supabase + .from('broadcasts') + .delete() + .eq('id', broadcastId); + setDeleting(false); + if (delErr) { + toast.error(`Failed to delete: ${delErr.message}`); + return; + } + toast.success('Broadcast deleted'); + router.push('/broadcasts'); + } + if (loading) { return (
@@ -250,7 +274,7 @@ export default function BroadcastDetailPage() { return (
{/* Header */} -
+
+ + {/* Delete — inline-confirm pattern matches the pipeline-settings + "Delete Pipeline" flow. Mid-send broadcasts can't be deleted + because orphaning in-flight Meta messages would leave the + funnel inconsistent. */} + {confirmDelete ? ( +
+ Delete this broadcast? + + +
+ ) : ( + + )}
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */} diff --git a/src/app/api/whatsapp/templates/sync/route.ts b/src/app/api/whatsapp/templates/sync/route.ts new file mode 100644 index 0000000..6e5ee03 --- /dev/null +++ b/src/app/api/whatsapp/templates/sync/route.ts @@ -0,0 +1,210 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { decrypt } from '@/lib/whatsapp/encryption' + +/** + * Sync message templates from Meta → local message_templates table. + * + * Why this exists: + * The Settings → Message Templates UI only writes to Supabase. It does + * NOT submit templates for approval to Meta. Users would create a + * template locally, try to broadcast with it, and hit Meta's error + * #132001 "Template name does not exist in the translation" — because + * Meta had never seen the template, or had it approved under a + * different language code than what WaCRM stored. + * + * This route pulls the source of truth (Meta's approved templates) + * and upserts them into the local catalog by (user_id, name, language). + * After a sync, every template row in WaCRM is guaranteed to match + * something Meta will actually accept on send. + * + * Scope: + * - Read-only against Meta. We never push local → Meta (template + * submission happens in Meta's WhatsApp Manager and requires human + * review). + * - Only approved templates are surfaced by default. We return + * everything Meta returns and let the UI filter — so the user can + * see their Pending / Rejected templates and understand why. + * - Locally-created templates (no Meta counterpart) are NOT deleted — + * they remain visible so the user can notice drift and clean up + * manually. + */ + +const META_API_VERSION = 'v21.0' +const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}` + +interface MetaTemplateComponent { + type: string + text?: string + format?: string +} + +interface MetaTemplate { + id: string + name: string + language: string + status: 'APPROVED' | 'PENDING' | 'REJECTED' | 'PAUSED' + category: string + components?: MetaTemplateComponent[] +} + +/** + * Meta's template categories are upper-snake (MARKETING / UTILITY / + * AUTHENTICATION); our DB CHECK constraint is TitleCase. Normalize. + */ +function normalizeCategory( + meta: string, +): 'Marketing' | 'Utility' | 'Authentication' { + const upper = meta.toUpperCase() + if (upper === 'UTILITY') return 'Utility' + if (upper === 'AUTHENTICATION') return 'Authentication' + return 'Marketing' +} + +/** + * Meta's template status is UPPERCASE; our DB uses TitleCase. + */ +function normalizeStatus( + meta: string, +): 'Draft' | 'Pending' | 'Approved' | 'Rejected' { + switch (meta.toUpperCase()) { + case 'APPROVED': + return 'Approved' + case 'PENDING': + case 'IN_APPEAL': + case 'PENDING_DELETION': + return 'Pending' + case 'REJECTED': + case 'DISABLED': + case 'PAUSED': + return 'Rejected' + default: + return 'Draft' + } +} + +export async function POST() { + try { + const supabase = await createClient() + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser() + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // whatsapp_config holds waba_id + encrypted access_token. + 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) + + // Pull up to 100 templates. Meta paginates via `paging.next`; for + // now we take page 1, which covers realistic small-business use. + // A follow-up can loop through paging.next if someone has more + // than 100 templates (rare — Meta caps new templates per account). + const url = `${META_API_BASE}/${config.waba_id}/message_templates?limit=100&fields=id,name,language,status,category,components` + const metaRes = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!metaRes.ok) { + let metaErr = `Meta API error: ${metaRes.status}` + try { + const body = await metaRes.json() + if (body?.error?.message) metaErr = body.error.message + } catch { + // response wasn't JSON — keep the fallback + } + return NextResponse.json({ error: metaErr }, { status: 502 }) + } + + const metaBody: { data?: MetaTemplate[] } = await metaRes.json() + const metaTemplates = metaBody.data ?? [] + + // For each Meta template: upsert by (user_id, name, language). + // No UNIQUE constraint on that triple, so we match manually. + let inserted = 0 + let updated = 0 + + for (const t of metaTemplates) { + const body = (t.components ?? []).find((c) => c.type === 'BODY') + const header = (t.components ?? []).find((c) => c.type === 'HEADER') + const footer = (t.components ?? []).find((c) => c.type === 'FOOTER') + + const row = { + user_id: user.id, + name: t.name, + category: normalizeCategory(t.category), + language: t.language, + header_type: header?.format?.toLowerCase() ?? null, + header_content: header?.text ?? null, + body_text: body?.text ?? '', + footer_text: footer?.text ?? null, + status: normalizeStatus(t.status), + updated_at: new Date().toISOString(), + } + + const { data: existing } = await supabase + .from('message_templates') + .select('id') + .eq('user_id', user.id) + .eq('name', t.name) + .eq('language', t.language) + .maybeSingle() + + if (existing?.id) { + await supabase + .from('message_templates') + .update(row) + .eq('id', existing.id) + updated++ + } else { + await supabase.from('message_templates').insert(row) + inserted++ + } + } + + return NextResponse.json({ + success: true, + total: metaTemplates.length, + inserted, + updated, + }) + } catch (error) { + console.error('Error syncing WhatsApp templates:', error) + return NextResponse.json( + { + error: + error instanceof Error ? error.message : 'Failed to sync templates', + }, + { status: 500 }, + ) + } +} diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx index 727541d..c728a7a 100644 --- a/src/components/settings/template-manager.tsx +++ b/src/components/settings/template-manager.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { Plus, Trash2, Loader2 } from 'lucide-react'; +import { Plus, Trash2, Loader2, RefreshCw } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; @@ -98,6 +98,7 @@ export function TemplateManager() { const [templates, setTemplates] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); const [saving, setSaving] = useState(false); + const [syncing, setSyncing] = useState(false); const [form, setForm] = useState(emptyForm); useEffect(() => { @@ -176,6 +177,40 @@ export function TemplateManager() { } } + /** + * 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 data = await res.json(); + if (!res.ok) { + throw new Error(data?.error || `Sync failed (HTTP ${res.status})`); + } + toast.success( + `Synced ${data.total} template${data.total === 1 ? '' : 's'} from Meta` + + (data.inserted || data.updated + ? ` (${data.inserted} new, ${data.updated} updated)` + : ''), + ); + await fetchTemplates(user.id); + } catch (err) { + console.error('Template sync error:', err); + toast.error( + err instanceof Error ? err.message : 'Failed to sync templates', + ); + } finally { + setSyncing(false); + } + } + async function handleDelete(id: string) { try { const { error } = await supabase @@ -202,21 +237,39 @@ export function TemplateManager() { return (
-
+

Message Templates

-

Create and manage your WhatsApp 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. +

+
+
+ +
-
{templates.length === 0 ? (