Merge pull request #29 from ArnasDon/feat/broadcasts-delete-and-meta-template-sync

feat(broadcasts): delete campaign + sync templates from Meta
This commit is contained in:
Arnas Donauskas 2026-04-17 17:10:47 +03:00 committed by GitHub
commit a328ef1f19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 344 additions and 14 deletions

View File

@ -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<RecipientStatus | 'all'>(
'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 (
<div className="flex h-64 items-center justify-center">
@ -250,7 +274,7 @@ export default function BroadcastDetailPage() {
return (
<div className="space-y-6">
{/* 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">
<Button
variant="outline"
@ -278,6 +302,49 @@ export default function BroadcastDetailPage() {
</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>
{/* 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 { 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<MessageTemplate[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [form, setForm] = useState<TemplateFormData>(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 (
<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>
<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 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>
<Button
onClick={() => {
setForm(emptyForm);
setDialogOpen(true);
}}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
<Plus className="size-4" />
New Template
</Button>
</div>
{templates.length === 0 ? (