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 { NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { createClient as createAdminClient } from '@supabase/supabase-js'
|
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'
|
import { encrypt, decrypt } from '@/lib/whatsapp/encryption'
|
||||||
|
|
||||||
// Lazy-initialised service-role client. We need it to detect a
|
// 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 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) {
|
if (!access_token || !phone_number_id) {
|
||||||
return NextResponse.json(
|
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.
|
// Reject if another user has already claimed this phone_number_id.
|
||||||
// wacrm is single-tenant-per-WhatsApp-number — letting two users
|
// wacrm is single-tenant-per-WhatsApp-number — letting two users
|
||||||
// bind the same number causes the webhook's `.single()` lookup to
|
// 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
|
const { data: existing } = await supabase
|
||||||
.from('whatsapp_config')
|
.from('whatsapp_config')
|
||||||
.select('id')
|
.select('id, registered_at, phone_number_id')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
|
|
||||||
|
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: 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) {
|
if (existing) {
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await supabase
|
||||||
.from('whatsapp_config')
|
.from('whatsapp_config')
|
||||||
.update({
|
.update(baseRow)
|
||||||
phone_number_id,
|
|
||||||
waba_id: waba_id || null,
|
|
||||||
access_token: encryptedAccessToken,
|
|
||||||
verify_token: encryptedVerifyToken,
|
|
||||||
status: 'connected',
|
|
||||||
connected_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
@ -242,15 +330,7 @@ export async function POST(request: Request) {
|
|||||||
} else {
|
} else {
|
||||||
const { error: insertError } = await supabase
|
const { error: insertError } = await supabase
|
||||||
.from('whatsapp_config')
|
.from('whatsapp_config')
|
||||||
.insert({
|
.insert({ user_id: user.id, ...baseRow })
|
||||||
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(),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (insertError) {
|
if (insertError) {
|
||||||
console.error('Error inserting whatsapp_config:', 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) {
|
} catch (error) {
|
||||||
console.error('Error in WhatsApp config POST:', error)
|
console.error('Error in WhatsApp config POST:', error)
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
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 [wabaId, setWabaId] = useState('');
|
||||||
const [accessToken, setAccessToken] = useState('');
|
const [accessToken, setAccessToken] = useState('');
|
||||||
const [verifyToken, setVerifyToken] = useState('');
|
const [verifyToken, setVerifyToken] = useState('');
|
||||||
|
const [pin, setPin] = useState('');
|
||||||
const [tokenEdited, setTokenEdited] = useState(false);
|
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 =
|
const webhookUrl =
|
||||||
typeof window !== 'undefined'
|
typeof window !== 'undefined'
|
||||||
? `${window.location.origin}/api/whatsapp/webhook`
|
? `${window.location.origin}/api/whatsapp/webhook`
|
||||||
@ -79,6 +99,7 @@ export function WhatsAppConfig() {
|
|||||||
setWabaId(data.waba_id || '');
|
setWabaId(data.waba_id || '');
|
||||||
setAccessToken(MASKED_TOKEN);
|
setAccessToken(MASKED_TOKEN);
|
||||||
setVerifyToken('');
|
setVerifyToken('');
|
||||||
|
setPin('');
|
||||||
setTokenEdited(false);
|
setTokenEdited(false);
|
||||||
} else {
|
} else {
|
||||||
setConfig(null);
|
setConfig(null);
|
||||||
@ -86,8 +107,11 @@ export function WhatsAppConfig() {
|
|||||||
setWabaId('');
|
setWabaId('');
|
||||||
setAccessToken('');
|
setAccessToken('');
|
||||||
setVerifyToken('');
|
setVerifyToken('');
|
||||||
|
setPin('');
|
||||||
setTokenEdited(false);
|
setTokenEdited(false);
|
||||||
}
|
}
|
||||||
|
// Clear any stale probe result when reloading the row.
|
||||||
|
setRegistrationProbe(null);
|
||||||
|
|
||||||
// Then verify health via the API (decrypts token + pings Meta)
|
// Then verify health via the API (decrypts token + pings Meta)
|
||||||
if (data) {
|
if (data) {
|
||||||
@ -151,6 +175,10 @@ export function WhatsAppConfig() {
|
|||||||
phone_number_id: phoneNumberId.trim(),
|
phone_number_id: phoneNumberId.trim(),
|
||||||
waba_id: wabaId.trim() || null,
|
waba_id: wabaId.trim() || null,
|
||||||
verify_token: verifyToken.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()) {
|
if (tokenEdited && accessToken !== MASKED_TOKEN && accessToken.trim()) {
|
||||||
@ -179,11 +207,28 @@ export function WhatsAppConfig() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(
|
// The route now returns a structured outcome:
|
||||||
data.phone_info?.verified_name
|
// * registered=true → number is live, events will flow
|
||||||
? `Connected to ${data.phone_info.verified_name}`
|
// * registered=false → credentials saved but /register
|
||||||
: 'Configuration saved successfully'
|
// 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
|
||||||
|
? `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);
|
if (user) await fetchConfig(user.id);
|
||||||
} catch (err) {
|
} 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() {
|
async function handleReset() {
|
||||||
if (!confirm('This will delete the current WhatsApp config so you can re-enter it. Continue?')) {
|
if (!confirm('This will delete the current WhatsApp config so you can re-enter it. Continue?')) {
|
||||||
return;
|
return;
|
||||||
@ -320,17 +391,125 @@ export function WhatsAppConfig() {
|
|||||||
<XCircle className="size-4 text-red-500" />
|
<XCircle className="size-4 text-red-500" />
|
||||||
)}
|
)}
|
||||||
<AlertTitle className="text-white mb-0">
|
<AlertTitle className="text-white mb-0">
|
||||||
{connectionStatus === 'connected' ? 'Connected' : 'Not Connected'}
|
{connectionStatus === 'connected' ? 'Credentials valid' : 'Not Connected'}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
</div>
|
</div>
|
||||||
<AlertDescription className="text-slate-400">
|
<AlertDescription className="text-slate-400">
|
||||||
{connectionStatus === 'connected'
|
{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 ||
|
: statusMessage ||
|
||||||
'Configure your Meta API credentials below to connect your WhatsApp Business account.'}
|
'Configure your Meta API credentials below to connect your WhatsApp Business account.'}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</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 */}
|
{/* API Credentials */}
|
||||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -406,6 +585,39 @@ export function WhatsAppConfig() {
|
|||||||
A custom string you create. Must match the token you set in Meta webhook settings.
|
A custom string you create. Must match the token you set in Meta webhook settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -65,6 +65,152 @@ export async function verifyPhoneNumber(
|
|||||||
return response.json()
|
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
|
// 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;
|
verify_token?: string;
|
||||||
status: 'connected' | 'disconnected';
|
status: 'connected' | 'disconnected';
|
||||||
connected_at?: string;
|
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)
|
// 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