mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
456 lines
12 KiB
TypeScript
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
|
|
}
|