docs: sync from backend 8fc2309 — M43/M44 missing FKs + H37 dispute enums

This commit is contained in:
Siavash Sameni
2026-06-07 07:16:02 +04:00
parent a2967ec594
commit 0bb60dbc98
24 changed files with 3428 additions and 906 deletions

View File

@@ -1,150 +1,38 @@
---
title: PurchaseRequest
tags: [data-model, mongoose, postgres, drizzle]
tags: [data-model, postgres, drizzle]
aliases: [Purchase Request, Buy Request, IPurchaseRequest]
---
# PurchaseRequest
> **Last updated:** 2026-06-03added Postgres / Drizzle schema section, child-table breakdowns, migration status, and dispute/escrow hold fields present in both Mongo and PG schemas.
> **Last updated:** 2026-06-06MongoDB/Mongoose fully removed; PostgreSQL + Drizzle ORM is the only database layer (backend v2.9.12). Removed dual-write/Mongo sections; updated IDs to UUID; clarified deliveryDate nesting and paymentId absence.
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`
> PostgreSQL schema (Drizzle): `backend/src/db/schema/purchaseRequest.ts`
> Mongoose model removed in v2.9.12 — `src/models/` directory deleted.
## 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.
**Complete.** MongoDB and Mongoose are fully removed from the backend runtime. PostgreSQL + Drizzle ORM is the only database layer. No dual-write mode; all domain stores use Postgres exclusively. 19 migrations landed (00000019), 32 tables total.
---
## 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
## PostgreSQL Schema (Drizzle)
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.
The PG model normalises prior embedded subdocuments into 7 tables. The `offers[]` array is not present; [[SellerOffer]] holds `purchase_request_id` as a back-reference.
> **ID note:** All primary keys are PostgreSQL UUIDs (`.id` field, `string`). There is no `_id` / ObjectId field in runtime code. A `legacy_object_id` column exists on each table solely for backfill traceability — do not use it in application logic.
> **paymentId note:** `PurchaseRequest` does **not** have a top-level `paymentId` field. Payment records reference the purchase request via `Payment.purchaseRequestId`; to find the payment for a request, query `Payment WHERE purchase_request_id = ?`.
> **preferredSellerIds note:** Stored in the `purchase_request_preferred_sellers` junction table as UUID `seller_id` references to `users(id)` (specifically `users.pgId`). They are UUID strings, not populated document objects.
> **deliveryDate note:** `deliveryDate` (and all other delivery logistics) are nested inside the `purchase_request_delivery_info` child table (`delivery_date` column). There is no top-level `deliveryDate` field on `purchase_requests`. Use `updatePurchaseRequestDeliveryInfo()` to update it.
### Enums (PG-level)
@@ -162,8 +50,8 @@ The PG model normalises the embedded Mongo subdocuments into 7 tables. The `offe
| 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 |
| `id` | uuid PK | no | `gen_random_uuid()` | Application primary key — use this everywhere |
| `legacy_object_id` | text | yes | — | 24-char former Mongo ObjectId; partial-unique index; traceability only |
| `buyer_id` | uuid | no | — | FK → `users(id)` |
| `category_id` | uuid | no | — | FK → `categories(id)` |
| `title` | varchar(200) | no | — | |
@@ -230,7 +118,7 @@ The PG model normalises the embedded Mongo subdocuments into 7 tables. The `offe
### Table: `purchase_request_delivery_info` (1:1)
Child of `purchase_requests`. Holds all delivery logistics.
Child of `purchase_requests`. Holds all delivery logistics. **`deliveryDate` and all delivery timestamps live here, not on the parent table.** Update via `updatePurchaseRequestDeliveryInfo()`.
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
@@ -243,7 +131,7 @@ Child of `purchase_requests`. Holds all delivery logistics.
| `notes` | text | yes | — | |
| `email` | varchar(255) | yes | — | CHECK: email regex or NULL |
| `delivery_date_time` | timestamptz | yes | — | |
| `delivery_date` | date | yes | — | |
| `delivery_date` | date | yes | — | Confirmed delivery date (nested inside deliveryInfo, not top-level on PurchaseRequest) |
| `shipped_at` | timestamptz | yes | — | |
| `delivery_code` | varchar(6) | yes | — | CHECK: length = 6 or NULL |
| `delivery_code_generated_at` | timestamptz | yes | — | |
@@ -338,7 +226,7 @@ Only populated for `service` / `consultation` product types.
### Table: `purchase_request_specifications` (1:N)
Queryable `{key, value, label}` specs extracted from the Mongo embedded array.
Queryable `{key, value, label}` specs.
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
@@ -355,10 +243,12 @@ Queryable `{key, value, label}` specs extracted from the Mongo embedded array.
### Table: `purchase_request_preferred_sellers` (N:M junction)
Stores the buyer's targeted seller list. Each row is a UUID reference to `users(id)` (i.e. `users.pgId`). There are no populated document objects — only UUID strings.
| Column | PG type | Nullable | Notes |
| --- | --- | --- | --- |
| `purchase_request_id` | uuid | no | FK → `purchase_requests(id)` |
| `seller_id` | uuid | no | FK → `users(id)` |
| `seller_id` | uuid | no | FK → `users(id)` — matches `users.pgId` |
**Indexes:** composite unique `idx_pr_preferred_sellers_uq` on `(purchase_request_id, seller_id)`; `idx_pr_preferred_sellers_seller_id` on `seller_id`
@@ -366,18 +256,28 @@ Queryable `{key, value, label}` specs extracted from the Mongo embedded array.
### 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`.
- **`offers[]` not present in PG.** Query `SellerOffer WHERE purchase_request_id = ?` instead.
- **`paymentId` not present.** `PurchaseRequest` has no top-level `paymentId`. Payments reference the request; query `Payment WHERE purchase_request_id = ?`.
- **`deliveryDate` is nested.** `delivery_date` lives in `purchase_request_delivery_info`, not on the main `purchase_requests` table. Update it via `updatePurchaseRequestDeliveryInfo()`.
- **Money scale.** `budget_min` / `budget_max` use `numeric(38,18)` (project-wide crypto convention) 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.
- **`legacy_object_id`** on every table uses a partial-unique index (`WHERE NOT NULL`) for idempotent backfill upserts. Do not use in application logic.
- **Dispute / escrow hold fields** (`dispute_raised`, `dispute_raised_at`, `dispute_resolved`, `dispute_resolved_at`, `dispute_hold_reason`, `hold_until`) are escrow-critical and present on the main `purchase_requests` table.
---
## 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. Using either would cause a validation error.
---
## 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`).
- **References**: [[User]] (`buyer_id`, `preferred_sellers[].seller_id` — UUIDs, `delivery_code_used_by`, `delivery_attempts[].seller_id`), [[Category]] (`category_id`), [[SellerOffer]] (`selected_offer_id`).
- **Referenced by**: [[SellerOffer]] (`purchase_request_id`), [[Payment]] (`purchase_request_id`), [[Dispute]] (`purchase_request_id`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'PurchaseRequest'`), [[Review]] (`purchase_request_id`).
## Template Checkout Mapping
@@ -416,29 +316,33 @@ stateDiagram-v2
## Common Queries
```ts
// Buyer's open requests
PurchaseRequest.find({ buyerId, status: { $in: ['pending', 'active', 'received_offers'] } });
// Buyer's open requests (Drizzle)
db.select().from(purchaseRequests)
.where(and(eq(purchaseRequests.buyerId, buyerId), inArray(purchaseRequests.status, ['pending', 'active', 'received_offers'])));
// Public marketplace feed
PurchaseRequest.find({ isPublic: true, status: 'active' }).sort({ createdAt: -1 });
db.select().from(purchaseRequests)
.where(and(eq(purchaseRequests.isPublic, true), eq(purchaseRequests.status, 'active')))
.orderBy(desc(purchaseRequests.createdAt));
// Sellers' eligible queue
PurchaseRequest.find({ productType, status: 'active', categoryId });
db.select().from(purchaseRequests)
.where(and(eq(purchaseRequests.productType, productType), eq(purchaseRequests.status, 'active'), eq(purchaseRequests.categoryId, 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
// Offers for a request
// SELECT * FROM seller_offers WHERE purchase_request_id = $1;
// PG: find requests with live escrow hold
// Payment for a request (no paymentId on PurchaseRequest — query payments table)
// SELECT * FROM payments WHERE purchase_request_id = $1;
// Delivery info including deliveryDate
// SELECT * FROM purchase_request_delivery_info WHERE purchase_request_id = $1;
// Requests with live escrow hold
// SELECT * FROM purchase_requests WHERE hold_until IS NOT NULL AND hold_until > now();
// Preferred sellers (UUID strings)
// SELECT seller_id FROM purchase_request_preferred_sellers WHERE purchase_request_id = $1;
```
Related: [[SellerOffer]], [[Payment]], [[Chat]], [[Dispute]], [[Review]], [[RequestTemplate]], [[Category]].