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>
This commit is contained in:
@@ -1,20 +1,28 @@
|
||||
---
|
||||
title: PurchaseRequest
|
||||
tags: [data-model, mongoose]
|
||||
tags: [data-model, mongoose, postgres, drizzle]
|
||||
aliases: [Purchase Request, Buy Request, IPurchaseRequest]
|
||||
---
|
||||
|
||||
# PurchaseRequest
|
||||
|
||||
> **Last updated:** 2026-05-31 — `budget.currency` aligned with template/Postgres enum (`USD`, `EUR`, `IRR`, `USDT`, `USDC`); template checkout now preserves seller-owned delivery mode and overlays buyer address/email.
|
||||
> **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] Source
|
||||
> `backend/src/models/PurchaseRequest.ts:95` — schema definition
|
||||
> `backend/src/models/PurchaseRequest.ts:387` — model export
|
||||
> [!note] Sources
|
||||
> Mongo model: `backend/src/models/PurchaseRequest.ts:95` — schema definition; `:387` — model export
|
||||
> Drizzle schema: `backend/src/db/schema/purchaseRequest.ts`
|
||||
|
||||
## Schema
|
||||
## 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 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
@@ -74,12 +82,18 @@ The central buyer-side document. A `PurchaseRequest` captures what a buyer wants
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -92,13 +106,13 @@ The central buyer-side document. A `PurchaseRequest` captures what a buyer wants
|
||||
|
||||
**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
|
||||
### Virtuals
|
||||
|
||||
None defined.
|
||||
|
||||
## Indexes
|
||||
### Mongo Indexes
|
||||
|
||||
Single-field — `backend/src/models/PurchaseRequest.ts:376-381`:
|
||||
Single-field — `backend/src/models/PurchaseRequest.ts:414-419`:
|
||||
|
||||
- `{ buyerId: 1 }`
|
||||
- `{ categoryId: 1 }`
|
||||
@@ -107,26 +121,262 @@ Single-field — `backend/src/models/PurchaseRequest.ts:376-381`:
|
||||
- `{ createdAt: -1 }`
|
||||
- `{ urgency: 1 }`
|
||||
|
||||
Compound — `backend/src/models/PurchaseRequest.ts:384-385`:
|
||||
Compound — `backend/src/models/PurchaseRequest.ts:422-423`:
|
||||
|
||||
- `{ productType: 1, status: 1 }`
|
||||
- `{ categoryId: 1, productType: 1 }`
|
||||
|
||||
## Pre/Post Hooks
|
||||
### Pre/Post Hooks
|
||||
|
||||
None declared at the schema level.
|
||||
|
||||
## Instance Methods
|
||||
### Instance Methods
|
||||
|
||||
None defined.
|
||||
|
||||
## Static Methods
|
||||
### 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 1–5 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[]`, `selectedOfferId`).
|
||||
- **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
|
||||
@@ -175,7 +425,7 @@ PurchaseRequest.find({ isPublic: true, status: 'active' }).sort({ createdAt: -1
|
||||
// Sellers' eligible queue
|
||||
PurchaseRequest.find({ productType, status: 'active', categoryId });
|
||||
|
||||
// Populate offers
|
||||
// Populate offers (Mongo only — offers[] array is not in PG)
|
||||
PurchaseRequest.findById(id).populate('offers').populate('selectedOfferId');
|
||||
|
||||
// Redeem delivery code
|
||||
@@ -183,6 +433,12 @@ 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]].
|
||||
|
||||
Reference in New Issue
Block a user