Files
nick-doc/04 - Flows/Purchase Request Flow.md

233 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"]
---
> **Last updated:** 2026-05-31 — template checkout delivery/payment rail behavior added.
> [!warning] Audit — 2026-05-29
> This document was corrected against the live codebase. Key changes: status enum updated (added `pending_payment`, `active`; removed undocumented `finalized`/`archived`); urgency values expanded to include `urgent`; sellers endpoint corrected; attachment upload endpoint corrected; `request-cancelled` socket event removed (non-existent); `new-purchase-request` fan-out target corrected to shared `sellers` room; socket room join/leave events documented; description minimum corrected to 5 chars; PUT vs PATCH mismatch flagged as known bug; two frontend actions hitting non-existent backend endpoints flagged as not implemented.
# 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 --> active: request activated
pending --> pending_payment: payment initiated
pending_payment --> active: payment confirmed
active --> 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: Request Network payment confirmed\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
pending --> cancelled: buyer cancels (any pre-payment status)
active --> cancelled
received_offers --> cancelled
in_negotiation --> cancelled
cancelled --> [*]
completed --> [*]
```
Terminal statuses: `completed`, `cancelled` (`PurchaseRequestService.ts:28`).
> [!note] Statuses `finalized` and `archived` do NOT exist in the frontend `IPurchaseRequest` type and are not live statuses. They are not part of the active state machine.
## 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 (5200 chars), description (52000 chars, **minimum is 5 characters** per frontend Zod schema — not 20), 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 | urgent`), preferred sellers (typeahead bound to `GET /api/marketplace/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/marketplace/purchase-requests/:id/attachments` — 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`: emits `new-purchase-request` to the shared **`sellers` room** (all connected sellers receive it in a single emit — no per-seller iteration for the socket event itself).
- For per-seller in-app notifications (bell icon): `User.find({ role: "seller", status: "active" })` OR only the curated `preferredSellerIds`.
- Iterates with **50 ms stagger** between notification writes to avoid overwhelming Mongo.
- 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/marketplace/purchase-requests/:id/attachments
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 for DB writes)
BE->>IO: emit 'new-purchase-request' to 'sellers' room (public requests)
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-purchase-request' (sellers room) + 'new-notification' (per-user)
```
## API calls
| Method | Endpoint | Purpose |
|---|---|---|
| `POST` | `/api/marketplace/purchase-requests` | Create the request |
| `GET` | `/api/marketplace/categories` | Step 1 dropdown |
| `GET` | `/api/marketplace/sellers` | Step 3 preferred-sellers typeahead |
| `GET` | `/api/addresses` | Step 4 saved addresses |
| `POST` | `/api/marketplace/purchase-requests/:id/attachments` | Attachments upload |
| `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) |
> [!bug] ⚠️ KNOWN BUG — PUT vs PATCH mismatch
> The frontend `updatePurchaseRequest` action sends `PUT /api/marketplace/purchase-requests/:id`, but the backend only registers a `PATCH` handler for that route. The `PUT` call will receive a `404` or `405` response. The backend handler must be updated to also accept `PUT`, or the frontend action must be changed to use `PATCH`.
> [!warning] ⚠️ NOT IMPLEMENTED — Frontend actions with no backend endpoints
> The following frontend actions target backend routes that do not exist:
> - `searchPurchaseRequests` → `GET /marketplace/purchase-requests/search` — this endpoint does not exist. Use query parameters on the standard list endpoint (`GET /api/marketplace/purchase-requests?q=...`) instead.
> - `getMarketplaceStats` → `GET /marketplace/purchase-requests/stats` — this endpoint does not exist. No stats aggregation route is registered.
## Database writes
- **`purchaserequests` collection**: full insert. Subsequent status transitions and `selectedOfferId` updates happen in [[Seller Offer Flow]], [[PRD - Request Network In-House Checkout]], 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-purchase-request`** → `sellers` room for public purchase requests (shared room, single broadcast; emitted by `notifyAllSellersAboutNewRequest`).
- **`new-notification`** → `user-{sellerId}` for each notified seller (via `NotificationService.emitRealTimeNotification`).
- **`purchase-request-update`** → `request-{id}` on status changes (`emitPurchaseRequestUpdate`, `PurchaseRequestService.ts:53-71`). Cancellation emits this event with `eventType: 'status-changed'` — there is **no** separate `request-cancelled` event.
- **`seller-offer-update`** → `seller-{id}` when an offer is created against this request (see [[Seller Offer Flow]]).
### Socket room join/leave events
| Event | Direction | Emitted by |
|---|---|---|
| `join-request-room` | client → server | Buyer detail page on mount (subscribes to `request-{id}`) |
| `join-seller-room` | client → server | `useSellerMarketplaceSocket` on mount |
| `leave-seller-room` | client → server | `useSellerMarketplaceSocket` on unmount |
| `join-buyer-room` | client → server | Buyer socket hook on mount |
| `leave-buyer-room` | client → server | Buyer socket hook on unmount |
## 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"` or `urgency === "urgent"`, 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`) and admin tools.
## Linked flows
- [[RequestTemplate]] checkout — seller chooses physical vs online delivery on the template; buyer checkout collects only the required address/email details and `batch-convert` creates one [[PurchaseRequest]] per seller/template group.
- [[Seller Offer Flow]] — sellers respond to the published request.
- [[Negotiation Flow]] — counter-offer mechanics in `in_negotiation`.
- [[PRD - Request Network In-House Checkout]] — 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`