refactor: Meta API helpers use named params (prevents swapped-args bugs)

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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-16 21:52:25 +03:00
parent 03b40e0e8e
commit f3c68967f1
No known key found for this signature in database
6 changed files with 160 additions and 73 deletions

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -114,22 +114,21 @@ export async function POST(request: Request) {
const attempt = async (phone: string): Promise<string> => {
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
}

View File

@ -298,7 +298,7 @@ async function parseMessageContent(
mediaId: string
): Promise<string | null> => {
try {
await getMediaUrl(mediaId, accessToken)
await getMediaUrl({ mediaId, accessToken })
return `/api/whatsapp/media/${mediaId}`
} catch (error) {
console.error(

View File

@ -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<never> {
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<MetaPhoneInfo> {
export async function verifyPhoneNumber(
args: VerifyPhoneNumberArgs
): Promise<MetaPhoneInfo> {
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<MetaSendResult> {
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<MetaSendResult> {
const {
phoneNumberId,
accessToken,
to,
templateName,
language = 'en_US',
params,
} = args
const url = `${META_API_BASE}/${phoneNumberId}/messages`
const template: Record<string, unknown> = {
@ -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 }
}