Files
nick-doc/04 - Flows/Chat Flow.md
Siavash Sameni 7a616744f4 docs: complete code-reality alignment for remaining docs + reconcile issue set
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>
2026-05-29 15:15:02 +04:00

18 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/: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)

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, 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

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 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 / purchase-request auto-chatPOST /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)

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

  1. 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.
  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. 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.

  2. 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

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

  1. 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

  1. 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.
  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. Limited to 5 typing indicators per 10 seconds.

Participants (add / remove / role)

  1. 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.

  2. 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.

  3. 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.

  4. 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

  1. getChatInfoGET /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

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

  • leaveConversationPUT /chat/:id/leavedoes NOT exist (404). Use DELETE /api/chat/:id/participants/:participantId.
  • getParticipantsGET /chat/:id/participantsdoes NOT exist (404). Use GET /api/chat/:id/info.
  • updateParticipantRolePUT /chat/:id/participants/:participantIdNOT 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-messagechat-{chatId} (every message).
  • chat-notificationuser-{recipientId} for non-senders (badge).
  • messages-readchat-{chatId} after read mark.
  • message-deletedchat-{chatId} after a message soft-delete.
  • user-typingchat-{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 participant403 "User is not a participant in this chat" (:209-211).
  • Chat not found404 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 minutes400.
  • 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 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 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

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)