mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Fixes 8 real issues found during a thorough review of the broadcast
feature (PRs #21-#29).
Security
* Webhook POSTs had zero spoof protection. Anyone could forge a
status update and drift broadcast counts arbitrarily. Added
HMAC-SHA256 signature verification via the X-Hub-Signature-256
header (Meta's standard). New helper src/lib/whatsapp/webhook-
signature.ts with timing-safe comparison. Rollout fails open
with a warning when META_APP_SECRET is unset so existing
deploys don't break at rollout; set the env var to enforce.
Correctness
* CSV audience was completely broken — synthesized "csv-N" strings
as contact_id, which failed the UUID cast on insert. Every CSV
broadcast silently created zero recipients. Replaced with a
real find-or-create against the contacts table (batched lookup
by phone, single INSERT for missing rows, de-duplicates CSV
inputs). CSV contacts now end up as real contacts.rows with
real UUIDs flowing through the rest of the pipeline.
* Recipient insert errors were logged with console.error and the
loop marched on — broadcasts then ran with incomplete recipient
sets, webhook updates couldn't find rows, and aggregate counts
drifted. Now: on batch insert failure, flip the broadcast to
failed + failed_count=total + throw so handleSend's toast fires.
* Webhook status guard ordered "failed" above "replied", which
meant a webhook replay delivering "failed" for an already-
replied recipient would upgrade replied → failed. Split the
ladder: forward-only ordering across pending → sent →
delivered → read → replied; failed is a terminal side branch
that can only be reached from pending or sent (never from any
success state). Net effect: a message that was read can no
longer regress to failed on a replay.
* Template sync only pulled page 1 (ignored paging.next). Users
with >100 templates silently missed some, and broadcasts with
those names failed with Meta's "template not found". Now
follows paging.next with a 20-page safety cap, reports
truncated=true in the response if hit.
* Template sync reported success:true even when individual
upserts failed. Now accumulates per-template errors and
surfaces them via a separate toast.error in the UI — green
toast no longer hides silent drift.
Performance
* Migration 005: replace the broadcast_recipients aggregate
trigger installed in migration 003. Old version ran
COUNT(*) FILTER on every row change — O(n²) across a send
loop (10k recipients × 10k index scans). New version
increments / decrements by ±1 per status transition: O(1)
per change. Kept the old recompute_broadcast_counts() as a
callable safety-net in case of drift.
* List page poll kept running while the tab was hidden. Added
visibilitychange handler — polling pauses when hidden,
resumes (and does an immediate fetch) on refocus.
UX
* Step 3 (Personalize) let users skip past unmapped placeholders
→ broadcasts then sent to Meta with empty-string params.
Next is now disabled until every {{N}} has a value; an amber
warning lists the missing placeholders.
Documented accepted risks in the PR body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
254 lines
7.4 KiB
TypeScript
254 lines
7.4 KiB
TypeScript
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)
|
|
|
|
// Paginate through every template Meta has for this WABA. Meta
|
|
// returns at most 100 per page; `paging.next` is a full URL. Cap
|
|
// at 20 pages (2k templates) as a safety against infinite loops
|
|
// from a misbehaving upstream.
|
|
const metaTemplates: MetaTemplate[] = []
|
|
let nextUrl:
|
|
| string
|
|
| null = `${META_API_BASE}/${config.waba_id}/message_templates?limit=100&fields=id,name,language,status,category,components`
|
|
const PAGE_CAP = 20
|
|
let pageCount = 0
|
|
|
|
while (nextUrl && pageCount < PAGE_CAP) {
|
|
pageCount++
|
|
const metaRes: Response = await fetch(nextUrl, {
|
|
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[]
|
|
paging?: { next?: string }
|
|
} = await metaRes.json()
|
|
if (metaBody.data) metaTemplates.push(...metaBody.data)
|
|
nextUrl = metaBody.paging?.next ?? null
|
|
}
|
|
|
|
// 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
|
|
const errors: { name: string; language: string; message: string }[] = []
|
|
|
|
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, error: lookupErr } = await supabase
|
|
.from('message_templates')
|
|
.select('id')
|
|
.eq('user_id', user.id)
|
|
.eq('name', t.name)
|
|
.eq('language', t.language)
|
|
.maybeSingle()
|
|
|
|
if (lookupErr) {
|
|
errors.push({
|
|
name: t.name,
|
|
language: t.language,
|
|
message: lookupErr.message,
|
|
})
|
|
continue
|
|
}
|
|
|
|
if (existing?.id) {
|
|
const { error: updErr } = await supabase
|
|
.from('message_templates')
|
|
.update(row)
|
|
.eq('id', existing.id)
|
|
if (updErr) {
|
|
errors.push({
|
|
name: t.name,
|
|
language: t.language,
|
|
message: updErr.message,
|
|
})
|
|
} else {
|
|
updated++
|
|
}
|
|
} else {
|
|
const { error: insErr } = await supabase
|
|
.from('message_templates')
|
|
.insert(row)
|
|
if (insErr) {
|
|
errors.push({
|
|
name: t.name,
|
|
language: t.language,
|
|
message: insErr.message,
|
|
})
|
|
} else {
|
|
inserted++
|
|
}
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: errors.length === 0,
|
|
total: metaTemplates.length,
|
|
inserted,
|
|
updated,
|
|
errors,
|
|
truncated: pageCount >= PAGE_CAP && nextUrl !== null,
|
|
})
|
|
} catch (error) {
|
|
console.error('Error syncing WhatsApp templates:', error)
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
error instanceof Error ? error.message : 'Failed to sync templates',
|
|
},
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
}
|