Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesMessages CommentsMessages + Comments — Behavior

Messages + Comments — Behavior

State machine

Messages don’t have a heavy lifecycle. The “states” derived from columns:

sent (created_at set) ──> read (read_at set) └─> edited (edited_at set; not yet wired) └─> deleted (deleted_at set)

There is no delivered state. The presence of a row in messages plus a WebSocket emit is the “sent” event; “delivered” is implicit (client got the WS push) and not persisted.

Invariants

InvariantEnforcement
Message must have content OR attachmentsService: if (!content && !attachments.length) throw 400
Sender must be an active org memberrequireOrgMembership
Recipient must be in the same orgService check
Pair must be allowed (staff↔anyone, member→staff) — member↔member blockedverifyCanMessage
Workout comment requires assignment to belong to this org AND requester is the assigned member OR active staffresolveWorkoutAssignmentForChat
Soft-deleted message can’t be re-read or re-editedAll reads filter deleted_at IS NULL
Read receipts can only be claimed by the recipientmarkRead checks recipientId === resolvedUserId
Realtime WS auth via Clerk JWTMessagesGateway.handleConnection calls verifyToken
WS messages are rate-limited 30 / 60s per socketcheckSocketRate
Staff-to-member email honors member preferencesConversationsService.sendMessage checks notification_prefs.newMessage.email; missing row/key = enabled, explicit false = 403 MEMBER_EMAIL_OPTED_OUT before any outbound insert
Member email entry points are tier-gatedWeb requires platform feature lead_management and a staff role; API preference enforcement is unconditional

Golden paths

Staff Messages desk

/dashboard/messages (staff) is a three-pane desk: the conversation list, one member’s timeline with its composer, and a member panel (a sheet below xl). Members keep the two-pane ChatWindow screen.

  1. List. One row per person, merged from the app DM list (GET …/conversations) and, when email is on, members’ email threads (GET …/inbox?contactType=member&channel=email, joined on contact.membershipId). Rows sit under Needs reply (unread, or the other side spoke last: DM lastMessageDirection / inbox lastDirection = inbound), Waiting on them (staff spoke last, within 14 days) and Quiet (staff spoke last, older). Each row marks the channel of its latest message.
  2. Timeline. App DMs (GET …/conversations/:membershipId, realtime) and the member’s email thread (GET …/inbox/:conversationId, polled every 30s) interleave by time. App messages render as bubbles; emails render as letter slips with the thread subject and delivery state. Opening the thread marks app messages read and, when unread, the email thread (POST …/inbox/:id/read).
  3. Composer. An App | Email switch (email only for members, with the tier on) defaults to the channel the member last wrote from. App sends POST /messages with attachments. Email sends POST …/inbox { contactUserId, channel:'email', subject?, body } when no thread exists, otherwise POST …/inbox/:id/messages { body, channel:'email' } under the stored subject (ConversationDetail.subject, shown as “Re: …”).
  4. Panel. Role, member since, contact details and reach: app presence, and email reachability from GET …/inbox/member-email-status/:userId (gets email / turned off / no address).

Coach DMs a member

  1. Coach opens a member on the desk, or starts one with New message.
  2. Coach types content + optionally attaches an image (presigned PUT via POST /messages/upload-url, then PUT to R2).
  3. POST /messages with { recipientMembershipId, content, attachmentIds: [...] }.
  4. Service: validates pair, resolves attachments, inserts a messages row + message_attachments rows in a transaction, then:
    • Emits WS message:new to both user:{senderId} and user:{recipientId} rooms.
    • Enqueues push to the recipient via PushNotificationsService.notifyUser with category newMessage.

Staff emails a member

  1. On the desk, staff switch the composer to Email (lead_management on). A missing address or newMessage.email=false blocks sending with a notice before typing.
  2. The unified inbox finds or creates the member thread and sends through the same email adapter used for leads.
  3. The email carries Reply-To: contact+<conversationId>@<INBOUND_EMAIL_DOMAIN>. An inbound reply is recorded by the existing email webhook and appears in the member’s desk timeline and in the Leads unified inbox.
  4. Every send, first or reply, re-checks the member preference before any outbound row is inserted.

Member reads the conversation

  1. Member opens chat → GET /conversations/:membershipId returns paginated history.
  2. UI calls PUT /conversations/:membershipId/read → service bulk-updates read_at = now for unread messages where the member is the recipient.
  3. Sender receives WS message:read for each updated message id.

Coach posts a workout comment

  1. Coach opens member’s workout drawer → comments tab.
  2. POST /workout-assignments/:assignmentId/comments with { content }.
  3. Service: validates assignment access, inserts row with workout_assignment_id set. Fans out push to the OTHER participant (coach OR member depending on sender) with category newComment. Recipient is the assignment’s userId if sender is staff, otherwise the assigning coach.

Per-program comment summary

GET /programs/:programId/workout-comments/unread-count and the program comments feed require assignments owned by that exact workout_assignments.program_id, within the organization and active program roster. Another program’s or NULL-owned ad-hoc assignments never contribute, even for the same athlete.

Edge cases

ScenarioBehavior
Send to a member who was just deactivatedresolveMembershipId requires status='active'; 404
Send to yourselfAllowed? verifyCanMessage doesn’t explicitly block — verify intent
Attachment uploaded but never referencedLives in R2 as a staged orphan until janitor
WS disconnect mid-sendAPI call still succeeds (REST is the source of truth); WS event is fire-and-forget
Two devices for same userBoth receive WS events (joined user:{id} room)
Push notif when user has opted out of newMessagefilterByPreferences drops the user before push enqueue
Member email when newMessage.email is explicitly falseAPI returns 403 MEMBER_EMAIL_OPTED_OUT before insert/provider send (a first send also creates no thread); the desk composer and the inbox thread show the opted-out state
Member has no notification preferences row/keyEmail is allowed (preferences are opt-out)
Member has no email address (or a blank legacy one)Reachability read returns email:null; the desk composer blocks email
Member emailed but never sent an app DMStill one desk row (from the inbox list’s contact.membershipId); opening it shows the email thread
lead_management is offNo App | Email switch, no email rows or slips; the desk is app DMs only
Soft-deleted message in a threadFiltered out of history; conversation still exists
Conversation list when other side has no membershipDefensive null handling needed — verify it doesn’t crash
Workout assignment deleted (cascade)messages.workout_assignment_id is ON DELETE CASCADE → comments are hard-deleted with the assignment
Org with newComment push disabled at member levelPush skipped; WS still fires

Side effects

ActionWritesEmits WSPushes / email
Send DMmessages insert + message_attachmentsmessage:new (sender + recipient rooms)newMessage push category
Send member emailconversations find/create + conversation_messages outbound row—Email through unified inbox adapter
Receive member email replyconversation_messages inbound row; conversation unread/status updateCONVERSATION_INBOUND_RECEIVED application event—
Send workout commentmessages insert (with workout_assignment_id)message:new (in assignment-scoped room — TBD)newComment push category
Mark read (single / bulk)messages update (read_at)message:read (sender room)—
Editcolumn exists; no path——
Deletemessages update (deleted_at)(no event today)—
Send upload-url———
EventTrackingService.trackPostHog (prod only)——

Permissions

ActionAuth
Send / read DMActive membership; pair rule via verifyCanMessage
Workout commentAssigned member OR any active staff in the org
DeleteSender only
Mark readRecipient only
Upload URLActive membership
Read member email status / send or reply by emailStaff only; lead_management feature
WebSocket connectValid Clerk JWT in handshake auth