From f3c68967f17ccf91b8778475c0b49668170f4fa7 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Thu, 16 Apr 2026 21:52:25 +0300 Subject: [PATCH] refactor: Meta API helpers use named params (prevents swapped-args bugs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over the course of the project the same swapped-args bug was found four separate times against the Meta API helpers (config in PR #8, send in PR #12, broadcast in PR #13, webhook media in PR #14). Each time the compiler accepted the call because all the args were strings, so the bug only surfaced at runtime as an opaque Meta error. This refactor eliminates the entire bug class by converting every helper to a single options object. A typo like sendTextMessage({ accessToken: id, phoneNumberId: tok, ... }) is now a TypeScript error at the call site rather than a 400 from Meta two layers deep. Helpers updated in src/lib/whatsapp/meta-api.ts: - verifyPhoneNumber({ phoneNumberId, accessToken }) - sendTextMessage({ phoneNumberId, accessToken, to, text }) - sendTemplateMessage({ phoneNumberId, accessToken, to, templateName, language?, params? }) - getMediaUrl({ mediaId, accessToken }) - downloadMedia({ downloadUrl, accessToken }) Each exported Args interface so routes can type local variables holding partial argument sets (useful for the broadcast retry loop). Also factored the duplicated "parse Meta error JSON" block into a shared throwMetaError() helper — was repeated in 5 places. Call sites updated (all were already correct after prior fixes, but now the types enforce it): - src/app/api/whatsapp/config/route.ts (GET + POST) - src/app/api/whatsapp/send/route.ts (attempt() closure) - src/app/api/whatsapp/broadcast/route.ts (retry loop) - src/app/api/whatsapp/webhook/route.ts (verifyAndBuildUrl) - src/app/api/whatsapp/media/[mediaId]/route.ts (proxy pipeline) No behavior change. Build passes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/whatsapp/broadcast/route.ts | 14 +- src/app/api/whatsapp/config/route.ts | 14 +- src/app/api/whatsapp/media/[mediaId]/route.ts | 7 +- src/app/api/whatsapp/send/route.ts | 23 ++- src/app/api/whatsapp/webhook/route.ts | 2 +- src/lib/whatsapp/meta-api.ts | 173 +++++++++++++----- 6 files changed, 160 insertions(+), 73 deletions(-) diff --git a/src/app/api/whatsapp/broadcast/route.ts b/src/app/api/whatsapp/broadcast/route.ts index 6171d25..265bb14 100644 --- a/src/app/api/whatsapp/broadcast/route.ts +++ b/src/app/api/whatsapp/broadcast/route.ts @@ -95,14 +95,14 @@ export async function POST(request: Request) { for (const variant of variants) { try { - const result = await sendTemplateMessage( - config.phone_number_id, + const result = await sendTemplateMessage({ + phoneNumberId: config.phone_number_id, accessToken, - variant, - template_name, - template_language || 'en_US', - template_params || [] - ) + to: variant, + templateName: template_name, + language: template_language || 'en_US', + params: template_params || [], + }) sentMessageId = result.messageId lastError = null break diff --git a/src/app/api/whatsapp/config/route.ts b/src/app/api/whatsapp/config/route.ts index 52d8721..e0c3375 100644 --- a/src/app/api/whatsapp/config/route.ts +++ b/src/app/api/whatsapp/config/route.ts @@ -75,10 +75,10 @@ export async function GET() { // Validate credentials against Meta try { - const phoneInfo = await verifyPhoneNumber( - config.phone_number_id, - accessToken - ) + const phoneInfo = await verifyPhoneNumber({ + phoneNumberId: config.phone_number_id, + accessToken, + }) return NextResponse.json({ connected: true, phone_info: phoneInfo }) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown Meta API error' @@ -131,10 +131,12 @@ export async function POST(request: Request) { } // Verify credentials with Meta BEFORE saving - // Signature: verifyPhoneNumber(phoneNumberId, accessToken) let phoneInfo try { - phoneInfo = await verifyPhoneNumber(phone_number_id, access_token) + phoneInfo = await verifyPhoneNumber({ + phoneNumberId: phone_number_id, + accessToken: access_token, + }) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown Meta API error' console.error('Meta API verification failed during save:', message) diff --git a/src/app/api/whatsapp/media/[mediaId]/route.ts b/src/app/api/whatsapp/media/[mediaId]/route.ts index 3bfe46d..f5a0a3c 100644 --- a/src/app/api/whatsapp/media/[mediaId]/route.ts +++ b/src/app/api/whatsapp/media/[mediaId]/route.ts @@ -48,10 +48,13 @@ export async function GET( const accessToken = decrypt(config.access_token) // Get the download URL from Meta - const mediaInfo = await getMediaUrl(mediaId, accessToken) + const mediaInfo = await getMediaUrl({ mediaId, accessToken }) // Download the binary data - const { buffer, contentType } = await downloadMedia(mediaInfo.url, accessToken) + const { buffer, contentType } = await downloadMedia({ + downloadUrl: mediaInfo.url, + accessToken, + }) return new Response(new Uint8Array(buffer), { status: 200, diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index f8614fd..090b83e 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -114,22 +114,21 @@ export async function POST(request: Request) { const attempt = async (phone: string): Promise => { if (message_type === 'template') { - const result = await sendTemplateMessage( - config.phone_number_id, + const result = await sendTemplateMessage({ + phoneNumberId: config.phone_number_id, accessToken, - phone, - template_name, - undefined, // default language (en_US) - template_params || [] - ) + to: phone, + templateName: template_name, + params: template_params || [], + }) return result.messageId } - const result = await sendTextMessage( - config.phone_number_id, + const result = await sendTextMessage({ + phoneNumberId: config.phone_number_id, accessToken, - phone, - content_text - ) + to: phone, + text: content_text, + }) return result.messageId } diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 06591e8..aa81d4e 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -298,7 +298,7 @@ async function parseMessageContent( mediaId: string ): Promise => { try { - await getMediaUrl(mediaId, accessToken) + await getMediaUrl({ mediaId, accessToken }) return `/api/whatsapp/media/${mediaId}` } catch (error) { console.error( diff --git a/src/lib/whatsapp/meta-api.ts b/src/lib/whatsapp/meta-api.ts index 1bcbcad..89c1da9 100644 --- a/src/lib/whatsapp/meta-api.ts +++ b/src/lib/whatsapp/meta-api.ts @@ -1,3 +1,14 @@ +/** + * Meta WhatsApp Cloud API helpers. + * + * Every function takes a single options object (named parameters) instead + * of positional arguments. This was a deliberate choice after the same + * swapped-args bug was found four times in a row with the positional form + * (e.g. `(accessToken, phoneNumberId)` vs `(phoneNumberId, accessToken)`). + * With named params, a typo surfaces immediately as a TypeScript error + * instead of a runtime rejection from Meta. + */ + const META_API_VERSION = 'v21.0' const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}` @@ -12,36 +23,73 @@ export interface MetaPhoneInfo { quality_rating?: string } +interface MetaErrorResponse { + error?: { message?: string; code?: number; type?: string } +} + +async function throwMetaError(response: Response, fallback: string): Promise { + let message = fallback + try { + const data = (await response.json()) as MetaErrorResponse + if (data.error?.message) message = data.error.message + } catch { + // response body wasn't JSON — keep the fallback + } + throw new Error(message) +} + +// ============================================================ +// Phone number / account +// ============================================================ + +export interface VerifyPhoneNumberArgs { + phoneNumberId: string + accessToken: string +} + /** - * Verify phone number ID with Meta Graph API + * Verify a Meta phone number ID by fetching its public metadata + * (display_phone_number, verified_name, quality_rating). */ -export async function verifyPhoneNumber(phoneNumberId: string, accessToken: string): Promise { +export async function verifyPhoneNumber( + args: VerifyPhoneNumberArgs +): Promise { + const { phoneNumberId, accessToken } = args const url = `${META_API_BASE}/${phoneNumberId}?fields=id,display_phone_number,verified_name,quality_rating` const response = await fetch(url, { - headers: { 'Authorization': `Bearer ${accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, }) if (!response.ok) { - const data = await response.json() - throw new Error(data.error?.message || `Meta API error: ${response.status}`) + await throwMetaError(response, `Meta API error: ${response.status}`) } return response.json() } +// ============================================================ +// Sending +// ============================================================ + +export interface SendTextMessageArgs { + phoneNumberId: string + accessToken: string + to: string + text: string +} + /** - * Send a text message via Meta WhatsApp API + * Send a free-form WhatsApp text message. + * Only works inside the 24-hour customer service window. */ export async function sendTextMessage( - phoneNumberId: string, - accessToken: string, - to: string, - text: string + args: SendTextMessageArgs ): Promise { + const { phoneNumberId, accessToken, to, text } = args const url = `${META_API_BASE}/${phoneNumberId}/messages` const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ messaging_product: 'whatsapp', @@ -52,24 +100,36 @@ export async function sendTextMessage( }), }) if (!response.ok) { - const data = await response.json() - throw new Error(data.error?.message || `Meta API error: ${response.status}`) + await throwMetaError(response, `Meta API error: ${response.status}`) } const data = await response.json() return { messageId: data.messages[0].id } } +export interface SendTemplateMessageArgs { + phoneNumberId: string + accessToken: string + to: string + templateName: string + language?: string + params?: string[] +} + /** - * Send a template message via Meta WhatsApp API + * Send a pre-approved WhatsApp message template. Required outside + * the 24-hour window and for any first-touch messaging. */ export async function sendTemplateMessage( - phoneNumberId: string, - accessToken: string, - to: string, - templateName: string, - language: string = 'en_US', - params?: string[] + args: SendTemplateMessageArgs ): Promise { + const { + phoneNumberId, + accessToken, + to, + templateName, + language = 'en_US', + params, + } = args const url = `${META_API_BASE}/${phoneNumberId}/messages` const template: Record = { @@ -78,17 +138,19 @@ export async function sendTemplateMessage( } if (params && params.length > 0) { - template.components = [{ - type: 'body', - parameters: params.map(p => ({ type: 'text', text: String(p) })), - }] + template.components = [ + { + type: 'body', + parameters: params.map((p) => ({ type: 'text', text: String(p) })), + }, + ] } const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ messaging_product: 'whatsapp', @@ -99,40 +161,61 @@ export async function sendTemplateMessage( }), }) if (!response.ok) { - const data = await response.json() - throw new Error(data.error?.message || `Meta API error: ${response.status}`) + await throwMetaError(response, `Meta API error: ${response.status}`) } const data = await response.json() return { messageId: data.messages[0].id } } -/** - * Get media download URL from Meta - */ -export async function getMediaUrl(mediaId: string, accessToken: string): Promise<{ url: string; mimeType: string }> { - const response = await fetch(`${META_API_BASE}/${mediaId}`, { - headers: { 'Authorization': `Bearer ${accessToken}` }, - }) - if (!response.ok) { - const data = await response.json() - throw new Error(data.error?.message || `Media fetch failed: ${response.status}`) - } - const data = await response.json() - if (!data.url) throw new Error('Media URL not found') - return { url: data.url, mimeType: data.mime_type || 'application/octet-stream' } +// ============================================================ +// Media +// ============================================================ + +export interface GetMediaUrlArgs { + mediaId: string + accessToken: string } /** - * Download media binary from Meta CDN + * Resolve a media ID to Meta's (short-lived, authenticated) CDN URL + * plus the MIME type. Step one of the media-proxy flow. */ -export async function downloadMedia(downloadUrl: string, accessToken: string): Promise<{ buffer: Buffer; contentType: string }> { +export async function getMediaUrl( + args: GetMediaUrlArgs +): Promise<{ url: string; mimeType: string }> { + const { mediaId, accessToken } = args + const response = await fetch(`${META_API_BASE}/${mediaId}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!response.ok) { + await throwMetaError(response, `Media fetch failed: ${response.status}`) + } + const data = await response.json() + if (!data.url) throw new Error('Media URL not found in Meta response') + return { url: data.url, mimeType: data.mime_type || 'application/octet-stream' } +} + +export interface DownloadMediaArgs { + downloadUrl: string + accessToken: string +} + +/** + * Fetch the binary bytes for a media URL obtained from getMediaUrl. + * Step two of the media-proxy flow. + */ +export async function downloadMedia( + args: DownloadMediaArgs +): Promise<{ buffer: Buffer; contentType: string }> { + const { downloadUrl, accessToken } = args const response = await fetch(downloadUrl, { - headers: { 'Authorization': `Bearer ${accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, }) if (!response.ok) { throw new Error(`Media download failed: ${response.status}`) } - const contentType = response.headers.get('content-type') || 'application/octet-stream' + const contentType = + response.headers.get('content-type') || 'application/octet-stream' const buffer = Buffer.from(await response.arrayBuffer()) return { buffer, contentType } }