Files
nick-doc/02 - Data Models/PurchaseRequest.md
Siavash Sameni d072238fe8 docs: update PG migration status, data models, architecture + add Telegram Mini App flow (v2.8.59)
- Postgres Runtime Cutover Status: 17 migrations (0000–0017), dual-write repo matrix
- Backend Architecture: dual-DB architecture, repo factory, MONGO_CONNECT_MODE modes
- Data Model Overview: 23-model index with PG table names and migration status
- User, PurchaseRequest, SellerOffer, Chat, Dispute: Drizzle schema + cutover status added
- 04 - Flows/Telegram Mini App.md: new doc covering Mini App architecture and flows
- mongo-to-pg-migration-prd.md: status block prepended with 2026-06-03 milestone tracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:30:51 +04:00

23 KiB
Raw Blame History

title, tags, aliases
title tags aliases
PurchaseRequest
data-model
mongoose
postgres
drizzle
Purchase Request
Buy Request
IPurchaseRequest

PurchaseRequest

Last updated: 2026-06-03 — added Postgres / Drizzle schema section, child-table breakdowns, migration status, and dispute/escrow hold fields present in both Mongo and PG schemas.

The central buyer-side document. A PurchaseRequest captures what a buyer wants to acquire (physical product, digital product, service, or consultation), the budget envelope, urgency, delivery details, and the entire lifecycle from creation through payment, delivery, and completion. Sellers respond by attaching SellerOffer documents; the buyer accepts one, a Payment is opened, and delivery is verified by a 6-digit code.

[!note] Sources Mongo model: backend/src/models/PurchaseRequest.ts:95 — schema definition; :387 — model export Drizzle schema: backend/src/db/schema/purchaseRequest.ts

Migration Status

DUAL-WRITE active — part of DualWriteMarketplaceRepo. Writes go to both Mongo and Postgres; reads still come from Mongo. Backfill and read-cutover are human-gated and not yet executed.


Mongo Schema

Fields

Field Type Required Default Validation Index Description
buyerId ObjectId → User yes yes Buyer that owns the request.
title String yes trim, maxlength 200 Short headline.
description String yes trim, minlength 5 (frontend), maxlength 2000 Long form description. Frontend enforces a 5-character minimum; the field is optional in the raw schema but the form will reject shorter values.
categoryId ObjectId → Category yes yes Category the request belongs to.
productType String no physical_product enum: physical_product / digital_product / service / consultation yes What kind of fulfilment is expected.
productLink String no trim, must match /^https?:\/\/.+/ Reference URL for the desired product.
size String no trim, maxlength 100 Product size.
color String no trim, maxlength 100 Product color.
brand String no trim, maxlength 100 Brand preference.
preferredSellerIds[] ObjectId → User no [] Targeted sellers for a private request.
quantity Number no 1 min 1 Unit count.
budget.min Number no min 0 Lower bound.
budget.max Number no min 0 Upper bound.
budget.currency String no USDT enum: USD / EUR / IRR / USDT / USDC Budget currency. Runtime Mongoose validation, request-template validation, and the PG budget_currency enum now share these values.
urgency String no medium enum: low / medium / high / urgent yes Buyer urgency.
status String no pending enum (13 values — see State Transitions below) yes Lifecycle state.
isPublic Boolean no true Public marketplace listing vs. private request.
tags[] String[] no trim Free-form tags.
specifications[].key String yes trim Spec key.
specifications[].value String yes trim Spec value.
specifications[].label String no trim Human label.
deliveryInfo.deliveryType String yes physical enum: physical / online Delivery channel. Direct requests are buyer-selected; template checkout inherits the seller-selected RequestTemplate delivery mode.
deliveryInfo.address String no Physical address. In template checkout this is built from the buyer's selected billing address only when the template requires physical delivery.
deliveryInfo.preferredDate Date no Buyer's target date.
deliveryInfo.notes String no Free-form notes.
deliveryInfo.deliveryAddress.name String no Recipient name.
deliveryInfo.deliveryAddress.phoneNumber String no Recipient phone.
deliveryInfo.deliveryAddress.fullAddress String no Full address string copied from checkout billing for physical template orders.
deliveryInfo.deliveryAddress.addressType String no e.g. Home / Office.
deliveryInfo.email String no email regex Buyer receiving email for digital/online template delivery.
deliveryInfo.sellerDeliveryInfo.estimatedDeliveryDate Date no Seller's ETA date.
deliveryInfo.sellerDeliveryInfo.estimatedDeliveryTime String no Seller's ETA time.
deliveryInfo.sellerDeliveryInfo.trackingNumber String no Carrier tracking.
deliveryInfo.sellerDeliveryInfo.deliveryNotes String no Notes from seller.
deliveryInfo.sellerDeliveryInfo.shippingMethod String no Method label.
deliveryInfo.sellerDeliveryInfo.downloadLink String no Download URL for digital products.
deliveryInfo.sellerDeliveryInfo.digitalFiles[] String[] no Digital file URLs.
deliveryInfo.deliveryDateTime Date no Confirmed delivery datetime.
deliveryInfo.deliveryDate Date no Confirmed delivery date.
deliveryInfo.shippedAt Date no Timestamp of shipment.
deliveryInfo.deliveryCode String no trim, length 6 6-digit handoff code.
deliveryInfo.deliveryCodeGeneratedAt Date no When code was issued.
deliveryInfo.deliveryCodeExpiresAt Date no When code expires.
deliveryInfo.deliveryCodeUsed Boolean no false Whether the code has been redeemed.
deliveryInfo.deliveryCodeUsedAt Date no When it was redeemed.
deliveryInfo.deliveryCodeUsedBy ObjectId → User no Seller that redeemed.
deliveryInfo.deliveredAt Date no Final delivery timestamp.
deliveryInfo.deliveryAttempts[].sellerId ObjectId → User yes Seller making the attempt.
deliveryInfo.deliveryAttempts[].attemptedAt Date no Date.now When attempted.
deliveryInfo.deliveryAttempts[].success Boolean yes Whether it succeeded.
deliveryInfo.deliveryAttempts[].code String no Code entered (only stored on success).
serviceInfo.duration Number no min 0.5 Hours, only for service/consultation.
serviceInfo.sessionType String no enum: online / in_person / hybrid Service session type.
serviceInfo.location String no trim, maxlength 200 Service location.
serviceInfo.requirements[] String[] no trim Pre-requisites.
attachments[] String[] no Attached file URLs.
offers[] ObjectId → SellerOffer no Offers received. Dropped in PG — query SellerOffer WHERE purchase_request_id = ? instead.
selectedOfferId ObjectId → SellerOffer no null Accepted offer.
rating Number no null min 1, max 5 Buyer's post-delivery rating.
feedback String no null maxlength 1000 Buyer's feedback text.
deliveryConfirmed Boolean no false Buyer confirmation flag.
deliveryConfirmedAt Date no null Confirmation timestamp.
disputeRaised Boolean no false Escrow: whether a dispute has been raised.
disputeRaisedAt Date no null When the dispute was raised.
disputeResolved Boolean no false Escrow: whether dispute is resolved.
disputeResolvedAt Date no null When it was resolved.
disputeHoldReason String no null Human-readable hold reason.
holdUntil Date no null Escrow hold expiry; partial index in PG for expiry sweeps.
metadata.source String no manual enum: manual / template / api Where the request came from.
metadata.templateId String no trim Originating RequestTemplate id.
metadata.version String no trim Schema version.
createdAt Date auto yes (desc) Mongoose timestamp.
updatedAt Date auto Mongoose timestamp.

Status enum — all valid values

pending_payment · pending · active · received_offers · in_negotiation · payment · processing · delivery · delivered · confirming · completed · seller_paid · cancelled

Note: finalized and archived are not valid status values and do not appear in the IPurchaseRequest frontend type or the Mongoose schema enum. Using either would cause a validation error.

Virtuals

None defined.

Mongo Indexes

Single-field — backend/src/models/PurchaseRequest.ts:414-419:

  • { buyerId: 1 }
  • { categoryId: 1 }
  • { productType: 1 }
  • { status: 1 }
  • { createdAt: -1 }
  • { urgency: 1 }

Compound — backend/src/models/PurchaseRequest.ts:422-423:

  • { productType: 1, status: 1 }
  • { categoryId: 1, productType: 1 }

Pre/Post Hooks

None declared at the schema level.

Instance Methods

None defined.

Static Methods

None defined.


Postgres / Drizzle Schema

Source: backend/src/db/schema/purchaseRequest.ts

The PG model normalises the embedded Mongo subdocuments into 7 tables. The offers[] array is dropped; SellerOffer holds purchase_request_id as a back-reference.

Enums (PG-level)

Enum name Values
purchase_request_status pending_payment, pending, active, received_offers, in_negotiation, payment, processing, delivery, delivered, confirming, completed, cancelled, seller_paid
product_type physical_product, digital_product, service, consultation
request_urgency low, medium, high, urgent
delivery_type physical, online
service_session_type online, in_person, hybrid
pr_metadata_source manual, template, api
budget_currency USD, EUR, IRR, USDT, USDC

Table: purchase_requests (main)

Column PG type Nullable Default Notes
id uuid PK no gen_random_uuid()
legacy_object_id text yes 24-char Mongo ObjectId; partial-unique index
buyer_id uuid no FK → users(id)
category_id uuid no FK → categories(id)
title varchar(200) no
description text no
product_type enum yes physical_product
product_link varchar(2000) yes CHECK: ^https?://.+
size varchar(100) yes
color varchar(100) yes
brand varchar(100) yes
quantity integer yes 1 CHECK ≥ 1
budget_min numeric(38,18) yes CHECK ≥ 0
budget_max numeric(38,18) yes CHECK ≥ 0
budget_currency enum yes USDT
urgency enum no medium
status enum no pending 13-value escrow-critical enum
is_public boolean yes true
tags text[] yes '{}'
attachments text[] yes '{}'
selected_offer_id uuid yes FK → seller_offers(id)
rating smallint yes CHECK 15 or NULL
feedback text yes CHECK length ≤ 1000 or NULL
delivery_confirmed boolean yes false
delivery_confirmed_at timestamptz yes
dispute_raised boolean no false
dispute_raised_at timestamptz yes
dispute_resolved boolean no false
dispute_resolved_at timestamptz yes
dispute_hold_reason text yes
hold_until timestamptz yes Partial index WHERE NOT NULL
metadata_source enum yes manual
metadata_template_id varchar(100) yes
metadata_version varchar(50) yes
created_at timestamptz no now()
updated_at timestamptz no now()

Indexes on purchase_requests:

Index Type Columns / condition
idx_pr_buyer_id btree buyer_id
idx_pr_category_id btree category_id
idx_pr_product_type btree product_type
idx_pr_status btree status
idx_pr_created_at btree created_at
idx_pr_urgency btree urgency
purchase_requests_legacy_object_id_uq partial-unique legacy_object_id WHERE NOT NULL
idx_pr_product_type_status btree (product_type, status)
idx_pr_category_product_type btree (category_id, product_type)
idx_pr_hold_until partial btree hold_until WHERE NOT NULL
idx_pr_dispute_raised partial btree dispute_raised WHERE dispute_raised = true

CHECK constraints on purchase_requests:

Name Expression
pr_rating_ck rating IS NULL OR (rating >= 1 AND rating <= 5)
pr_feedback_len_ck feedback IS NULL OR length(feedback) <= 1000
pr_quantity_min_ck quantity IS NULL OR quantity >= 1
pr_budget_min_ck budget_min IS NULL OR budget_min >= 0
pr_budget_max_ck budget_max IS NULL OR budget_max >= 0
pr_product_link_ck product_link IS NULL OR product_link ~ '^https?://.+'

Table: purchase_request_delivery_info (1:1)

Child of purchase_requests. Holds all delivery logistics.

Column PG type Nullable Default Notes
id uuid PK no random
legacy_object_id text yes Parent PR's legacy ObjectId for traceability
purchase_request_id uuid UNIQUE no FK → purchase_requests(id) CASCADE
delivery_type enum no physical
address varchar(500) yes
preferred_date timestamptz yes
notes text yes
email varchar(255) yes CHECK: email regex or NULL
delivery_date_time timestamptz yes
delivery_date date yes
shipped_at timestamptz yes
delivery_code varchar(6) yes CHECK: length = 6 or NULL
delivery_code_generated_at timestamptz yes
delivery_code_expires_at timestamptz yes
delivery_code_used boolean yes false
delivery_code_used_at timestamptz yes
delivery_code_used_by uuid yes FK → users(id)
delivered_at timestamptz yes
created_at timestamptz no now()
updated_at timestamptz no now()

Indexes: idx_pr_delivery_info_pr_id on purchase_request_id

CHECK constraints: pr_di_delivery_code_len_ck (length = 6 or NULL), pr_di_email_fmt_ck (email regex)


Table: purchase_request_delivery_address (1:1 under delivery_info)

Column PG type Nullable Notes
id uuid PK no
legacy_object_id text yes
delivery_info_id uuid UNIQUE no FK → purchase_request_delivery_info(id) CASCADE
recipient_name varchar(200) yes
phone_number varchar(20) yes
full_address text yes
address_type varchar(50) yes e.g. Home / Office

Index: idx_pr_delivery_addr_info_id on delivery_info_id


Table: purchase_request_seller_delivery_info (1:1 under delivery_info)

Column PG type Nullable Default Notes
id uuid PK no random
legacy_object_id text yes
delivery_info_id uuid UNIQUE no FK → purchase_request_delivery_info(id) CASCADE
estimated_delivery_date timestamptz yes
estimated_delivery_time varchar(50) yes
tracking_number varchar(100) yes
delivery_notes text yes
shipping_method varchar(100) yes
download_link varchar(2000) yes
digital_files text[] yes '{}'
created_at timestamptz no now()
updated_at timestamptz no now()

Index: idx_pr_seller_di_info_id on delivery_info_id


Table: delivery_attempts (1:N under delivery_info)

Append-only audit log of code-entry attempts.

Column PG type Nullable Default Notes
id uuid PK no random
delivery_info_id uuid no FK → purchase_request_delivery_info(id) CASCADE
seller_id uuid no FK → users(id)
attempted_at timestamptz no now()
success boolean no
code varchar(100) yes Only stored on successful attempts

Indexes: idx_delivery_attempts_info_id, idx_delivery_attempts_seller_id, idx_delivery_attempts_success


Table: purchase_request_service_info (1:1)

Only populated for service / consultation product types.

Column PG type Nullable Default Notes
id uuid PK no random
legacy_object_id text yes
purchase_request_id uuid UNIQUE no FK → purchase_requests(id) CASCADE
duration numeric(5,2) yes CHECK ≥ 0.5
session_type enum yes online / in_person / hybrid
location varchar(200) yes
requirements text[] yes '{}'
created_at timestamptz no now()
updated_at timestamptz no now()

Index: idx_pr_service_info_pr_id CHECK: pr_si_duration_min_ck (duration IS NULL OR duration >= 0.5)


Table: purchase_request_specifications (1:N)

Queryable {key, value, label} specs extracted from the Mongo embedded array.

Column PG type Nullable Default Notes
id uuid PK no random
purchase_request_id uuid no FK → purchase_requests(id) CASCADE
key varchar(255) no
value text no
label varchar(255) yes
position integer no 0 Preserves array order for round-trip fidelity

Indexes: idx_pr_specs_pr_id, idx_pr_specs_key, partial-unique purchase_request_specifications_request_key_uq on (purchase_request_id, key)


Table: purchase_request_preferred_sellers (N:M junction)

Column PG type Nullable Notes
purchase_request_id uuid no FK → purchase_requests(id)
seller_id uuid no FK → users(id)

Indexes: composite unique idx_pr_preferred_sellers_uq on (purchase_request_id, seller_id); idx_pr_preferred_sellers_seller_id on seller_id


Design Notes

  • offers[] dropped in PG. The Mongo offers[] array is not migrated. Query SellerOffer WHERE purchase_request_id = ? instead.
  • Money scale. budget_min / budget_max use numeric(38,18) (project-wide crypto convention) rather than the numeric(15,8) suggested in the migration guide, for consistency with Payment and FundsLedgerEntry.
  • tags / attachments stored as text[] (not JSONB) to enable ANY() array queries without a child table.
  • legacy_object_id on every table uses a partial-unique index (WHERE NOT NULL) for idempotent backfill upserts.
  • Dispute / escrow hold fields (dispute_raised, dispute_raised_at, dispute_resolved, dispute_resolved_at, dispute_hold_reason, hold_until) are present in both the Mongo interface (IPurchaseRequest) and the PG main table. They were added to the Mongo schema before the PG migration and are considered escrow-critical.

Relationships

  • References: User (buyerId, preferredSellerIds[], deliveryInfo.deliveryCodeUsedBy, deliveryInfo.deliveryAttempts[].sellerId), Category (categoryId), SellerOffer (offers[] Mongo only, selectedOfferId).
  • Referenced by: SellerOffer (purchaseRequestId), Payment (purchaseRequestId), Dispute (purchaseRequestId), Chat (relatedTo.id when relatedTo.type === 'PurchaseRequest'), Review (purchaseRequestId).

Template Checkout Mapping

When a buyer converts a RequestTemplate, the seller's template remains authoritative for delivery mode:

  • physical templates require a buyer billing/delivery address in checkout. The generated request stores both deliveryInfo.address and deliveryInfo.deliveryAddress.
  • online templates require a buyer email in checkout. The generated request stores it in deliveryInfo.email.
  • Mixed carts can produce multiple requests with different delivery modes; the checkout UI asks for the union of required buyer details.

State Transitions

stateDiagram-v2
    [*] --> pending_payment
    [*] --> pending
    pending_payment --> pending : payment confirmed
    pending --> active : published
    active --> received_offers : first offer
    received_offers --> in_negotiation : buyer engages
    in_negotiation --> payment : offer accepted
    payment --> processing : payment captured
    processing --> delivery : shipped
    delivery --> delivered : handed over
    delivered --> confirming : code redeemed
    confirming --> completed : buyer confirms
    completed --> seller_paid : payout released
    pending --> cancelled
    active --> cancelled
    received_offers --> cancelled
    in_negotiation --> cancelled
    completed --> [*]
    seller_paid --> [*]
    cancelled --> [*]

Common Queries

// Buyer's open requests
PurchaseRequest.find({ buyerId, status: { $in: ['pending', 'active', 'received_offers'] } });

// Public marketplace feed
PurchaseRequest.find({ isPublic: true, status: 'active' }).sort({ createdAt: -1 });

// Sellers' eligible queue
PurchaseRequest.find({ productType, status: 'active', categoryId });

// Populate offers (Mongo only — offers[] array is not in PG)
PurchaseRequest.findById(id).populate('offers').populate('selectedOfferId');

// Redeem delivery code
PurchaseRequest.findOneAndUpdate(
  { _id: id, 'deliveryInfo.deliveryCode': code, 'deliveryInfo.deliveryCodeUsed': false },
  { $set: { 'deliveryInfo.deliveryCodeUsed': true, 'deliveryInfo.deliveryCodeUsedAt': new Date() } }
);

// PG: offers for a request
// SELECT * FROM seller_offers WHERE purchase_request_id = $1;

// PG: find requests with live escrow hold
// SELECT * FROM purchase_requests WHERE hold_until IS NOT NULL AND hold_until > now();

Related: SellerOffer, Payment, Chat, Dispute, Review, RequestTemplate, Category.