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
| Invariant | Enforcement |
|---|---|
| Message must have content OR attachments | Service: if (!content && !attachments.length) throw 400 |
| Sender must be an active org member | requireOrgMembership |
| Recipient must be in the same org | Service check |
| Pair must be allowed (staff↔anyone, member→staff) — member↔member blocked | verifyCanMessage |
| Workout comment requires assignment to belong to this org AND requester is the assigned member OR active staff | resolveWorkoutAssignmentForChat |
| Soft-deleted message can’t be re-read or re-edited | All reads filter deleted_at IS NULL |
| Read receipts can only be claimed by the recipient | markRead checks recipientId === resolvedUserId |
| Realtime WS auth via Clerk JWT | MessagesGateway.handleConnection calls verifyToken |
| WS messages are rate-limited 30 / 60s per socket | checkSocketRate |
| Staff-to-member email honors member preferences | ConversationsService.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-gated | Web 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.
- 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 oncontact.membershipId). Rows sit under Needs reply (unread, or the other side spoke last: DMlastMessageDirection/ inboxlastDirection=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. - 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). - 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 /messageswith attachments. Email sendsPOST …/inbox{ contactUserId, channel:'email', subject?, body }when no thread exists, otherwisePOST …/inbox/:id/messages{ body, channel:'email' }under the stored subject (ConversationDetail.subject, shown as “Re: …”). - 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
- Coach opens a member on the desk, or starts one with New message.
- Coach types content + optionally attaches an image (presigned PUT via
POST /messages/upload-url, then PUT to R2). POST /messageswith{ recipientMembershipId, content, attachmentIds: [...] }.- Service: validates pair, resolves attachments, inserts a
messagesrow +message_attachmentsrows in a transaction, then:- Emits WS
message:newto bothuser:{senderId}anduser:{recipientId}rooms. - Enqueues push to the recipient via
PushNotificationsService.notifyUserwith categorynewMessage.
- Emits WS
Staff emails a member
- On the desk, staff switch the composer to Email (
lead_managementon). A missing address ornewMessage.email=falseblocks sending with a notice before typing. - The unified inbox finds or creates the member thread and sends through the same email adapter used for leads.
- 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. - Every send, first or reply, re-checks the member preference before any outbound row is inserted.
Member reads the conversation
- Member opens chat →
GET /conversations/:membershipIdreturns paginated history. - UI calls
PUT /conversations/:membershipId/read→ service bulk-updatesread_at = nowfor unread messages where the member is the recipient. - Sender receives WS
message:readfor each updated message id.
Coach posts a workout comment
- Coach opens member’s workout drawer → comments tab.
POST /workout-assignments/:assignmentId/commentswith{ content }.- Service: validates assignment access, inserts row with
workout_assignment_idset. Fans out push to the OTHER participant (coach OR member depending on sender) with categorynewComment. Recipient is the assignment’suserIdif 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
| Scenario | Behavior |
|---|---|
| Send to a member who was just deactivated | resolveMembershipId requires status='active'; 404 |
| Send to yourself | Allowed? verifyCanMessage doesn’t explicitly block — verify intent |
| Attachment uploaded but never referenced | Lives in R2 as a staged orphan until janitor |
| WS disconnect mid-send | API call still succeeds (REST is the source of truth); WS event is fire-and-forget |
| Two devices for same user | Both receive WS events (joined user:{id} room) |
Push notif when user has opted out of newMessage | filterByPreferences drops the user before push enqueue |
Member email when newMessage.email is explicitly false | API 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/key | Email 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 DM | Still one desk row (from the inbox list’s contact.membershipId); opening it shows the email thread |
lead_management is off | No App | Email switch, no email rows or slips; the desk is app DMs only |
| Soft-deleted message in a thread | Filtered out of history; conversation still exists |
| Conversation list when other side has no membership | Defensive 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 level | Push skipped; WS still fires |
Side effects
| Action | Writes | Emits WS | Pushes / email |
|---|---|---|---|
| Send DM | messages insert + message_attachments | message:new (sender + recipient rooms) | newMessage push category |
| Send member email | conversations find/create + conversation_messages outbound row | — | Email through unified inbox adapter |
| Receive member email reply | conversation_messages inbound row; conversation unread/status update | CONVERSATION_INBOUND_RECEIVED application event | — |
| Send workout comment | messages 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) | — |
| Edit | column exists; no path | — | — |
| Delete | messages update (deleted_at) | (no event today) | — |
| Send upload-url | — | — | — |
EventTrackingService.track | PostHog (prod only) | — | — |
Permissions
| Action | Auth |
|---|---|
| Send / read DM | Active membership; pair rule via verifyCanMessage |
| Workout comment | Assigned member OR any active staff in the org |
| Delete | Sender only |
| Mark read | Recipient only |
| Upload URL | Active membership |
| Read member email status / send or reply by email | Staff only; lead_management feature |
| WebSocket connect | Valid Clerk JWT in handshake auth |