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,95 +1,29 @@
---
title: Chat
tags: [data-model, mongoose, postgres]
tags: [data-model, postgres, drizzle]
aliases: [Conversation, IChat, IMessage]
---
# Chat
> **Last updated:** 2026-06-03added Postgres/Drizzle schema section; migration status clarified.
> **Last updated:** 2026-06-06MongoDB fully removed; PostgreSQL + Drizzle is the sole data layer (backend v2.9.12).
Conversation container with embedded messages. Used for buyer-seller direct chats, group chats, and support tickets. Each chat carries a list of participants, an embedded `messages[]` array (with reactions, replies, edit history), a denormalised `lastMessage` snapshot for list views, and per-user `unreadCounts`. A chat can be linked to any other entity through the `relatedTo` discriminator (currently `PurchaseRequest`, `SellerOffer`, or `Transaction`).
> [!note] Source
> `backend/src/models/Chat.ts:130` — chat schema definition
> `backend/src/models/Chat.ts:69` — message subdocument schema
> `backend/src/models/Chat.ts:348` — model export
> `backend/src/db/schema/chat.ts` — Drizzle/Postgres schema
> `backend/src/db/schema/chat.ts` — PostgreSQL schema (Drizzle)
> `backend/src/repositories/drizzle/DrizzleChatRepo.ts` — repository implementation
> [!warning] Embedded messages
> Messages live inside the chat document. Very long-running chats can grow past the 16 MB document limit. Treat this as a known constraint of the current schema.
> [!warning] Embedded messages (JSONB)
> Messages and participants are stored as JSONB arrays inside the `chats` table (`messages jsonb`, `participants jsonb`), not as separate relational child tables. Very long-running chats can accumulate large blobs. Chat normalization (JSONB → relational child tables) is a **future improvement**, not yet done.
> [!warning] `relatedTo` is NOT set via `POST /api/chat`
> Although `relatedTo` exists in the schema, it is **not accepted** by the `POST /api/chat` create endpoint. Purchase-request linkage is established server-side through the dedicated `POST /api/chat/purchase-request`, not by passing `relatedTo` to the generic create endpoint.
> [!danger] Migration status — DUAL-WRITE, reads still on Mongo
> Chat writes go to **both** MongoDB and Postgres (via `DrizzleChatRepo`). However, **all reads still come from MongoDB**. The Postgres `chats` table is a conservative shim: `participants` and `messages` are stored as JSONB blobs, not normalised child tables. Full normalisation (splitting messages into a separate table with proper threading) is a **known open blocker** for the Mongo → PG read cutover. Do not assume PG data is queryable relationally until that work is complete.
## Schema — Chat (MongoDB / Mongoose)
| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `type` | String | yes | `direct` | enum: `direct` / `group` / `support` | yes | Conversation type. |
| `name` | String | no | — | maxlength 100 | — | Display name (group chats). |
| `description` | String | no | — | maxlength 500 | — | Optional description. |
| `participants[].userId` | ObjectId → [[User]] | yes | — | — | yes | Member id. |
| `participants[].role` | String | no | `member` | enum: `member` / `admin` / `owner` | — | Member role. |
| `participants[].joinedAt` | Date | no | `Date.now` | — | — | Join time. |
| `participants[].lastSeen` | Date | no | — | — | — | Last activity. |
| `participants[].leftAt` | Date | no | — | — | — | Set when the participant is removed (soft removal). |
| `participants[].isActive` | Boolean | no | `true` | — | — | Still a participant. Set to `false` on soft removal (subdocument is kept). |
| `messages[]` | Subdocument[] | no | `[]` | — | yes (`messages.timestamp`) | Embedded messages. |
| `relatedTo.type` | String | no | — | enum: `PurchaseRequest` / `SellerOffer` / `Transaction` | yes (compound) | Linked entity kind. **Not accepted via `POST /api/chat`** — set only via `POST /api/chat/purchase-request`. |
| `relatedTo.id` | ObjectId | no | — | — | yes (compound) | Linked entity id. |
| `lastMessage.content` | String | no | — | — | — | Snapshot for list views. |
| `lastMessage.senderId` | ObjectId → [[User]] | no | — | — | — | Last sender. |
| `lastMessage.timestamp` | Date | no | — | — | — | Last message time. |
| `lastMessage.messageType` | String | no | — | — | — | Last message type. |
| `unreadCounts[].userId` | ObjectId → [[User]] | no | — | — | — | User the counter belongs to. |
| `unreadCounts[].count` | Number | no | `0` | — | — | Number of unread messages. |
| `settings.isArchived` | Boolean | no | `false` | — | — | Archived flag. |
| `settings.isMuted` | Boolean | no | `false` | — | — | Muted flag. |
| `settings.mutedUntil` | Date | no | — | — | — | Mute expiry. |
| `settings.notifications` | Boolean | no | `true` | — | — | Per-chat notification toggle. |
| `metadata.createdBy` | ObjectId → [[User]] | yes | — | — | — | Original creator. |
| `metadata.createdAt` | Date | no | `Date.now` | — | — | Created timestamp. |
| `metadata.updatedAt` | Date | no | `Date.now` | — | — | Touched by pre-save. |
| `metadata.lastActivity` | Date | no | `Date.now` | — | yes (desc) | Sort key for chat lists. |
> [!note] No top-level `timestamps`
> Unlike most models, this schema does not pass `{ timestamps: true }`. It uses its own `metadata.createdAt` / `metadata.updatedAt` instead, maintained by the pre-save hook.
> [!note] Soft removal of participants
> Removing a participant (via `DELETE /api/chat/:id/participants/:participantId`) does **not** delete the subdocument. It is a soft removal: `isActive` is set to `false` and `leftAt` is timestamped, preserving message attribution and history.
## Schema — Message (embedded, MongoDB)
| Field | Type | Required | Default | Validation | Description |
| --- | --- | --- | --- | --- | --- |
| `senderId` | ObjectId → [[User]] | yes | — | — | Author. |
| `senderType` | String | no | `User` | — | Currently fixed. |
| `content` | String | yes | — | **maxlength 5000** | Message body. Enforced at both schema and controller. |
| `messageType` | String | no | `text` | enum: `text` / `image` / `file` / `system` | Body kind. |
| `fileUrl` | String | no | — | — | If file/image. |
| `fileName` | String | no | — | — | Original filename. |
| `fileSize` | Number | no | — | — | Bytes. |
| `timestamp` | Date | no | `Date.now` | — | Sent time. |
| `isRead` | Boolean | no | `false` | — | Read flag. |
| `isEdited` | Boolean | no | `false` | — | Edited flag. |
| `editedAt` | Date | no | — | — | When edited. |
| `deletedAt` | Date | no | — | — | Set on soft-delete; `content` is cleared but the subdocument is kept. |
| `replyTo` | ObjectId | no | — | — | Reply target message id. |
| `reactions[].userId` | ObjectId → [[User]] | no | — | — | Reacting user. |
| `reactions[].reaction` | String | no | — | maxlength 10 | Emoji. |
> [!note] Messages are soft-deleted
> Deleting a message sets `deletedAt` and clears `content` (the body becomes empty). The message subdocument is **not** physically removed from `messages[]`, and a `message-deleted` socket event is emitted.
## Schema — `chats` table (Postgres / Drizzle)
## Schema — `chats` table (PostgreSQL / Drizzle)
> Source: `backend/src/db/schema/chat.ts`
> [!warning] Conservative JSONB shim — not normalised
> Unlike most other migrated tables, participants and messages are stored as **JSONB blobs** (`ChatParticipant[]` and `ChatMessage[]`), not as separate relational child tables. This was a deliberate trade-off to unblock dual-write without committing to a normalisation design. The normalised schema (separate `chat_messages` and `chat_participants` tables with proper FKs and threading support) is the **primary blocker** for cutting reads over to Postgres.
PostgreSQL is the **only** database layer. MongoDB and Mongoose have been completely removed from the backend runtime. Participants and messages are stored as **JSONB blobs** (`ChatParticipant[]` and `ChatMessage[]`) inside the `chats` table. Chat normalization (splitting messages and participants into separate relational child tables with proper FKs and threading support) is a known future improvement.
### Enums (declared in `_enums.ts`)
@@ -104,13 +38,13 @@ Conversation container with embedded messages. Used for buyer-seller direct chat
| Column | PG type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | NOT NULL | `gen_random_uuid()` | Primary key |
| `legacy_object_id` | `text` | nullable | — | Mongo ObjectId bridge; partial-unique index WHERE NOT NULL |
| `id` | `uuid` | NOT NULL | `gen_random_uuid()` | Primary key (PostgreSQL UUID — use `.id`, not `._id`) |
| `legacy_object_id` | `text` | nullable | — | Former Mongo ObjectId; partial-unique index WHERE NOT NULL |
| `type` | `chat_type` enum | NOT NULL | `'direct'` | |
| `name` | `text` | nullable | — | Group chat display name |
| `description` | `text` | nullable | — | |
| `participants` | `jsonb` | nullable | — | `ChatParticipant[]` blob — **not normalised** |
| `messages` | `jsonb` | nullable | — | `ChatMessage[]` blob — **not normalised** |
| `participants` | `jsonb` | nullable | — | `ChatParticipant[]` blob — stored as JSONB array |
| `messages` | `jsonb` | nullable | — | `ChatMessage[]` blob — stored as JSONB array |
| `related_to` | `jsonb` | nullable | — | `{ type: chat_related_to_type, id: string }` blob |
| `last_message` | `jsonb` | nullable | — | Denormalised snapshot |
| `unread_counts` | `jsonb` | nullable | — | `{ userId, count }[]` blob |
@@ -118,7 +52,7 @@ Conversation container with embedded messages. Used for buyer-seller direct chat
| `settings_is_muted` | `boolean` | nullable | `false` | |
| `settings_muted_until` | `timestamp with time zone` | nullable | — | |
| `settings_notifications` | `boolean` | nullable | `true` | |
| `created_by` | `text` | nullable | — | Mongo ObjectId or UUID string of creator |
| `created_by` | `text` | nullable | — | UUID string of creator |
| `created_at` | `timestamp with time zone` | NOT NULL | `now()` | |
| `updated_at` | `timestamp with time zone` | NOT NULL | `now()` | |
| `last_activity` | `timestamp with time zone` | nullable | `now()` | Sort key for chat lists |
@@ -134,68 +68,79 @@ Conversation container with embedded messages. Used for buyer-seller direct chat
| regular | `last_activity` | |
> [!note] No FK to `users`
> `created_by` is stored as `text` (not `uuid` FK) to accommodate both Mongo ObjectIds and PG UUIDs during the transition period.
> `created_by` is stored as `text` (not `uuid` FK) to accommodate both legacy Mongo ObjectIds (in `legacy_object_id`) and PG UUIDs during the transition period.
## Virtuals
## Chat Schema — participants and messages (JSONB field shapes)
| Virtual | Returns | Definition |
### `participants` JSONB array element
| Field | Type | Description |
| --- | --- | --- |
| `participantsCount` | Count of active participants | `backend/src/models/Chat.ts:259` |
| `userId` | string (UUID) | Member id. |
| `role` | `member` / `admin` / `owner` | Member role (default `member`). |
| `joinedAt` | ISO date string | Join time. |
| `lastSeen` | ISO date string? | Last activity. |
| `leftAt` | ISO date string? | Set on soft removal. |
| `isActive` | boolean | Still a participant (default `true`). Set to `false` on soft removal. |
## Indexes (MongoDB)
> [!note] Soft removal of participants
> Removing a participant does **not** delete the array element. It is a soft removal: `isActive` is set to `false` and `leftAt` is timestamped, preserving message attribution and history.
Defined at `backend/src/models/Chat.ts:243-247`:
### `messages` JSONB array element
- `{ 'participants.userId': 1 }`
- `{ 'metadata.lastActivity': -1 }`
- `{ 'relatedTo.type': 1, 'relatedTo.id': 1 }`
- `{ 'messages.timestamp': -1 }`
- `{ type: 1 }`
| Field | Type | Description |
| --- | --- | --- |
| `senderId` | string (UUID) | Author. |
| `senderType` | string | Currently fixed to `User`. |
| `content` | string | Message body. **maxlength 5000** enforced at controller. |
| `messageType` | `text` / `image` / `file` / `system` | Body kind (default `text`). |
| `fileUrl` | string? | If file/image. |
| `fileName` | string? | Original filename. |
| `fileSize` | number? | Bytes. |
| `timestamp` | ISO date string | Sent time. |
| `isRead` | boolean | Read flag (default `false`). |
| `isEdited` | boolean | Edited flag (default `false`). |
| `editedAt` | ISO date string? | When edited. |
| `deletedAt` | ISO date string? | Set on soft-delete; `content` is cleared but the element is kept. |
| `replyTo` | string? | Reply target message id. |
| `reactions` | `{ userId: string, reaction: string }[]` | Emoji reactions. |
## Pre/Post Hooks
> [!note] Messages are soft-deleted
> Deleting a message sets `deletedAt` and clears `content` (the body becomes empty). The message element is **not** physically removed from the `messages[]` JSONB array, and a `message-deleted` socket event is emitted.
| Hook | Behaviour |
## ID Field
The primary key is `id` (PostgreSQL UUID string). There is no `_id` field. The `legacy_object_id` column preserves the original MongoDB ObjectId for records migrated from Mongo, but is not used in application logic.
## Instance / Document Methods (removed)
Mongoose document methods `.addMessage()`, `.pull()`, and `.markAsRead()` no longer exist. The repository layer (`DrizzleChatRepo`) performs equivalent operations using plain array operations on the JSONB blobs (read → mutate array in JS → write back).
| Former Mongoose method | Replacement |
| --- | --- |
| `pre('save')` (`backend/src/models/Chat.ts:250`) | Updates `metadata.updatedAt` and refreshes `metadata.lastActivity` when there are messages. |
## Instance Methods
| Signature | Purpose |
| --- | --- |
| `getUnreadCount(userId: Types.ObjectId): number` | Returns the unread counter for a participant. `backend/src/models/Chat.ts:264` |
| `addMessage(messageData: Partial<IMessage>): IMessage` | Pushes a message, updates `lastMessage`, increments unread counters for everyone except the sender, and bumps `lastActivity`. `backend/src/models/Chat.ts:270` |
| `markAsRead(userId, messageIds?: Types.ObjectId[]): void` | Marks listed messages (or all when `messageIds` is empty/omitted) as read for the user, zeros their unread counter, and updates `lastSeen`. `backend/src/models/Chat.ts:308` |
## Static Methods
None defined.
| `chat.addMessage(data)` + `chat.save()` | `DrizzleChatRepo.addMessage(chatId, messageData)` — appends to JSONB array, updates `last_message`, increments unread counts, bumps `last_activity` |
| `chat.markAsRead(userId, messageIds?)` + `chat.save()` | `DrizzleChatRepo.markAsRead(chatId, userId, messageIds?)` — mutates `messages` JSONB array and zeroes `unread_counts` for that user |
| `chat.participants.pull(...)` | `DrizzleChatRepo.removeParticipant(chatId, participantId)` — soft-removes by setting `isActive: false`, `leftAt` in JSONB array |
## Relationships
- **References**: [[User]] (`participants[].userId`, `messages[].senderId`, `messages[].reactions[].userId`, `lastMessage.senderId`, `unreadCounts[].userId`, `metadata.createdBy`).
- **Referenced by**: [[Dispute]] (`chatId`), and any [[PurchaseRequest]] / [[SellerOffer]] indirectly through `relatedTo`.
- **References**: [[User]] (`participants[].userId`, `messages[].senderId`, `messages[].reactions[].userId`, `last_message.senderId`, `unread_counts[].userId`, `created_by`).
- **Referenced by**: [[Dispute]] (`chatId`), and any [[PurchaseRequest]] / [[SellerOffer]] indirectly through `related_to`.
## Migration Status
## Future Work: Chat Normalization
| Dimension | Status |
| --- | --- |
| Dual-write repo | `DrizzleChatRepo` — active |
| Writes | Both MongoDB and Postgres receive writes |
| Reads | **MongoDB only** — not yet cut over |
| Postgres schema style | JSONB shim (participants + messages as blobs) |
| Normalisation blocker | Chat message threading design not finalised — blocks PG read cutover |
The current JSONB-blob design unblocked the Mongo → PG migration but leaves these as known future improvements:
The normalisation work required before reads can be cut to PG:
1. Design a `chat_messages` table with proper threading/reply support (currently `replyTo` is an ObjectId embedded in a JSONB blob)
1. Design a `chat_messages` table with proper threading/reply support (currently `replyTo` is embedded in the JSONB blob)
2. Design a `chat_participants` table (currently a JSONB blob with soft-removal semantics)
3. Migrate reactions, edit history, and read tracking to relational rows
4. Align unread counts with the new structure
Until that work is complete, the Postgres `chats` table is treated as a write-ahead log / backup, not the source of truth for reads.
Until that work is complete, participants and messages in the `chats` table are not queryable relationally.
## State Transitions
No top-level status. Chat-level archival is a boolean flag (`settings.isArchived`):
No top-level status. Chat-level archival is a boolean flag (`settings_is_archived`):
```mermaid
stateDiagram-v2
@@ -209,21 +154,17 @@ stateDiagram-v2
## Common Queries
```ts
// A user's recent chats
Chat.find({ 'participants.userId': userId, 'participants.isActive': true })
.sort({ 'metadata.lastActivity': -1 });
// A user's recent chats (DrizzleChatRepo)
await chatRepo.findByParticipant(userId); // filters on participants JSONB, orders by last_activity desc
// Chat for a purchase request
Chat.findOne({ 'relatedTo.type': 'PurchaseRequest', 'relatedTo.id': prId });
await chatRepo.findByRelatedTo('PurchaseRequest', purchaseRequestId);
// Append a message
const chat = await Chat.findById(id);
chat.addMessage({ senderId, content: 'hi', messageType: 'text' });
await chat.save();
await chatRepo.addMessage(chatId, { senderId, content: 'hi', messageType: 'text' });
// Mark read
chat.markAsRead(userId);
await chat.save();
await chatRepo.markAsRead(chatId, userId);
```
Related: [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Dispute]].

View File

@@ -1,54 +1,54 @@
---
title: Data Model Overview
tags: [data-model, mongoose, postgres, drizzle, overview]
tags: [data-model, postgres, drizzle, overview]
aliases: [Models Index, Schema Overview]
---
# Data Model Overview
This section documents every Mongoose model that backs the marketplace and the parallel Drizzle/Postgres schema that is progressively replacing it. On backend `integrate-main-into-development@cab0719`, Mongoose models are still the live read path for most domains. The Drizzle layer has 17 applied migrations (00000017) and active dual-write repos for the majority of tables.
This section documents every Drizzle/PostgreSQL table that backs the marketplace. PostgreSQL is the primary and sole data store as of v2.9.12 (2026-06-06). The Mongo dual-write layer has been retired; all reads and writes are served from Postgres. The Drizzle schema has 17 applied migrations (00000017).
> [!note] Scope
> Twenty-two models are present in `backend/src/models/`. The "File" concept exists only at the service layer (`backend/src/services/file/`) and is not persisted as its own Mongoose collection, so it is not listed below.
> Twenty-two domain entities are modelled. The "File" concept exists only at the service layer (`backend/src/services/file/`) and is not persisted as its own table, so it is not listed below.
>
> [!note] Documentation freshness
> As of 2026-06-03 the Postgres migration inventory reflects migrations 00000017. The dual-write summary table at the bottom of this page is the authoritative migration-status reference. Individual model pages should be updated to note their PG table name and dual-write repo when they are deepened.
> As of 2026-06-06 (v2.9.12) the Postgres migration inventory reflects migrations 00000017. The table inventory at the bottom of this page is the authoritative schema-status reference. Individual model pages should be updated to note their PG table name and any notable constraints.
> [!warning] Mongo vs Postgres runtime status
> Dual-write repos exist for the majority of domain tables, but **reads are still served from Mongo** for all dual-write tables. Postgres is the sole store only for infra/bridge tables (`id_map`, `pg_dualwrite_gaps`), oracle quote rows (`payment_quotes`), and `config_setting_history`. Full read cutover is human-gated. See [[Postgres Runtime Cutover Status]].
> [!info] PostgreSQL runtime status
> PostgreSQL is the sole data store for all domain tables. The Mongo dual-write layer has been fully retired. All reads and writes now go directly to Postgres. Infra/bridge tables (`id_map`, `pg_dualwrite_gaps`) and oracle quote rows (`payment_quotes`) remain PG-only as before.
## Index of Models
### Mongo Models (still live read path)
### Domain Models
- [[User]] — Core identity. Stores credentials, profile, preferences, referral data, points, and WebAuthn passkeys. Every other model that records "who did what" points back at a `User._id`. Buyers, sellers, admins, resolvers, and guards all live in this collection, differentiated by a `role` enum. PG table: `users` (dual-write active).
- [[PurchaseRequest]] — The buyer-side document at the heart of the marketplace. Captures what a buyer wants, the budget, urgency, delivery preferences, and the full lifecycle status (`pending_payment``seller_paid`). Aggregates [[SellerOffer]] references and tracks delivery codes. PG table: `purchase_requests` + 6 child tables (dual-write active).
- [[SellerOffer]] — A seller's bid against a [[PurchaseRequest]]. Holds price, delivery ETA, attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). PG table: `seller_offers` (dual-write active).
- [[Payment]] — Records monetary movement intent and state: buyer pay-in, seller release, and refund. The current primary provider path is Request Network plus in-house checkout, derived destinations, funds ledger entries, and Transaction Safety Provider metadata. PG table: `payments` (dual-write active).
- [[Chat]] — Conversation container with embedded messages, participants, unread counters, and reactions. Used for direct buyer-seller chats, group chats, and support tickets. Can be linked to a [[PurchaseRequest]] or [[SellerOffer]]. PG table: `chats` (conservative JSONB shim; Chat normalization is an open blocker).
- [[Notification]] — Per-user notification with category, type, and 90-day TTL for automatic cleanup. References any related entity by stringified id. PG table: `notifications` (dual-write active; `user_id` stored as `text`, no hard FK).
- [[RequestTemplate]] — A seller-authored, sharable template that pre-fills a [[PurchaseRequest]]. Carries a public shareable link, usage counter, and an optional default proposal. PG table: `request_templates` (dual-write active).
- [[Dispute]] — Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures evidence uploads, a timeline of admin actions, deadlines, and a structured resolution. PG table: `disputes` (dual-write active; all IDs as `text` for ObjectId/UUID coexistence).
- [[BlogPost]] — Editorial content: title, slug, rich content, media, SEO metadata, view/like counters, and a draft/published/archived workflow. PG table: `blog_posts` (dual-write active).
- [[Address]] — User shipping address book entry. Enforces a single primary address per user via a pre-save hook. PG table: `addresses` (schema scaffolded, migration 0016; `addressStore.ts` reads PG directly).
- [[Category]] — Hierarchical product/service taxonomy referenced by [[PurchaseRequest]] and [[RequestTemplate]]. Supports parent/child via `parentId` and bilingual `name` / `nameEn`. PG table: `categories` (dual-write active).
- [[Review]] — Polymorphic 1-5 star review against either a seller or a [[RequestTemplate]] (`subjectType` discriminator). One review per reviewer per subject (compound unique index). PG table: `reviews` (schema scaffolded, no dual-write repo yet).
- [[PointTransaction]] — Ledger of point grants and spends per user. Sources include purchase, referral, bonus, admin grant, and redemption. PG table: `point_transactions` (dual-write active).
- [[LevelConfig]] — Static configuration of loyalty tiers (level number, point thresholds, benefits, icon, color). Driven by admins; consumed by the [[User]].points.level field. No PG table (read-only config; not yet migrated).
- [[ShopSettings]] — One-to-one storefront branding for a seller: name, description, avatar, cover image, review toggles, and social links. PG table: `shop_settings` (schema scaffolded, no dual-write repo yet).
- [[TempVerification]] — Short-lived signup record that holds candidate user data and a verification code. Auto-purges via TTL when `emailVerificationCodeExpires` passes. No PG table (TTL-only; not yet migrated).
- [[TelegramLink]] — Permanent auditable association between a Telegram user ID and an Amanat [[User]]. Stores Telegram profile metadata, link source (`miniapp` / `bot` / `login_widget`), status (`active` / `blocked`), and last-seen timestamp. One per Telegram user (unique on both `userId` and `telegramUserId`). PG table: `telegram_links` (schema scaffolded, no dual-write repo yet).
- [[TelegramSession]] — Short-lived Telegram Mini App session token issued when `initData` is verified. Carries the `initDataFingerprint` for replay protection and auto-expires via a MongoDB TTL index on `expiresAt`. PG table: `telegram_sessions` (schema scaffolded, no dual-write repo yet).
- [[ConfigSetting]] — Runtime configuration persisted in MongoDB for operational knobs that need an admin surface rather than a deploy. PG table: `config_settings` (schema scaffolded, no dual-write repo yet).
- [[DerivedDestination]] — Per-payment derived wallet destination records used to reduce address reuse and reconcile on-chain pay-ins. PG table: `derived_destinations` + `derived_destination_sweeps` (dual-write active).
- [[FundsLedgerEntry]] — Immutable accounting ledger rows for pay-in, hold, release, refund, fee, adjustment, and reversal events. PG table: `funds_ledger_entries` (dual-write active; immutability enforced by DB trigger since migration 0015).
- [[TrezorAccount]] — Hardware-wallet/safekeeping account metadata for custody operations and staged signer hardening. PG table: `trezor_accounts` + `trezor_derived_addresses` (dual-write active).
- [[ConfigSettingHistory]] — Immutable audit trail of numeric runtime-config changes. Currently used for per-chain confirmation threshold change events, keyed as `confirmation_threshold:<chainId>`. Added in commit `27fb15a`. PG table: `config_setting_history` (PG-only; no Mongo equivalent).
- [[User]] — Core identity. Stores credentials, profile, preferences, referral data, points, and WebAuthn passkeys. Every other model that records "who did what" points back at a `User.id` (UUID). Buyers, sellers, admins, resolvers, and guards all live in this table, differentiated by a `role` enum. PG table: `users`.
- [[PurchaseRequest]] — The buyer-side record at the heart of the marketplace. Captures what a buyer wants, the budget, urgency, delivery preferences, and the full lifecycle status (`pending_payment``seller_paid`). Aggregates [[SellerOffer]] references and tracks delivery codes. PG table: `purchase_requests` + 6 child tables.
- [[SellerOffer]] — A seller's bid against a [[PurchaseRequest]]. Holds price, delivery ETA, attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). PG table: `seller_offers`.
- [[Payment]] — Records monetary movement intent and state: buyer pay-in, seller release, and refund. The current primary provider path is Request Network plus in-house checkout, derived destinations, funds ledger entries, and Transaction Safety Provider metadata. PG table: `payments`.
- [[Chat]] — Conversation container with embedded messages, participants, unread counters, and reactions. Used for direct buyer-seller chats, group chats, and support tickets. Can be linked to a [[PurchaseRequest]] or [[SellerOffer]]. PG table: `chats` (JSONB shim; Chat normalization is an open follow-up).
- [[Notification]] — Per-user notification with category, type, and 90-day TTL for automatic cleanup. References any related entity by stringified id. PG table: `notifications` (`user_id` stored as `text`, no hard FK).
- [[RequestTemplate]] — A seller-authored, sharable template that pre-fills a [[PurchaseRequest]]. Carries a public shareable link, usage counter, and an optional default proposal. PG table: `request_templates`.
- [[Dispute]] — Buyer-raised complaint tied to a [[PurchaseRequest]]. Captures evidence uploads, a timeline of admin actions, deadlines, and a structured resolution. PG table: `disputes` (all IDs as `text` for legacy coexistence).
- [[BlogPost]] — Editorial content: title, slug, rich content, media, SEO metadata, view/like counters, and a draft/published/archived workflow. PG table: `blog_posts`.
- [[Address]] — User shipping address book entry. Enforces a single primary address per user via a partial-unique index. PG table: `addresses` (migration 0016; `addressStore.ts` reads PG directly).
- [[Category]] — Hierarchical product/service taxonomy referenced by [[PurchaseRequest]] and [[RequestTemplate]]. Supports parent/child via `parent_id` and bilingual `name` / `name_en`. PG table: `categories`.
- [[Review]] — Polymorphic 1-5 star review against either a seller or a [[RequestTemplate]] (`subject_type` discriminator). One review per reviewer per subject (compound unique index). PG table: `reviews` (schema scaffolded, no write repo yet).
- [[PointTransaction]] — Ledger of point grants and spends per user. Sources include purchase, referral, bonus, admin grant, and redemption. PG table: `point_transactions`.
- [[LevelConfig]] — Static configuration of loyalty tiers (level number, point thresholds, benefits, icon, color). Driven by admins; consumed by the `users.points_level` field. No PG table (read-only config; not yet migrated).
- [[ShopSettings]] — One-to-one storefront branding for a seller: name, description, avatar, cover image, review toggles, and social links. PG table: `shop_settings` (schema scaffolded, no write repo yet).
- [[TempVerification]] — Short-lived signup record that holds candidate user data and a verification code. Auto-purges via scheduled job when `email_verification_code_expires` passes. No PG table (TTL-only; not yet migrated).
- [[TelegramLink]] — Permanent auditable association between a Telegram user ID and an Amanat [[User]]. Stores Telegram profile metadata, link source (`miniapp` / `bot` / `login_widget`), status (`active` / `blocked`), and last-seen timestamp. One per Telegram user (unique on both `user_id` and `telegram_user_id`). PG table: `telegram_links` (schema scaffolded, no write repo yet).
- [[TelegramSession]] — Short-lived Telegram Mini App session token issued when `initData` is verified. Carries the `init_data_fingerprint` for replay protection and auto-expires via scheduled cleanup on `expires_at`. PG table: `telegram_sessions` (schema scaffolded, no write repo yet).
- [[ConfigSetting]] — Runtime configuration persisted in Postgres for operational knobs that need an admin surface rather than a deploy. PG table: `config_settings` (schema scaffolded, no write repo yet).
- [[DerivedDestination]] — Per-payment derived wallet destination records used to reduce address reuse and reconcile on-chain pay-ins. PG table: `derived_destinations` + `derived_destination_sweeps`.
- [[FundsLedgerEntry]] — Immutable accounting ledger rows for pay-in, hold, release, refund, fee, adjustment, and reversal events. PG table: `funds_ledger_entries` (immutability enforced by DB trigger since migration 0015).
- [[TrezorAccount]] — Hardware-wallet/safekeeping account metadata for custody operations and staged signer hardening. PG table: `trezor_accounts` + `trezor_derived_addresses`.
- [[ConfigSettingHistory]] — Immutable audit trail of numeric runtime-config changes. Currently used for per-chain confirmation threshold change events, keyed as `confirmation_threshold:<chainId>`. Added in commit `27fb15a`. PG table: `config_setting_history` (PG-only; no legacy equivalent).
### PG-Only Tables (no Mongo equivalent)
### PG-Only Tables (infrastructure / bridge)
- `id_map` — ObjectId → UUID bridge. Every migrated table upserts here during backfill/dual-write. Composite PK on `(collection, legacy_object_id)`, unique on `new_id`.
- `pg_dualwrite_gaps` — Append-only reconciliation gap log for failed PG dual-writes. Tracks collection, op, payload, severity, and resolution metadata.
- `id_map` Legacy ObjectId → UUID bridge. Retained for reference during any remaining data reconciliation. Composite PK on `(collection, legacy_object_id)`, unique on `new_id`.
- `pg_dualwrite_gaps` — Append-only reconciliation gap log from the dual-write era. Tracks collection, op, payload, severity, and resolution metadata.
- `payment_quotes` — Oracle pricing quotes per payment (oracle depeg-protection feature). Stores `fx_rate`, `token_price_usd`, `depeg_adjustment_bps`, `settle_amount`, chain/token, and expiry. Requires `ORACLE_QUOTING_ENABLED=true`. 1:1 to `payments`.
- `user_passkeys` — WebAuthn credential store (child of `users`). Columns: credential id (text PK), `user_id FK→users CASCADE`, `public_key`, `counter`, `device_type`, `device_name`.
- `user_refresh_tokens` — Refresh token store (child of `users`). Columns: `token text PK`, `user_id FK→users CASCADE`.
@@ -110,31 +110,28 @@ erDiagram
TREZOR_ACCOUNT ||--o{ TREZOR_DERIVED_ADDRESS : "issues"
DERIVED_DESTINATION ||--o{ DERIVED_DESTINATION_SWEEP : "swept by"
ID_MAP ||..|| USER : "bridges ObjectId"
ID_MAP ||..|| USER : "bridges legacy id"
```
## Conventions Across All Models
### Mongoose Conventions
### Drizzle/PostgreSQL Conventions
> [!note] Shared schema patterns
> - **Timestamps**: every model declares `{ timestamps: true }`, so `createdAt` and `updatedAt` are always present.
> - **ObjectId references**: foreign keys use `Schema.Types.ObjectId` with an explicit `ref` (e.g. `ref: 'User'`). The two exceptions are [[Notification]] and [[Payment]] which use string-typed or `Mixed` identifiers in places to support template-flow payments.
> - **Soft delete**: deletion is modelled as a `status` flag (e.g. `User.status = 'deleted'`, `BlogPost.status = 'archived'`) rather than physical removal.
> - **TTL indexes**: short-lived collections ([[Notification]], [[TempVerification]]) use `{ expireAfterSeconds: ... }` so MongoDB does the cleanup.
> - **toJSON sanitisation**: [[User]] overrides `toJSON` to strip credentials, refresh tokens, and verification codes before serialisation.
> - **Timestamps**: every table declares `created_at` and `updated_at timestamptz` with `withTimezone: true`.
> - **Primary keys**: all tables use `id uuid` (generated via `gen_random_uuid()` or application-side UUID v4). There are no integer sequences for domain tables.
> - **UUID references**: foreign keys reference the `id uuid` column of the target table (e.g. `user_id uuid REFERENCES users(id)`). The two exceptions are [[Notification]] and [[Payment]] which use `text`-typed identifiers in places to support template-flow payments.
> - **Soft delete**: deletion is modelled as a `status` flag (e.g. `users.status = 'deleted'`, `blog_posts.status = 'archived'`) rather than physical removal. `addresses` uses `deleted_at timestamptz` (nullable) with partial-unique indexes scoped to `WHERE deleted_at IS NULL`.
> - **TTL cleanup**: short-lived tables ([[TempVerification]], [[TelegramSession]]) rely on scheduled cleanup jobs rather than database-level TTL.
> - **JSON sanitisation**: [[User]] service layer strips credentials, refresh tokens, and verification codes before serialisation.
> [!warning] Index discipline
> Several schemas leave a comment noting that `unique: true` already creates an index — adding `schema.index({ field: 1 })` on top would produce a duplicate-index warning at startup. When introducing new indexes, search for `unique: true` first.
### Drizzle/Postgres Conventions
> Several tables carry both a `UNIQUE` constraint and would otherwise duplicate an index — check for existing unique constraints before adding explicit `CREATE INDEX` statements to avoid duplicate-index warnings at startup.
> [!note] PG schema patterns
> - **Legacy bridge**: every migrated table carries `legacy_object_id text` with a partial-unique index `WHERE legacy_object_id IS NOT NULL` for idempotent backfill upserts. The `id_map` table records the ObjectId → UUID mapping centrally.
> - **Legacy bridge**: `id_map` records the old ObjectId → UUID mapping for any reconciliation needs. The `legacy_object_id text` column with a partial-unique index `WHERE legacy_object_id IS NOT NULL` is retained on migrated tables for idempotent reconciliation upserts.
> - **Money columns**: `numeric(38,18)` for fiat/crypto amounts throughout, except `seller_offers` which uses `numeric(18,8)` per the Migration Guide. Blockchain balance columns use `numeric(78,0)` to hold uint256 without overflow.
> - **Polymorphic triples**: the `ref_kind` enum (`entity` | `template`) discriminator is expanded into three columns (`_ref_kind`, `_id`, `_external_ref`) with a CHECK constraint to enforce discriminator integrity. Used by `payments`, `funds_ledger_entries`, and `derived_destinations`.
> - **Soft delete**: `addresses` uses `deleted_at timestamptz` (nullable) with partial-unique indexes scoped to `WHERE deleted_at IS NULL`. Most other tables retain the Mongo `status` flag approach.
> - **Timestamps**: all timestamp columns declare `withTimezone: true`.
> - **Immutability**: `funds_ledger_entries` has both an UPDATE-blocking and a DELETE-blocking trigger installed at the DB level (migrations 0004, 0015). A TRUNCATE trigger was added in migration 0013.
> - **user_role enum**: values are `admin`, `buyer`, `seller`, `resolver`, `guard`. The `guard` value was added in migration 0017.
@@ -164,55 +161,55 @@ Schema entry point: `backend/src/db/schema/index.ts`
| 0016 | `0016_addresses_table.sql` | `address_type` enum + `addresses` table; partial-unique primary-address-per-user index |
| 0017 | `0017_user_role_guard.sql` | Adds `'guard'` to `user_role` enum (idempotent `ADD VALUE IF NOT EXISTS`) |
## Drizzle Table Inventory and Migration Status
## Drizzle Table Inventory
### Infrastructure / Bridge
| PG Table | Schema File | Status | Notes |
|---|---|---|---|
| `id_map` | `idMap.ts` | PG-only | ObjectId → UUID bridge; composite PK + unique on `new_id` |
| `pg_dualwrite_gaps` | `pgDualwriteGaps.ts` | PG-only | Append-only reconciliation gap log for failed dual-writes |
| `id_map` | `idMap.ts` | PG-only | Legacy ObjectId → UUID bridge; composite PK + unique on `new_id` |
| `pg_dualwrite_gaps` | `pgDualwriteGaps.ts` | PG-only | Append-only reconciliation gap log from dual-write era |
### Core Domain
| PG Table | Schema File | Status | Dual-Write Repo |
| PG Table | Schema File | Status | Notes |
|---|---|---|---|
| `users` | `users.ts` | Dual-write active | `DualWriteUserRepo` + `DrizzleUserRepo` + `MongoUserRepo` |
| `user_passkeys` | `users.ts` | Dual-write active (child of users) | — |
| `user_refresh_tokens` | `users.ts` | Dual-write active (child of users) | — |
| `categories` | `category.ts` | Dual-write active | `DualWriteMarketplaceRepo` |
| `purchase_requests` | `purchaseRequest.ts` | Dual-write active | `DualWriteMarketplaceRepo` |
| `purchase_request_delivery_info` | `purchaseRequest.ts` | Dual-write active (1:1 child) | — |
| `purchase_request_delivery_address` | `purchaseRequest.ts` | Dual-write active (1:1 child) | — |
| `purchase_request_seller_delivery_info` | `purchaseRequest.ts` | Dual-write active (1:1 child) | — |
| `delivery_attempts` | `purchaseRequest.ts` | Dual-write active (1:N child) | — |
| `purchase_request_service_info` | `purchaseRequest.ts` | Dual-write active (1:1 child) | — |
| `purchase_request_specifications` | `purchaseRequest.ts` | Dual-write active (1:N child) | — |
| `purchase_request_preferred_sellers` | `purchaseRequest.ts` | Dual-write active (N:M junction) | — |
| `seller_offers` | `sellerOffer.ts` | Dual-write active | `DualWriteMarketplaceRepo` |
| `payments` | `payment.ts` | Dual-write active | `DualWritePaymentRepo` + `DrizzlePaymentRepo` + `MongoPaymentRepo` |
| `payment_quotes` | `paymentQuote.ts` | PG-only | No Mongo equivalent; oracle depeg-protection feature |
| `funds_ledger_entries` | `fundsLedgerEntry.ts` | Dual-write active | `DrizzlePaymentRepo` / `DualWritePaymentRepo` |
| `derived_destinations` | `derivedDestination.ts` | Dual-write active | `DualWriteDerivedDestinationRepo` + `DrizzleDerivedDestinationRepo` |
| `derived_destination_sweeps` | `derivedDestination.ts` | Dual-write active (append-only child) | — |
| `trezor_accounts` | `trezorAccount.ts` | Dual-write active | `DualWriteTrezorAccountRepo` + `DrizzleTrezorAccountRepo` |
| `trezor_derived_addresses` | `trezorAccount.ts` | Dual-write active (child of trezor_accounts) | — |
| `point_transactions` | `pointTransaction.ts` | Dual-write active | `DualWritePointsRepo` + `DrizzlePointsRepo` |
| `request_templates` | `requestTemplate.ts` | Dual-write active | `DualWriteMarketplaceRepo` |
| `chats` | `chat.ts` | Dual-write active | `DrizzleChatRepo` |
| `blog_posts` | `blogPost.ts` | Dual-write active | `DualWriteBlogRepo` + `DrizzleBlogRepo` |
| `notifications` | `notification.ts` | Dual-write active | `DualWriteNotificationRepo` + `DrizzleNotificationRepo` |
| `disputes` | `dispute.ts` | Dual-write active | `DualWriteDisputeRepo` + `DrizzleDisputeRepo` |
| `addresses` | `address.ts` | Schema scaffolded | No dual-write repo; `addressStore.ts` reads PG directly (migration 0016) |
| `shop_settings` | `shopSettings.ts` | Schema scaffolded | No dual-write repo |
| `config_settings` | `configSetting.ts` | Schema scaffolded | No dual-write repo |
| `config_setting_history` | `configSetting.ts` | PG-only | No Mongo equivalent; child of `config_settings` |
| `telegram_links` | `telegramLink.ts` | Schema scaffolded | No dual-write repo |
| `telegram_sessions` | `telegramSession.ts` | Schema scaffolded | No dual-write repo |
| `reviews` | `review.ts` | Schema scaffolded | No dual-write repo |
| `users` | `users.ts` | Active | `DrizzleUserRepo` |
| `user_passkeys` | `users.ts` | Active (child of users) | — |
| `user_refresh_tokens` | `users.ts` | Active (child of users) | — |
| `categories` | `category.ts` | Active | `DrizzleMarketplaceRepo` |
| `purchase_requests` | `purchaseRequest.ts` | Active | `DrizzleMarketplaceRepo` |
| `purchase_request_delivery_info` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `purchase_request_delivery_address` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `purchase_request_seller_delivery_info` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `delivery_attempts` | `purchaseRequest.ts` | Active (1:N child) | — |
| `purchase_request_service_info` | `purchaseRequest.ts` | Active (1:1 child) | — |
| `purchase_request_specifications` | `purchaseRequest.ts` | Active (1:N child) | — |
| `purchase_request_preferred_sellers` | `purchaseRequest.ts` | Active (N:M junction) | — |
| `seller_offers` | `sellerOffer.ts` | Active | `DrizzleMarketplaceRepo` |
| `payments` | `payment.ts` | Active | `DrizzlePaymentRepo` |
| `payment_quotes` | `paymentQuote.ts` | PG-only | No legacy equivalent; oracle depeg-protection feature |
| `funds_ledger_entries` | `fundsLedgerEntry.ts` | Active | `DrizzlePaymentRepo` |
| `derived_destinations` | `derivedDestination.ts` | Active | `DrizzleDerivedDestinationRepo` |
| `derived_destination_sweeps` | `derivedDestination.ts` | Active (append-only child) | — |
| `trezor_accounts` | `trezorAccount.ts` | Active | `DrizzleTrezorAccountRepo` |
| `trezor_derived_addresses` | `trezorAccount.ts` | Active (child of trezor_accounts) | — |
| `point_transactions` | `pointTransaction.ts` | Active | `DrizzlePointsRepo` |
| `request_templates` | `requestTemplate.ts` | Active | `DrizzleMarketplaceRepo` |
| `chats` | `chat.ts` | Active | `DrizzleChatRepo` |
| `blog_posts` | `blogPost.ts` | Active | `DrizzleBlogRepo` |
| `notifications` | `notification.ts` | Active | `DrizzleNotificationRepo` |
| `disputes` | `dispute.ts` | Active | `DrizzleDisputeRepo` |
| `addresses` | `address.ts` | Schema scaffolded | No write repo; `addressStore.ts` reads PG directly (migration 0016) |
| `shop_settings` | `shopSettings.ts` | Schema scaffolded | No write repo |
| `config_settings` | `configSetting.ts` | Schema scaffolded | No write repo |
| `config_setting_history` | `configSetting.ts` | PG-only | No legacy equivalent; child of `config_settings` |
| `telegram_links` | `telegramLink.ts` | Schema scaffolded | No write repo |
| `telegram_sessions` | `telegramSession.ts` | Schema scaffolded | No write repo |
| `reviews` | `review.ts` | Schema scaffolded | No write repo |
> [!note] Read cutover status
> **Dual-write active** means writes go to both Mongo and PG; reads still come from Mongo (per MEMORY.md as of 2026-06-03). **Schema scaffolded** means the Drizzle table exists but no DualWriteRepo plumbs it. **PG-only** means there is no Mongo model for that data.
> [!note] Status key
> **Active** means reads and writes are served from Postgres. **Schema scaffolded** means the Drizzle table exists but no repo wires it into the service layer yet. **PG-only** means there is no legacy model for that data.
## Shared Enum Reference
@@ -240,19 +237,21 @@ Enums live in `backend/src/db/schema/_enums.ts` (shared) and individual schema f
## Lifecycle View
The dominant happy-path flow exercises five collections in order:
The dominant happy-path flow exercises five tables in order:
1. A buyer (`User`) creates a `PurchaseRequest` with `status: 'pending'`.
2. Sellers (other `User`s) attach `SellerOffer` documents; the request transitions through `received_offers``in_negotiation` as the parties chat in a `Chat`.
3. The buyer accepts an offer; a `Payment` is opened against the Request Network provider and, once verified by webhook/reconciliation and safety checks, advances to a funded escrow state. If `ORACLE_QUOTING_ENABLED=true`, a `payment_quote` row is written to PG at this point.
4. The seller marks the request `delivery``delivered`; the buyer confirms with the 6-digit `deliveryCode` and the request becomes `completed`.
5. The escrow `Payment` flips to `released` after a ledger-gated custody transfer instruction. Each ledger event appends an immutable `FundsLedgerEntry` row (Mongo + PG). Optionally the buyer writes a `Review` and earns a `PointTransaction`.
1. A buyer (`users`) creates a `purchase_requests` row with `status: 'pending'`.
2. Sellers (other `users` rows) attach `seller_offers` rows; the request transitions through `received_offers``in_negotiation` as the parties chat in a `chats` row.
3. The buyer accepts an offer; a `payments` row is opened against the Request Network provider and, once verified by webhook/reconciliation and safety checks, advances to a funded escrow state. If `ORACLE_QUOTING_ENABLED=true`, a `payment_quotes` row is written to PG at this point.
4. The seller marks the request `delivery``delivered`; the buyer confirms with the 6-digit `delivery_code` and the request becomes `completed`.
5. The escrow `payments` row flips to `released` after a ledger-gated custody transfer instruction. Each ledger event appends an immutable `funds_ledger_entries` row. Optionally the buyer writes a `reviews` row and earns a `point_transactions` row.
If anything goes sideways, the buyer can open a `Dispute`, which freezes release until an admin resolves it (refund, replacement, compensation, or no-action).
If anything goes sideways, the buyer can open a `disputes` row, which freezes release until an admin resolves it (refund, replacement, compensation, or no-action).
## How to Navigate
Each model has its own note in this folder. Cross-references use `[[wikilinks]]` so backlinks work in Obsidian's graph view. Schemas are documented at field-level granularity — every field is listed with its type, default, validation, and indexing decisions. Where a model carries a meaningful state machine, a Mermaid `stateDiagram-v2` accompanies the schema table.
> [!note] Source of truth
> The information below is mirrored from the TypeScript schema definitions. If a field listed here disagrees with the code, the code wins — please update the note. All source citations use the form `backend/src/models/<File>.ts:<line>` for Mongo and `backend/src/db/schema/<File>.ts:<line>` for Drizzle/PG.
> The information below is mirrored from the TypeScript schema definitions. If a field listed here disagrees with the code, the code wins — please update the note. All source citations use the form `backend/src/db/schema/<File>.ts:<line>` for Drizzle/PG.
>
> Last updated: v2.9.12 / 2026-06-06

View File

@@ -1,45 +1,45 @@
---
title: Payment
tags: [data-model, mongoose]
tags: [data-model, postgresql, drizzle]
aliases: [Payment Record, Escrow, IPayment]
---
# Payment
> **Last updated:** 2026-06-01documented the first payment-repo runtime seam for funds ledger appends/balance reads.
> **Last updated:** 2026-06-06MongoDB fully removed; PostgreSQL + Drizzle ORM is the only database layer as of backend v2.9.12.
Records every monetary movement in the marketplace: buyer pay-ins, seller payouts, and refunds. The current model is centered on Request Network pay-in, in-house checkout metadata, on-chain transaction verification, escrow state, and provider request IDs. The `provider` and `direction` discriminators let one collection hold incoming buyer payments, outgoing seller releases, refunds, and legacy/other provider records.
Records every monetary movement in the marketplace: buyer pay-ins, seller payouts, and refunds. The model is centered on Request Network pay-in, in-house checkout metadata, on-chain transaction verification, escrow state, and provider request IDs. The `provider` and `direction` discriminators let one table hold incoming buyer payments, outgoing seller releases, refunds, and legacy/other provider records.
> [!warning] Runtime store
> The `Payment` document is still created, read, and updated through Mongoose on most normal request paths. Backend `2.8.20` routes `FundsLedgerEntry` appends and balance reads through the payment repository seam (`REPO_PAYMENT=mongo|dual|pg`), but the default remains Mongo and this does not make the whole payment domain PG-authoritative. Oracle quotes can persist to Postgres `payment_quotes` when `ORACLE_QUOTING_ENABLED=true` and a PG parent payment row can be resolved. See [[Postgres Runtime Cutover Status]].
> [!note] Runtime store
> The `Payment` record is stored exclusively in PostgreSQL (`payments` table). Mongoose and MongoDB have been completely removed from the backend as of v2.9.12. The repository factory returns Drizzle repos only. `MONGO_URI` / `MONGODB_URI` / `MONGO_CONNECT_MODE` env vars are obsolete; `PG_URL` is required.
> [!note] Source
> `backend/src/models/Payment.ts:3` — schema definition
> `backend/src/models/Payment.ts:257` — model export (default export)
> `backend/src/repositories/drizzle/DrizzlePaymentRepo.ts` — Drizzle repository implementation
> `backend/src/db/schema/` — Drizzle schema definitions
> [!warning] Mixed types
> `purchaseRequestId`, `sellerOfferId`, and `sellerId` use `Schema.Types.Mixed`. They are usually `ObjectId`s, but the template-checkout flow passes string ids that do not yet exist in the database, so the schema accepts both.
> [!note] IDs
> All primary keys are PostgreSQL UUIDs (`.id` field, string). The legacy MongoDB ObjectId is preserved as `legacy_object_id` for historical lookups only. Marketplace FKs (e.g. `sellerId`) reference `user.pgId` (UUID), not the legacy `_id`.
> [!note] `provider` values
> The current backend schema accepts `request.network`, `amn.scanner`, `shkeeper`, and `other`. `amn.scanner` is the in-house scanner provider used for direct on-chain monitoring. Older docs and some frontend types may still mention historical values such as `test` or `decentralized`; treat those as legacy until their active routes are audited again.
> The backend accepts `request.network`, `amn.scanner`, `shkeeper`, `escrow`, and `other`. `amn.scanner` is the in-house scanner provider used for direct on-chain monitoring. `escrow` is used for internal escrow-native flows. Older docs and some frontend types may still mention historical values such as `test` or `decentralized`; treat those as legacy until their active routes are audited.
> [!note] `confirmed` vs `completed` — stats parity
> Payment stats should count both **`confirmed`** and **`completed`** as successful. Backend `2.8.20` aligns the Mongo and Drizzle payment repository implementations with that behavior before broader payment-service wiring.
> Payment stats count both **`confirmed`** and **`completed`** as successful.
> [!warning] `SIM_` payment-hash bypass — security concern
> In both `payment/paymentRoutes.ts` and `marketplace/routes.ts`, a `paymentHash` that starts with `SIM_` (or a short `0x...` hash under 64 chars) is treated as a simulated transaction and **skips on-chain verification entirely** (`isVerified = true`). There is **no environment guard** (e.g. no `NODE_ENV !== 'production'` check) around this branch, so the bypass is reachable in production. ⚠️ A caller can mark a payment verified without any real on-chain settlement.
## Schema
## PostgreSQL schema (Drizzle)
| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `purchaseRequestId` | Mixed (ObjectId or String) | yes | — | — | yes (compound, partial) | Linked [[PurchaseRequest]] id (or template id). |
| `sellerOfferId` | Mixed (ObjectId or String) | yes | — | — | — | Linked [[SellerOffer]] id (or template offer ref). |
| `buyerId` | ObjectId → [[User]] | yes | — | — | yes (compound) | Buyer paying. |
| `sellerId` | Mixed (ObjectId or String) | yes | — | — | yes (compound) | Seller receiving (or template seller). |
| `amount.amount` | Number | yes | — | — | — | Numeric amount. |
| `amount.currency` | String | yes | `USDT` | — | — | Settlement currency. |
| `provider` | String | no | `request.network` | enum: `request.network` / `amn.scanner` / `shkeeper` / `other` | yes (compound, partial) | Payment processor. `amn.scanner` is the in-house scanner pay-in rail. |
| `id` | UUID (string) | yes | gen_random_uuid() | — | yes (PK) | Primary key. |
| `purchaseRequestId` | UUID or String | yes | — | — | yes (compound, partial) | Linked [[PurchaseRequest]] id (or template id). |
| `sellerOfferId` | UUID or String | yes | — | — | — | Linked [[SellerOffer]] id (or template offer ref). |
| `buyerId` | UUID → [[User]] | yes | — | — | yes (compound) | Buyer paying. |
| `sellerId` | UUID or String | yes | — | — | yes (compound) | Seller receiving (or template seller). References `user.pgId`. |
| `amount` | String (decimal) | yes | — | decimal string | — | Settlement amount as a decimal string (e.g. `"12.50"`). |
| `provider` | String | no | `request.network` | enum: `request.network` / `amn.scanner` / `shkeeper` / `escrow` / `other` | yes (compound, partial) | Payment processor. `amn.scanner` is the in-house scanner pay-in rail. |
| `direction` | String | no | `in` | enum: `in` / `out` / `refund` | yes (compound, partial) | Flow direction. |
| `blockchain.network` | String | no | — | — | — | Network identifier. |
| `blockchain.transactionHash` | String | no | — | — | yes (sparse) | On-chain tx hash. |
@@ -48,8 +48,11 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `blockchain.sender` | String | no | — | — | — | Source address. |
| `blockchain.receiver` | String | no | — | — | — | Destination address. |
| `blockchain.confirmedAt` | Date | no | — | — | — | When tx confirmed. |
| `blockchain.confirmations` | Number | no | `0` | — | — | Accepted confirmation count. For settled webhooks this is capped at the effective per-chain threshold (for example `50`, `200`, `300`) rather than an endlessly increasing live block count; payment screens render settled values with a `+` suffix. |
| `status` | String | no | `pending` | enum: `pending` / `processing` / `confirmed` / `completed` / `failed` / `cancelled` / `refunded` | yes (compound) | Lifecycle status. ⚠️ `confirmed` vs `completed`: only `confirmed` is counted as a successful payment in stats. See status note below. |
| `blockchain.confirmations` | Number | no | `0` | — | — | Accepted confirmation count. For settled webhooks this is capped at the effective per-chain threshold rather than an endlessly increasing live block count; payment screens render settled values with a `+` suffix. |
| `blockchain.blockNumber` | Number | no | — | — | — | Block number of the confirmed transaction. |
| `blockchain.gasUsed` | Number | no | — | — | — | Gas units consumed by the transaction. |
| `blockchain.isSimulated` | Boolean | no | — | — | — | True when the payment was created via the `SIM_` hash bypass (no real on-chain tx). |
| `status` | String | no | `pending` | enum: `pending` / `processing` / `confirmed` / `completed` / `failed` / `cancelled` / `refunded` | yes (compound) | Lifecycle status. Both `confirmed` and `completed` are counted as successful in payment stats. |
| `escrowState` | String | no | — | enum: `funded` / `releasable` / `released` / `refunded` / `releasing` / `failed` / `cancelled` / `partial` | — | Escrow lifecycle. Note the intermediate states `releasable` (delivery confirmed, ready to pay out) and `releasing` (payout in flight) between `funded` and `released`. |
| `providerPaymentId` | String | no | — | — | yes (sparse) | External provider id for idempotency. |
| `metadata.userAgent` | String | no | — | — | — | Browser UA. |
@@ -58,7 +61,7 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `metadata.paymentMethod` | String | no | — | — | — | Payment method label. |
| `metadata.shkeeperUrl` | String | no | — | — | — | Invoice URL. |
| `metadata.shkeeperInvoiceId` | String | no | — | — | — | Invoice id. |
| `metadata.shkeeperData` | Mixed | no | — | — | — | Raw provider payload. |
| `metadata.shkeeperData` | JSONB | no | — | — | — | Raw provider payload. |
| `metadata.shkeeperStatus` | String | no | — | — | — | Provider status string. |
| `metadata.balanceFiat` | String | no | — | — | — | Fiat-equivalent balance. |
| `metadata.balanceCrypto` | String | no | — | — | — | Crypto balance. |
@@ -68,16 +71,16 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `metadata.requestNetworkRequestId` | String | no | — | — | — | Request Network request id. |
| `metadata.requestNetworkPaymentReference` | String | no | — | — | — | Request Network payment reference. |
| `metadata.requestNetworkSecurePaymentUrl` | String | no | — | — | — | Request Network secure payment URL. |
| `metadata.requestNetworkData` | Mixed | no | — | — | — | Raw Request Network payload. |
| `metadata.transactionSafety` | Mixed | no | — | — | — | Last Transaction Safety Provider decision, checks, evidence, and blocker reason. |
| `metadata.derivedDestination` | Object | no | — | — | — | Snapshot of per-payment derived destination address/path/index/chain. |
| `metadata.requestNetworkData` | JSONB | no | — | — | — | Raw Request Network payload. |
| `metadata.transactionSafety` | JSONB | no | — | — | — | Last Transaction Safety Provider decision, checks, evidence, and blocker reason. |
| `metadata.derivedDestination` | JSONB | no | — | — | — | Snapshot of per-payment derived destination address/path/index/chain. |
| `metadata.lastWebhookAt` | Date | no | — | — | — | Last webhook timestamp. |
| `metadata.webhookPayload` | Mixed | no | — | — | — | Last webhook body. |
| `metadata.webhookPayload` | JSONB | no | — | — | — | Last webhook body. |
| `metadata.createdVia` | String | no | — | — | — | Origin marker. |
| `metadata.payoutType` | String | no | — | — | — | Payout sub-type. |
| `metadata.error` | String | no | — | — | — | Last error message. |
| `metadata.failedAt` | Date | no | — | — | — | When it failed. |
| `quote.quoteId` | String | no | — | — | — | PG `payment_quotes.id` when a Postgres quote row exists. |
| `quote.quoteId` | String | no | — | — | — | `payment_quotes.id` (UUID) when a Postgres quote row exists. |
| `quote.pricingCurrency` | String | no | — | — | — | Seller offer currency used for the quote. |
| `quote.offerAmount` | String | no | — | decimal string | — | Seller obligation in `pricingCurrency`. |
| `quote.invoiceUSD` | String | no | — | decimal string | — | `offerAmount × fxRate` at quote time. |
@@ -93,57 +96,38 @@ Records every monetary movement in the marketplace: buyer pay-ins, seller payout
| `quote.chainId` | Number | no | — | — | — | Settlement chain id. |
| `quote.fetchedAt` | Date | no | — | — | — | Oracle rate timestamp. |
| `quote.expiresAt` | Date | no | — | — | — | Quote expiry. |
| `createdAt` | Date | auto | `Date.now` | — | yes (compound) | Mongoose timestamp. |
| `createdAt` | Date | auto | now() | — | yes (compound) | Row creation timestamp. |
| `processedAt` | Date | no | — | — | — | When processing started. |
| `completedAt` | Date | no | — | — | — | When fully settled. |
| `notes` | String | no | — | — | — | Free-form notes. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Last update timestamp. |
| `legacy_object_id` | String | no | — | — | yes (sparse) | Original MongoDB ObjectId preserved for historical lookups during migration window. |
## Virtuals
## Virtuals / Computed
| Virtual | Returns | Definition |
| Field | Returns | Description |
| --- | --- | --- |
| `paymentRef` | `PAY-<LAST_8_OF_ID_UPPERCASE>` | `backend/src/models/Payment.ts:191` |
The schema enables `toJSON: { virtuals: true }` and `toObject: { virtuals: true }` so the ref appears in API responses.
| `paymentRef` | `PAY-<LAST_8_OF_ID_UPPERCASE>` | Derived from UUID `id`. Included in API responses. |
## Indexes
Defined at `backend/src/models/Payment.ts:174-188`:
PostgreSQL indexes on the `payments` table:
- `{ status: 1, createdAt: -1 }` — admin queues.
- `{ buyerId: 1, status: 1 }` — buyer dashboard.
- `{ sellerId: 1, status: 1 }` — seller dashboard.
- `{ 'blockchain.transactionHash': 1 }` (sparse) — webhook lookup by hash.
- `{ providerPaymentId: 1 }` (sparse) — provider idempotency.
- `{ buyerId: 1, purchaseRequestId: 1, provider: 1, direction: 1 }` (unique, partial: `provider === 'shkeeper' && direction === 'in' && status === 'pending'`, name `uniq_pending_shkeeper_by_buyer_session`) — guards against duplicate pending invoices.
- `{ status, createdAt DESC }` — admin queues.
- `{ buyerId, status }` — buyer dashboard.
- `{ sellerId, status }` — seller dashboard.
- `{ blockchain.transactionHash }` (sparse) — webhook lookup by hash.
- `{ providerPaymentId }` (sparse) — provider idempotency.
- `{ buyerId, purchaseRequestId, provider, direction }` (unique partial: `provider = 'shkeeper' AND direction = 'in' AND status = 'pending'`, name `uniq_pending_shkeeper_by_buyer_session`) — guards against duplicate pending invoices.
## Postgres Quote Table
The Postgres money-core branch can store oracle quotes in `payment_quotes`, a 1:1 child table keyed by `payment_id → payments.id`. Amount/rate columns use `numeric(38,18)` and the route resolves the PG parent through `payments.legacy_object_id` or `id_map` during the Mongo/PG dual-write window. If the PG payment row is missing, the quote is mirrored to this Mongo `quote` subdocument and a `pg_dualwrite_gaps` row is recorded for reconciliation. This table is quote/audit storage only until the payment service itself is wired through the PG repository path.
## Funds Ledger Repository Seam
Backend `2.8.20` routes `appendFundsLedgerEntry`, `getFundsBalanceByPurchaseRequestId`, and `getFundsBalanceByPaymentId` through `getPaymentRepo()`. In default mode this is still `MongoPaymentRepo`, preserving the existing `FundsLedgerEntry` collection behavior. `REPO_PAYMENT=dual` can mirror ledger writes to Postgres after backfill/verification; `REPO_PAYMENT=pg` should wait until the surrounding payment services, derived destinations, and webhook/update paths are also wired and soaked.
The Drizzle ledger balance path supports both UUID entity refs and external/string refs, which matters for template-checkout rows where `purchaseRequestId` or `paymentId` is not a normal Mongo ObjectId.
## Pre/Post Hooks
None declared.
## Instance Methods
None defined.
## Static Methods
None defined.
Oracle quotes are stored in `payment_quotes`, a 1:1 child table keyed by `payment_id → payments.id`. Amount/rate columns use `numeric(38,18)`. The `payments.legacy_object_id` column supports lookups that originate from legacy references during the migration window.
## Relationships
- **References**: [[User]] (`buyerId`, sometimes `sellerId`), [[PurchaseRequest]] (`purchaseRequestId`), [[SellerOffer]] (`sellerOfferId`).
- **Referenced by**: Indirectly through [[PurchaseRequest]] status transitions and [[Dispute]] resolution amounts; no model holds a direct foreign key back to `Payment`.
- **References**: [[User]] (`buyerId`, `sellerId` via `pgId`), [[PurchaseRequest]] (`purchaseRequestId`), [[SellerOffer]] (`sellerOfferId`).
- **Referenced by**: Indirectly through [[PurchaseRequest]] status transitions and [[Dispute]] resolution amounts; no table holds a direct foreign key back to `payments`.
## State Transitions
@@ -182,22 +166,17 @@ stateDiagram-v2
## Common Queries
```ts
// Buyer history
Payment.find({ buyerId, direction: 'in' }).sort({ createdAt: -1 });
// Buyer history (Drizzle)
db.select().from(payments).where(and(eq(payments.buyerId, buyerId), eq(payments.direction, 'in'))).orderBy(desc(payments.createdAt));
// Seller payouts
Payment.find({ sellerId, direction: 'out', status: 'completed' });
db.select().from(payments).where(and(eq(payments.sellerId, sellerId), eq(payments.direction, 'out'), eq(payments.status, 'completed')));
// Webhook lookup
Payment.findOne({ providerPaymentId });
db.select().from(payments).where(eq(payments.providerPaymentId, providerPaymentId));
// Pending escrows ready for release
Payment.find({ direction: 'in', escrowState: 'releasable' });
// Idempotent invoice creation (will fail by unique index if a pending one exists)
Payment.create({
buyerId, purchaseRequestId, provider: 'shkeeper', direction: 'in', status: 'pending', ...
});
db.select().from(payments).where(and(eq(payments.direction, 'in'), eq(payments.escrowState, 'releasable')));
```
Related: [[PurchaseRequest]], [[SellerOffer]], [[User]], [[Dispute]].

View File

@@ -3,15 +3,27 @@ title: Postgres Runtime Cutover Status
tags: [data-model, postgres, migration, runtime-status]
aliases: [Postgres Status, PG Cutover Status, Mongo vs Postgres Runtime]
created: 2026-05-31
updated: 2026-06-03
updated: 2026-06-06
source: backend integrate-main-into-development@41087c7 + deployment main@8764fdf
---
# Postgres Runtime Cutover Status
> **Current branch:** backend `integrate-main-into-development` at `41087c7`, version `2.8.79`; dev deployment `main` at `8764fdf`.
> **Current branch:** backend `integrate-main-into-development`, version `2.9.12`.
>
> **Bottom line:** the codebase is in **active dual-write phase**. All 11 repository domains in the factory now have Drizzle schemas, Drizzle repos, and dual-write wrappers (except Chat and ReleaseHold, which have Drizzle repos but no dual-write counterpart). 18 Drizzle migrations (00000017) have landed, covering every table in scope. Dev deployment defaults nine PG-capable stores to Postgres: auth-owned users/Telegram auth, confirmation-threshold config/history, user addresses, categories, level config, shop settings, reviews, notifications, and the oracle payment_quotes path. Code-level defaults remain `mongo` outside those deployment overrides. Repository factory normalizes `postgres` and `pg` as equivalent mode tokens. The unmounted legacy marketplace router is detached. As of `2.8.79`, the `guard` user role is in PG schema, chat routes are fixed, notifications deliver in real time, PG response serialization/id resolution in marketplace is corrected, and seller shop lookup is tolerant of uuid/legacy id formats. Reads are still Mongo-authoritative across all dual-write domains — read cutover is the remaining gate for each domain. Chat normalization (participants/messages stored as JSONB blobs, not relational child tables) remains an open blocker for full Chat cutover.
> **Bottom line: Migration complete as of 2026-06-06, backend v2.9.12.** MongoDB and Mongoose have been fully removed from the runtime. PostgreSQL (Drizzle ORM) is the sole database. All 11 repository domains use DrizzleXxxRepo exclusively. No dual-write wrappers are active. TypeScript compilation: 0 errors.
## Migration Status
| Phase | Status |
|---|---|
| Schema design | Complete — 32 tables, 19 migrations (00000019) |
| Drizzle repos | Complete — all 11 factory domains have a DrizzleXxxRepo |
| Dual-write wrappers | Decommissioned — removed from runtime as of v2.9.12 |
| Write cutover | Complete — all writes go to PostgreSQL only |
| Read cutover | Complete — all reads from PostgreSQL |
| Mongoose removal | Complete — no Mongoose imports in runtime src/ |
| TypeScript compilation | 0 errors |
## Schema and Repository Coverage
@@ -33,29 +45,29 @@ All tables below have a `.ts` schema file in `src/db/schema/` and are covered by
**Content/Social:** `blog_posts`, `notifications`, `disputes`, `chats`
Total: **32 tables** across 18 migrations (00000017).
Total: **32 tables** across 19 migrations (00000019).
### Tables with a Drizzle Repository
| Drizzle Repo | Dual-Write Repo | Domain |
|---|---|---|
| `DrizzleUserRepo` | `DualWriteUserRepo` | Users, passkeys, refresh tokens |
| `DrizzlePaymentRepo` | `DualWritePaymentRepo` | Payments, funds ledger |
| `DrizzleMarketplaceRepo` | `DualWriteMarketplaceRepo` | Categories, purchase requests, seller offers, request templates |
| `DrizzleDerivedDestinationRepo` | `DualWriteDerivedDestinationRepo` | Derived destinations, sweeps |
| `DrizzleTrezorAccountRepo` | `DualWriteTrezorAccountRepo` | Trezor accounts, derived addresses |
| `DrizzlePointsRepo` | `DualWritePointsRepo` | Point transactions |
| `DrizzleNotificationRepo` | `DualWriteNotificationRepo` | Notifications |
| `DrizzleDisputeRepo` | `DualWriteDisputeRepo` | Disputes |
| `DrizzleBlogRepo` | `DualWriteBlogRepo` | Blog posts |
| `DrizzleChatRepo` | _(none — no dual-write wrapper)_ | Chats (JSONB shim; Chat normalization is a blocker) |
| `DrizzleReleaseHoldRepo` | _(none — no dual-write wrapper)_ | Release holds (bridges payments + purchase_requests) |
| Drizzle Repo | Domain |
|---|---|
| `DrizzleUserRepo` | Users, passkeys, refresh tokens |
| `DrizzlePaymentRepo` | Payments, funds ledger |
| `DrizzleMarketplaceRepo` | Categories, purchase requests, seller offers, request templates |
| `DrizzleDerivedDestinationRepo` | Derived destinations, sweeps |
| `DrizzleTrezorAccountRepo` | Trezor accounts, derived addresses |
| `DrizzlePointsRepo` | Point transactions |
| `DrizzleNotificationRepo` | Notifications |
| `DrizzleDisputeRepo` | Disputes |
| `DrizzleBlogRepo` | Blog posts |
| `DrizzleChatRepo` | Chats (JSONB shim; Chat normalization is an optional future improvement) |
| `DrizzleReleaseHoldRepo` | Release holds (bridges payments + purchase_requests) |
Tables with schema but no dedicated Drizzle repo yet: `addresses` (handled via addressStore facade), `shop_settings` (handled via shopSettings facade), `config_settings` / `config_setting_history` (handled via config-store facade), `telegram_links` / `telegram_sessions` (handled via auth-store facade), `reviews` (handled via review-store facade).
Tables with schema but no dedicated Drizzle repo (handled via store facades): `addresses`, `shop_settings`, `config_settings` / `config_setting_history`, `telegram_links` / `telegram_sessions`, `reviews`.
### Migration Count
18 migrations landed: **0000 through 0017**.
19 migrations landed: **0000 through 0019**.
| Migration | Key change |
|---|---|
@@ -77,88 +89,86 @@ Tables with schema but no dedicated Drizzle repo yet: `addresses` (handled via a
| 0015 | Ledger immutability extended: UPDATE + DELETE triggers |
| 0016 | `address_type` enum + `addresses` table |
| 0017 | `guard` value added to `user_role` enum |
| 0018 | AI request fields |
| 0019 | `payment_provider` enum: added `escrow` |
## What Uses Postgres Now
All domains are PostgreSQL-only. The table below summarises the runtime topology for reference.
| Area | Runtime status | Notes |
|---|---|---|
| Postgres connection | Available when `PG_URL` is set | Store facades use `src/infrastructure/postgres/client.ts`; the broader `src/db/` Drizzle layer and repository factory are fully populated. |
| Runtime schema bootstrap | Implemented for auth, config, address, and reference stores | Auth tables bootstrapped from `src/services/auth/postgresAuthSchema.ts`; store facades bootstrap their own tables at startup when their `*_STORE=postgres` flag is enabled. |
| Health observability | Implemented in `/api/health` | `checks.postgres` reports `configured`, `required`, `storeModes`, `enabledStores`, and `enabledStoreCount`. Dev Gatus asserts all dev PG-backed store modes are `postgres`, including notifications. Mongoose health check is lazy-loaded and skipped when Mongo is optional under `MONGO_CONNECT_MODE=auto/never`. |
| Auth-owned user store | PG-backed in dev deployment; code opt-in with `AUTH_STORE=postgres` | Auth, passkey, Telegram auth/link/session/temp-verification, and `/api/user` profile paths use an auth-store facade. PG-mode users are mirrored back to Mongo through `legacy_object_id` for compatibility with still-Mongo services. |
| Confirmation-threshold runtime config | PG-backed in dev deployment; code opt-in with `CONFIG_STORE=postgres` | `ConfigSetting` / `ConfigSettingHistory` access for `/api/admin/settings/confirmation-thresholds` and transaction-safety confirmation thresholds uses a config-store facade. PG-mode writes mirror back to Mongo. Legacy models load only for Mongo fallback/backfill/mirror paths. |
| User addresses | PG-backed in dev deployment; code opt-in with `ADDRESS_STORE=postgres` | `/api/addresses` CRUD uses an address-store facade. PG mode enforces one primary address per user with a partial unique index and mirrors writes/deletes back to Mongo. |
| Marketplace categories | PG-backed in dev deployment; code opt-in with `CATEGORY_STORE=postgres` | `CategoryService` and the default `General` category path use a category-store facade. PG-mode writes mirror back to Mongo. Migration 0009 deactivated duplicate active category labels and enforces `categories_active_name_norm_uq` on `lower(btrim(name)) WHERE is_active = true`. |
| Level configuration | PG-backed in dev deployment; code opt-in with `LEVEL_CONFIG_STORE=postgres` | `PointsService` level reads use a level-config facade. `PointTransaction` and user points remain Mongo-backed. `LEVEL_STORE=postgres` accepted as compatibility alias. |
| Shop settings | PG-backed in dev deployment; code opt-in with `SHOP_SETTINGS_STORE=postgres` | Shop settings controller, seller payment rail resolution (`2.8.56` fixes seller shop lookup to handle both uuid and legacy id formats), and review enable/disable checks use a shop-settings facade. PG-mode writes mirror back to Mongo. |
| Marketplace reviews | PG-backed in dev deployment; code opt-in with `REVIEW_STORE=postgres` | Review list/summary/create routes use a review-store facade. PG-mode list responses still hydrate `reviewerId` from the user mirror to preserve frontend shape. |
| Notifications | PG-backed in dev deployment; code opt-in with `NOTIFICATION_STORE=postgres` or `REPO_NOTIFICATION=pg` | `NotificationService` uses `getNotificationRepo()` for create/list/read/delete/count paths. `2.8.55` fixes chat routes and delivers notifications in real time. `2.8.37` fixes repository mode aliasing so `postgres` resolves to the Drizzle notification repo. Backfill script (`npm run backfill:notification:postgres`) and smoke script (`scripts/smoke/notifications-postgres.sh`) are available. |
| Oracle quote persistence | Conditional runtime PG write | `/api/payment/request-network/intents` lazily imports `quoteRepo` only when `ORACLE_QUOTING_ENABLED=true`; it writes `payment_quotes` if the PG parent payment row exists, mirrors to Mongo `Payment.quote`, and records `pg_dualwrite_gaps` if PG is behind. |
| Funds ledger | Repository-backed, default Mongo | `appendFundsLedgerEntry` and `getFundsBalanceBy*` call `getPaymentRepo()`. Default is `MongoPaymentRepo`; `REPO_PAYMENT=dual`/`pg` exercises the Drizzle ledger after backfill/soak. |
| Backfill/verify scripts | Available as operator tooling | `MIGRATION_PG_URL` drives all backfill scripts; guards restrict allowed target hosts. Marketplace-core runner backfills users/categories, request templates, purchase requests, seller offers, and `selectedOfferId` remap in dependency order. Not run automatically at startup. |
| Guard user role | PG schema-ready | Migration 0017 adds `guard` to the `user_role` enum. `2.8.54` adds guard role support across auth and user management. |
| PG response serialization | Fixed in `2.8.512.8.53` | PG response serialization and id resolution in marketplace purchase-request paths corrected; user creation and purchase request unblocked from a PG FK constraint error. |
| Admin user management | PG-capable as of `2.8.50` | Admin user count queries route through postgres-capable stores; admin user management works end-to-end under PG. |
| Seeds | Postgres-capable as of `2.8.47` | Seeds in `src/seeds/*` are store-aware and idempotent; can seed fresh PG under `MONGO_CONNECT_MODE=never`. |
| Postgres connection | Required — `PG_URL` must be set | Store facades use `src/infrastructure/postgres/client.ts`; the broader `src/db/` Drizzle layer and repository factory are fully populated. |
| Runtime schema bootstrap | Implemented for auth, config, address, and reference stores | Auth tables bootstrapped from `src/services/auth/postgresAuthSchema.ts`; store facades bootstrap their own tables at startup. |
| Health observability | Implemented in `/api/health` | `checks.postgres` reports `configured`, `required`, `storeModes`, `enabledStores`, and `enabledStoreCount`. Mongoose health check is no longer present. |
| Auth-owned user store | PG-backed | Auth, passkey, Telegram auth/link/session/temp-verification, and `/api/user` profile paths use the auth-store facade pointing at Postgres. `legacy_object_id` column retained for id-map compatibility. |
| Confirmation-threshold runtime config | PG-backed | `ConfigSetting` / `ConfigSettingHistory` access routes through the config-store facade. |
| User addresses | PG-backed | `/api/addresses` CRUD uses the address-store facade. |
| Marketplace categories | PG-backed | `CategoryService` and the default `General` category path use the category-store facade. `categories_active_name_norm_uq` enforced. |
| Level configuration | PG-backed | `PointsService` level reads use the level-config facade. `LEVEL_STORE=postgres` accepted as compatibility alias. |
| Shop settings | PG-backed | Shop settings controller, seller payment rail resolution, and review enable/disable checks use the shop-settings facade. Seller shop lookup handles both uuid and legacy id formats. |
| Marketplace reviews | PG-backed | Review list/summary/create routes use the review-store facade. |
| Notifications | PG-backed | `NotificationService` uses `getNotificationRepo()` for create/list/read/delete/count paths. |
| Oracle quote persistence | PG write when `ORACLE_QUOTING_ENABLED=true` | `/api/payment/request-network/intents` writes `payment_quotes` to PG. Mongo mirror path removed. |
| Funds ledger | PG-backed | `appendFundsLedgerEntry` and `getFundsBalanceBy*` call `getPaymentRepo()` which resolves to `DrizzlePaymentRepo`. |
| Payments and escrow state | PG-backed | All payment services use Drizzle repos; Mongoose `Payment` model removed. |
| Derived destinations and sweeps | PG-backed | `getDerivedDestinationRepo()` resolves to `DrizzleDerivedDestinationRepo`. |
| Points/referrals/transactions | PG-backed | `getPointsRepo()` resolves to `DrizzlePointsRepo`. |
| Chat/messages | PG-backed (JSONB shim) | `getChatRepo()` resolves to `DrizzleChatRepo`. Participants/messages are stored as JSONB blobs; normalization into relational child tables is an optional future improvement. |
| Disputes/blog | PG-backed | Both resolve to Drizzle repos. |
| ReleaseHold | PG-backed | `getReleaseHoldRepo()` resolves to `DrizzleReleaseHoldRepo`. |
| Backfill/verify scripts | Available as operator tooling | `MIGRATION_PG_URL` drives all backfill scripts. Not run automatically at startup. |
| Guard user role | PG schema-ready | Migration 0017 adds `guard` to the `user_role` enum. |
| Seeds | Postgres-capable | Seeds in `src/seeds/*` are store-aware and idempotent under `MONGO_CONNECT_MODE=never`. |
## What Is Still Mongo-Backed
## What Was Mongo-Backed (Historical)
Writes across all dual-write domains go to both Mongo and Postgres. Reads remain Mongo-authoritative for every dual-write domain — read cutover has not been performed for any domain. Chat is repository-backed (Drizzle repo exists) but participants/messages are stored as JSONB blobs rather than normalized child tables; Chat normalization is the primary structural blocker for Chat read cutover.
All domains are now PostgreSQL-only as of v2.9.12. The following were the remaining Mongo-backed areas prior to the final cutover:
| Domain | Current live store | Why not Postgres yet |
|---|---|---|
| User reads | MongoDB authoritative | Auth-owned users can be PG-backed for writes, but reads remain Mongo-authoritative. Still-Mongo domains expect Mongo ObjectId user references; PG-mode writes maintain a Mongo mirror until all consumers cut over. |
| Admin cleanup / seed address tooling | MongoDB | User-facing address CRUD is PG-capable, but admin cleanup scripts still operate on Mongo first. Seed scripts backfill addresses to PG when `ADDRESS_STORE=postgres`. |
| Marketplace requests/offers/templates reads | Repository-backed writes; Mongo reads | `getMarketplaceRepo()` wires all marketplace writes. `REPO_MARKETPLACE` defaults to Mongo; full PG/dual read cutover needs smoke coverage before flipping. The legacy `marketplaceRouter` is detached from the service index (`2.8.36`). |
| Payments and escrow state reads | MongoDB primary | Request Network, AMN scanner, webhook, admin, release/refund, adapter, reconciliation, and legacy payment paths still create/update `Payment` Mongoose documents directly for reads. Payment Drizzle repo and dual-write repo exist; `REPO_PAYMENT=dual` enables dual-writes, but read paths remain Mongo. |
| Derived destinations and sweeps | Repository-backed writes; Mongo reads | `getDerivedDestinationRepo()` wires writes; `REPO_DERIVED_DESTINATION` defaults to Mongo and has not been flipped in dev. |
| Points/referrals/transactions | Repository-backed writes; Mongo reads | `getPointsRepo()` wires writes; `REPO_POINTS` defaults to Mongo. Level config is PG-capable for reads; point transaction and user-point flows are not flipped in dev. |
| Chat/messages | Repository-backed writes (JSONB shim); Mongo reads | `getChatRepo()` wires writes. `REPO_CHAT` / `CHAT_STORE` defaults to Mongo. `DrizzleChatRepo` uses JSONB blobs for participants and messages — Chat normalization into relational child tables is required before safe read cutover. No dual-write wrapper exists. |
| Disputes/blog | Repository-backed writes; Mongo reads | Both have Drizzle repos and dual-write wrappers. Code defaults resolve to Mongo until `REPO_DISPUTE`/`BLOG_STORE` are flipped. |
| ReleaseHold | Drizzle repo only; default Mongo | `getReleaseHoldRepo()` returns Drizzle or Mongo; no dual-write wrapper — `dual` mode silently uses Mongo. Separate cutover needed. |
| Runtime config outside confirmation thresholds | MongoDB | `ConfigSetting` and `ConfigSettingHistory` are PG-capable for confirmation thresholds only; other admin-editable settings need to route through the config-store boundary before counting as cut over. |
| Telegram link/session/temp verification reads | PG-backed writes in dev; Mongo reads in code default | These records move with `AUTH_STORE=postgres`. Dev compose defaults that flag to `postgres`; read cutover follows the auth-store flag. |
- **User reads** — Auth-owned users were PG-backed for writes but reads remained Mongo-authoritative until the full auth cutover.
- **Marketplace requests/offers/templates reads** — `REPO_MARKETPLACE` defaulted to Mongo; read cutover required smoke coverage.
- **Payments and escrow state reads** — Payment services called Mongoose documents directly for reads until the final payment-domain wiring was completed.
- **Derived destinations and sweeps** — `REPO_DERIVED_DESTINATION` defaulted to Mongo.
- **Points/referrals/transactions** — `REPO_POINTS` defaulted to Mongo.
- **Chat/messages** — `getChatRepo()` defaulted to Mongo; JSONB shim was the Drizzle path. No dual-write wrapper existed.
- **Disputes/blog** — Defaulted to Mongo until `REPO_DISPUTE`/`BLOG_STORE` were flipped.
- **ReleaseHold** — No dual-write wrapper; required explicit flip.
All of the above are now fully PostgreSQL-backed. MongoDB and Mongoose have been removed from the runtime.
## Env Flag Reality
The backend code defaults every store flag below to `mongo`. Dev deployment overrides eight PG-capable store flags to `postgres` in `deployment/docker-compose.yml`. Repository factory normalizes `postgres` and `pg` as equivalent.
All `*_STORE=mongo` and `REPO_*=mongo` env flags are obsolete — the repository factory only supports `postgres`/`pg` mode. `MONGO_URI` and `MONGO_CONNECT_MODE` have been removed from the runtime.
| Flag | Current meaning |
|---|---|
| `AUTH_STORE` | Code default `mongo`; dev deployment default `postgres`. Routes auth-owned users, refresh tokens, passkeys, Telegram links/sessions, and temp verifications through Postgres. |
| `CONFIG_STORE` | Code default `mongo`; dev deployment default `postgres`. Routes confirmation-threshold settings/history through Postgres. |
| `ADDRESS_STORE` | Code default `mongo`; dev deployment default `postgres`. Routes `/api/addresses` through Postgres. |
| `CATEGORY_STORE` | Code default `mongo`; dev deployment default `postgres`. Routes marketplace category reads/writes through Postgres. Active PG categories are unique by normalized visible name. |
| `LEVEL_CONFIG_STORE` | Code default `mongo`; dev deployment default `postgres`. Routes level configuration reads and seed replacement through Postgres. `LEVEL_STORE=postgres` accepted as compatibility alias. |
| `SHOP_SETTINGS_STORE` | Code default `mongo`; dev deployment default `postgres`. Routes shop settings, review gates, and seller payment rails through Postgres. `2.8.56` fixes seller shop lookup tolerance for uuid vs legacy id formats. |
| `REVIEW_STORE` | Code default `mongo`; dev deployment default `postgres`. Routes marketplace reviews through Postgres. |
| `NOTIFICATION_STORE` / `REPO_NOTIFICATION` | Code default `mongo`; dev deployment default `postgres`. Routes notification inbox create/list/read/delete/count through the Drizzle notification repo. `postgres` and `pg` both resolve correctly since `2.8.37`. |
| `PG_URL` | Makes PG code importable/reachable. Required for any `*_STORE=postgres` flag; does not cut over unrelated app domains by itself. |
| `MIGRATION_PG_URL` | Used by backfill scripts and migration runbooks; not part of normal request handling. Marketplace-core dry-run/non-dry backfills also require `MIGRATION_MONGO_URL`. |
| `REPO_PAYMENT` | Code default `mongo`. Funds ledger appends and balance reads route through this flag. `dual` mode enables dual-write for the ledger seam. Do not flip broad payment runtime to `pg` yet; most payment services still call Mongoose directly for reads. |
| `REPO_MARKETPLACE` | Code default `mongo`. All marketplace writes route through `getMarketplaceRepo()`. Full read cutover needs smoke coverage. |
| `REPO_USER`, `REPO_POINTS`, `REPO_DERIVED_DESTINATION`, `REPO_TREZOR` | Factory flags with full trio (mongo/dual/pg). Writes are wired; reads remain Mongo-authoritative until each flag is flipped and verified. |
| `REPO_DISPUTE` / `DISPUTE_STORE`, `REPO_BLOG` / `BLOG_STORE` | Code default `mongo`. Dual-write wrappers exist; flip per-domain after verification. |
| `REPO_CHAT` / `CHAT_STORE` | Code default `mongo`. Chat normalization (JSONB→relational) is a structural blocker; no dual-write wrapper. |
| `REPO_RELEASE_HOLD` / `RELEASE_HOLD_STORE` | Code default `mongo`. No dual-write wrapper; `dual` silently uses Mongo. Must be flipped explicitly. As of `2.8.37`, `REPO_DISPUTE=pg` no longer leaks into release hold mode. |
| `ORACLE_QUOTING_ENABLED` | Enables server-side quote computation and the `payment_quotes` PG write in checkout when a PG parent payment row exists. |
| `MONGO_CONNECT_MODE` | Handled in Mongoose connection setup (not in the repository factory). `auto`/`never` allow PG-only boot; lazy Mongoose health check skips when Mongo is optional. |
| `MONGO_URI` | REMOVED — MongoDB has been removed from the runtime. |
| `MONGO_CONNECT_MODE` | REMOVED — MongoDB has been removed from the runtime. |
| `AUTH_STORE` | OBSOLETE — only `postgres` is valid. Setting to `mongo` has no effect. |
| `CONFIG_STORE` | OBSOLETE — only `postgres` is valid. |
| `ADDRESS_STORE` | OBSOLETE — only `postgres` is valid. |
| `CATEGORY_STORE` | OBSOLETE — only `postgres` is valid. |
| `LEVEL_CONFIG_STORE` | OBSOLETE — only `postgres` is valid. `LEVEL_STORE=postgres` accepted as alias. |
| `SHOP_SETTINGS_STORE` | OBSOLETE — only `postgres` is valid. |
| `REVIEW_STORE` | OBSOLETE — only `postgres` is valid. |
| `NOTIFICATION_STORE` / `REPO_NOTIFICATION` | OBSOLETE — only `postgres`/`pg` is valid. |
| `PG_URL` | REQUIRED — PostgreSQL is the sole database. All store facades and repos require this. |
| `MIGRATION_PG_URL` | Used by backfill scripts and migration runbooks; not part of normal request handling. |
| `REPO_PAYMENT` | OBSOLETE — only `postgres` is valid. All payment services use `DrizzlePaymentRepo`. |
| `REPO_MARKETPLACE` | OBSOLETE — only `postgres` is valid. All marketplace writes and reads route through `DrizzleMarketplaceRepo`. |
| `REPO_USER`, `REPO_POINTS`, `REPO_DERIVED_DESTINATION`, `REPO_TREZOR` | OBSOLETE — only `postgres` is valid. All resolve to their respective Drizzle repos. |
| `REPO_DISPUTE` / `DISPUTE_STORE`, `REPO_BLOG` / `BLOG_STORE` | OBSOLETE — only `postgres` is valid. |
| `REPO_CHAT` / `CHAT_STORE` | OBSOLETE — only `postgres` is valid. `DrizzleChatRepo` is the sole chat repo. |
| `REPO_RELEASE_HOLD` / `RELEASE_HOLD_STORE` | OBSOLETE — only `postgres` is valid. |
| `ORACLE_QUOTING_ENABLED` | Enables server-side quote computation and the `payment_quotes` PG write in checkout. |
## Overall Migration Phase
## What's Next (Post-Migration)
| Phase | Status |
|---|---|
| Schema design | Complete — 32 tables, 18 migrations (00000017) |
| Drizzle repos | Complete — all 11 factory domains have a Drizzle repo |
| Dual-write wrappers | Mostly complete — 9 of 11 domains have a dual-write wrapper (Chat and ReleaseHold are exceptions) |
| Write cutover (dual-write active) | Not yet enabled by default — `REPO_DEFAULT` is still `mongo`; must be flipped per-domain with care |
| Read cutover | Not started for any domain — Mongo remains authoritative for all reads |
| Prod backfill | Not run — backfill scripts are operator-ready but not executed against production |
| Chat normalization | Blocked — participants/messages stored as JSONB; relational normalization required before Chat read cutover |
1. **Prod backfill** — If the production instance was running Mongo-backed data before the cutover, a one-time backfill from Mongo to Postgres under a maintenance window is required. Use `MIGRATION_PG_URL` + `MIGRATION_MONGO_URL` with the existing backfill scripts. Validate row counts before switching prod traffic.
2. **Chat normalization** — The `DrizzleChatRepo` currently stores participants and messages as JSONB blobs rather than normalized relational child tables. This is an optional future improvement; it does not block current operation but would enable richer querying and FK integrity on chat data.
3. **`payment_provider` enum `escrow` value** — Confirm migration 0019 has been applied on all target databases (adds `escrow` to the `payment_provider` enum). If not already run, apply it before using escrow-provider payment records.
Estimated overall: **schema and infrastructure phase complete; write-seam phase substantially complete; read cutover and backfill execution remain for every domain.**
## Recent Progress Since Last Update (2.8.37 → 2.8.79)
## Recent Progress Since Last Update (2.8.37 → 2.9.12)
- **2.8.382.8.46:** Complete dual-write repos for all remaining domains; Drizzle migrations pipeline finalized; TTL scheduler added; shop lookup bug-fixed.
- **2.8.47:** Seeds made Postgres-capable and idempotent for PG-only boot (`MONGO_CONNECT_MODE=never`).
@@ -181,34 +191,7 @@ Estimated overall: **schema and infrastructure phase complete; write-seam phase
- **2.8.77:** Telegram keep email code panel mounted after sending.
- **2.8.78:** Telegram system messages neutral + post-delivery seller review.
- **2.8.79:** Request template maxUsage made truly optional; template creation 500 fix.
- **2.8.512.8.53:** PG response serialization and id resolution corrected in marketplace; user creation and purchase request creation unblocked from PG FK constraint errors.
- **2.8.54:** `guard` user role added to `user_role` enum (migration 0017); guard role support across auth and user management.
- **2.8.55:** Chat routes fixed; notifications delivered in real time alongside chat.
- **2.8.56:** Seller shop lookup made tolerant of both uuid and legacy id formats; `dataCleanupService` guarded against `MONGO_CONNECT_MODE=never`.
## Next Cutover Work
1. Apply Drizzle migrations to the target Postgres database (00000017 must be in-order; 0002 is a reset migration — confirm idempotency on existing instances).
2. For dev/test data, run the existing backfills or reseed acceptable test data before relying on PG-backed stores. The deployment default flip does not move historical Mongo rows.
3. For auth cutover, run `PG_URL=... npm run backfill:auth:postgres`, verify counts, and confirm `AUTH_STORE=postgres` in the target runtime.
4. For confirmation-threshold config cutover, run `PG_URL=... npm run backfill:config:postgres`, verify counts/history, and confirm `CONFIG_STORE=postgres`.
5. For address cutover, run `PG_URL=... npm run backfill:address:postgres`, verify one-primary invariants, and confirm `ADDRESS_STORE=postgres`.
6. For reference-domain cutover, run:
- `PG_URL=... npm run backfill:category:postgres`
- `PG_URL=... npm run backfill:level-config:postgres`
- `PG_URL=... npm run backfill:shop-settings:postgres`
- `PG_URL=... npm run backfill:review:postgres`
7. Run `PG_URL=... scripts/smoke/categories-postgres-unique.sh` and `PG_URL=... MONGODB_URI=... scripts/smoke/reference-stores-postgres.sh`, then confirm reference store flags in non-prod.
8. For marketplace-core data, run `MIGRATION_MONGO_URL=... MIGRATION_PG_URL=... npm run backfill:marketplace-core:postgres:dry-run`, then the non-dry run. The group runs root dependencies, RequestTemplate rows, PurchaseRequest main rows, SellerOffer rows, then the selected-offer remap.
9. Run `scripts/smoke/marketplace-core-postgres-backfill.sh` with the same migration DSNs and record row-count/checksum results.
10. For notifications, run `PG_URL=... npm run backfill:notification:postgres` and `PG_URL=... scripts/smoke/notifications-postgres.sh` against dev to validate the current default.
11. Enable `REPO_MARKETPLACE=dual`, `REPO_PAYMENT=dual`, `REPO_POINTS=dual`, `REPO_DERIVED_DESTINATION=dual`, `REPO_TREZOR=dual`, `REPO_DISPUTE=dual`, `REPO_BLOG=dual` one domain at a time after backfill verification, and run a soak window before flipping reads.
12. Continue payment-domain wiring: add missing payment repo methods for provider lookups, transaction-hash/webhook lookups, metadata/blockchain patching, template duplicate cleanup, and quote updates before moving `paymentService`, `paymentCoordinator`, RN, or AMN scanner routes.
13. Add a derived-destination/sweep repository seam before payment PG read cutover; destination allocation is payment-address state and should not remain Mongo-only once payments become PG-backed for reads.
14. Resolve Chat normalization: design relational child tables for participants and messages; migrate `DrizzleChatRepo` away from JSONB blobs; add `DualWriteChatRepo`; flip `REPO_CHAT=dual` only after normalization.
15. Add `DualWriteReleaseHoldRepo`; flip `REPO_RELEASE_HOLD=dual` explicitly after wiring is proven.
16. Flip reads to `pg` per domain only after zero-diff shadow reads and a documented rollback plan are in place.
17. Run prod backfill under a maintenance window with `MIGRATION_PG_URL` pointing at prod; validate row counts before cutting over reads.
- **2.9.x:** Full MongoDB/Mongoose removal — all Mongoose models replaced by Drizzle repos, dual-write decommissioned, TypeScript compiles with 0 errors (2026-06-06).
## Related Docs

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]].

View File

@@ -1,58 +1,29 @@
---
title: SellerOffer
tags: [data-model, mongoose, postgres]
tags: [data-model, postgres]
aliases: [Seller Offer, Bid, ISellerOffer]
---
# SellerOffer
> **Last updated:** 2026-06-03added Postgres/Drizzle table definition and migration status.
> **Last updated:** 2026-06-06MongoDB/Mongoose fully removed; PostgreSQL + Drizzle is now the only database layer.
A seller's bid against a [[PurchaseRequest]]. Stores the proposed price, the delivery time commitment, optional notes/attachments, and a small status machine (`pending` / `accepted` / `rejected` / `withdrawn`). The parent `PurchaseRequest` keeps the array of offer ids in `offers[]` and the chosen one in `selectedOfferId`.
> [!note] Source
> `backend/src/models/SellerOffer.ts:24` — Mongoose schema definition
> `backend/src/models/SellerOffer.ts:100` — Mongoose model export
> `backend/src/db/schema/sellerOffer.ts` — Drizzle/Postgres table definition
## Migration Status
**DUAL-WRITE** — part of `DualWriteMarketplaceRepo`. Writes go to both MongoDB and Postgres; reads still come from MongoDB.
> `backend/src/db/schema/sellerOffer.ts` — PostgreSQL schema (Drizzle) definition
## Schema
### Mongoose (MongoDB)
| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `sellerId` | ObjectId → [[User]] | yes | — | — | yes | Seller submitting the bid. |
| `purchaseRequestId` | ObjectId → [[PurchaseRequest]] | yes | — | — | yes | Parent request. |
| `title` | String | yes | — | trim, maxlength 200 | — | Offer headline. |
| `description` | String | yes | — | trim, maxlength 1000 | — | Pitch and details. |
| `price.amount` | Number | yes | — | min 0 | — | Quoted amount. |
| `price.currency` | String | yes | `USDT` | enum: `USD` / `EUR` / `IRR` / `TRY` / `USDT` / `USDC` | — | Quote currency. `TRY` is supported by the oracle/depeg path through the off-chain FX provider. |
| `deliveryTime.amount` | Number | yes | — | min 1 | — | Numeric ETA. |
| `deliveryTime.unit` | String | yes | — | enum: `hours` / `days` / `weeks` | — | ETA unit. |
| `status` | String | no | `pending` | enum: `pending` / `accepted` / `rejected` / `withdrawn` / `active` | yes | Offer status. |
| `attachments[]` | String[] | no | — | — | — | URLs of supporting files. |
| `notes` | String | no | — | trim | — | Internal/private notes. |
| `validUntil` | Date | no | — | — | — | Expiration. |
| `requireAmlCheck` | Boolean | no | — | — | — | If true, AML screening must pass before the offer is presented to the buyer. |
| `amlBlockOnFailure` | Boolean | no | — | — | — | If true and AML screening fails, the offer is blocked. Otherwise it is flagged for manual review. |
| `createdAt` | Date | auto | — | — | yes (desc) | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
> **Status enum note:** `active` is accepted by the current backend schema for marketplace/listing flows, in addition to the negotiation statuses `pending | accepted | rejected | withdrawn`.
### Postgres (Drizzle) — `seller_offers`
### PostgreSQL schema (Drizzle) — `seller_offers`
Table: `seller_offers` | Schema file: `backend/src/db/schema/sellerOffer.ts`
| PG Column | Drizzle Type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | `uuid` PK | no | `gen_random_uuid()` | PG primary key |
| `legacy_object_id` | `text` | yes | — | Mongo ObjectId bridge; partial-unique WHERE NOT NULL |
| `seller_id` | `uuid` FK → `users` CASCADE | no | — | Maps from `sellerId` |
| `id` | `uuid` PK | no | `gen_random_uuid()` | Primary key (UUID string) |
| `legacy_object_id` | `text` | yes | — | Former Mongo ObjectId; partial-unique WHERE NOT NULL |
| `seller_id` | `uuid` FK → `users` CASCADE | no | — | Maps from `sellerId` (uses user.pgId) |
| `purchase_request_id` | `uuid` FK → `purchase_requests` CASCADE | no | — | Maps from `purchaseRequestId` |
| `title` | `varchar(200)` | no | — | |
| `description` | `varchar(1000)` | no | — | |
@@ -84,6 +55,8 @@ Table: `seller_offers` | Schema file: `backend/src/db/schema/sellerOffer.ts`
**Money precision note:** `price_amount` uses `numeric(18,8)` — differs from the `numeric(38,18)` used by `payments` and `funds_ledger_entries`. This matches the Migration Guide specification for offer amounts.
**ID note:** The primary key is `id` (UUID string), not `_id`. `legacy_object_id` retains the former MongoDB ObjectId for backfill/bridging purposes only and is not used by any runtime query.
#### Postgres Indexes
| Index | Type | Notes |
@@ -95,22 +68,43 @@ Table: `seller_offers` | Schema file: `backend/src/db/schema/sellerOffer.ts`
| `(purchase_request_id, seller_id)` | btree | composite |
| `legacy_object_id` | partial-unique | WHERE NOT NULL; idempotent backfill upserts |
## Domain Fields (TypeScript)
| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | `string` (UUID) | yes | auto | PG primary key; replaces former `_id` ObjectId |
| `sellerId` | `string` (UUID) | yes | — | user.pgId of the submitting seller |
| `purchaseRequestId` | `string` (UUID) | yes | — | Parent request |
| `title` | `string` | yes | — | Offer headline (max 200) |
| `description` | `string` | yes | — | Pitch and details (max 1000) |
| `price.amount` | `number` | yes | — | Quoted amount (min 0) |
| `price.currency` | `string` | yes | `USDT` | `USD` / `EUR` / `IRR` / `TRY` / `USDT` / `USDC` |
| `deliveryTime.amount` | `number` | yes | — | Numeric ETA (min 1) |
| `deliveryTime.unit` | `string` | yes | — | `hours` / `days` / `weeks` |
| `status` | `string` | no | `pending` | `pending` / `accepted` / `rejected` / `withdrawn` / `active` |
| `attachments[]` | `string[]` | no | — | URLs of supporting files |
| `notes` | `string` | no | — | Internal/private notes |
| `validUntil` | `Date` | no | — | Expiration |
| `requireAmlCheck` | `boolean` | no | — | AML screening required before presenting to buyer |
| `amlBlockOnFailure` | `boolean` | no | — | Block offer on AML failure (vs. flag for review) |
| `createdAt` | `Date` | auto | — | |
| `updatedAt` | `Date` | auto | — | |
> **Status enum note:** `active` is accepted by the current backend schema for marketplace/listing flows, in addition to the negotiation statuses `pending | accepted | rejected | withdrawn`.
> **Currency note:** `TRY` is supported by the oracle/depeg path through the off-chain FX provider.
## UpdateSellerOfferInput
`UpdateSellerOfferInput` does **not** include an `updatedAt` field — the column is managed automatically by the database (`now()` default; updated by the repo layer on write).
## Virtuals
None defined.
## Mongoose Indexes
Defined at `backend/src/models/SellerOffer.ts:95-98`:
- `{ sellerId: 1 }`
- `{ purchaseRequestId: 1 }`
- `{ status: 1 }`
- `{ createdAt: -1 }`
## Pre/Post Hooks
None declared.
None declared (Drizzle ORM does not use Mongoose-style lifecycle hooks).
## Instance Methods
@@ -136,7 +130,7 @@ The frontend exposes this via the `withdrawOffer(offerId)` action in `src/action
## Relationships
- **References**: [[User]] (`sellerId`), [[PurchaseRequest]] (`purchaseRequestId`).
- **References**: [[User]] (`sellerId` = user.pgId), [[PurchaseRequest]] (`purchaseRequestId`).
- **Referenced by**: [[PurchaseRequest]] (`offers[]`, `selectedOfferId`), [[Payment]] (`sellerOfferId`), [[Chat]] (`relatedTo.id` when `relatedTo.type === 'SellerOffer'`).
- **PG FKs**: `seller_offers.seller_id → users.id CASCADE`, `seller_offers.purchase_request_id → purchase_requests.id CASCADE`.
- **Referenced by (PG)**: `payments.seller_offer_id` (polymorphic triple), `payment_quotes` (via payment join).
@@ -156,25 +150,6 @@ stateDiagram-v2
## Common Queries
### MongoDB
```ts
// Offers for a request
SellerOffer.find({ purchaseRequestId }).sort({ createdAt: -1 });
// Seller's active offers
SellerOffer.find({ sellerId, status: 'pending' });
// Reject siblings on accept
SellerOffer.updateMany(
{ purchaseRequestId, _id: { $ne: acceptedId }, status: 'pending' },
{ status: 'rejected' }
);
// Cleanup expired offers
SellerOffer.find({ validUntil: { $lt: new Date() }, status: 'pending' });
```
### Postgres (Drizzle)
```ts

View File

@@ -1,24 +1,34 @@
---
title: User
tags: [data-model, mongoose, postgres, dual-write]
tags: [data-model, postgres, drizzle]
aliases: [User Model, IUser, Account]
---
# User
> **Last updated:** 2026-06-03added Postgres/Drizzle schema, `guard` role (migration 0017), dual-write status. Previous update: 2026-05-29 (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md))
> **Last updated:** 2026-06-06MongoDB fully removed; PostgreSQL + Drizzle is the only database layer (backend v2.9.12). Previous update: 2026-06-03 (dual-write status, guard role).
The core identity document for every actor in the marketplace: buyers, sellers, and admins. Stores credentials (password + WebAuthn passkeys), profile/preference data, referral bookkeeping, point balances, and a soft-delete status flag. Almost every other model carries an `ObjectId` (Mongo) or `uuid` (Postgres) reference back to `User`, so this collection is the relational hub of the system.
The core identity document for every actor in the marketplace: buyers, sellers, and admins. Stores credentials (password + WebAuthn passkeys), profile/preference data, referral bookkeeping, point balances, and a soft-delete status flag. Almost every other model carries a `uuid` (Postgres) reference back to `User`, so this table is the relational hub of the system.
> [!info] Migration status: DUAL-WRITE
> Writes go to **both** MongoDB (`User` collection) and Postgres (`users` table) via `DualWriteUserRepo`.
> Reads still come from **MongoDB** — PG reads are not yet enabled.
> Repositories: `DrizzleUserRepo`, `MongoUserRepo`, `DualWriteUserRepo`
> [!info] Migration status: COMPLETE
> MongoDB and Mongoose have been fully removed from the backend runtime. PostgreSQL + Drizzle ORM is the sole database layer (19 migrations landed, 00000019, 32 tables).
> Repository: `DrizzleUserRepo` (returned exclusively by the repository factory)
> Postgres table: **`users`** — `backend/src/db/schema/users.ts`
---
## Postgres Table: `users`
## ID Duality
| Field | Storage | Purpose |
| --- | --- | --- |
| `id` (PG column) / `pgId` (domain object) | `uuid`, PG primary key | Used for all marketplace foreign keys: `offer.sellerId`, `purchaseRequest.buyerId`, `payment.buyerId/sellerId`, etc. |
| `legacy_object_id` (PG column) / `_id` (domain/auth tokens) | `text`, 24-hex ObjectId string | Kept for backward compatibility with socket rooms (rooms keyed by legacy id) and auth tokens issued before migration. Partial-unique index WHERE NOT NULL. |
> [!warning] Always match marketplace FKs on `pgId` (UUID), not on `legacy_object_id`. Notifications and socket rooms use the legacy id string.
---
## PostgreSQL Schema (Drizzle): `users`
> [!note] Source
> `backend/src/db/schema/users.ts`
@@ -27,8 +37,8 @@ The core identity document for every actor in the marketplace: buyers, sellers,
| Column | PG Type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | no | `gen_random_uuid()` | Primary key |
| `legacy_object_id` | `text` | yes | — | Mongo ObjectId; partial-unique index WHERE NOT NULL; used for idempotent backfill upserts |
| `id` | `uuid` | no | `gen_random_uuid()` | Primary key (`pgId` in domain objects) |
| `legacy_object_id` | `text` | yes | — | 24-hex ObjectId string; partial-unique index WHERE NOT NULL; kept for socket rooms and legacy auth token compatibility |
| `email` | `varchar(255)` | yes | — | Partial-unique index WHERE NOT NULL |
| `password` | `varchar(255)` | yes | — | Hashed |
| `first_name` | `text` | yes | — | — |
@@ -62,7 +72,7 @@ The core identity document for every actor in the marketplace: buyers, sellers,
### Child Tables
**`user_passkeys`** — WebAuthn credentials extracted from the embedded array:
**`user_passkeys`** — WebAuthn credentials:
| Column | Type | Notes |
| --- | --- | --- |
@@ -74,14 +84,14 @@ The core identity document for every actor in the marketplace: buyers, sellers,
| `device_name` | `text` | Optional human label |
| `created_at` | `timestamptz` | — |
**`user_refresh_tokens`** — Active JWT refresh tokens extracted from the Mongo array:
**`user_refresh_tokens`** — Active JWT refresh tokens:
| Column | Type | Notes |
| --- | --- | --- |
| `token` | `text` (PK) | The refresh token string |
| `user_id` | `uuid FK→users CASCADE` | Owner |
### Indexes (Postgres)
### Indexes
| Index | Type | Condition |
| --- | --- | --- |
@@ -102,11 +112,7 @@ The core identity document for every actor in the marketplace: buyers, sellers,
---
## MongoDB Collection: `User` (legacy — reads still active)
> [!note] Source
> `backend/src/models/User.ts:70` — schema definition
> `backend/src/models/User.ts:257` — model export
## Field Reference
> [!note] Email change re-verification
> When a profile update (`PUT /api/user/profile`, `userController.updateUserProfile`) changes `email` to a new value, the controller sets `isEmailVerified = false`, generates a **6-digit** `emailVerificationCode` (valid 15 minutes), stores it on `emailVerificationCode` / `emailVerificationCodeExpires`, and emails the code to the new address. The user must then confirm via `POST /api/user/profile/email/verify` (or request a new code with `POST /api/user/profile/email/resend-verification`).
@@ -114,99 +120,73 @@ The core identity document for every actor in the marketplace: buyers, sellers,
> [!note] Wallet ownership proof
> `PATCH /api/user/wallet-address` accepts both EVM and TON wallets. EVM addresses require an EIP-191 signature (`ethers.verifyMessage`); TON addresses are format-validated and may include an optional TonProof. A successful proof sets `profile.walletProofVerified = true` and `profile.walletProofTimestamp`.
### Schema
| Field | Type | Required | Default | Validation | Index | Description |
| --- | --- | --- | --- | --- | --- | --- |
| `email` | String | no | — | lowercase, trim | unique, sparse | Primary email login identifier. Nullable for Telegram-only accounts. |
| `password` | String | no | — | minlength 6 | — | Hashed password. Optional to support passkey-only, Google, and Telegram accounts. |
| `firstName` | String | no | `"کاربر"` | trim | — | Persian default ("user"). |
| `lastName` | String | no | `"جدید"` | trim | — | Persian default ("new"). |
| `role` | String | yes | `"buyer"` | enum: `admin` / `buyer` / `seller` / `resolver` / `guard` | yes | Authorisation tier. `resolver` (commit `fce8a19`): can view/resolve disputes and bypass chat membership checks. `guard` (migration 0017): added in PG schema; purpose TBD. |
| `isEmailVerified` | Boolean | no | `false` | — | — | Set to true after the email verification code is consumed. Warning: Changing the email via `PUT /api/user/profile` **resets this to `false`** and dispatches a fresh **6-digit** verification code to the new address. |
| `authProvider` | String | yes | `"email"` | enum: `email` / `google` / `telegram` | yes | Provider used to create the account. Existing email/password accounts remain `email`; Telegram-only users are `telegram`. |
| `telegramVerified` | Boolean | no | `false` | — | — | Set when Telegram identity has been signature-verified and linked through `TelegramLink`. |
| `emailVerificationToken` | String | no | — | — | — | Legacy token-based email verification. |
| `emailVerificationCode` | String | no | — | — | — | OTP code for email verification. |
| `emailVerificationCodeExpires` | Date | no | — | — | — | Expiry for `emailVerificationCode`. |
| `passwordResetToken` | String | no | — | — | — | Token for reset link flow. |
| `passwordResetExpires` | Date | no | — | — | — | Expiry of `passwordResetToken`. |
| `passwordResetCode` | String | no | — | — | — | OTP reset code. |
| `passwordResetCodeExpires` | Date | no | — | — | — | Expiry for OTP reset code. |
| `passkeys[]` | Subdocument array | no | `[]` | — | — | WebAuthn credentials. Extracted to `user_passkeys` table in PG. |
| `passkeys[].id` | String | yes | — | — | — | Credential ID. |
| `passkeys[].publicKey` | String | yes | — | — | — | Stored public key. |
| `passkeys[].counter` | Number | yes | `0` | — | — | Signature counter. |
| `passkeys[].deviceType` | String | yes | — | enum: `platform` / `cross-platform` | — | Authenticator class. |
| `passkeys[].deviceName` | String | no | — | — | — | Optional human label. |
| `passkeys[].createdAt` | Date | no | `Date.now` | — | — | Registration timestamp. |
| `profile.avatar` | String | no | — | — | — | Avatar URL. |
| `profile.photoURL` | String | no | — | — | — | Alternative photo URL. |
| `profile.phone` | String | no | — | — | — | Contact phone. |
| `profile.address.street` | String | no | — | — | — | Inline address (separate from Address book). |
| `profile.address.city` | String | no | — | — | — | — |
| `profile.address.state` | String | no | — | — | — | — |
| `profile.address.zipCode` | String | no | — | — | — | — |
| `profile.address.country` | String | no | — | — | — | — |
| `profile.bio` | String | no | — | — | — | Free-form bio. |
| `profile.website` | String | no | — | — | — | Personal website URL. |
| `profile.walletAddress` | String | no | — | — | — | On-chain wallet address (EVM `0x…` or TON). Set via `PATCH /api/user/wallet-address`. |
| `profile.walletType` | String | no | — | enum: `evm` / `ton` | — | Which chain family the stored `walletAddress` belongs to. |
| `profile.walletProvider` | String | no | — | — | — | Wallet provider label (e.g. `evm`, `telegram-wallet`). Defaults to `telegram-wallet` for TON, `evm` otherwise. |
| `profile.walletProofVerified` | Boolean | no | — | — | — | True when ownership was proven — EIP-191 signature for EVM, or a verified TonProof for TON. |
| `profile.walletProofTimestamp` | Date | no | — | — | — | When the wallet proof was last verified (only set when `walletProofVerified` is true). |
| `profile.isPublic` | Boolean | no | `false` | — | — | Whether the profile is publicly visible. |
| `preferences.language` | String | no | `"en"` | — | — | UI language. |
| `preferences.currency` | String | no | `"USD"` | — | — | Display currency. |
| `preferences.notifications.email` | Boolean | no | `true` | — | — | Opt-in for email notifications. |
| `preferences.notifications.sms` | Boolean | no | `false` | — | — | Opt-in for SMS notifications. |
| `preferences.notifications.push` | Boolean | no | `true` | — | — | Opt-in for push notifications. |
| `status` | String | no | `"active"` | enum: `active` / `suspended` / `deleted` | yes | Soft-delete and moderation flag. |
| `lastLoginAt` | Date | no | — | — | — | Updated by auth middleware. |
| `refreshTokens[]` | String[] | no | `[]` | — | — | Array of currently active JWT refresh tokens. Warning: Reset to `[]` on password change and on password reset, invalidating every outstanding session. Extracted to `user_refresh_tokens` table in PG. |
| `referralCode` | String | no | — | — | unique, sparse | **Not yet implemented** — planned for referral programme. |
| `referredBy` | ObjectId -> User | no | — | — | yes | **Not yet implemented** — planned for referral programme. |
| `points.total` | Number | no | `0` | — | — | **Not yet implemented** — planned for loyalty system. |
| `points.available` | Number | no | `0` | — | — | **Not yet implemented.** |
| `points.used` | Number | no | `0` | — | — | **Not yet implemented.** |
| `points.level` | Number | no | `1` | — | yes | **Not yet implemented** — planned for LevelConfig lookup. |
| `referralStats.totalReferrals` | Number | no | `0` | — | — | **Not yet implemented.** |
| `referralStats.activeReferrals` | Number | no | `0` | — | — | **Not yet implemented.** |
| `referralStats.totalEarned` | Number | no | `0` | — | — | **Not yet implemented.** |
| `createdAt` | Date | auto | — | — | — | Mongoose timestamp. |
| `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. |
### Virtuals
| Virtual | Returns | Definition |
| Field (domain / camelCase) | PG Column | Notes |
| --- | --- | --- |
| `fullName` | `${firstName} ${lastName}` | `backend/src/models/User.ts:238` |
| `id` / `pgId` | `id` (uuid PK) | Used for all marketplace FKs |
| `_id` / `legacyObjectId` | `legacy_object_id` | 24-hex string; socket rooms + legacy auth tokens |
| `email` | `email` | Primary email login; nullable for Telegram-only accounts |
| `password` | `password` | Hashed; optional for passkey/Google/Telegram accounts |
| `firstName` | `first_name` | Persian default "کاربر" |
| `lastName` | `last_name` | Persian default "جدید" |
| `role` | `role` | enum: `admin` / `buyer` / `seller` / `resolver` / `guard` |
| `isEmailVerified` | `is_email_verified` | Reset to false on email change |
| `authProvider` | `auth_provider` | enum: `email` / `google` / `telegram` |
| `telegramVerified` | `telegram_verified` | Set after Telegram signature-verify + link |
| `emailVerificationToken` | `email_verification_token` | Legacy token flow |
| `emailVerificationCode` | `email_verification_code` | OTP code |
| `emailVerificationCodeExpires` | `email_verification_code_expires` | — |
| `passwordResetToken` | `password_reset_token` | Token for reset link flow |
| `passwordResetExpires` | `password_reset_expires` | — |
| `passwordResetCode` | `password_reset_code` | OTP reset code |
| `passwordResetCodeExpires` | `password_reset_code_expires` | — |
| `passkeys[]` | `user_passkeys` child table | WebAuthn credentials |
| `passkeys[].id` | `user_passkeys.id` | Credential ID (PK) |
| `passkeys[].publicKey` | `user_passkeys.public_key` | Stored public key |
| `passkeys[].counter` | `user_passkeys.counter` | Signature counter |
| `passkeys[].deviceType` | `user_passkeys.device_type` | enum: `platform` / `cross-platform` |
| `passkeys[].deviceName` | `user_passkeys.device_name` | Optional human label |
| `passkeys[].createdAt` | `user_passkeys.created_at` | Registration timestamp |
| `profile.avatar` | `profile` jsonb | Avatar URL |
| `profile.photoURL` | `profile` jsonb | Alternative photo URL |
| `profile.phone` | `profile` jsonb | Contact phone |
| `profile.address.*` | `profile` jsonb | street, city, state, zipCode, country |
| `profile.bio` | `profile` jsonb | Free-form bio |
| `profile.website` | `profile` jsonb | Personal website URL |
| `profile.walletAddress` | `profile` jsonb | EVM `0x…` or TON address; set via `PATCH /api/user/wallet-address` |
| `profile.walletType` | `profile` jsonb | enum: `evm` / `ton` |
| `profile.walletProvider` | `profile` jsonb | e.g. `evm`, `telegram-wallet` |
| `profile.walletProofVerified` | `profile` jsonb | True when ownership proven (EIP-191 or TonProof) |
| `profile.walletProofTimestamp` | `profile` jsonb | Last verified timestamp |
| `profile.isPublic` | `profile` jsonb | Whether profile is publicly visible |
| `preferences.language` | `preferences` jsonb | UI language; default `"en"` |
| `preferences.currency` | `preferences` jsonb | Display currency; default `"USD"` |
| `preferences.notifications.email` | `preferences` jsonb | Opt-in email notifications; default `true` |
| `preferences.notifications.sms` | `preferences` jsonb | Opt-in SMS notifications; default `false` |
| `preferences.notifications.push` | `preferences` jsonb | Opt-in push notifications; default `true` |
| `status` | `status` | enum: `active` / `suspended` / `deleted` |
| `lastLoginAt` | `last_login_at` | Updated by auth middleware |
| `refreshTokens[]` | `user_refresh_tokens` child table | Active JWT refresh tokens; reset on password change/reset |
| `referralCode` | `referral_code` | Planned referral programme |
| `referredBy` | `referred_by_id` (uuid FK) | Planned referral programme |
| `points.total` | `points_total` | Planned loyalty system |
| `points.available` | `points_available` | Planned loyalty system |
| `points.used` | `points_used` | Planned loyalty system |
| `points.level` | `points_level` | Planned LevelConfig lookup |
| `referralStats.totalReferrals` | `referral_stats_total` | Planned |
| `referralStats.activeReferrals` | `referral_stats_active` | Planned |
| `referralStats.totalEarned` | `referral_stats_total_earned` | Planned |
| `createdAt` | `created_at` | Drizzle timestamp |
| `updatedAt` | `updated_at` | Drizzle timestamp |
### Indexes (MongoDB)
### Computed / Virtual
Defined explicitly:
| Virtual | Returns | Notes |
| --- | --- | --- |
| `fullName` | `${firstName} ${lastName}` | Computed in domain layer (was Mongoose virtual) |
- `{ email: 1 }` unique sparse — allows multiple Telegram-only users without email while preserving uniqueness for email-bearing users.
- `{ role: 1 }``backend/src/models/User.ts:178`
- `{ status: 1 }``backend/src/models/User.ts:179`
- `{ authProvider: 1 }` — supports provider-level account reporting and cleanup.
### Serialisation
> [!warning] Missing indexes in Mongo schema
> The schema currently defines only `role` and `status` indexes. The `referralCode`, `referredBy`, and `points.level` indexes documented below are **not yet present** in `User.ts`.
### Pre/Post Hooks
None declared at the schema level.
### Instance Methods
| Signature | Purpose |
| --- | --- |
| `toJSON(): object` | Strips `password`, `refreshTokens`, all `emailVerification*` and `passwordReset*` fields before serialisation. Defined at `backend/src/models/User.ts:243`. |
### Static Methods
None defined on the schema.
`toJSON()` strips `password`, `refreshTokens`, all `emailVerification*` and `passwordReset*` fields before serialisation.
---
@@ -218,16 +198,13 @@ None defined on the schema.
| `buyer` | original | Place purchase requests, confirm delivery |
| `seller` | original | Submit offers, manage shop |
| `resolver` | commit `fce8a19` | View/resolve disputes; bypass chat membership checks; no other admin privileges |
| `guard` | migration 0017 (PG only) | Purpose TBD — defined in `user_role` PG enum, not yet in Mongo schema |
> [!warning] Role enum drift
> The Postgres `user_role` enum includes `guard`; the Mongo schema enum does not. Until the Mongo schema is updated, any `guard`-role user created through PG will not be representable in Mongo and will break dual-write for that record.
| `guard` | migration 0017 | Defined in `user_role` PG enum; purpose TBD |
---
## Relationships
- **References**: User (self, via `referredBy` / `referred_by_id`).
- **References**: User (self, via `referred_by_id`).
- **Referenced by**: PurchaseRequest (`buyerId`, `preferredSellerIds`, `deliveryInfo.deliveryCodeUsedBy`, `deliveryInfo.deliveryAttempts[].sellerId`), SellerOffer (`sellerId`), Payment (`buyerId`, `sellerId`), Chat (`participants[].userId`, `messages[].senderId`, `metadata.createdBy`), Notification (`userId` as string), RequestTemplate (`sellerId`), Dispute (`buyerId`, `sellerId`, `adminId`), BlogPost (`author.id`), Address (`userId`), Review (`sellerId`, `reviewerId`), PointTransaction (`user`, `referredUser`), ShopSettings (`sellerId`).
## State Transitions
@@ -244,32 +221,24 @@ stateDiagram-v2
## Common Queries
```ts
// Mongo — Find by email (login)
User.findOne({ email: email.toLowerCase() });
// Mongo — Active sellers
User.find({ role: 'seller', status: 'active' });
// Mongo — Validate referral
User.findOne({ referralCode: code });
// Mongo — Leaderboard by points
User.find({ status: 'active' }).sort({ 'points.total': -1 }).limit(10);
// Mongo — Promote level
User.updateOne({ _id: id }, { $set: { 'points.level': newLevel } });
```
```sql
-- PG — Find by email
-- Find by email (login)
SELECT * FROM users WHERE email = lower($1) AND email IS NOT NULL;
-- PG — Active sellers
-- Active sellers
SELECT * FROM users WHERE role = 'seller' AND status = 'active';
-- PG — Leaderboard by points
-- Validate referral code
SELECT * FROM users WHERE referral_code = $1 AND referral_code IS NOT NULL;
-- Leaderboard by points
SELECT * FROM users WHERE status = 'active' ORDER BY points_total DESC LIMIT 10;
-- Promote level
UPDATE users SET points_level = $1, updated_at = now() WHERE id = $2;
-- Lookup by legacy ObjectId (socket rooms / auth token migration)
SELECT * FROM users WHERE legacy_object_id = $1;
```
Related: TempVerification, LevelConfig, PointTransaction, ShopSettings.