diff --git a/src/app/(dashboard)/broadcasts/page.tsx b/src/app/(dashboard)/broadcasts/page.tsx index 491b8dd..2140ca1 100644 --- a/src/app/(dashboard)/broadcasts/page.tsx +++ b/src/app/(dashboard)/broadcasts/page.tsx @@ -90,18 +90,38 @@ export default function BroadcastsPage() { ); useEffect(() => { - // Start / stop the poller based on whether any row is sending. - if (anySending && !pollTimer.current) { + function startPolling() { + if (pollTimer.current) return; pollTimer.current = setInterval(fetchBroadcasts, POLL_INTERVAL_MS); - } else if (!anySending && pollTimer.current) { + } + function stopPolling() { + if (!pollTimer.current) return; clearInterval(pollTimer.current); pollTimer.current = null; } - return () => { - if (pollTimer.current) { - clearInterval(pollTimer.current); - pollTimer.current = null; + + // Pause polling while the tab is hidden — keeps Supabase cold when + // the user is away, and ensures a fresh fetch the moment they + // refocus so they don't see stale data on return. + function handleVisibilityChange() { + if (!anySending) return; + if (document.visibilityState === 'hidden') { + stopPolling(); + } else { + fetchBroadcasts(); + startPolling(); } + } + + if (anySending && document.visibilityState === 'visible') { + startPolling(); + } else { + stopPolling(); + } + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + stopPolling(); + document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [anySending]); diff --git a/src/app/api/whatsapp/templates/sync/route.ts b/src/app/api/whatsapp/templates/sync/route.ts index 6e5ee03..5b23ac0 100644 --- a/src/app/api/whatsapp/templates/sync/route.ts +++ b/src/app/api/whatsapp/templates/sync/route.ts @@ -125,33 +125,47 @@ export async function POST() { 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}` }, - }) + // 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 - 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 + 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 }) } - return NextResponse.json({ error: metaErr }, { status: 502 }) - } - const metaBody: { data?: MetaTemplate[] } = await metaRes.json() - const metaTemplates = metaBody.data ?? [] + 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') @@ -171,7 +185,7 @@ export async function POST() { updated_at: new Date().toISOString(), } - const { data: existing } = await supabase + const { data: existing, error: lookupErr } = await supabase .from('message_templates') .select('id') .eq('user_id', user.id) @@ -179,23 +193,52 @@ export async function POST() { .eq('language', t.language) .maybeSingle() + if (lookupErr) { + errors.push({ + name: t.name, + language: t.language, + message: lookupErr.message, + }) + continue + } + if (existing?.id) { - await supabase + const { error: updErr } = await supabase .from('message_templates') .update(row) .eq('id', existing.id) - updated++ + if (updErr) { + errors.push({ + name: t.name, + language: t.language, + message: updErr.message, + }) + } else { + updated++ + } } else { - await supabase.from('message_templates').insert(row) - inserted++ + 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: true, + 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) diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 36da8e9..37c0d2a 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -3,6 +3,7 @@ import { createClient } from '@supabase/supabase-js' import { decrypt } from '@/lib/whatsapp/encryption' import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api' import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils' +import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature' // Lazy-initialized to avoid build-time crash when env vars are missing // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -120,10 +121,27 @@ export async function GET(request: Request) { // POST - Receive messages export async function POST(request: Request) { - // Always return 200 immediately to acknowledge receipt - const body = await request.json() + // Read raw body first so we can HMAC-verify the exact bytes Meta + // signed. request.json() would re-encode and break the signature. + const rawBody = await request.text() + const signature = request.headers.get('x-hub-signature-256') - // Process asynchronously + if (!verifyMetaWebhookSignature(rawBody, signature)) { + // 401 (not 200) — we want Meta's delivery dashboard to show failures + // loudly if a misconfiguration causes signatures to stop matching, + // rather than silently eating events. + console.warn('[webhook] rejected request with invalid signature') + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }) + } + + let body: { entry?: WhatsAppWebhookEntry[] } + try { + body = JSON.parse(rawBody) + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + // Process asynchronously so we can ack Meta within their timeout. processWebhook(body).catch((error) => { console.error('Error processing webhook:', error) }) @@ -179,20 +197,46 @@ async function processWebhook(body: { entry?: WhatsAppWebhookEntry[] }) { } } -// Ordered forward-only: a webhook replay must never regress a recipient -// from `read` back to `sent`. Index into this array gives the "level". -const RECIPIENT_STATUS_ORDER = [ +// The happy-path status ladder — pending → sent → delivered → read → +// replied. Webhook replays must never regress a recipient back down +// this ladder. +// +// `failed` is NOT on this ladder. It's a terminal side branch that is +// only valid from the early states (pending / sent) — once Meta has +// delivered or the user has read or replied, a later "failed" status +// event is a bug in Meta's pipeline or a spoof attempt and must be +// ignored. +const RECIPIENT_STATUS_LADDER = [ 'pending', 'sent', 'delivered', 'read', 'replied', - 'failed', ] as const -function recipientStatusLevel(s: string): number { - const idx = (RECIPIENT_STATUS_ORDER as readonly string[]).indexOf(s) - return idx < 0 ? 0 : idx +function ladderLevel(s: string): number { + const idx = (RECIPIENT_STATUS_LADDER as readonly string[]).indexOf(s) + return idx < 0 ? -1 : idx +} + +/** + * Can a recipient transition from `current` to `incoming`? + * - Along the ladder, only forward moves are allowed. + * - `failed` is accepted only from `pending` or `sent`; it's refused + * once the recipient has reached any of the success states. + */ +function isValidStatusTransition(current: string, incoming: string): boolean { + if (incoming === 'failed') { + return current === 'pending' || current === 'sent' + } + if (current === 'failed') { + return false // failed is terminal + } + const ci = ladderLevel(current) + const ii = ladderLevel(incoming) + if (ii < 0) return false // unknown incoming status + if (ci < 0) return true // unknown current — accept anything on the ladder + return ii > ci } async function handleStatusUpdate(status: { @@ -230,10 +274,9 @@ async function handleStatusUpdate(status: { } if (!recipient) return // message wasn't part of a broadcast — fine - // Forward-only status guard: only advance. - const currentLevel = recipientStatusLevel(recipient.status) - const incomingLevel = recipientStatusLevel(status.status) - if (incomingLevel <= currentLevel) return + // Guard transitions — forward-only on the success ladder, and + // `failed` only from pre-delivered states. + if (!isValidStatusTransition(recipient.status, status.status)) return const update: Record = { status: status.status } if (status.status === 'sent' && !('sent_at' in update)) update.sent_at = tsIso diff --git a/src/components/broadcasts/step3-personalize.tsx b/src/components/broadcasts/step3-personalize.tsx index d8ec22b..46ae2f5 100644 --- a/src/components/broadcasts/step3-personalize.tsx +++ b/src/components/broadcasts/step3-personalize.tsx @@ -111,6 +111,24 @@ export function Step3Personalize({ return [...new Set(matches)].sort(); }, [template.body_text]); + /** + * A placeholder is "unmapped" if the user hasn't picked either a + * static value or a field/custom-field source. Blocks Next until + * every placeholder has something — otherwise the broadcast would + * ship with empty strings and confuse recipients. + */ + const unmappedKeys = useMemo(() => { + const missing: string[] = []; + for (const placeholder of placeholders) { + const key = placeholder.replace(/^\{\{|\}\}$/g, ''); + const mapping = variables[key]; + if (!mapping || !mapping.value?.trim()) { + missing.push(placeholder); + } + } + return missing; + }, [placeholders, variables]); + function updateVariable(key: string, patch: Partial) { const current = variables[key] ?? { type: 'static' as VariableType, value: '' }; onUpdate({ @@ -311,6 +329,16 @@ export function Step3Personalize({ + {unmappedKeys.length > 0 && ( +
+ Map every placeholder before continuing — still missing{' '} + + {unmappedKeys.join(', ')} + + . Otherwise those placeholders will ship to Meta as empty strings. +
+ )} +