Remaining docs updated to match code (the docs that the first pass had not covered):
- Flows: Chat, Referral, Rating, Registration, Google OAuth, Negotiation, Payout,
Trezor Safekeeping — corrected endpoints, socket events, status enums, auth gaps
- API Reference: User API, Trezor API — admin route prefix/verb/status corrections,
added undocumented endpoints (ton-proof challenge, profile email verify,
GET /trezor/account, POST /trezor/verify-operation)
- Data Models: Chat, Notification, Payment, PointTransaction, User — corrected
enums (PaymentProvider, escrowState, PointTransaction.type, User.status),
90-day notification TTL, soft-delete semantics, wallet fields
Trezor "zero frontend" finding (audit C31/C32) corrected as STALE:
- Verified current code HAS a full frontend Trezor implementation (admin/trezor
page, TrezorSettingsView, trezorConnector via @trezor/connect-web,
TrezorSignDialog, actions/trezor.ts building the {message,signature} object)
- Fixed Trezor Safekeeping Flow doc (removed false "no frontend" warnings)
- Reclassified ISSUE-012 as invalid/superseded with explanation
Issue set reconciled to a single canonical numbering (ISSUE-001..054):
- Adopted the comprehensive 51-issue set (long-slug, fully indexed)
- Removed 35 superseded short-slug duplicates from the first pass
- Removed a duplicate ISSUE-046 file
- Added 3 issues the 51-set lacked: ISSUE-052 (completed-not-counted-in-stats),
ISSUE-053 (axios 401-only interceptor), ISSUE-054 (rate limiter counts all attempts)
- Regenerated Issues Index: 53 open (14 critical, 39 major) + 1 invalid
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
274 lines
18 KiB
Markdown
274 lines
18 KiB
Markdown
---
|
|
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)
|