Files
nick-doc/04 - Flows/Purchase Request Flow.md
2026-05-23 20:35:34 +03:30

12 KiB
Raw Blame History

title, tags, related_models, related_apis
title tags related_models related_apis
Purchase Request Flow
flow
marketplace
buyer
purchase-request
PurchaseRequest
Category
Address
SellerOffer
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.
  • BackendPurchaseRequestService.createPurchaseRequest and the marketplace controller (backend/src/services/marketplace/marketplaceController.ts).
  • MongoDBpurchaserequests, 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.

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 (5200 chars), description (202000 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

  1. Frontend POSTs POST /api/marketplace/purchase-requests with the full payload (PurchaseRequestCreateData in PurchaseRequestService.ts:73-106).
  2. 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".
  3. Notify the buyer: notificationService.notifyPurchaseRequestCreated() fires asynchronously (no await) — an info notification appears in the buyer's bell-icon dropdown.
  4. 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}).
  5. 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

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

Socket events emitted

  • new-notificationuser-{sellerId} for each notified seller (via NotificationService.emitRealTimeNotification).
  • purchase-request-updaterequest-{id} on status changes (emitPurchaseRequestUpdate, PurchaseRequestService.ts:53-71).
  • seller-offer-updateseller-{id} when an offer is created against this request (see Seller Offer Flow).
  • request-cancelleduser-{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 minutes400 with Persian message. Prevents double-submit on flaky networks.
  • Invalid category ObjectId400 from Mongoose validation.
  • preferredSellerIds with invalid ObjectIds → silently dropped, not an error.
  • Empty cleaned preferredSellerIds + no "all" in originalisPublic 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 PATCH400 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

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