Messaging Robustness — Detailed Changes (2026-09-17)¶
Summary¶
A full audit of the messaging system followed by an overhaul across OktaPT-API (11 files), OktaPT-FE (19 files) and Okta-Mobile (14 files). The change fixes the structural causes behind duplicate threads, stale and mixed-up chat state, inconsistent names and missing authorization, and adds care-team management: therapists can add doctors to a conversation with a per-add "share previous messages" choice, remove them, or leave, and an existing conversation with the same people is always reused instead of duplicated.
Cross-repo PRs: OktaPT-API #417, OktaPT-FE #584, Okta-Mobile #65. Participant management is gated by the existing multiProviderGroupMessaging per-tenant flag.
For the engineering reference see ../internal/messaging-architecture.md. For the user-facing description see ../messaging.md.
Audit findings addressed¶
Conversation identity
- One-on-one dedup used a Prisma
everysubset match with an unorderedfindFirst, so a stale one-participant row produced a brand-new duplicate every time; group creation had no dedup at all; the patient "all my doctors" route matched any subset (and could hand a patient a doctors-only group) and crashed when the patient had no doctors (doctorConnections[0]dereferenced before the emptiness check). - Two users creating the same thread at once produced two threads; the feedback service raced the controller the same way.
Membership
- Participants could be added to groups only; nothing ever wrote
leftAt; a participant added later saw the entire history; one-on-ones could not be expanded although the docs said they could.
Names
- Five formatters on web and three on mobile disagreed ("Dr. Smith" in the sidebar, "Dr. John Smith" in the header, first name only for patients in groups); two places rendered the literal "John undefined"; every doctor's avatar showed "D".
Client state
- The web store had no request guards (fast switching put chat A's messages under chat B's header, in-flight polls appended into the wrong chat), only the open thread was polled, the list was never re-sorted, hidden tabs marked messages read, a removed user only got console errors.
- Mobile push taps opened the inbox instead of the thread, the thread loaded the 100 oldest messages, the poll interval was recreated on every message, a failed send lost the typed text.
Server
- Unread counts were N+1 (sequential per conversation);
getMessagesreturned the oldest page and acceptedlimit=abc; sending awaited email/SMS/push fan-out before responding; message create andlastMessageAtwere not transactional; new one-on-ones hadlastMessageAt = nulland pinned to the top of the list (NULLS FIRST). - Anyone could open a one-on-one with any user id in any tenant; read receipts accepted arbitrary message ids; two endpoints logged request users and doctor lists; English titles and system text were baked server-side.
Data model¶
ConversationParticipant.historyVisibleFrom DateTime?— null means full history; otherwise only messages withcreatedAt >= valueare visible.@@index([userId, leftAt])onConversationParticipantreplaces@@index([userId]);@@index([conversationId, createdAt])onMessagereplaces@@index([conversationId]).- Migration
messaging_history_visibility_and_indexesplus a hand-added backfillUPDATE "Conversation" SET "lastMessageAt" = "createdAt" WHERE "lastMessageAt" IS NULL.
OktaPT-API (#417)¶
| File | Change |
|---|---|
src/services/messaging/conversationService.ts (new, ~830 lines) |
ConversationError + codes; shared selects (userSummarySelect, participantInclude, messagePreviewInclude, conversationInclude, messageInclude); formatUserName, normalizeIds, lockKeyFor; systemMessageData (structured metadata + English fallback); findConversationByExactParticipants; findOrCreateConversation under a pg_advisory_xact_lock (wrapped in a CTE so Prisma can deserialize the result); addParticipants (row lock, set lock, re-read and re-checked caps, switch detection, one-on-one → group conversion, historyVisibleFrom, connections, system messages); removeParticipant; requireActiveParticipant; getUnreadCountsByConversation (single raw query, excludes SYSTEM); applyViewerHistoryCutoff |
src/routes/messages/validators.ts (new) |
zod schemas for create, add-participants (shareHistory default false), send (≤ 5000 chars, client types only, attachment key prefix), messages query (limit 1..100, since, before, ignored offset), mark-read |
src/routes/messages/controllers.ts (rewritten) |
Every endpoint on the service; new removeParticipantFromConversation; static imports; sendConversationError → { error, code }; detached notifyConversationParticipants; keyset before paging; newest page ascending + hasMore; single unread query; one-on-one authorization; all-doctors crash fixed and ONE_ON_ONE for a single doctor; attachment ownership check; PII logs removed |
src/routes/messages/index.ts |
Validators wired; DELETE /conversations/:id/participants/:userId |
src/routes/messages/types.ts |
GetMessagesQuery { limit, since, before }, GetMessagesResponse { messages, hasMore }, AddParticipantsRequest/Response, RemoveParticipantResponse, re-exported SystemMessageMetadata |
src/services/feedbackNotificationService.ts |
getOrCreateFeedbackConversation uses findOrCreateConversation (active, accepted, non-archived connections; no title; no system message) and skips the therapist flow when the sending doctor is no longer connected |
prisma/schema.prisma + migration |
See Data model |
src/routes/__tests__/messages.test.ts |
Rewritten: 57 cases (dedup for both types, cross-tenant, connection checks, admin rejection, switch, conversion, historyVisibleFrom, caps, remove/leave matrix, history-filtered paging, param validation, non-participant 403 with code, content validation, transactional send, list mapping, scoped receipts, all-doctors paths, attachment ownership) |
src/services/messaging/__tests__/conversationService.test.ts (new) |
17 cases for the pure pieces, lock-first ordering, exact-set shape, unread mapping, cutoff, removal rules |
src/services/__tests__/feedbackNotificationService.test.ts |
Transaction/raw mocks; exact-set reuse; one-doctor ONE_ON_ONE; untitled GROUP; skip when the sending doctor is not connected |
OktaPT-FE (#584)¶
| File | Change |
|---|---|
lib/utils/userName.ts (new) |
formatUserName, getInitials, getRoleLabel, getConversationName, getConversationInitials, formatSystemMessage |
lib/api/messaging.ts |
SystemEvent/SystemMessageMetadata, historyVisibleFrom, optional trimmed Message.sender, before/hasMore, add/remove request and response types, removeParticipant(), getMessagingErrorCode (validated against the translated set), isNotParticipantError, getMessagingErrorKey |
lib/stores/messaging.ts (rewritten) |
Module-level timers and selectionSeq; getConversation fallback for unknown ids; newest page + loadOlderMessages; visibility-aware read marks and visibilitychange flush; 15 s list polling; re-sort on updates; addParticipants honouring switched; removeParticipant, leaveConversation, handleLostAccess → notice; isLoadingOlderMessages reset on selection changes; error, isCreatingConversation and the timer-in-state removed |
lib/hooks/useMessagesPage.ts (new) |
Shared page logic: load + list polling, ?conversation=<id> deep link and shallow URL sync, one-shot notice toasts, create/open/send/load-older handlers |
components/messaging/MessageList.tsx (new) |
Scroll owner with near-bottom rule, viewport-preserving prepend, "Load earlier messages" |
components/messaging/ParticipantsPanel.tsx (new) |
Participant rows with role label, Remove / Leave with confirmation and busy state, last-doctor guard |
components/messaging/AddParticipantPopover.tsx (new) |
Tenant-doctor picker with the message-history radio; switch handling |
components/messaging/ConversationView.tsx |
Slimmed to header + panels + MessageList; + on groups and one-on-ones with a patient; pluralised participant count |
components/messaging/MessageBubble.tsx, ConversationList.tsx |
Helper-based names and initials, localized system lines, null-safe preview sender, you/yesterday/reference/edited keys |
components/messaging/NewConversationModal.tsx |
Awaited callbacks with submitting state (stays open on failure), split effects, full names + role labels, "John undefined" sites fixed |
components/ui/ConfirmDiscardDialog.tsx |
Optional isBusy / busyLabel (spinner, disabled buttons, no dismiss while busy) |
pages/doctor/messages.tsx, pages/patient/messages.tsx |
On useMessagesPage; mobile split derived from selection |
lib/hooks/useNavigationItems.ts, components/header/MainNavigation.tsx |
Unread badge from the store, so the mobile menu shows it too |
public/locales/en/common.json, ru/common.json |
~50 keys under messages.* (roles, participants panel, history choice, toasts, errors, system lines, plural participantCount_*) |
Okta-Mobile (#65)¶
| File | Change |
|---|---|
lib/userName.ts (new) + lib/__tests__/userName.test.ts (new) |
Name/initials/role/system-line helpers with 14 tests against the real locale file |
services/messagingService.ts |
SystemEvent, SystemMessageMetadata, historyVisibleFrom, before/hasMore, offset dropped |
services/notificationService.ts |
{ navigate, push } navigator; message and feedback pushes open /conversation/<id>; cold-start and warm paths share one handler with per-tap dedupe; storage read failures are caught |
app/_layout.tsx |
Passes router.navigate / router.push |
app/conversation/[id].tsx |
Focus-owned loading and polling (stable 5 s interval, conversation refresh every 6th tick), polls held during the initial load, 403/404 exit with alert, before paging on onEndReached, invalid ids handled, helper-based header |
app/(tabs)/messages.tsx |
Single focus effect with 30 s polling while focused, stale-response guard, alreadyExists handling, error alerts |
components/messaging/MessageBubble.tsx, FeedbackMessageBubble.tsx, ConversationListItem.tsx, NewConversationSheet.tsx, MessageInput.tsx |
SYSTEM bubble, localized strings, full names and two-letter initials, "All My Doctors" hidden for doctors, text restored on failed send |
locales/en.json, locales/ru.json |
messages.* additions (roles, participant count, system lines, errors) and feedback badge labels |
Behaviour changes clients notice¶
GET /conversations/:id/messagesreturns the newest page (was the oldest) plushasMore;offsetis ignored.- Unread counts exclude
SYSTEMmessages. shareHistoryomitted ⇒false.- The patient shortcut yields
ONE_ON_ONEfor a single doctor. - New system messages and titles carry no "Dr." prefix and no English title; clients render participant names when
titleis null.
Post-merge fixes¶
- The advisory lock initially used a bare
SELECT pg_advisory_xact_lock(...), which Prisma rejects (voidcolumn). It is now wrapped in a CTE returning an integer. - The shared confirm dialog gained a busy state so removing or leaving shows progress.
- A review pass added: validated error-code mapping on the web,
isLoadingOlderMessagesreset on selection change, i18next plural forms for the participant count, a stale-response guard on the mobile inbox, an invalid-id exit and poll hold on the mobile thread, a caught storage read on push taps, an attachment-only notification preview, attachment ownership enforcement, re-checked caps under the lock, and the therapist-feedback connection guard.
Verification¶
- API:
npx tsc --noEmit; the three messaging suites (88 tests) pass; the full suite passes when run with--runInBand(the parallel run is CPU-starved on a laptop). - Web:
npx tsc --noEmit;npm run i18n:checkreports EN and RU in sync. - Mobile:
npx tsc --noEmit;npx jestpasses apart from one pre-existing unrelated suite. - Manual two-browser script (doctor, second doctor, patient) covering dedup, add with and without history, remove and leave, the removed user's notice, load-earlier, fast switching, and the mobile push deep link.