wacrm/src/app/api/whatsapp/webhook/route.ts
Arnas Donauskas ffdf2d1c80
fix: webhook message insert uses wrong schema field names
When a WhatsApp message arrives, the webhook creates/updates the contact
and conversation fine (so the conversation appears in the Inbox), but
every message INSERT fails silently because the field names don't match
the messages table schema:

  Webhook wrote              Schema has
  -----------------          ----------
  user_id                    (doesn't exist)
  whatsapp_message_id        message_id
  direction: 'inbound'       sender_type ('customer' | 'agent' | 'bot')
  message_type               content_type
  media_type                 (doesn't exist)
  status: 'received'         status ('sending'|'sent'|'delivered'|'read'|'failed')
  timestamp                  created_at

The insert error was caught and logged with console.error but silently
swallowed — the conversation update succeeded (its fields were right),
so the Inbox showed the contact and last message text, but opening the
thread showed zero messages. The 24-hour session timer also read
"no customer messages ever" and immediately marked the session expired.

Fixes in src/app/api/whatsapp/webhook/route.ts:
- processMessage() — message insert now uses the correct schema columns:
    sender_type: 'customer'
    content_type: message.type
    message_id: message.id
    status: 'delivered'
    created_at: <ISO from Meta timestamp>
- handleStatusUpdate() — lookup uses message_id (not whatsapp_message_id)
  and no longer sets updated_at (column doesn't exist on messages).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:03:08 +03:00

456 lines
12 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'
// 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 }
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) {
// Always return 200 immediately to acknowledge receipt
const body = await request.json()
// Process asynchronously
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
)
}
}
}
}
async function handleStatusUpdate(status: {
id: string
status: string
timestamp: string
recipient_id: string
}) {
// The messages table schema uses `message_id` (not whatsapp_message_id)
// and has no `updated_at` column. Meta's status values (sent/delivered/
// read/failed) already match our CHECK constraint.
const { error } = await supabaseAdmin()
.from('messages')
.update({ status: status.status })
.eq('message_id', status.id)
if (error) {
console.error('Error updating message status:', error)
}
}
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
const { error: msgError } = await supabaseAdmin().from('messages').insert({
conversation_id: conversation.id,
sender_type: 'customer',
content_type: message.type,
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)
}
}
async function parseMessageContent(
message: WhatsAppMessage,
accessToken: string
): Promise<{
contentText: string | null
mediaUrl: string | null
mediaType: string | null
}> {
switch (message.type) {
case 'text':
return {
contentText: message.text?.body || null,
mediaUrl: null,
mediaType: null,
}
case 'image':
if (message.image) {
try {
await getMediaUrl(accessToken, message.image.id)
return {
contentText: message.image.caption || null,
mediaUrl: `/api/whatsapp/media/${message.image.id}`,
mediaType: message.image.mime_type,
}
} catch (error) {
console.error('Error verifying image media:', error)
}
}
return { contentText: message.image?.caption || null, mediaUrl: null, mediaType: null }
case 'video':
if (message.video) {
try {
await getMediaUrl(accessToken, message.video.id)
return {
contentText: message.video.caption || null,
mediaUrl: `/api/whatsapp/media/${message.video.id}`,
mediaType: message.video.mime_type,
}
} catch (error) {
console.error('Error verifying video media:', error)
}
}
return { contentText: message.video?.caption || null, mediaUrl: null, mediaType: null }
case 'document':
if (message.document) {
try {
await getMediaUrl(accessToken, message.document.id)
return {
contentText: message.document.caption || message.document.filename || null,
mediaUrl: `/api/whatsapp/media/${message.document.id}`,
mediaType: message.document.mime_type,
}
} catch (error) {
console.error('Error verifying document media:', error)
}
}
return { contentText: message.document?.filename || null, mediaUrl: null, mediaType: null }
case 'audio':
if (message.audio) {
try {
await getMediaUrl(accessToken, message.audio.id)
return {
contentText: null,
mediaUrl: `/api/whatsapp/media/${message.audio.id}`,
mediaType: message.audio.mime_type,
}
} catch (error) {
console.error('Error verifying audio media:', error)
}
}
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
}