Receiving & Replying to Messages
Business view
Two things have to happen correctly for a hidden-number contact to have a working conversation with the clinic:
- Receiving — when Meta forwards their message to Dentolize, the system has to recognize it as a message at all (not fail outright) and figure out who it's from using whatever identifier Meta actually provided.
- Replying — when the clinic (or Dentolize's auto-responder) sends something back, it has to be addressed the way that specific contact is reachable. WhatsApp's Cloud API will not deliver a phone-addressed reply to someone who only gave a BSUID, and vice versa.
Before this PR, both of these assumed a phone number always existed. Receiving a BSUID-only message crashed the conversation upsert; replying always used the phone-style address, which Meta silently ignores for a BSUID-only contact.
Technical view
Receiving: telling a phone from a BSUID in the webhook payload
Meta's webhook shape changed to account for this case. OnMessageDto (packages/whatsapp-official/src/messages/dto/on-message.dto.ts:205-207, 214-216) now types the relevant fields as optional and adds the BSUID-carrying ones:
contacts?: [{ profile: { name: string; username?: string }; wa_id?: string; user_id?: string }];
messages?: {
from?: string;
from_user_id?: string;
...
}[];
Per the code comment, Meta omits wa_id (and sends user_id instead) "when the user has usernames enabled, is not in the contact book, and had no message/call exchange in the last 30 days." MessagesService.onMessage (packages/whatsapp-official/src/messages/messages.service.ts:85-100) reads this:
const waId = onMessageDto.entry[0].changes[0].value.contacts[0].wa_id;
const bsuid = onMessageDto.entry[0].changes[0].value.contacts[0].user_id
|| onMessageDto.entry[0].changes[0].value.messages[0].from_user_id;
const customerNumberId = waId || bsuid;
const isBsuid = !waId && !!bsuid;
if (!customerNumberId) {
console.log("No wa_id or user_id in payload", ...);
return;
}
Two things worth calling out:
bsuidis read from eithercontacts[0].user_idormessages[0].from_user_id— per the code comment, the BSUID "is on every messages webhook," so this is a belt-and-suspenders read rather than two different meanings.customerNumberIdremains a single variable holding either kind of identifier, used throughout the rest of the function (rate-limit cache keys, the response-handler'sActionHandlerArgs.customerNumberId, etc.) exactly as before. The newisBsuidboolean is threaded alongside it everywhere a caller needs to know which kind of identifier it's looking at, rather than re-deriving it — seeActionHandlerArgs.isBsuid(on-message.dto.ts:60,72) and its use atmessages.service.ts:282.- If Meta sends neither (shouldn't happen per the API contract, but the code no longer assumes it can't), the handler now logs and returns instead of crashing on
customerNumberIdbeingundefined.
The username, when present, comes from contacts[0].profile.username (messages.service.ts:153) and is written to the conversation on every message, unconditionally — unlike phone/BSUID, it's never gated by the backfill flag described in Identifying a Contact, so a later message overwrites a stale username with a fresh one.
Replying: to vs recipient
WhatsApp's Cloud API MessageReply type (on-message.dto.ts:358-373) changed from a required to: string to:
// Exactly one of `to` (phone) or `recipient` (business-scoped user ID) is sent.
to?: string;
recipient?: string;
Every place messages.service.ts builds a reply now branches on isBsuid instead of hard-coding to:
...(isBsuid ? { recipient: customerNumberId } : { to: customerNumberId }),
This appears twice: the main auto-response reply (messages.service.ts:331) and the branch-location reply sent after certain menu actions (messages.service.ts:475).
The main GraphQL server has the same problem for staff-initiated replies (a human agent typing in the Chats screen) and solves it with a small helper, waRecipient (packages/server/src/resolvers/mutations/actions/officialWhatsApp/official-whats-app.utils.js:13):
export const waRecipient = ({ phone, bsuid } = {}) => (phone ? { to: phone } : { recipient: bsuid })
Note this prefers phone whenever the conversation has one, even if it also has a bsuid on file — once a number is known, replies keep using it, which per the PR description matches Meta's own behavior ("Meta ignores recipient when both are present"). waRecipient is used in handleSendWhatsappMessage.js (server mutation, :50) and handleSendWhatsAppMessageUrl (official-whats-app.utils.js:97, used for link/CTA-button messages).
handleSendWhatsappMessage (packages/server/src/resolvers/mutations/actions/officialWhatsApp/handleSendWhatsappMessage.js) also changed what it trusts as the address source. Previously the client passed phone as a mutation argument and the resolver used it directly for both the Graph API call and the Redis cache key. Now:
// :37-40 — the address comes from the conversation, not from the client
const conversationData = await prisma.onlineConversation.findUnique({
where: { id: conversation },
select: { id: true, lastMessage: true, language: true, phone: true, bsuid: true }
});
...
...waRecipient(conversationData), // :50
...
`whatsapp:phoneId:${companyId}:${conversationData.phone || conversationData.bsuid}` // :63
The GraphQL schema's phone argument on handleSendWhatsappMessage went from required to optional (packages/server/src/schema.graphql:1242) — it's effectively unused by the resolver now, kept optional rather than removed so the mobile mutation (packages/clinic-mobile/.../handleSendWhatsappMessage.js:7, also changed from String! to String) doesn't have to send a value it may not have. This is a case where the honest read of the code is that the argument is now vestigial — a follow-up cleanup could remove it from the schema entirely, but this PR didn't.
Also in this PR: a boot-time fix, unrelated to hidden numbers
packages/whatsapp-official/src/main.ts:1 adds import "dotenv/config"; as the first line of the entrypoint. The code comment and PR description explain why: the service's Postgres connection pool was previously constructed before .env was loaded, so DATABASE_URL's password could still be undefined at pool-construction time, and pg's SCRAM auth would fail at boot with "client password must be a string." This has nothing to do with the BSUID work and is a straightforward ordering fix, bundled into this branch because it blocked deploying it.