Conversation
The public `/v1/contacts/{identifier}/messages` handlers picked both halves of
their target without an ordering, so which one they got was left to Postgres.
`findByContactWithInboxes` ran `findFirst` with no `orderBy`, and a contact
holds a DM conversation plus one comment thread per post they commented on —
so the same contact could resolve to a different conversation on two
consecutive calls. It now prefers the DM thread, which is what all three
handlers are about. A contact who has only ever commented has no DM row and
still resolves to their comment thread.
Deciding that inside the lookup rather than behind a per-caller flag is the
point: with only the send path preferring the DM thread, an integrator could
POST a message, get 204, then list the conversation and not find it. There is
no flag to forget, so send, list and get cannot drift.
`resolveContactInboxForSend` took `contactInboxes[0]`. That relation is keyed
by `contactId`, so it carries every inbox the contact has across every channel
— an unordered `[0]` could address the wrong page entirely. It now comes from
`findRecentByContactId`, the same resolution `resolveContactInboxForConversation`
already used.
Both orderings are spelled as SQL with `DESC NULLS LAST`. `lastActivityAt` is
nullable with no default, so a freshly created conversation holds NULL and a
plain DESC would rank a never-active conversation above every real one; the
`{ lastActivityAt: "desc" }` object form cannot express the NULLS clause, and
raw SQL reaches `orderBy` only through its callback form. This also matches
`Conversation_workspaceId_lastActivityAt_id_idx`, declared `.desc().nullsLast()`.
A wrong resolution failed asynchronously — the handler had already answered
204 — which is the silent-send shape reported in #879. Whether it accounts for
the failure rate measured there is still unconfirmed, so this refs the issue
rather than closing it.
refs #879
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/v1/contacts/{identifier}/messageshandlers picked both their target conversation and their targetContactInboxwithout an ordering, so which one they got was left to Postgres. A wrong resolution failed asynchronously — the handler had already answered 204 — which is the silent-send shape reported in Comment-originated contacts cannot be messaged via API even after the contact replies (silent failure) #879.contactInboxes[0]is gone. That relation is keyed bycontactId, so it carries every inbox the contact has across every channel — an unordered[0]could address the wrong page entirely.Whether this accounts for the failure rate measured in #879 is still unconfirmed, so this refs the issue rather than closing it.
Changes
packages/business/src/conversation/service.tsfindByContactWithInboxesprobes the DM conversation (sourceId IS NULL, a single hit on the uniqueConversation_contactId_dm_key) before falling back. A contact who has only ever commented still resolves to their comment thread.resolveContactInboxForSendresolves theContactInboxviacontactInboxService.findRecentByContactId, the same resolutionresolveContactInboxForConversationalready used. An explicitinboxIdstill wins.DESC NULLS LAST.lastActivityAtis nullable with no default, so a freshly created conversation holds NULL and a plain DESC would rank a never-active conversation above every real one. The{ lastActivityAt: "desc" }object form cannot express the NULLS clause, and raw SQL reachesorderByonly through its callback form. This also matchesConversation_workspaceId_lastActivityAt_id_idx, declared.desc().nullsLast().findLatestByContactcarried the same latent bug and is fixed alongside.ContactInboxalone: aConversationcarries no inbox or channel column, and the single DM row a contact owns is shared across all their channels.packages/business/__tests__/conversation-find-by-contact-with-inboxes.test.ts(new) — DM-first probe, comment-thread fallback, and theNULLS LASTordering.packages/business/__tests__/conversation-resolve-contact-inbox-for-send.test.ts— the case asserting the oldcontactInboxes[0]behaviour is replaced; the 404 paths now coverfindRecentByContactIdreturning nothing.Notes for reviewers
listMessagesandgetMessageare not touched: they callfindByContactWithInboxeswith no arguments and pick up the new behaviour automatically. That is deliberate — it is what makes the three endpoints agree by construction.One behaviour change worth a second opinion:
listMessageswill no longer surface messages that live in a comment thread for a contact who also has a DM thread. Previously that was arbitrary, so it sometimes did. The endpoint has no parameter for choosing a thread; if that turns out to matter, the fix is to add aconversationIdparameter rather than to go back to an arbitrary pick.Test plan
pnpm --filter @chatbotx.io/business test— 274 files, 3083 tests passpnpm --filter builder test— 649 files, 5037 pass / 2 skippedpnpm --filter builder check-typespnpm lint/v1/contacts/{id}/messages, then GET the same path and confirm the message is listed