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>
This commit is contained in:
@@ -5,6 +5,8 @@ related_models: ["[[User]]", "[[TempVerification]]"]
|
||||
related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"]
|
||||
---
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
|
||||
|
||||
> [!caution] Audit note — last reviewed 2026-05-29
|
||||
> Several discrepancies between frontend code, backend code, and this document were identified and corrected in this revision. See inline ⚠️ markers for known bugs and mismatches.
|
||||
|
||||
@@ -52,7 +54,7 @@ End-to-end specification for **email + password** authentication, JWT issuance,
|
||||
> [!warning] Token storage is `localStorage`, not cookies
|
||||
> Tokens are persisted in `window.localStorage`. This is intentional (the API is a separate origin and the app is fully SPA-like), but it means tokens are reachable from any script running on the page. Mitigations in place: strict CSP via `helmet`, no third-party scripts in the auth views, and short access-token TTL with refresh rotation. There are **no httpOnly auth cookies**.
|
||||
|
||||
16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request. On a `401` response, the interceptor automatically triggers the refresh flow described below. A `403` response (e.g., `EMAIL_NOT_VERIFIED`) is **not** retried via refresh — it is surfaced directly to the caller.
|
||||
16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request. On a `401` response, the interceptor automatically triggers the refresh flow described below. A `403` response (e.g., `EMAIL_NOT_VERIFIED`) is **not** retried via refresh — it is surfaced directly to the caller. The interceptor only checks `status === 401` (`axios.ts:105`); 403 responses are not handled by the interceptor and propagate as errors.
|
||||
17. **Socket.IO bootstrap**: After login, the dashboard layout connects to Socket.IO and emits `join-user-room`, `join-buyer-room`/`join-seller-room` based on `user.role`. See `backend/src/app.ts:83-126`.
|
||||
|
||||
## Sequence diagram
|
||||
@@ -102,6 +104,7 @@ sequenceDiagram
|
||||
| `POST` | `/api/auth/refresh-token` | `authRoutes.ts:24-27` → `authController.refreshToken` |
|
||||
| `POST` | `/api/auth/logout` | `authRoutes.ts:68` → `authController.logout` (protected) |
|
||||
| `GET` | `/api/auth/profile` | `authRoutes.ts:69` → `authController.getProfile` |
|
||||
| `DELETE` | `/api/auth/account` | `authRoutes.ts:86-89` → `authController.deleteAccount` (requires `password` in body, runs `deleteAccountValidation`) |
|
||||
|
||||
## Telegram first-class auth flow
|
||||
|
||||
@@ -119,6 +122,10 @@ Telegram is now a peer auth provider alongside email/password, Google, and passk
|
||||
|
||||
High-risk actions are unchanged: escrow release, refund, dispute-sensitive, and wallet-sensitive operations still use the existing protected backend authorization and step-up gates. Telegram auth only establishes the user session.
|
||||
|
||||
## Passkey auth flow
|
||||
|
||||
The frontend `registerPasskey` and `authenticateWithPasskey` actions call passkey API endpoints. All passkey API calls are proxied directly to the Express backend via the `next.config.ts` rewrite rule (`/api/:path*` → backend). There are no Next.js route handler files (`route.ts`) for passkey paths — requests travel: browser → Next.js dev server (rewrite) → Express backend.
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users` collection**: `lastLoginAt` updated; `refreshTokens` array gains one entry per successful login or refresh.
|
||||
@@ -146,7 +153,7 @@ The access token is short-lived. When a protected request returns `401 TOKEN_INV
|
||||
4. The new pair is written back to `localStorage` and the original failed request is retried.
|
||||
|
||||
> [!note] 403 responses are not retried
|
||||
> The interceptor only triggers token refresh for `status === 401`. A `403` (e.g., `EMAIL_NOT_VERIFIED`) is passed through directly to the caller without attempting a refresh.
|
||||
> The interceptor only triggers token refresh for `status === 401` (`axios.ts:105`). A `403` (e.g., `EMAIL_NOT_VERIFIED`) is passed through directly to the caller without attempting a refresh.
|
||||
|
||||
> [!warning] Refresh-token sequence diagram is truncated
|
||||
> The Mermaid diagram below is **incomplete** — it was truncated in the original source at the point where the backend checks that the refresh token exists in `user.refreshTokens`. The remaining steps (rotate tokens, persist, respond, retry original request) are described in prose above but are not yet rendered in the diagram.
|
||||
@@ -178,17 +185,17 @@ sequenceDiagram
|
||||
|
||||
### deleteAccount
|
||||
|
||||
> [!bug] Account deletion is currently broken
|
||||
> The frontend `deleteAccount` action calls `DELETE /user/profile`, which does **not exist** on the backend. The real backend endpoint is `DELETE /api/auth/account` (requires `password` in the request body and runs `deleteAccountValidation`). Until the frontend is updated to call the correct endpoint, account deletion will always fail with a 404 or routing error.
|
||||
> [!bug] Account deletion frontend calls wrong endpoint
|
||||
> The frontend `deleteAccount` action calls `DELETE /user/profile`, which does **not exist** on the backend. The real backend endpoint is `DELETE /api/auth/account` (`authRoutes.ts:86-89`), which requires a `password` field in the request body and runs `deleteAccountValidation` middleware. Until the frontend is updated to call the correct endpoint, account deletion will always fail with a 404 or routing error.
|
||||
|
||||
## Known issues summary
|
||||
|
||||
| Issue | Severity | Details |
|
||||
|---|---|---|
|
||||
| `deleteAccount` calls wrong endpoint | Bug | Frontend calls `DELETE /user/profile`; backend endpoint is `DELETE /api/auth/account` |
|
||||
| `deleteAccount` calls wrong endpoint | Bug | Frontend calls `DELETE /user/profile`; correct backend endpoint is `DELETE /api/auth/account` (requires `password` in body) |
|
||||
| No change-password UI | Gap | `POST /api/auth/change-password` and `changePassword()` action exist but no dashboard page renders the form |
|
||||
| Rate limiter counts all attempts | Clarification | Counter increments before password check — 5 total attempts (not 5 failures) triggers lockout |
|
||||
| Axios interceptor 403 passthrough | Clarification | Interceptor only auto-refreshes on 401; 403 errors are surfaced directly |
|
||||
| Axios interceptor 401-only | Clarification | Interceptor only auto-refreshes on `status === 401` (`axios.ts:105`); 403 errors propagate directly to caller |
|
||||
| Refresh-token diagram truncated | Doc debt | Mermaid diagram cut off mid-flow; prose description is authoritative |
|
||||
|
||||
## Linked flows
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
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"]
|
||||
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.
|
||||
|
||||
@@ -18,7 +19,7 @@ Real-time messaging between buyer & seller (direct), three-way dispute mediation
|
||||
- **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`.
|
||||
- **Socket.IO** — events `new-message`, `chat-notification`, `messages-read`, `user-typing`, `user-status-change`, `message-deleted`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
@@ -32,8 +33,8 @@ 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 --> Archived: PATCH /api/chat/:id/archive (toggle)
|
||||
Archived --> Active: PATCH /api/chat/:id/archive (same endpoint toggles back)
|
||||
Active --> [*]: chat deleted (rare)
|
||||
```
|
||||
|
||||
@@ -41,25 +42,33 @@ stateDiagram-v2
|
||||
|
||||
### 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 } }`.
|
||||
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'`).
|
||||
- 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 payment is confirmed, the payment-state cascade ensures a direct chat exists between buyer and winning seller.
|
||||
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. Optionally `socket.emit('user-online', userId)` so other clients see green status (`app.ts:161-169`).
|
||||
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/:chatId/messages` with `{ content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }`.
|
||||
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 }`.
|
||||
@@ -71,20 +80,64 @@ stateDiagram-v2
|
||||
|
||||
### 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`.
|
||||
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
|
||||
|
||||
13. When the user opens a chat, frontend POSTs `POST /api/chat/:chatId/read` (optionally with `messageIds: string[]`).
|
||||
14. `ChatService.markMessagesAsRead` (`:438-483`):
|
||||
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
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -100,22 +153,28 @@ sequenceDiagram
|
||||
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
|
||||
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}
|
||||
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: POST /api/chat/{id}/read
|
||||
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)
|
||||
@@ -128,25 +187,49 @@ sequenceDiagram
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/chat` | Find-or-create chat |
|
||||
| `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/: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 |
|
||||
| `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; `participants.$.isActive` flipped on leave.
|
||||
- **`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.
|
||||
- **`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
|
||||
@@ -161,11 +244,13 @@ sequenceDiagram
|
||||
- **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.
|
||||
- **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 a 2s idle.
|
||||
- **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.
|
||||
|
||||
@@ -26,7 +26,7 @@ After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escr
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
1. **Seller marks shipped** — from the seller step `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`, clicks "Mark as shipped". This patches the request status to `delivery`. No code is generated at this point.
|
||||
1. **Seller marks shipped** — from the seller step `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`, clicks "Mark as shipped". The frontend action `updateDelivery` calls `PUT /api/marketplace/purchase-requests/:id/delivery`. The controller's `updateDeliveryInfo` sets `shippedAt` and advances status to `delivery`. No code is generated at this point.
|
||||
2. **Buyer generates the delivery code** — once status is `delivery`, the buyer explicitly triggers `POST /api/marketplace/purchase-requests/:id/delivery-code/generate` (buyerId is enforced server-side). `DeliveryService.generateDeliveryCode(requestId)`:
|
||||
- Generates a 6-digit code (`Math.floor(100000 + Math.random()*900000)`).
|
||||
- Sets `deliveryInfo.deliveryCode`, `deliveryCodeGeneratedAt = now`, `deliveryCodeExpiresAt = now + 7d`, `deliveryCodeUsed = false`.
|
||||
@@ -34,13 +34,13 @@ After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escr
|
||||
- The code is displayed to the buyer in `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`.
|
||||
3. **Buyer reads code to seller** — at hand-off the buyer reads the 6-digit code out loud (or shows it) to the seller.
|
||||
4. **Seller enters code** — seller types the code into `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx`.
|
||||
5. **Verification** — `POST /api/marketplace/purchase-requests/:id/delivery-code/verify` with `{ code }` (selectedOffer.sellerId is enforced server-side):
|
||||
5. **Verification** — `POST /api/marketplace/purchase-requests/:id/delivery-code/verify` with `{ code }` (selectedOffer.sellerId is enforced server-side). Handled by `DeliveryService.verifyDeliveryCode` (lines 180-212):
|
||||
- Matches `code` against `deliveryInfo.deliveryCode`.
|
||||
- Checks `deliveryCodeExpiresAt > now` and `deliveryCodeUsed === false`.
|
||||
- On success: `deliveryInfo.deliveryCodeUsed = true; deliveryCodeUsedAt = now`. Status flips `delivery → delivered`.
|
||||
- Emits `purchase-request-update` `status-changed`.
|
||||
- Triggers buyer/seller notifications via `notifyDeliveryConfirmed` (see `PurchaseRequestService.ts:631-641`).
|
||||
6. **Alternative path — buyer fast-track** — the buyer can also call `PATCH .../confirm-delivery` to set status to `delivered` without any code (used when the code path fails, e.g. code expired or lost). **⚠️ Authorization gap:** this endpoint currently has no authorization check; any authenticated user can call it.
|
||||
- Sends delivery-confirmed notifications to both buyer and seller directly within `DeliveryService.verifyDeliveryCode`.
|
||||
6. **Alternative path — buyer fast-track** — the buyer can also call `PATCH .../confirm-delivery` to set status to `delivered` without any code (used when the code path fails, e.g. code expired or lost). This endpoint emits only `purchase-request-update` with `status-changed` — it does **not** send delivery-specific notifications to either party. **⚠️ Authorization gap:** this endpoint currently has no authorization check; any authenticated user can call it.
|
||||
7. **Optional auto-release timer** — once `status === 'delivered'`, a scheduled job can flip the request to `confirming` and then to `seller_paid` after a grace period (e.g. 48h). The auto-release worker is not yet implemented; today an admin completes the chain via [[Payout Flow]].
|
||||
|
||||
## Sequence diagram
|
||||
@@ -56,14 +56,14 @@ sequenceDiagram
|
||||
participant IO as Socket.IO
|
||||
|
||||
S->>FE: Click "Mark as shipped"
|
||||
FE->>BE: PATCH /api/marketplace/purchase-requests/{id} {status:"delivery"}
|
||||
BE->>DB: PurchaseRequest.status="delivery"
|
||||
FE->>BE: PUT /api/marketplace/purchase-requests/{id}/delivery
|
||||
BE->>DB: PurchaseRequest.shippedAt=now, status="delivery"
|
||||
Note over BE,DB: No code generated here
|
||||
|
||||
B->>FE: View delivery code in step-5-receive-goods
|
||||
FE->>BE: POST /api/marketplace/purchase-requests/{id}/delivery-code/generate
|
||||
BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
|
||||
BE->>IO: emit request-{id} 'delivery-code-generated'
|
||||
BE->>IO: emit request-{id} 'delivery-code-generated' {code, expiresAt}
|
||||
FE->>B: Display 6-digit code
|
||||
|
||||
B->>S: At hand-off, read the 6-digit code aloud
|
||||
@@ -73,8 +73,8 @@ sequenceDiagram
|
||||
BE->>DB: set deliveryCodeUsed = true
|
||||
BE->>DB: set status = "delivered"
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' status-changed
|
||||
BE->>B: notifyDeliveryConfirmed
|
||||
BE->>S: notifyDeliveryConfirmed
|
||||
BE->>B: notifyDeliveryConfirmed (DeliveryService.verifyDeliveryCode)
|
||||
BE->>S: notifyDeliveryConfirmed (DeliveryService.verifyDeliveryCode)
|
||||
Note over BE: Auto-release timer (planned) → seller_paid → payout
|
||||
```
|
||||
|
||||
@@ -82,12 +82,12 @@ sequenceDiagram
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` `{status:"delivery"}` | Seller marks shipped |
|
||||
| `PUT` | `/api/marketplace/purchase-requests/:id/delivery` | Seller marks shipped (sets shippedAt, advances to `delivery`) |
|
||||
| `GET` | `/api/marketplace/purchase-requests/:id/delivery-code` | Retrieve current code (buyer + seller) |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/generate` | Buyer generates delivery code (buyer only) |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code/verify` | Seller verifies code (seller only) |
|
||||
| `GET` | `/api/marketplace/purchase-requests/:id/delivery-code/status` | Check code status (buyer + seller) |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) — ⚠️ no auth check |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) — ⚠️ no auth check, no delivery notifications |
|
||||
|
||||
### Phantom frontend actions (routes do NOT exist on backend)
|
||||
|
||||
@@ -101,24 +101,25 @@ These Redux/API actions exist in the frontend but call endpoints that return 404
|
||||
|
||||
## Two paths to `delivered` status
|
||||
|
||||
1. **Code path** — seller calls `POST .../delivery-code/verify` with the correct, unexpired code → status becomes `delivered`.
|
||||
2. **Fast-track path** — buyer calls `PATCH .../confirm-delivery` (no code required) → also becomes `delivered`. ⚠️ Currently no authorization check on this endpoint.
|
||||
1. **Code path** — seller calls `POST .../delivery-code/verify` with the correct, unexpired code → status becomes `delivered`. Both buyer and seller receive delivery-confirmed notifications (sent by `DeliveryService.verifyDeliveryCode`).
|
||||
2. **Fast-track path** — buyer calls `PATCH .../confirm-delivery` (no code required) → also becomes `delivered`. ⚠️ Currently no authorization check on this endpoint, and no delivery-specific notifications are sent to either party.
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`purchaserequests.deliveryInfo`** — `deliveryCode`, `deliveryCodeGeneratedAt`, `deliveryCodeExpiresAt`, `deliveryCodeUsed`, `deliveryCodeUsedAt`.
|
||||
- **`purchaserequests.shippedAt`** — set when seller calls `PUT .../delivery`.
|
||||
- **`purchaserequests.status`** — `delivery` → `delivered` → (eventually `seller_paid` → `completed`).
|
||||
- **`notifications`** — generated for both parties.
|
||||
- **`notifications`** — generated for both parties (code path only).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`delivery-code-generated`** → `request-{id}` (with code, expiresAt).
|
||||
- **`delivery-code-generated`** → `request-{id}` room (payload: `{ requestId, code, expiresAt, timestamp }`). **⚠️ Security note:** the full 6-digit code is included in the payload and broadcast to all subscribers in the room, including the seller. The buyer dashboard displays the code; the seller receives it via socket as well.
|
||||
- **`delivery-update`** → `request-{id}` (`type: 'code-generated'`).
|
||||
- **`purchase-request-update`** `status-changed` on `delivery → delivered`.
|
||||
|
||||
## Side effects
|
||||
|
||||
- The code is shown only to the **buyer** in their dashboard. The buyer verbally shares it with the seller — there is no backend push of the code to the seller.
|
||||
- The code is displayed to the **buyer** in their dashboard. The buyer verbally shares it with the seller at hand-off. Note that the `delivery-code-generated` socket event also broadcasts the raw code to the entire request room (including the seller — see socket events section above).
|
||||
- Triggers the path that eventually frees up the escrow (manual today via [[Payout Flow]], auto in the future).
|
||||
|
||||
## Error / edge cases
|
||||
@@ -143,9 +144,8 @@ These Redux/API actions exist in the frontend but call endpoints that return 404
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/delivery/DeliveryService.ts`
|
||||
- Backend: `backend/src/services/delivery/DeliveryService.ts` (generateDeliveryCode, verifyDeliveryCode lines 180-212)
|
||||
- Backend: `backend/src/services/marketplace/routes.ts` (delivery endpoints)
|
||||
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:631-641` (confirmation notifications)
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/step-3-ship-goods.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-5-receive-goods.tsx`
|
||||
|
||||
@@ -6,6 +6,8 @@ related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api
|
||||
audit: "2026-05-29 — corrected against source: Dispute.ts, DisputeService.ts, routes/disputeRoutes.ts (dashboard), services/dispute/disputeRoutes.ts (release-hold), app.ts. Previous version had wrong resolution schema, invented status values, missing security issues, and incorrect socket-event description."
|
||||
---
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
|
||||
|
||||
# Dispute Flow
|
||||
|
||||
When something goes wrong (item not delivered, wrong item, seller misbehaviour), either party can open a **dispute**. A three-way chat (buyer, seller, admin mediator) is created automatically. After evidence is gathered, the admin **resolves** the dispute — selecting an action such as refund, replacement, compensation, warning, or ban.
|
||||
|
||||
@@ -7,6 +7,8 @@ related_apis: ["POST /api/auth/google/signup", "POST /api/auth/google/signin"]
|
||||
|
||||
# Google OAuth 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))
|
||||
|
||||
Google sign-in/up integration using **Google Identity Services** (`accounts.google.com/gsi/client`). The flow short-circuits email verification because Google accounts are pre-verified.
|
||||
|
||||
## Actors
|
||||
@@ -33,8 +35,8 @@ Google sign-in/up integration using **Google Identity Services** (`accounts.goog
|
||||
4. On success the popup returns an **ID token** (a Google-signed JWT containing `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`, `sub`).
|
||||
5. Frontend calls `signUpWithGoogle({ googleToken, role, referralCode })` (`frontend/src/auth/context/jwt/action.ts:281-304`), which POSTs `POST /api/auth/google/signup`.
|
||||
6. Backend `authController.googleSignUp` (`:781-872`) calls `googleOAuthService.verifyGoogleToken(googleToken)`. The verifier uses `google-auth-library` to validate the JWT signature, expiry, audience (`client_id`), and issuer.
|
||||
7. **Duplicate check**: `User.findOne({ email: googleUser.email })` — if found returns `409 USER_EXISTS` so the user can use *sign-in* instead.
|
||||
8. **New user creation** with `password` omitted, `isEmailVerified: true`, `status: "active"`, `profile.avatar = googleUser.picture`, role from the request.
|
||||
7. **Duplicate check**: `User.findOne({ email: googleUser.email })` — if the email already exists, returns **`409 USER_EXISTS`** so the user can use *sign-in* instead.
|
||||
8. **New user creation** with `password` omitted, `isEmailVerified: true`, `status: "active"`, `profile.avatar = googleUser.picture`, and the chosen `role` from the request.
|
||||
9. **Referral attribution** (`authController.ts:817-838`): same logic as the email path — increment `referrer.referralStats.totalReferrals`, emit `referral-signup` on `user-${referrer._id}`.
|
||||
10. Generate access + refresh tokens, push refresh into `user.refreshTokens[]`, respond with `{ user, tokens }`.
|
||||
11. Frontend stores tokens in `localStorage` and redirects to the dashboard.
|
||||
@@ -44,12 +46,15 @@ Google sign-in/up integration using **Google Identity Services** (`accounts.goog
|
||||
1. User clicks the Google icon on `/auth/jwt/sign-in`.
|
||||
2. Same GSI flow as sign-up — Google returns an ID token.
|
||||
3. Frontend calls `signInWithGoogle(googleToken)` → `POST /api/auth/google/signin`.
|
||||
4. Backend verifies the token, looks up `User.findOne({ email: googleUser.email })`. If no user, returns `404 USER_NOT_FOUND` ("please sign up first"). The frontend surfaces a localized prompt.
|
||||
4. Backend verifies the token, then looks up `User.findOne({ email: googleUser.email, status: "active" })` (`authController.ts:1194`). Note the **`status: "active"` filter**: the query only matches active accounts. If no active user matches, returns **`404 USER_NOT_FOUND`** ("please sign up first"). The frontend surfaces a localized prompt.
|
||||
5. On hit: `existingUser.lastLoginAt = now`; if `profile.avatar` is empty and Google has a picture, it is back-filled (`authController.ts:905-907`).
|
||||
6. Tokens issued and returned identically to email login.
|
||||
|
||||
> [!tip] Account linking is implicit by email
|
||||
> A user who originally signed up via email + password can sign in with Google as long as the email matches — no extra "link account" step. The backend simply reuses the existing user document. There is **no** separate `googleId` field stored today, so this is a one-way trust on `googleUser.email`.
|
||||
> [!warning] No account merge
|
||||
> There is **no** account-merge step between a Telegram-only / email account and a Google account. The Google sign-in path simply looks up an **active** user by email and reuses that document if one exists; it does not reconcile, link, or merge distinct identities. There is **no** separate `googleId` field stored today, so matching is a one-way trust on `googleUser.email`.
|
||||
|
||||
> [!warning] Soft-deleted accounts get a generic 404 on Google sign-in
|
||||
> Because the sign-in lookup filters by `status: "active"`, a user who registered via Google and was later **soft-deleted** (`status: "deleted"`) is invisible to the query. They receive the **same generic `404 USER_NOT_FOUND`** as a never-registered user — there is **no** distinct "account deleted" / "account disabled" error.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
@@ -76,15 +81,19 @@ sequenceDiagram
|
||||
end
|
||||
BE->>GA: verifyGoogleToken(googleToken)
|
||||
GA-->>BE: { email, name, picture, ... } or null
|
||||
BE->>DB: User.findOne({ email })
|
||||
alt Sign-up: user exists
|
||||
alt Sign-up
|
||||
BE->>DB: User.findOne({ email })
|
||||
else Sign-in
|
||||
BE->>DB: User.findOne({ email, status: "active" })
|
||||
end
|
||||
alt Sign-up: email exists
|
||||
BE-->>FE: 409 USER_EXISTS
|
||||
else Sign-up: new
|
||||
BE->>DB: User.create({ email, role, isEmailVerified:true, profile.avatar })
|
||||
opt referral
|
||||
BE->>DB: increment referrer.referralStats
|
||||
end
|
||||
else Sign-in: user missing
|
||||
else Sign-in: no active user (missing or soft-deleted)
|
||||
BE-->>FE: 404 USER_NOT_FOUND
|
||||
else Sign-in: ok
|
||||
BE->>DB: set user.lastLoginAt = now
|
||||
@@ -120,8 +129,9 @@ sequenceDiagram
|
||||
## Error / edge cases
|
||||
|
||||
- **Invalid Google token** (bad signature, wrong audience, expired) → `googleOAuthService` returns `null` → `401 INVALID_GOOGLE_TOKEN`.
|
||||
- **User already exists during sign-up** → `409`; frontend prompts to use sign-in instead.
|
||||
- **User missing during sign-in** → `404`; frontend redirects to sign-up.
|
||||
- **Email already exists during sign-up** → `409 USER_EXISTS`; frontend prompts to use sign-in instead.
|
||||
- **User does not exist during sign-in** → `404 USER_NOT_FOUND`; frontend redirects to sign-up.
|
||||
- **Soft-deleted user signs in via Google** → `404 USER_NOT_FOUND` (generic, indistinguishable from "never registered") because the lookup filters by `status: "active"`.
|
||||
- **Popup blocker** → GSI throws a client-side error caught in the view and surfaced as a toast.
|
||||
- **Network failure to `accounts.google.com`** → GSI rejects; frontend retries on next click.
|
||||
- **`email_verified === false` on the Google token** → currently not enforced; the backend trusts any successful Google response. For an extra-strict mode, gate on `googleUser.email_verified === true` in `googleOAuthService`.
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
title: Negotiation Flow
|
||||
tags: [flow, marketplace, negotiation, counter-offer, chat]
|
||||
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Chat]]"]
|
||||
related_apis: ["PATCH /api/marketplace/offers/:id", "POST /api/chat", "POST /api/chat/:chatId/messages"]
|
||||
related_apis: ["PUT /api/marketplace/offers/:id", "POST /api/chat", "POST /api/chat/:chatId/messages"]
|
||||
---
|
||||
|
||||
# Negotiation 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))
|
||||
|
||||
After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can negotiate the price/ETA via **counter-offers** exchanged through the chat. The request status moves to `in_negotiation`, and either party can finalise with accept/reject.
|
||||
|
||||
## Actors
|
||||
@@ -16,7 +18,7 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
|
||||
- **Frontend** — chat component (`frontend/src/sections/chat/`) overlaid on the request view; offer-edit modal under `frontend/src/sections/request/components/buyer-steps/step-3-components/`.
|
||||
- **Backend** — `ChatService.sendMessage` for chat lines, `SellerOfferService.updateOffer` for price/ETA edits, `PurchaseRequestService.updatePurchaseRequest` for the status flip.
|
||||
- **MongoDB** — `chats`, `selleroffers`, `purchaserequests`.
|
||||
- **Socket.IO** — `new-message`, `seller-offer-update`, `purchase-request-update`.
|
||||
- **Socket.IO** — `new-message`, `purchase-request-update`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
@@ -24,6 +26,9 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
|
||||
- The purchase request is `received_offers` or `in_negotiation`.
|
||||
- Both parties are still active users.
|
||||
|
||||
> [!info] Status vocabulary
|
||||
> The negotiation drives the **PurchaseRequest** into the `in_negotiation` status. The **SellerOffer** moves only between `pending`, `accepted`, `rejected`, and `withdrawn` (`backend/src/models/SellerOffer.ts:80`). There is **no `'active'` SellerOffer status** — any documentation or UI that references an "active" offer is incorrect.
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
1. **Open negotiation chat** — when a buyer first clicks "Chat with seller" on an offer card, the frontend calls `POST /api/chat` to find-or-create a `direct` chat tied to the purchase request (`ChatService.createChat`, `chat.ts:90-192`). The chat's `relatedTo = { type: 'PurchaseRequest', id }` makes it discoverable from the request view.
|
||||
@@ -35,20 +40,26 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
|
||||
|
||||
3. **Buyer proposes a counter** — the buyer types a message like "Can you do $80 instead of $100?". Two patterns are used:
|
||||
- **Free-form** — just a chat message; the seller eyeballs the offer-edit screen and updates the price.
|
||||
- **Structured counter** — the buyer opens an "edit offer" modal that PATCHes `/api/marketplace/offers/{id}` with the new desired terms. This is currently a seller-only edit endpoint; structured buyer counters typically come as a system message in the chat (`messageType: 'system'`) referencing the new price.
|
||||
- **Structured counter** — the buyer opens an "edit offer" modal that (via the frontend `updateOffer` action) sends `PUT /api/marketplace/offers/{id}` with the new desired terms. This is a seller-side edit endpoint; structured buyer counters typically come as a system message in the chat (`messageType: 'system'`) referencing the new price.
|
||||
|
||||
4. **Seller updates the offer** — `SellerOfferService.updateOffer` (`:271-295`):
|
||||
- `SellerOffer.findByIdAndUpdate(id, { ...updateData, updatedAt: now }, { new: true })`.
|
||||
- Emits `purchase-request-update` with `eventType: 'offer-updated'` to `request-{requestId}` (`SellerOfferService.ts:284-288`) — both parties' open tabs refresh.
|
||||
|
||||
> [!bug] ⚠️ KNOWN BUG — PUT/PATCH method mismatch on offer edit
|
||||
> The frontend `updateOffer` action (`frontend/src/actions/marketplace.ts:286-297`) sends **`PUT /marketplace/offers/:id`**, but the legacy backend router registers only **`PATCH /offers/:id`** (`backend/src/services/marketplace/routes.ts:1260`). No `PUT /offers/:id` handler is registered, so structured offer edits from the UI may **404**. Fix by aligning on a single method (register `PUT` on the backend, or switch the frontend to `PATCH`).
|
||||
|
||||
5. **Buyer accepts** -- clicks "Accept this offer", which kicks off [[PRD - Request Network In-House Checkout]] with the selected `sellerOfferId`. Payment confirmation flips offer -> `accepted` and request -> `payment`.
|
||||
|
||||
6. **Buyer rejects** — calls `PATCH /api/marketplace/offers/{id}` with `{ status: 'rejected' }`. `SellerOfferService.updateOfferStatus` (`:306-353`) sends `notifyOfferRejected` to the seller and stamps `rejectedAt` + `rejectionReason`.
|
||||
6. **Buyer rejects** — the frontend `rejectOffer` action calls `PUT /api/marketplace/offers/{id}/status` with `{ status: 'rejected' }`. `SellerOfferService.updateOfferStatus` (`:306-353`) sends `notifyOfferRejected` to the seller and stamps `rejectedAt` + `rejectionReason`.
|
||||
|
||||
7. **Seller withdraws** — `withdrawOffer` (`:428-443`) only works while `status === 'pending'`. After rejection/acceptance, withdrawal is impossible.
|
||||
7. **Seller withdraws** — there is **no dedicated `/withdraw` endpoint** (see warning below). The only way to withdraw is `PUT /api/marketplace/offers/{id}/status` with `{ status: 'withdrawn' }` (`routes.ts:1914`). `withdrawOffer` (`:428-443`) only works while `status === 'pending'`. After rejection/acceptance, withdrawal is impossible.
|
||||
|
||||
8. **Chat continues** — even after status flips, the chat remains open for clarifications (delivery details, dispute prep). See [[Chat Flow]] for message-level semantics.
|
||||
|
||||
> [!warning] ⚠️ NOT IMPLEMENTED — `POST /api/marketplace/offers/:id/withdraw`
|
||||
> No `POST .../offers/:id/withdraw` route is registered anywhere in the backend; calling it returns **404**. Withdrawal is performed exclusively through the status endpoint: `PUT /api/marketplace/offers/:id/status` with body `{ status: 'withdrawn' }`.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
@@ -75,8 +86,8 @@ sequenceDiagram
|
||||
BE->>DB: PurchaseRequest.status = "in_negotiation"
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' (status-changed)
|
||||
S->>FE_S: Open edit-offer modal, set new price
|
||||
FE_S->>BE: PATCH /api/marketplace/offers/{id} {price:{amount:80}}
|
||||
BE->>DB: SellerOffer update
|
||||
FE_S->>BE: PUT /api/marketplace/offers/{id} {price:{amount:80}} ⚠️ backend only registers PATCH
|
||||
BE->>DB: SellerOffer update (if PUT handled; else 404)
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
|
||||
IO-->>FE_B: refresh offer card
|
||||
alt Buyer accepts
|
||||
@@ -84,10 +95,15 @@ sequenceDiagram
|
||||
Note over BE: Webhook PAID flips offer→accepted, request→payment
|
||||
else Buyer rejects
|
||||
B->>FE_B: Click "Reject"
|
||||
FE_B->>BE: PATCH /api/marketplace/offers/{id} {status:"rejected"}
|
||||
FE_B->>BE: PUT /api/marketplace/offers/{id}/status {status:"rejected"}
|
||||
BE->>DB: offer.status = "rejected"
|
||||
BE->>BE: notifyOfferRejected(seller)
|
||||
IO-->>FE_S: 'new-notification'
|
||||
else Seller withdraws
|
||||
S->>FE_S: Click "Withdraw offer"
|
||||
FE_S->>BE: PUT /api/marketplace/offers/{id}/status {status:"withdrawn"}
|
||||
BE->>DB: offer.status = "withdrawn"
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
|
||||
end
|
||||
```
|
||||
|
||||
@@ -97,14 +113,16 @@ sequenceDiagram
|
||||
|---|---|---|
|
||||
| `POST` | `/api/chat` | Find-or-create negotiation chat |
|
||||
| `POST` | `/api/chat/:chatId/messages` | Send chat message |
|
||||
| `PATCH` | `/api/marketplace/offers/:id` | Seller updates price / ETA / notes (counter) |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/offers` | Create offer (scoped) — `routes.ts:1163` |
|
||||
| `GET` | `/api/marketplace/purchase-requests/:id/offers` | List offers for a request (scoped) — `routes.ts:1223` |
|
||||
| `PUT` | `/api/marketplace/offers/:id` | Seller updates price / ETA / notes (counter). ⚠️ KNOWN BUG: frontend sends `PUT`, backend registers only `PATCH /offers/:id` (`routes.ts:1260`) → may 404. |
|
||||
| `PUT` | `/api/marketplace/offers/:id/status` | Reject (`{ status: 'rejected' }`) and withdraw (`{ status: 'withdrawn' }`) — `routes.ts:1914`. There is no separate `/withdraw` endpoint. |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Status transition to `in_negotiation` |
|
||||
| `POST` | `/api/marketplace/offers/:id/withdraw` | Seller pulls the offer |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`chats`**: messages appended via `chat.addMessage`; `metadata.lastActivity` bumped; `unreadCounts` incremented for non-sender participants.
|
||||
- **`selleroffers`**: counter changes update `price`, `deliveryTime`, `notes`, `updatedAt`.
|
||||
- **`selleroffers`**: counter changes update `price`, `deliveryTime`, `notes`, `updatedAt`; status moves between `pending`/`accepted`/`rejected`/`withdrawn`.
|
||||
- **`purchaserequests`**: status flips when first counter arrives.
|
||||
- **`notifications`**: created per status change (accept/reject), not per chat message (chat has its own real-time channel).
|
||||
|
||||
@@ -122,9 +140,11 @@ sequenceDiagram
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Offer edit returns 404** — see the KNOWN BUG above (PUT vs PATCH method mismatch).
|
||||
- **Sender not a chat participant** → `403 "User is not a participant in this chat"` (`ChatService.sendMessage:209-211`).
|
||||
- **Counter on an offer the buyer doesn't own a request for** → blocked by the controller (the buyer must be the request's owner).
|
||||
- **Counter on an `accepted` offer** → `updateOffer` does not enforce status; the schema/controller should reject. Current code allows the price change, which is dangerous post-payment. Recommended hardening: guard with `if (offer.status !== 'pending') throw`.
|
||||
- **Withdraw after accept/reject** → `withdrawOffer` only acts while `status === 'pending'`, so withdrawal is rejected once the offer leaves that state.
|
||||
- **Status regression attempt** (`in_negotiation → received_offers`) → blocked by `isValidStatusProgression` (`PurchaseRequestService.ts:31-50`).
|
||||
- **Two simultaneous edits** — last-write-wins on `findByIdAndUpdate`; consider optimistic concurrency via `__v` if conflicts become an issue.
|
||||
- **Chat created in negotiation but buyer never pays** → orphan chat remains; the post-payment chat (in [[Chat Flow]]) reuses it because the find-or-create logic matches by participants + relatedTo.
|
||||
@@ -141,8 +161,11 @@ sequenceDiagram
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/marketplace/SellerOfferService.ts:271-353`
|
||||
- Backend: `backend/src/services/marketplace/SellerOfferService.ts:271-443`
|
||||
- Backend: `backend/src/services/marketplace/routes.ts:1163-1278,1914` (offer routes)
|
||||
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:408-495`
|
||||
- Backend: `backend/src/services/chat/ChatService.ts:90-260`
|
||||
- Backend: `backend/src/models/SellerOffer.ts:17,80` (status enum)
|
||||
- Frontend: `frontend/src/actions/marketplace.ts:286-308` (`updateOffer`, `rejectOffer`)
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/`
|
||||
- Frontend: `frontend/src/sections/chat/` (chat UI)
|
||||
|
||||
@@ -65,6 +65,10 @@ Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and
|
||||
- `Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now })`.
|
||||
- After updating, the backend emits `unread-count-update` to `user-{userId}` so all open tabs (and other devices) immediately sync their badge counter.
|
||||
|
||||
### Purchase request status coverage gap
|
||||
|
||||
`NotificationService.notifyRequestStatusChanged` handles many purchase-request statuses but does **not** emit notifications for `pending_payment` or `seller_paid`. If a buyer moves to `pending_payment` or a seller is marked `seller_paid`, no notification is created. This is a known coverage gap; add dedicated helper methods (or extend the switch-case) if those transitions need to surface to recipients.
|
||||
|
||||
### Preferences
|
||||
|
||||
- `User.preferences.notifications` (in the User schema) can hold per-category opt-outs (`emailNotifications`, `pushNotifications`, etc.). The current implementation does not enforce preferences at send-time — all enabled notifications fire. Add a check in `createNotification` to short-circuit when the user has opted out of a category.
|
||||
|
||||
@@ -5,6 +5,8 @@ related_models: ["[[User]]"]
|
||||
related_apis: ["POST /api/auth/request-password-reset", "POST /api/auth/reset-password-with-code", "POST /api/auth/reset-password"]
|
||||
---
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
|
||||
|
||||
> [!caution] Audit note — last reviewed 2026-05-29
|
||||
> Several discrepancies between frontend code, backend code, and this document were identified and corrected in this revision. See inline ⚠️ markers for known bugs and mismatches.
|
||||
|
||||
@@ -47,7 +49,7 @@ The primary UI-driven path uses the **code-based** endpoint. The token-based end
|
||||
6. User receives the email and enters the code + new password on `/auth/jwt/update-password`.
|
||||
7. Frontend POSTs `POST /api/auth/reset-password-with-code { email, code, password }`.
|
||||
8. Backend `authController.resetPasswordWithCode` (`:611-657`):
|
||||
- Validates code format `/^\d{6}$/` — a code of any other length (e.g., 8 digits) will **always fail** here.
|
||||
- Validates code format `/^\d{6}$/` — codes of any other length will **always fail** here.
|
||||
- `User.findOne({ email, passwordResetCode: code, passwordResetCodeExpires: { $gt: now }, status: "active" })`. Mismatch → `400 Invalid or expired reset code`.
|
||||
- Hashes the new password with bcrypt cost 12. **No password complexity validation is applied** — weak passwords such as `123456` or `aaaaaa` are accepted without error.
|
||||
- Sets `user.password = hashed`, clears `passwordResetCode` and `passwordResetCodeExpires`, **wipes `user.refreshTokens = []`** to invalidate all existing sessions.
|
||||
@@ -100,7 +102,7 @@ sequenceDiagram
|
||||
>
|
||||
> **`POST /api/auth/reset-password-with-code`** (primary UI path)
|
||||
> - Uses a 6-digit numeric code delivered by email.
|
||||
> - `isValidVerificationCode()` validates with `/^\d{6}$/`. An 8-digit code will always fail.
|
||||
> - `isValidVerificationCode()` validates with `/^\d{6}$/`.
|
||||
> - Has **no password complexity middleware**. Any string is accepted as the new password.
|
||||
>
|
||||
> **`POST /api/auth/reset-password`** (legacy token-based path)
|
||||
@@ -126,7 +128,7 @@ sequenceDiagram
|
||||
## Error / edge cases
|
||||
|
||||
- **Unknown email** → always `200`, generic message. No enumeration.
|
||||
- **Invalid code format** → `400` from `isValidVerificationCode` guard before DB lookup. Note: the `authController.ts` comment mentions "8 digits" but the actual implementation generates and validates exactly 6 digits — any 8-digit code will be rejected.
|
||||
- **Invalid code format** → `400` from `isValidVerificationCode` guard before DB lookup.
|
||||
- **Expired code** (>1h) → `400 Invalid or expired reset code`.
|
||||
- **Multiple parallel requests** → each overwrites the previous `passwordResetCode`; the latest email wins, prior codes silently invalidated.
|
||||
- **User attempts reset on deleted account** → treated as unknown (no email sent, `200` returned).
|
||||
@@ -136,15 +138,11 @@ sequenceDiagram
|
||||
> [!warning] Plaintext code in logs
|
||||
> Same as [[Registration Flow]]: the reset code is `console.log`-ed by the controller in all environments. Restrict log access in production or gate the log behind `NODE_ENV !== 'production'`.
|
||||
|
||||
> [!bug] Controller comment says "8 digits" but code generates 6
|
||||
> The comment in `authController.ts` describes an 8-digit code, but `authService.generateVerificationCode()` uses `Math.floor(100000 + Math.random() * 900000)`, which produces a number in the range 100000–999999 (exactly 6 digits). `isValidVerificationCode()` enforces `/^\d{6}$/`. Any 8-digit value sent to `reset-password-with-code` will always be rejected. The comment is wrong; the 6-digit implementation and validation are correct and consistent.
|
||||
|
||||
## Known issues summary
|
||||
|
||||
| Issue | Severity | Details |
|
||||
|---|---|---|
|
||||
| No password complexity on code-based reset | Security gap | `POST /api/auth/reset-password-with-code` has no complexity middleware; weak passwords accepted |
|
||||
| Controller comment says 8 digits | Doc bug | Comment is wrong; code generates and validates exactly 6 digits |
|
||||
| Inconsistent complexity between reset endpoints | Security gap | Token-based reset enforces complexity; code-based reset does not |
|
||||
|
||||
## Linked flows
|
||||
|
||||
@@ -5,6 +5,8 @@ related_models: ["[[Payment]]", "[[PurchaseRequest]]"]
|
||||
related_apis: ["POST /api/payment/decentralized/save", "POST /api/payment/decentralized/verify/:paymentId"]
|
||||
---
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
|
||||
|
||||
> [!caution] Audit — 2026-05-29
|
||||
> This document was reviewed against the live codebase. **12 corrections applied** — endpoint paths, missing route bugs, TypeScript type gaps, a security issue, and stats undercounting. See inline ⚠️ callouts throughout.
|
||||
|
||||
@@ -169,6 +171,28 @@ The following four Request Network payout/release/refund sub-paths are **not reg
|
||||
- **`payments`** — same model as the Request Network flow. `provider` distinguishes the source.
|
||||
- **`selleroffers`**, **`purchaserequests`**, **`chats`**, **`notifications`** — identical funded-escrow cascade (offer accepted, others rejected, request → `payment`, chat created, notifications fanned out).
|
||||
|
||||
### Payment status values
|
||||
|
||||
| `status` | `escrowState` | Meaning |
|
||||
|---|---|---|
|
||||
| `pending` | — | Intent created, awaiting on-chain transfer |
|
||||
| `completed` | `funded` | On-chain transfer verified (terminal success for DePay/wallet-direct) |
|
||||
| `failed` | — | Transaction reverted or verification failed |
|
||||
|
||||
### escrowState values (backend-authoritative)
|
||||
|
||||
| `escrowState` | Meaning |
|
||||
|---|---|
|
||||
| `funded` | Escrow received the on-chain transfer |
|
||||
| `releasable` | Escrow funds cleared for release to seller |
|
||||
| `releasing` | Release to seller in progress (intermediate state) |
|
||||
| `released` | Funds sent to seller |
|
||||
| `refunding` | Refund to buyer in progress |
|
||||
| `refunded` | Funds returned to buyer |
|
||||
|
||||
> [!note] `'completed'` is not counted as a successful payment in stats
|
||||
> `paymentService.getPaymentStats` counts only `status === 'confirmed'` as `successfulPayments`. DePay/wallet-direct payments terminate at `'completed'`, so they are **excluded** from the success count. The aggregate must include `'completed'` alongside `'confirmed'` to avoid undercounting.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`payment-created`** (admin dashboard) on intent creation.
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
title: Payment Flow - SHKeeper
|
||||
tags: [flow, payment, shkeeper, crypto, escrow, webhook]
|
||||
related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[SellerOffer]]"]
|
||||
related_apis: ["POST /api/payment/shkeeper/create", "POST /api/payment/shkeeper/webhook", "POST /api/payment/:id/release", "POST /api/payment/:id/refund"]
|
||||
related_apis: ["POST /api/payment/shkeeper/intents", "POST /api/payment/shkeeper/webhook", "POST /api/payment/:id/release", "POST /api/payment/:id/refund"]
|
||||
---
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
|
||||
|
||||
> [!caution] Audit — 2026-05-29
|
||||
> This document was reviewed against the live codebase. **2 corrections applied**: the non-existent HTTP polling endpoint has been removed (status updates arrive via socket only), and the release/refund/confirm paths have been corrected to remove the erroneous `/shkeeper/` segment.
|
||||
> This document was reviewed against the live codebase. **3 corrections applied**: (1) the non-existent HTTP polling endpoint has been removed (status updates arrive via socket only), (2) the release/refund/confirm paths have been corrected to remove the erroneous `/shkeeper/` segment, and (3) the intent-creation endpoint corrected from `/shkeeper/create` to `/shkeeper/intents` and parallel stats/export paths documented.
|
||||
|
||||
# Payment Flow — SHKeeper (Crypto Pay-In)
|
||||
|
||||
@@ -66,7 +68,7 @@ stateDiagram-v2
|
||||
### Phase 1 — Create intent
|
||||
|
||||
1. Buyer clicks "Pay" on the chosen offer (`/dashboard/buyer/requests/{id}` → step-3-select-and-pay).
|
||||
2. Frontend POSTs `POST /api/payment/shkeeper/create` with `{ purchaseRequestId, sellerOfferId, amount, token?, network? }`.
|
||||
2. Frontend POSTs `POST /api/payment/shkeeper/intents` with `{ purchaseRequestId, sellerOfferId, amount, token?, network? }`.
|
||||
3. Backend `createPayInIntent`:
|
||||
- Validates ObjectIds (`shkeeperService.ts:55-71`). Special path for **template checkout** (string IDs starting with `template-checkout-`).
|
||||
- **Duplicate-guard #1** (`:75-116`): if there is already an active `Payment` (`status ∈ {pending, processing}`) for the same `{purchaseRequestId, sellerOfferId, buyerId}`, the existing record is reused — same `paymentUrl`, no new wallet allocation.
|
||||
@@ -127,7 +129,7 @@ stateDiagram-v2
|
||||
|
||||
21. The buyer's checkout page subscribes to socket events (`payment-update`, `template-checkout-payment-confirmed`). When the status flips to `completed`, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery).
|
||||
|
||||
> [!warning] ⚠️ No HTTP polling endpoint — socket events only
|
||||
> [!warning] No HTTP polling endpoint — socket events only
|
||||
> `GET /api/payment/shkeeper/status/:paymentId` **does not exist** — there is no polling route in `shkeeperRoutes.ts`. Status transitions must be observed via Socket.IO events (`payment-update`, `template-checkout-payment-confirmed`). Any frontend code path that polls this URL will always receive 404. Remove HTTP polling and rely solely on the socket subscription.
|
||||
|
||||
22. The seller's dashboard receives `seller-offer-update` `payment-completed` and surfaces the green "Order paid — start preparing" banner.
|
||||
@@ -148,7 +150,7 @@ sequenceDiagram
|
||||
actor S as Seller
|
||||
|
||||
B->>FE: Choose offer, click "Pay"
|
||||
FE->>BE: POST /api/payment/shkeeper/create
|
||||
FE->>BE: POST /api/payment/shkeeper/intents
|
||||
BE->>DB: dedupe / upsert Payment(status:"pending")
|
||||
BE->>R: getCachedWallet(amount, token, network, requestId)
|
||||
alt cache hit
|
||||
@@ -183,18 +185,26 @@ sequenceDiagram
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose | Source |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/payment/shkeeper/create` | Create pay-in intent | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
|
||||
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | `shkeeperWebhook.handleShkeeperWebhook` |
|
||||
| `POST` | `/api/payment/:id/release` | Release escrow to seller | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/:id/release/confirm` | Confirm escrow release | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/:id/refund` | Refund to buyer | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/:id/refund/confirm` | Confirm buyer refund | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/payments/:id/fetch-tx` | Manual transaction lookup | `paymentRoutes.ts` |
|
||||
| ~~`GET /api/payment/shkeeper/status/:paymentId`~~ | | ⚠️ **404 — does not exist.** Use socket events instead. | — |
|
||||
| Method | Endpoint | Purpose | Auth | Source |
|
||||
|---|---|---|---|---|
|
||||
| `POST` | `/api/payment/shkeeper/intents` | Create pay-in intent | Bearer JWT (buyer) | `shkeeperRoutes.ts` → `shkeeperService.createPayInIntent` |
|
||||
| `POST` | `/api/payment/shkeeper/webhook` | Receive SHKeeper webhook | HMAC / API key | `shkeeperWebhook.handleShkeeperWebhook` |
|
||||
| `POST` | `/api/payment/:id/release` | Release escrow to seller | Bearer JWT | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/:id/release/confirm` | Confirm escrow release | Bearer JWT | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/:id/refund` | Refund to buyer | Bearer JWT | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/:id/refund/confirm` | Confirm buyer refund | Bearer JWT | `paymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/payments/:id/fetch-tx` | Manual transaction lookup | Bearer JWT | `paymentRoutes.ts` |
|
||||
| `GET` | `/api/payment/payments/stats` | Payment statistics (admin-gated strict) | Bearer JWT + admin role | `paymentRoutes.ts` |
|
||||
| `GET` | `/api/payment/stats` | Payment statistics (no admin guard) | Bearer JWT | `paymentRoutes.ts` |
|
||||
| ~~`GET /api/payment/shkeeper/status/:paymentId`~~ | | **404 — does not exist.** Use socket events instead. | — | — |
|
||||
|
||||
> [!warning] ⚠️ Release/refund path correction
|
||||
> [!note] Two parallel stats paths
|
||||
> Two separate stats endpoints exist with different auth levels:
|
||||
> - `GET /api/payment/payments/stats` — admin-gated (strict role check); intended for admin dashboard.
|
||||
> - `GET /api/payment/stats` — authenticated but no admin guard; accessible to any logged-in user.
|
||||
> Similarly, export endpoints exist at two paths with different auth levels. Confirm which is appropriate for each consumer before wiring the frontend.
|
||||
|
||||
> [!warning] Release/refund path correction
|
||||
> Previously documented paths included a `/shkeeper/` segment that does **not** exist in the router:
|
||||
> - ~~`POST /api/payment/shkeeper/:id/release`~~ → correct: `POST /api/payment/:id/release`
|
||||
> - ~~`POST /api/payment/shkeeper/:id/release/confirm`~~ → correct: `POST /api/payment/:id/release/confirm`
|
||||
|
||||
@@ -7,6 +7,8 @@ related_apis: ["POST /api/payment/:id/release", "POST /api/payment/:id/release/c
|
||||
|
||||
# Payout 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))
|
||||
|
||||
This page describes how escrowed funds leave Amanat custody after an order is complete or a dispute is resolved.
|
||||
|
||||
The current flow is no longer SHKeeper payout-task centric. Release and refund are instruction-based:
|
||||
@@ -34,7 +36,7 @@ Today the custody signer can be an admin/Trezor path when enabled. The roadmap t
|
||||
- The release/refund amount is positive and does not exceed available ledger balance.
|
||||
- No active dispute hold blocks the operation, unless the operation is the explicit dispute resolution path.
|
||||
- Recipient wallet is known and verified.
|
||||
- If `TREZOR_SAFEKEEPING_REQUIRED=true`, the confirm step includes the expected Trezor operation signature.
|
||||
- If `TREZOR_SAFEKEEPING_REQUIRED=true`, the confirm step **must** include the expected Trezor operation signature (see gate below).
|
||||
- Production target: Safe multisig execution is required for custody movement.
|
||||
|
||||
## Release Narrative
|
||||
@@ -43,7 +45,7 @@ Today the custody signer can be an admin/Trezor path when enabled. The roadmap t
|
||||
2. Admin calls `POST /api/payment/:id/release` with optional partial amount.
|
||||
3. Backend loads the `Payment`, validates ledger availability when `PAYMENT_LEDGER_ENFORCEMENT=true`, and returns an instruction payload.
|
||||
4. Custody signer broadcasts the seller payment transaction.
|
||||
5. Admin calls `POST /api/payment/:id/release/confirm` with `txHash` and optional Trezor proof.
|
||||
5. Admin calls `POST /api/payment/:id/release/confirm` with `txHash` and (when safekeeping is enabled) a Trezor signature proof.
|
||||
6. Backend verifies signer proof when required, confirms adapter state, appends a `release` ledger entry, and marks escrow released.
|
||||
|
||||
## Refund Narrative
|
||||
@@ -52,7 +54,7 @@ Today the custody signer can be an admin/Trezor path when enabled. The roadmap t
|
||||
2. Admin calls `POST /api/payment/:id/refund`.
|
||||
3. Backend validates available funds and policy.
|
||||
4. Custody signer broadcasts the refund transaction.
|
||||
5. Admin calls `POST /api/payment/:id/refund/confirm` with `txHash` and optional Trezor proof.
|
||||
5. Admin calls `POST /api/payment/:id/refund/confirm` with `txHash` and (when safekeeping is enabled) a Trezor signature proof.
|
||||
6. Backend appends a `refund` ledger entry and marks escrow refunded.
|
||||
|
||||
## Sequence Diagram
|
||||
@@ -74,15 +76,19 @@ sequenceDiagram
|
||||
A->>C: Request Trezor/Safe execution
|
||||
C->>BC: Broadcast transfer
|
||||
BC-->>C: txHash
|
||||
A->>BE: POST /confirm { txHash, signer proof }
|
||||
A->>BE: POST /confirm { txHash, trezor proof if safekeeping }
|
||||
BE->>BE: Verify proof if required
|
||||
BE->>DB: append release/refund ledger entry
|
||||
BE->>DB: update Payment escrowState
|
||||
BE-->>R: notification
|
||||
BE-->>R: notification (no realtime socket listener — see gap below)
|
||||
```
|
||||
|
||||
## API Calls
|
||||
|
||||
### Release / Refund (custody) — correct paths
|
||||
|
||||
These are mounted on `paymentControllerRouter` at `/api/payment` (`backend/src/services/payment/paymentControllerRoutes.ts:23-26`). Note: **no `/shkeeper/` segment**.
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/payment/:id/release` | Build release instruction |
|
||||
@@ -92,6 +98,44 @@ sequenceDiagram
|
||||
| `GET` | `/api/admin/payments/awaiting-confirmation` | Admin view of payments blocked on confirmation depth |
|
||||
| `GET` | `/api/payment/derived-destinations` | Admin view of derived destination sweep state |
|
||||
|
||||
### Request Network — actually implemented routes
|
||||
|
||||
Mounted at `/api/payment/request-network` (`app.ts:428` → `requestNetwork/requestNetworkRoutes.ts`). Only these exist:
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/payment/request-network/pay-in` | Create a pay-in intent (authenticated) — `requestNetworkRoutes.ts:111` |
|
||||
| `POST` | `/api/payment/request-network/intents` | Create checkout intent — `requestNetworkRoutes.ts:289` |
|
||||
| `GET` | `/api/payment/request-network/:paymentId/checkout` | In-house checkout block fetcher — `requestNetworkRoutes.ts:152` |
|
||||
| `POST` | `/api/payment/request-network/webhook` | Provider webhook (raw body) — `requestNetworkRoutes.ts:330` |
|
||||
|
||||
> [!warning] ⚠️ NOT IMPLEMENTED — Request Network payout/release/refund sub-routes
|
||||
> The following routes are **not registered anywhere** and return **404**:
|
||||
> - `POST /api/payment/request-network/:id/payout/initiate`
|
||||
> - `POST /api/payment/request-network/:id/payout/confirm`
|
||||
> - `POST /api/payment/request-network/:id/release/confirm`
|
||||
> - `POST /api/payment/request-network/:id/refund/confirm`
|
||||
>
|
||||
> Release and refund are handled exclusively by the custody routes under `/api/payment/:id/...` listed above — **not** under the `request-network` namespace.
|
||||
|
||||
## Custody-signer / Trezor safekeeping gate
|
||||
|
||||
> [!warning] Safekeeping gate blocks the legacy non-custodial helpers
|
||||
> When `TREZOR_SAFEKEEPING_REQUIRED=true` (`backend/src/services/trezor/trezorService.ts:214`), the release/refund `confirm` endpoints require a Trezor operation signature in the request body.
|
||||
>
|
||||
> - The **active admin UI** path uses `TrezorSignDialog` (`frontend/src/components/trezor-sign-dialog/TrezorSignDialog.tsx`), wired into the awaiting-confirmation list view. It builds the signed payload via `getTrezorOperationMessage` + `trezorSignMessage` and posts `{ txHash, amount, trezor: { message, signature } }` through `confirmRelease` / `confirmRefund` (`frontend/src/actions/trezor.ts:108,133`). This path satisfies the gate.
|
||||
> - The **legacy helpers** `confirmReleaseTx` / `confirmRefundTx` (`frontend/src/actions/payment.ts:487,503`) post only `{ txHash, ...extra }` — by default **no Trezor proof**. They have **no UI callers** today, but if used with safekeeping enabled the backend will **reject** the payout. Prefer the `TrezorSignDialog` flow; remove or retrofit the legacy helpers to attach the signature.
|
||||
|
||||
## Derived-destinations sweep
|
||||
|
||||
HD-wallet derived-destination sweep infrastructure exists but is **admin-tooling only**:
|
||||
|
||||
- Routes: `GET /api/payment/derived-destinations` (`app.ts:546` → `wallets/derivedDestinationRoutes`).
|
||||
- Cron: `startSweepCron()` auto-starts only when `DERIVED_DESTINATION_SWEEP_AUTOSTART=true` (`app.ts:578-582`, `wallets/sweepService.ts`).
|
||||
- Model: `DerivedDestination` with statuses `active`/`swept`/`sweeping`/`quarantined` (`models/DerivedDestination.ts:35`).
|
||||
|
||||
This is not part of the buyer/seller payout UX; it consolidates funds from per-payment derived addresses.
|
||||
|
||||
## Database Writes
|
||||
|
||||
- **`payments`** -- status, `escrowState`, `blockchain.transactionHash`, signer metadata.
|
||||
@@ -99,14 +143,24 @@ sequenceDiagram
|
||||
- **`purchaserequests`** -- terminal business state after release/refund completes.
|
||||
- **`notifications`** -- release/refund receipt to the relevant party.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
> [!warning] Real-time payout/payment events have NO frontend listeners
|
||||
> Two seller-facing socket events are emitted by the backend but **no frontend code subscribes to them**, so sellers receive no real-time notification:
|
||||
> - **`payout-completed`** → `user-{sellerId}`, emitted after admin wallet payout (`backend/src/services/payment/decentralizedPaymentService.ts:911`). No frontend listener.
|
||||
> - **`payment-received`** → `user-{sellerId}`, emitted on Web3 verify (`backend/src/services/payment/paymentRoutes.ts:622`) and from `marketplace/routes.ts:2611`. No frontend listener.
|
||||
>
|
||||
> Until the frontend socket layer registers handlers for these, sellers must refresh / poll to see payout and incoming-payment state. Persisted DB notifications still surface through the standard notification channel.
|
||||
|
||||
## Error / Edge Cases
|
||||
|
||||
- **Insufficient ledger balance** -- reject instruction build/confirm.
|
||||
- **Active dispute hold** -- reject release/refund unless the operation is the explicit dispute outcome.
|
||||
- **Missing signer proof** -- reject when `TREZOR_SAFEKEEPING_REQUIRED=true`.
|
||||
- **Missing signer proof** -- reject confirm when `TREZOR_SAFEKEEPING_REQUIRED=true` (legacy `confirmReleaseTx`/`confirmRefundTx` helpers omit it — see gate above).
|
||||
- **Custody tx sent but not confirmed in app** -- reconcile by tx hash and append the missing ledger entry once verified.
|
||||
- **Partial split** -- build separate release and refund instructions whose sum does not exceed available balance.
|
||||
- **Payout reverted** -- leave escrow in failed/retryable state and do not append the terminal ledger entry.
|
||||
- **Wrong namespace** -- calling release/refund under `/api/payment/request-network/:id/...` returns 404 (those routes do not exist).
|
||||
|
||||
## Legacy SHKeeper Note
|
||||
|
||||
@@ -122,9 +176,15 @@ Older versions used SHKeeper payout tasks and scripts such as `fix-transaction-h
|
||||
|
||||
## Source Files
|
||||
|
||||
- Backend: `backend/src/services/payment/paymentControllerRoutes.ts:23-26` (release/refund routes)
|
||||
- Backend: `backend/src/services/payment/requestNetwork/requestNetworkRoutes.ts:111,152,289,330` (implemented RN routes)
|
||||
- Backend: `backend/src/services/payment/orchestration/releaseRefundService.ts`
|
||||
- Backend: `backend/src/services/payment/ledger/fundsLedgerService.ts`
|
||||
- Backend: `backend/src/services/payment/adapters/requestNetworkAdapter.ts`
|
||||
- Backend: `backend/src/services/trezor/trezorService.ts`
|
||||
- Backend: `backend/src/services/trezor/trezorService.ts:214` (safekeeping gate)
|
||||
- Backend: `backend/src/services/dispute/releaseHoldService.ts`
|
||||
- Frontend: admin payment/release/refund surfaces under `frontend/src/sections/`
|
||||
- Backend: `backend/src/services/payment/decentralizedPaymentService.ts:911` (`payout-completed` emit)
|
||||
- Backend: `backend/src/services/payment/paymentRoutes.ts:622` (`payment-received` emit)
|
||||
- Backend: `backend/src/services/payment/wallets/sweepService.ts`, `models/DerivedDestination.ts` (sweep infra)
|
||||
- Frontend: `frontend/src/components/trezor-sign-dialog/TrezorSignDialog.tsx`, `frontend/src/actions/trezor.ts:108,133` (active Trezor confirm path)
|
||||
- Frontend: `frontend/src/actions/payment.ts:487,503` (legacy `confirmReleaseTx`/`confirmRefundTx`, no Trezor proof)
|
||||
|
||||
@@ -5,6 +5,8 @@ related_models: ["[[PurchaseRequest]]", "[[Category]]", "[[Address]]", "[[Seller
|
||||
related_apis: ["POST /api/marketplace/purchase-requests", "GET /api/marketplace/purchase-requests", "PATCH /api/marketplace/purchase-requests/:id"]
|
||||
---
|
||||
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
|
||||
|
||||
> [!warning] Audit — 2026-05-29
|
||||
> This document was corrected against the live codebase. Key changes: status enum updated (added `pending_payment`, `active`; removed undocumented `finalized`/`archived`); urgency values expanded to include `urgent`; sellers endpoint corrected; attachment upload endpoint corrected; `request-cancelled` socket event removed (non-existent); `new-purchase-request` fan-out target corrected to shared `sellers` room; socket room join/leave events documented; description minimum corrected to 5 chars; PUT vs PATCH mismatch flagged as known bug; two frontend actions hitting non-existent backend endpoints flagged as not implemented.
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ related_apis: ["POST /api/marketplace/reviews", "GET /api/marketplace/reviews/:s
|
||||
|
||||
# Rating 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))
|
||||
|
||||
> [!caution] Not deeply audited
|
||||
> This flow was not deeply covered by the 2026-05-29 audit; endpoints should be verified against `reviewRoutes`/`marketplaceController` before relying on them for UAT.
|
||||
|
||||
After an order is `completed`, the buyer rates the seller and (optionally) leaves a review; the seller can rate the buyer in the reciprocal flow. Reviews are scoped by `subjectType` (`seller` | `template`) and constrained by the seller's `ShopSettings`.
|
||||
|
||||
## Actors
|
||||
|
||||
@@ -7,15 +7,17 @@ related_apis: ["POST /api/points/generate-referral-code", "GET /api/points/refer
|
||||
|
||||
# Referral 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))
|
||||
|
||||
Each user can generate a personal referral code, share a short URL, and earn points/commission when their referees sign up and complete purchases. Codes can also be entered at sign-up time (Phase 1 attribution) — see [[Registration Flow]].
|
||||
|
||||
## Actors
|
||||
|
||||
- **Referrer** — the user with the code.
|
||||
- **Referred user** — the new sign-up.
|
||||
- **Backend** — `PointsService` (`backend/src/services/points/PointsService.ts`), points routes at `backend/src/routes/pointsRoutes.ts`.
|
||||
- **Backend** — `PointsService` (`backend/src/services/points/PointsService.ts`), `authController` (`backend/src/services/auth/authController.ts`), points routes at `backend/src/routes/pointsRoutes.ts`.
|
||||
- **MongoDB** — `users` (`referralCode`, `referredBy`, `referralStats`, `points`), `pointtransactions`, `levelconfigs`.
|
||||
- **Socket.IO** — `referral-signup` and `level-up` events.
|
||||
- **Socket.IO** — `referral-signup` (auth domain) and `referral-reward` / `level-up` (points domain) events.
|
||||
|
||||
## Preconditions
|
||||
|
||||
@@ -26,17 +28,19 @@ Each user can generate a personal referral code, share a short URL, and earn poi
|
||||
|
||||
### 1. Code generation
|
||||
|
||||
1. User opens `/dashboard/account/referrals`. If they don't have a code yet, they click "Generate code".
|
||||
2. Frontend POSTs `POST /api/points/generate-referral-code`.
|
||||
1. User opens the points dashboard. If they don't have a code yet, they receive one automatically (`getUserPoints` lazily generates one — `PointsService.ts:216-219`).
|
||||
2. A manual `POST /api/points/generate-referral-code` is also available.
|
||||
3. `PointsService.generateReferralCode(userId)` (`:12-31`):
|
||||
- Loops generating an 8-character code from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` until uniqueness is confirmed by `User.findOne({ referralCode })`.
|
||||
- Saves the code to the user.
|
||||
- **ALWAYS overwrites** the user's existing code via `User.findByIdAndUpdate(userId, { referralCode: code })` (`:29`). There is **no idempotency / no `force` flag** — any param in the request body is ignored. Calling this endpoint rotates (replaces) the code every time, invalidating previously shared links.
|
||||
- Returns it.
|
||||
4. Frontend renders the share URL `https://amn.gg/r/{code}` and a copy button.
|
||||
4. Frontend renders the share URL `${NEXT_PUBLIC_API_URL}/r/${referralCode}` (pointing to the **backend** API URL, not a frontend URL) and a copy button. This is constructed in `frontend/src/sections/points/points-invite-friends.tsx:35-36`.
|
||||
> [!warning] Share link points at the wrong base
|
||||
> The link is built from `NEXT_PUBLIC_API_URL` (the backend) rather than the frontend origin. The `/r/:code` redirect on the backend then bounces the user to the frontend sign-up — so it functions, but the surfaced URL is the API host, which is not the intended public-facing brand URL.
|
||||
|
||||
### 2. Short-URL redirect
|
||||
|
||||
5. When a friend clicks the short URL, `GET /r/:code` (`backend/src/app.ts:274-278`) redirects to `${FRONTEND_URL}/auth/jwt/sign-up?ref={code}`.
|
||||
5. When a friend clicks the share URL, `GET /r/:code` (`backend/src/app.ts:274-278`) redirects to `${FRONTEND_URL}/auth/jwt/sign-up?ref={code}`.
|
||||
6. The sign-up form reads `?ref=` and pre-fills the referral field (hidden or visible).
|
||||
|
||||
### 3. Attribution at sign-up
|
||||
@@ -44,26 +48,38 @@ Each user can generate a personal referral code, share a short URL, and earn poi
|
||||
7. During [[Registration Flow]] verification (or [[Google OAuth Flow]] sign-up), the controller looks up `User.findOne({ referralCode })`:
|
||||
- Sets `user.referredBy = referrer._id` on the new user.
|
||||
- Increments `referrer.referralStats.totalReferrals`.
|
||||
- Emits **`referral-signup`** to `user-{referrerId}` with the referee's name, email, and updated total.
|
||||
- Emits **`referral-signup`** to `user-{referrerId}` with the referee's name, email, and updated total — emitted from `authController.ts`, not from PointsService.
|
||||
8. At this point the referee is **counted** but no points have changed hands yet. Points award on subsequent business events.
|
||||
|
||||
> [!danger] No self-referral guard
|
||||
> There is **no check** preventing a user from using their own referral code. A user who enters their own code at sign-up (or any flow that sets `referredBy`) is not blocked at the controller or service level. This is a known gap — add a guard such as `if (referrer._id.equals(user._id)) return` in the email and Google sign-up paths.
|
||||
|
||||
### 4. Points awarding
|
||||
|
||||
9. `PointsService.addPoints(userId, amount, source, metadata)` (`:36-100`) is called by other services on triggering events:
|
||||
- **Purchase completion** (intended): when a referred user finishes an order, the referrer should get a commission. The hook point is `PurchaseRequestService` `notifyTransactionCompleted` — the exact wiring is implementation-specific; the service exposes `source: 'purchase' | 'referral' | 'bonus' | 'admin'`.
|
||||
- **Bonus**: ad-hoc admin grants.
|
||||
10. Inside `addPoints`:
|
||||
9. The **only** caller that awards referral points is `marketplaceController.ts`, which invokes `PointsService.processReferralReward(id)` **only when an order transitions to `'completed'`** (`marketplaceController.ts:473-475`, inside `if (newStatus === 'completed')`). It is **NOT** triggered on `'delivered'`, `'delivery'`, `'seller_paid'`, or any other status.
|
||||
10. `PointsService.processReferralReward(purchaseRequestId)` (`:372-429`):
|
||||
- Loads the purchase request, finds the buyer and the buyer's `referredBy` referrer (returns `null` if either is missing).
|
||||
- Computes `referralPoints = Math.floor(amount * 0.02)` — a flat **2% commission** on the selected offer's price.
|
||||
- Calls `PointsService.addPoints(referrerId, referralPoints, 'referral', {...})`.
|
||||
- Recomputes `referrer.referralStats.activeReferrals` as a count of **ALL** users with `referredBy = referrer._id` (`:409-411`) — this includes referrals that never purchased; it is **not** scoped to converted referrals.
|
||||
- Increments `referrer.referralStats.totalEarned`.
|
||||
- Emits **`referral-reward`** to `user-{referrerId}` (`:417`).
|
||||
11. Inside `addPoints` (`:36-113`):
|
||||
- Transaction-scoped Mongo session.
|
||||
- `user.points.total += amount; user.points.available += amount`.
|
||||
- `PointTransaction.create({ type:'earn', source, amount, balance, metadata })`.
|
||||
- `PointTransaction.create({ type:'earn', source, amount, balance, metadata })`. For `source === 'referral'`, `metadata.commission` is set to the amount.
|
||||
- `updateUserLevel(userId, session)` recomputes the user's tier from `LevelConfig`.
|
||||
- Emits **`level-up`** on `user-{userId}` if the level changed (`:91-99`).
|
||||
11. Both the referrer and the referee may earn points (e.g. "give 100, get 100" growth model). The current code awards per `addPoints` call — design decision lives in the caller, not in PointsService.
|
||||
12. Note: only the **referrer** earns points via this path. There is no "referee also earns" reward in the current code — the referee gets nothing automatically.
|
||||
|
||||
### 5. Redemption / payout
|
||||
### 5. Redemption
|
||||
|
||||
12. Users see their balance under `/dashboard/account/points` and can spend via `POST /api/points/redeem` (e.g. for service-credit or discount codes).
|
||||
13. `PointTransaction` records `type: 'spend'` with negative `amount`, keeping `balance` running.
|
||||
13. Users see their balance under `/dashboard/points` and can spend via `POST /api/points/redeem` (applied as a discount against a specific purchase request).
|
||||
14. `redeemPoints(userId, pointsToUse, purchaseRequestId)` (`:118-167`):
|
||||
- Requires both `purchaseRequestId` and `pointsToUse` (controller returns `400` if either is missing or `pointsToUse <= 0`).
|
||||
- Throws `Insufficient points` if `user.points.available < pointsToUse`.
|
||||
- Decrements `available`, increments `used`, and records a `PointTransaction` with `type: 'spend'`, `source: 'redemption'`.
|
||||
- The controller computes `discount = pointsToUse * 1000` (1 point = 1000 IRR, **always**) and returns `{ transaction, discount, remainingPoints }`. There are **no** `amount` / `purpose` / `newBalance` / `redemption` fields in the response.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
@@ -77,11 +93,11 @@ sequenceDiagram
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
R->>FE: Generate referral code
|
||||
R->>FE: Generate referral code (or auto-assigned)
|
||||
FE->>BE: POST /api/points/generate-referral-code
|
||||
BE->>DB: User.findByIdAndUpdate(referralCode=...)
|
||||
BE-->>FE: { code }
|
||||
R->>R: share https://amn.gg/r/{code}
|
||||
BE->>DB: User.findByIdAndUpdate(referralCode=...) (ALWAYS overwrites)
|
||||
BE-->>FE: { referralCode }
|
||||
R->>R: share ${NEXT_PUBLIC_API_URL}/r/{code} (backend URL)
|
||||
|
||||
N->>BE: GET /r/{code}
|
||||
BE-->>N: 302 → /auth/jwt/sign-up?ref={code}
|
||||
@@ -89,67 +105,96 @@ sequenceDiagram
|
||||
FE->>BE: POST /api/auth/verify-email-code (with referralCode in TempVerification)
|
||||
BE->>DB: User.create
|
||||
BE->>DB: referrer.referralStats.totalReferrals += 1
|
||||
BE->>IO: emit user-{R} 'referral-signup'
|
||||
BE->>IO: emit user-{R} 'referral-signup' (authController)
|
||||
|
||||
Note over BE,DB: Later, when N completes a purchase
|
||||
BE->>BE: PointsService.addPoints(R, +X, 'referral', {referredUserId:N})
|
||||
BE->>DB: add X points to user balance
|
||||
BE->>DB: create PointTransaction record
|
||||
Note over BE,DB: ONLY when N's order reaches status 'completed'
|
||||
BE->>BE: marketplaceController → PointsService.processReferralReward(id)
|
||||
BE->>BE: addPoints(R, floor(amount*0.02), 'referral', {...})
|
||||
BE->>DB: add points to balance + create PointTransaction
|
||||
BE->>BE: updateUserLevel → maybe 'level-up'
|
||||
BE->>IO: emit user-{R} 'level-up'
|
||||
BE->>DB: activeReferrals = count(referredBy=R) (ALL, not just buyers)
|
||||
BE->>IO: emit user-{R} 'referral-reward' (PointsService)
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/points/generate-referral-code` | Generate or rotate referral code |
|
||||
| `GET` | `/api/points/my-points` | Balance + level |
|
||||
| `GET` | `/api/points/transactions` | History |
|
||||
| `GET` | `/api/points/referrals` | Referred users list |
|
||||
| `GET` | `/api/points/leaderboard` | Global top referrers |
|
||||
| `GET` | `/api/points/levels` | Level config (public) |
|
||||
| `POST` | `/api/points/redeem` | Spend points |
|
||||
| `POST` | `/api/points/admin/add` | Admin-only manual grant |
|
||||
| `GET` | `/r/:code` | Short-URL redirect to sign-up |
|
||||
> [!note] All points routes require authentication
|
||||
> `router.use(authenticateToken)` is applied to **every** route in `pointsRoutes.ts:8`. None of these endpoints — including `GET /api/points/levels` — are public.
|
||||
|
||||
| Method | Endpoint | Auth | Body / Query | Response data |
|
||||
|---|---|---|---|---|
|
||||
| `POST` | `/api/points/generate-referral-code` | user | (ignored) | `{ referralCode }` — always rotates the code |
|
||||
| `GET` | `/api/points/my-points` | user | — | `{ points, referral, currentLevel, nextLevel }` |
|
||||
| `GET` | `/api/points/transactions` | user | `page`, `limit`, `type` (`earn`/`spend`/`expire` only) | `{ transactions, pagination }` |
|
||||
| `GET` | `/api/points/referrals` | user | `page`, `limit` | `{ referrals, pagination }` |
|
||||
| `GET` | `/api/points/leaderboard` | user | `limit` only (**`period` is NOT supported**) | `{ leaderboard, total }` |
|
||||
| `GET` | `/api/points/levels` | user (**NOT public**) | — | `{ levels }` |
|
||||
| `POST` | `/api/points/redeem` | user | `{ pointsToUse, purchaseRequestId }` (both required) | `{ transaction, discount, remainingPoints }` |
|
||||
| `POST` | `/api/points/admin/add` | admin | `{ userId, amount, description }` | `{ transaction, user, levelChanged, newLevel }` |
|
||||
| `GET` | `/r/:code` | public | — | `302` redirect to sign-up |
|
||||
|
||||
### Endpoint notes (verified against code)
|
||||
|
||||
- **`GET /api/points/transactions` — `type` filter** only accepts `earn`, `spend`, or `expire` (`PointsService.ts:250-265`). There is **no source-based filtering**: you cannot filter by `referral` / `purchase` / `admin` / `redemption`.
|
||||
- **`GET /api/points/leaderboard` — the `period` filter (`all`/`month`/`week`) does not exist and is silently ignored.** `getLeaderboard(limit)` only honors `limit` and always returns all-time data sorted by `totalReferrals` then `totalEarned` (`PointsService.ts:434-479`).
|
||||
- **`POST /api/points/admin/add`** reads `{ userId, amount, description }` (the field is `description`, **not** `reason`). However the `description` is **read but never persisted** — the controller calls `addPoints(userId, amount, 'admin', {})` with an empty metadata object (`pointsController.ts:209`), so admin-granted points store **no human-readable reason**. The stored description is the generic auto-generated `'admin'` label from `getTransactionDescription`.
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users`**: `referralCode` on generation, `referredBy` on referee creation, `referralStats.{totalReferrals, activeReferrals, totalEarned}` and `points.{total, available, level}` on point events.
|
||||
- **`pointtransactions`**: one document per earn/spend/refund.
|
||||
- **`users`**: `referralCode` on generation/rotation, `referredBy` on referee creation, `referralStats.{totalReferrals, activeReferrals, totalEarned}` and `points.{total, available, used, level}` on point events. `activeReferrals` is set by `PointsService.processReferralReward` (`:409`) as a count of **all** users with `referredBy = referrer._id`, regardless of purchase history.
|
||||
- **`pointtransactions`**: one document per `earn` / `spend` event. (`expire` is defined in the schema but **never written** — see below.)
|
||||
- **`levelconfigs`**: read-only at runtime (seeded at deploy).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`referral-signup`** → `user-{referrerId}` on referee creation.
|
||||
- **`level-up`** → `user-{userId}` when crossing a tier.
|
||||
- **`new-notification`** → standard notification channel for points-related milestones.
|
||||
- **`referral-signup`** → `user-{referrerId}` on referee creation — emitted by `authController.ts`; this is an **auth-domain** event (NOT emitted by `PointsService`).
|
||||
- **`referral-reward`** → `user-{referrerId}` when `PointsService.processReferralReward` runs — emitted by `PointsService.ts:417`; this is the **points-domain** event. (There is no `referral-signup` emitted from PointsService.)
|
||||
- **`level-up`** → `user-{userId}` when crossing a tier (`PointsService.ts:92`).
|
||||
|
||||
## Side effects
|
||||
|
||||
- The referee never sees the referrer's identity unless surfaced in UI.
|
||||
- `points.available` is the spendable balance; `points.total` is the lifetime accumulation (used for level tiers).
|
||||
- Transactions are wrapped in a Mongo session for atomicity (`addPoints:47-88`).
|
||||
- `points.available` is the spendable balance; `points.total` is the lifetime accumulation (used for level tiers); `points.used` tracks redeemed points.
|
||||
- Transactions are wrapped in a Mongo session for atomicity (`addPoints:47-88`, `redeemPoints:123-153`).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Code collision** — extremely unlikely with 32^8 ≈ 1.1 × 10¹² combinations; the while-loop in `generateReferralCode` is a hard guarantee.
|
||||
- **Self-referral** — not blocked at controller level. Add a check `if (referrer._id.equals(user._id)) return` in `verifyEmailWithCode` and `googleSignUp` to prevent gaming.
|
||||
- **Referral code entered with leading/trailing spaces** — `.trim()` is applied (`authController.ts:74`, `:127`).
|
||||
- **Referrer deleted** — `referredBy` still points to the deleted user; the new user is effectively un-attributed. Soft-delete preservation is acceptable.
|
||||
- **Points overflow** — `Number` is sufficient up to 2⁵³; no overflow risk in practice.
|
||||
- **Race on level-up** — the Mongo session ensures `user.points` and `PointTransaction` are atomically updated, but two parallel `addPoints` calls might both trigger level-up emit. Idempotent in practice (frontend shows toast once).
|
||||
- **`activeReferrals`** — defined in `referralStats` but no code path increments it currently. Define "active" (e.g. referee has at least one completed purchase) and update accordingly.
|
||||
- **Self-referral** — **NOT blocked** at any level (see danger callout above). Known gap.
|
||||
- **Code rotation on regenerate** — calling `generate-referral-code` again replaces the existing code, breaking previously shared links. There is no opt-out.
|
||||
- **Referrer deleted** — `referredBy` still points to the deleted user; the new user is effectively un-attributed.
|
||||
- **Point expiry never enforced** — the `expiresAt` field and the `'expire'` transaction type exist in the schema, and there is a sparse index for expiry sweeps, but **no cron job, TTL index, or service ever creates `expire`-type transactions**. Points never actually expire today.
|
||||
- **`activeReferrals` semantics** — counts **all** referred users, not just those who completed a purchase. If conversion tracking is the intent, this counter is misleading.
|
||||
|
||||
> [!tip] Track conversion, not just sign-ups
|
||||
> `totalReferrals` is incremented on sign-up; consider also tracking `convertedReferrals` (completed purchases) to measure real growth value.
|
||||
> `totalReferrals` is incremented on sign-up and `activeReferrals` counts all referees regardless of purchase; neither distinguishes converted referrals. Consider a dedicated `convertedReferrals` counter incremented only inside `processReferralReward`.
|
||||
|
||||
## Frontend coverage (known gaps)
|
||||
|
||||
The following routes are referenced conceptually but **do NOT exist** — navigating to them returns **404**:
|
||||
|
||||
- `/dashboard/points/referrals` — 404 (no page file)
|
||||
- `/dashboard/points/transactions` — 404 (no page file)
|
||||
- `/dashboard/points/levels` — 404 (no page file)
|
||||
|
||||
Only `/dashboard/points` (`frontend/src/app/dashboard/points/page.tsx`) exists.
|
||||
|
||||
The following frontend actions are defined in `frontend/src/actions/points.ts` but have **no UI callers** (dead code from the UI's perspective):
|
||||
|
||||
- `redeemPoints` — no caller.
|
||||
- `generateReferralCode` — no caller (codes are auto-assigned server-side via `getUserPoints`).
|
||||
- `getLevels` — no caller.
|
||||
- `getReferrals` — no caller.
|
||||
- `adminAddPoints` — no caller.
|
||||
|
||||
Only `getMyPoints`, `getTransactions`, and `getLeaderboard` are actually invoked by the UI (`points-main-view.tsx`, `points-leaderboard.tsx`).
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Registration Flow]] — attribution point.
|
||||
- [[Google OAuth Flow]] — also supports `referralCode`.
|
||||
- [[Notification Flow]] — `referral-signup`, `level-up`, and points events surface here.
|
||||
- [[PRD - Request Network In-House Checkout]] / [[Escrow Flow]] — completion of a purchase is the canonical trigger for awarding referral commission.
|
||||
- [[Notification Flow]] — `referral-signup`, `referral-reward`, `level-up` surface here.
|
||||
- [[Escrow Flow]] — order reaching `'completed'` is the **sole** trigger for awarding referral commission.
|
||||
|
||||
## Source files
|
||||
|
||||
@@ -158,7 +203,8 @@ sequenceDiagram
|
||||
- Backend: `backend/src/routes/pointsRoutes.ts`
|
||||
- Backend: `backend/src/models/PointTransaction.ts`
|
||||
- Backend: `backend/src/models/LevelConfig.ts`
|
||||
- Backend: `backend/src/services/auth/authController.ts:411-433` (referral attribution on email signup)
|
||||
- Backend: `backend/src/services/auth/authController.ts:817-838` (referral on Google signup)
|
||||
- Backend: `backend/src/services/marketplace/marketplaceController.ts:473-475` (referral reward triggered ONLY on `'completed'`)
|
||||
- Backend: `backend/src/services/auth/authController.ts` (referral attribution + `referral-signup` emit on email/Google signup)
|
||||
- Backend: `backend/src/app.ts:274-278` (short-URL redirect)
|
||||
- Frontend: `/dashboard/account/referrals` view (see `frontend/src/sections/account/`)
|
||||
- Frontend: `frontend/src/sections/points/points-invite-friends.tsx:35-36` (builds share URL from `NEXT_PUBLIC_API_URL`)
|
||||
- Frontend: `frontend/src/actions/points.ts` (action layer; several actions have no UI callers)
|
||||
|
||||
@@ -7,6 +7,8 @@ related_apis: ["POST /api/auth/register", "POST /api/auth/verify-email-code", "P
|
||||
|
||||
# Registration 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))
|
||||
|
||||
End-to-end specification for **email + password** registration with role selection (buyer/seller), six-digit email verification, optional referral code attribution, and terms acceptance.
|
||||
|
||||
## Actors
|
||||
@@ -53,10 +55,10 @@ stateDiagram-v2
|
||||
1. **User visits `/auth/jwt/sign-up`** (optionally with `?ref=ABCD1234` from the short-URL referral redirect implemented at `backend/src/app.ts:274-278`).
|
||||
2. **User selects role** (buyer or seller), enters email, password (held in client state only), accepts the Terms checkbox, and clicks "Create account".
|
||||
|
||||
> [!tip] Password is **not** sent to `/register`
|
||||
> The password is only included in the second step (`/verify-email-code`). The intent: never hash and store a password for an unverified account. The TempVerification document carries `password: ''` until verification.
|
||||
> [!bug] ⚠️ KNOWN BUG / quirk — the sign-up form does not collect the real password
|
||||
> `jwt-sign-up-view.tsx` `onSubmit` calls `signUp({ ..., password: '' })` with a **hard-coded empty string** (`jwt-sign-up-view.tsx:191`, with the inline comment `// You might need to add password field to form`). So the actual password is **not** collected on the sign-up form at all — it is collected at the **email-verification step** (`/verify-email-code`). The `TempVerification.password` field is effectively **unused** (it is set to `''` and never read as a real credential). The credential that ends up on the `User` is the one entered at verification.
|
||||
|
||||
3. **HTTP request**: `POST /api/auth/register` with `{ email, password?, firstName?, lastName?, role, referralCode? }`. (The frontend currently passes the password through, but the controller stores `''` regardless — see `authController.ts:123`.)
|
||||
3. **HTTP request**: `POST /api/auth/register` with `{ email, password: '', firstName?, lastName?, role, referralCode? }`. The frontend passes `password: ''` (empty string) — see the quirk above. The controller persists this empty string into `TempVerification.password`, which is never used as a real credential.
|
||||
4. **Validation middleware** `registerValidation` (`authValidation.ts`) checks email format, password complexity, and role enum.
|
||||
5. **Duplicate check** (`authController.ts:55-64`): `User.findOne({ email })` — if found, returns `409 USER_EXISTS`.
|
||||
6. **Idempotent temp record**: `TempVerification.findOne({ email })` — if present, the existing temp is **updated in place** (new name, role, referralCode, fresh 6-digit code, expiry pushed to now + 15 min).
|
||||
@@ -74,10 +76,11 @@ stateDiagram-v2
|
||||
15. **Lookup**: `TempVerification.findOne({ email, emailVerificationCode: code, emailVerificationCodeExpires: { $gt: now } })` — if any field mismatches or the code is older than 15 minutes, returns `400`.
|
||||
16. **Hash password**: `bcrypt.hash(password, 12)` via `authService.hashPassword()`.
|
||||
17. **Create `User`** (`authController.ts:400-435`): `email`, `password: hashedPassword`, `firstName`, `lastName`, `role`, `isEmailVerified: true`, `status: "active"`.
|
||||
18. **Apply referral** (`authController.ts:411-433`): if `tempVerification.referralCode` exists, find the referrer by `User.findOne({ referralCode })`. If found:
|
||||
18. **Apply referral** (`authController.ts:691-713`): `tempVerification.referralCode` (stored on the `TempVerification` document at registration and applied here at verification) is looked up via `User.findOne({ referralCode })`. If a referrer is found:
|
||||
- `user.referredBy = referrer._id`
|
||||
- `referrer.referralStats.totalReferrals += 1`
|
||||
- Emit `referral-signup` on `user-${referrer._id}` Socket.IO room — see [[Referral Flow]] for the points-awarding side effect that happens later on the first purchase.
|
||||
- Emit `referral-signup` on `user-${referrer._id}` Socket.IO room (`authController.ts:704`; the equivalent Google/other path emits at `authController.ts:1132`) — see [[Referral Flow]] for the points-awarding side effect that happens later on the first purchase.
|
||||
- ⚠️ **No self-referral guard**: the code only checks `if (referrer)` — it never compares `referrer._id` to the newly created user. A user who somehow signs up with their own `referralCode` would be attributed as their own referrer.
|
||||
19. **Persist user**, then **delete** the TempVerification document (`findByIdAndDelete`).
|
||||
20. **Token issuance**: identical to [[Authentication Flow]] — generate access + refresh, push the refresh into `user.refreshTokens[]`.
|
||||
21. **Response**: `{ user, tokens: { accessToken, refreshToken } }`. Frontend writes both into `localStorage` (`action.ts:228-235`) and routes the user into the appropriate dashboard (`/dashboard/buyer` or `/dashboard/seller`).
|
||||
@@ -139,9 +142,9 @@ sequenceDiagram
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`tempverifications` collection**: insert on first POST, in-place update on duplicate POST (`authController.ts:66-108`), delete on successful verification.
|
||||
- **`users` collection**: full insert on successful verification (`authController.ts:400-435`). The first refresh token is appended in the same save.
|
||||
- **`users` collection (referrer)**: `referralStats.totalReferrals` incremented (`authController.ts:419`).
|
||||
- **`tempverifications` collection**: insert on first POST (carrying `email`, `password: ''`, `firstName`, `lastName`, `role`, `referralCode`, code + expiry), in-place update on duplicate POST, delete on successful verification.
|
||||
- **`users` collection**: full insert on successful verification (`authController.ts:680-688`). The first refresh token is appended in the same save.
|
||||
- **`users` collection (referrer)**: `referralStats.totalReferrals` incremented (`authController.ts:699`).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
@@ -149,7 +152,7 @@ sequenceDiagram
|
||||
```
|
||||
{ userId, userName, userEmail, timestamp, totalReferrals }
|
||||
```
|
||||
Source: `authController.ts:423-431`.
|
||||
Source: `authController.ts:704-710` (and `:1132` on the parallel path).
|
||||
|
||||
## Side effects
|
||||
|
||||
@@ -168,6 +171,7 @@ sequenceDiagram
|
||||
- **Code format wrong (non-digits or wrong length)** → `400` from `isValidVerificationCode` guard before DB lookup.
|
||||
- **Email delivery failure** → response still `201`/`200`; the user can hit "Resend" or check spam.
|
||||
- **Referral code that does not match any user** → silently ignored; the user is still created with `referredBy: undefined`.
|
||||
- **Self-referral** → **not guarded**. The referral attribution (`authController.ts:691-713`) only checks that a referrer exists, never that it differs from the signing-up user.
|
||||
- **Race condition: two parallel registrations for the same email** → MongoDB unique index on `User.email` ensures only one user document; the loser of the race sees `E11000` and returns `409 USER_EXISTS`.
|
||||
- **Race condition: verify request arrives twice with the same code** → second request finds no TempVerification and returns `400`. The created `User` is the canonical record.
|
||||
- **Role tampering** → role is validated by `registerValidation` enum (`buyer | seller`). Admin role is created only via the bootstrap seed (`initializeAdminUser` in `app.ts:377`), never via this flow.
|
||||
|
||||
@@ -136,7 +136,7 @@ sequenceDiagram
|
||||
end
|
||||
BE->>N: notifyNewOfferReceived
|
||||
N->>IO: emit notification to buyer
|
||||
BE->>IO: emit seller new-offer
|
||||
BE->>IO: emit new-offer to buyer-{buyerId}
|
||||
BE-->>FE_S: 200 { offer }
|
||||
IO-->>FE_B: notify buyer bell icon
|
||||
B->>FE_B: Open request detail
|
||||
@@ -171,6 +171,7 @@ sequenceDiagram
|
||||
## Socket events emitted
|
||||
|
||||
- **`seller-offer-update`** with `eventType: 'new-offer'` → `seller-{sellerId}` (creator's other tabs).
|
||||
- **`new-offer`** → `buyer-{buyerId}` room — emitted directly by `marketplaceController.ts` on offer creation; `use-marketplace-socket.ts` (lines 300, 497) listens on this event to update the buyer's offer list in real time.
|
||||
- **`purchase-request-update`** with `eventType: 'offer-updated'` → `request-{requestId}` on edits (`SellerOfferService.ts:284-288`).
|
||||
- **`purchase-request-update`** → `request-{requestId}` when buyer calls `select-offer` (generic room event only — no per-seller notifications or events are sent to winning or losing sellers).
|
||||
- **`seller-offer-update`** with `eventType: 'payment-completed'` to winning seller, `'offer-rejected'` to losers (emitted by the webhook handler after payment confirmation).
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
> **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report)
|
||||
|
||||
# Trezor Safekeeping Flow
|
||||
|
||||
This flow adds hardware-backed custody controls without replacing the current payment model. The backend never stores private keys. Trezor support starts as a single hardware signer and is designed to upgrade to multisig later.
|
||||
|
||||
Default mode: optional. Existing release/refund flows do not require Trezor proof unless `TREZOR_SAFEKEEPING_REQUIRED=true`.
|
||||
|
||||
> **Note (corrected 2026-05-29):** The frontend Trezor implementation **does exist** in current code — the 2026-05-29 audit's "zero frontend implementation" claim was based on an older snapshot. The active surface is:
|
||||
> - `src/app/dashboard/admin/trezor/page.tsx` → `TrezorSettingsView` (registration + re-register UI)
|
||||
> - `src/web3/trezor/trezorConnector.ts` → lazy-imports `@trezor/connect-web` (`trezorGetXpub`, `trezorGetAddress`, `trezorSignMessage`)
|
||||
> - `src/components/trezor-sign-dialog/TrezorSignDialog.tsx` → build-instruction → sign-on-Trezor → enter-txHash → confirm
|
||||
> - `src/actions/trezor.ts` → full API client (`getTrezorAccount`, `getTrezorRegistrationMessage`, `registerTrezor`, `getTrezorOperationMessage`, `confirmRelease`/`confirmRefund`) that **builds the `trezor: { message, signature }` object**
|
||||
>
|
||||
> The legacy `confirmReleaseTx`/`confirmRefundTx` helpers in `src/actions/payment.ts` post only `{ txHash }` (no `trezor` field), but they have **no UI callers** — the active admin release/refund path goes through `TrezorSignDialog` → `actions/trezor.ts`, which satisfies the `assertTrezorSignatureForOperation` guard when `TREZOR_SAFEKEEPING_REQUIRED=true`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Generate a fresh receive address per user/payment from a registered Trezor xpub.
|
||||
@@ -11,14 +21,19 @@ Default mode: optional. Existing release/refund flows do not require Trezor proo
|
||||
- Keep the Request Network payment adapter and legacy provider abstractions intact while adding custody controls.
|
||||
- Preserve the existing `Payment` model and orchestration surface.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Admin** — the only party who can request operation messages and submit verify-operation calls. The registered Trezor must belong to an admin account; the safekeeping guard validates against the admin's `TrezorAccount.registrationAddress`.
|
||||
- **Any authenticated user** — may call `POST /api/trezor/register` (no role restriction on that endpoint).
|
||||
|
||||
## Registration
|
||||
|
||||
1. User connects a Trezor in the frontend and exports an Ethereum account xpub, for example `m/44'/60'/0'`.
|
||||
1. The Trezor owner (typically an admin) connects a Trezor and exports an Ethereum account xpub, for example `m/44'/60'/0'`.
|
||||
2. Backend builds a registration challenge:
|
||||
- `GET /api/trezor/registration-message?xpub=...®istrationAddress=...`
|
||||
3. The registration address must be the first derived address from the xpub:
|
||||
- `m/44'/60'/0'/0/0`
|
||||
4. User signs the challenge with that Trezor address.
|
||||
4. The owner signs the challenge with that Trezor address.
|
||||
5. Frontend submits:
|
||||
- `POST /api/trezor/register`
|
||||
- `xpub`
|
||||
@@ -30,14 +45,7 @@ Default mode: optional. Existing release/refund flows do not require Trezor proo
|
||||
- xpub is public, not private.
|
||||
- registration address matches xpub-derived index `0`.
|
||||
- signature recovers the registration address.
|
||||
7. Backend stores only:
|
||||
- `userId`
|
||||
- xpub fingerprint
|
||||
- xpub
|
||||
- base derivation path
|
||||
- registration address
|
||||
- next address index
|
||||
- issued address records
|
||||
7. Backend stores / updates the `TrezorAccount` record. **Upsert behaviour:** if a record already exists for the user, `xpub`, `basePath`, and `label` are updated, but `nextAddressIndex` and the existing `addresses` array are preserved via `$setOnInsert`. Old address records continue to reference the previous xpub — a xpub mismatch is therefore possible after re-registration.
|
||||
|
||||
## Address Generation
|
||||
|
||||
@@ -51,6 +59,15 @@ POST /api/trezor/addresses/next
|
||||
}
|
||||
```
|
||||
|
||||
Valid values for `purpose` (as enumerated in the schema):
|
||||
|
||||
| Value | Description |
|
||||
|---|---|
|
||||
| `deposit` | Incoming payment address |
|
||||
| `release` | Address used in a release operation |
|
||||
| `refund` | Address used in a refund operation |
|
||||
| `other` | General-purpose address |
|
||||
|
||||
The backend derives non-hardened receive addresses from the registered xpub:
|
||||
|
||||
```text
|
||||
@@ -59,9 +76,9 @@ m/44'/60'/0'/0/{index}
|
||||
|
||||
If a `paymentId` already has an address, the endpoint returns the same address instead of incrementing the index.
|
||||
|
||||
## Transaction Approval
|
||||
## Transaction Approval (Admin-only)
|
||||
|
||||
Before a release/refund confirmation, the admin asks the backend for the exact operation message:
|
||||
`POST /api/trezor/operation-message` and `POST /api/trezor/verify-operation` are admin-only endpoints. Before a release/refund confirmation, the admin asks the backend for the exact operation message:
|
||||
|
||||
```http
|
||||
POST /api/trezor/operation-message
|
||||
@@ -75,19 +92,17 @@ POST /api/trezor/operation-message
|
||||
}
|
||||
```
|
||||
|
||||
The Trezor signs that message. Release/refund confirmation then includes:
|
||||
The Trezor signs that message and the admin submits it. **The frontend implements this flow** via `TrezorSignDialog`, which calls `getTrezorOperationMessage()`, prompts the Trezor to sign, and then submits the release/refund confirmation through `confirmRelease()` / `confirmRefund()` in `src/actions/trezor.ts` with the full payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"txHash": "0x...",
|
||||
"trezor": {
|
||||
"message": "Amanat escrow Trezor transaction approval\n...",
|
||||
"signature": "0x..."
|
||||
}
|
||||
"amount": 100,
|
||||
"trezor": { "message": "<canonical operation message>", "signature": "0x..." }
|
||||
}
|
||||
```
|
||||
|
||||
When `TREZOR_SAFEKEEPING_REQUIRED=true`, `confirmReleaseRefundInstruction` verifies the signature before calling the payment adapter confirmation path.
|
||||
The `trezor` object is included whenever a signature was produced, satisfying the backend `assertTrezorSignatureForOperation` guard. (The older `confirmReleaseTx`/`confirmRefundTx` helpers in `src/actions/payment.ts` post only `{ txHash }`, but they are unused legacy code with no UI callers.)
|
||||
|
||||
## Enforcement Flag
|
||||
|
||||
@@ -95,7 +110,7 @@ When `TREZOR_SAFEKEEPING_REQUIRED=true`, `confirmReleaseRefundInstruction` verif
|
||||
TREZOR_SAFEKEEPING_REQUIRED=false
|
||||
```
|
||||
|
||||
Default is permissive so existing Request Network release/refund flows continue to work. Set it to `true` only after registering the operating admin's Trezor account and testing the signing path. Any value other than the literal string `true` is treated as disabled.
|
||||
Default is permissive so existing Request Network release/refund flows continue to work. Set it to `true` only after registering the operating admin's Trezor account (the frontend signing flow via `TrezorSignDialog` is already implemented). Any value other than the literal string `true` is treated as disabled.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
|
||||
Reference in New Issue
Block a user