Skip to content

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.
  • unreadCount counts 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

{
  "data": {
    "messageId": "uuid",
    "reactions": []
  }
}

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

{
  "data": {
    "unreadCount": 3
  }
}

Real-time delivery (SSE)

Chat messages are delivered in real time over the same SSE stream used for session updates:

GET /api/events/mentorships/:id/events

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.