Dentolize · Hidden-Number WhatsApp Contacts Walkthrough
On this pageBusiness viewTechnical view

Patient Linking & What Staff See

Business view

Two more things depend on a WhatsApp conversation's phone number specifically, not just "some identifier for this contact":

  • Matching a conversation to an existing patient, or creating a lead for a new one. Dentolize patient records only ever store a phone number — there's no field for a WhatsApp BSUID. So a hidden-number contact can still be correctly linked to their existing patient record, but only if the conversation has a phone number on file (from this message or an earlier one) — a BSUID alone can't be matched against a patient.
  • What staff actually see in the Conversations list, the Requests table, and the patient's own record — a phone number when there is one, and a sensible fallback (@username, then the raw BSUID) when there isn't.

Technical view

Patient and lead matching uses the conversation's phone, not the inbound message's address

Before this PR, MessagesService.onMessage matched patients using customerNumberId directly — implicitly assuming it was always a phone number. It now explicitly checks for, and uses, the conversation's stored phone:

// packages/whatsapp-official/src/messages/messages.service.ts:232-253
// Patient carries numbers only, so this needs the conversation's number rather than this message's address:
// a contact who hid their number still has it on record from their earlier exchanges.
if (!patientId && onlineConversation.phone) {
  let patient = await this.patientService.findFirst({
    companyId, fullNumber: `+${onlineConversation.phone}`, type: { not: 2 }
  });
  ...
  if (!patient && leadStageId) {
    patient = await this.patientService.findFirst({ companyId, fullNumber: `+${onlineConversation.phone}`, type: 2 });
  }
  ...
}

If onlineConversation.phone is empty (a contact Dentolize has only ever seen via BSUID), this whole block is skipped — no patient lookup is attempted, and no lead is auto-created for a message. That's a real gap, not a hidden PR feature: a first-time hidden-number contact isn't automatically turned into a lead until (if ever) their number becomes known. See For Quality for how to probe this.

Lead creation itself has the identical fix. LeadService.createNewLead (packages/whatsapp-official/src/services/lead.service.ts:12-63) used to parse customerNumberId into a phone number unconditionally:

// before: const parsedNumber = parsePhoneNumberFromString(`+${customerNumberId}`);
// after (:29):
const parsedNumber = onlineConversation.phone ? parsePhoneNumberFromString(`+${onlineConversation.phone}`) : undefined;

and downstream, phoneNumber and fullNumber on the new lead's patient record are set conditionally on parsedNumber existing (lead.service.ts:61,63) rather than always. A lead created for a BSUID-only contact gets a name but no phone number field populated at all — again, an accurate reflection of what the data actually allows, not a workaround.

Appointments booked over WhatsApp look up the conversation by whichever identifier the booking used

savePatientDetails.js previously looked up the OnlineConversation for a WhatsApp-originated appointment by phone only:

// before
where: { companyId_phone: { phone: appointment.whatsapp, companyId } }
// after (packages/server/src/resolvers/mutations/actions/patient/savePatientDetails.js:175-178)
// appointment.whatsapp holds whichever identifier the booking came in on: a phone or a business-scoped user ID
where: conversationKey(branch.company.id, appointment.whatsapp)

using the conversationKey helper described in Identifying a Contact, which picks the phone or BSUID unique-key shape based on the regex heuristic.

Editing a patient's phone number no longer assumes the linked conversation has one

editPatient.js disconnects a patient's linked OnlineConversation when the patient's phone number is edited to something different from what the conversation is keyed on — the idea being that if the patient's number changes, the old WhatsApp conversation (tied to the old number) shouldn't stay attached to the now-differently-numbered patient. That comparison used to read patient.onlineConversation.phone unconditionally; if the conversation had no phone (a hidden-number contact), the comparison itself is meaningless and the code now guards on it explicitly:

// packages/server/src/resolvers/mutations/actions/patient/editPatient.js:211-218
onlineConversation:
  !hidePhoneNumbers(request) &&
  args.phoneNumber !== patient.phoneNumber &&
  patient.onlineConversation &&
  patient.onlineConversation.phone &&
  phoneNumber?.replace('_', '').replace(/ /g, '') !== `+${patient.onlineConversation.phone}`
    ? { disconnect: true }
    : undefined,

The added patient.onlineConversation.phone check (and the corresponding select: { id: true, phone: true } at editPatient.js:76, previously just { id: true }) means a patient linked to a phone-less conversation keeps that link when their phone number field is edited — there's nothing to compare against, so nothing is disconnected.

What staff see: the shared getConversationHandle fallback

Both clinic-web and clinic-mobile define the same small helper (not shared between the two packages — each has its own copy):

// packages/clinic-web/src/shared/utils/helpers.js:16-17
// packages/clinic-mobile/src/shared/utils/helpers.js:6-7
export const getConversationHandle = ({ phone, username, bsuid } = {}) =>
  phone ? `+${phone}` : username ? `@${username}` : bsuid || ''

It's used everywhere a conversation's address is rendered:

ScreenFileWhat changed
Conversations list (web)clinic-web/.../conversations/ConversationBox.js:63-73WhatsApp icon only links out when item.phone exists; text falls back to getConversationHandle(item)
Conversation header duplicate-check (web)clinic-web/.../conversations/Chats.js:154-159DuplicatePopover (finds other patients sharing this number) is suppressed entirely when the conversation has no phone — there's no number to check for duplicates against
Requests table (web)clinic-web/.../conversationFeedbacks/ConversationFeedbacks.js:62-66Phone column renders a clickable renderPhoneLink when there's a number, else the handle fallback
Conversations list (mobile)clinic-mobile/.../onlineConversations/OnlineConversation.js:42-50Same pattern as the web conversation list, using PhoneButton
Requests list (mobile)clinic-mobile/.../conversationsFeedbacks/ConversationsFeedback.js:74-81Same pattern as the web Requests table

All five reads are on GraphQL selections that had to be widened to actually fetch bsuid and username alongside phone — see onlineConversations.js, onlineConversationDetails.js, and conversationFeedbacks.js under both clinic-mobile/src/shared/store/queries/actions/officialWhatsApp/ (client-side) and server/src/resolvers/queries/actions/officialWhatsApp/ (server-side select), each adding bsuid: true, username: true next to the existing phone: true. Without that widening, getConversationHandle would always fall through to phone (undefined) with no username or BSUID to fall back to, regardless of the backend fix.