wacrm/src/app/api/whatsapp/broadcast/route.ts
Arnas Donauskas 2e911d5e5a
fix(multi-user): share whatsapp_config + templates by account
Reported by @ArnasDon: a teammate accepting an invite landed in the
shared account but the WhatsApp Config tab still showed "not
connected." Inbox banner read "WhatsApp not connected" too.

Cause: PR 8 of the multi-user series converted the *webhook* and
the *automations/flows engines* to account-scoped lookups, but
left every user-facing API route + UI page still filtering by
`user_id`. Post-017 `whatsapp_config` and `message_templates` are
account-scoped (one row per account, with `account_id NOT NULL`),
so `.eq('user_id', user.id)` returned zero rows for any teammate
who didn't personally save the config — including everyone who
joined via invite.

This PR completes the conversion across the user-facing surface.

Surface (11 files)
- `src/components/settings/whatsapp-config.tsx` — read accountId
  from `useAuth()`; trigger fetch on profile-ready, not just
  user-ready; pass accountId through to the supabase query.
- `src/app/(dashboard)/inbox/page.tsx` — resolve account_id from
  profiles, query whatsapp_config by account_id for the
  connected-banner state.
- `src/app/api/whatsapp/config/route.ts` — GET/POST/DELETE all
  resolve account_id from profile. The phone_number_id collision
  check (service-role) now compares against `account_id`, not
  `user_id` — two accounts still can't share a Meta number, two
  teammates in one account share one row by design. INSERT
  populates `account_id` (tenancy) + `user_id` (audit).
- `src/app/api/whatsapp/config/verify-registration/route.ts` —
  same conversion.
- `src/app/api/whatsapp/send/route.ts` — conversation,
  whatsapp_config, message_templates lookups all switch to
  account_id; rate-limit key stays per-user.
- `src/app/api/whatsapp/react/route.ts` — conversation +
  whatsapp_config switch to account_id.
- `src/app/api/whatsapp/broadcast/route.ts` — whatsapp_config +
  message_templates + broadcasts switch to account_id.
- `src/app/api/whatsapp/media/[mediaId]/route.ts` — config
  lookup switches to account_id.
- `src/app/api/whatsapp/templates/submit/route.ts` — config
  lookup + every message_templates SELECT/upsert now uses
  account_id. The upsert ROW gains `account_id` so the
  NOT NULL constraint added by migration 017 stops rejecting
  new templates (this was an incidental bug — every new
  template post-017 would have been broken regardless of
  multi-user).
- `src/app/api/whatsapp/templates/sync/route.ts` — same.
- `src/app/api/whatsapp/templates/[id]/route.ts` PATCH +
  DELETE — same.

Pattern at every route:

  const { data: profile } = await supabase
    .from('profiles')
    .select('account_id')
    .eq('user_id', user.id)
    .maybeSingle()
  const accountId = profile?.account_id as string | undefined
  if (!accountId) return 403

then every downstream `.eq('user_id', user.id)` → `.eq('account_id', accountId)`.

Known follow-up not in this PR
- `message_templates` still has its UNIQUE index on
  `(user_id, name, language)` from migration 014; the submit
  upsert's `onConflict` targets that same triple. As a result,
  two teammates can still INSERT same-named templates without
  conflicting. The fix needs a small migration (drop legacy
  index, add `(account_id, name, language)` index, switch the
  `onConflict` target) — TODO comment left in the route file so
  it's not forgotten.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 14:01:46 +03:00

264 lines
7.7 KiB
TypeScript

import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption'
import type { SendTimeParams } from '@/lib/whatsapp/template-send-builder'
import { isMessageTemplate } from '@/lib/whatsapp/template-row-guard'
import {
sanitizePhoneForMeta,
isValidE164,
phoneVariants,
isRecipientNotAllowedError,
} from '@/lib/whatsapp/phone-utils'
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit'
interface BroadcastResult {
phone: string
status: 'sent' | 'failed'
whatsapp_message_id?: string
error?: string
}
/**
* Two input shapes are accepted:
*
* NEW (preferred — supports per-recipient variable substitution):
* {
* recipients: Array<{ phone: string; params: string[] }>,
* template_name, template_language
* }
*
* LEGACY (all phones receive the same params — kept so existing
* callers don't break):
* {
* phone_numbers: string[],
* template_params: string[],
* template_name, template_language
* }
*
* Previous implementation only supported the legacy shape, and the
* sending hook was forced to ship every batch with `templateParams[0]`
* — meaning every recipient got contact-0's personalization. The new
* shape is what actually fixes that.
*/
interface NewRecipient {
phone: string
/** Body variable values, one per {{N}}. Legacy field. */
params?: string[]
/**
* Structured per-send values (header text variable, media URL
* override, URL/COPY_CODE button values). When set, takes
* precedence over `params` for the body too — see
* sendTemplateMessage for the merge rules.
*/
messageParams?: SendTimeParams
}
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 broadcast budget. Note: this limits how often a user
// can *start* a campaign, not how many messages go out inside
// one — the fan-out loop below runs without additional gating.
const limit = checkRateLimit(`broadcast:${user.id}`, RATE_LIMITS.broadcast)
if (!limit.success) {
return rateLimitResponse(limit)
}
// Resolve the caller's account_id. whatsapp_config + templates
// + broadcasts are all account-scoped post-multi-user, so the
// old `.eq('user_id', user.id)` filters miss every row created
// by a teammate.
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) {
return NextResponse.json(
{ error: 'Your profile is not linked to an account.' },
{ status: 403 },
)
}
const body = await request.json()
const {
recipients: newRecipients,
phone_numbers,
template_name,
template_language,
template_params,
} = body
// Normalize to a list of {phone, params} regardless of shape.
let recipients: NewRecipient[]
if (Array.isArray(newRecipients) && newRecipients.length > 0) {
recipients = newRecipients
} else if (Array.isArray(phone_numbers) && phone_numbers.length > 0) {
const shared: string[] = Array.isArray(template_params)
? template_params
: []
recipients = phone_numbers.map((phone: string) => ({
phone,
params: shared,
}))
} else {
return NextResponse.json(
{
error:
'Provide either `recipients` (preferred) or `phone_numbers` — must be a non-empty array',
},
{ status: 400 }
)
}
if (!template_name) {
return NextResponse.json(
{ error: 'template_name is required' },
{ status: 400 }
)
}
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('account_id', accountId)
.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)
// Load the template row once so sendTemplateMessage can build
// header + button components on each iteration. Loading inside
// the loop would N+1 against Supabase for every recipient.
// Guard against a malformed local row crashing every send in
// the loop with the same opaque TypeError — fail loudly once.
const { data: rawTemplateRow } = await supabase
.from('message_templates')
.select('*')
.eq('account_id', accountId)
.eq('name', template_name)
.eq('language', template_language || 'en_US')
.maybeSingle()
if (rawTemplateRow && !isMessageTemplate(rawTemplateRow)) {
return NextResponse.json(
{
error:
'Template row is malformed locally — run "Sync from Meta" in Settings to repair it before broadcasting.',
},
{ status: 500 },
)
}
const templateRow = rawTemplateRow ?? null
const results: BroadcastResult[] = []
let sentCount = 0
let failedCount = 0
for (const recipient of recipients) {
const sanitized = sanitizePhoneForMeta(recipient.phone)
if (!isValidE164(sanitized)) {
results.push({
phone: recipient.phone,
status: 'failed',
error: 'Invalid phone number format',
})
failedCount++
continue
}
// Retry with phone variants on "not in allowed list" so numbers
// that differ only in a trunk-prefix 0 still reach recipients.
const variants = phoneVariants(sanitized)
let sentMessageId: string | null = null
let lastError: string | null = null
for (const variant of variants) {
try {
const result = await sendTemplateMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: variant,
templateName: template_name,
language: template_language || 'en_US',
template: templateRow ?? undefined,
messageParams: recipient.messageParams,
params: recipient.params ?? [],
})
sentMessageId = result.messageId
lastError = null
break
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error'
if (!isRecipientNotAllowedError(errorMessage)) {
lastError = errorMessage
break
}
lastError = errorMessage
// retry with next variant
}
}
if (sentMessageId) {
results.push({
phone: recipient.phone,
status: 'sent',
whatsapp_message_id: sentMessageId,
})
sentCount++
} else {
console.error(
`Failed to send broadcast to ${recipient.phone}:`,
lastError
)
results.push({
phone: recipient.phone,
status: 'failed',
error: lastError || 'Unknown error',
})
failedCount++
}
}
return NextResponse.json({
success: true,
total: recipients.length,
sent: sentCount,
failed: failedCount,
results,
})
} catch (error) {
console.error('Error in WhatsApp broadcast POST:', error)
return NextResponse.json(
{ error: 'Failed to process broadcast' },
{ status: 500 }
)
}
}