feat(broadcasts): delete campaign + sync templates from Meta

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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-17 17:08:33 +03:00
parent 6bae86bd66
commit b37229f6a9
No known key found for this signature in database
3 changed files with 344 additions and 14 deletions

View File

@ -31,7 +31,9 @@ import {
Filter, Filter,
Download, Download,
ChevronDown, ChevronDown,
Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner';
import { import {
getBroadcastStatus, getBroadcastStatus,
getRecipientStatus, getRecipientStatus,
@ -151,6 +153,8 @@ export default function BroadcastDetailPage() {
const [statusFilter, setStatusFilter] = useState<RecipientStatus | 'all'>( const [statusFilter, setStatusFilter] = useState<RecipientStatus | 'all'>(
'all', 'all',
); );
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
@ -219,6 +223,26 @@ export default function BroadcastDetailPage() {
downloadBlob(`broadcast-${safeName}-${broadcastId.slice(0, 8)}.csv`, csv); 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) { if (loading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@ -250,7 +274,7 @@ export default function BroadcastDetailPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button <Button
variant="outline" variant="outline"
@ -278,6 +302,49 @@ export default function BroadcastDetailPage() {
</div> </div>
</div> </div>
</div> </div>
{/* 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 ? (
<div className="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm">
<span className="text-red-300">Delete this broadcast?</span>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="h-7 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
>
Cancel
</Button>
<Button
size="sm"
onClick={handleDelete}
disabled={deleting}
className="h-7 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? 'Deleting…' : 'Confirm'}
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
disabled={broadcast.status === 'sending'}
onClick={() => setConfirmDelete(true)}
title={
broadcast.status === 'sending'
? 'Cannot delete while a broadcast is actively sending'
: 'Delete this broadcast'
}
className="border-red-500/30 bg-transparent text-red-400 hover:bg-red-500/10 disabled:opacity-40"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div> </div>
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */} {/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}

View File

@ -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 },
)
}
}

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'sonner'; 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 { 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';
@ -98,6 +98,7 @@ export function TemplateManager() {
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 [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [form, setForm] = useState<TemplateFormData>(emptyForm); const [form, setForm] = useState<TemplateFormData>(emptyForm);
useEffect(() => { 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) { async function handleDelete(id: string) {
try { try {
const { error } = await supabase const { error } = await supabase
@ -202,11 +237,28 @@ export function TemplateManager() {
return ( return (
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<div className="flex items-center justify-between"> <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">Create and manage your WhatsApp message templates.</p> <p className="text-sm text-slate-400">
Create and manage your WhatsApp message templates. Meta requires
every template to be approved in the WhatsApp Manager before it can
be sent — use &quot;Sync from Meta&quot; to pull your approved list.
</p>
</div> </div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleSyncFromMeta}
disabled={syncing}
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
title="Pull approved templates from your Meta WhatsApp Business Account"
>
<RefreshCw
className={`size-4 ${syncing ? 'animate-spin' : ''}`}
/>
{syncing ? 'Syncing…' : 'Sync from Meta'}
</Button>
<Button <Button
onClick={() => { onClick={() => {
setForm(emptyForm); setForm(emptyForm);
@ -218,6 +270,7 @@ export function TemplateManager() {
New Template New Template
</Button> </Button>
</div> </div>
</div>
{templates.length === 0 ? ( {templates.length === 0 ? (
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent"> <Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">