mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
commit
a328ef1f19
@ -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 */}
|
||||
|
||||
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 { 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,11 +237,28 @@ 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 "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);
|
||||
@ -218,6 +270,7 @@ export function TemplateManager() {
|
||||
New Template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user