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.
For direct chats tied to a purchase request, the request exists and both users are participants in it.
Chat lifecycle
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
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 } }.
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.
Group chat (dispute) — same pattern, but type: 'group', all three participants (buyer, seller, admin) added (admin is added later by DisputeService.assignAdmin).
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.
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)
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}.
Optionally socket.emit('user-online', userId) so other clients see green status (app.ts:161-169).
Sending a message
User types and hits send. Frontend POSTs POST /api/chat/:chatId/messages with { content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }.
ChatService.sendMessage (:195-260):
Loads chat, verifies the sender is in participants[] and isActive.
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).
Frontend reconciles its own message list (the sender either appends optimistically and then matches the server echo or waits for the round-trip).
Attachments
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 }).
The send-message call then includes messageType: 'image' | 'file' and the file metadata. Files are served from /uploads.
Read receipts
When the user opens a chat, frontend POSTs POST /api/chat/:chatId/read (optionally with messageIds: string[]).
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
On input events, frontend emits socket.emit('typing-start', { chatId, userId, userName }); on idle/blur emits typing-stop.
Backend app.ts:142-158 relays to chat-{chatId} as user-typing (excluding the sender). No DB persistence.
Sequence diagram
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; metadata.lastActivity=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[]; $setmetadata.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.