From cf706a8c9ddd06f6d35e2cc89246d08762b0333f Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Thu, 16 Apr 2026 21:21:01 +0300 Subject: [PATCH] fix: outbound messages silently failing (wrong Meta args + schema) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two silent bugs in the send pipeline that made agent messages never reach WhatsApp AND disappear on page reload: 1. /api/whatsapp/send passed Meta API args in the wrong order. The helpers have signature (phoneNumberId, accessToken, to, ...) but the route was calling sendTextMessage(accessToken, phoneNumberId, to, text). So the request went to POST https://graph.facebook.com/v21.0//messages with Authorization: Bearer which Meta rejects. The try block caught the error and the outer catch returned a generic "Failed to send message" with no detail. 2. Even if Meta had succeeded, the DB insert would have failed because the route was using field names that don't exist in the messages table — same class of bug as the webhook had before PR #10: user_id → not a column on messages whatsapp_message_id → should be message_id direction → should be sender_type ('customer'|'agent'|'bot') message_type → should be content_type timestamp → should be created_at Insert would have failed, DB row would never exist, and the optimistic message in the UI would vanish on the next page load / fetchMessages. 3. The frontend swallowed errors silently (console.error only), so the UI looked like the message had been sent even when nothing reached Meta and nothing was saved. Changes: src/app/api/whatsapp/send/route.ts - Swap Meta API args to the correct order (phoneNumberId first, then accessToken) - Wrap the Meta call in its own try/catch so the error message from Meta bubbles up to the client (returned as 502 with the actual text) - Fix messages insert to use schema-correct columns: sender_type, content_type, content_text, media_url, template_name, message_id, status. Dropped non-existent user_id/direction/message_type/timestamp. - Also surface the underlying DB error if the insert fails. src/components/inbox/message-thread.tsx - Added onUpdateMessage prop. - handleSend parses the error JSON, toasts the actual reason, and flips the optimistic bubble's status to 'failed' so MessageBubble renders the red XCircle. On success flips to 'sent' immediately (realtime INSERT still replaces the temp bubble with the real row). src/app/(dashboard)/inbox/page.tsx - Added handleUpdateMessage that mutates one message by id. - Passed as onUpdateMessage to MessageThread. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/(dashboard)/inbox/page.tsx | 10 ++++ src/app/api/whatsapp/send/route.ts | 63 ++++++++++++++++--------- src/components/inbox/message-thread.tsx | 28 +++++++++-- 3 files changed, 74 insertions(+), 27 deletions(-) diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index 61968cd..6779558 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -156,6 +156,15 @@ export default function InboxPage() { }); }, []); + const handleUpdateMessage = useCallback( + (id: string, updates: Partial) => { + setMessages((prev) => + prev.map((m) => (m.id === id ? { ...m, ...updates } : m)) + ); + }, + [] + ); + const handleStatusChange = useCallback( (conversationId: string, status: ConversationStatus) => { setConversations((prev) => @@ -197,6 +206,7 @@ export default function InboxPage() { messages={messages} onMessagesLoaded={handleMessagesLoaded} onNewMessage={handleNewMessage} + onUpdateMessage={handleUpdateMessage} onStatusChange={handleStatusChange} /> diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index 7823280..b4cc04c 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -100,40 +100,57 @@ export async function POST(request: Request) { const accessToken = decrypt(config.access_token) // Send via Meta API + // Function signatures are: + // sendTextMessage(phoneNumberId, accessToken, to, text) + // sendTemplateMessage(phoneNumberId, accessToken, to, templateName, language?, params?) + // The args were previously swapped (accessToken before phoneNumberId), + // causing Meta to reject every send. let waMessageId: string - if (message_type === 'template') { - const result = await sendTemplateMessage( - accessToken, - config.phone_number_id, - sanitizedPhone, - template_name, - template_params || [] + try { + if (message_type === 'template') { + const result = await sendTemplateMessage( + config.phone_number_id, + accessToken, + sanitizedPhone, + template_name, + undefined, // use default language (en_US) + template_params || [] + ) + waMessageId = result.messageId + } else { + const result = await sendTextMessage( + config.phone_number_id, + accessToken, + sanitizedPhone, + content_text + ) + waMessageId = result.messageId + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown Meta API error' + console.error('Meta API send failed:', message) + return NextResponse.json( + { error: `Meta API error: ${message}` }, + { status: 502 } ) - waMessageId = result.messageId - } else { - const result = await sendTextMessage( - accessToken, - config.phone_number_id, - sanitizedPhone, - content_text - ) - waMessageId = result.messageId } - // Insert message into DB + // Insert message into DB — field names MUST match the messages 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 const { data: messageRecord, error: msgError } = await supabase .from('messages') .insert({ conversation_id, - user_id: user.id, - whatsapp_message_id: waMessageId, - direction: 'outbound', - message_type, + sender_type: 'agent', + content_type: message_type, content_text: content_text || null, media_url: media_url || null, + template_name: template_name || null, + message_id: waMessageId, status: 'sent', - timestamp: new Date().toISOString(), }) .select() .single() @@ -141,7 +158,7 @@ export async function POST(request: Request) { if (msgError) { console.error('Error inserting sent message:', msgError) return NextResponse.json( - { error: 'Message sent but failed to save to database' }, + { error: `Message sent to Meta but failed to save to DB: ${msgError.message}` }, { status: 500 } ) } diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index 439b29f..f499428 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -22,6 +22,7 @@ import { import { ScrollArea } from "@/components/ui/scroll-area"; import { MessageBubble } from "./message-bubble"; import { MessageComposer } from "./message-composer"; +import { toast } from "sonner"; interface MessageThreadProps { conversation: Conversation | null; @@ -29,6 +30,7 @@ interface MessageThreadProps { messages: Message[]; onMessagesLoaded: (messages: Message[]) => void; onNewMessage: (message: Message) => void; + onUpdateMessage: (id: string, updates: Partial) => void; onStatusChange: (conversationId: string, status: ConversationStatus) => void; } @@ -68,6 +70,7 @@ export function MessageThread({ messages, onMessagesLoaded, onNewMessage, + onUpdateMessage, onStatusChange, }: MessageThreadProps) { const [loading, setLoading] = useState(false); @@ -169,9 +172,11 @@ export function MessageThread({ async (text: string) => { if (!conversation) return; - // Optimistic update + const tempId = `temp-${Date.now()}`; + + // Optimistic update — shows the message immediately with "sending" status const optimisticMsg: Message = { - id: `temp-${Date.now()}`, + id: tempId, conversation_id: conversation.id, sender_type: "agent", content_type: "text", @@ -192,14 +197,29 @@ export function MessageThread({ }), }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) { - console.error("Failed to send message"); + const reason = payload?.error || `HTTP ${res.status}`; + console.error("Failed to send message:", reason); + toast.error(`Failed to send: ${reason}`); + // Mark the optimistic bubble as failed so the user sees what happened + onUpdateMessage(tempId, { status: "failed" }); + return; } + + // Success — the realtime INSERT event will replace the temp bubble + // with the real DB row. If realtime hasn't arrived yet, at least + // flip status to 'sent' so the UI stops showing "sending". + onUpdateMessage(tempId, { status: "sent" }); } catch (err) { console.error("Failed to send message:", err); + const reason = err instanceof Error ? err.message : "network error"; + toast.error(`Failed to send: ${reason}`); + onUpdateMessage(tempId, { status: "failed" }); } }, - [conversation, onNewMessage] + [conversation, onNewMessage, onUpdateMessage] ); const handleStatusChange = useCallback(