---
title: Chat Flow
tags: [flow, chat, socket-io, messaging]
related_models: ["[[Chat]]", "[[Message]]", "[[User]]"]
related_apis: ["POST /api/chat", "POST /api/chat/:id/messages", "GET /api/chat/:id/messages", "PATCH /api/chat/:id/messages/read"]
---

# Chat Flow
> **Last updated:** 2026-05-29 — aligned with code (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))

Real-time messaging between buyer & seller (direct), three-way dispute mediation (group), and user-to-support (support). Backed by `Chat` documents with embedded `messages[]` and a Socket.IO room per chat for live updates.

## Actors

- **User A (initiator)** — typically a buyer.
- **User B (recipient)** — typically a seller.
- **Support agent** — for `type: 'support'` chats (user is `support@amn.gg`).
- **Admin** — added as a third participant in dispute chats.
- **Frontend** — `frontend/src/sections/chat/` (chat list, conversation view, message composer).
- **Backend** — `ChatService` (`backend/src/services/chat/ChatService.ts`), routes under `/api/chat`.
- **MongoDB** — `chats` collection with embedded `messages`, `participants`, `unreadCounts`, `settings`, `metadata`.
- **Socket.IO** — events `new-message`, `chat-notification`, `messages-read`, `user-typing`, `user-status-change`, `message-deleted`.

## Preconditions

- Both parties authenticated.
- For `direct` chats tied to a purchase request, the request exists and both users are participants in it.

## Chat lifecycle

```mermaid
stateDiagram-v2
    [*] --> Created: ChatService.createChat\n(or auto on first contact)
    Created --> Active: messages flowing
    Active --> Active: send / read / typing
    Active --> Archived: PATCH /api/chat/:id/archive (toggle)
    Archived --> Active: PATCH /api/chat/:id/archive (same endpoint toggles back)
    Active --> [*]: chat deleted (rare)
```

## Step-by-step narrative

### Creation

1. **Direct chat (find-or-create)** — when buyer clicks "Chat with seller" on a request detail, frontend POSTs `POST /api/chat` with `{ type: 'direct', participantIds: [sellerId] }`. The endpoint requires **exactly 1 external `participantId`**; the authenticated caller is auto-appended to make 2.

   > [!warning] `relatedTo` is NOT accepted on `POST /api/chat`
   > Despite the schema carrying a `relatedTo` discriminator, the create endpoint ignores/does not accept a `relatedTo` payload. Purchase-request linkage is performed server-side via the dedicated `POST /api/chat/purchase-request` (see step 5), not by passing `relatedTo` to `POST /api/chat`.

2. `ChatService.createChat` (`ChatService.ts:90-192`):
   - For `direct` with exactly 2 participants, runs `Chat.findOne({ type: 'direct', 'participants.userId': { $all: [...] }, 'participants.isActive': true })` and returns the existing chat if found.
   - Otherwise creates a new `Chat` with `participants` (each with `role:'member'`, `joinedAt`, `isActive:true`), zeroed `unreadCounts`, default `settings`, `metadata.createdBy`.
   - Appends a system welcome message (`messageType: 'system'`).
   - Re-loads with `populate('participants.userId', 'firstName lastName profile.avatar email')` for the response.
3. **Group chat (dispute)** — same pattern, but `type: 'group'`, all three participants (buyer, seller, admin) added (admin is added later by `DisputeService.assignAdmin`).
4. **Support chat** — `ChatService.createSupportChat(userId)` (`:41-88`) auto-discovers `User.findOne({ email: 'support@amn.gg' })` and creates a `type: 'support'` chat with a welcome message. Idempotent.
5. **Post-payment / purchase-request auto-chat** — `POST /api/chat/purchase-request` exists on the backend and creates/links a direct chat for a purchase request. When payment is confirmed, the payment-state cascade ensures a direct chat exists between buyer and winning seller. **No frontend action is wired to `POST /api/chat/purchase-request`** — this direct chat is created server-side.

### Joining the room (real-time)

6. On chat page mount, the frontend emits `socket.emit('join-chat-room', chatId)` (`backend/src/app.ts:130-133`). The socket joins room `chat-{chatId}`.
7. **`join-user-room` and `user-online` are SEPARATE events** (do not conflate them):
   - `socket.emit('join-user-room', userId)` makes the socket join the personal `user-{userId}` room (so it can receive `chat-notification`).
   - `socket.emit('user-online', userId)` broadcasts a `user-status-change` (online) to other clients.

   > [!warning] No offline broadcast on disconnect — stale "online" status
   > On socket disconnect, **no offline `user-status-change` is emitted**. Other users keep seeing a stale "online" indicator for a peer who has actually left. Document this as a known gap.

### Sending a message

8. User types and hits send. Frontend POSTs `POST /api/chat/:id/messages` with `{ content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }`. Backend enforces a **5000-character maximum** on `content` at both Mongoose schema and controller validation levels.
9. `ChatService.sendMessage` (`:195-260`):
   - Loads chat, verifies the sender is in `participants[]` and `isActive`.
   - Builds `Message`: `{ senderId, content, messageType: 'text', fileUrl?, fileName?, fileSize?, replyTo?, timestamp, isRead: false, isEdited: false }`.
   - `chat.addMessage(messageData)` — schema method that pushes the message, updates `metadata.lastActivity`, increments `unreadCounts` for non-senders, and updates the cached `lastMessage` summary.
   - Persists.
   - Emits **`new-message`** to `chat-{chatId}` (everyone in the room sees the message immediately).
   - Emits **`chat-notification`** to each non-sender's `user-{userId}` room (drives the chat-list unread badge and the toast/notification bell if the user is not currently viewing the chat).
10. Frontend reconciles its own message list (the sender either appends optimistically and then matches the server echo or waits for the round-trip).

### Attachments

11. **File upload endpoint:** the real endpoint is **`POST /api/chat/:id/messages/file`** (multipart/form-data). The flow previously referenced `POST /api/chat/:chatId/upload`, which **does NOT exist**.

    > [!bug] ⚠️ KNOWN BUG — file uploads broken
    > The frontend `chatService.sendFileMessage` currently POSTs to the **text** message endpoint (`POST /api/chat/:id/messages`) instead of `POST /api/chat/:id/messages/file`. As a result file uploads are broken — they hit the wrong endpoint.

12. When working correctly, the backend handles the multipart payload at `POST /api/chat/:id/messages/file`, persists the upload via `fileService` (returns `{ fileUrl, fileName, fileSize }`), and records the message with `messageType: 'image' | 'file'`.

    > [!warning] ⚠️ Security concern — anonymous file access
    > Uploaded files are stored under `uploads/chat/` and served with **anonymous access**. Sensitive attachments (KYC docs, dispute evidence) are fetchable by any user who has the URL. Consider signed URLs or per-user authorisation.

### Editing a message

13. Editing a message uses a body of `{ content }` (max 5000 chars). Edits are only allowed within a **15-minute edit window** — edits attempted after that return **400**.

    > [!bug] ⚠️ KNOWN BUG — edits fail / are ignored
    > The frontend `editMessage` action sends `{ text }`, but the backend expects `{ content }`. The mismatched field name means edits fail or are silently ignored.

### Deleting a message (soft-delete)

14. Message DELETE **soft-deletes**: it sets `deletedAt`, clears the message `content`, and emits **`message-deleted`** to `chat-{chatId}`. The subdocument is not physically removed.

### Read receipts

15. When the user opens a chat, frontend marks messages read via **`PATCH /api/chat/:id/messages/read`** (note: **PATCH**, not POST; there is no `POST /api/chat/:chatId/read`). The body may carry `messageIds: string[]`; if `messageIds` is **empty or omitted, ALL messages are marked read**.
16. `ChatService.markMessagesAsRead` (`:438-483`):
    - Calls `chat.markAsRead(userId, messageObjectIds)` (schema method that flips `isRead` on the relevant messages and zeros the user's `unreadCounts` entry).
    - Emits **`messages-read`** to `chat-{chatId}` so the sender sees the double-tick.

### Typing indicator

17. On `input` events, frontend emits `socket.emit('typing-start', { chatId, userId, userName })`; on idle/blur emits `typing-stop`.
18. Backend `app.ts:142-158` relays to `chat-{chatId}` as `user-typing` (excluding the sender). No DB persistence. Limited to **5 typing indicators per 10 seconds**.

### Participants (add / remove / role)

19. **Add a participant** — real endpoint `POST /api/chat/:id/participants` expects a body of **`{ userId }` (a single id)**.

    > [!bug] ⚠️ KNOWN BUG — add participant payload mismatch
    > The frontend `addParticipants` action sends `{ participants: string[] }` (an array), but the backend expects `{ userId }` (a single id). The shapes do not match.

20. **Remove / leave** — to remove a participant (or have a user leave), use `DELETE /api/chat/:id/participants/:participantId`. Removal is a **soft removal**: the participant subdocument is kept with `isActive=false` and a `leftAt` timestamp.

    > [!bug] ⚠️ KNOWN BUG — leave action 404s
    > `PUT /chat/:id/leave` **does NOT exist** on the backend. The frontend `leaveConversation` action targets that path and therefore **404s**. Use `DELETE /api/chat/:id/participants/:participantId` instead.

21. **List participants** —

    > [!bug] ⚠️ KNOWN BUG — getParticipants 404s
    > `GET /chat/:id/participants` **does NOT exist** — the backend only exposes `POST` (add) and `DELETE` (remove) on that path. The frontend `getParticipants` action 404s. Participants must be read from **`GET /api/chat/:id/info`** instead.

22. **Change a participant role** —

    > [!bug] ⚠️ NOT IMPLEMENTED — updateParticipantRole
    > `PUT /chat/:id/participants/:participantId` **does NOT exist** on the backend. The frontend `updateParticipantRole` action has no backend counterpart.

### Chat info

23. `getChatInfo` → `GET /api/chat/:id/info` returns chat details **plus only the first 50 messages** (page 1, limit 50) — **not** the full message history. Use the paginated `GET /api/chat/:id/messages` to load older messages.

## Sequence diagram

```mermaid
sequenceDiagram
    autonumber
    actor A as User A
    actor B as User B
    participant FE_A as Frontend A
    participant FE_B as Frontend B
    participant BE as Backend
    participant DB as MongoDB
    participant IO as Socket.IO

    A->>FE_A: Open conversation
    FE_A->>BE: POST /api/chat {type:direct, participantIds:[sellerId]}
    BE->>DB: find-or-create Chat (caller auto-appended)
    BE-->>FE_A: { chat }
    FE_A->>IO: emit 'join-chat-room' chatId
    FE_A->>IO: emit 'join-user-room' userId (separate from user-online)
    FE_B->>IO: emit 'join-chat-room' chatId (when B opens too)

    A->>FE_A: type & send
    FE_A->>BE: POST /api/chat/{id}/messages {content} (max 5000 chars)
    BE->>DB: chat.addMessage and update metadata.lastActivity to now
    BE->>IO: emit chat-{id} 'new-message'
    IO-->>FE_A: 'new-message' (echo)
    IO-->>FE_B: 'new-message' (live)
    BE->>IO: emit user-{B} 'chat-notification' (badge)

    A->>FE_A: attach file
    FE_A->>BE: POST /api/chat/{id}/messages/file (multipart/form-data)
    BE->>DB: chat.addMessage with fileUrl/fileName/fileSize
    BE->>IO: emit chat-{id} 'new-message'

    B->>FE_B: opens chat
    FE_B->>BE: PATCH /api/chat/{id}/messages/read (empty messageIds = all)
    BE->>DB: chat.markAsRead(B)
    BE->>IO: emit chat-{id} 'messages-read'
    IO-->>FE_A: 'messages-read' (double-tick)

    A->>IO: emit 'typing-start'
    IO-->>FE_B: 'user-typing' {isTyping:true}
```

## API calls

| Method | Endpoint | Purpose |
|---|---|---|
| `POST` | `/api/chat` | Find-or-create chat (exactly 1 external `participantId`; caller auto-appended; `relatedTo` NOT accepted) |
| `GET` | `/api/chat` | List user's chats |
| `GET` | `/api/chat/:id/info` | Chat details + first 50 messages (page 1, limit 50) + participants |
| `GET` | `/api/chat/:id/messages` | Paginated message history |
| `POST` | `/api/chat/:id/messages` | Send text message |
| `POST` | `/api/chat/:id/messages/file` | Send file attachment (multipart/form-data) |
| `PATCH` | `/api/chat/:id/messages/read` | Mark read (empty/omitted `messageIds` marks ALL read) |
| `PUT` | `/api/chat/:id/messages/:messageId` | Edit message — body `{ content }`, 15-min edit window |
| `DELETE` | `/api/chat/:id/messages/:messageId` | Soft-delete a message (`deletedAt`, content cleared, emits `message-deleted`) |
| `POST` | `/api/chat/:id/participants` | Add a participant — body `{ userId }` (single) |
| `DELETE` | `/api/chat/:id/participants/:participantId` | Remove / leave (soft: `isActive=false`, `leftAt`) |
| `POST` | `/api/chat/support` | Create/get support chat |
| `POST` | `/api/chat/purchase-request` | Create/link direct chat for a purchase request (no frontend action wired) |
| `PATCH` | `/api/chat/:id/archive` | Toggle archived state (archive **and** unarchive via same endpoint) |

> [!bug] Frontend actions that target non-existent or mismatched backend endpoints
> - `leaveConversation` → `PUT /chat/:id/leave` — **does NOT exist** (404). Use `DELETE /api/chat/:id/participants/:participantId`.
> - `getParticipants` → `GET /chat/:id/participants` — **does NOT exist** (404). Use `GET /api/chat/:id/info`.
> - `updateParticipantRole` → `PUT /chat/:id/participants/:participantId` — **NOT IMPLEMENTED** on backend.
> - `editMessage` → sends `{ text }` but backend expects `{ content }` — edits fail/ignored.
> - `addParticipants` → sends `{ participants: string[] }` but backend expects `{ userId }` (single).
> - `sendFileMessage` → POSTs to the text endpoint instead of `POST /api/chat/:id/messages/file` — file uploads broken.

## Rate limits & constraints

- **Messages:** 20 messages / minute per user per chat.
- **Typing indicators:** 5 / 10 seconds.
- **Message dedup:** 5-minute window (duplicate sends within the window are de-duplicated).
- **Edit window:** 15 minutes — edits after that return **400**.
- **Message length:** 5000-character maximum (schema + controller).

## Database writes

- **`chats`**: insert on create; `$push` into `messages[]`; `$set` `metadata.lastActivity`; `unreadCounts.$.count` increment per recipient; `settings.isArchived` toggled (archive/unarchive); message soft-delete sets `deletedAt` + clears `content`; participant removal sets `participants.$.isActive=false` + `participants.$.leftAt`.

## Socket events emitted

- **`new-message`** → `chat-{chatId}` (every message).
- **`chat-notification`** → `user-{recipientId}` for non-senders (badge).
- **`messages-read`** → `chat-{chatId}` after read mark.
- **`message-deleted`** → `chat-{chatId}` after a message soft-delete.
- **`user-typing`** → `chat-{chatId}` (relayed by `app.ts`).
- **`user-status-change`** → broadcast when `user-online` is emitted (online only; **no offline broadcast on disconnect**).
- **`new-message`** (system) for system welcome lines on chat creation.

## Side effects

- **`metadata.lastActivity`** drives the chat-list sort order.
- **`lastMessage`** cache lets the chat-list render previews without loading the entire `messages[]` array.
- **`unreadCounts`** is the source-of-truth for badge counts; resetting on read also drives global unread totals.
- **Embedded messages array** can grow large; consider migrating to a separate `messages` collection if conversations exceed several thousand messages.

## Error / edge cases

- **Sender not a participant** → `403 "User is not a participant in this chat"` (`:209-211`).
- **Chat not found** → `404` on `getChatMessages`.
- **Direct duplicate** → idempotent — `createChat` returns existing chat.
- **Content too long** — backend rejects messages exceeding 5000 characters at both Mongoose schema and controller validation levels.
- **Edit after 15 minutes** → `400`.
- **Files served from `uploads/chat/`** — anonymous access is allowed. Sensitive attachments (KYC docs, dispute evidence) should be protected by signed URLs or per-user authorisation; currently any user with the URL can fetch.
- **Stale online status** — no offline broadcast on disconnect; peers may show "online" for a user who has left.
- **Long conversations** — `getChatMessages` slices an in-memory copy of the messages array. For >10k messages this is inefficient. Use aggregation `$slice` or a separate collection.
- **Race on `markAsRead`** — two parallel reads may double-zero the counter, which is harmless.
- **Typing indicator spam** — clients should debounce `typing-start` and emit `typing-stop` on idle (rate-limited to 5/10s server-side regardless).

> [!warning] Notification message uses placeholder sender name
> `ChatService.sendMessage` posts `chat-notification` with `senderName: "کاربر"` (`:248`) — the literal Persian word for "user". Resolve `senderName` from `participant.userId.firstName` for a better UX.

## Linked flows

- [[Notification Flow]] — `chat-notification` is one of the inputs.
- [[Negotiation Flow]] — uses chats heavily.
- [[Dispute Flow]] — three-way group chat.
- [[Authentication Flow]] — supplies the `user-${userId}` rooms via `join-user-room`.

## Source files

- Backend: `backend/src/services/chat/ChatService.ts`
- Backend: `backend/src/services/chat/chatController.ts`
- Backend: `backend/src/services/chat/routes.ts` (under `/api/chat`)
- Backend: `backend/src/models/Chat.ts`
- Backend: `backend/src/app.ts:130-179` (Socket.IO chat handlers)
- Frontend: `frontend/src/sections/chat/`
- Frontend: `frontend/src/contexts/socket-context.tsx` (or equivalent socket provider)
