mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-03 01:39:46 +00:00
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>
264 lines
7.7 KiB
TypeScript
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 }
|
|
)
|
|
}
|
|
}
|