Chat¶
Endpoints for 1:1 mentorship chat — persistent messaging with real-time delivery.
Authentication: All endpoints require a valid session and an organization context.
Endpoints¶
| Method | Path | Min Role | Description |
|---|---|---|---|
GET |
/api/chat/conversations |
Teacher |
List conversations (newest first) |
POST |
/api/mentorships/:id/chat/token |
Teacher |
Mint an Agora chat (RTM) token |
GET |
/api/mentorships/:id/chat/messages |
Teacher |
List messages (cursor paginated) |
POST |
/api/mentorships/:id/chat/messages |
Teacher |
Send a message |
POST |
/api/mentorships/:id/chat/messages/:msgId/reactions |
Teacher |
Add emoji reaction to a message |
DELETE |
/api/mentorships/:id/chat/messages/:msgId/reactions/:emoji |
Teacher |
Remove own emoji reaction |
PATCH |
/api/mentorships/:id/chat/read-receipt |
Teacher |
Update last-read message pointer |
GET |
/api/mentorships/:id/chat/unread |
Teacher |
Get unread message count |
Access control
Only the coach, teacher, and org-level admins (OrgAdmin, Manager) in the same organization may access a mentorship's chat. PlatformAdmin can read but is not a chat participant.
GET /api/chat/conversations¶
Returns all mentorships that have at least one chat message, sorted newest-first by last message time. This powers the Messages inbox tab.
Roles: Teacher, Coach, OrgAdmin, Manager, PlatformAdmin, Owner
Response 200
{
"data": [
{
"mentorshipId": "uuid",
"mentorshipTitle": "string | null",
"mentorshipStatus": "active | pending | paused | ended",
"otherUser": {
"id": "uuid",
"firstName": "string | null",
"lastName": "string | null",
"email": "string",
"avatar": "string | null"
},
"lastMessage": {
"id": "uuid",
"body": "string",
"senderId": "uuid",
"createdAt": "ISO 8601"
},
"unreadCount": 0
}
]
}
Notes:
- Only mentorships with at least one message are included.
unreadCountcounts messages from others sent after the caller's last read receipt.- OrgAdmin and Manager see all conversations in their org; Coach and Teacher see only their own.
Data Model¶
ChatMessage¶
| Field | Type | Description |
|---|---|---|
id |
string (UUID) | Message ID |
mentorshipId |
string (UUID) | Mentorship this message belongs to |
senderId |
string | User CUID of the sender |
body |
string | Message text (max 4000 chars) |
clientMessageId |
string | null | Optional client-generated UUID for idempotency |
createdAt |
string | ISO 8601 timestamp |
editedAt |
string | null | ISO 8601 timestamp if edited |
deletedAt |
string | null | ISO 8601 timestamp if soft-deleted |
replyToMessageId |
string (UUID) | null | ID of the parent message being replied to |
replyTo |
object | null | Truncated preview of the parent message (see below) |
attachments |
ChatAttachment[] |
List of files attached to this message |
reactions |
ChatReaction[] |
Aggregated reaction counts and user IDs by emoji |
sender |
object | null | Sender profile (populated in list responses) |
ChatAttachment¶
| Field | Type | Description |
|---|---|---|
id |
string (UUID) | file_attachments row ID |
fileId |
string (UUID) | files row ID |
filename |
string | null | Original filename |
mimeType |
string | null | MIME type (image/jpeg, application/pdf, …) |
sizeBytes |
number | null | File size in bytes |
url |
string | Presigned GET URL (15-minute expiry) |
ChatMessagePreview (replyTo)¶
| Field | Type | Description |
|---|---|---|
id |
string (UUID) | Parent message ID |
senderId |
string | User CUID of the parent message's sender |
senderName |
string | Display name of the parent message's sender |
body |
string | null | Truncated body (≤120 chars); null if soft-deleted |
ChatReadReceipt¶
| Field | Type | Description |
|---|---|---|
mentorshipId |
string (UUID) | Mentorship |
userId |
string | User CUID |
lastReadMessageId |
string | null | UUID of the last message the user has read |
updatedAt |
string | ISO 8601 timestamp |
ChatReaction¶
| Field | Type | Description |
|---|---|---|
emoji |
string | Emoji identifier (for example 👍, ❤️) |
count |
number | Number of users who reacted with this emoji |
userIds |
string[] | User CUIDs who reacted with this emoji |
POST /api/mentorships/:id/chat/token¶
Mint a short-lived Agora RTM (Signaling) token for the authenticated user.
The token is valid for 1 hour and scoped to the user's own Agora identity. Clients use it to authenticate with the Agora Signaling SDK for real-time message delivery. Persisted messages are always the source of truth; the token is for transport only.
Response 200
{
"data": {
"appId": "agora-app-id",
"token": "007eJxT...",
"userId": "usr_abc123",
"expiresAt": "2024-01-01T01:00:00.000Z"
}
}
Errors
| Status | Condition |
|---|---|
| 401 | Not authenticated |
| 403 | Not a participant or admin of this mentorship |
| 404 | Mentorship not found |
| 503 | Agora credentials not configured on the server |
GET /api/mentorships/:id/chat/messages¶
Returns paginated message history for a mentorship. Messages are ordered newest first (descending createdAt). Soft-deleted messages are excluded.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
number | 20 |
Max messages per page (1–100) |
before |
string | — | ISO 8601 cursor — return messages older than this time |
q |
string | — | Full-text search term (Postgres tsvector query) |
Response 200
{
"data": [
{
"id": "uuid",
"mentorshipId": "uuid",
"senderId": "user-id",
"body": "Hello!",
"clientMessageId": null,
"createdAt": "2024-01-01T00:00:00.000Z",
"editedAt": null,
"deletedAt": null,
"sender": {
"id": "user-id",
"firstName": "Coach",
"lastName": "Smith",
"email": "coach@example.com",
"avatar": null
}
}
],
"meta": {
"nextCursor": "2024-01-01T00:00:00.000Z",
"hasMore": true
}
}
Pass meta.nextCursor as the before parameter to fetch the next (older) page.
Search mode
When q is provided, the endpoint switches to full-text search mode for the current mentorship. Results are limited to 50, ordered newest-first, and meta.hasMore is always false.
POST /api/mentorships/:id/chat/messages¶
Persist and publish a new chat message.
The server inserts the message into Postgres and publishes a chat-message event on the Redis channel mentorship:{id}:chat, which is relayed over the existing SSE stream.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
body |
string | ✅ | Message text (1–4000 chars, trimmed) |
clientMessageId |
string | — | Client UUID v4 for idempotent delivery |
attachmentIds |
string[] | — | Array of up to 5 fileId values from /api/files/upload |
replyToMessageId |
string | — | UUID of the message being replied to (same mentorship) |
Idempotency: if clientMessageId is provided and already exists in the database, the existing message is returned with status 200 instead of inserting a duplicate.
Attachment flow: call POST /api/files/upload first to obtain fileId values, then pass them as attachmentIds. Each file must have been uploaded by the calling user; references to other users' files are rejected with 403.
Reply flow: if replyToMessageId is provided, the server verifies the parent message exists in this mentorship; otherwise it returns 422.
Push notifications: after the message is persisted, the server looks up the Expo push tokens for all mentorship participants who are not the sender and delivers a push notification (title = sender display name, body = message text truncated to 200 chars). Delivery is fire-and-forget and never blocks the response.
Response 201 (or 200 on idempotent repeat)
{
"data": {
"id": "uuid",
"mentorshipId": "uuid",
"senderId": "user-id",
"body": "Hello!",
"clientMessageId": "client-uuid",
"createdAt": "2024-01-01T00:00:00.000Z",
"editedAt": null,
"deletedAt": null,
"replyToMessageId": "uuid | null",
"replyTo": null,
"attachments": [],
"sender": { ... }
}
}
Errors
| Status | Condition |
|---|---|
| 400 | Validation error (empty body, >5 attachmentIds, etc.) |
| 401 | Not authenticated |
| 403 | Not a participant or admin; or fileId uploaded by other user |
| 404 | Mentorship not found |
| 422 | replyToMessageId not found in this mentorship |
PATCH /api/mentorships/:id/chat/messages/:msgId¶
Edit an existing message. Only the original sender may edit, and only within 15 minutes of the original send time.
Required role: Coach, Teacher, OrgAdmin, Manager, PlatformAdmin, Owner (sender-only restriction enforced separately)
Request body
| Field | Type | Required | Constraints |
|---|---|---|---|
body |
string | ✅ | 1–4 000 characters |
Response 200 — returns the updated ChatMessage with editedAt populated:
{
"data": {
"id": "...",
"mentorshipId": "...",
"senderId": "...",
"body": "Updated message body",
"editedAt": "2026-01-01T12:15:00.000Z",
"deletedAt": null,
"createdAt": "2026-01-01T12:00:00.000Z",
"sender": { "id": "...", "firstName": "...", "lastName": "...", "email": "...", "avatar": null }
}
}
Errors
| Status | Condition |
|---|---|
| 400 | Validation error (empty body) |
| 401 | Not authenticated |
| 403 | Not the original sender |
| 404 | Mentorship or message not found |
| 422 | Edit window expired (>15 minutes since send) |
The SSE stream emits a chat-message-updated event for all subscribers when an edit is saved.
POST /api/mentorships/:id/chat/messages/:msgId/reactions¶
Adds the caller's reaction for the target message/emoji pair.
If the same user already reacted with the same emoji, the operation is idempotent and still returns 200.
Request body
| Field | Type | Required | Constraints |
|---|---|---|---|
emoji |
string | ✅ | 1–8 chars |
Response 200
{
"data": {
"messageId": "uuid",
"reactions": [{ "emoji": "👍", "count": 2, "userIds": ["coach-id", "teacher-id"] }]
}
}
Errors
| Status | Condition |
|---|---|
| 400 | Validation error (emoji missing) |
| 401 | Not authenticated |
| 403 | Not a participant or admin |
| 404 | Mentorship or message not found |
DELETE /api/mentorships/:id/chat/messages/:msgId/reactions/:emoji¶
Removes the caller's reaction for the target emoji. Removing a non-existing reaction is treated as a no-op and still returns 200.
Response 200
Errors
| Status | Condition |
|---|---|
| 401 | Not authenticated |
| 403 | Not a participant or admin |
| 404 | Mentorship or message not found |
DELETE /api/mentorships/:id/chat/messages/:msgId¶
Soft-delete a message. The sender may always delete their own messages; OrgAdmin, Manager, PlatformAdmin, and Owner may delete any message.
Required role: Coach, Teacher, OrgAdmin, Manager, PlatformAdmin, Owner
Response 204 (no body)
Errors
| Status | Condition |
|---|---|
| 401 | Not authenticated |
| 403 | Not the sender and not an admin |
| 404 | Mentorship or message not found (or already deleted) |
Deleted messages are soft-deleted (deletedAt set). They no longer appear in GET /chat/messages results. The SSE stream emits a chat-message-deleted event so connected clients can replace the bubble with a tombstone immediately.
Tombstone persistence
Because GET /chat/messages omits soft-deleted messages, a tombstone shown via SSE will not appear in the history after a page reload or reconnect — the bubble will simply disappear. If your use-case requires persistent tombstones in history, consider returning deleted messages with a hidden body from the history endpoint instead.
PATCH /api/mentorships/:id/chat/read-receipt¶
Update the caller's last-read pointer for this mentorship's chat.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
lastReadMessageId |
string | ✅ | UUID of the last message read |
The message must belong to the specified mentorship; otherwise 404 is returned.
Response 204 (no body)
GET /api/mentorships/:id/chat/unread¶
Returns the number of unread messages for the caller (messages sent by others since the caller's last read receipt).
Response 200
Real-time delivery (SSE)¶
Chat messages are delivered in real time over the same SSE stream used for session updates:
Events:
| Event name | Payload | Description |
|---|---|---|
chat-message |
ChatMessage (JSON) |
A new message was sent |
chat-message-updated |
ChatMessage (JSON) with editedAt |
A message was edited |
chat-message-deleted |
{ id, mentorshipId } (JSON) |
A message was soft-deleted (show tombstone) |
chat-reaction-added |
{ messageId, userId, emoji, reactions } |
A reaction was added/updated |
chat-reaction-removed |
{ messageId, userId, emoji, reactions } |
A reaction was removed |
On reconnect, clients should call GET /chat/messages without a cursor to fetch any messages missed during the disconnection period.
Reconnect/resync pattern
The frontend useMentorshipSSE hook automatically invalidates the chat.messages query on app foreground, triggering a re-fetch from the history endpoint to catch up on missed messages.