Identifying a Contact: Phone, Username & BSUID
Business view
Every WhatsApp conversation in Dentolize used to be keyed on one thing: the contact's phone number. That's a safe assumption for the vast majority of contacts, but WhatsApp lets a person hide their number from a business they haven't recently exchanged messages or calls with — a "username privacy" setting. When that happens, Meta doesn't send Dentolize a phone number at all; it sends a different, business-scoped identifier (Meta's own term is BSUID) that only means something inside this one WhatsApp Business Account.
Dentolize now stores whichever identifiers Meta actually sends — a phone number, a BSUID, and/or an @username — and treats a phone number as optional rather than something every conversation must have. The goal is that a contact who hides their number, later reveals it, or switches back and forth, is always recognized as the same conversation rather than starting a new one each time their visible identity changes.
Technical view
Schema
OnlineConversation (packages/prisma/schema.prisma:4095-4136) previously required phone: String. It now has:
phone String?
bsuid String?
username String?
...
@@unique([companyId, phone])
@@unique([companyId, bsuid])
(packages/prisma/schema.prisma:4105-4107, 4131-4132). Two independent unique indexes — a conversation is unique per company on phone and separately unique per company on BSUID, but a single row can (and typically will, once a number is learned) carry both. The migration (packages/prisma/migrations/20260818210227_add_bsuid_to_online_conversation_for_whatsapp/migration.sql) adds the two nullable columns, drops the NOT NULL constraint on phone, and adds the new unique index. It does not backfill or touch any existing row.
username has no unique constraint — it's a display convenience, not an identity key, and WhatsApp usernames aren't guaranteed unique the way a BSUID is.
Matching an inbound contact to an existing conversation
The actual matching logic lives in whatsapp-official, the standalone service that receives Meta's webhooks: OnlineConversationService.findConversationByAddress (packages/whatsapp-official/src/services/online-conversation.service.ts:19-38).
async findConversationByAddress(companyId, phone?, bsuid?) {
const byBsuid = bsuid ? await this.prisma.onlineConversation.findUnique({ where: { companyId_bsuid: { companyId, bsuid } } }) : null;
const byPhone = phone ? await this.prisma.onlineConversation.findUnique({ where: { companyId_phone: { companyId, phone } } }) : null;
const found = byPhone || byBsuid;
if (!found) return null;
return { ...found, backfill: !(byPhone && byBsuid && byPhone.id !== byBsuid.id) };
}
It looks up both addresses independently (a webhook only sends one of them per message, but a conversation may already have both on file from earlier messages) and prefers the phone-matched row if both exist and agree. The backfill flag is the interesting part:
- Normal case — only one of the two lookups finds a row, or both find the same row:
backfillistrue. It's safe to write the newly-learned address onto that row. - Conflict case — the phone lookup and the BSUID lookup find two different rows. This is how a contact who messaged the clinic before this migration existed can look: their old conversation has a phone number but no BSUID, and if a later message from the same person arrives BSUID-only (or their BSUID happens to coincide with some other existing row — the mechanism doesn't distinguish why, only that the two lookups disagree), a second row could plausibly claim to represent them.
backfillisfalse, and the caller does not write to either column. The number-matched row is used as-is, because it carries the longer conversation history and, per the code comment, "the patient link." Merging the two rows is explicitly left as a separate job — this only avoids failing (or silently misattributing) rather than solving it.
That backfill flag is consumed in MessagesService.onMessage (packages/whatsapp-official/src/messages/messages.service.ts:147-168):
const existingConversation = await this.onlineConversationService.findConversationByAddress(companyId, waId, bsuid);
...
const onlineConversation = await this.onlineConversationService.createOrUpdateOnlineConversation(
companyId,
existingConversation
? { id: existingConversation.id }
: isBsuid
? { companyId_bsuid: { companyId, bsuid: customerNumberId } }
: { companyId_phone: { companyId, phone: customerNumberId } },
{
...
phone: existingConversation?.backfill === false ? undefined : waId || undefined,
bsuid: existingConversation?.backfill === false ? undefined : bsuid || undefined,
username: username || undefined,
...
},
{ name, phone: waId, bsuid, username, ... }
);
Three cases fall out of this:
- No existing conversation — create one, keyed by whichever address this message carries.
- Existing conversation,
backfill: true— update it byid, and writewaId/bsuidonto it (anundefinedvalue in a Prisma update is a no-op, so a field this message didn't supply is left alone — this is how a number learned in an earlier message survives a later BSUID-only message from the same contact, and vice versa). - Existing conversation,
backfill: false(the conflict case above) — update the number-matched row byid, but passundefinedfor bothphoneandbsuid, leaving the row exactly as it was.
createOrUpdateOnlineConversation itself (online-conversation.service.ts:40-99) changed its signature to take companyId as an explicit first argument, because the where clause passed in can now be { id } instead of always { companyId_phone: {...} } — previously companyId was read out of where.companyId_phone.companyId, which would have thrown when matching by BSUID or by id instead.
The GraphQL-server side of the same problem
The main GraphQL API server (packages/server) has its own, separate need to look up a conversation by "whichever identifier we have" — for example when a clinic user sends an outbound message, or when an appointment records which WhatsApp identifier a booking came in on. It solves this with a small helper rather than the findConversationByAddress lookup above:
// packages/server/src/resolvers/mutations/actions/officialWhatsApp/official-whats-app.utils.js:9-18
export const isBsuid = value => /^[A-Z]{2}\.[A-Za-z0-9]+$/i.test(value || '')
export const conversationKey = (companyId, value) =>
isBsuid(value) ? { companyId_bsuid: { companyId, bsuid: value } } : { companyId_phone: { companyId, phone: value } }
This isBsuid is a regex heuristic (two letters, a dot, then alphanumerics — matching the EG.1693676845194969 shape from the PR description), independent of the whatsapp-official service's isBsuid, which is simply "Meta didn't send wa_id but did send a user_id." The two are not shared code and could in principle disagree on an edge-case value, though both are only ever fed values that actually came from Meta. conversationKey is used in savePatientDetails.js:177 (see Patient Linking & What Staff See) and inside handleSendWhatsAppMessageUrl (official-whats-app.utils.js:65).