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 |
Golden paths
Coach DMs a member
- Coach opens
/dashboard/messages, picks a member (membership row) from the conversation list or starts a new conversation. - 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
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 joins assignments through the workouts table to bucket unread comments to a program — surfaced as a badge on the program tab.
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 |
| 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 |
|---|---|---|---|
| Send DM | messages insert + message_attachments | message:new (sender + recipient rooms) | newMessage category |
| Send workout comment | messages 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) | — |
| 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 |
| WebSocket connect | Valid Clerk JWT in handshake auth |