mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
6bae86bd66
commit
b37229f6a9
@ -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 */}
|
||||||
|
|||||||
210
src/app/api/whatsapp/templates/sync/route.ts
Normal file
210
src/app/api/whatsapp/templates/sync/route.ts
Normal 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,21 +237,39 @@ 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 "Sync from Meta" to pull your approved list.
|
||||||
|
</p>
|
||||||
|
</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
|
||||||
|
onClick={() => {
|
||||||
|
setForm(emptyForm);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Template
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setForm(emptyForm);
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
|
||||||
>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
New Template
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{templates.length === 0 ? (
|
{templates.length === 0 ? (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user