Files
nick-doc/04 - Flows/Chat Flow.md

189 lines
10 KiB
Markdown

---
title: Chat Flow
tags: [flow, chat, socket-io, messaging]
related_models: ["[[Chat]]", "[[Message]]", "[[User]]"]
related_apis: ["POST /api/chat", "POST /api/chat/:chatId/messages", "GET /api/chat/:chatId/messages", "POST /api/chat/:chatId/read"]
---
# Chat Flow
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`.
## 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: settings.isArchived=true
Archived --> Active: unarchive
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: [buyer, seller], relatedTo: { type: 'PurchaseRequest', id } }`.
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'`).
- If `relatedTo.type === 'PurchaseRequest'`, also writes `"چت برای درخواست خرید \"{title}\" ایجاد شد"` system line.
- 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 auto-chat** — when SHKeeper confirms payment, `shkeeperWebhook.ts:606-618` calls `chatService.createChat` to ensure a direct chat exists between buyer and winning seller.
### 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. Optionally `socket.emit('user-online', userId)` so other clients see green status (`app.ts:161-169`).
### Sending a message
8. User types and hits send. Frontend POSTs `POST /api/chat/:chatId/messages` with `{ content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }`.
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. To attach a file, the user picks a file → frontend calls `chatService.uploadChatFile(chatId, file)` (or the equivalent `POST /api/chat/:chatId/upload`) — backend persists the upload via `fileService` (returns `{ fileUrl, fileName, fileSize }`).
12. The send-message call then includes `messageType: 'image' | 'file'` and the file metadata. Files are served from `/uploads`.
### Read receipts
13. When the user opens a chat, frontend POSTs `POST /api/chat/:chatId/read` (optionally with `messageIds: string[]`).
14. `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
15. On `input` events, frontend emits `socket.emit('typing-start', { chatId, userId, userName })`; on idle/blur emits `typing-stop`.
16. Backend `app.ts:142-158` relays to `chat-{chatId}` as `user-typing` (excluding the sender). No DB persistence.
## 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, relatedTo}
BE->>DB: find-or-create Chat
BE-->>FE_A: { chat }
FE_A->>IO: emit 'join-chat-room' chatId
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}
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)
B->>FE_B: opens chat
FE_B->>BE: POST /api/chat/{id}/read
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 |
| `GET` | `/api/chat` | List user's chats |
| `GET` | `/api/chat/:chatId/messages` | Paginated message history |
| `POST` | `/api/chat/:chatId/messages` | Send message |
| `POST` | `/api/chat/:chatId/upload` | Upload attachment |
| `POST` | `/api/chat/:chatId/read` | Mark read |
| `POST` | `/api/chat/support` | Create/get support chat |
## Database writes
- **`chats`**: insert on create; `$push` into `messages[]`; `$set` `metadata.lastActivity`; `unreadCounts.$.count` increment per recipient; `settings.isArchived` toggled; `participants.$.isActive` flipped on leave.
## Socket events emitted
- **`new-message`** → `chat-{chatId}` (every message).
- **`chat-notification`** → `user-{recipientId}` for non-senders (badge).
- **`messages-read`** → `chat-{chatId}` after read mark.
- **`user-typing`** → `chat-{chatId}` (relayed by `app.ts`).
- **`user-status-change`** → broadcast when `user-online` is emitted.
- **`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.
- **Empty content** — currently allowed (system messages are typically non-empty though); add a min-length validator if needed.
- **Files served from `/uploads`** — 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.
- **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 a 2s idle.
> [!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)