wacrm/src/app/api/whatsapp/send/route.ts
Arnas Donauskas c63a68c01e
chore(templates): polish pass from senior backend / FE / UX review
A focused cleanup PR that addresses the high-signal findings from
a self-review of PRs #148-153. Every change is local to the
templates surface; no schema or wire-format changes.

Safety
------
* Delete now goes through a confirmation Dialog (mirrors the
  existing tag-manager pattern). Copy explicitly tells the user
  when Meta will be hit, so a slip on the trash icon doesn't take
  the template off Meta as well as locally.
* AUTHENTICATION submit is now blocked by a persistent in-dialog
  banner + disabled submit button. The disappearing toast that
  used to fire on submit is gone — defensive `return` remains in
  handleSubmit as a second line of defense.
* updateButton uses a per-variant switch + ButtonPatch union
  instead of the partial-spread + `as TemplateButton` cast. Each
  variant's spread keeps its discriminant pinned; TS enforces
  exhaustiveness.
* New template-row-guard.ts surfaces malformed Supabase rows at
  the route boundary instead of letting buildSendComponents crash
  deep in the stack with an opaque TypeError.
* UUID regex check on /[id] PATCH+DELETE so non-UUID payloads
  return a clean 400 before hitting Supabase.

Submit-route cleanup
--------------------
* Two near-identical upsert blocks (error path + success path)
  collapsed into a buildUpsertRow + upsertTemplateRow helper.
  Adding a column now touches one place, not two.

UX copy + feedback
------------------
* Submit success toast sets the right expectation: "typical
  review time is 24 hours. Status updates automatically." Old
  copy just said "status will update once approved."
* Header media URL helper text is now actually useful: HTTPS
  requirement, ~24h lifetime, per-format size/format recommendations
  (JPEG/PNG ≥800×418 ≤5 MB for images, MP4 ≤16 MB for video, etc).
* Truncated-sync toast switches from `warning` (which auto-dismisses
  fast) to `error` with `duration: 10000` and concrete copy: "Synced
  the first 2000 templates only — your account has more. Sync again
  to continue."
* Edit and Resubmit buttons gain visible text labels alongside
  their icons + better aria-label/title pairs. A lone rotate icon
  wasn't self-explanatory.
* Broadcast picker drops the redundant "Approved" chip — every
  template in that list is APPROVED by filter, so the chip carried
  zero information. Unused templateStatusConfig import removed.

State + a11y
------------
* handleSubmit now awaits fetchTemplates BEFORE closing the dialog
  so re-opening immediately doesn't see a stale list.
* Header content / media URL / sample are preserved across format
  switches in the New Template form. The payload builder is already
  format-aware, so orphan values on hidden fields are harmless —
  and keeping them lets users switch formats to compare without
  losing typing.
* Button-type Select gains the same null-guard the header Select
  got in PR #148 (per @base-ui semantics, onValueChange can fire
  with null on deselect).
* Dynamic body-sample inputs get id + aria-label so each one is
  individually addressable (WCAG A — screen-reader announcement
  beat "edit text"). Header text + header sample inputs too.

Deliberately NOT here
---------------------
* The 940-line template-manager component is not split. It's
  cohesive (one feature surface) and splitting for size alone
  would be over-engineering.
* extractVariableIndices in the buttons loop is not memoized.
  Regex on a short URL string is sub-microsecond; a real profiler
  would never flag it.
* No JS heap secret-clearing for access tokens. Strings are
  immutable in JS — there's no way to actually clear one.

Tests: 256/256 pass (no new tests required — pure refactor +
copy + new UI elements). Lint 0 errors. Typecheck clean. Build
succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 17:50:11 +02:00

370 lines
12 KiB
TypeScript

import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
import { supabaseAdmin } from '@/lib/flows/admin-client'
import {
sanitizePhoneForMeta,
isValidE164,
phoneVariants,
isRecipientNotAllowedError,
} from '@/lib/whatsapp/phone-utils'
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit'
import type { MessageTemplate } from '@/types'
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
export async function POST(request: Request) {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Per-user rate limit. Bucket key is scoped to this route so
// `/broadcast` has an independent budget.
const limit = checkRateLimit(`send:${user.id}`, RATE_LIMITS.send)
if (!limit.success) {
return rateLimitResponse(limit)
}
const body = await request.json()
const {
conversation_id,
message_type,
content_text,
media_url,
template_name,
template_language,
template_params,
template_message_params,
reply_to_message_id,
} = body
if (!conversation_id || !message_type) {
return NextResponse.json(
{ error: 'conversation_id and message_type are required' },
{ status: 400 }
)
}
if (message_type === 'text' && !content_text) {
return NextResponse.json(
{ error: 'content_text is required for text messages' },
{ status: 400 }
)
}
if (message_type === 'template' && !template_name) {
return NextResponse.json(
{ error: 'template_name is required for template messages' },
{ status: 400 }
)
}
// Fetch conversation and contact
const { data: conversation, error: convError } = await supabase
.from('conversations')
.select('*, contact:contacts(*)')
.eq('id', conversation_id)
.eq('user_id', user.id)
.single()
if (convError || !conversation) {
return NextResponse.json(
{ error: 'Conversation not found' },
{ status: 404 }
)
}
const contact = conversation.contact
if (!contact?.phone) {
return NextResponse.json(
{ error: 'Contact phone number not found' },
{ status: 400 }
)
}
// Sanitize and validate phone
const sanitizedPhone = sanitizePhoneForMeta(contact.phone)
if (!isValidE164(sanitizedPhone)) {
return NextResponse.json(
{ error: 'Invalid phone number format' },
{ status: 400 }
)
}
// Fetch and decrypt WhatsApp config
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('user_id', user.id)
.single()
if (configError || !config) {
return NextResponse.json(
{ error: 'WhatsApp not configured. Please set up your WhatsApp integration first.' },
{ status: 400 }
)
}
const accessToken = decrypt(config.access_token)
// Self-heal legacy CBC-encrypted tokens. Fire-and-forget: we
// return from the send without waiting, so a failed upgrade just
// means the next send tries again. The upgrade is idempotent —
// concurrent sends both produce valid GCM ciphertexts of the same
// plaintext, last write wins.
if (isLegacyFormat(config.access_token)) {
void supabase
.from('whatsapp_config')
.update({ access_token: encrypt(accessToken) })
.eq('id', config.id)
.then(({ error }) => {
if (error) {
console.warn(
'[whatsapp/send] access_token GCM upgrade failed:',
error.message,
)
}
})
}
// Resolve the reply target (if any) to its Meta message_id, which is
// what `context.message_id` on the outgoing Meta payload needs. The
// parent must belong to this same conversation — otherwise a caller
// could quote messages they can't see by guessing UUIDs.
let contextMessageId: string | undefined
if (reply_to_message_id) {
const { data: parent, error: parentError } = await supabase
.from('messages')
.select('message_id, conversation_id')
.eq('id', reply_to_message_id)
.eq('conversation_id', conversation_id)
.maybeSingle()
if (parentError || !parent) {
return NextResponse.json(
{ error: 'reply_to_message_id not found in this conversation' },
{ status: 400 }
)
}
if (!parent.message_id) {
// Parent never reached Meta (still in 'sending' or 'failed') — we
// can't quote it on WhatsApp. Send without context rather than
// dropping the message entirely.
console.warn(
'[whatsapp/send] reply target has no Meta message_id; sending without context'
)
} else {
contextMessageId = parent.message_id
}
}
// Send via Meta API — retry with phone-number variants if Meta rejects
// with "recipient not in allowed list" (common in sandbox / when a
// number was registered with/without a trunk 0). If an alternate
// format succeeds, we persist it back to the contact row so the
// next send goes through on the first attempt.
let waMessageId = ''
let workingPhone = sanitizedPhone
// For template sends, load the row so sendTemplateMessage can
// build header + button components from the template definition.
// Match on (user_id, name, language) — same triple the unique
// index enforces — so multi-language templates work correctly.
// Missing template falls through with `templateRow = null` and
// the legacy body-only path runs.
// Load the template row so sendTemplateMessage can build header
// + button components from the definition. isMessageTemplate
// guards against a malformed row (e.g. from a partial sync)
// crashing the send-builder later in the stack.
let templateRow: MessageTemplate | null = null
if (message_type === 'template' && template_name) {
const { data } = await supabase
.from('message_templates')
.select('*')
.eq('user_id', user.id)
.eq('name', template_name)
.eq('language', template_language || 'en_US')
.maybeSingle()
if (data && !isMessageTemplate(data)) {
return NextResponse.json(
{
error:
'Template row is malformed locally — run "Sync from Meta" in Settings to repair it.',
},
{ status: 500 },
)
}
templateRow = data ?? null
}
const attempt = async (phone: string): Promise<string> => {
if (message_type === 'template') {
const result = await sendTemplateMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
templateName: template_name,
language: template_language || 'en_US',
template: templateRow ?? undefined,
messageParams: template_message_params ?? undefined,
// Legacy body-only fallback — only consulted when
// messageParams.body isn't set.
params: template_params || [],
contextMessageId,
})
return result.messageId
}
const result = await sendTextMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
text: content_text,
contextMessageId,
})
return result.messageId
}
try {
const variants = phoneVariants(sanitizedPhone)
let lastError: unknown = null
for (const variant of variants) {
try {
waMessageId = await attempt(variant)
workingPhone = variant
lastError = null
break
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// Only retry when the failure is specifically that the
// recipient isn't in Meta's allowed list. Any other error
// (bad token, invalid template, etc.) bubbles up immediately.
if (!isRecipientNotAllowedError(message)) {
throw err
}
lastError = err
console.warn(`[whatsapp/send] variant "${variant}" rejected by Meta, trying next…`)
}
}
if (lastError) throw lastError
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown Meta API error'
console.error('Meta API send failed for all variants:', message)
return NextResponse.json(
{ error: `Meta API error: ${message}` },
{ status: 502 }
)
}
// If a non-original variant succeeded, update the contact so future
// sends go straight through. sanitizePhoneForMeta on workingPhone
// will yield workingPhone itself, so re-storing preserves it.
if (workingPhone !== sanitizedPhone) {
console.log(
`[whatsapp/send] Auto-corrected contact phone: ${sanitizedPhone} → ${workingPhone}`
)
await supabase
.from('contacts')
.update({ phone: workingPhone })
.eq('id', contact.id)
}
// 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,
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',
reply_to_message_id: reply_to_message_id || null,
})
.select()
.single()
if (msgError) {
console.error('Error inserting sent message:', msgError)
return NextResponse.json(
{ error: `Message sent to Meta but failed to save to DB: ${msgError.message}` },
{ status: 500 }
)
}
// Update conversation
await supabase
.from('conversations')
.update({
last_message_text: content_text || `[${message_type}]`,
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', conversation_id)
// Pause any active Flow run for this contact — the agent stepping
// in is the strongest "yield, human is here" signal. See PR #2
// plan for why we pause (not end): preserves diagnostic state +
// lets the agent or the 24h timeout sweep cleanly resolve the
// run later. For accounts with no active runs the UPDATE matches
// zero rows — cheap and harmless.
try {
const { error: pauseErr } = await supabaseAdmin()
.from('flow_runs')
.update({
status: 'paused_by_agent',
ended_at: new Date().toISOString(),
end_reason: 'agent_replied',
})
.eq('user_id', user.id)
.eq('contact_id', contact.id)
.eq('status', 'active')
if (pauseErr) {
// Best-effort — log + continue. The agent's message already
// landed at Meta; don't fail the response over a bookkeeping
// miss. Worst case: a stale active run gets caught by the
// stale-run cron sweep within 24h.
console.error('[flows] pause-on-agent-send failed:', pauseErr.message)
}
} catch (err) {
console.error(
'[flows] pause-on-agent-send threw:',
err instanceof Error ? err.message : err,
)
}
return NextResponse.json({
success: true,
message_id: messageRecord.id,
whatsapp_message_id: waMessageId,
})
} catch (error) {
console.error('Error in WhatsApp send POST:', error)
return NextResponse.json(
{ error: 'Failed to send message' },
{ status: 500 }
)
}
}