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

Golden paths

Coach DMs a member

  1. Coach opens /dashboard/messages, picks a member (membership row) from the conversation list or starts a new conversation.
  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.

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 joins assignments through the workouts table to bucket unread comments to a program — surfaced as a badge on the program tab.

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
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
Send DMmessages insert + message_attachmentsmessage:new (sender + recipient rooms)newMessage category
Send workout commentmessages insert (with workout_assignment_id)message:new (in assignment-scoped room — TBD)newComment
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
WebSocket connectValid Clerk JWT in handshake auth