fix: identify WhatsApp senders by business-scoped user ID, not phone only (#519) - #533
Open
ArnasDon wants to merge 1 commit into
Open
fix: identify WhatsApp senders by business-scoped user ID, not phone only (#519)#533ArnasDon wants to merge 1 commit into
ArnasDon wants to merge 1 commit into
Conversation
…only (#519) Meta's username rollout stopped sending phone numbers. For a customer who has adopted a WhatsApp username and has no recent interaction history with the business, the message webhook omits `messages[].from` AND `contacts[].wa_id` entirely — the sender is identified only by `from_user_id` / `user_id`, a business-scoped user ID (BSUID). The webhook keyed everything on the phone. `normalizePhone(undefined)` returned '', `findExistingContact('')` returns null by design, and migration 022's unique index is partial (`WHERE phone_normalized <> ''`) — so nothing stopped a brand-new contact, and a brand-new conversation, being inserted for EVERY inbound message from the same person. Replies were impossible on top of that: every send path demanded a valid E.164 number and threw without one. Inbound: - `src/lib/whatsapp/wa-identity.ts` (new, unit-tested) collapses a message + its `contacts[]` entry into one identity, handling both the legacy and username-only payload shapes. - Migration 040 adds `contacts.wa_user_id` / `wa_parent_user_id` / `wa_username` with a partial UNIQUE (account_id, wa_user_id) — the same guarantee 022 gave phone numbers. `phone` stays NOT NULL and holds '' for a BSUID-only contact, which 022's partial index already tolerates. - `findOrCreateContact` resolves by BSUID first, phone second, and backfills whichever key it just learned onto the matched row. That backfill is what keeps history in one place across the transition: a contact we've only known by phone gets its BSUID stamped the first time Meta sends one, so the next phone-less message still finds it. - A delivery carrying neither key is now dropped with a log instead of creating an unmatchable row. Outbound — Meta uses `recipient` for a BSUID and `to` + `recipient_type` for a phone number, never both: - `recipientFields()` in `meta-api.ts` picks the right field for all six senders. The two can't be confused: a sanitized phone is digits only and a BSUID always carries a two-letter prefix and a dot. - `resolveContactSendTarget()` gives the inbox composer, the Flows engine (3 sites), automations, and reactions one shared answer for "how do we address this contact". Phone still wins when usable, since only it supports the trunk-prefix variant retry; a BSUID gets a single attempt and never triggers the phone auto-correct write-back. Also: a BSUID-only contact rendered a blank phone row in the inbox and contact detail. `contactHandle()` shows `@username`, or the BSUID, in that slot instead. Broadcasts are NOT covered — `/api/whatsapp/broadcast`, `broadcast-core` and `broadcast-resume` carry a `phone`-shaped contract through the public API, and a BSUID-only contact is skipped there exactly as a contact with no phone always was. Worth a follow-up issue. The migration was structurally reviewed only — there is no local Postgres in this repo — but `.github/workflows/migrations.yml` replays it from scratch in CI, and `supabase/ci/verify-schema.sql` now asserts the new index exists so an IF NOT EXISTS typo can't pass silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Updates to Preview Branch (fix/519-whatsapp-bsuid) ↗︎
Tasks are run on every commit but only new migration files are pushed.
Preview Branch Database Settings ↗︎. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #519.
The bug, confirmed against Meta's docs
Meta's business-scoped user IDs rollout changed the inbound payload. For a customer who has adopted a WhatsApp username and has no recent interaction history with the business, Meta omits the phone number entirely:
fromandwa_idare both absent. The webhook keyed everything on the phone, and the failure chained:normalizePhone(undefined)→''findExistingContact('')returnsnullby designWHERE phone_normalized <> ''So the insert always succeeded. One new contact and one new conversation per inbound message from the same person — the same fragmentation as #363, on a key nobody had guarded. And replies were impossible regardless: every send path required a valid E.164 number and threw without one.
Inbound
src/lib/whatsapp/wa-identity.ts(new, unit-tested) collapses a message plus itscontacts[]entry into a single identity, handling both payload shapes. A BSUID field that isn't BSUID-shaped is discarded rather than stored — a junk value inwa_user_idwould become a permanent wrong contact key.wa_user_id/wa_parent_user_id/wa_usernamewith a partialUNIQUE (account_id, wa_user_id)— the guarantee 022 gave phone numbers.phonestaysNOT NULLand holds''for a BSUID-only contact, which 022's partial index already tolerates and which keepsContact.phonea plainstringin the app.findOrCreateContactresolves by BSUID first, phone second, then backfills whichever key it just learned. That backfill is the part that matters for the transition: a contact we've only ever known by phone gets its BSUID stamped the first time Meta sends one, so the next phone-less message resolves to the same row instead of forking. The reverse also holds — a BSUID-only contact gains its number the moment Meta discloses it.200, so Meta doesn't retry) instead of creating an unmatchable row.Outbound
Meta uses
recipientfor a BSUID andto+recipient_typefor a phone number — sending both is a 400.recipientFields()inmeta-api.tspicks the right field for all six senders. The two are never ambiguous: a sanitized phone is digits only; a BSUID always carries a two-letter prefix and a dot. A test asserts a range of real phone formats never trip the BSUID branch.resolveContactSendTarget()gives one shared answer to "how do we address this contact" for the inbox composer, the Flows engine (3 call sites), automations, and reactions. Those last two matter most in practice — an automation or flow auto-replying to a BSUID-only sender previously threwcontact phone invalid.Also
A BSUID-only contact rendered a blank phone row in the inbox sidebar, the thread header, and contact detail.
contactHandle()shows@username— or the BSUID — in that slot. No new i18n keys.Verification
All four CI steps pass locally:
lint(0 errors, 37 warnings — same asorigin/main),typecheck,test(871 passing, 46 new),build.The new tests reproduce the reported scenario end-to-end through the real
POSThandler: a username-only payload creates exactly one contact, a second message from the same BSUID reuses it, a transition payload backfills the BSUID onto a phone-matched contact, and the legacy phone-only path is asserted byte-for-byte unchanged.One regression I introduced and then caught: routing the name through the display fallback meant an inbound message from a contact with no WhatsApp profile name would overwrite an agent's hand-edited name with the phone number. The backfill now only ever adopts a label Meta actually supplied, with a test pinning it.
The migration is structurally reviewed only — there is no local Postgres in this repo, so it has not been executed anywhere.
.github/workflows/migrations.ymlreplays it from scratch in CI, andsupabase/ci/verify-schema.sqlnow asserts the new index exists, so anIF NOT EXISTStypo can't apply cleanly and guarantee nothing.Out of scope
Broadcasts do not support BSUID recipients.
/api/whatsapp/broadcast,broadcast-core.tsandbroadcast-resume.tsthread aphone-shaped contract through the public API and validate withisValidE164. A BSUID-only contact is skipped there exactly as a contact with no phone number always was — not a regression from this PR, but worth its own issue.🤖 Generated with Claude Code