204 lines
12 KiB
Markdown
204 lines
12 KiB
Markdown
---
|
||
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
|
||
BE->>BE: 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`
|