mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Closes the silent-failure bug a user reported: when multiple phone
numbers are connected to the same WABA, only the first one actually
receives webhook events from Meta. The wacrm UI shows "Connected"
because the metadata probe succeeds, but Meta's activity log is
empty for every subsequent number.
Root cause
----------
The POST /api/whatsapp/config handler verified credentials with a
GET /{phone_number_id} probe and saved them to Supabase — but
never called Meta's two registration endpoints that actually wire
a number for inbound events:
1. POST /{phone_number_id}/register
Subscribes the number to THIS app's webhook. Requires a
6-digit 2FA PIN the user previously set in Meta WhatsApp
Manager → Two-step verification. Without it, Meta routes
events for that number to whichever app last claimed it.
2. POST /{waba_id}/subscribed_apps
Subscribes the WABA itself to this app. Required once per
WABA, but idempotent on Meta's side so we call on every
save without harm.
The first number "worked" because someone (the user, Embedded
Signup, or Meta dashboard) had already done both calls for it
out-of-band. Every subsequent number silently failed.
What this PR does
-----------------
* New helpers in meta-api.ts: registerPhoneNumber (treats Meta's
"already registered" 400 as success), subscribeWabaToApp
(idempotent), getSubscribedApps (for the diagnostic).
* POST /api/whatsapp/config now:
- Accepts an optional `pin` (6-digit, validated).
- Calls subscribeWabaToApp on every save (idempotent).
- Calls registerPhoneNumber on first save and whenever a PIN
is supplied. Skipped when the same number is already
registered locally and no fresh PIN is provided — re-running
/register with a stale PIN would actually undo the active
subscription.
- Persists registered_at / subscribed_apps_at timestamps and
last_registration_error for diagnostic surfacing.
- Returns {success, registered, registration_error} so the UI
can distinguish "creds valid" from "actually live".
- Saves the row even when /register fails so the user can
retry without re-entering everything.
* New diagnostic GET /api/whatsapp/config/verify-registration —
hits Meta to confirm the saved number is reachable + the WABA
is subscribed. Returns a per-check breakdown the UI renders.
Solves the misleading-green-banner UX failure that caused the
bug to go unnoticed in the first place.
* Migration 015 adds registered_at, subscribed_apps_at,
last_registration_error to whatsapp_config. All nullable so
existing rows survive — the UI labels them "registration status
unknown" until the user clicks Verify or re-saves.
* whatsapp-config.tsx:
- New PIN input with help text pointing to the exact Meta
Manager setting and explaining why it's needed.
- Two-tier status: existing "Credentials valid" alert (relabeled
from "Connected" since it was misleadingly broad) PLUS a new
Registration Status alert that flips emerald/amber based on
the registered_at timestamp.
- "Verify with Meta" button on the registration alert pings
the diagnostic endpoint and renders the per-check result
inline. Surfaces last_registration_error verbatim with a
pointer to retry.
- PIN clears from state after a successful save so it can't
accidentally retrigger /register on the next save.
Tests + checks
--------------
* 9 new fetch-mock tests cover registerPhoneNumber (happy path,
already-registered passthrough, PIN-required error verbatim,
generic Meta error), subscribeWabaToApp (URL + bearer + error),
getSubscribedApps (data, empty, error).
* 265/265 tests pass. Lint 0 errors. Typecheck clean. Build
registers both /api/whatsapp/config and
/api/whatsapp/config/verify-registration.
Out of scope
------------
* Multi-number-per-CRM-user (current schema is UNIQUE(user_id)
so one CRM user = one number). The reported bug was
multi-number-per-WABA across multiple CRM users, which this
fully resolves.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
183 lines
5.3 KiB
TypeScript
183 lines
5.3 KiB
TypeScript
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/);
|
|
});
|
|
});
|