On this page
What this isWho this is forThe core problem, in one sequenceWhat changed, in one paragraphWhat this PR is honest aboutWhere to go nextHidden-Number WhatsApp Contacts — Overview
What this is
This PR ("feat(whatsapp): keep hidden-number contacts on one conversation", branch mo/whatsapp_fix_hidden_numbers) fixes a gap in Dentolize's WhatsApp integration: contacts who use WhatsApp's username privacy feature to hide their phone number from businesses they haven't recently talked to.
When that happens, Meta's webhook does not send a phone number at all. Instead it sends a business-scoped user ID (BSUID — Meta's own term, e.g. EG.1693676845194969), which identifies the contact only to this WhatsApp Business Account. Before this PR, every part of the WhatsApp pipeline — inbound message handling, outbound replies, patient/lead matching, and the conversation lists in clinic-web and clinic-mobile — assumed a phone number was always present. A message from a hidden-number contact broke the conversation upsert outright (Prisma error: Argument 'phone' is missing), and even where it didn't crash, a reply addressed with WhatsApp's to field (phone-only) would silently fail to reach a BSUID-only contact, because Meta requires such replies to use the recipient field instead.
This PR makes the phone number optional throughout the conversation model and teaches every code path that touches it — schema, webhook parsing, outbound replies, patient/lead linking, and the UI — to fall back to the BSUID (and, where Meta supplies it, the contact's @username) when no number is on file.
Who this is for
- Clinic staff working WhatsApp Business conversations (reception, marketing, whoever answers
/whatsapp/chats) — a contact who hides their number now shows up as a conversation instead of an error, addressed by@usernameor their BSUID instead of a phone number. - Contacts who use WhatsApp's username-privacy setting — they can message a clinic and stay reachable without exposing their number, and if they later show their number (or the clinic already had it on file from a prior exchange), they stay on the same conversation rather than fragmenting into two.
- Engineers on the WhatsApp integration — the PR also fixes an unrelated boot-time bug in
whatsapp-official(env vars loaded after the Postgres pool was already built) that would otherwise be masked by this same deploy.
The core problem, in one sequence
- A contact enables "Manage who can see when you're online" / username privacy in WhatsApp, or Meta simply hasn't linked the clinic to this contact's number recently enough (see Glossary for Meta's exact 30-day/contact-book rule).
- They message the clinic. Meta's webhook payload has
contacts[0].user_id(a BSUID) instead ofcontacts[0].wa_id(a phone number), and nophoneat all. - Dentolize used to build its
OnlineConversationlookup key entirely from the phone number. With no phone, the upsert had nothing to key on, and creating a new row failed schema validation becausephonewas a requiredString. - Even a conversation that somehow existed already couldn't be replied to: outbound Graph API calls always set
to: <phone>, but Meta only delivers to a BSUID-addressed contact viarecipient: <bsuid>.
What changed, in one paragraph
OnlineConversation.phone is now nullable, and the model gains bsuid and username columns with their own unique index (companyId, bsuid). The webhook handler in whatsapp-official records whichever identifiers Meta sends on every message, looks up an existing conversation by either address, and never overwrites an address the conversation already has with a blank one. Every place that used to build a Graph API payload with to: <phone> now picks to or recipient based on which address the conversation actually has. Patient and lead matching, which can only work by phone number (patient records don't store a BSUID), now explicitly reads the conversation's stored phone rather than assuming the inbound message address was a phone number. clinic-web and clinic-mobile display the phone if there is one, else @username, else the raw BSUID. See Identifying a Contact, Receiving & Replying to Messages, and Patient Linking & What Staff See for the technical detail.
What this PR is honest about
- The sandbox used for this documentation has no seeded WhatsApp conversations (
whatsapp=falsein this environment's settings), so the screenshots in the Walkthrough show the real, current screens — empty — rather than a staged hidden-number conversation. The Business/Technical explanations in each feature page are verified directly against the code in/work/repo, not against a live example. - The two
isBsuidchecks in this PR are two different, independently-implemented mechanisms — a regex in the main GraphQL server (packages/server/.../official-whats-app.utils.js) and a "did Meta sendwa_id?" boolean in the separatewhatsapp-officialNestJS service (packages/whatsapp-official/.../messages.service.ts). They agree in practice but are not the same code. See Receiving & Replying to Messages. - A contact who can only be reached by BSUID cannot receive an online-payment link —
handleOnlinePayment.tsnow throws explicitly rather than silently mishandling a non-dialable ID, but no alternative payment flow was added for that case. See For Quality. - Rows created before this migration that already have conflicting phone/BSUID matches are not merged. If a contact's phone number and BSUID already resolve to two different existing
OnlineConversationrows (possible for anyone whose conversation predates this column), the code keeps the number-based row and leaves the BSUID row alone rather than merging them or failing. See Identifying a Contact.
Where to go next
- New to this fix? Start with the Walkthrough for the real screens this PR touches.
- Reviewing or extending the code? Each Feature breakdown page has a Business view and a Technical view with exact file references.
- Preparing to support, train on, or test this? See the By team pages.