Dentolize · WhatsApp Conversation Race Fix Walkthrough
On this pageBusiness viewTechnical view

The race condition fix

Business view

Every WhatsApp conversation a clinic has with a contact is stored as one row — one "conversation" — in Dentolize. The very first time a contact messages the clinic, the system has to decide: do I already have a conversation with this person, or do I need to start one?

Normally that works perfectly. But WhatsApp can deliver messages in quick bursts — for example, a contact sends two messages back-to-back, or clicks a "Click to WhatsApp" ad twice, or Meta's webhook redelivers a message. When two of these deliveries for the same brand-new contact arrive close enough together, both can ask "do I have a conversation with this person?" at almost the same moment, both get told "no," and both try to create one. Only one is allowed to succeed — a phone number (or anonymous contact ID) can only belong to a single conversation.

Before this fix, the one that lost that race caused an error that was never handled. The practical effect: that contact's message never showed up in the clinic's inbox, and the bot never replied to it — with nothing in the product indicating anything had gone wrong. From the clinic's side, and the patient's side, the message just silently disappeared.

The fix makes the loser of the race fall back to finding the conversation that did get created and attaching its message there instead of giving up. The message and the bot's reply are no longer lost. What the fix does not do is merge two conversations that turn out to belong to the same person under different identifiers (e.g., a phone number and an anonymous ID that both belong to the same clinic) — that reconciliation is intentionally left as separate future work, called out directly in the code's comments.

Technical view

Why the upsert can fail at all

createOrUpdateOnlineConversation (packages/whatsapp-official/src/services/online-conversation.service.ts:94-139) is the single call site that turns an inbound WhatsApp webhook into a row in the OnlineConversation table. It's called once, from MessagesService.onMessage in packages/whatsapp-official/src/messages/messages.service.ts:187-217.

Ideally this would be one atomic INSERT ... ON CONFLICT DO UPDATE. Prisma can't compile that when the write includes nested relation writes (here, messages: { create: messageData } on both the update and create branches — messages.service.ts:203 and :214) into a single SQL statement, so prisma.onlineConversation.upsert() executes as separate read-then-write statements under the hood. Two concurrent calls for the same new contact can both read "not found" and both attempt to insert, and the database's unique constraints reject the second one:

// packages/prisma/schema.prisma:4152-4153
@@unique([companyId, phone])
@@unique([companyId, bsuid])

Prisma surfaces that rejection as error code P2002 (online-conversation.service.ts:7).

What determines the where the upsert uses

In messages.service.ts:187-193, the where passed to createOrUpdateOnlineConversation is, in priority order:

  1. { id: conversationId } — if a prior lookup (findConversationByAddress,

called at messages.service.ts:166) or a same-request phone merge (contactPhoneService.mergeSharedPhone, messages.service.ts:177-183) already resolved a conversation for this contact.

  1. { companyId_bsuid } — if this delivery has no dialable phone number

(isBsuid is true; see glossary for what a BSUID is).

  1. { companyId_phone } — otherwise.

Case 2 and 3 are exactly the "first message from a brand-new contact" case: there is no existing row to find by id, so two racing deliveries both fall into the same companyId_bsuid or companyId_phone upsert with nothing to disambiguate them until the database enforces uniqueness.

The recovery path this PR adds

// packages/whatsapp-official/src/services/online-conversation.service.ts:104-138
try {
  return await this.prisma.onlineConversation.upsert({ where, update, create: {...}, select: conversationSelect });
} catch (error) {
  if (error?.code !== UNIQUE_CONSTRAINT_FAILED) {
    throw error;
  }

  const claimed =
    where.id != null
      ? { id: where.id }
      : await this.findConversationByAddress(companyId, create.phone ?? undefined, create.bsuid ?? undefined);

  if (!claimed) {
    throw error;
  }

  return this.prisma.onlineConversation.update({
    where: { id: claimed.id },
    data: { ...update, phone: undefined, bsuid: undefined },
    select: conversationSelect
  });
}

Two distinct failure shapes are handled identically, per the code's own comment (lines 119-121):

  • No where.id (cases 2/3 above): the constraint failed on INSERT

because another concurrent call created the row between this call's read and its write. The fallback re-resolves the row by address via findConversationByAddress (online-conversation.service.ts:65-84, which checks both companyId_bsuid and companyId_phone) and re-targets the update at whichever row it finds.

  • where.id present: the row existed and was matched by id, but the

update tried to write a phone or bsuid value that some other conversation already owns (constraint failed on UPDATE, not INSERT). There, claimed is just { id: where.id } — the row being updated is not in question, only the address value is dropped.

In both cases the retried update strips phone/bsuid from the write (data: { ...update, phone: undefined, bsuid: undefined }) — the clashing address is deliberately left exactly where it already is on whichever row owns it. Everything else in update still applies: messagesCount/unseenCount increments, lastMessage/lastActivity timestamps, and — critically — the nested messages: { create: messageData } that attaches the incoming message and keeps the bot's reply flow working (messages.service.ts:475-517 sends the bot response and logs it against onlineConversation.id regardless of which path produced that id).

If claimed can't be resolved at all (message has neither a usable phone nor bsuid to look up), the original error is re-thrown — the pre-existing behavior for a genuinely unrecoverable case.

Deliberately out of scope

The recovery comment at online-conversation.service.ts:131-132 is explicit: when the fallback lands a message on a row via the "address already belongs to another conversation" branch, the two conversations (one per address) are not merged. A contact can end up known to the clinic under two separate conversation rows — one per address — until a separate reconciliation job handles that. This mirrors the existing, pre-PR backfill: false case in findConversationByAddress (online-conversation.service.ts:60-63), which already tolerates the same kind of split identity rather than resolving it.

Reuse: the select clause

The PR also lifts the large select object (which fields the bot and subscribers read off a conversation) out to a module-level conversationSelect constant (online-conversation.service.ts:10-50) so the new recovery-path update call can share it with the main upsert call — a pure refactor with no behavior change, needed because there are now two call sites that need the identical shape.

No test coverage

There is no online-conversation.service.spec.ts in the repository, and the existing messages.service.spec.ts does not exercise createOrUpdateOnlineConversation, findConversationByAddress, or any unique-constraint/race scenario. This logic currently ships untested; see For Quality for what a test should cover.