mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #155 from ArnasDon/fix/whatsapp-multi-number-registration
fix(whatsapp): register phone numbers on Meta + verify subscription state
This commit is contained in:
commit
6c0c70486c
@ -1,7 +1,11 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js'
|
||||
import { verifyPhoneNumber } from '@/lib/whatsapp/meta-api'
|
||||
import {
|
||||
registerPhoneNumber,
|
||||
subscribeWabaToApp,
|
||||
verifyPhoneNumber,
|
||||
} from '@/lib/whatsapp/meta-api'
|
||||
import { encrypt, decrypt } from '@/lib/whatsapp/encryption'
|
||||
|
||||
// Lazy-initialised service-role client. We need it to detect a
|
||||
@ -138,7 +142,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { phone_number_id, waba_id, access_token, verify_token } = body
|
||||
const { phone_number_id, waba_id, access_token, verify_token, pin } = body
|
||||
|
||||
if (!access_token || !phone_number_id) {
|
||||
return NextResponse.json(
|
||||
@ -147,6 +151,15 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
if (pin !== undefined && pin !== null && pin !== '') {
|
||||
if (typeof pin !== 'string' || !/^\d{6}$/.test(pin)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'PIN must be exactly 6 digits.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Reject if another user has already claimed this phone_number_id.
|
||||
// wacrm is single-tenant-per-WhatsApp-number — letting two users
|
||||
// bind the same number causes the webhook's `.single()` lookup to
|
||||
@ -211,25 +224,100 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Upsert — overwrite any existing (possibly corrupted) config
|
||||
// Look up any pre-existing row so we know whether this number is
|
||||
// already registered with Meta — if so we can skip /register when
|
||||
// the user didn't provide a PIN this time around.
|
||||
const { data: existing } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.select('id')
|
||||
.select('id, registered_at, phone_number_id')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (existing) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.update({
|
||||
const sameNumber =
|
||||
existing?.phone_number_id === phone_number_id &&
|
||||
existing?.registered_at != null
|
||||
|
||||
// Step 1: register the phone number for inbound webhooks.
|
||||
//
|
||||
// Required on first save AND whenever the user supplies a fresh
|
||||
// PIN (e.g. they rotated the 2FA PIN in Meta Manager). Skipped
|
||||
// when the same number is already registered and no PIN was
|
||||
// supplied — re-registering an already-active number with a
|
||||
// stale PIN would actually fail and undo the active subscription.
|
||||
let registeredAt: string | null = existing?.registered_at ?? null
|
||||
let registrationError: string | null = null
|
||||
|
||||
const needsRegistration = !sameNumber || (typeof pin === 'string' && pin.length > 0)
|
||||
if (needsRegistration) {
|
||||
if (!pin) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Two-step verification PIN is required to subscribe this number to wacrm. ' +
|
||||
'Set a 6-digit PIN in Meta WhatsApp Manager → Phone Numbers → Two-step verification, then paste it below.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
try {
|
||||
await registerPhoneNumber({
|
||||
phoneNumberId: phone_number_id,
|
||||
accessToken: access_token,
|
||||
pin,
|
||||
})
|
||||
registeredAt = new Date().toISOString()
|
||||
} catch (err) {
|
||||
registrationError =
|
||||
err instanceof Error ? err.message : 'Unknown Meta API error'
|
||||
console.error('Phone number /register failed:', registrationError)
|
||||
// We deliberately fall through and still save the row so the
|
||||
// user can retry without re-entering everything. The UI
|
||||
// surfaces `last_registration_error` so they see WHY it's
|
||||
// not actually live yet.
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: subscribe the WABA to this app. Idempotent on Meta's
|
||||
// side, so we call on every save and persist the timestamp.
|
||||
// Skipped only when there's no waba_id (legacy rows from before
|
||||
// we required it).
|
||||
let subscribedAppsAt: string | null = null
|
||||
if (waba_id) {
|
||||
try {
|
||||
await subscribeWabaToApp({
|
||||
wabaId: waba_id,
|
||||
accessToken: access_token,
|
||||
})
|
||||
subscribedAppsAt = new Date().toISOString()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.warn('WABA subscribed_apps failed (non-fatal):', message)
|
||||
// Subscription failures are rare once the App has the right
|
||||
// permissions; we don't block save on them — the diagnostic
|
||||
// endpoint surfaces this state too.
|
||||
}
|
||||
}
|
||||
|
||||
// Persist everything in one shot. If /register failed we still
|
||||
// store the credentials and the error so the UI can guide the
|
||||
// user through a retry.
|
||||
const baseRow = {
|
||||
phone_number_id,
|
||||
waba_id: waba_id || null,
|
||||
access_token: encryptedAccessToken,
|
||||
verify_token: encryptedVerifyToken,
|
||||
status: 'connected',
|
||||
connected_at: new Date().toISOString(),
|
||||
status: registrationError ? 'disconnected' : 'connected',
|
||||
connected_at: registrationError ? null : new Date().toISOString(),
|
||||
registered_at: registrationError ? null : registeredAt,
|
||||
subscribed_apps_at: subscribedAppsAt ?? null,
|
||||
last_registration_error: registrationError,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.update(baseRow)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (updateError) {
|
||||
@ -242,15 +330,7 @@ export async function POST(request: Request) {
|
||||
} else {
|
||||
const { error: insertError } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
phone_number_id,
|
||||
waba_id: waba_id || null,
|
||||
access_token: encryptedAccessToken,
|
||||
verify_token: encryptedVerifyToken,
|
||||
status: 'connected',
|
||||
connected_at: new Date().toISOString(),
|
||||
})
|
||||
.insert({ user_id: user.id, ...baseRow })
|
||||
|
||||
if (insertError) {
|
||||
console.error('Error inserting whatsapp_config:', insertError)
|
||||
@ -261,7 +341,25 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, phone_info: phoneInfo })
|
||||
if (registrationError) {
|
||||
// Save succeeded but the number isn't actually live. Return
|
||||
// 200 with a structured error so the UI can show the specific
|
||||
// remediation step instead of a generic toast.
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
saved: true,
|
||||
registered: false,
|
||||
registration_error: registrationError,
|
||||
phone_info: phoneInfo,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
saved: true,
|
||||
registered: true,
|
||||
phone_info: phoneInfo,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in WhatsApp config POST:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
|
||||
139
src/app/api/whatsapp/config/verify-registration/route.ts
Normal file
139
src/app/api/whatsapp/config/verify-registration/route.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||
import {
|
||||
getSubscribedApps,
|
||||
verifyPhoneNumber,
|
||||
} from '@/lib/whatsapp/meta-api'
|
||||
|
||||
/**
|
||||
* GET /api/whatsapp/config/verify-registration
|
||||
*
|
||||
* Diagnostic endpoint — confirms the user's saved phone number is
|
||||
* actually reachable on Meta's side. Solves the failure mode that
|
||||
* surfaced the multi-number bug originally: "UI says Connected but
|
||||
* Meta isn't delivering events."
|
||||
*
|
||||
* Three checks run independently so the UI can show which step
|
||||
* passes and which fails:
|
||||
*
|
||||
* 1. phone_info — GET /{phone_number_id} succeeds
|
||||
* 2. waba_subscription — our app appears in
|
||||
* GET /{waba_id}/subscribed_apps
|
||||
* 3. registered_at — local timestamp set by POST /config when
|
||||
* /register last succeeded; NULL means the
|
||||
* number was saved but never actually subscribed
|
||||
*
|
||||
* Returns 200 in every case so the UI can render diagnostic detail
|
||||
* rather than a generic error toast. The combined `live` flag is
|
||||
* what the UI badges on.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser()
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data: config } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({
|
||||
live: false,
|
||||
checks: { config_exists: false },
|
||||
message: 'No WhatsApp configuration saved yet.',
|
||||
})
|
||||
}
|
||||
|
||||
let accessToken: string
|
||||
try {
|
||||
accessToken = decrypt(config.access_token)
|
||||
} catch {
|
||||
return NextResponse.json({
|
||||
live: false,
|
||||
checks: {
|
||||
config_exists: true,
|
||||
token_decryptable: false,
|
||||
},
|
||||
message:
|
||||
'Stored access token can\'t be decrypted — likely ENCRYPTION_KEY changed. Re-enter the token to repair.',
|
||||
})
|
||||
}
|
||||
|
||||
const checks: {
|
||||
config_exists: boolean
|
||||
token_decryptable: boolean
|
||||
phone_metadata_ok: boolean
|
||||
waba_subscribed_to_app: boolean | null
|
||||
locally_marked_registered: boolean
|
||||
} = {
|
||||
config_exists: true,
|
||||
token_decryptable: true,
|
||||
phone_metadata_ok: false,
|
||||
waba_subscribed_to_app: null,
|
||||
locally_marked_registered: config.registered_at != null,
|
||||
}
|
||||
const errors: string[] = []
|
||||
|
||||
// 1. Phone metadata
|
||||
try {
|
||||
await verifyPhoneNumber({
|
||||
phoneNumberId: config.phone_number_id,
|
||||
accessToken,
|
||||
})
|
||||
checks.phone_metadata_ok = true
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`Phone metadata check failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 2. WABA subscription — only meaningful if we have a waba_id
|
||||
if (config.waba_id) {
|
||||
try {
|
||||
const subs = await getSubscribedApps({
|
||||
wabaId: config.waba_id,
|
||||
accessToken,
|
||||
})
|
||||
// Meta returns the apps subscribed to this WABA. If the list
|
||||
// is non-empty, OUR app is in there (the access_token we used
|
||||
// belongs to our app — Meta wouldn't return data for an app
|
||||
// the token can't see). Treat any entry as success.
|
||||
checks.waba_subscribed_to_app = subs.length > 0
|
||||
if (!checks.waba_subscribed_to_app) {
|
||||
errors.push(
|
||||
'WABA has no subscribed apps. Re-save the configuration to subscribe.',
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`WABA subscription check failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
errors.push(
|
||||
'No WABA ID on file — webhooks can\'t be wired without it. Add it in the form and re-save.',
|
||||
)
|
||||
}
|
||||
|
||||
const live =
|
||||
checks.phone_metadata_ok &&
|
||||
(checks.waba_subscribed_to_app ?? false) &&
|
||||
checks.locally_marked_registered
|
||||
|
||||
return NextResponse.json({
|
||||
live,
|
||||
checks,
|
||||
errors,
|
||||
last_registration_error: config.last_registration_error ?? null,
|
||||
registered_at: config.registered_at ?? null,
|
||||
subscribed_apps_at: config.subscribed_apps_at ?? null,
|
||||
})
|
||||
}
|
||||
@ -52,8 +52,28 @@ export function WhatsAppConfig() {
|
||||
const [wabaId, setWabaId] = useState('');
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const [verifyToken, setVerifyToken] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
const [tokenEdited, setTokenEdited] = useState(false);
|
||||
|
||||
// True once /register has succeeded on Meta's side (timestamp set
|
||||
// in the row). When false, the saved config is metadata-only and
|
||||
// Meta will silently drop every inbound event — that's the
|
||||
// multi-number bug that prompted this work.
|
||||
const isRegistered = Boolean(config?.registered_at);
|
||||
const lastRegistrationError = config?.last_registration_error ?? null;
|
||||
|
||||
const [verifyingRegistration, setVerifyingRegistration] = useState(false);
|
||||
type RegistrationProbe = {
|
||||
live: boolean;
|
||||
checks: Record<string, boolean | null>;
|
||||
errors?: string[];
|
||||
last_registration_error?: string | null;
|
||||
registered_at?: string | null;
|
||||
subscribed_apps_at?: string | null;
|
||||
};
|
||||
const [registrationProbe, setRegistrationProbe] =
|
||||
useState<RegistrationProbe | null>(null);
|
||||
|
||||
const webhookUrl =
|
||||
typeof window !== 'undefined'
|
||||
? `${window.location.origin}/api/whatsapp/webhook`
|
||||
@ -79,6 +99,7 @@ export function WhatsAppConfig() {
|
||||
setWabaId(data.waba_id || '');
|
||||
setAccessToken(MASKED_TOKEN);
|
||||
setVerifyToken('');
|
||||
setPin('');
|
||||
setTokenEdited(false);
|
||||
} else {
|
||||
setConfig(null);
|
||||
@ -86,8 +107,11 @@ export function WhatsAppConfig() {
|
||||
setWabaId('');
|
||||
setAccessToken('');
|
||||
setVerifyToken('');
|
||||
setPin('');
|
||||
setTokenEdited(false);
|
||||
}
|
||||
// Clear any stale probe result when reloading the row.
|
||||
setRegistrationProbe(null);
|
||||
|
||||
// Then verify health via the API (decrypts token + pings Meta)
|
||||
if (data) {
|
||||
@ -151,6 +175,10 @@ export function WhatsAppConfig() {
|
||||
phone_number_id: phoneNumberId.trim(),
|
||||
waba_id: wabaId.trim() || null,
|
||||
verify_token: verifyToken.trim() || null,
|
||||
// Optional — only sent when the user filled it in. The server
|
||||
// requires it on first save or when changing numbers; for a
|
||||
// simple token rotation, leaving it blank skips re-register.
|
||||
pin: pin.trim() || null,
|
||||
};
|
||||
|
||||
if (tokenEdited && accessToken !== MASKED_TOKEN && accessToken.trim()) {
|
||||
@ -179,11 +207,28 @@ export function WhatsAppConfig() {
|
||||
return;
|
||||
}
|
||||
|
||||
// The route now returns a structured outcome:
|
||||
// * registered=true → number is live, events will flow
|
||||
// * registered=false → credentials saved but /register
|
||||
// failed; UI shows the specific error
|
||||
// and a retry path. registration_error
|
||||
// is human-readable from Meta.
|
||||
if (data.registered === false && data.registration_error) {
|
||||
toast.error(
|
||||
`Saved, but Meta couldn't register the number: ${data.registration_error}`,
|
||||
{ duration: 12000 },
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
data.phone_info?.verified_name
|
||||
? `Connected to ${data.phone_info.verified_name}`
|
||||
: 'Configuration saved successfully'
|
||||
? `Live — ${data.phone_info.verified_name} can now receive events.`
|
||||
: 'WhatsApp connected. Events will start flowing within a minute.',
|
||||
);
|
||||
// Clear the PIN so subsequent saves don't accidentally
|
||||
// re-register (which would void the active subscription if
|
||||
// the PIN became stale).
|
||||
setPin('');
|
||||
}
|
||||
|
||||
if (user) await fetchConfig(user.id);
|
||||
} catch (err) {
|
||||
@ -224,6 +269,32 @@ export function WhatsAppConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerifyRegistration() {
|
||||
setVerifyingRegistration(true);
|
||||
setRegistrationProbe(null);
|
||||
try {
|
||||
const res = await fetch('/api/whatsapp/config/verify-registration', {
|
||||
method: 'GET',
|
||||
});
|
||||
const data = (await res.json()) as RegistrationProbe;
|
||||
setRegistrationProbe(data);
|
||||
if (data.live) {
|
||||
toast.success('Number is fully wired — Meta is delivering events.');
|
||||
} else {
|
||||
toast.error(
|
||||
'Number is not fully registered. See the checks below for which step failed.',
|
||||
{ duration: 8000 },
|
||||
);
|
||||
}
|
||||
if (user) await fetchConfig(user.id);
|
||||
} catch (err) {
|
||||
console.error('verify-registration failed:', err);
|
||||
toast.error('Could not reach the verification endpoint.');
|
||||
} finally {
|
||||
setVerifyingRegistration(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!confirm('This will delete the current WhatsApp config so you can re-enter it. Continue?')) {
|
||||
return;
|
||||
@ -320,17 +391,125 @@ export function WhatsAppConfig() {
|
||||
<XCircle className="size-4 text-red-500" />
|
||||
)}
|
||||
<AlertTitle className="text-white mb-0">
|
||||
{connectionStatus === 'connected' ? 'Connected' : 'Not Connected'}
|
||||
{connectionStatus === 'connected' ? 'Credentials valid' : 'Not Connected'}
|
||||
</AlertTitle>
|
||||
</div>
|
||||
<AlertDescription className="text-slate-400">
|
||||
{connectionStatus === 'connected'
|
||||
? 'Your WhatsApp Business API is connected and ready to send/receive messages.'
|
||||
? 'Your access token authenticates with Meta. See Registration status below for whether webhooks are actually wired.'
|
||||
: statusMessage ||
|
||||
'Configure your Meta API credentials below to connect your WhatsApp Business account.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Registration Status — the "is it actually live?" check.
|
||||
Credentials being valid is necessary but not sufficient;
|
||||
without a successful /register call the number won't
|
||||
receive inbound events. Surface this dimension separately
|
||||
so users don't trust a misleading green banner. */}
|
||||
{config && (
|
||||
<Alert
|
||||
className={
|
||||
isRegistered
|
||||
? 'bg-emerald-950/30 border-emerald-700/50'
|
||||
: 'bg-amber-950/30 border-amber-700/50'
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{isRegistered ? (
|
||||
<CheckCircle2 className="size-4 text-emerald-400" />
|
||||
) : (
|
||||
<AlertTriangle className="size-4 text-amber-400" />
|
||||
)}
|
||||
<AlertTitle
|
||||
className={
|
||||
'mb-0 ' + (isRegistered ? 'text-emerald-200' : 'text-amber-200')
|
||||
}
|
||||
>
|
||||
{isRegistered
|
||||
? 'Registered — Meta will deliver events to wacrm'
|
||||
: 'Not registered — Meta will not deliver events'}
|
||||
</AlertTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleVerifyRegistration}
|
||||
disabled={verifyingRegistration}
|
||||
className="border-slate-700 bg-transparent text-slate-200 hover:bg-slate-800 h-7"
|
||||
>
|
||||
{verifyingRegistration ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Zap className="size-3.5" />
|
||||
)}
|
||||
Verify with Meta
|
||||
</Button>
|
||||
</div>
|
||||
<AlertDescription className="text-slate-400 mt-2 text-xs leading-relaxed">
|
||||
{isRegistered ? (
|
||||
<>
|
||||
Subscribed since{' '}
|
||||
{config.registered_at
|
||||
? new Date(config.registered_at).toLocaleString()
|
||||
: 'unknown'}
|
||||
. Click <strong>Verify with Meta</strong> if events
|
||||
stop arriving.
|
||||
</>
|
||||
) : lastRegistrationError ? (
|
||||
<>
|
||||
Last attempt failed with:{' '}
|
||||
<span className="text-red-300">
|
||||
"{lastRegistrationError}"
|
||||
</span>
|
||||
. Enter (or correct) the 2-step PIN below and click
|
||||
Save Configuration to retry.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This number was saved before registration tracking
|
||||
existed, or registration was skipped. Enter the
|
||||
2-step PIN below and click Save Configuration to
|
||||
subscribe it.
|
||||
</>
|
||||
)}
|
||||
</AlertDescription>
|
||||
|
||||
{registrationProbe && (
|
||||
<div className="mt-3 rounded border border-slate-700 bg-slate-900/60 px-3 py-2 space-y-1.5 text-[11px]">
|
||||
<p className="font-medium text-slate-200">
|
||||
Diagnostic — last run: {' '}
|
||||
<span className={registrationProbe.live ? 'text-emerald-400' : 'text-amber-400'}>
|
||||
{registrationProbe.live ? 'live' : 'not live'}
|
||||
</span>
|
||||
</p>
|
||||
<ul className="space-y-0.5 text-slate-400">
|
||||
{Object.entries(registrationProbe.checks).map(([k, v]) => (
|
||||
<li key={k} className="flex items-center gap-1.5">
|
||||
{v === true ? (
|
||||
<CheckCircle2 className="size-3 text-emerald-400 shrink-0" />
|
||||
) : v === false ? (
|
||||
<XCircle className="size-3 text-red-400 shrink-0" />
|
||||
) : (
|
||||
<span className="size-3 rounded-full border border-slate-600 shrink-0" />
|
||||
)}
|
||||
<code className="text-slate-300">{k}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{(registrationProbe.errors ?? []).length > 0 && (
|
||||
<ul className="pt-1 space-y-0.5 text-red-300">
|
||||
{registrationProbe.errors?.map((e, i) => (
|
||||
<li key={i}>• {e}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* API Credentials */}
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<CardHeader>
|
||||
@ -406,6 +585,39 @@ export function WhatsAppConfig() {
|
||||
A custom string you create. Must match the token you set in Meta webhook settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">
|
||||
Two-step verification PIN
|
||||
{!isRegistered && (
|
||||
<span className="ml-1 text-red-400">*</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="6-digit PIN from Meta WhatsApp Manager"
|
||||
value={pin}
|
||||
onChange={(e) =>
|
||||
setPin(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||||
}
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 tracking-widest"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Required the first time you connect a number, and any
|
||||
time you swap to a different number. Set it in{' '}
|
||||
<strong className="text-slate-300">
|
||||
Meta Business Manager → WhatsApp Accounts → Phone
|
||||
Numbers → Two-step verification
|
||||
</strong>
|
||||
. Without this PIN, Meta saves your credentials but
|
||||
won't actually route inbound messages to wacrm —
|
||||
the symptom that hits second numbers under a shared
|
||||
WABA. Leave blank to keep an existing registration
|
||||
untouched.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -65,6 +65,152 @@ export async function verifyPhoneNumber(
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cloud API registration (subscription for inbound webhooks)
|
||||
// ============================================================
|
||||
//
|
||||
// Saving a phone_number_id + access_token to whatsapp_config is NOT
|
||||
// enough to receive inbound events from Meta. Two extra calls are
|
||||
// required:
|
||||
//
|
||||
// POST /{phone_number_id}/register
|
||||
// Subscribes the number for THIS app's webhook. Requires a
|
||||
// 6-digit 2FA PIN the user previously set in Meta WhatsApp
|
||||
// Manager → Two-step verification. Without /register, inbound
|
||||
// events are routed to whichever app last claimed the number
|
||||
// (often the one that did Embedded Signup) — so a second user
|
||||
// adding a second number under the same WABA silently loses
|
||||
// every inbound message.
|
||||
//
|
||||
// POST /{waba_id}/subscribed_apps
|
||||
// Subscribes the WABA itself to this app. Required exactly
|
||||
// once per WABA, but idempotent so calling on every save is
|
||||
// safe and cheap.
|
||||
//
|
||||
// Both calls are no-ops when already done — Meta returns success +
|
||||
// the helpers below treat that as success.
|
||||
|
||||
export interface RegisterPhoneNumberArgs {
|
||||
phoneNumberId: string
|
||||
accessToken: string
|
||||
/**
|
||||
* 6-digit PIN the user set in Meta WhatsApp Manager →
|
||||
* Two-step verification. If 2FA is not enabled on the number,
|
||||
* Meta rejects /register with a clear error and the user is
|
||||
* pointed at the right setting in the UI.
|
||||
*/
|
||||
pin: string
|
||||
}
|
||||
|
||||
export interface RegisterPhoneNumberResult {
|
||||
success: boolean
|
||||
/**
|
||||
* True when Meta indicated the number was already registered to
|
||||
* THIS app — same outcome as a fresh registration from the
|
||||
* caller's POV, surfaced separately for logging clarity.
|
||||
*/
|
||||
alreadyRegistered: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a phone number for inbound webhook events.
|
||||
*
|
||||
* Errors that should be surfaced verbatim to the user:
|
||||
* * Missing / wrong PIN → "Two-step verification PIN required..."
|
||||
* * No 2FA enabled → "Two-factor authentication is not on..."
|
||||
* * Number on other app → "Number is registered to another app..."
|
||||
*/
|
||||
export async function registerPhoneNumber(
|
||||
args: RegisterPhoneNumberArgs
|
||||
): Promise<RegisterPhoneNumberResult> {
|
||||
const { phoneNumberId, accessToken, pin } = args
|
||||
const url = `${META_API_BASE}/${phoneNumberId}/register`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ messaging_product: 'whatsapp', pin }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true, alreadyRegistered: false }
|
||||
}
|
||||
|
||||
// Meta returns an error envelope with a code. Code 133005 + the
|
||||
// text "already registered" appears when the number is already
|
||||
// subscribed to this app — that's success from the caller's
|
||||
// perspective, surface it as such.
|
||||
let data: { error?: { message?: string; code?: number; error_subcode?: number } } = {}
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
/* keep empty */
|
||||
}
|
||||
const message = data.error?.message ?? `Meta API error: ${response.status}`
|
||||
if (/already.*registered/i.test(message)) {
|
||||
return { success: true, alreadyRegistered: true }
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
export interface SubscribeWabaToAppArgs {
|
||||
wabaId: string
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe the WABA to this Meta app's webhook. Idempotent — Meta
|
||||
* returns success even when the subscription already exists.
|
||||
*/
|
||||
export async function subscribeWabaToApp(
|
||||
args: SubscribeWabaToAppArgs
|
||||
): Promise<void> {
|
||||
const { wabaId, accessToken } = args
|
||||
const url = `${META_API_BASE}/${wabaId}/subscribed_apps`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!response.ok) {
|
||||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export interface GetSubscribedAppsArgs {
|
||||
wabaId: string
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export interface SubscribedApp {
|
||||
whatsapp_business_api_data?: {
|
||||
id?: string
|
||||
name?: string
|
||||
link?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic — fetch the list of apps currently subscribed to this
|
||||
* WABA. The UI uses this to confirm OUR app is in the list when
|
||||
* the user clicks Verify Registration.
|
||||
*/
|
||||
export async function getSubscribedApps(
|
||||
args: GetSubscribedAppsArgs
|
||||
): Promise<SubscribedApp[]> {
|
||||
const { wabaId, accessToken } = args
|
||||
const url = `${META_API_BASE}/${wabaId}/subscribed_apps`
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!response.ok) {
|
||||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||||
}
|
||||
const data = (await response.json()) as { data?: SubscribedApp[] }
|
||||
return data.data ?? []
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sending
|
||||
// ============================================================
|
||||
|
||||
182
src/lib/whatsapp/registration.test.ts
Normal file
182
src/lib/whatsapp/registration.test.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getSubscribedApps,
|
||||
registerPhoneNumber,
|
||||
subscribeWabaToApp,
|
||||
} from './meta-api';
|
||||
|
||||
function okResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('registerPhoneNumber', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn().mockResolvedValue(okResponse({ success: true }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('POSTs to /{phone_number_id}/register with messaging_product + pin', async () => {
|
||||
const result = await registerPhoneNumber({
|
||||
phoneNumberId: 'PNID_123',
|
||||
accessToken: 'tok',
|
||||
pin: '123456',
|
||||
});
|
||||
expect(result).toEqual({ success: true, alreadyRegistered: false });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toContain('/PNID_123/register');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers.Authorization).toBe('Bearer tok');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
messaging_product: 'whatsapp',
|
||||
pin: '123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats "already registered" as success (idempotent re-save)', async () => {
|
||||
// This is the silent-failure case we're guarding against — Meta
|
||||
// returns 400 + "Phone number is already registered" when the
|
||||
// number was previously registered to THIS app. From the user's
|
||||
// POV that's the desired outcome, surface it as success.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
errorResponse(400, {
|
||||
error: {
|
||||
message: 'Phone number is already registered to this app.',
|
||||
code: 133005,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await registerPhoneNumber({
|
||||
phoneNumberId: 'PNID_123',
|
||||
accessToken: 'tok',
|
||||
pin: '123456',
|
||||
});
|
||||
expect(result).toEqual({ success: true, alreadyRegistered: true });
|
||||
});
|
||||
|
||||
it("surfaces Meta's PIN-required error verbatim so the UI can show it", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
errorResponse(400, {
|
||||
error: {
|
||||
message:
|
||||
"Two-step verification PIN required. Set one in Meta WhatsApp Manager → Two-step verification.",
|
||||
code: 133007,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
registerPhoneNumber({
|
||||
phoneNumberId: 'P',
|
||||
accessToken: 't',
|
||||
pin: '000000',
|
||||
}),
|
||||
).rejects.toThrow(/Two-step verification PIN required/);
|
||||
});
|
||||
|
||||
it('surfaces generic Meta errors as throw', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
errorResponse(500, {
|
||||
error: { message: 'Internal Meta error' },
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
registerPhoneNumber({
|
||||
phoneNumberId: 'P',
|
||||
accessToken: 't',
|
||||
pin: '123456',
|
||||
}),
|
||||
).rejects.toThrow(/Internal Meta error/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeWabaToApp', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn().mockResolvedValue(okResponse({ success: true }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('POSTs to /{waba_id}/subscribed_apps with bearer token', async () => {
|
||||
await subscribeWabaToApp({ wabaId: 'WABA_1', accessToken: 'tok' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toContain('/WABA_1/subscribed_apps');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers.Authorization).toBe('Bearer tok');
|
||||
});
|
||||
|
||||
it('throws on non-OK', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
errorResponse(403, { error: { message: 'Insufficient permissions' } }),
|
||||
);
|
||||
await expect(
|
||||
subscribeWabaToApp({ wabaId: 'WABA_1', accessToken: 'tok' }),
|
||||
).rejects.toThrow(/Insufficient permissions/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubscribedApps', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns the list of subscribed apps', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
okResponse({
|
||||
data: [
|
||||
{
|
||||
whatsapp_business_api_data: {
|
||||
id: 'APP1',
|
||||
name: 'wacrm',
|
||||
link: 'https://example.com/app',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const apps = await getSubscribedApps({
|
||||
wabaId: 'WABA_1',
|
||||
accessToken: 'tok',
|
||||
});
|
||||
expect(apps).toHaveLength(1);
|
||||
expect(apps[0].whatsapp_business_api_data?.name).toBe('wacrm');
|
||||
});
|
||||
|
||||
it('returns empty array when Meta returns no data field', async () => {
|
||||
fetchMock.mockResolvedValueOnce(okResponse({}));
|
||||
const apps = await getSubscribedApps({
|
||||
wabaId: 'WABA_1',
|
||||
accessToken: 'tok',
|
||||
});
|
||||
expect(apps).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws on non-OK', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
errorResponse(401, { error: { message: 'Invalid OAuth token' } }),
|
||||
);
|
||||
await expect(
|
||||
getSubscribedApps({ wabaId: 'WABA_1', accessToken: 'tok' }),
|
||||
).rejects.toThrow(/Invalid OAuth token/);
|
||||
});
|
||||
});
|
||||
@ -138,6 +138,16 @@ export interface WhatsAppConfig {
|
||||
verify_token?: string;
|
||||
status: 'connected' | 'disconnected';
|
||||
connected_at?: string;
|
||||
/**
|
||||
* Set when POST /{phone_number_id}/register last succeeded. NULL
|
||||
* means the number was saved but never actually subscribed for
|
||||
* webhooks on Meta's side — inbound events will be silently lost.
|
||||
*/
|
||||
registered_at?: string;
|
||||
/** Set when POST /{waba_id}/subscribed_apps last succeeded. */
|
||||
subscribed_apps_at?: string;
|
||||
/** Last error from /register; cleared on success. */
|
||||
last_registration_error?: string;
|
||||
}
|
||||
|
||||
// Raw Meta status enum. We persist this verbatim from Meta (sync + webhook)
|
||||
|
||||
45
supabase/migrations/015_whatsapp_config_registration.sql
Normal file
45
supabase/migrations/015_whatsapp_config_registration.sql
Normal file
@ -0,0 +1,45 @@
|
||||
-- ============================================================
|
||||
-- whatsapp_config: track Meta Cloud API registration state
|
||||
--
|
||||
-- Why this exists:
|
||||
-- Saving a row to whatsapp_config does NOT make a phone number
|
||||
-- actually receive webhook events from Meta. Two extra Cloud API
|
||||
-- calls are required:
|
||||
--
|
||||
-- POST /{phone_number_id}/register — subscribes the number
|
||||
-- with a 2FA PIN, makes
|
||||
-- it routable to OUR app
|
||||
-- POST /{waba_id}/subscribed_apps — subscribes the WABA
|
||||
-- (one-time per app, but
|
||||
-- idempotent so we can
|
||||
-- call on every save)
|
||||
--
|
||||
-- Until those two complete successfully, Meta routes inbound
|
||||
-- events to whichever app last registered the number (often the
|
||||
-- one that did Embedded Signup originally). Symptom: a second
|
||||
-- wacrm user adds a second number under the same WABA, the UI
|
||||
-- reports "Connected" because metadata verification succeeds,
|
||||
-- but Meta's activity log shows zero events for that number.
|
||||
--
|
||||
-- These columns let the UI distinguish "credentials saved" from
|
||||
-- "actually live" and let users retry registration without
|
||||
-- re-entering everything.
|
||||
--
|
||||
-- Backfill: every column is nullable. Existing rows survive with
|
||||
-- NULL values; the UI shows them as "registration status unknown —
|
||||
-- click Verify Registration" and the diagnostic endpoint fills the
|
||||
-- timestamps on the next probe.
|
||||
--
|
||||
-- Idempotent — safe to re-run.
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE whatsapp_config
|
||||
ADD COLUMN IF NOT EXISTS registered_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS subscribed_apps_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS last_registration_error TEXT;
|
||||
|
||||
-- Index supports the "find all numbers awaiting registration"
|
||||
-- query a future admin dashboard might want; cheap to maintain.
|
||||
CREATE INDEX IF NOT EXISTS idx_whatsapp_config_registered_at
|
||||
ON whatsapp_config (registered_at)
|
||||
WHERE registered_at IS NULL;
|
||||
Loading…
Reference in New Issue
Block a user