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 }