wacrm/src/app/api/whatsapp/webhook/route.ts
Arnas Donauskas 66dd4ef97e
fix(broadcasts): senior-review hardening pass
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>
2026-04-17 17:38:52 +03:00

620 lines
18 KiB
TypeScript

import { NextResponse } from 'next/server'
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
let _adminClient: any = null
function supabaseAdmin() {
if (!_adminClient) {
_adminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
}
return _adminClient
}
interface WhatsAppMessage {
id: string
from: string
timestamp: string
type: string
text?: { body: string }
image?: { id: string; mime_type: string; caption?: string }
video?: { id: string; mime_type: string; caption?: string }
document?: { id: string; mime_type: string; filename?: string; caption?: string }
audio?: { id: string; mime_type: string }
sticker?: { id: string; mime_type: string }
location?: { latitude: number; longitude: number; name?: string; address?: string }
reaction?: { message_id: string; emoji: string }
}
interface WhatsAppWebhookEntry {
id: string
changes: Array<{
value: {
messaging_product: string
metadata: {
display_phone_number: string
phone_number_id: string
}
contacts?: Array<{
profile: { name: string }
wa_id: string
}>
messages?: WhatsAppMessage[]
statuses?: Array<{
id: string
status: string
timestamp: string
recipient_id: string
}>
}
field: string
}>
}
// GET - Webhook verification
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const mode = searchParams.get('hub.mode')
const challenge = searchParams.get('hub.challenge')
const verifyToken = searchParams.get('hub.verify_token')
if (mode !== 'subscribe' || !challenge || !verifyToken) {
return NextResponse.json(
{ error: 'Missing verification parameters' },
{ status: 400 }
)
}
// Fetch all whatsapp configs to check verify tokens
const { data: configs, error: configError } = await supabaseAdmin()
.from('whatsapp_config')
.select('id, verify_token')
if (configError || !configs) {
console.error('Error fetching configs for verification:', configError)
return NextResponse.json(
{ error: 'Verification failed' },
{ status: 403 }
)
}
// Check if any config's verify_token matches
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const matched = configs.some((config: any) => {
if (!config.verify_token) return false
try {
const decrypted = decrypt(config.verify_token)
return decrypted === verifyToken
} catch {
return false
}
})
if (matched) {
// Return challenge as plain text
return new Response(challenge, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
})
}
return NextResponse.json(
{ error: 'Verification token mismatch' },
{ status: 403 }
)
} catch (error) {
console.error('Error in webhook GET verification:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
// POST - Receive messages
export async function POST(request: Request) {
// 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')
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)
})
return NextResponse.json({ status: 'received' }, { status: 200 })
}
async function processWebhook(body: { entry?: WhatsAppWebhookEntry[] }) {
if (!body.entry) return
for (const entry of body.entry) {
for (const change of entry.changes) {
const value = change.value
// Handle status updates
if (value.statuses) {
for (const status of value.statuses) {
await handleStatusUpdate(status)
}
}
// Handle incoming messages
if (!value.messages || !value.contacts) continue
const phoneNumberId = value.metadata.phone_number_id
// Find user's config by phone_number_id
const { data: config, error: configError } = await supabaseAdmin()
.from('whatsapp_config')
.select('*')
.eq('phone_number_id', phoneNumberId)
.single()
if (configError || !config) {
console.error('No config found for phone_number_id:', phoneNumberId)
continue
}
const decryptedAccessToken = decrypt(config.access_token)
for (let i = 0; i < value.messages.length; i++) {
const message = value.messages[i]
const contact = value.contacts[i] || value.contacts[0]
await processMessage(
message,
contact,
config.user_id,
decryptedAccessToken
)
}
}
}
}
// 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',
] as const
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: {
id: string
status: string
timestamp: string
recipient_id: string
}) {
// 1) Mirror onto messages (legacy behavior) — Meta's status values
// already match the CHECK constraint on messages.status.
const { error: msgErr } = await supabaseAdmin()
.from('messages')
.update({ status: status.status })
.eq('message_id', status.id)
if (msgErr) {
console.error('Error updating message status:', msgErr)
}
// 2) Mirror onto broadcast_recipients via whatsapp_message_id
// (added in migration 003). The aggregate trigger on
// broadcast_recipients re-derives the parent broadcast's
// sent/delivered/read/failed counts automatically.
const tsIso = new Date(parseInt(status.timestamp) * 1000).toISOString()
const { data: recipient, error: recFetchErr } = await supabaseAdmin()
.from('broadcast_recipients')
.select('id, status')
.eq('whatsapp_message_id', status.id)
.maybeSingle()
if (recFetchErr) {
console.error('Error fetching broadcast recipient:', recFetchErr)
return
}
if (!recipient) return // message wasn't part of a broadcast — fine
// 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<string, unknown> = { status: status.status }
if (status.status === 'sent' && !('sent_at' in update)) update.sent_at = tsIso
if (status.status === 'delivered') update.delivered_at = tsIso
if (status.status === 'read') update.read_at = tsIso
const { error: recUpdateErr } = await supabaseAdmin()
.from('broadcast_recipients')
.update(update)
.eq('id', recipient.id)
if (recUpdateErr) {
console.error('Error updating broadcast recipient status:', recUpdateErr)
}
}
/**
* If an inbound message's sender is on a still-unreplied
* broadcast_recipients row, flip it to `replied` so the reply count
* advances on the parent broadcast.
*
* Runs on a best-effort basis — failures here must not break the
* main inbound-message flow, so errors are swallowed with a log.
*/
async function flagBroadcastReplyIfAny(userId: string, contactId: string) {
try {
// Most recent outbound broadcast that hasn't been replied to yet.
const { data: recs, error } = await supabaseAdmin()
.from('broadcast_recipients')
.select('id, status, broadcast_id, broadcasts!inner(user_id)')
.eq('contact_id', contactId)
.eq('broadcasts.user_id', userId)
.in('status', ['sent', 'delivered', 'read'])
.order('created_at', { ascending: false })
.limit(1)
if (error || !recs || recs.length === 0) return
const row = recs[0]
const { error: updErr } = await supabaseAdmin()
.from('broadcast_recipients')
.update({ status: 'replied', replied_at: new Date().toISOString() })
.eq('id', row.id)
if (updErr) {
console.error('Error marking broadcast recipient replied:', updErr)
}
} catch (err) {
console.error('flagBroadcastReplyIfAny failed:', err)
}
}
async function processMessage(
message: WhatsAppMessage,
contact: { profile: { name: string }; wa_id: string },
userId: string,
accessToken: string
) {
const senderPhone = normalizePhone(message.from)
const contactName = contact.profile.name
// Parse message content based on type
const { contentText, mediaUrl, mediaType } = await parseMessageContent(
message,
accessToken
)
// Find or create contact
const contactRecord = await findOrCreateContact(
userId,
senderPhone,
contactName
)
if (!contactRecord) return
// Find or create conversation
const conversation = await findOrCreateConversation(
userId,
contactRecord.id
)
if (!conversation) return
// Insert message — field names MUST match the messages table schema
// (see supabase/migrations/001_initial_schema.sql):
// conversation_id, sender_type, content_type, content_text,
// media_url, template_name, message_id, status, created_at
// `mediaType` is intentionally unused — the schema has no media_type
// column; the MIME type is only used to construct the proxy URL during
// parseMessageContent. Silence the unused-var warning:
void mediaType
// The messages.content_type CHECK constraint only allows:
// text, image, document, audio, video, location, template
// Map incoming WhatsApp types that aren't in that list to the closest
// allowed value so the INSERT doesn't fail with a constraint error.
const ALLOWED_CONTENT_TYPES = new Set([
'text', 'image', 'document', 'audio', 'video', 'location', 'template',
])
const contentType = ALLOWED_CONTENT_TYPES.has(message.type)
? message.type
: message.type === 'sticker'
? 'image' // stickers are images
: 'text' // reaction, unknown → text fallback
const { error: msgError } = await supabaseAdmin().from('messages').insert({
conversation_id: conversation.id,
sender_type: 'customer',
content_type: contentType,
content_text: contentText,
media_url: mediaUrl,
message_id: message.id,
status: 'delivered',
created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(),
})
if (msgError) {
console.error('Error inserting message:', msgError)
return
}
// Update conversation
const { error: convError } = await supabaseAdmin()
.from('conversations')
.update({
last_message_text: contentText || `[${message.type}]`,
last_message_at: new Date().toISOString(),
unread_count: (conversation.unread_count || 0) + 1,
updated_at: new Date().toISOString(),
})
.eq('id', conversation.id)
if (convError) {
console.error('Error updating conversation:', convError)
}
// If this contact was a recent broadcast recipient, flag the reply
// so the broadcast's `replied_count` advances (via the aggregate
// trigger installed in migration 003).
await flagBroadcastReplyIfAny(userId, contactRecord.id)
}
async function parseMessageContent(
message: WhatsAppMessage,
accessToken: string
): Promise<{
contentText: string | null
mediaUrl: string | null
mediaType: string | null
}> {
// getMediaUrl signature is (mediaId, accessToken) — earlier code had
// the args swapped, so every verification hit an invalid Meta URL and
// fell through to the catch block, leaving mediaUrl as null. That's
// why images showed up as empty bubbles in the inbox.
const verifyAndBuildUrl = async (
mediaId: string
): Promise<string | null> => {
try {
await getMediaUrl({ mediaId, accessToken })
return `/api/whatsapp/media/${mediaId}`
} catch (error) {
console.error(
`Failed to verify media ${mediaId} with Meta:`,
error instanceof Error ? error.message : error
)
return null
}
}
switch (message.type) {
case 'text':
return {
contentText: message.text?.body || null,
mediaUrl: null,
mediaType: null,
}
case 'image':
if (message.image?.id) {
return {
contentText: message.image.caption || null,
mediaUrl: await verifyAndBuildUrl(message.image.id),
mediaType: message.image.mime_type,
}
}
return { contentText: null, mediaUrl: null, mediaType: null }
case 'video':
if (message.video?.id) {
return {
contentText: message.video.caption || null,
mediaUrl: await verifyAndBuildUrl(message.video.id),
mediaType: message.video.mime_type,
}
}
return { contentText: null, mediaUrl: null, mediaType: null }
case 'document':
if (message.document?.id) {
return {
contentText:
message.document.caption || message.document.filename || null,
mediaUrl: await verifyAndBuildUrl(message.document.id),
mediaType: message.document.mime_type,
}
}
return { contentText: null, mediaUrl: null, mediaType: null }
case 'audio':
if (message.audio?.id) {
return {
contentText: null,
mediaUrl: await verifyAndBuildUrl(message.audio.id),
mediaType: message.audio.mime_type,
}
}
return { contentText: null, mediaUrl: null, mediaType: null }
case 'sticker':
// Stickers are images under the hood. Treat them as such so the
// MessageBubble renders the <img>. The caller maps the DB
// content_type to 'image' for the CHECK constraint.
if (message.sticker?.id) {
return {
contentText: null,
mediaUrl: await verifyAndBuildUrl(message.sticker.id),
mediaType: message.sticker.mime_type,
}
}
return { contentText: null, mediaUrl: null, mediaType: null }
case 'location':
if (message.location) {
const loc = message.location
const locationText = [loc.name, loc.address, `${loc.latitude},${loc.longitude}`]
.filter(Boolean)
.join(' - ')
return {
contentText: locationText,
mediaUrl: null,
mediaType: null,
}
}
return { contentText: null, mediaUrl: null, mediaType: null }
case 'reaction':
return {
contentText: message.reaction?.emoji || null,
mediaUrl: null,
mediaType: null,
}
default:
return {
contentText: `[Unsupported message type: ${message.type}]`,
mediaUrl: null,
mediaType: null,
}
}
}
async function findOrCreateContact(
userId: string,
phone: string,
name: string
) {
// Look up existing contacts for this user
const { data: contacts, error: contactsError } = await supabaseAdmin()
.from('contacts')
.select('*')
.eq('user_id', userId)
if (contactsError) {
console.error('Error fetching contacts:', contactsError)
return null
}
// Use phonesMatch for flexible matching
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const existingContact = contacts?.find((c: any) => phonesMatch(c.phone, phone))
if (existingContact) {
// Update name if it changed
if (name && name !== existingContact.name) {
await supabaseAdmin()
.from('contacts')
.update({ name, updated_at: new Date().toISOString() })
.eq('id', existingContact.id)
}
return existingContact
}
// Create new contact
const { data: newContact, error: createError } = await supabaseAdmin()
.from('contacts')
.insert({
user_id: userId,
phone,
name: name || phone,
})
.select()
.single()
if (createError) {
console.error('Error creating contact:', createError)
return null
}
return newContact
}
async function findOrCreateConversation(userId: string, contactId: string) {
// Look for existing conversation
const { data: existing, error: findError } = await supabaseAdmin()
.from('conversations')
.select('*')
.eq('user_id', userId)
.eq('contact_id', contactId)
.single()
if (!findError && existing) {
return existing
}
// Create new conversation
const { data: newConv, error: createError } = await supabaseAdmin()
.from('conversations')
.insert({
user_id: userId,
contact_id: contactId,
})
.select()
.single()
if (createError) {
console.error('Error creating conversation:', createError)
return null
}
return newConv
}