diff --git a/src/app/api/whatsapp/config/route.ts b/src/app/api/whatsapp/config/route.ts index 92738d1..3480bd4 100644 --- a/src/app/api/whatsapp/config/route.ts +++ b/src/app/api/whatsapp/config/route.ts @@ -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() + 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) { const { error: updateError } = await supabase .from('whatsapp_config') - .update({ - 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(), - }) + .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 }) diff --git a/src/app/api/whatsapp/config/verify-registration/route.ts b/src/app/api/whatsapp/config/verify-registration/route.ts new file mode 100644 index 0000000..adcdc5d --- /dev/null +++ b/src/app/api/whatsapp/config/verify-registration/route.ts @@ -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, + }) +} diff --git a/src/components/settings/whatsapp-config.tsx b/src/components/settings/whatsapp-config.tsx index b106890..05edb1a 100644 --- a/src/components/settings/whatsapp-config.tsx +++ b/src/components/settings/whatsapp-config.tsx @@ -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; + errors?: string[]; + last_registration_error?: string | null; + registered_at?: string | null; + subscribed_apps_at?: string | null; + }; + const [registrationProbe, setRegistrationProbe] = + useState(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; } - toast.success( - data.phone_info?.verified_name - ? `Connected to ${data.phone_info.verified_name}` - : 'Configuration saved successfully' - ); + // 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 + ? `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() { )} - {connectionStatus === 'connected' ? 'Connected' : 'Not Connected'} + {connectionStatus === 'connected' ? 'Credentials valid' : 'Not 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 || 'Configure your Meta API credentials below to connect your WhatsApp Business account.'} + {/* 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 && ( + +
+
+ {isRegistered ? ( + + ) : ( + + )} + + {isRegistered + ? 'Registered — Meta will deliver events to wacrm' + : 'Not registered — Meta will not deliver events'} + +
+ +
+ + {isRegistered ? ( + <> + Subscribed since{' '} + {config.registered_at + ? new Date(config.registered_at).toLocaleString() + : 'unknown'} + . Click Verify with Meta if events + stop arriving. + + ) : lastRegistrationError ? ( + <> + Last attempt failed with:{' '} + + "{lastRegistrationError}" + + . 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. + + )} + + + {registrationProbe && ( +
+

+ Diagnostic — last run: {' '} + + {registrationProbe.live ? 'live' : 'not live'} + +

+
    + {Object.entries(registrationProbe.checks).map(([k, v]) => ( +
  • + {v === true ? ( + + ) : v === false ? ( + + ) : ( + + )} + {k} +
  • + ))} +
+ {(registrationProbe.errors ?? []).length > 0 && ( +
    + {registrationProbe.errors?.map((e, i) => ( +
  • • {e}
  • + ))} +
+ )} +
+ )} +
+ )} + {/* API Credentials */} @@ -406,6 +585,39 @@ export function WhatsAppConfig() { A custom string you create. Must match the token you set in Meta webhook settings.

+ +
+ + + 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" + /> +

+ Required the first time you connect a number, and any + time you swap to a different number. Set it in{' '} + + Meta Business Manager → WhatsApp Accounts → Phone + Numbers → Two-step verification + + . 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. +

+
diff --git a/src/lib/whatsapp/meta-api.ts b/src/lib/whatsapp/meta-api.ts index a190007..9bea86b 100644 --- a/src/lib/whatsapp/meta-api.ts +++ b/src/lib/whatsapp/meta-api.ts @@ -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 { + 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 { + 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 { + 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 // ============================================================ diff --git a/src/lib/whatsapp/registration.test.ts b/src/lib/whatsapp/registration.test.ts new file mode 100644 index 0000000..bb69a55 --- /dev/null +++ b/src/lib/whatsapp/registration.test.ts @@ -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; + 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; + 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; + 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/); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index e656a72..4685d2a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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) diff --git a/supabase/migrations/015_whatsapp_config_registration.sql b/supabase/migrations/015_whatsapp_config_registration.sql new file mode 100644 index 0000000..12cc610 --- /dev/null +++ b/supabase/migrations/015_whatsapp_config_registration.sql @@ -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;