Initial commit: nick docs
This commit is contained in:
187
04 - Flows/Authentication Flow.md
Normal file
187
04 - Flows/Authentication Flow.md
Normal file
@@ -0,0 +1,187 @@
|
||||
---
|
||||
title: Authentication Flow
|
||||
tags: [flow, auth, jwt, login, security]
|
||||
related_models: ["[[User]]", "[[TempVerification]]"]
|
||||
related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"]
|
||||
---
|
||||
|
||||
# Authentication Flow
|
||||
|
||||
End-to-end specification for **email + password** authentication, JWT issuance, token lifecycle, refresh-token rotation, and logout cleanup. This is the most-used auth path in the marketplace and underpins every protected API call and Socket.IO room subscription.
|
||||
|
||||
## Actors
|
||||
|
||||
- **User (Buyer / Seller / Admin)** – submits credentials via the frontend.
|
||||
- **Frontend (Next.js)** – the JWT sign-in view in `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`, which delegates to `signInWithPassword()` in `frontend/src/auth/context/jwt/action.ts`.
|
||||
- **Backend (Express)** – `AuthController.login` in `backend/src/services/auth/authController.ts`.
|
||||
- **MongoDB** – `User` collection (refresh tokens are appended to `user.refreshTokens[]`).
|
||||
- **Redis** – session store (`sessionService.createSession`) and login-attempt rate-limiter (`rateLimitService.checkLoginAttempts`).
|
||||
- **Socket.IO** – not directly involved in login, but the issued JWT is later used to join `user-${userId}` rooms (see [[Notification Flow]] and [[Chat Flow]]).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The user has already completed [[Registration Flow]] and their `User.isEmailVerified === true`.
|
||||
- The user's `User.status === "active"` (soft-deleted accounts have status `"deleted"`).
|
||||
- Network connectivity is available (the frontend uses `NetworkUtils.isOnline()` from `frontend/src/auth/utils/error-handler.ts`).
|
||||
- `localStorage` is available — the frontend rejects the request early via `StorageUtils.isAvailable()` if storage is blocked.
|
||||
- Backend env vars `JWT_SECRET`, `JWT_EXPIRES_IN`, `REFRESH_TOKEN_EXPIRES_IN` are set (`backend/src/services/auth/authService.ts:19-21`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
1. **User fills the sign-in form** at `/auth/jwt/sign-in` and clicks "Sign in". The form is implemented in `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`.
|
||||
2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error.
|
||||
3. **HTTP request**: The frontend POSTs `{ email, password }` to `POST /api/auth/login` (resolved by `endpoints.auth.login` in `frontend/src/lib/axios.ts`). An `AbortController` is armed with a 60-second timeout.
|
||||
4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`.
|
||||
5. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). Five failures within 15 minutes returns `429 TOO_MANY_ATTEMPTS`. Counters live in Redis so they survive restarts.
|
||||
6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")` — `password` is `select: false` by default in the schema and must be explicitly projected.
|
||||
7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design.
|
||||
8. **Email-verification gate**: If `!user.isEmailVerified`, returns `403 EMAIL_NOT_VERIFIED` with `needsVerification: true`. The frontend intercepts this in `action.ts:104-111` and redirects to `/auth/jwt/verify?email=...`.
|
||||
9. **Reset attempts**: On success, `rateLimitService.resetLoginAttempts(email)` wipes the Redis counter.
|
||||
10. **Last-login stamp**: `user.lastLoginAt = new Date()` is saved.
|
||||
11. **Token issuance**:
|
||||
- **Access token** — `authService.generateToken()` builds a JWT with `{ id, email, role, isEmailVerified, iat }`, signed with `HS256`, `issuer: 'marketplace-backend'`, `audience: 'marketplace-users'`, default `expiresIn` from config.
|
||||
- **Refresh token** — `authService.generateRefreshToken()` issues a separate JWT with `{ id, type: 'refresh' }` and a longer TTL.
|
||||
12. **Refresh-token persistence**: The new refresh token is appended to the `User.refreshTokens` array (`authController.ts:230-231`). This array is the server-side allow-list — only tokens present here can be used to mint new access tokens.
|
||||
13. **Redis session**: `sessionService.createSession(accessToken, userId, email, role, ip, userAgent, 86400)` stores a 24h session record keyed by the access token (`authController.ts:235-247`). Failures are logged but do **not** block login.
|
||||
14. **Response**: `200 OK` with `{ user: user.toJSON(), tokens: { accessToken, refreshToken } }`. `toJSON()` strips `password`, `refreshTokens`, and verification codes (see User model `toJSON` transform).
|
||||
15. **Client-side storage**: The frontend writes both tokens to `localStorage` via `StorageUtils.safeSet()` — keys `accessToken` and `refreshToken` (`action.ts:77-82`).
|
||||
|
||||
> [!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 and, on `401/403`, automatically calls the refresh flow described below.
|
||||
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
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as User
|
||||
participant FE as Frontend (Next.js)
|
||||
participant BE as Backend (Express)
|
||||
participant DB as MongoDB
|
||||
participant R as Redis
|
||||
participant IO as Socket.IO
|
||||
|
||||
U->>FE: Enter email + password, click Sign in
|
||||
FE->>FE: NetworkUtils.isOnline() / StorageUtils.isAvailable()
|
||||
FE->>BE: POST /api/auth/login { email, password }
|
||||
BE->>R: rateLimitService.checkLoginAttempts(email)
|
||||
R-->>BE: { allowed: true, remaining }
|
||||
BE->>DB: User.findOne({ email, status: "active" }).select("+password")
|
||||
DB-->>BE: user document
|
||||
BE->>BE: bcrypt.compare(password, user.password)
|
||||
alt password invalid
|
||||
BE-->>FE: 401 Invalid credentials
|
||||
else email not verified
|
||||
BE-->>FE: 403 EMAIL_NOT_VERIFIED
|
||||
FE-->>U: Redirect /auth/jwt/verify
|
||||
else success
|
||||
BE->>R: rateLimitService.resetLoginAttempts(email)
|
||||
BE->>DB: user.lastLoginAt = now; user.refreshTokens.push(refresh)
|
||||
BE->>BE: generateToken(authUser) / generateRefreshToken(authUser)
|
||||
BE->>R: sessionService.createSession(accessToken, ...)
|
||||
BE-->>FE: 200 { user, tokens: { accessToken, refreshToken } }
|
||||
FE->>FE: localStorage.setItem('accessToken' / 'refreshToken')
|
||||
FE->>IO: socket.emit('join-user-room', userId)
|
||||
FE-->>U: Redirect to /dashboard
|
||||
end
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/auth/login` | `backend/src/services/auth/authRoutes.ts:22` → `authController.login` |
|
||||
| `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` |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users` collection**: `lastLoginAt` updated; `refreshTokens` array gains one entry per successful login or refresh.
|
||||
- No write at all on a failed attempt (only Redis counter increments).
|
||||
- On `changePassword` / `resetPassword`: `refreshTokens` is reset to `[]` (forces re-login on every device — `authController.ts:600` and `:685`).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- Login itself emits nothing. After the response, the frontend emits the **client-side** events `join-user-room`, `join-buyer-room` or `join-seller-room` to subscribe to targeted notifications.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Redis session**: 24-hour key holding `{ userId, email, role, ip, userAgent }`. Used for forced logout (admin can call `sessionService.deleteSession(token)`).
|
||||
- **Redis rate-limit counter**: TTL 15 min, reset on success.
|
||||
- **No email** is sent on a normal login (no "new sign-in" notification today — opportunity for future enhancement).
|
||||
- **Sentry**: any unexpected exception bubbles to `Sentry.setupExpressErrorHandler` (`app.ts:351`).
|
||||
|
||||
## Refresh-token flow
|
||||
|
||||
The access token is short-lived. When a protected request returns `401 TOKEN_INVALID` or `403`, the axios interceptor calls:
|
||||
|
||||
1. `POST /api/auth/refresh-token` with `{ refreshToken }` from `localStorage`.
|
||||
2. Backend `authController.refreshToken` (`:263-313`) verifies the token via `verifyRefreshToken`, checks it is **still present in `user.refreshTokens[]`**, then issues a brand-new access **and** refresh token.
|
||||
3. The old refresh token is **removed** from the array and the new one is pushed — implementing **refresh-token rotation**. A leaked-but-stale token therefore becomes invalid the moment the legitimate user refreshes.
|
||||
4. The new pair is written back to `localStorage` and the original failed request is retried.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant FE as Frontend axios
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
|
||||
FE->>BE: GET /api/marketplace/... (Bearer access)
|
||||
BE-->>FE: 401 TOKEN_INVALID
|
||||
FE->>BE: POST /api/auth/refresh-token { refreshToken }
|
||||
BE->>BE: verifyRefreshToken(refreshToken)
|
||||
BE->>DB: User.findById(decoded.id); ensure refresh ∈ user.refreshTokens
|
||||
BE->>BE: Generate new access + refresh tokens
|
||||
BE->>DB: user.refreshTokens = [...minus old, new]
|
||||
BE-->>FE: 200 { tokens: { accessToken, refreshToken } }
|
||||
FE->>BE: GET /api/marketplace/... (Bearer new access) — retry
|
||||
```
|
||||
|
||||
## Logout flow
|
||||
|
||||
1. Frontend `signOut()` (`action.ts:146-176`) reads `refreshToken` from `localStorage` and POSTs `/api/auth/logout` with a 10-second timeout.
|
||||
2. Backend `authController.logout` (`:316-344`) removes the refresh token from `user.refreshTokens[]` and calls `sessionService.deleteSession(accessToken)`.
|
||||
3. **Always-clear**: the frontend's `finally` block removes both `accessToken` and `refreshToken` from `localStorage` regardless of network success — meaning even an offline logout effectively signs the user out locally.
|
||||
|
||||
> [!tip] Force-logout an entire user
|
||||
> Setting `user.refreshTokens = []` in MongoDB instantly invalidates all sessions on next refresh. `changePassword`, `resetPassword`, and `deleteAccount` all do this.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Wrong password** → `401 Invalid credentials` (intentionally vague — no distinction between "unknown email" and "wrong password").
|
||||
- **Email unverified** → `403 EMAIL_NOT_VERIFIED`; frontend auto-redirects to verify page.
|
||||
- **5+ failures in 15 min** → `429 TOO_MANY_ATTEMPTS`; only an admin can manually clear via Redis.
|
||||
- **Network timeout** → axios `AbortController` cancels at 60s; frontend shows a typed error and the user can retry.
|
||||
- **Redis down** → login still succeeds (session creation is best-effort, wrapped in try/catch at `authController.ts:234-247`). Rate limiting falls back to the in-memory map in `authService.ts:113-145` if `rateLimitService` itself throws.
|
||||
- **Stale refresh token** (rotated by another device) → `403 Invalid refresh token`. Frontend signs out and redirects to sign-in.
|
||||
- **JWT signature mismatch** (secret rotated) → all sessions invalidated server-side; clients clear tokens on first 401.
|
||||
- **Token issued for another audience/issuer** → `verifyToken` returns `null` (`authService.ts:60-79`), middleware returns `403 TOKEN_INVALID`.
|
||||
- **Refresh token used as access token** → blocked by the `if (decoded.type === 'refresh') return null` check in `verifyToken` (`authService.ts:67`). This is critical: a leaked refresh token alone cannot read protected data.
|
||||
- **Soft-deleted account** → `User.findOne({ status: "active" })` filter excludes deleted accounts; login fails as if the email did not exist.
|
||||
|
||||
> [!warning] Constant-time response is approximate
|
||||
> Today we return `401` immediately when the user is missing, before running bcrypt. This is a timing oracle that lets an attacker enumerate registered emails by response-time analysis. Mitigation tracked separately — the recommendation is to always run a dummy bcrypt compare on missing users.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Registration Flow]] — produces the `User` document this flow consumes.
|
||||
- [[Password Reset Flow]] — alternate entry into the account if credentials are lost.
|
||||
- [[Google OAuth Flow]] — parallel auth path that produces equivalent tokens.
|
||||
- [[Passkey (WebAuthn) Flow]] — passwordless alternative.
|
||||
- [[Chat Flow]], [[Notification Flow]] — both consume the access token to authorise Socket.IO rooms.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/auth/authController.ts:161-260`
|
||||
- Backend: `backend/src/services/auth/authService.ts:24-99`
|
||||
- Backend: `backend/src/services/auth/authRoutes.ts:22`
|
||||
- Backend: `backend/src/services/redis/sessionService.ts`
|
||||
- Backend: `backend/src/services/redis/rateLimitService.ts`
|
||||
- Frontend: `frontend/src/auth/context/jwt/action.ts:32-176`
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`
|
||||
- Frontend: `frontend/src/lib/axios.ts` (interceptor + endpoints)
|
||||
188
04 - Flows/Chat Flow.md
Normal file
188
04 - Flows/Chat Flow.md
Normal file
@@ -0,0 +1,188 @@
|
||||
---
|
||||
title: Chat Flow
|
||||
tags: [flow, chat, socket-io, messaging]
|
||||
related_models: ["[[Chat]]", "[[Message]]", "[[User]]"]
|
||||
related_apis: ["POST /api/chat", "POST /api/chat/:chatId/messages", "GET /api/chat/:chatId/messages", "POST /api/chat/:chatId/read"]
|
||||
---
|
||||
|
||||
# Chat Flow
|
||||
|
||||
Real-time messaging between buyer & seller (direct), three-way dispute mediation (group), and user-to-support (support). Backed by `Chat` documents with embedded `messages[]` and a Socket.IO room per chat for live updates.
|
||||
|
||||
## Actors
|
||||
|
||||
- **User A (initiator)** — typically a buyer.
|
||||
- **User B (recipient)** — typically a seller.
|
||||
- **Support agent** — for `type: 'support'` chats (user is `support@amn.gg`).
|
||||
- **Admin** — added as a third participant in dispute chats.
|
||||
- **Frontend** — `frontend/src/sections/chat/` (chat list, conversation view, message composer).
|
||||
- **Backend** — `ChatService` (`backend/src/services/chat/ChatService.ts`), routes under `/api/chat`.
|
||||
- **MongoDB** — `chats` collection with embedded `messages`, `participants`, `unreadCounts`, `settings`, `metadata`.
|
||||
- **Socket.IO** — events `new-message`, `chat-notification`, `messages-read`, `user-typing`, `user-status-change`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Both parties authenticated.
|
||||
- For `direct` chats tied to a purchase request, the request exists and both users are participants in it.
|
||||
|
||||
## Chat lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: ChatService.createChat\n(or auto on first contact)
|
||||
Created --> Active: messages flowing
|
||||
Active --> Active: send / read / typing
|
||||
Active --> Archived: settings.isArchived=true
|
||||
Archived --> Active: unarchive
|
||||
Active --> [*]: chat deleted (rare)
|
||||
```
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Creation
|
||||
|
||||
1. **Direct chat (find-or-create)** — when buyer clicks "Chat with seller" on a request detail, frontend POSTs `POST /api/chat` with `{ type: 'direct', participantIds: [buyer, seller], relatedTo: { type: 'PurchaseRequest', id } }`.
|
||||
2. `ChatService.createChat` (`ChatService.ts:90-192`):
|
||||
- For `direct` with exactly 2 participants, runs `Chat.findOne({ type: 'direct', 'participants.userId': { $all: [...] }, 'participants.isActive': true })` and returns the existing chat if found.
|
||||
- Otherwise creates a new `Chat` with `participants` (each with `role:'member'`, `joinedAt`, `isActive:true`), zeroed `unreadCounts`, default `settings`, `metadata.createdBy`.
|
||||
- Appends a system welcome message (`messageType: 'system'`).
|
||||
- If `relatedTo.type === 'PurchaseRequest'`, also writes `"چت برای درخواست خرید \"{title}\" ایجاد شد"` system line.
|
||||
- Re-loads with `populate('participants.userId', 'firstName lastName profile.avatar email')` for the response.
|
||||
3. **Group chat (dispute)** — same pattern, but `type: 'group'`, all three participants (buyer, seller, admin) added (admin is added later by `DisputeService.assignAdmin`).
|
||||
4. **Support chat** — `ChatService.createSupportChat(userId)` (`:41-88`) auto-discovers `User.findOne({ email: 'support@amn.gg' })` and creates a `type: 'support'` chat with a welcome message. Idempotent.
|
||||
5. **Post-payment auto-chat** — when SHKeeper confirms payment, `shkeeperWebhook.ts:606-618` calls `chatService.createChat` to ensure a direct chat exists between buyer and winning seller.
|
||||
|
||||
### Joining the room (real-time)
|
||||
|
||||
6. On chat page mount, the frontend emits `socket.emit('join-chat-room', chatId)` (`backend/src/app.ts:130-133`). The socket joins room `chat-{chatId}`.
|
||||
7. Optionally `socket.emit('user-online', userId)` so other clients see green status (`app.ts:161-169`).
|
||||
|
||||
### Sending a message
|
||||
|
||||
8. User types and hits send. Frontend POSTs `POST /api/chat/:chatId/messages` with `{ content, messageType?, fileUrl?, fileName?, fileSize?, replyTo? }`.
|
||||
9. `ChatService.sendMessage` (`:195-260`):
|
||||
- Loads chat, verifies the sender is in `participants[]` and `isActive`.
|
||||
- Builds `Message`: `{ senderId, content, messageType: 'text', fileUrl?, fileName?, fileSize?, replyTo?, timestamp, isRead: false, isEdited: false }`.
|
||||
- `chat.addMessage(messageData)` — schema method that pushes the message, updates `metadata.lastActivity`, increments `unreadCounts` for non-senders, and updates the cached `lastMessage` summary.
|
||||
- Persists.
|
||||
- Emits **`new-message`** to `chat-{chatId}` (everyone in the room sees the message immediately).
|
||||
- Emits **`chat-notification`** to each non-sender's `user-{userId}` room (drives the chat-list unread badge and the toast/notification bell if the user is not currently viewing the chat).
|
||||
10. Frontend reconciles its own message list (the sender either appends optimistically and then matches the server echo or waits for the round-trip).
|
||||
|
||||
### Attachments
|
||||
|
||||
11. To attach a file, the user picks a file → frontend calls `chatService.uploadChatFile(chatId, file)` (or the equivalent `POST /api/chat/:chatId/upload`) — backend persists the upload via `fileService` (returns `{ fileUrl, fileName, fileSize }`).
|
||||
12. The send-message call then includes `messageType: 'image' | 'file'` and the file metadata. Files are served from `/uploads`.
|
||||
|
||||
### Read receipts
|
||||
|
||||
13. When the user opens a chat, frontend POSTs `POST /api/chat/:chatId/read` (optionally with `messageIds: string[]`).
|
||||
14. `ChatService.markMessagesAsRead` (`:438-483`):
|
||||
- Calls `chat.markAsRead(userId, messageObjectIds)` (schema method that flips `isRead` on the relevant messages and zeros the user's `unreadCounts` entry).
|
||||
- Emits **`messages-read`** to `chat-{chatId}` so the sender sees the double-tick.
|
||||
|
||||
### Typing indicator
|
||||
|
||||
15. On `input` events, frontend emits `socket.emit('typing-start', { chatId, userId, userName })`; on idle/blur emits `typing-stop`.
|
||||
16. Backend `app.ts:142-158` relays to `chat-{chatId}` as `user-typing` (excluding the sender). No DB persistence.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor A as User A
|
||||
actor B as User B
|
||||
participant FE_A as Frontend A
|
||||
participant FE_B as Frontend B
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
A->>FE_A: Open conversation
|
||||
FE_A->>BE: POST /api/chat {type:direct, participantIds, relatedTo}
|
||||
BE->>DB: find-or-create Chat
|
||||
BE-->>FE_A: { chat }
|
||||
FE_A->>IO: emit 'join-chat-room' chatId
|
||||
FE_B->>IO: emit 'join-chat-room' chatId (when B opens too)
|
||||
|
||||
A->>FE_A: type & send
|
||||
FE_A->>BE: POST /api/chat/{id}/messages {content}
|
||||
BE->>DB: chat.addMessage; metadata.lastActivity=now
|
||||
BE->>IO: emit chat-{id} 'new-message'
|
||||
IO-->>FE_A: 'new-message' (echo)
|
||||
IO-->>FE_B: 'new-message' (live)
|
||||
BE->>IO: emit user-{B} 'chat-notification' (badge)
|
||||
|
||||
B->>FE_B: opens chat
|
||||
FE_B->>BE: POST /api/chat/{id}/read
|
||||
BE->>DB: chat.markAsRead(B)
|
||||
BE->>IO: emit chat-{id} 'messages-read'
|
||||
IO-->>FE_A: 'messages-read' (double-tick)
|
||||
|
||||
A->>IO: emit 'typing-start'
|
||||
IO-->>FE_B: 'user-typing' {isTyping:true}
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/chat` | Find-or-create chat |
|
||||
| `GET` | `/api/chat` | List user's chats |
|
||||
| `GET` | `/api/chat/:chatId/messages` | Paginated message history |
|
||||
| `POST` | `/api/chat/:chatId/messages` | Send message |
|
||||
| `POST` | `/api/chat/:chatId/upload` | Upload attachment |
|
||||
| `POST` | `/api/chat/:chatId/read` | Mark read |
|
||||
| `POST` | `/api/chat/support` | Create/get support chat |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`chats`**: insert on create; `$push` into `messages[]`; `$set` `metadata.lastActivity`; `unreadCounts.$.count` increment per recipient; `settings.isArchived` toggled; `participants.$.isActive` flipped on leave.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-message`** → `chat-{chatId}` (every message).
|
||||
- **`chat-notification`** → `user-{recipientId}` for non-senders (badge).
|
||||
- **`messages-read`** → `chat-{chatId}` after read mark.
|
||||
- **`user-typing`** → `chat-{chatId}` (relayed by `app.ts`).
|
||||
- **`user-status-change`** → broadcast when `user-online` is emitted.
|
||||
- **`new-message`** (system) for system welcome lines on chat creation.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **`metadata.lastActivity`** drives the chat-list sort order.
|
||||
- **`lastMessage`** cache lets the chat-list render previews without loading the entire `messages[]` array.
|
||||
- **`unreadCounts`** is the source-of-truth for badge counts; resetting on read also drives global unread totals.
|
||||
- **Embedded messages array** can grow large; consider migrating to a separate `messages` collection if conversations exceed several thousand messages.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Sender not a participant** → `403 "User is not a participant in this chat"` (`:209-211`).
|
||||
- **Chat not found** → `404` on `getChatMessages`.
|
||||
- **Direct duplicate** → idempotent — `createChat` returns existing chat.
|
||||
- **Empty content** — currently allowed (system messages are typically non-empty though); add a min-length validator if needed.
|
||||
- **Files served from `/uploads`** — anonymous access is allowed. Sensitive attachments (KYC docs, dispute evidence) should be protected by signed URLs or per-user authorisation; currently any user with the URL can fetch.
|
||||
- **Long conversations** — `getChatMessages` slices an in-memory copy of the messages array. For >10k messages this is inefficient. Use aggregation `$slice` or a separate collection.
|
||||
- **Race on `markAsRead`** — two parallel reads may double-zero the counter, which is harmless.
|
||||
- **Typing indicator spam** — clients should debounce `typing-start` and emit `typing-stop` on a 2s idle.
|
||||
|
||||
> [!warning] Notification message uses placeholder sender name
|
||||
> `ChatService.sendMessage` posts `chat-notification` with `senderName: "کاربر"` (`:248`) — the literal Persian word for "user". Resolve `senderName` from `participant.userId.firstName` for a better UX.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Notification Flow]] — `chat-notification` is one of the inputs.
|
||||
- [[Negotiation Flow]] — uses chats heavily.
|
||||
- [[Dispute Flow]] — three-way group chat.
|
||||
- [[Authentication Flow]] — supplies the `user-${userId}` rooms via `join-user-room`.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/chat/ChatService.ts`
|
||||
- Backend: `backend/src/services/chat/chatController.ts`
|
||||
- Backend: `backend/src/services/chat/routes.ts` (under `/api/chat`)
|
||||
- Backend: `backend/src/models/Chat.ts`
|
||||
- Backend: `backend/src/app.ts:130-179` (Socket.IO chat handlers)
|
||||
- Frontend: `frontend/src/sections/chat/`
|
||||
- Frontend: `frontend/src/contexts/socket-context.tsx` (or equivalent socket provider)
|
||||
127
04 - Flows/Delivery Confirmation Flow.md
Normal file
127
04 - Flows/Delivery Confirmation Flow.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: Delivery Confirmation Flow
|
||||
tags: [flow, delivery, escrow-release, code]
|
||||
related_models: ["[[PurchaseRequest]]", "[[Payment]]"]
|
||||
related_apis: ["POST /api/marketplace/purchase-requests/:id/delivery-code", "POST /api/marketplace/purchase-requests/:id/verify-delivery"]
|
||||
---
|
||||
|
||||
# Delivery Confirmation Flow
|
||||
|
||||
After the escrow is funded ([[Payment Flow - SHKeeper]] / [[Payment Flow - DePay & Web3]]) and the seller has prepared the item, the seller **marks shipped**, the buyer **enters a delivery code** to confirm receipt, and the escrow becomes eligible for release ([[Payout Flow]]).
|
||||
|
||||
## Actors
|
||||
|
||||
- **Seller** — marks the order shipped and presents the delivery code to the buyer at hand-off.
|
||||
- **Buyer** — confirms by entering the code in the dashboard.
|
||||
- **Backend** — `DeliveryService` (`backend/src/services/delivery/DeliveryService.ts`), exposed through the marketplace routes (`backend/src/services/marketplace/routes.ts`).
|
||||
- **MongoDB** — `purchaserequests.deliveryInfo` subdocument fields.
|
||||
- **Socket.IO** — `delivery-code-generated`, `delivery-update`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `PurchaseRequest.status` is `payment`, `processing`, or `delivery`.
|
||||
- `Payment.escrowState === 'funded'`.
|
||||
|
||||
## 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`.
|
||||
2. **Delivery code generation** — when the order transitions to `delivery`, `DeliveryService.generateDeliveryCode(requestId)` is invoked. It:
|
||||
- Generates a 6-digit code (`Math.floor(100000 + Math.random()*900000)`).
|
||||
- Sets `deliveryInfo.deliveryCode`, `deliveryCodeGeneratedAt = now`, `deliveryCodeExpiresAt = now + 7d`, `deliveryCodeUsed = false`.
|
||||
- Emits `delivery-code-generated` and `delivery-update` to `request-{requestId}`.
|
||||
- Sends a notification to the buyer with the code (in-app, and via email if configured).
|
||||
3. **Buyer entry** — buyer meets the courier / picks up the item, enters the code in `frontend/src/sections/request/components/seller-steps/delivery-code-verification.tsx` (also surfaced on the buyer side via `step-5-receive-goods.tsx`).
|
||||
4. **Verification** — `POST /api/marketplace/purchase-requests/:id/verify-delivery` with `{ code }`:
|
||||
- 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`).
|
||||
5. **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]].
|
||||
6. **Manual fast-track** — the buyer can also tap "Confirm I received it" to skip the code (used when the code path fails — e.g. lost in transit) which patches `status` to `delivered`. This relies on admin trust.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor S as Seller
|
||||
actor B as Buyer
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
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"
|
||||
BE->>BE: DeliveryService.generateDeliveryCode
|
||||
BE->>DB: deliveryInfo.deliveryCode=XXXXXX\nexpires=+7d
|
||||
BE->>IO: emit request-{id} 'delivery-code-generated'
|
||||
BE->>B: notification w/ code (in-app/email)
|
||||
|
||||
S->>B: At hand-off, share the 6-digit code (verbally)
|
||||
B->>FE: Enter code in dashboard
|
||||
FE->>BE: POST /api/marketplace/purchase-requests/{id}/verify-delivery {code}
|
||||
BE->>DB: match code, expires>now, !used
|
||||
BE->>DB: deliveryCodeUsed=true; status="delivered"
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' status-changed
|
||||
BE->>B,S: notifyDeliveryConfirmed
|
||||
Note over BE: Auto-release timer (planned) → seller_paid → payout
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` `{status:"delivery"}` | Seller marks shipped |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/delivery-code` | Manual code regeneration (admin) |
|
||||
| `POST` | `/api/marketplace/purchase-requests/:id/verify-delivery` | Buyer confirms with code |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id/confirm-delivery` | Buyer fast-track confirm (no code) |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`purchaserequests.deliveryInfo`** — `deliveryCode`, `deliveryCodeGeneratedAt`, `deliveryCodeExpiresAt`, `deliveryCodeUsed`, `deliveryCodeUsedAt`.
|
||||
- **`purchaserequests.status`** — `delivery` → `delivered` → (eventually `seller_paid` → `completed`).
|
||||
- **`notifications`** — generated for both parties.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`delivery-code-generated`** → `request-{id}` (with code, expiresAt).
|
||||
- **`delivery-update`** → `request-{id}` (`type: 'code-generated'`).
|
||||
- **`purchase-request-update`** `status-changed` on `delivery → delivered`.
|
||||
- **`new-notification`** → `user-{buyerId}` with the code.
|
||||
|
||||
## Side effects
|
||||
|
||||
- Code is **emitted via socket and in-app notification**. If a malicious actor has access to the buyer's notifications, they could intercept and confirm delivery prematurely. Treat the code as confidential at the UI layer.
|
||||
- Triggers the path that eventually frees up the escrow (manual today via [[Payout Flow]], auto in the future).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Wrong code** → `400 Invalid delivery code`.
|
||||
- **Expired code** (>7 days) → `400 Code expired`. Admin can regenerate via the manual endpoint.
|
||||
- **Already used code** → `400 Code already used`.
|
||||
- **Buyer never confirms** → status remains `delivery`. Auto-release timer (not yet built) should trigger `delivered` after N days. Until then, admin intervention.
|
||||
- **Seller delivers but never marks shipped** → buyer can dispute via [[Dispute Flow]]; the dispute resolution will release the escrow regardless.
|
||||
- **Lost code** → `POST /:id/delivery-code` regenerates a new 6-digit value, invalidates the old one, and re-notifies. Restrict to admin/seller to avoid abuse.
|
||||
|
||||
> [!tip] Use the code as proof-of-handover
|
||||
> The seller should ask the courier or the buyer at the door for the code before leaving the item. If the buyer disputes "never received", an unused code is strong circumstantial evidence; a used code = buyer confirmed.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Payment Flow - SHKeeper]] / [[Payment Flow - DePay & Web3]] — funding precondition.
|
||||
- [[Escrow Flow]] — state transitions triggered by confirmation.
|
||||
- [[Payout Flow]] — fires after confirmation (manual today).
|
||||
- [[Dispute Flow]] — escape hatch.
|
||||
- [[Notification Flow]] — channel for the code.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/delivery/DeliveryService.ts`
|
||||
- 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`
|
||||
199
04 - Flows/Dispute Flow.md
Normal file
199
04 - Flows/Dispute Flow.md
Normal file
@@ -0,0 +1,199 @@
|
||||
---
|
||||
title: Dispute Flow
|
||||
tags: [flow, dispute, mediator, evidence, chat, state-machine]
|
||||
related_models: ["[[Dispute]]", "[[Chat]]", "[[PurchaseRequest]]", "[[Payment]]"]
|
||||
related_apis: ["POST /api/disputes", "POST /api/disputes/:id/assign", "POST /api/disputes/:id/resolve", "POST /api/disputes/:id/evidence", "PATCH /api/disputes/:id/status"]
|
||||
---
|
||||
|
||||
# Dispute Flow
|
||||
|
||||
When something goes wrong (item not delivered, wrong item, fraud), 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 — releasing the escrow to the seller, refunding the buyer, splitting the funds, or rejecting the claim.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Buyer** — typical initiator.
|
||||
- **Seller** — party against whom the dispute is raised (or in rarer cases, initiator).
|
||||
- **Admin / Mediator** — assigned to investigate.
|
||||
- **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard.
|
||||
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), `DisputeController` (`backend/src/controllers/disputeController.ts`), routes at `backend/src/routes/disputeRoutes.ts`.
|
||||
- **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`.
|
||||
- **Socket.IO** — `new-notification`, `new-message`, `dispute-updated` (planned).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The related `PurchaseRequest` exists.
|
||||
- The initiator is the request's buyer or the related seller.
|
||||
- Funds are typically held in escrow (`Payment.escrowState = 'funded'`) — disputes on unfunded orders are accepted but have no monetary impact.
|
||||
|
||||
## Dispute state machine (`Dispute.status`)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: createDispute()\nresponseDeadline=+48h\ndeadline=+7d
|
||||
pending --> in_progress: admin assigned\nassignAdmin()
|
||||
in_progress --> resolved: admin resolves\naction ∈ {refund, partial, release, reject}
|
||||
in_progress --> closed: admin closes without resolution\n(e.g. duplicate/spam)
|
||||
pending --> closed: same
|
||||
resolved --> [*]
|
||||
closed --> [*]
|
||||
```
|
||||
|
||||
Resolution actions (from `Dispute.resolution.action` enum, see `Dispute.ts`): `refund`, `partial`, `release`, `reject` (the wording differs slightly in the model — verify with `backend/src/models/Dispute.ts`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Phase 1 — Opening
|
||||
|
||||
1. Buyer or seller opens the request detail and clicks **"Report problem"** (`frontend/src/sections/request/components/report-problem-to-admin.tsx`).
|
||||
2. They select a `category` (delivery, payment, quality, fraud, other), a `priority` (`low | medium | high | urgent`), write a `description`, and optionally upload `evidence` (images, screenshots, video, document) via `POST /api/files/upload`.
|
||||
3. Frontend POSTs `POST /api/disputes` with `{ purchaseRequestId, reason, description, priority, category, evidence: [...] }`.
|
||||
4. Backend `DisputeService.createDispute` (`:12-119`):
|
||||
- Loads the purchase request with `populate('selectedOfferId')`.
|
||||
- Resolves the **counter-party `sellerId`** by priority: explicit `data.sellerId` → `selectedOffer.sellerId` → first of `preferredSellerIds`. This means once an offer is accepted, the dispute targets the actual seller, not the entire preferred list.
|
||||
- Creates the `Dispute` with `status: 'pending'`, `responseDeadline = now + 48h`, `deadline = now + 7 days`, and an empty `timeline[]`.
|
||||
- Creates a **`Chat` of type `group`** with the buyer and the resolved seller as participants. The opening message is a system-typed line `"اختلاف جدید ایجاد شد: {reason}"`. The chat's `relatedTo = { type: 'PurchaseRequest', id }`.
|
||||
- Persists `dispute.chatId = chat._id`.
|
||||
5. Notifications (currently a `TODO` in the service — `:107-116`) should fire `new-notification` to the seller. Today the chat creation alone provides real-time presence via the `new-message` socket emit inside `Chat.create`'s lifecycle.
|
||||
|
||||
> [!warning] Dispute does not auto-pause escrow
|
||||
> Today, opening a dispute does **not** flip `Payment.escrowState` away from `funded`. An admin could theoretically still release the escrow before resolving the dispute. Until a `disputed` flag is added to Payment, admins must check the dispute table before any release/refund action.
|
||||
|
||||
### Phase 2 — Admin assignment
|
||||
|
||||
6. The admin dispute dashboard lists pending disputes (sorted by `priority: -1, createdAt: -1`).
|
||||
7. Admin clicks "Pick up" → `POST /api/disputes/:id/assign` with `{ adminId }` (currently the admin's own id).
|
||||
8. `DisputeService.assignAdmin` (`:184-223`):
|
||||
- `dispute.adminId = adminId; dispute.status = 'in_progress'`.
|
||||
- Appends `timeline` entry `{ action: 'admin_assigned', performedBy: adminId, ... }`.
|
||||
- Adds the admin to the dispute `chat.participants[]` (role `admin`).
|
||||
- Saves.
|
||||
|
||||
### Phase 3 — Investigation
|
||||
|
||||
9. All three parties chat in the dispute chat room (same socket mechanics as [[Chat Flow]]). Each party can upload more evidence via `POST /api/disputes/:id/evidence` — `DisputeService.addEvidence` (`:305-337`) appends to `dispute.evidence[]` and writes a `timeline` entry `evidence_added`.
|
||||
10. The admin may also `PATCH /api/disputes/:id/status` with intermediate states or notes; this updates `dispute.status` and writes a `timeline` entry `status_changed`.
|
||||
|
||||
### Phase 4 — Resolution
|
||||
|
||||
11. Once the admin has enough information, they call `POST /api/disputes/:id/resolve` with `{ action, amount?, currency?, notes? }`.
|
||||
12. `DisputeService.resolveDispute` (`:262-300`):
|
||||
- `dispute.status = 'resolved'`
|
||||
- `dispute.resolution = { action, amount, currency, notes, resolvedBy: adminId, resolvedAt: now }`
|
||||
- `dispute.closedAt = now`
|
||||
- Appends `timeline` entry `dispute_resolved`.
|
||||
- Saves.
|
||||
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **payout** ([[Payout Flow]] with `kind: 'release'`) or the **refund** (`kind: 'refund'`, see [[Escrow Flow]]). The dispute service does not automatically dispatch the on-chain action.
|
||||
14. Both parties are notified (TODOs in code — planned: `notifyDisputeResolved`).
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
actor S as Seller
|
||||
actor A as Admin
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
B->>FE: "Report problem" on request
|
||||
B->>FE: Choose category, priority, evidence
|
||||
FE->>BE: POST /api/disputes
|
||||
BE->>DB: Dispute.create({status:"pending"})
|
||||
BE->>DB: Chat.create({type:"group", participants:[buyer, seller], system message})
|
||||
BE->>DB: dispute.chatId = chat._id
|
||||
BE-->>FE: { dispute }
|
||||
FE-->>B,S: chat opens (real-time via existing chat join)
|
||||
|
||||
A->>FE: Admin dashboard, click "Pick up"
|
||||
FE->>BE: POST /api/disputes/{id}/assign
|
||||
BE->>DB: dispute.adminId, status="in_progress", timeline.push
|
||||
BE->>DB: chat.participants.push(admin)
|
||||
BE-->>FE: { dispute }
|
||||
|
||||
loop investigation
|
||||
A->>FE: Chat with B & S
|
||||
B-->>BE: POST /api/disputes/{id}/evidence (image)
|
||||
BE->>DB: dispute.evidence.push, timeline.push
|
||||
end
|
||||
|
||||
A->>FE: Click "Resolve" choose action
|
||||
FE->>BE: POST /api/disputes/{id}/resolve { action, amount, notes }
|
||||
BE->>DB: dispute.status="resolved", resolution={...}
|
||||
alt action="refund"
|
||||
A->>BE: trigger refund payout to buyer\n[[Escrow Flow]] / [[Payout Flow]]
|
||||
else action="release"
|
||||
A->>BE: trigger payout to seller\n[[Payout Flow]]
|
||||
else action="partial"
|
||||
A->>BE: split — refund X to buyer, release Y to seller
|
||||
end
|
||||
BE-->>FE: { dispute }
|
||||
IO-->>B,S: 'new-notification' dispute resolved (planned)
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/disputes` | `disputeRoutes.ts:12` → `DisputeController.createDispute` |
|
||||
| `GET` | `/api/disputes` | `disputeRoutes.ts:15` (filters: status, priority, category, adminId, buyer/seller) |
|
||||
| `GET` | `/api/disputes/statistics` | `disputeRoutes.ts:18` |
|
||||
| `GET` | `/api/disputes/:id` | `disputeRoutes.ts:21` |
|
||||
| `POST` | `/api/disputes/:id/assign` | `disputeRoutes.ts:24` |
|
||||
| `PATCH` | `/api/disputes/:id/status` | `disputeRoutes.ts:27` |
|
||||
| `POST` | `/api/disputes/:id/resolve` | `disputeRoutes.ts:30` |
|
||||
| `POST` | `/api/disputes/:id/evidence` | `disputeRoutes.ts:33` |
|
||||
|
||||
All require `authenticateToken` (router-level middleware).
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`disputes`** — insert on open; updates `adminId`, `status`, `timeline[]`, `evidence[]`, `resolution`, `closedAt` over the lifecycle.
|
||||
- **`chats`** — new `group` chat on open; admin appended to `participants[]` on assignment; messages appended throughout.
|
||||
- **`purchaserequests`** — not directly mutated by the dispute service; the resolution side-effect (release/refund) updates the request via [[Escrow Flow]].
|
||||
- **`payments`** — touched indirectly when the admin performs the financial resolution.
|
||||
- **`notifications`** — `TODO` markers in code; planned addition.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-message`** → `chat-{disputeChatId}` for each chat line (via the standard `ChatService.sendMessage` and the system message created in `DisputeService.createDispute`).
|
||||
- **`new-notification`** (planned) → `user-{buyerId}` and `user-{sellerId}` on creation, assignment, evidence-added, resolution.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Three-way chat creation** is the most visible side effect — pulls the buyer and seller into a controlled conversation room.
|
||||
- **Timeline append-only log** is the audit trail. Surface it in the admin UI for compliance.
|
||||
- **Response deadline = 48h** — used by reminders / SLA dashboards (no automated enforcement today). Past-deadline disputes could auto-escalate priority.
|
||||
- **Hard deadline = 7d** — same intent: a watchdog could mark long-unresolved disputes for admin attention.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Purchase request missing** → `400 Purchase request not found`.
|
||||
- **No seller identifiable** (orphan request) → dispute still created but with `sellerId: undefined`; the chat becomes 2-party (buyer + admin only). Recommended: reject creation in this case to avoid mediator-less situations.
|
||||
- **Initiator is neither buyer nor seller** → not enforced at service level — should be validated in `DisputeController` (recommended hardening).
|
||||
- **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding `unique on (purchaseRequestId, status:'pending'|'in_progress')` to prevent duplicates.
|
||||
- **Evidence URL is hot-linked** → frontend uploads through `POST /api/files/upload` and the URL is served from `/uploads`. Ensure auth on the upload endpoint to prevent random users from polluting evidence.
|
||||
- **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state. Add automation that auto-fires the payout/refund when the admin selects `release` or `refund`.
|
||||
- **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`.
|
||||
|
||||
> [!tip] Sort disputes by priority + age
|
||||
> The query `Dispute.find().sort({ priority: -1, createdAt: -1 })` already used in `getDisputes` ensures `urgent` ones bubble to the top. Make sure the admin dashboard uses this default sort.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Chat Flow]] — message-level mechanics inside the dispute chat.
|
||||
- [[Escrow Flow]] — the financial state being contested.
|
||||
- [[Payout Flow]] — executed on `release` resolutions.
|
||||
- [[Notification Flow]] — channels for dispute alerts.
|
||||
- [[Delivery Confirmation Flow]] — disputes often arise from failed delivery.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/dispute/DisputeService.ts`
|
||||
- Backend: `backend/src/controllers/disputeController.ts`
|
||||
- Backend: `backend/src/routes/disputeRoutes.ts`
|
||||
- Backend: `backend/src/models/Dispute.ts`
|
||||
- Frontend: `frontend/src/sections/request/components/report-problem-to-admin.tsx`
|
||||
- Frontend: admin dispute dashboard under `frontend/src/sections/admin/` (subject to organisation)
|
||||
196
04 - Flows/Escrow Flow.md
Normal file
196
04 - Flows/Escrow Flow.md
Normal file
@@ -0,0 +1,196 @@
|
||||
---
|
||||
title: Escrow Flow
|
||||
tags: [flow, escrow, payment, state-machine]
|
||||
related_models: ["[[Payment]]", "[[PurchaseRequest]]"]
|
||||
related_apis: ["POST /api/payment/release/:paymentId", "POST /api/payment/refund/:paymentId"]
|
||||
---
|
||||
|
||||
# Escrow Flow
|
||||
|
||||
The escrow is not a separate smart contract — it is a **state machine on the `Payment` document** combined with a **custodial wallet** (the platform-controlled BSC address `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS`). Funds sit at that wallet once SHKeeper / Web3 verification completes, and are released to the seller or refunded to the buyer based on order outcome.
|
||||
|
||||
## Actors
|
||||
|
||||
- **System** — the backend, on receiving pay-in confirmation.
|
||||
- **Buyer** — confirms delivery to authorise release; can open a dispute to block release.
|
||||
- **Seller** — recipient of release.
|
||||
- **Admin** — resolves disputes and signs payout transactions when manual control is required.
|
||||
- **MongoDB** — `payments` document holds the canonical `escrowState`.
|
||||
|
||||
## Escrow state machine (`Payment.escrowState`)
|
||||
|
||||
Enum from `Payment.ts:112-115`: `funded | releasable | released | refunded | releasing | failed`.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Pending: Payment.status="pending"\nescrowState=undefined
|
||||
Pending --> Partial: webhook PARTIAL\nescrowState="partial"
|
||||
Pending --> Funded: webhook PAID/OVERPAID\nor on-chain verify success\nescrowState="funded"
|
||||
Partial --> Funded: top-up reaches threshold
|
||||
Funded --> Releasable: buyer confirms delivery\n(or auto-release timer)
|
||||
Releasable --> Releasing: admin/system initiates payout\n[[Payout Flow]]
|
||||
Releasing --> Released: payout tx confirmed\nescrowState="released"
|
||||
Releasing --> Failed: payout tx reverted\nescrowState="failed"
|
||||
Funded --> Refunded: dispute resolution = refund\nescrowState="refunded"
|
||||
Funded --> Refunded: order cancelled\npre-shipment
|
||||
Failed --> Releasing: admin retries
|
||||
Released --> [*]
|
||||
Refunded --> [*]
|
||||
```
|
||||
|
||||
`Payment.status` mirrors a coarser business state:
|
||||
- `pending` → invoice issued, awaiting funds.
|
||||
- `processing` → SHKeeper sees partial / confirmations in progress.
|
||||
- `confirmed` → fully credited (intermediate; sometimes skipped).
|
||||
- `completed` → escrow `funded` and onward.
|
||||
- `failed`, `cancelled`, `refunded` → terminal.
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### 1. Funding
|
||||
|
||||
- Triggered by either [[Payment Flow - SHKeeper]] (webhook `PAID`/`OVERPAID`) or [[Payment Flow - DePay & Web3]] (verified `eth_getTransactionReceipt`).
|
||||
- Backend sets `Payment.status = "completed"` and `Payment.escrowState = "funded"` (`shkeeperWebhook.ts:388-391`, `shkeeperService.ts:600-602`).
|
||||
- Cascade: `PurchaseRequest.status` → `payment`, then `processing` once the seller acknowledges; `SellerOffer.status` → `accepted`; chat created.
|
||||
- Funds physically sit at the **custodial wallet** — SHKeeper's per-invoice deposit address (auto-swept to the merchant wallet) or directly at the escrow wallet in the Web3 path.
|
||||
|
||||
### 2. Holding
|
||||
|
||||
- While `escrowState === "funded"` and the order is in `processing` / `delivery`, the funds are inert. No interest accrues; no on-chain action happens.
|
||||
- The buyer cannot withdraw; the seller cannot collect. Only an admin/system action moves it forward.
|
||||
- Visible in admin dashboard: `GET /api/payment/admin/funded?status=funded` (or similar — see admin payment view in `frontend/src/sections/payment/view/payment-list-admin-view.tsx`).
|
||||
|
||||
### 3. Releasing (happy path)
|
||||
|
||||
- Trigger options:
|
||||
- **Buyer confirms delivery** via the delivery-code flow ([[Delivery Confirmation Flow]]).
|
||||
- **Auto-release timer** elapses (configurable; today a manual or scheduled job — `PurchaseRequestService` exposes status transitions through to `completed`).
|
||||
- **Admin manual release** from the admin payment detail view.
|
||||
- The system marks `Payment.escrowState = "releasable"` (intermediate).
|
||||
- `shkeeperPayoutService.createPayoutTask` (or a manual EVM admin signature via `admin-wallet-payout.tsx`) starts the on-chain transfer to the seller's verified wallet address. State flips to `releasing`.
|
||||
- On confirmation: `confirmAdminTx(paymentId, txHash, 'release')` (`shkeeperService.ts:628-647`) sets:
|
||||
- `Payment.status = 'completed'`
|
||||
- `Payment.escrowState = 'released'`
|
||||
- `Payment.blockchain.transactionHash = <payout tx hash>`
|
||||
- Cascade: `PurchaseRequest.status` → `seller_paid` then `completed`.
|
||||
|
||||
### 4. Refunding (dispute / cancellation)
|
||||
|
||||
- Trigger: dispute resolution with `action: 'refund'` or pre-shipment cancellation.
|
||||
- Backend builds the refund tx via `buildAdminSignedTxPayload(paymentId, 'refund')` (`shkeeperService.ts:614-626`) — destination is `payment.blockchain.sender` (the buyer's verified wallet).
|
||||
- Admin signs and broadcasts (currently a manual step in the admin UI).
|
||||
- On confirmation: `confirmAdminTx(paymentId, txHash, 'refund')` sets:
|
||||
- `Payment.status = 'refunded'`
|
||||
- `Payment.escrowState = 'refunded'`
|
||||
- Cascade: `PurchaseRequest.status` → `cancelled` (or remains in dispute-resolved state).
|
||||
|
||||
### 5. Failed payout
|
||||
|
||||
- If the payout tx reverts (insufficient gas, contract pause, wrong address), `escrowState = 'failed'`. Admin can retry by initiating a fresh payout.
|
||||
|
||||
## Sequence diagram (release path)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
actor A as Admin
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant SK as SHKeeper Payout API
|
||||
participant BC as BSC
|
||||
|
||||
B->>FE: Enter delivery code (or auto-timer fires)
|
||||
FE->>BE: POST /api/marketplace/purchase-requests/:id/confirm-delivery
|
||||
BE->>DB: PurchaseRequest.status="delivered"\nPayment.escrowState="releasable"
|
||||
BE-->>FE: ok
|
||||
A->>FE: Click "Release" in admin
|
||||
FE->>BE: POST /api/payment/shkeeper/payout
|
||||
BE->>DB: Payment.escrowState="releasing"
|
||||
BE->>SK: createPayoutTask({recipient, amount})
|
||||
SK->>BC: signed payout tx
|
||||
BC-->>SK: confirmed
|
||||
SK->>BE: payout webhook / poll
|
||||
BE->>BE: confirmAdminTx(paymentId, txHash, "release")
|
||||
BE->>DB: Payment.escrowState="released"\nPurchaseRequest.status="completed"
|
||||
```
|
||||
|
||||
## Sequence diagram (refund path)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor A as Admin
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant BC as BSC
|
||||
actor B as Buyer
|
||||
|
||||
A->>BE: Dispute resolved with action="refund"
|
||||
BE->>BE: buildAdminSignedTxPayload(paymentId, "refund")
|
||||
BE-->>A: { to:buyerWallet, amount, token, network }
|
||||
A->>BC: sign + broadcast tx
|
||||
BC-->>A: txHash
|
||||
A->>BE: confirmAdminTx(paymentId, txHash, "refund")
|
||||
BE->>DB: Payment.status="refunded"\nescrowState="refunded"
|
||||
BE->>B: notifyRefundCompleted
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/payment/admin/release/:paymentId` | Initiate release |
|
||||
| `POST` | `/api/payment/admin/refund/:paymentId` | Initiate refund |
|
||||
| `POST` | `/api/payment/admin/confirm-tx/:paymentId` | Admin marks the signed tx confirmed |
|
||||
| `GET` | `/api/payment/:paymentId/status` | Polled by both parties |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`payments`**: `status`, `escrowState`, `blockchain.transactionHash`, `completedAt`, `metadata.*` are mutated as the state progresses.
|
||||
- **`purchaserequests`**: `status` cascades (`payment → processing → delivery → delivered → confirming → seller_paid → completed`).
|
||||
- **`notifications`**: created on each terminal state.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`purchase-request-update`** `status-changed` on every cascading status flip.
|
||||
- **`payment-status`** (planned/admin) — admin dashboard real-time feed.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Custodial risk** — the escrow wallet's private key sits with the platform. Lose it → lose all in-flight escrows. Operational controls: hardware wallet, multi-sig, cold storage of the recovery seed.
|
||||
- **No on-chain escrow contract** — there is no Solidity escrow today. Migration toward a smart-contract escrow (e.g. OpenZeppelin's `Escrow.sol` pattern) would remove custodial trust at the cost of higher complexity and gas.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Buyer never confirms delivery** → today requires admin intervention. An auto-release timer (e.g. 7 days after `delivered`) is a recommended addition.
|
||||
- **Seller's wallet address invalid** → payout tx fails or sends to a black hole. Validate `recipientAddress` shape (`^0x[0-9a-fA-F]{40}$`) before signing (`shkeeperPayoutService.ts:62-64` checks `.startsWith('0x')`).
|
||||
- **Partial payment** (`PARTIAL`) → escrow remains in `pending/partial`; release blocked until full payment arrives.
|
||||
- **Overpaid** → currently treated as `completed/funded`; the surplus is not auto-refunded.
|
||||
- **Concurrent release + refund** → blocked by `PaymentCoordinator` serialisation; whichever fires first wins, the other is rejected.
|
||||
- **Payout fails on chain** → state stays in `releasing` until admin re-runs; consider auto-retry with exponential backoff.
|
||||
- **Disputed payment** → `escrowState` is **not** auto-changed when a dispute is opened. Admin must explicitly resolve to refund/release. Add a `disputed` boolean or `escrowState='disputed'` to make this more obvious.
|
||||
|
||||
> [!warning] Single custodial wallet = single point of failure
|
||||
> Centralising all in-flight escrow at one BSC address is the platform's largest operational risk. Use a multi-sig (Gnosis Safe) for the escrow wallet, store one key in HSM, and require two admin signatures for any payout > a threshold.
|
||||
|
||||
> [!tip] Recovering inconsistent state
|
||||
> If `Payment.escrowState` looks stale (e.g. `released` but no on-chain tx hash), inspect with `Payment.find({ escrowState: 'released', 'blockchain.transactionHash': { $exists: false } })` and reconcile via the SHKeeper invoice or the `fix-transaction-hashes.js` script.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Payment Flow - SHKeeper]] — funds the escrow.
|
||||
- [[Payment Flow - DePay & Web3]] — alternative funding path.
|
||||
- [[Delivery Confirmation Flow]] — triggers release.
|
||||
- [[Dispute Flow]] — can divert to refund.
|
||||
- [[Payout Flow]] — executes the release transfer.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/models/Payment.ts:96-145` (status + escrowState enums)
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts:600-647`
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:387-411`
|
||||
- Backend: `backend/src/services/payment/paymentCoordinator.ts`
|
||||
- Frontend: `frontend/src/sections/payment/view/payment-list-admin-view.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx`
|
||||
144
04 - Flows/Google OAuth Flow.md
Normal file
144
04 - Flows/Google OAuth Flow.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Google OAuth Flow
|
||||
tags: [flow, auth, oauth, google]
|
||||
related_models: ["[[User]]"]
|
||||
related_apis: ["POST /api/auth/google/signup", "POST /api/auth/google/signin"]
|
||||
---
|
||||
|
||||
# Google OAuth Flow
|
||||
|
||||
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
|
||||
|
||||
- **User** with a Google account.
|
||||
- **Google Identity Services (GSI)** — JS SDK loaded on demand by the frontend service.
|
||||
- **Frontend** — `frontend/src/auth/services/google-oauth.ts`, consumed by `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx` and `jwt-sign-up-view.tsx`.
|
||||
- **Backend** — `AuthController.googleSignUp` and `googleSignIn` in `backend/src/services/auth/authController.ts`, backed by `googleOAuthService.verifyGoogleToken()` in `backend/src/services/auth/googleOAuthService.ts`.
|
||||
- **MongoDB** — `User` collection (account linking by email).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `NEXT_PUBLIC_GOOGLE_CLIENT_ID` is set on the frontend (`frontend/src/auth/services/google-oauth.ts` line 2 — there is a hard-coded fallback for the dev project ID).
|
||||
- Same client ID is whitelisted on the backend `googleOAuthService`.
|
||||
- The current origin is registered under "Authorized JavaScript origins" in Google Cloud Console (see `frontend/GOOGLE_OAUTH_SETUP.md`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Sign-up
|
||||
|
||||
1. User clicks the Google icon on `/auth/jwt/sign-up`. The form is configured with the chosen role (buyer/seller) and an optional referral code.
|
||||
2. The frontend lazy-loads `https://accounts.google.com/gsi/client` if it is not yet on `window.google`.
|
||||
3. `google.accounts.oauth2.initTokenClient({ client_id, scope: 'openid email profile' })` is initialised, and `.requestAccessToken()` opens the Google popup.
|
||||
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.
|
||||
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.
|
||||
|
||||
### Sign-in
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as User
|
||||
participant FE as Frontend
|
||||
participant G as Google GSI
|
||||
participant BE as Backend
|
||||
participant GA as google-auth-library
|
||||
participant DB as MongoDB
|
||||
|
||||
U->>FE: Click Google icon
|
||||
FE->>G: load gsi/client, initTokenClient(client_id)
|
||||
FE->>G: requestAccessToken()
|
||||
G-->>U: Popup → consent
|
||||
U-->>G: Approve
|
||||
G-->>FE: ID token (signed JWT)
|
||||
alt Sign-up
|
||||
FE->>BE: POST /api/auth/google/signup { googleToken, role, referralCode }
|
||||
else Sign-in
|
||||
FE->>BE: POST /api/auth/google/signin { googleToken }
|
||||
end
|
||||
BE->>GA: verifyGoogleToken(googleToken)
|
||||
GA-->>BE: { email, name, picture, ... } or null
|
||||
BE->>DB: User.findOne({ email })
|
||||
alt Sign-up: user 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
|
||||
BE-->>FE: 404 USER_NOT_FOUND
|
||||
else Sign-in: ok
|
||||
BE->>DB: user.lastLoginAt = now; back-fill avatar if blank
|
||||
end
|
||||
BE->>BE: generate access + refresh; push refresh
|
||||
BE-->>FE: 200 { user, tokens }
|
||||
FE->>FE: localStorage.setItem(accessToken/refreshToken)
|
||||
FE-->>U: Redirect /dashboard/{role}
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/auth/google/signup` | `authRoutes.ts:30` → `authController.googleSignUp` |
|
||||
| `POST` | `/api/auth/google/signin` | `authRoutes.ts:31` → `authController.googleSignIn` |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users` collection**: on sign-up, full insert (no password). On sign-in, only `lastLoginAt`, possibly `profile.avatar`, and a new refresh token appended.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`referral-signup`** → `user-${referrerId}` when sign-up includes a valid `referralCode`.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **No email** is sent (Google handles trust). No `TempVerification` is created.
|
||||
- The avatar URL is stored from Google's CDN; consider proxying or rehosting if Google's privacy rules change for `googleusercontent.com`.
|
||||
|
||||
## 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.
|
||||
- **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`.
|
||||
|
||||
> [!warning] Single backup file
|
||||
> `frontend/src/auth/services/google-oauth.ts.backup` is checked in. Delete or convert to a documentation note — it leaks a hard-coded client ID that should only live in `.env.*`.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Authentication Flow]] — token issuance and storage are identical from step 9 onward.
|
||||
- [[Registration Flow]] — alternative path that requires email verification.
|
||||
- [[Referral Flow]] — works identically for Google-signup referrals.
|
||||
|
||||
## Source files
|
||||
|
||||
- Frontend: `frontend/src/auth/services/google-oauth.ts`
|
||||
- Frontend: `frontend/src/auth/context/jwt/action.ts:281-331`
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-up-view.tsx`, `jwt-sign-in-view.tsx`
|
||||
- Frontend: `frontend/GOOGLE_OAUTH_SETUP.md`
|
||||
- Backend: `backend/src/services/auth/authController.ts:781-941`
|
||||
- Backend: `backend/src/services/auth/googleOAuthService.ts`
|
||||
- Backend: `backend/src/services/auth/authRoutes.ts:30-31`
|
||||
148
04 - Flows/Negotiation Flow.md
Normal file
148
04 - Flows/Negotiation Flow.md
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
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"]
|
||||
---
|
||||
|
||||
# Negotiation Flow
|
||||
|
||||
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
|
||||
|
||||
- **Buyer** — initiates negotiation by replying to an offer or opening chat from the request detail.
|
||||
- **Seller** — receives counter, can accept or counter back.
|
||||
- **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`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- A `SellerOffer` exists on the purchase request (status `pending`).
|
||||
- The purchase request is `received_offers` or `in_negotiation`.
|
||||
- Both parties are still active users.
|
||||
|
||||
## 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.
|
||||
|
||||
> [!tip] Pre-payment chats vs. post-payment chats
|
||||
> A negotiation chat may exist **before** the SHKeeper webhook auto-creates the post-payment chat. The `ChatService.createChat` `direct` find-or-create logic (`ChatService.ts:95-108`) prevents duplicates — the same chat object is reused.
|
||||
|
||||
2. **Status flip to `in_negotiation`** — the first message in the negotiation chat triggers a backend hook (or a manual frontend PATCH) that calls `PurchaseRequestService.updatePurchaseRequest` with `{ status: 'in_negotiation' }`. The status-progression guard allows this (`received_offers → in_negotiation`).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
5. **Buyer accepts** — clicks "Accept this offer", which kicks off [[Payment Flow - SHKeeper]] with the (now-updated) `sellerOfferId`. The webhook 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`.
|
||||
|
||||
7. **Seller withdraws** — `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.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
actor S as Seller
|
||||
participant FE_B as Frontend (buyer)
|
||||
participant FE_S as Frontend (seller)
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
B->>FE_B: Click "Chat with seller" on offer
|
||||
FE_B->>BE: POST /api/chat (type:direct, relatedTo:PR)
|
||||
BE->>DB: find-or-create Chat
|
||||
BE-->>FE_B: { chat }
|
||||
B->>FE_B: Send "Can you do $80?"
|
||||
FE_B->>BE: POST /api/chat/{id}/messages
|
||||
BE->>DB: Chat.addMessage(...)
|
||||
BE->>IO: emit chat-{id} 'new-message'
|
||||
IO-->>FE_S: 'new-message' (seller sees in real time)
|
||||
BE->>BE: optionally trigger request → in_negotiation
|
||||
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
|
||||
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
|
||||
IO-->>FE_B: refresh offer card
|
||||
alt Buyer accepts
|
||||
B->>FE_B: Click "Pay" → [[Payment Flow - SHKeeper]]
|
||||
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"}
|
||||
BE->>DB: offer.status = "rejected"
|
||||
BE->>BE: notifyOfferRejected(seller)
|
||||
IO-->>FE_S: 'new-notification'
|
||||
end
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `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) |
|
||||
| `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`.
|
||||
- **`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).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-message`** → `chat-{chatId}` (the canonical chat event).
|
||||
- **`chat-notification`** → `user-{participantId}` for non-senders (badge increment).
|
||||
- **`purchase-request-update`** with `eventType: 'offer-updated'` → `request-{id}` whenever the offer is edited.
|
||||
- **`purchase-request-update`** with `eventType: 'status-changed'` → `request-{id}` on the `received_offers → in_negotiation` flip.
|
||||
|
||||
## Side effects
|
||||
|
||||
- The chat's unread badge grows in the recipient's chat list (`Chat.unreadCounts` array). Resets when they open the chat and `POST /api/chat/{id}/read`.
|
||||
- Typing indicators are emitted via `typing-start` / `typing-stop` socket events (see `backend/src/app.ts:142-158`) — purely client-driven.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **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`.
|
||||
- **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.
|
||||
|
||||
> [!warning] No structured "counter-offer object"
|
||||
> Today, counter-offer negotiations are mostly free-form chat plus an `updateOffer` edit. There is no `CounterOffer` collection with provenance. If audit/regulatory needs emerge, capture each counter as a snapshot (`{ oldPrice, newPrice, byUserId, atTime }`) on the offer.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Seller Offer Flow]] — the prior step.
|
||||
- [[Payment Flow - SHKeeper]] — closes the negotiation with an on-chain payment.
|
||||
- [[Chat Flow]] — message-level mechanics, attachments, read receipts.
|
||||
- [[Notification Flow]] — accept/reject notifications.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/marketplace/SellerOfferService.ts:271-353`
|
||||
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts:408-495`
|
||||
- Backend: `backend/src/services/chat/ChatService.ts:90-260`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/`
|
||||
- Frontend: `frontend/src/sections/chat/` (chat UI)
|
||||
155
04 - Flows/Notification Flow.md
Normal file
155
04 - Flows/Notification Flow.md
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
title: Notification Flow
|
||||
tags: [flow, notification, socket-io, email]
|
||||
related_models: ["[[Notification]]", "[[User]]"]
|
||||
related_apis: ["GET /api/notifications", "PATCH /api/notifications/:id/read", "POST /api/notifications/read-all", "DELETE /api/notifications/:id"]
|
||||
---
|
||||
|
||||
# Notification Flow
|
||||
|
||||
Cross-cutting flow that powers the bell icon, toast pop-ups, badge counters, and (optionally) email digests. Notifications are created by many services and travel to the user via both **MongoDB persistence** and **Socket.IO push**.
|
||||
|
||||
## Trigger sources
|
||||
|
||||
- **Purchase request lifecycle** — created, status changed, cancelled (`PurchaseRequestService`).
|
||||
- **Offer lifecycle** — new offer, accepted, rejected, withdrawn (`SellerOfferService`).
|
||||
- **Payment lifecycle** — confirmed, refunded, payout sent (`shkeeperWebhook`, `PurchaseRequestService.notifyPaymentConfirmed`).
|
||||
- **Chat** — `chat-notification` events (see [[Chat Flow]]).
|
||||
- **Dispute** — created, assigned, resolved (TODO in `DisputeService`; the chat itself notifies).
|
||||
- **Delivery** — code generated, delivery confirmed (`DeliveryService`).
|
||||
- **Referral** — sign-up via referral code (`AuthController.verifyEmailWithCode`, `googleSignUp`).
|
||||
- **Points / levels** — `level-up` event when crossing a tier (`PointsService.addPoints:91-99`).
|
||||
- **Admin actions** — e.g. dispute resolution, manual payouts.
|
||||
|
||||
## Actors
|
||||
|
||||
- **System** — the various services calling `NotificationService.createNotification`.
|
||||
- **User** — the recipient.
|
||||
- **Frontend** — bell-icon dropdown and toast subscribers in `frontend/src/layouts/components/notifications-drawer/` and the global socket provider.
|
||||
- **Backend** — `NotificationService` (`backend/src/services/notification/NotificationService.ts`), routes at `/api/notifications`.
|
||||
- **MongoDB** — `notifications` collection (one document per notification).
|
||||
- **Socket.IO** — emits `new-notification` to `user-{userId}`.
|
||||
- **Email** (optional) — periodic digest worker (not implemented today; planned).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Creating a notification
|
||||
|
||||
1. Any service builds a `NotificationCreateData` object:
|
||||
```
|
||||
{
|
||||
userId, title, message,
|
||||
type: 'info' | 'success' | 'warning' | 'error',
|
||||
category: 'purchase_request' | 'offer' | 'payment' | 'delivery' | 'system',
|
||||
relatedId?, metadata?, actionUrl?
|
||||
}
|
||||
```
|
||||
2. Calls `await notificationService.createNotification(data)`.
|
||||
3. `NotificationService.createNotification` (`NotificationService.ts:18-37`):
|
||||
- `Notification.create({ ...data, isRead: false, createdAt: now })`.
|
||||
- Calls `this.emitRealTimeNotification(userId, saved)` which `global.io.to('user-${userId}').emit('new-notification', payload)`.
|
||||
4. The notification is persisted and pushed simultaneously.
|
||||
|
||||
### Frontend reception
|
||||
|
||||
5. The frontend's global Socket.IO provider listens for `new-notification` events on its `user-{me}` room (joined on app mount via `socket.emit('join-user-room', userId)`).
|
||||
6. On event: increment the bell-icon badge, optionally show a toast (`notistack`), and prepend the entry into the cached notifications list (React Query cache).
|
||||
7. The bell-icon dropdown also fetches `GET /api/notifications?page=1&limit=20` for paginated history.
|
||||
|
||||
### Reading
|
||||
|
||||
8. User opens the bell-icon dropdown — frontend calls `POST /api/notifications/mark-read` for each viewed entry (or `POST /api/notifications/read-all`).
|
||||
9. `NotificationService.markAsRead(notificationId, userId)` (`NotificationService.ts:74-90`):
|
||||
- `Notification.findOneAndUpdate({ _id, userId }, { isRead: true, readAt: now })`.
|
||||
- Emits `notification-read` (or recomputes unread count) so other open tabs sync.
|
||||
|
||||
### 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.
|
||||
|
||||
### Email digest (planned)
|
||||
|
||||
- A scheduled worker should `Notification.find({ userId, emailDigested: false, createdAt: { $gte: yesterday } })`, batch by user, render a digest email via `emailService`, mark `emailDigested: true`. Not implemented today.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Svc as Originating Service<br/>(SellerOfferService / Webhook / ...)
|
||||
participant NS as NotificationService
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
participant FE as Frontend
|
||||
actor U as User
|
||||
|
||||
Svc->>NS: createNotification({userId, title, message, ...})
|
||||
NS->>DB: Notification.create
|
||||
NS->>IO: emit user-{userId} 'new-notification'
|
||||
IO-->>FE: 'new-notification' payload
|
||||
FE-->>U: badge++, toast, prepend to list
|
||||
|
||||
U->>FE: open bell dropdown
|
||||
FE->>NS: GET /api/notifications?page=1&limit=20
|
||||
NS->>DB: Notification.find({userId}).sort({createdAt:-1})
|
||||
NS-->>FE: { notifications, total, unreadCount }
|
||||
|
||||
U->>FE: click notification
|
||||
FE->>NS: PATCH /api/notifications/{id}/read
|
||||
NS->>DB: Notification.findOneAndUpdate(isRead:true)
|
||||
FE-->>U: badge--, mark item as read
|
||||
FE-->>U: navigate to notification.actionUrl
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/notifications` | Paginated list with `unreadCount` |
|
||||
| `GET` | `/api/notifications/unread-count` | Just the unread count for badge |
|
||||
| `PATCH` | `/api/notifications/:id/read` | Mark single notification read |
|
||||
| `POST` | `/api/notifications/read-all` | Mark all read |
|
||||
| `DELETE` | `/api/notifications/:id` | Remove from list |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`notifications`** — insert on create, update on read, delete on remove.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-notification`** → `user-{userId}`. Payload includes the full notification document (so the frontend doesn't need to re-fetch).
|
||||
- **`level-up`** → `user-{userId}` from `PointsService.addPoints`.
|
||||
- **`referral-signup`** → `user-{referrerId}` from auth verify.
|
||||
- **`chat-notification`** → `user-{participantId}` from `ChatService.sendMessage` (these are not stored in the `notifications` collection — they live alongside but drive only the chat-list badge).
|
||||
|
||||
## Side effects
|
||||
|
||||
- Bell badge count is derived from `unreadCount` returned by the GET endpoint or computed client-side as items arrive.
|
||||
- Notification actions deep-link via `actionUrl` (e.g. `/dashboard/buyer/requests/{id}`).
|
||||
- Sentry breadcrumbs capture failed notification creations.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **User offline** → notification is persisted in MongoDB; the user sees it when they next sign in. The socket emit is lossy (no replay).
|
||||
- **Multiple tabs / devices** → the same `user-{id}` room receives the event in each socket; all tabs update.
|
||||
- **Disabled categories** (planned) → service should early-return without DB write if the user has opted out, otherwise persist but don't push (so it shows in history but not as a toast).
|
||||
- **High volume** (e.g. fan-out to thousands of sellers) → today every notification is a separate Mongo insert + socket emit. For mass announcements, consider `insertMany` + per-room broadcast. The 50ms stagger in [[Purchase Request Flow]] mitigates the worst case.
|
||||
- **Stale unread count** → if the frontend trusts a stale React Query cache, it can show wrong numbers; always reconcile against `unread-count` on bell-icon open.
|
||||
|
||||
> [!tip] Always set `actionUrl`
|
||||
> Every notification should have a deep-link target. Notifications without `actionUrl` lead to dead clicks. The factory methods in `NotificationService` (e.g. `notifyNewOfferReceived`) already enforce this — keep the pattern when adding new helpers.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- Every other flow in this folder emits notifications via this service.
|
||||
- [[Chat Flow]] — separate `chat-notification` socket channel for chat badges.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/notification/NotificationService.ts`
|
||||
- Backend: `backend/src/services/notification/notificationController.ts`
|
||||
- Backend: `backend/src/services/notification/notificationControllerRoutes.ts`
|
||||
- Backend: `backend/src/services/notification/routes.ts`
|
||||
- Backend: `backend/src/models/Notification.ts`
|
||||
- Frontend: `frontend/src/layouts/components/notifications-drawer/`
|
||||
- Frontend: socket provider (joins `user-{id}` and listens for `new-notification`)
|
||||
162
04 - Flows/Passkey (WebAuthn) Flow.md
Normal file
162
04 - Flows/Passkey (WebAuthn) Flow.md
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: Passkey (WebAuthn) Flow
|
||||
tags: [flow, auth, passkey, webauthn, passwordless]
|
||||
related_models: ["[[User]]"]
|
||||
related_apis: ["POST /api/auth/passkey/register/challenge", "POST /api/auth/passkey/register", "POST /api/auth/passkey/authenticate/challenge", "POST /api/auth/passkey/authenticate", "GET /api/auth/passkey/list", "DELETE /api/auth/passkey/:passkeyId"]
|
||||
---
|
||||
|
||||
# Passkey (WebAuthn) Flow
|
||||
|
||||
Passwordless sign-in using **WebAuthn / Passkeys**. The backend issues challenges, validates signed assertions, stores credential metadata under `User.passkeys[]`, and finally issues the same JWT pair as the password flow.
|
||||
|
||||
## Actors
|
||||
|
||||
- **User** with a WebAuthn-capable authenticator (Touch ID, Face ID, Windows Hello, Android biometric, YubiKey).
|
||||
- **Browser WebAuthn API** — `navigator.credentials.create()` / `.get()`.
|
||||
- **Frontend** — `frontend/src/auth/components/PasskeyManagement.tsx` (registration UI on the account settings page) and `frontend/src/auth/components/PasskeySignIn.tsx` (sign-in entry).
|
||||
- **Backend** — `backend/src/services/auth/passkeyService.ts` and the routes in `backend/src/services/auth/passkeyRoutes.ts`.
|
||||
- **MongoDB** — `User.passkeys[]` subdocument array (id, publicKey, counter, deviceType, deviceName, createdAt).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The browser supports WebAuthn (`window.PublicKeyCredential`). The frontend checks this and throws `"WebAuthn در این مرورگر پشتیبانی نمیشود"` otherwise.
|
||||
- The relying party ID derives from `config.frontendUrl` — `backend/src/services/auth/passkeyService.ts:36` strips scheme/port to produce the WebAuthn `rpId`.
|
||||
- For **registration**, the user is already authenticated via password or Google (the `/passkey/register/*` routes are behind `authService.authenticateToken`).
|
||||
- For **sign-in**, no auth is required — the authenticator's credential ID identifies the user.
|
||||
- Env vars consumed by the frontend (commonly `NEXT_PUBLIC_PASSKEY_RP_ID`, `NEXT_PUBLIC_PASSKEY_RP_NAME`, `NEXT_PUBLIC_PASSKEY_ORIGIN` per the codebase conventions) drive WebAuthn options on the client.
|
||||
|
||||
## Registration flow
|
||||
|
||||
1. From `/dashboard/account/security`, the user opens the Passkey management card and clicks **"Add new passkey"**.
|
||||
2. Frontend `PasskeyManagement.tsx` calls `POST /api/auth/passkey/register/challenge` (with the bearer access token).
|
||||
3. Backend `passkeyService.generateRegistrationChallenge(userId)` (`passkeyService.ts:58-70`):
|
||||
- `crypto.randomBytes(32).toString('base64url')` — a 256-bit challenge.
|
||||
- Stored in an in-memory `Map<challenge, { userId, timestamp }>` (5-min TTL via interval cleanup).
|
||||
- Returns `{ challenge, rpId, userVerification: 'preferred', timeout: 60000 }`.
|
||||
4. Frontend calls `navigator.credentials.create({ publicKey: { challenge, rp, user, pubKeyCredParams, ... } })`. The browser prompts the authenticator (Touch ID, etc.) and returns a `PublicKeyCredential` containing `id`, `rawId`, `response.clientDataJSON`, `response.attestationObject`.
|
||||
5. Frontend POSTs `POST /api/auth/passkey/register` with `{ challenge, credential }`.
|
||||
6. Backend `passkeyService.verifyRegistration(challenge, credential)` (`:108-146`):
|
||||
- Looks up the stored challenge → `{ userId }`. Deletes it (single-use).
|
||||
- Loads `User.findById(userId)`.
|
||||
- Appends to `user.passkeys[]`: `{ id: credential.id, publicKey: 'simulated-public-key', counter: 0, deviceType: 'platform', deviceName: 'Default Device', createdAt: now }`.
|
||||
- Saves.
|
||||
7. Frontend re-fetches `GET /api/auth/passkey/list` and renders the new entry.
|
||||
|
||||
> [!warning] Attestation validation is stubbed
|
||||
> `passkeyService.verifyRegistration` currently **does not** parse the attestation object or extract the real COSE public key — see the comment block at `passkeyService.ts:122-128` ("In a real implementation, you would..."). The `publicKey` field is the literal string `'simulated-public-key'`. This means a malicious client could register an attacker-controlled credential ID under any user; harden this before production. Use `@simplewebauthn/server` to parse attestation and store the verified public key.
|
||||
|
||||
## Authentication flow
|
||||
|
||||
1. From `/auth/jwt/sign-in`, the user clicks **"Sign in with passkey"**.
|
||||
2. Frontend `PasskeySignIn.tsx` calls `POST /api/auth/passkey/authenticate/challenge` — note this is a **public** route (no bearer token).
|
||||
3. Backend `passkeyService.generateAuthenticationChallengeForSignIn()` (`:88-105`) generates a 32-byte challenge and stores it with `userId: 'pending'`.
|
||||
4. Frontend calls `navigator.credentials.get({ publicKey: { challenge, rpId, userVerification: 'preferred' } })`. The browser surfaces all matching passkeys for the rpId; the user picks one and approves biometrically.
|
||||
5. The authenticator returns a `PublicKeyCredential` whose `response` includes `clientDataJSON`, `authenticatorData`, `signature`, `userHandle`.
|
||||
6. Frontend POSTs `POST /api/auth/passkey/authenticate` with `{ challenge, assertion }`.
|
||||
7. Backend `passkeyService.verifyAuthentication(challenge, assertion)` (`:149-272`):
|
||||
- Confirms the challenge exists (and deletes it).
|
||||
- `User.findOne({ 'passkeys.id': assertion.id })` — finds the user whose passkey matches the credential ID supplied by the authenticator.
|
||||
- `passkey.counter += 1` (the schema stores a counter; a real implementation must reject replays where the new counter is not strictly greater than the stored one).
|
||||
- Issues JWT access + refresh tokens directly via `jwt.sign(...)` (`:230-248`). Note: these are signed by the same `config.jwtSecret` as in `authService`, so they are interchangeable with password-issued tokens.
|
||||
8. Response: `{ success: true, userId, user: {...}, tokens: { accessToken, refreshToken } }`.
|
||||
9. Frontend stores tokens in `localStorage` and redirects to the dashboard.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as User
|
||||
participant FE as Frontend
|
||||
participant W as WebAuthn (Browser)
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
|
||||
rect rgb(245,247,250)
|
||||
Note over U,DB: Registration (user already authenticated)
|
||||
U->>FE: Click "Add passkey"
|
||||
FE->>BE: POST /api/auth/passkey/register/challenge (Bearer)
|
||||
BE->>BE: generateRegistrationChallenge(userId)\nstore in Map
|
||||
BE-->>FE: { challenge, rpId, ... }
|
||||
FE->>W: navigator.credentials.create({ publicKey })
|
||||
W-->>FE: PublicKeyCredential
|
||||
FE->>BE: POST /api/auth/passkey/register { challenge, credential }
|
||||
BE->>BE: verifyRegistration → consume challenge
|
||||
BE->>DB: user.passkeys.push({ id, counter, deviceType })
|
||||
BE-->>FE: { success: true }
|
||||
end
|
||||
|
||||
rect rgb(245,247,250)
|
||||
Note over U,DB: Authentication (no prior session)
|
||||
U->>FE: Click "Sign in with passkey"
|
||||
FE->>BE: POST /api/auth/passkey/authenticate/challenge (public)
|
||||
BE->>BE: generateAuthenticationChallengeForSignIn → store
|
||||
BE-->>FE: { challenge, rpId, ... }
|
||||
FE->>W: navigator.credentials.get({ publicKey })
|
||||
W-->>FE: PublicKeyCredential (assertion)
|
||||
FE->>BE: POST /api/auth/passkey/authenticate { challenge, assertion }
|
||||
BE->>BE: consume challenge
|
||||
BE->>DB: User.findOne({ 'passkeys.id': assertion.id })
|
||||
DB-->>BE: user with matching passkey
|
||||
BE->>DB: passkey.counter += 1
|
||||
BE->>BE: jwt.sign(access) / jwt.sign(refresh)
|
||||
BE-->>FE: { success, user, tokens }
|
||||
FE->>FE: localStorage.setItem(tokens)
|
||||
FE-->>U: Redirect /dashboard
|
||||
end
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Auth | Source |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/auth/passkey/register/challenge` | Bearer | `passkeyRoutes.ts:50` |
|
||||
| `POST` | `/api/auth/passkey/register` | Bearer | `passkeyRoutes.ts:66` |
|
||||
| `POST` | `/api/auth/passkey/authenticate/challenge` | Public | `passkeyRoutes.ts:10` |
|
||||
| `POST` | `/api/auth/passkey/authenticate` | Public | `passkeyRoutes.ts:23` |
|
||||
| `GET` | `/api/auth/passkey/list` | Bearer | `passkeyRoutes.ts:87` |
|
||||
| `DELETE` | `/api/auth/passkey/:passkeyId` | Bearer | `passkeyRoutes.ts:103` |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users.passkeys`** — append on register, increment `counter` on each successful auth, splice on delete.
|
||||
- A new refresh token is **not** appended to `user.refreshTokens` in the current passkey path (the JWT is signed directly without round-tripping through `authService.generateRefreshToken`). This means the password-flow refresh-token allow-list does not apply to passkey logins. See edge cases.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- None directly. The frontend joins the same Socket.IO rooms after login as in [[Authentication Flow]].
|
||||
|
||||
## Side effects
|
||||
|
||||
- **In-memory `storedChallenges` map**: per-instance, not Redis. On a horizontally scaled deployment, the challenge created on instance A can only be verified on instance A. Either pin to a single instance, use sticky sessions, or move to Redis (`paymentRedisService`-style).
|
||||
- **Cleanup interval**: every 5 minutes, expired challenges (>5 min old) are removed (`passkeyService.ts:42-55`).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Browser without WebAuthn** → frontend throws localized error before issuing the challenge request.
|
||||
- **User cancels biometric prompt** → `NotAllowedError` from the browser; frontend shows "Cancelled" toast.
|
||||
- **Challenge expired or unknown** → backend `Invalid or expired challenge` (`:117`). Frontend asks user to retry.
|
||||
- **Credential ID does not match any user** → `404 Passkey not found` (`passkeyRoutes.ts:40-44`). UX should suggest signing in by email instead.
|
||||
- **Multi-instance backend** (challenge stored on instance A, verified on instance B) → verification fails. Fix by moving `storedChallenges` to Redis.
|
||||
- **Replay** — current implementation does not strictly enforce monotonic counter; revisit before production.
|
||||
- **Refresh-token rotation gap** — passkey-issued refresh tokens are not added to `user.refreshTokens[]`. The standard `/api/auth/refresh-token` will reject them on the next refresh. Until fixed, treat passkey access tokens as short-lived (the user must passkey-sign-in again after expiry) or unify token issuance through `authService.generateRefreshToken` and persist them.
|
||||
|
||||
> [!warning] Production hardening checklist
|
||||
> 1. Replace stub attestation parsing with `@simplewebauthn/server`.
|
||||
> 2. Persist the COSE public key, not a stub string.
|
||||
> 3. Enforce strictly increasing counter (signal of cloned authenticator if not).
|
||||
> 4. Move challenge storage to Redis to support multi-instance deploys.
|
||||
> 5. Add `excludeCredentials` during registration to prevent re-registering the same passkey.
|
||||
> 6. Push the passkey-issued refresh token into `user.refreshTokens[]`.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Authentication Flow]] — token semantics are identical post-issuance.
|
||||
- [[Registration Flow]] — passkey is an additional credential, not a replacement for initial account creation.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/auth/passkeyService.ts`
|
||||
- Backend: `backend/src/services/auth/passkeyRoutes.ts`
|
||||
- Frontend: `frontend/src/auth/components/PasskeyManagement.tsx`
|
||||
- Frontend: `frontend/src/auth/components/PasskeySignIn.tsx`
|
||||
124
04 - Flows/Password Reset Flow.md
Normal file
124
04 - Flows/Password Reset Flow.md
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
title: Password Reset Flow
|
||||
tags: [flow, auth, password-reset, email]
|
||||
related_models: ["[[User]]"]
|
||||
related_apis: ["POST /api/auth/request-password-reset", "POST /api/auth/reset-password-with-code"]
|
||||
---
|
||||
|
||||
# Password Reset Flow
|
||||
|
||||
Self-service password recovery: request a 6-digit code by email, submit it with the new password.
|
||||
|
||||
## Actors
|
||||
|
||||
- **User** who has forgotten their password.
|
||||
- **Frontend** — `frontend/src/auth/view/jwt/jwt-reset-password-view.tsx` (request) and `jwt-update-password-view.tsx` (submit new password).
|
||||
- **Backend** — `AuthController.requestPasswordReset` and `AuthController.resetPasswordWithCode` in `backend/src/services/auth/authController.ts`.
|
||||
- **MongoDB** — `User` collection (`passwordResetCode`, `passwordResetCodeExpires`, `refreshTokens`).
|
||||
- **Email service** — `emailService.sendPasswordResetCodeEmail`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The account exists and `status === "active"` (deleted accounts are silently treated as non-existent).
|
||||
- The user has access to the email inbox associated with the account.
|
||||
- A 6-digit code is valid for **1 hour** (`authController.ts:556`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
1. User clicks "Forgot password?" on the sign-in page and lands at `/auth/jwt/reset-password`.
|
||||
2. User enters their email and submits.
|
||||
3. Frontend POSTs `POST /api/auth/request-password-reset { email }`.
|
||||
4. Backend `authController.requestPasswordReset` (`:542-574`):
|
||||
- `User.findOne({ email, status: "active" })`. If absent, returns `200` with the same generic message — **no enumeration**.
|
||||
- Generates a 6-digit code via `authService.generateVerificationCode()`.
|
||||
- Saves `passwordResetCode` and `passwordResetCodeExpires = now + 3_600_000 ms` on the user.
|
||||
- Calls `emailService.sendPasswordResetCodeEmail(email, firstName, code)`.
|
||||
5. Response: `200 "If an account with this email exists, a password reset code has been sent"` regardless of outcome.
|
||||
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}$/`.
|
||||
- `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.
|
||||
- Sets `user.password = hashed`, clears `passwordResetCode` and `passwordResetCodeExpires`, **wipes `user.refreshTokens = []`** to invalidate all existing sessions.
|
||||
- Saves.
|
||||
9. Response: `200 "Password reset successfully"`. Frontend redirects to `/auth/jwt/sign-in` for a fresh login.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as User
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant MAIL as Email Service
|
||||
|
||||
U->>FE: Click "Forgot password", enter email
|
||||
FE->>BE: POST /api/auth/request-password-reset { email }
|
||||
BE->>DB: User.findOne({ email, status: "active" })
|
||||
alt user found
|
||||
BE->>BE: code = generateVerificationCode()
|
||||
BE->>DB: user.passwordResetCode = code\nexpires = +1h
|
||||
BE->>MAIL: sendPasswordResetCodeEmail(email, firstName, code)
|
||||
MAIL-->>U: Email with 6-digit code
|
||||
end
|
||||
BE-->>FE: 200 "if account exists, code sent"
|
||||
|
||||
U->>FE: Enter code + new password
|
||||
FE->>BE: POST /api/auth/reset-password-with-code { email, code, password }
|
||||
BE->>DB: User.findOne({ email, code, expires>now })
|
||||
BE->>BE: bcrypt.hash(password, 12)
|
||||
BE->>DB: user.password = hash\nuser.refreshTokens = []\nclear reset fields
|
||||
BE-->>FE: 200 "Password reset successfully"
|
||||
FE-->>U: Redirect /auth/jwt/sign-in
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/auth/request-password-reset` | `authRoutes.ts:44-47` |
|
||||
| `POST` | `/api/auth/reset-password-with-code` | `authRoutes.ts:54-56` |
|
||||
| `POST` | `/api/auth/reset-password` | `authRoutes.ts:49-52` (legacy token-based variant) |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`users` collection**: on request, sets `passwordResetCode` + `passwordResetCodeExpires`. On submit, replaces `password`, clears reset fields, and empties `refreshTokens`.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- None.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Email**: one transactional message containing the 6-digit code.
|
||||
- **Server-side log**: `authController.ts:559` `console.log` includes the generated code in plain text — same hardening note as [[Registration Flow]].
|
||||
- **Session invalidation**: All refresh tokens cleared → all devices forced to re-login after password change. Access tokens still valid until expiry (typically minutes).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Unknown email** → always `200`, generic message. No enumeration.
|
||||
- **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).
|
||||
- **Email delivery failure** → response still `200`; user can request again.
|
||||
- **Access tokens still valid post-reset** → unavoidable with stateless JWT; mitigated by short TTL. Critical operations should re-verify password.
|
||||
|
||||
> [!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'`.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Authentication Flow]] — user re-signs-in after reset.
|
||||
- [[Registration Flow]] — same code-generation utility.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/auth/authController.ts:542-657`
|
||||
- Backend: `backend/src/services/email/emailService.ts` (`sendPasswordResetCodeEmail`)
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-reset-password-view.tsx`
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-update-password-view.tsx`
|
||||
- Frontend: `frontend/src/auth/context/jwt/action.ts:181-200`, `:261-276`
|
||||
172
04 - Flows/Payment Flow - DePay & Web3.md
Normal file
172
04 - Flows/Payment Flow - DePay & Web3.md
Normal file
@@ -0,0 +1,172 @@
|
||||
---
|
||||
title: Payment Flow - DePay & Web3
|
||||
tags: [flow, payment, web3, wagmi, walletconnect, bsc]
|
||||
related_models: ["[[Payment]]", "[[PurchaseRequest]]"]
|
||||
related_apis: ["POST /api/payment/decentralized/create", "POST /api/payment/decentralized/verify"]
|
||||
---
|
||||
|
||||
# Payment Flow — DePay & Web3 (Wallet-Direct)
|
||||
|
||||
Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]], the buyer connects their own wallet (MetaMask / WalletConnect / Coinbase Wallet) and signs an **on-chain transfer to the escrow wallet** directly. The backend then verifies the transaction against the BSC RPC.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Buyer** — owner of the wallet doing the on-chain transfer.
|
||||
- **Frontend** — `frontend/src/web3/` (wagmi provider, web3-provider context); wallet-connect UI in `frontend/src/sections/account/account-wallet-connection.tsx`; the in-checkout integration is in `frontend/src/sections/request/components/buyer-steps/payment-card.tsx` and `frontend/src/sections/request/components/buyer-steps/step-3-components/step-3-payment.tsx`.
|
||||
- **Wagmi / WalletConnect / MetaMask** — wallet stack.
|
||||
- **Backend** — `decentralizedPaymentService.ts` (intent), `BSCTransactionVerifier` (on-chain verification), `decentralizedPaymentRoutes.ts`.
|
||||
- **Blockchain (BSC)** — verified via `https://bsc-dataseed.binance.org/` JSON-RPC.
|
||||
- **MongoDB** — `payments` collection (same model as SHKeeper, different `provider` value).
|
||||
- **Socket.IO** — `payment-created`, plus the cascade events from [[Payment Flow - SHKeeper]] when verification succeeds.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS` is set on the frontend (see `frontend/src/global-config.ts`). This is the destination address — typically a custodial BSC wallet operated by the platform admin.
|
||||
- Wagmi is configured with WalletConnect projectID and supported chains (BSC mainnet `56`, optionally BSC testnet `97`).
|
||||
- Buyer has a wallet with USDT/USDC balance and a small amount of BNB for gas.
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Phase 1 — Connect wallet
|
||||
|
||||
1. Buyer hits the payment step and sees both pay options. Clicking **"Pay with wallet"** opens the WalletConnect modal via wagmi's `useConnect()`.
|
||||
2. The connection emits an `accountsChanged` event; the web3 context (`frontend/src/web3/context/web3-provider.tsx`) stores `wallet.address` and `wallet.chainId`.
|
||||
3. If `chainId !== 56` (BSC), the UI prompts a `wallet_switchEthereumChain` request.
|
||||
|
||||
### Phase 2 — Create intent on backend
|
||||
|
||||
4. Frontend POSTs `POST /api/payment/decentralized/create` with `{ purchaseRequestId, sellerOfferId, amount, fromAddress: wallet.address, token: 'USDT', network: 'bsc' }`. The backend records a `Payment` with `provider: 'other'` (or `'decentralized'` depending on enum extension), `direction: 'in'`, `status: 'pending'`, `blockchain.{network, token, sender, receiver: ESCROW_WALLET_ADDRESS}`.
|
||||
5. Response includes the **escrow wallet address** and the exact token amount (in decimals — for USDT-BEP20 that's 18 decimals; the helper `convertPaymentAmountForShkeeper` is shared from `currencyUtils.ts`).
|
||||
|
||||
### Phase 3 — Token approval (ERC-20 / BEP-20)
|
||||
|
||||
6. The frontend checks the user's current allowance via `useReadContract` / `allowance(owner, spender)` on the USDT contract.
|
||||
7. If allowance < amount, the frontend prompts an `approve(escrow, amount)` transaction. After confirmation, it proceeds to step 8.
|
||||
- Some flows skip `approve` and use **direct transfer** instead (`transfer(to, amount)` — no approval needed because the buyer is the holder, not a contract pulling from them). The codebase favours direct transfer; see usages of `web3Service.transferToken(...)` in `frontend/src/web3/web3Service.ts`.
|
||||
|
||||
### Phase 4 — On-chain transfer
|
||||
|
||||
8. Frontend calls `transfer(escrowAddress, amount)` on the USDT contract via wagmi's `useWriteContract`. The wallet popup asks the user to confirm gas.
|
||||
9. The buyer signs; the transaction is broadcast.
|
||||
10. Frontend listens via wagmi's `useWaitForTransactionReceipt({ hash })`. On `success` (1 confirmation), it captures the `transactionHash`.
|
||||
|
||||
### Phase 5 — Backend verification
|
||||
|
||||
11. Frontend POSTs `POST /api/payment/decentralized/verify` with `{ paymentId, transactionHash }`.
|
||||
12. Backend `BSCTransactionVerifier.verifyTransaction(txHash)` (`decentralizedPaymentService.ts`):
|
||||
- JSON-RPC `eth_getTransactionReceipt` against `bsc-dataseed.binance.org`.
|
||||
- Confirms `receipt.status === '0x1'` (success).
|
||||
- Computes confirmations = `current eth_blockNumber - receipt.blockNumber`.
|
||||
- Optionally decodes the `Transfer` event log to confirm `from`, `to`, and `value` match the expected payment.
|
||||
13. On success the backend:
|
||||
- Updates the `Payment`: `status = 'completed'`, `escrowState = 'funded'`, `blockchain.transactionHash`, `blockchain.confirmations`, `blockchain.confirmedAt = now`.
|
||||
- Triggers the **same cascade** as the SHKeeper webhook: mark winning offer accepted, reject others, transition request to `payment`, create chat, send notifications, emit socket events.
|
||||
14. Returns `{ status: 'confirmed', confirmations, blockNumber }`.
|
||||
|
||||
### Phase 6 — Frontend reaction
|
||||
|
||||
15. The checkout UI shows "Payment verified" with the block-explorer link (`https://bscscan.com/tx/{hash}`) and transitions to the awaiting-delivery state.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
participant W as Wallet (MetaMask/WC)
|
||||
participant FE as Frontend (wagmi)
|
||||
participant BE as Backend
|
||||
participant BC as BSC RPC
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
B->>FE: Click "Pay with wallet"
|
||||
FE->>W: connect()
|
||||
W-->>FE: { address, chainId }
|
||||
opt chainId != 56
|
||||
FE->>W: wallet_switchEthereumChain(0x38)
|
||||
end
|
||||
FE->>BE: POST /api/payment/decentralized/create
|
||||
BE->>DB: Payment.create({provider:"other", direction:"in", receiver:ESCROW})
|
||||
BE-->>FE: { paymentId, escrowAddress, amount }
|
||||
opt allowance < amount
|
||||
FE->>W: approve(escrow, amount)
|
||||
W-->>FE: tx confirmed
|
||||
end
|
||||
FE->>W: transfer(escrow, amount)
|
||||
W-->>FE: tx broadcast
|
||||
W-->>BC: signed tx
|
||||
BC-->>W: tx confirmed
|
||||
FE->>BE: POST /api/payment/decentralized/verify { paymentId, txHash }
|
||||
BE->>BC: eth_getTransactionReceipt(txHash)
|
||||
BC-->>BE: { status:0x1, blockNumber, logs }
|
||||
BE->>BC: eth_blockNumber
|
||||
BC-->>BE: currentBlock
|
||||
BE->>BE: confirmations = currentBlock - txBlock
|
||||
BE->>DB: Payment.status="completed"\nescrowState="funded"\ntx hash + confirmations
|
||||
BE->>BE: cascade (mark offer accepted, others rejected,\nrequest→payment, chat, notifications)
|
||||
BE->>IO: emit payment-completed events
|
||||
BE-->>FE: { status:"confirmed", confirmations }
|
||||
FE-->>B: "Payment verified ✓" + BscScan link
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/payment/decentralized/create` | `decentralizedPaymentRoutes.ts` |
|
||||
| `POST` | `/api/payment/decentralized/verify` | `decentralizedPaymentRoutes.ts` |
|
||||
| `GET` | `/api/payment/fetch-tx/:paymentId` | `paymentRoutes.ts` (manual rechecker) |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`payments`** — same model as the SHKeeper flow. `provider` distinguishes the source.
|
||||
- **`selleroffers`**, **`purchaserequests`**, **`chats`**, **`notifications`** — identical cascade to [[Payment Flow - SHKeeper]] (offer accepted, others rejected, request → `payment`, chat created, notifications fanned out).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`payment-created`** (admin dashboard) on intent creation.
|
||||
- **`seller-offer-update`** `'payment-completed'` and `'offer-rejected'` post-verification.
|
||||
- **`purchase-request-update`** `'status-changed'`.
|
||||
- **`new-notification`** to both parties.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **No SHKeeper involvement** — the escrow wallet is custodial; the platform admin holds the keys. Payouts from this wallet to sellers happen via [[Payout Flow]] (SHKeeper payouts API) or manual admin signing using `admin-wallet-payout.tsx` UI.
|
||||
- **Verified-wallet field** — the buyer's connected wallet is also saved against `User.profile.walletAddress` (in `WalletConnectionCard`), which is later used to refund this same wallet if the order is disputed.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Wrong network** → frontend forces a chain switch; if user refuses, transaction will fail or hit the wrong chain (escrow is BSC-only today).
|
||||
- **Insufficient gas (BNB)** → wallet rejects the tx; nothing to verify.
|
||||
- **Transaction reverted** (`receipt.status === '0x0'`) → verifier returns `failed`; backend marks `Payment.status = 'failed'`. Buyer can retry.
|
||||
- **Transaction not yet mined** at verification time → verifier returns `pending` with `error: 'Transaction not found or still pending'`. Frontend retries verification on a backoff schedule.
|
||||
- **Tx hash already used by another payment** — `blockchain.transactionHash` has a sparse index (`Payment.ts:178`); a duplicate verification attempt finds the existing payment and returns its status.
|
||||
- **Wrong amount or wrong recipient** — must be enforced by log decoding (the verifier should reject if the `Transfer` event's `value` is less than expected or `to` ≠ escrow). The current verifier only checks the receipt's `status`; tightening this is recommended.
|
||||
- **RPC throttling** — public BSC dataseed is generous but rate-limits exist; consider a dedicated RPC (Ankr, QuickNode) for production.
|
||||
- **User closes the browser before verification** — the on-chain transfer still happened. A periodic reconciliation job (`/api/payment/fetch-tx/:paymentId`) or admin tool can replay verification from the txHash.
|
||||
- **Confirmation depth** — currently 1 confirmation triggers `completed`. For larger amounts consider gating release until ≥ 12 confirmations on BSC.
|
||||
|
||||
> [!warning] Verify the event log, not just the receipt
|
||||
> A receipt status of `0x1` means the transaction did not revert — it does **not** confirm the right amount went to the right address. Decode the ERC-20 `Transfer(address,address,uint256)` event and assert `to == ESCROW_WALLET_ADDRESS` and `value >= expectedAmount`. The current implementation in `decentralizedPaymentService.ts` checks only the receipt status; harden this before accepting large payments.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Payment Flow - SHKeeper]] — sibling pay-in path; same downstream cascade.
|
||||
- [[Escrow Flow]] — funded state semantics.
|
||||
- [[Payout Flow]] — releasing the funded escrow to the seller.
|
||||
- [[Dispute Flow]] — refunds back to the buyer's verified wallet.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/payment/decentralizedPaymentService.ts`
|
||||
- Backend: `backend/src/services/payment/decentralizedPaymentRoutes.ts`
|
||||
- Backend: `backend/src/services/payment/paymentCoordinator.ts`
|
||||
- Backend: `backend/src/models/Payment.ts`
|
||||
- Frontend: `frontend/src/web3/web3Service.ts`
|
||||
- Frontend: `frontend/src/web3/context/wagmi-provider.tsx`
|
||||
- Frontend: `frontend/src/web3/context/web3-provider.tsx`
|
||||
- Frontend: `frontend/src/sections/account/account-wallet-connection.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/payment-card.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/step-3-payment.tsx`
|
||||
- Frontend: `frontend/src/global-config.ts` (`ESCROW_WALLET_ADDRESS`)
|
||||
252
04 - Flows/Payment Flow - SHKeeper.md
Normal file
252
04 - Flows/Payment Flow - SHKeeper.md
Normal file
@@ -0,0 +1,252 @@
|
||||
---
|
||||
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", "GET /api/payment/shkeeper/status/:id"]
|
||||
---
|
||||
|
||||
# Payment Flow — SHKeeper (Crypto Pay-In)
|
||||
|
||||
End-to-end **crypto pay-in** via the self-hosted [SHKeeper](https://github.com/vsys-host/shkeeper.io) gateway at `pay.amn.gg`. The buyer pays in stablecoins/crypto; SHKeeper monitors the blockchain, sends webhooks back, and the backend marks the escrow funded.
|
||||
|
||||
## Supported assets
|
||||
|
||||
Pulled from env: `SHKEEPER_NETWORKS` and `SHKEEPER_ALLOWED_TOKENS` (`shkeeperService.ts:97-98`).
|
||||
- **Networks**: `bsc`, `ethereum` (default in code).
|
||||
- **Tokens**: `USDT`, `USDC` (default). The endpoint URL is built as `https://pay.amn.gg/api/v1/{NETWORK_PREFIX}-{TOKEN}/payment_request` (`shkeeperService.ts:413-417`).
|
||||
- BSC → `BNB-USDT`, `BNB-USDC` (i.e. BEP-20).
|
||||
- Ethereum → `ETH-USDT`, `ETH-USDC` (ERC-20).
|
||||
- TRC-20 (`USDT-TRC20`) and native `BTC` are mentioned in the task brief but **not currently wired** in `shkeeperService.ts` — only BSC/ETH variants are produced from the code path. Verify SHKeeper-side configuration if those are required.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Buyer** — pays.
|
||||
- **Seller** — passive in this flow; gets notified on success.
|
||||
- **Frontend** — checkout components under `frontend/src/sections/request/components/buyer-steps/step-3-components/` and `frontend/src/sections/payment/`.
|
||||
- **Backend** — `shkeeperService.createPayInIntent` (`backend/src/services/payment/shkeeper/shkeeperService.ts:48-533`) and `shkeeperWebhook.handleShkeeperWebhook` (`backend/src/services/payment/shkeeper/shkeeperWebhook.ts`).
|
||||
- **SHKeeper gateway** (`https://pay.amn.gg`) — issues per-payment deposit addresses, watches the chain, sends webhooks.
|
||||
- **Blockchain** — BSC / Ethereum.
|
||||
- **PaymentCoordinator** (`backend/src/services/payment/paymentCoordinator.ts`) — serialises concurrent payment-status updates from multiple sources (webhook, wallet monitor, manual confirm).
|
||||
- **MongoDB** — `payments`, `purchaserequests`, `selleroffers`, `chats`, `notifications`.
|
||||
- **Redis** — `paymentRedisService` (wallet-address cache, 2 h TTL).
|
||||
- **Socket.IO** — `payment-created`, `seller-offer-update`, `purchase-request-update`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Buyer has selected an offer (or is using the template-checkout shortcut).
|
||||
- Backend env: `SHKEEPER_API_URL`, `SHKEEPER_API_KEY`, `SHKEEPER_WEBHOOK_SECRET`, `API_URL` (for the callback URL).
|
||||
- Redis is reachable (graceful degradation if not — see `app.ts:361-367`).
|
||||
- SHKeeper has wallets provisioned for each `crypto_name`.
|
||||
|
||||
## Payment state machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: createPayInIntent\n(Payment.status="pending")
|
||||
pending --> pending_partial: webhook PARTIAL\nescrowState="partial"
|
||||
pending --> completed: webhook PAID/OVERPAID\nescrowState="funded"
|
||||
pending --> failed: webhook EXPIRED/CANCELLED\nescrowState="cancelled"
|
||||
pending_partial --> completed: top-up arrives, total ≥ amount
|
||||
pending_partial --> failed: expires
|
||||
completed --> released: admin release → seller payout\n[[Payout Flow]]
|
||||
completed --> refunded: dispute resolution → buyer refund
|
||||
refunded --> [*]
|
||||
released --> [*]
|
||||
failed --> [*]
|
||||
```
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### 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? }`.
|
||||
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.
|
||||
- **Duplicate-guard #2 (template checkout)** (`:131-198`): if a recent `completed`/`confirmed` payment exists for the same buyer + template session, reuse it; otherwise dedupe pending records within the last 5 minutes.
|
||||
- **Upsert** (`:218-249`): atomic `Payment.findOneAndUpdate(filter, {$setOnInsert: {...}}, {upsert: true, new: true})` keyed by `{buyerId, purchaseRequestId, provider, direction:'in', status:'pending'}` — prevents race-condition duplicates.
|
||||
- Sets `Payment.providerPaymentId = externalId`. For template checkouts, `externalId = template-{ts}-{rand}`; otherwise it's the `Payment._id`.
|
||||
- **Wallet cache lookup** (`:421-450`): `paymentRedisService.getCachedWallet(cacheKey)` — if a wallet was allocated for the same `(amount, token, network, requestId)` within the last 2 h, reuse it. Avoids hammering SHKeeper for the same checkout.
|
||||
- **Call SHKeeper API** (`:453-475`): `POST https://pay.amn.gg/api/v1/{cryptoName}/payment_request` with header `X-Shkeeper-Api-Key`. Body: `{ external_id, fiat: 'USD', amount: '12.34', callback_url: ${API_URL}/api/payment/shkeeper/webhook }`. The HTTP call is wrapped by `shkeeperFetch` (`shkeeperHealthCheck.ts`) which trips the breaker on repeated failures.
|
||||
- **Persist response** (`:484-503`): updates `Payment.metadata.{shkeeperInvoiceId, shkeeperData, cryptoName, walletAddress}`; caches the wallet for 2 h; calls `walletMonitor.addWallet(...)` so the on-chain watcher can confirm independently (belt-and-braces against missed webhooks).
|
||||
4. Returns `{ paymentId, paymentUrl, shkeeperInvoiceId, walletAddress, amount, exchangeRate, displayName, cryptoName }`.
|
||||
5. Emits **`payment-created`** globally via `emitGlobalEvent` (`shkeeperService.ts:277-287`) so the admin dashboard sees the new pending payment in real time.
|
||||
|
||||
### Phase 2 — Buyer pays
|
||||
|
||||
6. Frontend renders a **QR code** for `${walletAddress}?amount=${amount}&token=...` and shows the exchange-rate-locked USDT amount, recalculate-after timer (`recalculate_after` from SHKeeper, typically 15 min), and a copy-to-clipboard button.
|
||||
7. Buyer scans with MetaMask / Trust Wallet / Binance App and sends the on-chain transfer.
|
||||
8. SHKeeper polls the chain, detects the deposit. When confirmations reach the threshold it marks the invoice `PAID` (or `OVERPAID` if the buyer sent extra).
|
||||
|
||||
### Phase 3 — Webhook
|
||||
|
||||
9. SHKeeper POSTs to `${API_URL}/api/payment/shkeeper/webhook` with the `ShkeeperWebhookPayload` shape (`shkeeperWebhook.ts:14-37`):
|
||||
```
|
||||
{
|
||||
external_id, crypto: "BNB-USDT", addr, fiat: "USD",
|
||||
balance_fiat, balance_crypto, paid: true,
|
||||
status: "PAID" | "PENDING" | "EXPIRED" | "CANCELLED" | "PARTIAL" | "OVERPAID",
|
||||
transactions: [{ txid, date, amount_crypto, amount_fiat, trigger: true, ... }],
|
||||
fee_percent, fee_fixed, fee_policy, overpaid_fiat
|
||||
}
|
||||
```
|
||||
10. **Signature verification** (`shkeeperWebhook.ts:84-120`): HMAC-SHA256 of the raw body with `SHKEEPER_WEBHOOK_SECRET`, header `x-shkeeper-signature` (also accepts `x-signature`, `signature`, `x-hub-signature`, `x-hub-signature-256`). Mismatch → `401` in production, allowed in dev. Length-mismatched signature → `401` (avoids `timingSafeEqual` crash).
|
||||
11. **Fallback auth** (`:122-141`): if no signature header but env requires it, the route accepts `X-Shkeeper-Api-Key` matching `SHKEEPER_API_KEY`. Otherwise returns `202` to **stop SHKeeper retries** even if rejected (idempotency principle: always 2xx unless the request itself is mangled).
|
||||
12. **DB reconnect** (`:143-155`): if Mongoose is disconnected, attempt reconnection. On failure → `202 OK` to avoid retry loop, log for investigation.
|
||||
13. **Payment lookup**: `Payment.findOne({ providerPaymentId: payload.external_id })`. If not found and the external_id looks like a template checkout, hand off to `handleTemplateCheckoutWebhook` (`templateCheckoutWebhook.ts`). Otherwise → `202 OK` with a rate-limited log.
|
||||
14. **Duplicate-webhook detection** (`:249-296`): if `metadata.shkeeperStatus`, `balance_fiat`, `paid` are identical to the previous webhook **and** less than 10 seconds have passed → return `202` (idempotent). Logged once per minute per payment.
|
||||
15. **Map SHKeeper status → internal status** (`:387-411`):
|
||||
| SHKeeper | Internal `status` | `escrowState` |
|
||||
|---|---|---|
|
||||
| `PAID` | `completed` | `funded` |
|
||||
| `OVERPAID` | `completed` | `funded` |
|
||||
| `EXPIRED`, `CANCELLED` | `failed` | `cancelled` |
|
||||
| `PARTIAL` | `pending` | `partial` |
|
||||
| `PENDING` | `pending` | — |
|
||||
16. **Extract `transactionHash`** (`:311-385`) — prefers the transaction with `trigger === true`, then falls back to the latest, then to fetching from SHKeeper's invoice endpoint if the webhook somehow arrived without transactions.
|
||||
17. **PaymentCoordinator** (`:482-507`) — `coordinatePaymentUpdate` returns false if another worker already started processing this state change, otherwise `executePaymentUpdate` writes the new status/escrowState/txHash atomically with metadata.
|
||||
18. **Cascade on PAID/OVERPAID** (`:543-714`):
|
||||
- Load `PurchaseRequest` and `SellerOffer` for this payment.
|
||||
- **Mark winning offer accepted**: `selectedOffer.status = 'accepted'; save()`.
|
||||
- **Reject all other offers**: `SellerOffer.updateMany({ purchaseRequestId, _id: { $ne: sellerOfferId } }, { status: 'rejected' })`.
|
||||
- **Promote request**: `status = 'payment'; selectedOfferId = sellerOfferId`.
|
||||
- **Create direct chat** (`chatService.createChat`, see [[Chat Flow]]).
|
||||
- **Notifications**: `notifyPaymentConfirmed` (to both parties), `notifyOfferAccepted` (winner), `notifyRequestStatusChanged` (`received_offers → payment`).
|
||||
- **Socket fan-out**: `seller-offer-update` `'payment-completed'` to winner, `'offer-rejected'` to losers (each carries `offerId`, `reason`).
|
||||
19. **Cleanup**: `simpleAutoWebhook.removePayment(external_id)` stops the simple polling fallback.
|
||||
20. Always respond `202 Accepted` (SHKeeper retries on non-2xx). `200` would cause infinite retries because SHKeeper expects `202` per its convention.
|
||||
|
||||
### Phase 4 — Frontend reaction
|
||||
|
||||
21. The buyer's checkout page subscribes to socket events and polls `GET /api/payment/shkeeper/status/{paymentId}`. When status flips to `completed`, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery).
|
||||
22. The seller's dashboard receives `seller-offer-update` `payment-completed` and surfaces the green "Order paid — start preparing" banner.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant R as Redis
|
||||
participant SK as SHKeeper (pay.amn.gg)
|
||||
participant BC as Blockchain
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
actor S as Seller
|
||||
|
||||
B->>FE: Choose offer, click "Pay"
|
||||
FE->>BE: POST /api/payment/shkeeper/create
|
||||
BE->>DB: dedupe / upsert Payment(status:"pending")
|
||||
BE->>R: getCachedWallet(amount, token, network, requestId)
|
||||
alt cache hit
|
||||
R-->>BE: cached wallet
|
||||
else cache miss
|
||||
BE->>SK: POST /api/v1/{cryptoName}/payment_request\nX-Shkeeper-Api-Key
|
||||
SK-->>BE: { id, wallet, amount, exchange_rate, ... }
|
||||
BE->>R: setCachedWallet (TTL 2h)
|
||||
BE->>BE: walletMonitor.addWallet (chain watcher)
|
||||
end
|
||||
BE->>IO: emit 'payment-created' (admin)
|
||||
BE-->>FE: { paymentId, walletAddress, amount, QR-ready data }
|
||||
FE-->>B: Render QR + countdown + copy address
|
||||
B->>BC: Send USDT/USDC to walletAddress
|
||||
BC-->>SK: deposit confirmed
|
||||
SK->>BE: POST /api/payment/shkeeper/webhook\nx-shkeeper-signature
|
||||
BE->>BE: HMAC verify
|
||||
BE->>DB: Payment.findOne({providerPaymentId})
|
||||
BE->>BE: duplicate-webhook check
|
||||
BE->>BE: PaymentCoordinator.coordinate + execute
|
||||
BE->>DB: Payment.status="completed"\nescrowState="funded"\nblockchain.transactionHash=...
|
||||
BE->>DB: SellerOffer.status="accepted" (others "rejected")
|
||||
BE->>DB: PurchaseRequest.status="payment", selectedOfferId
|
||||
BE->>DB: Chat.create (buyer + winning seller)
|
||||
BE->>IO: emit seller-{winner} 'payment-completed'
|
||||
BE->>IO: emit seller-{loser_i} 'offer-rejected'
|
||||
BE-->>SK: 202 OK
|
||||
IO-->>FE: status updated
|
||||
IO-->>S: dashboard updates
|
||||
FE-->>B: "Payment received ✓"
|
||||
```
|
||||
|
||||
## 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` |
|
||||
| `GET` | `/api/payment/shkeeper/status/:paymentId` | Frontend polling | `shkeeperRoutes.ts` |
|
||||
| `GET` | `/api/payment/fetch-tx/:paymentId` | Manual transaction lookup | `paymentRoutes.ts` |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`payments`**: insert on intent creation (`status: 'pending'`); update on each webhook (status, escrowState, blockchain.transactionHash, metadata).
|
||||
- **`payments`** (unique index `uniq_pending_shkeeper_by_buyer_session`, see `Payment.ts:181-188`): partial unique on `{buyerId, purchaseRequestId, provider:'shkeeper', direction:'in', status:'pending'}` prevents duplicate pending pay-ins.
|
||||
- **`selleroffers`**: `status` flipped (`accepted` / `rejected`) by the webhook cascade.
|
||||
- **`purchaserequests`**: `status` → `payment`, `selectedOfferId` set.
|
||||
- **`chats`**: a new `direct` chat (or reuse) — find-or-create via `ChatService.createChat`.
|
||||
- **`notifications`**: 2–N entries depending on parties.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`payment-created`** (global) — broadcast on intent creation.
|
||||
- **`seller-offer-update`** with `eventType: 'payment-completed'` → winning seller.
|
||||
- **`seller-offer-update`** with `eventType: 'offer-rejected'` → each losing seller.
|
||||
- **`purchase-request-update`** with `eventType: 'status-changed'` (via `PurchaseRequestService`) → `request-{id}`.
|
||||
- **`new-notification`** → both buyer and seller.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Redis**: `walletCache:{amount}_{token}_{network}_{requestId}` set for 2 hours.
|
||||
- **Wallet monitor**: `walletMonitor.addWallet(addr, amount, paymentId, token, network)` — the on-chain watcher polls BSC/ETH RPCs directly; if SHKeeper's webhook is lost, the monitor still flips the payment to `completed`. This is the redundancy mechanism noted in the comments.
|
||||
- **simpleAutoWebhook** (`shkeeperSimpleAuto.ts`): a poll-based fallback that asks SHKeeper for invoice status; cleaned up once a real webhook arrives.
|
||||
- **Webhook stats**: `webhookStats.recordWebhook(...)` updates an in-memory ring buffer surfaced via `webhookStats.ts` admin endpoint.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Duplicate intent submission** → reuse the existing pending payment (no new wallet). UX-safe.
|
||||
- **SHKeeper API unreachable** → `shkeeperFetch` (with circuit breaker) throws; controller returns a **demo fallback** URL (`shkeeperService.ts:520-532`). In production this is observed as a Sentry error.
|
||||
- **Webhook signature mismatch in prod** → `401`, SHKeeper retries — usually the secret has rotated; fix env and they catch up.
|
||||
- **Webhook missing both signature and API key in prod** → `202 OK` (no-op) to prevent retry storm.
|
||||
- **DB disconnected during webhook** → reconnect; on failure `202 OK` + log (consider DLQ).
|
||||
- **`PARTIAL` payment** → state held as `pending/partial`; further deposits to the same address are aggregated by SHKeeper and a new webhook arrives.
|
||||
- **`OVERPAID`** → treated as `completed/funded`; the overage stays with the platform unless an admin manually refunds (no automatic refund of overpayment today).
|
||||
- **`EXPIRED`** → `failed/cancelled`. Buyer can re-initiate; the duplicate-guard will create a fresh intent because the old one is no longer `pending`.
|
||||
- **External_id not found** → `202` with rate-limited log; common for orphaned webhooks from old tests.
|
||||
- **Webhook arrives twice within 10 s with same data** → idempotency skip → `202`.
|
||||
- **`PaymentCoordinator` deferral** → `202` with a "coordinator skipped update" log; the in-flight worker will finish the state change.
|
||||
- **Wallet address reuse** — cached for 2 h means two parallel checkouts for the same `(amount, token, network, requestId)` share one address; whichever pays first wins (acceptable since the duplicate-guard reuses the same `Payment` doc anyway).
|
||||
- **`crypto_name` mismatch** — only `BNB-*` and `ETH-*` are produced; for TRC-20, additional logic is needed in `shkeeperService.ts:415`.
|
||||
|
||||
> [!warning] Webhook returns 202 even on errors
|
||||
> The handler always responds 2xx to avoid SHKeeper's retry storm — even for unknown payments, signature failures (in non-production paths), DB errors, and unexpected exceptions. Operationally this means failed-to-process webhooks are silently swallowed unless someone tails the logs. Hook the catch-all into Sentry severity = `error` and alert on `webhookStats.errorCount`.
|
||||
|
||||
> [!tip] Manual reconciliation
|
||||
> Use `fix-transaction-hashes.js` at repo root to backfill `blockchain.transactionHash` for payments where the webhook arrived without transactions. See [[Payout Flow]] for the parallel payout-side script usage.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Purchase Request Flow]] — supplies the request being paid for.
|
||||
- [[Seller Offer Flow]] — supplies the offer being accepted.
|
||||
- [[Payment Flow - DePay & Web3]] — alternative direct-wallet route.
|
||||
- [[Escrow Flow]] — what `escrowState=funded` means downstream.
|
||||
- [[Chat Flow]] — auto-created on success.
|
||||
- [[Notification Flow]] — both parties pinged.
|
||||
- [[Payout Flow]] — pays the seller from the funded escrow.
|
||||
- [[Dispute Flow]] — escape if the order goes wrong.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts` (intent creation, ~650 lines)
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts` (webhook handler, ~750 lines)
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperHealthCheck.ts` (circuit breaker)
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperRoutes.ts`
|
||||
- Backend: `backend/src/services/payment/paymentCoordinator.ts`
|
||||
- Backend: `backend/src/services/payment/cleanupPendingPayments.ts` (periodic GC)
|
||||
- Backend: `backend/src/services/blockchain/walletMonitor.ts` (chain watcher)
|
||||
- Backend: `backend/src/services/redis/paymentRedisService.ts` (wallet cache)
|
||||
- Backend: `backend/src/models/Payment.ts`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-components/step-3-payment.tsx`
|
||||
- Frontend: `frontend/src/sections/payment/`
|
||||
133
04 - Flows/Payout Flow.md
Normal file
133
04 - Flows/Payout Flow.md
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: Payout Flow
|
||||
tags: [flow, payment, payout, shkeeper, seller]
|
||||
related_models: ["[[Payment]]"]
|
||||
related_apis: ["POST /api/payment/shkeeper/payout", "GET /api/payment/shkeeper/payout/:taskId"]
|
||||
---
|
||||
|
||||
# Payout Flow
|
||||
|
||||
How the **seller receives the escrowed crypto** once the order is complete. Two variants are implemented:
|
||||
|
||||
1. **SHKeeper Payouts API** (`shkeeperPayoutService.ts`) — the gateway signs and broadcasts on behalf of the platform.
|
||||
2. **Manual admin wallet payout** (`admin-wallet-payout.tsx`) — an admin connects their own wallet and signs the transfer; the tx hash is reported back to the backend.
|
||||
|
||||
Both result in `Payment.escrowState = 'released'` and an outgoing `Payment` record with `direction: 'out'`.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Admin** (or scheduled system trigger) — initiates the payout.
|
||||
- **Seller** — recipient, has saved their wallet address under `User.profile.walletAddress`.
|
||||
- **Backend** — `shkeeperPayoutService.createPayoutTask` and the manual confirmation routes.
|
||||
- **SHKeeper Payouts API** — `POST https://pay.amn.gg/api/v1/payout` (per SHKeeper docs).
|
||||
- **Blockchain (BSC)** — final on-chain settlement.
|
||||
- **MongoDB** — separate `Payment` document with `direction: 'out'`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The original pay-in `Payment` has `escrowState = 'funded'` (or `releasable`).
|
||||
- The seller has set `profile.walletAddress` (validated `^0x...` format).
|
||||
- The corresponding `PurchaseRequest` is in a status that allows payout (`delivered`, `confirming`, `seller_paid`, or `completed`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### SHKeeper-mediated payout
|
||||
|
||||
1. Admin (or the auto-release scheduler — not yet implemented) hits `POST /api/payment/shkeeper/payout` with `{ purchaseRequestId, sellerOfferId, buyerId, sellerId, amount, recipientAddress, token?, network? }`.
|
||||
2. Backend `shkeeperPayoutService.createPayoutTask` (`shkeeperPayoutService.ts:40-150`):
|
||||
- Validates ObjectIds and the `recipientAddress` (`startsWith('0x')`).
|
||||
- **Idempotency**: `Payment.findOne({ purchaseRequestId, sellerOfferId, sellerId, provider:'shkeeper', direction:'out', status: { $in:['pending','processing','completed'] } })` — if found, reuses it.
|
||||
- Creates a new `Payment` document with `direction: 'out'`, `escrowState: 'releasing'`, `blockchain.receiver = recipientAddress`.
|
||||
- Calls SHKeeper Payouts API (`POST /api/v1/payout`) with the body documented at <https://shkeeper.io/api/#tag/Payouts>. SHKeeper returns a `task_id`.
|
||||
- Stores `Payment.providerPaymentId = task_id`, `metadata.shkeeperTaskId = task_id`, `metadata.payoutType = 'seller-payment'`.
|
||||
3. Polling or webhook: when SHKeeper completes the payout, it pushes a webhook (or the backend polls `GET /api/v1/payout/{task_id}`) and the system flips `Payment.status = 'completed'`, `escrowState = 'released'`, populates `blockchain.transactionHash`.
|
||||
4. The original pay-in `Payment` is updated in tandem: `escrowState = 'released'`, `PurchaseRequest.status = 'seller_paid'` → `completed`.
|
||||
5. Notifications: `notifyPayoutSent` to the seller, internal admin log.
|
||||
|
||||
### Manual admin payout
|
||||
|
||||
1. Admin opens the request detail in the admin view; the admin-step component `admin-wallet-payout.tsx` shows the recipient and amount.
|
||||
2. Admin connects their wallet (`useWeb3` / `web3Service.connect()`).
|
||||
3. Admin clicks "Send payout"; wagmi triggers `transfer(recipient, amount)` on the USDT contract.
|
||||
4. After confirmation, the admin clicks "Confirm in system", which POSTs `POST /api/payment/admin/confirm-tx/:paymentId` with `{ txHash, kind: 'release' }`.
|
||||
5. Backend `confirmAdminTx(paymentId, txHash, 'release')` (`shkeeperService.ts:628-647`) sets `status: 'completed'`, `escrowState: 'released'`, `blockchain.transactionHash = txHash`.
|
||||
|
||||
### Sequence diagram (SHKeeper payout)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor A as Admin/System
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant SK as SHKeeper Payout API
|
||||
participant BC as BSC
|
||||
actor S as Seller
|
||||
|
||||
A->>BE: POST /api/payment/shkeeper/payout
|
||||
BE->>DB: Payment.create({direction:"out", escrowState:"releasing"})
|
||||
BE->>SK: POST /api/v1/payout {to, amount, crypto}
|
||||
SK-->>BE: { task_id, status:"pending" }
|
||||
BE->>DB: Payment.providerPaymentId=task_id
|
||||
SK->>BC: signed payout tx (managed wallet)
|
||||
BC-->>SK: confirmed
|
||||
SK->>BE: webhook payout-completed (or BE polls)
|
||||
BE->>DB: Payment.status="completed"\nescrowState="released"\ntxHash
|
||||
BE->>DB: pay-in Payment.escrowState="released"\nPurchaseRequest.status="seller_paid"
|
||||
BE->>S: notifyPayoutSent
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/payment/shkeeper/payout` | `shkeeperPayoutRoutes.ts` → `createPayoutTask` |
|
||||
| `GET` | `/api/payment/shkeeper/payout/:taskId` | Polls SHKeeper task status |
|
||||
| `POST` | `/api/payment/admin/confirm-tx/:paymentId` | Manual admin confirmation |
|
||||
| `GET` | `/api/payment/admin/payouts` | List payouts (admin dashboard) |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`payments`** — new outgoing document; updates to `status`, `escrowState`, `blockchain.transactionHash` as the task progresses.
|
||||
- **`payments`** (pay-in counterpart) — `escrowState = 'released'`.
|
||||
- **`purchaserequests`** — `status` advances to `seller_paid` → `completed`.
|
||||
- **`notifications`** — seller payout receipt.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`payment-status`** (admin) on each transition.
|
||||
- **`purchase-request-update`** `status-changed`.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **`fix-transaction-hashes.js`** at repo root (`backend/fix-transaction-hashes.js`) — script used to backfill missing `blockchain.transactionHash` on payouts where the SHKeeper webhook arrived without the txid (e.g. signature length mismatch in dev). Run locally with the same Mongo URI to repair stale documents. Use it as the reference for the data-fix pattern — pull recent payouts, query SHKeeper for invoice/task details, write back the hash.
|
||||
- **Hash repair** — periodic reconciliation against SHKeeper invoice GET endpoints ensures bookkeeping accuracy.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Invalid recipient address** → throws synchronously, no DB record created.
|
||||
- **SHKeeper insufficient hot-wallet balance** → SHKeeper returns an error; payout task stays `pending`, backend logs.
|
||||
- **Duplicate payout request** → idempotency: existing payment returned with no extra SHKeeper call.
|
||||
- **Payout reverted on chain** → SHKeeper marks the task `failed`; backend sets `Payment.status = 'failed'`, `escrowState = 'failed'`. Admin retries.
|
||||
- **Missing `transactionHash` after success** → use `fix-transaction-hashes.js` to backfill.
|
||||
- **Manual payout signed but never confirmed in system** → on-chain transfer happened, but `Payment.escrowState` stays `releasing`. Admin can run a reconciliation script that scans the escrow wallet's outgoing txs and matches by amount/timestamp.
|
||||
- **Seller changes wallet address mid-flight** → the saved `recipientAddress` is the snapshot taken at payout creation; subsequent profile changes do not affect in-flight payouts.
|
||||
|
||||
> [!warning] Auto-release is not yet implemented
|
||||
> Today, payouts are admin-initiated. The flow is ready for an automatic trigger when [[Delivery Confirmation Flow]] completes — implement a cron job or queue worker that scans for `PurchaseRequest.status='delivered'` and auto-creates payouts after a configurable grace period.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Escrow Flow]] — sets up the conditions under which payout is allowed.
|
||||
- [[Delivery Confirmation Flow]] — green-lights the payout.
|
||||
- [[Dispute Flow]] — can divert funds to a refund instead.
|
||||
- [[Notification Flow]] — payout receipt to seller.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperPayoutService.ts`
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperPayoutRoutes.ts`
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts:614-647` (build & confirm admin tx payload)
|
||||
- Backend: `backend/fix-transaction-hashes.js` (reconciliation script)
|
||||
- Frontend: `frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx`
|
||||
- Frontend: `frontend/src/web3/web3Service.ts`
|
||||
202
04 - Flows/Purchase Request Flow.md
Normal file
202
04 - Flows/Purchase Request Flow.md
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
title: Purchase Request Flow
|
||||
tags: [flow, marketplace, buyer, purchase-request]
|
||||
related_models: ["[[PurchaseRequest]]", "[[Category]]", "[[Address]]", "[[SellerOffer]]"]
|
||||
related_apis: ["POST /api/marketplace/purchase-requests", "GET /api/marketplace/purchase-requests", "PATCH /api/marketplace/purchase-requests/:id"]
|
||||
---
|
||||
|
||||
# Purchase Request Flow
|
||||
|
||||
A **buyer** drafts and publishes a purchase request describing what they want to source. Once published, the request becomes visible to sellers (either all sellers or a curated subset), kicking off [[Seller Offer Flow]].
|
||||
|
||||
## Actors
|
||||
|
||||
- **Buyer** — owner of the request.
|
||||
- **Frontend** — multi-step wizard at `/dashboard/request/new` (`frontend/src/app/dashboard/request/new/page.tsx`). The wizard component lives in `frontend/src/sections/request/components/request-form-wizard.tsx` and uses the step files under `frontend/src/sections/request/components/steps/` (basic info, details, budget, review) plus `buyer-steps/` for the post-publish lifecycle.
|
||||
- **Backend** — `PurchaseRequestService.createPurchaseRequest` and the marketplace controller (`backend/src/services/marketplace/marketplaceController.ts`).
|
||||
- **MongoDB** — `purchaserequests`, with population from `users` and `categories`.
|
||||
- **Socket.IO** — emits `purchase-request-update` to the `request-{id}` room and `seller-offer-update` to seller rooms.
|
||||
- **Notification service** — pushes in-app notifications to all targeted sellers.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- User is authenticated and `req.user.role === 'buyer'`.
|
||||
- At least one category exists (seeded via `seedCategories`).
|
||||
- Optional: the buyer has saved a delivery address under `/dashboard/account/addresses`.
|
||||
|
||||
## State machine
|
||||
|
||||
Status progression is enforced by `STATUS_PROGRESSION_ORDER` in `PurchaseRequestService.ts:12-26`. Moving backward is disallowed except into a terminal status.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: createPurchaseRequest()
|
||||
pending --> received_offers: first SellerOffer saved\nSellerOfferService.createOffer
|
||||
received_offers --> in_negotiation: buyer/seller chat\n(counter-offer, see [[Negotiation Flow]])
|
||||
in_negotiation --> received_offers: counter rejected
|
||||
received_offers --> payment: SHKeeper webhook PAID\n(selected offer)
|
||||
in_negotiation --> payment: same
|
||||
payment --> processing: seller acknowledges
|
||||
processing --> delivery: seller marks shipped
|
||||
delivery --> delivered: buyer enters delivery code
|
||||
delivered --> confirming: optional auto-release timer
|
||||
confirming --> completed: escrow released to seller
|
||||
completed --> finalized: ratings exchanged
|
||||
finalized --> archived: 30 days idle
|
||||
pending --> cancelled: buyer cancels (any pre-payment status)
|
||||
received_offers --> cancelled
|
||||
in_negotiation --> cancelled
|
||||
cancelled --> [*]
|
||||
archived --> [*]
|
||||
```
|
||||
|
||||
Terminal statuses: `completed`, `finalized`, `archived`, `cancelled` (`PurchaseRequestService.ts:28`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Multi-step wizard
|
||||
|
||||
1. Buyer clicks "New request" in the dashboard sidebar and lands at `/dashboard/request/new`.
|
||||
2. **Step 1 — Basic info** (`steps/request-basic-info-step.tsx`): title (5–200 chars), description (20–2000 chars), category selection (dropdown populated from `GET /api/marketplace/categories`).
|
||||
3. **Step 2 — Details** (`steps/request-details-step.tsx`): optional product link, size, color, quantity, free-form specifications (key/value pairs), AI-assisted description generation (calls `POST /api/ai/generate-description` if the user clicks the magic-wand button — see `backend/src/services/ai/`).
|
||||
4. **Step 3 — Budget** (`steps/request-budget-step.tsx`): min/max in chosen currency (default USDT), urgency (low/medium/high), preferred sellers (typeahead bound to `GET /api/users/sellers`; `"all"` means public).
|
||||
5. **Step 4 — Review** (`steps/request-review-step.tsx`): summary; user can attach a saved address (`GET /api/addresses`) or enter a one-off `deliveryInfo.address`. Upload optional attachments via `POST /api/files/upload` — returns URLs persisted into `attachments[]`.
|
||||
6. **Draft vs. publish** — The wizard always POSTs on submit; drafts are not first-class today (the local wizard state is the "draft"). The persisted record is immediately `status: "pending"` and visible to sellers.
|
||||
|
||||
### Submission
|
||||
|
||||
7. Frontend POSTs `POST /api/marketplace/purchase-requests` with the full payload (`PurchaseRequestCreateData` in `PurchaseRequestService.ts:73-106`).
|
||||
8. Backend `PurchaseRequestService.createPurchaseRequest` (`:123-188`):
|
||||
- **Duplicate-guard** (`:128-143`): rejects a request with identical `buyerId`, `title`, `description` within the last 5 minutes. Returns `Error("درخواست مشابه در ۵ دقیقه گذشته ایجاد شده است")`.
|
||||
- **Sanitise `preferredSellerIds`** (`:146-150`): drops literal `"all"` and any invalid ObjectIds.
|
||||
- **`isPublic`** is `true` when the cleaned array is empty OR the original payload included `"all"`. Public requests are visible to every active seller; private requests only to listed sellers.
|
||||
- Builds and saves the `PurchaseRequest` document with `status: "pending"`.
|
||||
9. **Notify the buyer**: `notificationService.notifyPurchaseRequestCreated()` fires asynchronously (no `await`) — an info notification appears in the buyer's bell-icon dropdown.
|
||||
10. **Fan-out to sellers** (`notifyAllSellersAboutNewRequest`, `:190-249`):
|
||||
- If `isPublic`: `User.find({ role: "seller", status: "active" })`.
|
||||
- Otherwise: only the curated `preferredSellerIds`.
|
||||
- Iterates with **50 ms stagger** between notifications to avoid overwhelming Mongo/Socket.IO.
|
||||
- For each seller: `notificationService.createNotification(...)` writes a `Notification` doc AND emits via Socket.IO (`actionUrl: /dashboard/seller/marketplace/request/{id}`).
|
||||
11. **Real-time fan-out** is also performed when sellers eventually act on the request (offers, payments) via `emitPurchaseRequestUpdate(requestId, eventType, data)` (`PurchaseRequestService.ts:53-71`) and `emitOfferUpdate` in [[Seller Offer Flow]].
|
||||
|
||||
### Visibility filter
|
||||
|
||||
When a seller hits `GET /api/marketplace/purchase-requests?sellerId=...`, `PurchaseRequestService.getPurchaseRequests` (`:251-364`) applies a per-status visibility filter:
|
||||
|
||||
| Request status | Visible to seller if |
|
||||
|---|---|
|
||||
| `pending` | `isPublic` OR seller ∈ `preferredSellerIds` |
|
||||
| `received_offers`, `in_negotiation` | seller has an offer for the request OR no offer is selected yet AND (public/preferred) |
|
||||
| `payment`, `processing`, `delivery`, `delivered`, `confirming`, `seller_paid`, `completed` | seller is the **selected** seller (their offer is `selectedOfferId`) |
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
participant FE as Frontend Wizard
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant N as NotificationService
|
||||
participant IO as Socket.IO
|
||||
actor S1 as Seller (n sellers)
|
||||
|
||||
B->>FE: Open /dashboard/request/new
|
||||
loop Steps 1-4
|
||||
B->>FE: Fill basic / details / budget / review
|
||||
FE-->>FE: Local validation
|
||||
end
|
||||
opt AI assist
|
||||
FE->>BE: POST /api/ai/generate-description
|
||||
BE-->>FE: { description }
|
||||
end
|
||||
opt attachments
|
||||
FE->>BE: POST /api/files/upload
|
||||
BE-->>FE: { url }
|
||||
end
|
||||
B->>FE: Click "Publish"
|
||||
FE->>BE: POST /api/marketplace/purchase-requests
|
||||
BE->>DB: Duplicate check (same title+desc in 5m?)
|
||||
BE->>BE: clean preferredSellerIds; compute isPublic
|
||||
BE->>DB: PurchaseRequest.create({status: "pending"})
|
||||
DB-->>BE: savedRequest
|
||||
BE->>N: notifyPurchaseRequestCreated(buyer, requestId)
|
||||
par fan-out to sellers (staggered 50ms)
|
||||
BE->>DB: User.find({role:"seller", status:"active"}) (or preferred)
|
||||
BE->>N: createNotification(seller_i, ...)
|
||||
N->>IO: emit user-{seller_i} 'new-notification'
|
||||
N->>DB: Notification.create
|
||||
end
|
||||
BE-->>FE: 201 { request }
|
||||
FE-->>B: Redirect /dashboard/buyer/requests/{id}
|
||||
IO-->>S1: 'new-notification' (sellers receive in real time)
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/marketplace/purchase-requests` | Create the request |
|
||||
| `GET` | `/api/marketplace/categories` | Step 1 dropdown |
|
||||
| `GET` | `/api/users/sellers` | Step 3 preferred-sellers typeahead |
|
||||
| `GET` | `/api/addresses` | Step 4 saved addresses |
|
||||
| `POST` | `/api/files/upload` | Attachments |
|
||||
| `POST` | `/api/ai/generate-description` | Optional AI-assisted description |
|
||||
| `GET` | `/api/marketplace/purchase-requests` | Listing (buyer's own and seller's filtered view) |
|
||||
| `GET` | `/api/marketplace/purchase-requests/:id` | Detail page (joins payment data) |
|
||||
| `PATCH` | `/api/marketplace/purchase-requests/:id` | Generic update (status, attachments, etc.) |
|
||||
| `DELETE` | `/api/marketplace/purchase-requests/:id` | Cancel (only before payment) |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`purchaserequests` collection**: full insert. Subsequent status transitions and `selectedOfferId` updates happen in [[Seller Offer Flow]], [[Payment Flow - SHKeeper]], and [[Delivery Confirmation Flow]].
|
||||
- **`notifications` collection**: one per notified seller plus one for the buyer.
|
||||
- **`users.referralStats`** is not touched at request creation.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`new-notification`** → `user-{sellerId}` for each notified seller (via `NotificationService.emitRealTimeNotification`).
|
||||
- **`purchase-request-update`** → `request-{id}` on status changes (`emitPurchaseRequestUpdate`, `PurchaseRequestService.ts:53-71`).
|
||||
- **`seller-offer-update`** → `seller-{id}` when an offer is created against this request (see [[Seller Offer Flow]]).
|
||||
- **`request-cancelled`** → `user-{buyerId}` and `user-{sellerId}` when the buyer cancels (`PurchaseRequestService.ts:671-693`).
|
||||
|
||||
## Side effects
|
||||
|
||||
- One Mongo write per notification (potentially N+1 for a large seller base — mitigated by the 50 ms stagger). For mass markets this should be batched.
|
||||
- The buyer is auto-subscribed to the `request-{id}` Socket.IO room on the detail page mount (frontend emits `join-request-room`).
|
||||
- If `urgency === "high"`, the notification message uses the high-priority template — visible in [[Notification Flow]].
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Duplicate within 5 minutes** → `400` with Persian message. Prevents double-submit on flaky networks.
|
||||
- **Invalid category ObjectId** → `400` from Mongoose validation.
|
||||
- **`preferredSellerIds` with invalid ObjectIds** → silently dropped, not an error.
|
||||
- **Empty cleaned `preferredSellerIds` + no `"all"` in original** → `isPublic` is `true` (open marketplace). This is the intended fallback.
|
||||
- **Buyer cancels after payment** → blocked by `STATUS_PROGRESSION_ORDER` (cannot move to `cancelled` from `processing`+ without admin intervention; in practice cancellations after payment must go through [[Dispute Flow]]).
|
||||
- **Notification fan-out failure for one seller** → logged and resolved as `false`; the overall request creation still succeeds.
|
||||
- **Invalid status progression on PATCH** → `400 Invalid status progression` (`PurchaseRequestService.ts:418-424`).
|
||||
- **Seller calls GET listing without `sellerId` query** → logs `"No sellerId provided - returning ALL requests!"` (`:348`) and returns the full set. Frontend always supplies `sellerId` for seller dashboards; missing it is a bug worth catching.
|
||||
|
||||
> [!tip] Status progression is forward-only
|
||||
> Once `status` reaches `payment`, you cannot put it back to `received_offers` even via PATCH. The only escape hatches are the terminal statuses (`cancelled`, `archived`, etc.) and admin tools.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Seller Offer Flow]] — sellers respond to the published request.
|
||||
- [[Negotiation Flow]] — counter-offer mechanics in `in_negotiation`.
|
||||
- [[Payment Flow - SHKeeper]] — buyer pays for the accepted offer.
|
||||
- [[Delivery Confirmation Flow]] — seller ships, buyer confirms.
|
||||
- [[Dispute Flow]] — escape hatch for failed deliveries.
|
||||
- [[Notification Flow]] — backbone of the seller fan-out.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/marketplace/PurchaseRequestService.ts`
|
||||
- Backend: `backend/src/services/marketplace/marketplaceController.ts`
|
||||
- Backend: `backend/src/services/marketplace/controllerRoutes.ts`
|
||||
- Backend: `backend/src/models/PurchaseRequest.ts`
|
||||
- Frontend: `frontend/src/app/dashboard/request/new/page.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/request-form-wizard.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/steps/` (4 step files)
|
||||
- Frontend: `frontend/src/sections/request/view/buyer-request-view.tsx`
|
||||
120
04 - Flows/Rating Flow.md
Normal file
120
04 - Flows/Rating Flow.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: Rating Flow
|
||||
tags: [flow, rating, review, moderation]
|
||||
related_models: ["[[Review]]", "[[ShopSettings]]", "[[PurchaseRequest]]"]
|
||||
related_apis: ["POST /api/marketplace/reviews", "GET /api/marketplace/reviews/:subjectType/:subjectId"]
|
||||
---
|
||||
|
||||
# Rating Flow
|
||||
|
||||
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
|
||||
|
||||
- **Buyer** (typical reviewer).
|
||||
- **Seller** (subject; can also be a reviewer in the reciprocal direction).
|
||||
- **System** — enforces uniqueness and moderation rules.
|
||||
- **Backend** — `backend/src/services/marketplace/reviewRoutes.ts`.
|
||||
- **MongoDB** — `reviews` collection (`backend/src/models/Review.ts`).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The associated `PurchaseRequest` is `completed` (or `finalized`).
|
||||
- The reviewer is the buyer of that request (for `isVerifiedBuyer` to be `true`).
|
||||
- The subject's `ShopSettings.allowSellerReviews` / `allowTemplateReviews` is not `false` (`reviewRoutes.ts:15-31`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
1. From the request detail or seller profile, the buyer clicks "Leave review". The form captures `rating` (1–5) and `comment` (≤ 1000 chars).
|
||||
2. Frontend POSTs `POST /api/marketplace/reviews` with `{ subjectType: 'seller' | 'template', subjectId, rating, comment, purchaseRequestId? }`.
|
||||
3. Backend route handler:
|
||||
- Validates payload.
|
||||
- Calls `isReviewsAllowed(subjectType, subjectId)` — checks the seller's `ShopSettings` (for sellers, look up the seller directly; for templates, look up the template's owning seller).
|
||||
- Sets `isVerifiedBuyer = true` if the user owns a `completed` purchase request from that seller.
|
||||
- Defaults `status: 'published'` (no moderation queue today).
|
||||
4. Inserts a `Review` document. Unique index `{ subjectType, subjectId, reviewerId }` prevents a user from reviewing the same subject twice (`Review.ts:34`).
|
||||
5. Aggregated stats are recomputed on read via `computeStats` (`reviewRoutes.ts:33-62`) — count, average, per-star histogram. No denormalised counter on `User` today; everything is computed at read-time.
|
||||
|
||||
## Visibility
|
||||
|
||||
- Public — anyone hitting `GET /api/marketplace/reviews/seller/:sellerId` sees `status: 'published'` reviews paginated by 10.
|
||||
- If `ShopSettings.allowSellerReviews === false` (or `allowTemplateReviews === false`), reads return `403 Reviews are disabled by seller`.
|
||||
- The seller can flag a review for moderation (planned — current statuses include `pending` and `rejected`, but no UI to flip them today; admin can update via direct DB).
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Buyer
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
|
||||
B->>FE: Open seller profile / request detail
|
||||
B->>FE: Click "Leave review", choose stars + comment
|
||||
FE->>BE: POST /api/marketplace/reviews
|
||||
BE->>DB: ShopSettings.findOne({sellerId}) → allowSellerReviews?
|
||||
alt allowed
|
||||
BE->>DB: PurchaseRequest.exists({buyer, seller, status:"completed"})?
|
||||
BE->>DB: Review.create({status:"published", isVerifiedBuyer})
|
||||
BE-->>FE: 201 { review }
|
||||
else disabled
|
||||
BE-->>FE: 403 Reviews disabled
|
||||
end
|
||||
|
||||
Note over FE: Public visitor:
|
||||
FE->>BE: GET /api/marketplace/reviews/seller/{id}
|
||||
BE->>DB: Review.find / computeStats aggregate
|
||||
BE-->>FE: { items, pagination, stats:{count, avg, histogram} }
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/marketplace/reviews` | Submit review |
|
||||
| `GET` | `/api/marketplace/reviews/:subjectType/:subjectId` | List reviews + stats |
|
||||
| `PATCH` | `/api/marketplace/reviews/:id` | Edit own review (within edit window) |
|
||||
| `DELETE` | `/api/marketplace/reviews/:id` | Delete own review |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`reviews`** — insert on submission; one document per `(subjectType, subjectId, reviewerId)`.
|
||||
- **`shopsettings`** — read-only here; the seller controls `allowSellerReviews` / `allowTemplateReviews` in their shop settings.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- None today. A `new-review` event broadcast to `user-{sellerId}` would be a useful enhancement so sellers see reviews appear live.
|
||||
|
||||
## Side effects
|
||||
|
||||
- Recompute aggregate on every list call — fine for small volumes; consider caching `stats` per subject when the review count grows.
|
||||
- Order rating field also stamps `metadata.rating` on the purchase request when the marketplace endpoint accepts ratings inline (see `routes.ts` references in `backend/src/services/marketplace/routes.ts`).
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Duplicate review** → MongoDB `E11000` from the unique index; surface as `409 Already reviewed`.
|
||||
- **Subject disabled reviews** → `403`.
|
||||
- **Reviewer not a verified buyer** → review is still allowed but `isVerifiedBuyer = false`. Display this in the UI.
|
||||
- **Rating out of 1–5** → Mongoose schema validator rejects.
|
||||
- **Comment > 1000 chars** → schema-level rejection.
|
||||
- **Seller toggles `allowSellerReviews=false` after reviews exist** → existing reviews remain stored but become unreadable via the public GET (`reviewRoutes.ts:81-83`).
|
||||
- **Spam / abuse** → no automatic moderation; admin can flip `status` to `rejected` to hide.
|
||||
|
||||
> [!tip] Verified-buyer badge
|
||||
> The `isVerifiedBuyer` flag is the most credible signal for prospective buyers. Always render a "Verified buyer" pill next to reviews where this is `true`.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Purchase Request Flow]] — precursor that makes the buyer "verified".
|
||||
- [[Seller Offer Flow]] — display average rating on offer cards.
|
||||
- [[Dispute Flow]] — a resolved dispute could trigger a review prompt; today they are independent.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/marketplace/reviewRoutes.ts`
|
||||
- Backend: `backend/src/models/Review.ts`
|
||||
- Backend: `backend/src/services/marketplace/shopSettingsController.ts` (allow flags)
|
||||
- Backend: `backend/src/services/marketplace/routes.ts` (inline rating on order completion)
|
||||
- Frontend: review components under `frontend/src/sections/account/` and seller profile views
|
||||
163
04 - Flows/Referral Flow.md
Normal file
163
04 - Flows/Referral Flow.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: Referral Flow
|
||||
tags: [flow, referral, points, growth]
|
||||
related_models: ["[[User]]", "[[PointTransaction]]", "[[LevelConfig]]"]
|
||||
related_apis: ["POST /api/points/generate-referral-code", "GET /api/points/referrals", "GET /api/points/leaderboard"]
|
||||
---
|
||||
|
||||
# Referral Flow
|
||||
|
||||
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`.
|
||||
- **MongoDB** — `users` (`referralCode`, `referredBy`, `referralStats`, `points`), `pointtransactions`, `levelconfigs`.
|
||||
- **Socket.IO** — `referral-signup` and `level-up` events.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Authenticated user (for referral-code generation and points endpoints).
|
||||
- The 8-character code is unique (alphabet excludes I/O/0/1 to avoid confusion — `PointsService.ts:13`).
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### 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`.
|
||||
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.
|
||||
- Returns it.
|
||||
4. Frontend renders the share URL `https://amn.gg/r/{code}` and a copy button.
|
||||
|
||||
### 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}`.
|
||||
6. The sign-up form reads `?ref=` and pre-fills the referral field (hidden or visible).
|
||||
|
||||
### 3. Attribution at sign-up
|
||||
|
||||
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.
|
||||
8. At this point the referee is **counted** but no points have changed hands yet. Points award on subsequent business events.
|
||||
|
||||
### 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`:
|
||||
- Transaction-scoped Mongo session.
|
||||
- `user.points.total += amount; user.points.available += amount`.
|
||||
- `PointTransaction.create({ type:'earn', source, amount, balance, metadata })`.
|
||||
- `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.
|
||||
|
||||
### 5. Redemption / payout
|
||||
|
||||
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.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor R as Referrer
|
||||
actor N as New User
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant IO as Socket.IO
|
||||
|
||||
R->>FE: Generate referral code
|
||||
FE->>BE: POST /api/points/generate-referral-code
|
||||
BE->>DB: User.findByIdAndUpdate(referralCode=...)
|
||||
BE-->>FE: { code }
|
||||
R->>R: share https://amn.gg/r/{code}
|
||||
|
||||
N->>BE: GET /r/{code}
|
||||
BE-->>N: 302 → /auth/jwt/sign-up?ref={code}
|
||||
N->>FE: Fills sign-up, completes email verification
|
||||
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'
|
||||
|
||||
Note over BE,DB: Later, when N completes a purchase
|
||||
BE->>BE: PointsService.addPoints(R, +X, 'referral', {referredUserId:N})
|
||||
BE->>DB: user.points += X; PointTransaction.create
|
||||
BE->>BE: updateUserLevel → maybe 'level-up'
|
||||
BE->>IO: emit user-{R} 'level-up'
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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.
|
||||
- **`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.
|
||||
|
||||
## 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`).
|
||||
|
||||
## 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.
|
||||
|
||||
> [!tip] Track conversion, not just sign-ups
|
||||
> `totalReferrals` is incremented on sign-up; consider also tracking `convertedReferrals` (completed purchases) to measure real growth value.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Registration Flow]] — attribution point.
|
||||
- [[Google OAuth Flow]] — also supports `referralCode`.
|
||||
- [[Notification Flow]] — `referral-signup`, `level-up`, and points events surface here.
|
||||
- [[Payment Flow - SHKeeper]] — completion of a purchase is the canonical trigger for awarding referral commission.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/points/PointsService.ts`
|
||||
- Backend: `backend/src/controllers/pointsController.ts`
|
||||
- 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/app.ts:274-278` (short-URL redirect)
|
||||
- Frontend: `/dashboard/account/referrals` view (see `frontend/src/sections/account/`)
|
||||
195
04 - Flows/Registration Flow.md
Normal file
195
04 - Flows/Registration Flow.md
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
title: Registration Flow
|
||||
tags: [flow, auth, signup, email-verification, referral]
|
||||
related_models: ["[[User]]", "[[TempVerification]]"]
|
||||
related_apis: ["POST /api/auth/register", "POST /api/auth/verify-email-code", "POST /api/auth/resend-verification"]
|
||||
---
|
||||
|
||||
# Registration Flow
|
||||
|
||||
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
|
||||
|
||||
- **Prospective User** – submits the sign-up form.
|
||||
- **Frontend** – `frontend/src/auth/view/jwt/jwt-sign-up-view.tsx`, calling `signUp()` and later `verifyEmailWithCode()` from `frontend/src/auth/context/jwt/action.ts`.
|
||||
- **Backend** – `AuthController.register` and `AuthController.verifyEmailWithCode` in `backend/src/services/auth/authController.ts`.
|
||||
- **MongoDB** – `TempVerification` collection (temporary), then `User` collection (final).
|
||||
- **Email service** – `backend/src/services/email/emailService.ts` (SMTP/transactional provider) — `sendVerificationCodeEmail()`.
|
||||
- **Socket.IO** – emits `referral-signup` to the referrer if a referral code is supplied.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The email is not already a verified `User`. If a `TempVerification` already exists, its code and metadata are **regenerated and resent** rather than throwing a conflict.
|
||||
- Outbound SMTP credentials are configured (`EMAIL_*` env vars consumed by `emailService.ts`).
|
||||
- If a `referralCode` is supplied, it does **not** need to exist for sign-up to succeed — invalid codes are silently ignored at verification time.
|
||||
|
||||
## State machine: `TempVerification → User`
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> NotStarted
|
||||
NotStarted --> TempCreated: POST /api/auth/register\nemail + role [+ ref]
|
||||
TempCreated --> TempCreated: POST /api/auth/resend-verification\n(new code, 15-min TTL)
|
||||
TempCreated --> TempExpired: 15 minutes elapse\nor verification fails
|
||||
TempExpired --> TempCreated: User clicks "Resend"
|
||||
TempCreated --> UserActive: POST /api/auth/verify-email-code\n(code + password)
|
||||
UserActive --> [*]
|
||||
note right of TempCreated
|
||||
TempVerification document holds:
|
||||
email, firstName, lastName, role,
|
||||
referralCode, code, codeExpires
|
||||
end note
|
||||
note right of UserActive
|
||||
User created with isEmailVerified=true,
|
||||
status="active"; tokens issued immediately.
|
||||
end note
|
||||
```
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Phase 1 — Submit registration
|
||||
|
||||
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.
|
||||
|
||||
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`.)
|
||||
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).
|
||||
7. **Verification code**: `authService.generateVerificationCode()` (`authService.ts:226-228`) returns a uniformly random 6-digit string.
|
||||
8. **Persistence**: A new `TempVerification` is saved with `{ email, password: '', firstName: defaults to "کاربر", lastName: defaults to "جدید", role, referralCode, emailVerificationCode, emailVerificationCodeExpires }`.
|
||||
9. **Email dispatch**: `emailService.sendVerificationCodeEmail(email, firstName, code)` is called. The email contains the 6-digit code, branding, and a 15-minute expiry notice. Failure to send is logged but the response still succeeds with `201` (the user can resend).
|
||||
10. **Response**: `{ email, message: "Verification code sent to email" }` with HTTP `201` for first-time, `200` for resend.
|
||||
11. **Frontend** transitions to the OTP screen `/auth/jwt/verify?email=...` (`frontend/src/auth/view/jwt/jwt-verify-view.tsx`).
|
||||
|
||||
### Phase 2 — Verify code and finalise
|
||||
|
||||
12. **User enters the 6-digit code** and confirms the password. The password may be re-entered here for safety.
|
||||
13. **HTTP request**: `POST /api/auth/verify-email-code` with `{ email, code, password }`.
|
||||
14. **Format guard**: `authService.isValidVerificationCode(code)` enforces `/^\d{6}$/` (`authService.ts:236-238`).
|
||||
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:
|
||||
- `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.
|
||||
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`).
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as User
|
||||
participant FE as Frontend
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant MAIL as Email Service
|
||||
participant IO as Socket.IO
|
||||
|
||||
U->>FE: Fill sign-up form (email, role, ref?, password)
|
||||
FE->>BE: POST /api/auth/register
|
||||
BE->>DB: User.findOne({ email })
|
||||
DB-->>BE: null
|
||||
BE->>DB: TempVerification.findOne({ email })
|
||||
DB-->>BE: null
|
||||
BE->>BE: code = generateVerificationCode()
|
||||
BE->>DB: TempVerification.create({...code, expires=+15m})
|
||||
BE->>MAIL: sendVerificationCodeEmail(email, firstName, code)
|
||||
MAIL-->>U: Email with 6-digit code
|
||||
BE-->>FE: 201 { email, message }
|
||||
FE-->>U: Redirect /auth/jwt/verify
|
||||
|
||||
U->>FE: Enter code + (re)password
|
||||
FE->>BE: POST /api/auth/verify-email-code { email, code, password }
|
||||
BE->>DB: TempVerification.findOne({ email, code, expires>now })
|
||||
DB-->>BE: tempVerification doc
|
||||
BE->>BE: hashPassword(password)
|
||||
BE->>DB: User.create({...isEmailVerified:true, status:active})
|
||||
opt referral present
|
||||
BE->>DB: User.findOne({ referralCode })
|
||||
DB-->>BE: referrer
|
||||
BE->>DB: referrer.referralStats.totalReferrals += 1
|
||||
BE->>IO: emit user-{refId} 'referral-signup'
|
||||
end
|
||||
BE->>DB: TempVerification.findByIdAndDelete(...)
|
||||
BE->>BE: generate tokens; push refresh
|
||||
BE-->>FE: 200 { user, tokens }
|
||||
FE->>FE: localStorage.setItem(accessToken, refreshToken)
|
||||
FE-->>U: Redirect /dashboard/{role}
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Source |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/auth/register` | `authRoutes.ts:21` → `authController.register` |
|
||||
| `POST` | `/api/auth/verify-email-code` | `authRoutes.ts:34` → `authController.verifyEmailWithCode` |
|
||||
| `POST` | `/api/auth/resend-verification` | `authRoutes.ts:36-40` → `authController.resendVerificationEmail` |
|
||||
| `GET` | `/r/:code` | `app.ts:274-278` — short-URL redirect that injects `?ref=` into the sign-up page |
|
||||
| `POST` | `/api/auth/force-verify-user` | Dev-only — `authController.forceVerifyUser` (rejects outside `NODE_ENV=development`) |
|
||||
|
||||
## 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`).
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`referral-signup`** → `user-${referrerId}` room when a referred user verifies. Payload:
|
||||
```
|
||||
{ userId, userName, userEmail, timestamp, totalReferrals }
|
||||
```
|
||||
Source: `authController.ts:423-431`.
|
||||
|
||||
## Side effects
|
||||
|
||||
- **Email**: one transactional message per `/register` and per `/resend-verification`. Content is generated by `emailService.sendVerificationCodeEmail`. Plain-text fallback included.
|
||||
- **Sentry**: errors during `User.create` or email dispatch are captured server-side.
|
||||
- **Logs**: the controller `console.log`s the generated code in **all environments** (`authController.ts:88`, `:117`, `:518`). Useful in dev; in prod the same log line ends up in CloudWatch/Sentry breadcrumbs. (Tracked as a hardening item.)
|
||||
|
||||
> [!warning] Verification code is logged server-side
|
||||
> The generated 6-digit code is `console.log`-ed by the controller even in production. Anyone with log access can take over an unverified account. Move behind `if (NODE_ENV !== 'production')`.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Email already registered (verified)** → `409 USER_EXISTS`.
|
||||
- **Email already in temp (unverified)** → `200`, code regenerated, email re-sent. User-friendly; no error.
|
||||
- **Code mismatch / expired (>15 min)** → `400 Invalid or expired verification code`. The TempVerification is **not** deleted, so the user can request a new code via "Resend".
|
||||
- **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`.
|
||||
- **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.
|
||||
|
||||
## Defaults & quirks
|
||||
|
||||
- `firstName` / `lastName` are not required by the frontend in many sign-up variants; the controller defaults them to Persian placeholders `"کاربر"` / `"جدید"` (`authController.ts:52-53`). They can be edited later under `/dashboard/account/profile`.
|
||||
- The TempVerification TTL is enforced by the `emailVerificationCodeExpires` check, not by a Mongo TTL index — expired docs remain in the collection until overwritten or manually purged.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Authentication Flow]] — the next time the user signs in.
|
||||
- [[Referral Flow]] — full points-awarding mechanics triggered here.
|
||||
- [[Google OAuth Flow]] — alternative path that bypasses `TempVerification` (Google identities are pre-verified).
|
||||
- [[Password Reset Flow]] — if the user forgets the password they set during verification.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/auth/authController.ts:33-158` (register), `:364-469` (verify), `:498-539` (resend)
|
||||
- Backend: `backend/src/services/auth/authValidation.ts` (validation rules)
|
||||
- Backend: `backend/src/models/TempVerification.ts` (temp schema)
|
||||
- Backend: `backend/src/services/email/emailService.ts` (`sendVerificationCodeEmail`)
|
||||
- Backend: `backend/src/app.ts:274-278` (short referral redirect)
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-up-view.tsx`
|
||||
- Frontend: `frontend/src/auth/view/jwt/jwt-verify-view.tsx`
|
||||
- Frontend: `frontend/src/auth/context/jwt/action.ts:121-256`
|
||||
197
04 - Flows/Seller Offer Flow.md
Normal file
197
04 - Flows/Seller Offer Flow.md
Normal file
@@ -0,0 +1,197 @@
|
||||
---
|
||||
title: Seller Offer Flow
|
||||
tags: [flow, marketplace, seller, offer]
|
||||
related_models: ["[[SellerOffer]]", "[[PurchaseRequest]]", "[[Notification]]"]
|
||||
related_apis: ["POST /api/marketplace/offers", "GET /api/marketplace/offers/request/:requestId", "PATCH /api/marketplace/offers/:id"]
|
||||
---
|
||||
|
||||
# Seller Offer Flow
|
||||
|
||||
A **seller** browses open purchase requests and submits an offer with a price, delivery time, and notes. The buyer is notified in real time and can accept (which moves the request to [[Payment Flow - SHKeeper]]) or reject.
|
||||
|
||||
## Actors
|
||||
|
||||
- **Seller** — proposes an offer.
|
||||
- **Buyer** — receives the offer in their request detail view.
|
||||
- **Frontend (seller)** — `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx` and the seller marketplace listing under `frontend/src/app/dashboard/seller/marketplace/`.
|
||||
- **Frontend (buyer)** — `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx` for the offer chooser.
|
||||
- **Backend** — `SellerOfferService` (`backend/src/services/marketplace/SellerOfferService.ts`) and controller routes.
|
||||
- **MongoDB** — `selleroffers` collection.
|
||||
- **Socket.IO** — `seller-offer-update` and `purchase-request-update` events.
|
||||
- **Notification service** — buyer notifications.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Seller is authenticated, `role === "seller"`, `status === "active"`.
|
||||
- Target purchase request exists and `status` is `pending` or `received_offers` (`SellerOfferService.ts:83-85`).
|
||||
- Seller does **not** already have an offer on this request (uniqueness enforced by `SellerOfferService.createOffer`).
|
||||
|
||||
## Offer state machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: createOffer()
|
||||
pending --> active: (optional — manual seller activation)
|
||||
pending --> withdrawn: seller withdraws (only while pending)
|
||||
pending --> rejected: another offer accepted\nor buyer rejects this one
|
||||
pending --> accepted: acceptOffer()\nor SHKeeper PAID webhook
|
||||
accepted --> [*]
|
||||
rejected --> [*]
|
||||
withdrawn --> [*]
|
||||
pending --> expired_via_withdrawn: validUntil < now\n→ markExpiredOffersAsWithdrawn (cron)
|
||||
```
|
||||
|
||||
The active enum values are `pending | accepted | rejected | withdrawn` (`SellerOfferService.ts:308`). `validUntil` expirations are converted to `withdrawn`.
|
||||
|
||||
## Step-by-step narrative
|
||||
|
||||
### Discovery
|
||||
|
||||
1. Seller opens `/dashboard/seller/marketplace`. The page hits `GET /api/marketplace/purchase-requests?sellerId={me}` and renders cards.
|
||||
2. Filtering rules: only `pending` or `received_offers` requests where the seller is public-eligible or in `preferredSellerIds` (see visibility table in [[Purchase Request Flow]]).
|
||||
3. Clicking a card navigates to `/dashboard/seller/marketplace/request/{id}`; the seller sees the buyer's description, attachments, address city/region (full address withheld), and existing offers if visible.
|
||||
|
||||
### Submission
|
||||
|
||||
4. Seller clicks "Send proposal" — opens `step-1-send-proposal.tsx`. Fields:
|
||||
- **Title** (defaults to a derivative of the request title)
|
||||
- **Description / notes**
|
||||
- **Price** (amount + currency, default USDT)
|
||||
- **Delivery time** (amount + unit: hours / days / weeks)
|
||||
- **Attachments** (optional, via `POST /api/files/upload`)
|
||||
- **Valid until** (optional expiry)
|
||||
5. Frontend POSTs `POST /api/marketplace/offers`.
|
||||
6. Backend `SellerOfferService.createOffer` (`:51-140`):
|
||||
- **Uniqueness**: `SellerOffer.findOne({ purchaseRequestId, sellerId })` — if present, throws `"شما قبلاً برای این درخواست پیشنهاد دادهاید"` (`:74`). Use `updateOffer` to amend.
|
||||
- **Status guard**: loads the `PurchaseRequest`; rejects if its status is anything other than `pending` or `received_offers`.
|
||||
- Saves the offer (`status: "pending"` by default in the schema).
|
||||
- Re-loads with `.populate('sellerId').populate('purchaseRequestId')` for the response.
|
||||
7. **Real-time fan-out** (`emitOfferUpdate`, `:24-46`): emits `seller-offer-update` to `seller-{sellerId}` so the seller's other tabs reflect the new offer instantly.
|
||||
8. **Status auto-progression**: if this is the **first** offer on a `pending` request, the request transitions to `received_offers` (`:107-122`).
|
||||
9. **Buyer notification**: `notificationService.notifyNewOfferReceived(buyerId, requestId, requestTitle, sellerName)` writes a `Notification` and emits to `user-{buyerId}`.
|
||||
10. Response: `200 { offer }` (populated).
|
||||
|
||||
### Buyer review
|
||||
|
||||
11. Buyer's request detail page (`/dashboard/buyer/requests/{id}`) joins `GET /api/marketplace/offers/request/{requestId}` — `SellerOfferService.getOffersByPurchaseRequest` returns all offers sorted by `createdAt: -1`.
|
||||
12. Each offer card shows seller name, avatar, rating (from [[Rating Flow]]), price, ETA, notes.
|
||||
13. Buyer either **negotiates** (opens chat → [[Negotiation Flow]]) or **accepts** the offer by triggering payment.
|
||||
|
||||
### Accept → Payment
|
||||
|
||||
14. The buyer's "Pay this offer" button kicks off [[Payment Flow - SHKeeper]] with `purchaseRequestId` and `sellerOfferId`. The offer is **not** immediately marked `accepted`; the SHKeeper webhook does that atomically when the on-chain payment is confirmed.
|
||||
15. On `PAID`/`OVERPAID` webhook (see `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714`):
|
||||
- The selected offer's `status` → `accepted`.
|
||||
- All other offers on the same request → `rejected` via `SellerOffer.updateMany`.
|
||||
- The purchase request: `status = "payment"`, `selectedOfferId = sellerOfferId`.
|
||||
- A direct chat is created (see [[Chat Flow]]).
|
||||
- Notifications: `notifyOfferAccepted` to the winning seller, generic rejection notifications to the others (`SellerOfferService.acceptOffer` does the same in the manual path).
|
||||
- Socket events: `seller-offer-update` `payment-completed` to the winner, `seller-offer-update` `offer-rejected` to losers (`shkeeperWebhook.ts:679-705`).
|
||||
|
||||
### Withdrawal
|
||||
|
||||
16. Seller can withdraw their `pending` offer from `/dashboard/seller/marketplace/offers/{offerId}` → `withdrawOffer` (`SellerOfferService.ts:428-443`). The DB filter `{ status: 'pending' }` means withdrawal is impossible once `accepted` or `rejected`.
|
||||
|
||||
## Sequence diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor S as Seller
|
||||
actor B as Buyer
|
||||
participant FE_S as Frontend (seller)
|
||||
participant FE_B as Frontend (buyer)
|
||||
participant BE as Backend
|
||||
participant DB as MongoDB
|
||||
participant N as NotificationService
|
||||
participant IO as Socket.IO
|
||||
|
||||
S->>FE_S: Browse /dashboard/seller/marketplace
|
||||
FE_S->>BE: GET /api/marketplace/purchase-requests?sellerId
|
||||
BE-->>FE_S: filtered list
|
||||
S->>FE_S: Open request, click "Send proposal"
|
||||
S->>FE_S: Fill price, ETA, notes; submit
|
||||
FE_S->>BE: POST /api/marketplace/offers
|
||||
BE->>DB: ensure no existing offer; check status
|
||||
BE->>DB: SellerOffer.create({status:"pending"})
|
||||
opt first offer on the request
|
||||
BE->>DB: PurchaseRequest.status = "received_offers"
|
||||
end
|
||||
BE->>N: notifyNewOfferReceived(buyer, requestId, sellerName)
|
||||
N->>IO: emit user-{buyer} new-notification
|
||||
BE->>IO: emit seller-{sellerId} 'new-offer'
|
||||
BE-->>FE_S: 200 { offer }
|
||||
IO-->>FE_B: new-notification (buyer's bell icon)
|
||||
B->>FE_B: Open request detail
|
||||
FE_B->>BE: GET /api/marketplace/offers/request/{id}
|
||||
BE-->>FE_B: offers[]
|
||||
alt Buyer accepts via payment
|
||||
B->>FE_B: Click "Pay" → starts [[Payment Flow - SHKeeper]]
|
||||
Note over BE,DB: SHKeeper webhook PAID arrives later;<br/>winning offer → accepted, others → rejected
|
||||
else Buyer negotiates
|
||||
B->>FE_B: Open chat → [[Negotiation Flow]]
|
||||
end
|
||||
```
|
||||
|
||||
## API calls
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/marketplace/offers` | Create offer |
|
||||
| `GET` | `/api/marketplace/offers/request/:requestId` | Buyer view of offers on a request |
|
||||
| `GET` | `/api/marketplace/offers/seller/:sellerId` | Seller's own offer history |
|
||||
| `GET` | `/api/marketplace/offers/:id` | Single offer details |
|
||||
| `PATCH` | `/api/marketplace/offers/:id` | Update price / ETA / notes (seller, while pending) |
|
||||
| `POST` | `/api/marketplace/offers/:id/accept` | Manual acceptance (when not via webhook) |
|
||||
| `POST` | `/api/marketplace/offers/:id/withdraw` | Seller withdraws |
|
||||
|
||||
## Database writes
|
||||
|
||||
- **`selleroffers`**: insert on create; update on accept/reject/withdraw; `updateMany` to bulk-reject other offers when one is accepted (`SellerOfferService.acceptOffer:376-388`).
|
||||
- **`purchaserequests`**: status moves to `received_offers` on first offer, then `payment` on successful payment, and `selectedOfferId` is set.
|
||||
- **`notifications`**: at least one per state change.
|
||||
|
||||
## Socket events emitted
|
||||
|
||||
- **`seller-offer-update`** with `eventType: 'new-offer'` → `seller-{sellerId}` (creator's other tabs).
|
||||
- **`purchase-request-update`** with `eventType: 'offer-updated'` → `request-{requestId}` on edits (`SellerOfferService.ts:284-288`).
|
||||
- **`seller-offer-update`** with `eventType: 'payment-completed'` to winning seller, `'offer-rejected'` to losers (emitted by the webhook handler).
|
||||
- **`new-notification`** → `user-{buyerId}` for each new offer.
|
||||
|
||||
## Side effects
|
||||
|
||||
- Triggers chat creation **only after payment** (not on offer creation) — minimises noise from speculative offers.
|
||||
- The `rejectionReason` field is set to `"Another offer was accepted by buyer"` for losers (`SellerOfferService.ts:387`).
|
||||
- Seller statistics aggregate (`getOfferStatistics`, `:446-475`) is computed on demand for dashboards; no denormalised counter on the user document.
|
||||
|
||||
## Error / edge cases
|
||||
|
||||
- **Duplicate offer by same seller** → `400` with localized error. Use `updateOffer` instead.
|
||||
- **Request status not open** → `400 "این درخواست دیگر برای پیشنهاد باز نیست"` (`:84`).
|
||||
- **Price = 0 or negative** → Mongoose validator on `SellerOffer.price.amount` rejects (`SellerOfferService.ts:55-60` logs the validation state).
|
||||
- **Seller withdraws an `accepted` offer** → blocked by the `{ status: 'pending' }` filter; returns `null`.
|
||||
- **`validUntil` in the past at creation** → schema-level validator should reject; otherwise the next `markExpiredOffersAsWithdrawn` cron run flips it to `withdrawn`.
|
||||
- **Race condition: two payments to two different offers** → unlikely (frontend disables payment buttons once one is chosen); even if both arrive, the SHKeeper webhook coordinator (`PaymentCoordinator`) is idempotent and the first PAID wins.
|
||||
- **Offer for a deleted request** → orphan; the webhook handler logs `"Purchase request not found"` and continues. Periodic cleanup should remove orphans.
|
||||
|
||||
> [!tip] Real-time UX
|
||||
> Sellers should `socket.emit('join-seller-room', myUserId)` on dashboard mount so they see `seller-offer-update` events instantly when the buyer accepts/rejects.
|
||||
|
||||
## Linked flows
|
||||
|
||||
- [[Purchase Request Flow]] — produces the requests sellers offer on.
|
||||
- [[Negotiation Flow]] — counter-offer in `in_negotiation`.
|
||||
- [[Payment Flow - SHKeeper]] — locks in the accepted offer.
|
||||
- [[Chat Flow]] — direct chat opened after payment.
|
||||
- [[Notification Flow]] — channels for offer events.
|
||||
- [[Rating Flow]] — seller's average rating displayed in the offer card.
|
||||
|
||||
## Source files
|
||||
|
||||
- Backend: `backend/src/services/marketplace/SellerOfferService.ts`
|
||||
- Backend: `backend/src/services/marketplace/marketplaceController.ts`
|
||||
- Backend: `backend/src/models/SellerOffer.ts`
|
||||
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714` (acceptance via webhook)
|
||||
- Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx`
|
||||
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx`
|
||||
- Frontend: `frontend/src/app/dashboard/seller/marketplace/`
|
||||
Reference in New Issue
Block a user