Skip to content

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 every subset match with an unordered findFirst, 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); getMessages returned the oldest page and accepted limit=abc; sending awaited email/SMS/push fan-out before responding; message create and lastMessageAt were not transactional; new one-on-ones had lastMessageAt = null and 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 with createdAt >= value are visible.
  • @@index([userId, leftAt]) on ConversationParticipant replaces @@index([userId]); @@index([conversationId, createdAt]) on Message replaces @@index([conversationId]).
  • Migration messaging_history_visibility_and_indexes plus a hand-added backfill UPDATE "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/messages returns the newest page (was the oldest) plus hasMore; offset is ignored.
  • Unread counts exclude SYSTEM messages.
  • shareHistory omitted ⇒ false.
  • The patient shortcut yields ONE_ON_ONE for a single doctor.
  • New system messages and titles carry no "Dr." prefix and no English title; clients render participant names when title is null.

Post-merge fixes

  • The advisory lock initially used a bare SELECT pg_advisory_xact_lock(...), which Prisma rejects (void column). 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, isLoadingOlderMessages reset 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:check reports EN and RU in sync.
  • Mobile: npx tsc --noEmit; npx jest passes 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.