From 870e25f0ba276bc88394b2e0e3241ce2eb8ea98b Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Thu, 16 Apr 2026 21:43:07 +0300 Subject: [PATCH] fix: media messages (image/video/audio/document/sticker) now render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incoming photos showed up as empty bubbles in the Inbox (only the timestamp, no image). Investigation found three compounding bugs in the media pipeline: 1. Swapped getMediaUrl args in webhook parseMessageContent The helper signature is getMediaUrl(mediaId, accessToken), but every media case (image/video/document/audio) called it as getMediaUrl(accessToken, message.X.id). Meta got requests for GET /v21.0/ with Authorization: Bearer Every verification failed → the catch block returned mediaUrl: null → the message was stored in the DB with no media_url → MessageBubble had nothing to render. Refactored to a single verifyAndBuildUrl(mediaId) helper called per media type, with the arguments in the correct order. 2. No fallback UI when media_url is null MessageBubble's image/video/audio/document cases all did `{message.media_url && }` with no else branch. So a media message without a URL rendered an empty
— the user saw only the timestamp. Added a component that shows the icon + "Image/Video/Audio/Document unavailable" text when media_url is null. Applied to all four media types. 3. Sticker type would fail the CHECK constraint on insert WhatsApp sends image stickers as type: "sticker", but the messages.content_type CHECK constraint only allows: text, image, document, audio, video, location, template Inserting content_type: 'sticker' silently failed. processMessage now validates content_type against an allow-list before insert. Stickers are mapped to 'image' (they ARE images). Reactions and any other unknown type are mapped to 'text'. Also added the sticker media handling to parseMessageContent so sticker images actually render (with the verified proxy URL). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/whatsapp/webhook/route.ts | 116 +++++++++++++++--------- src/components/inbox/message-bubble.tsx | 26 +++++- 2 files changed, 94 insertions(+), 48 deletions(-) diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 2b0071f..06591e8 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -27,6 +27,7 @@ interface WhatsAppMessage { 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 } } @@ -235,10 +236,24 @@ async function processMessage( // 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: message.type, + content_type: contentType, content_text: contentText, media_url: mediaUrl, message_id: message.id, @@ -275,6 +290,25 @@ async function parseMessageContent( 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 => { + 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 { @@ -284,61 +318,55 @@ async function parseMessageContent( } 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) + if (message.image?.id) { + return { + contentText: message.image.caption || null, + mediaUrl: await verifyAndBuildUrl(message.image.id), + mediaType: message.image.mime_type, } } - return { contentText: message.image?.caption || null, mediaUrl: null, mediaType: null } + return { contentText: 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) + if (message.video?.id) { + return { + contentText: message.video.caption || null, + mediaUrl: await verifyAndBuildUrl(message.video.id), + mediaType: message.video.mime_type, } } - return { contentText: message.video?.caption || null, mediaUrl: null, mediaType: null } + return { contentText: 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) + 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: message.document?.filename || null, mediaUrl: null, mediaType: null } + return { contentText: 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) + 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 . 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 } diff --git a/src/components/inbox/message-bubble.tsx b/src/components/inbox/message-bubble.tsx index 8084188..bdadf53 100644 --- a/src/components/inbox/message-bubble.tsx +++ b/src/components/inbox/message-bubble.tsx @@ -36,6 +36,15 @@ function StatusIcon({ status }: { status: Message["status"] }) { } } +function MediaUnavailable({ label }: { label: string }) { + return ( +
+ + {label} unavailable +
+ ); +} + function MediaImage({ url, alt }: { url: string; alt: string }) { const [src, setSrc] = useState(null); const [error, setError] = useState(false); @@ -111,8 +120,10 @@ function MessageContent({ message }: { message: Message }) { case "image": return (
- {message.media_url && ( + {message.media_url ? ( + ) : ( + )} {message.content_text && (

@@ -125,12 +136,14 @@ function MessageContent({ message }: { message: Message }) { case "video": return (

- {message.media_url && ( + {message.media_url ? (