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

10 KiB

title, tags, related_models, related_apis
title tags related_models related_apis
Chat Flow
flow
chat
socket-io
messaging
Chat
Message
User
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.
  • Frontendfrontend/src/sections/chat/ (chat list, conversation view, message composer).
  • BackendChatService (backend/src/services/chat/ChatService.ts), routes under /api/chat.
  • MongoDBchats 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

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 chatChatService.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)

  1. 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}.
  2. Optionally socket.emit('user-online', userId) so other clients see green status (app.ts:161-169).

Sending a message

  1. User types and hits send. Frontend POSTs POST /api/chat/:chatId/messages with { content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }.
  2. 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).
  3. Frontend reconciles its own message list (the sender either appends optimistically and then matches the server echo or waits for the round-trip).

Attachments

  1. 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 }).
  2. The send-message call then includes messageType: 'image' | 'file' and the file metadata. Files are served from /uploads.

Read receipts

  1. When the user opens a chat, frontend POSTs POST /api/chat/:chatId/read (optionally with messageIds: string[]).
  2. 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

  1. On input events, frontend emits socket.emit('typing-start', { chatId, userId, userName }); on idle/blur emits typing-stop.
  2. 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 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-messagechat-{chatId} (every message).
  • chat-notificationuser-{recipientId} for non-senders (badge).
  • messages-readchat-{chatId} after read mark.
  • user-typingchat-{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 participant403 "User is not a participant in this chat" (:209-211).
  • Chat not found404 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 conversationsgetChatMessages 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

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)