341 KiB
title, tags, aliases, created, source, updated
| title | tags | aliases | created | source | updated | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| MongoDB → PostgreSQL Migration Guide |
|
|
2026-05-31 | backend/src (automated multi-agent scan) | 2026-06-01 for backend integrate-main-into-development@1543b53 |
MongoDB → PostgreSQL Migration Guide
[!abstract] What this document is A migration helper: a complete, codebase-grounded map of how the escrow backend uses MongoDB today, intended to drive a partial or full migration to PostgreSQL. It documents every collection's data structure, all cross-collection relationships, every place data is read or written, the Mongo-specific features that do not map 1:1 to SQL, and a costed migration strategy with a risk register and time estimate.
It was generated by scanning
backend/src/only (the persistence layer + service code). Frontend and scanner are out of scope. Cross-links ([[Name]]) point at the existing per-collection pages in this vault.Execution plan: see the companion MongoDB to PostgreSQL Migration Plan (Drizzle) for the phased, Drizzle-concrete plan (repository seam,
id_map, dual-write, per-phase cutover runbook).
[!warning] Current implementation delta This guide started as a migration helper. Backend
integrate-main-into-development@1543b53now contains the first Postgres implementation layer: Drizzle schemas/migrations through0009,src/db/client.ts,id_map,pg_dualwrite_gaps, repository implementations/factory, backfill/verify scripts, conditionalpayment_quotespersistence, and aligned purchase/template request budget validation. Backend2.8.17also hardens the PurchaseRequest/SellerOffer backfill runner for marketplace-core dry-runs and selected-offer remapping, and enforces unique active marketplace categories by normalized visible name in Postgres mode. The broad service layer is still Mongoose-first and is not fully wired through those repositories. Use Postgres Runtime Cutover Status as the authoritative current-state snapshot.
[!info] Scan coverage (2026-05-31)
- 23 Mongoose models (collections)
- 19 backend service domains
- 68 documented relationships between collections
- 375 distinct database read/write operations catalogued
- 6 cross-cutting Mongo-feature inventories (populate-joins, aggregations, transactions, polymorphic ids, indexes, connection/tooling)
Numbers reflect what the automated scan found in source; treat them as a high-fidelity starting point, not a hand-audited contract. Verify hot paths before cutover.
How to read this guide
- #1. Executive summary & migration difficulty index — the shape of the problem and a per-collection difficulty rating.
- #2. Relationship map — every cross-collection edge and its proposed Postgres modeling (FK / join table / JSONB / child table).
- #3. Collection reference (data structures) — full field-by-field schema per collection with a proposed
CREATE TABLE. - #4. Database access catalog (functions that read/write) — every service domain's DB operations; this is the port checklist.
- #5. Mongo-specific feature inventories — the cross-cutting cost-drivers (joins via
populate, aggregations, atomicity, polymorphic ids, indexes, tooling). - #6. Migration strategy, risk register & effort estimate — options, phasing, data-modeling decisions, risks, and engineer-week ranges.
Table of contents
- #1. Executive summary & migration difficulty index
- #2. Relationship map
- #3. Collection reference (data structures)
- #Address
- #BlogPost
- #Category
- #Chat
- #ConfigSetting
- #ConfigSettingHistory
- #DerivedDestination
- #Dispute
- #FundsLedgerEntry
- #LevelConfig
- #Notification
- #Payment
- #PointTransaction
- #PurchaseRequest
- #RequestTemplate
- #Review
- #SellerOffer
- #ShopSettings
- #TelegramLink
- #TelegramSession
- #TempVerification
- #TrezorAccount
- #User
- #4. Database access catalog (functions that read/write)
- #address service service
- #admin service service
- #auth service service
- #blockchain service service
- #blog service service
- #chat service service
- #delivery service
- #dispute service
- #email service
- #file service
- #health service
- #marketplace service
- #notification service
- #payment service service
- #points service
- #redis service service
- #telegram service service
- #trezor service service
- #user service service
- #5. Mongo-specific feature inventories
- #6. Migration strategy, risk register & effort estimate
1. Executive summary & migration difficulty index
The backend is a Node/TypeScript service using Mongoose against MongoDB (connection in backend/src/infrastructure/database/connection.ts, single mongoose.connect, pool size 10). Backend 2.6.80 now has repository interfaces/implementations under backend/src/db/repositories, but most services still call Mongoose models directly, so the remaining structural task is wiring live services through that seam.
The dominant migration cost-drivers, in order:
populate()joins — the implicit cross-collection joins scattered across services become explicit SQL JOINs (or an ORM relation layer). See #5. Mongo-specific feature inventories.Schema.Types.Mixed/ polymorphic ids — notablyPayment.purchaseRequestId,sellerOfferId,sellerIdwhich can each be anObjectIdor a string (template checkout). These cannot be plain foreign keys and need a discriminator + typed columns. See #5. Mongo-specific feature inventories.- Embedded arrays — messages in
Chat, offers/timeline inPurchaseRequest, evidence/timeline inDispute— each is a candidate child table vs. JSONB decision. - Atomicity — money/points/ledger flows that currently lean on Mongo's per-document atomic operators (
$inc,$push) must move to real SQL transactions. See #5. Mongo-specific feature inventories. - TTL indexes — Postgres has no native TTL; expiring collections (sessions, notifications, temp verifications) need scheduled deletes (
pg_cron).
Per-collection migration difficulty index
| Model | Collection | Rels | Migration difficulty | Key hazards |
|---|---|---|---|---|
| Address | addresses |
1 | 🟢 Low | hooks/virtuals |
| BlogPost | blogposts |
1 | 🟢 Low | hooks/virtuals |
| Category | categories |
3 | 🟢 Low | scalar/ref only |
| Chat | chats |
9 | 🔴 High | Mixed/polymorphic ids, hooks/virtuals |
| ConfigSetting | configsettings |
1 | 🟢 Low | hooks/virtuals |
| ConfigSettingHistory | configSettingHistories |
1 | 🟢 Low | scalar/ref only |
| DerivedDestination | derivedDestinations |
2 | 🔴 High | Mixed/polymorphic ids, hooks/virtuals |
| Dispute | disputes |
10 | 🟠 Medium | embedded arrays, hooks/virtuals |
| FundsLedgerEntry | funds_ledger_entries |
3 | 🔴 High | Mixed/polymorphic ids, hooks/virtuals |
| LevelConfig | levelconfigs |
0 | 🟢 Low | scalar/ref only |
| Notification | notifications |
2 | 🔴 High | Mixed/polymorphic ids, TTL index |
| Payment | payments |
4 | 🔴 High | Mixed/polymorphic ids, hooks/virtuals |
| PointTransaction | point_transactions |
3 | 🟢 Low | hooks/virtuals |
| PurchaseRequest | purchase_requests |
9 | 🟠 Medium | embedded arrays, hooks/virtuals |
| RequestTemplate | request_templates |
2 | 🟢 Low | scalar/ref only |
| Review | reviews |
4 | 🔴 High | Mixed/polymorphic ids, hooks/virtuals |
| SellerOffer | seller_offers |
5 | 🟠 Medium | embedded arrays, hooks/virtuals |
| ShopSettings | shopSettings |
1 | 🟢 Low | scalar/ref only |
| TelegramLink | telegramLinks |
1 | 🟢 Low | scalar/ref only |
| TelegramSession | telegramSessions |
1 | 🟢 Low | TTL index |
| TempVerification | tempverifications |
1 | 🟢 Low | TTL index |
| TrezorAccount | trezor_accounts |
3 | 🟠 Medium | embedded arrays, hooks/virtuals |
| User | users |
1 | 🟢 Low | hooks/virtuals |
Difficulty is a heuristic from each collection's relationships, Mixed-types, embedded arrays, TTL indexes, and hooks/virtuals. Use it to sequence work, not as a precise estimate.
2. Relationship map
68 relationships were extracted across all collections. Each row is an edge that must be reproduced in Postgres — as a foreign-key column, a join table, a child table, or a JSONB blob.
| Source collection | Field | Target | Cardinality | Kind | PG strategy |
|---|---|---|---|---|---|
| Address | userId | User | N:1 | ref ObjectId | FK column on addresses table referencing users.id (or users.user_id) |
| BlogPost | author.id | User | N:1 | ref ObjectId | Foreign key (UUID) to users table. author.name and author.avatar are denormalized copies — consider refresh strategy. |
| Category | parentId | Category | 1:N | self-referential ObjectId ref | FK column parent_id REFERENCES categories(_id) ON DELETE SET NULL |
| Category | referenced by PurchaseRequest.categoryId | PurchaseRequest | 1:N | ObjectId ref (reverse) | FK column in purchase_requests table: category_id REFERENCES categories(_id) |
| Category | referenced by RequestTemplate.categoryId | RequestTemplate | 1:N | ObjectId ref (reverse) | FK column in request_templates table: category_id REFERENCES categories(_id) |
| Chat | participants[].userId | User | N:M | ref ObjectId | FK in child table chat_participants(user_id) → users(id) |
| Chat | messages[].senderId | User | N:1 | ref ObjectId | FK in child table chat_messages(sender_id) → users(id) |
| Chat | messages[].reactions[].userId | User | N:1 | ref ObjectId | FK in message_reactions(user_id) → users(id) or JSONB array if denormalized |
| Chat | lastMessage.senderId | User | N:1 | denormalized copy | last_message_sender_id (uuid FK) — maintain via trigger on message insert or application logic |
| Chat | unreadCounts[].userId | User | N:M | ref ObjectId | FK in child table chat_unread_counts(user_id) → users(id) |
| Chat | relatedTo.id (type=PurchaseRequest) | PurchaseRequest | N:1 | polymorphic ref ObjectId | FK with type discriminator: (related_to_type='PurchaseRequest', related_to_id) → purchase_requests(id) OR JSONB {type, id} with CHECK |
| Chat | relatedTo.id (type=SellerOffer) | SellerOffer | N:1 | polymorphic ref ObjectId | FK with type discriminator: (related_to_type='SellerOffer', related_to_id) → seller_offers(id) OR JSONB |
| Chat | relatedTo.id (type=Transaction) | Payment (implicit Transaction model) | N:1 | polymorphic ref ObjectId | FK with type discriminator: (related_to_type='Transaction', related_to_id) → payments(id) OR JSONB |
| Chat | metadata.createdBy | User | N:1 | ref ObjectId | FK created_by (uuid) → users(id) |
| ConfigSetting | updatedBy | User | N:1 | ref ObjectId | FK uuid REFERENCES users(_id) ON DELETE SET NULL |
| ConfigSettingHistory | changedBy | User | N:1 | ref ObjectId | FK column (uuid NULL REFERENCES users(id)) but allow NULL for system changes |
| DerivedDestination | buyerId | User | N:1 | ref ObjectId | FK column (uuid) → users.id ON DELETE CASCADE |
| DerivedDestination | sellerOfferId | SellerOffer (inferred) | N:1 (likely) | Mixed (ObjectId | string) - polymorphic | VARCHAR(24) FK to seller_offers.id, or migrate stringified IDs to uuid. Normalize at app level or add FOREIGN KEY if IDs are consistent. |
| Dispute | purchaseRequestId | PurchaseRequest | N:1 | ref ObjectId | FK constraint, CASCADE DELETE |
| Dispute | buyerId | User | N:1 | ref ObjectId | FK constraint, cascade or NO ACTION |
| Dispute | sellerId | User | N:1 | ref ObjectId (nullable) | FK constraint, NO ACTION |
| Dispute | adminId | User | N:1 | ref ObjectId (nullable) | FK constraint, NO ACTION |
| Dispute | evidence[] | embedded | 1:N | embedded array | Child table dispute_evidence with FK to disputes(_id) |
| Dispute | chatId | Chat | 1:1 | ref ObjectId (nullable) | FK constraint, NO ACTION (or SET NULL if chats can be deleted) |
| Dispute | timeline[] | embedded | 1:N | embedded array (audit trail) | Child table dispute_timeline with FK to disputes(_id) |
| Dispute | resolution | embedded | 1:1 | embedded subdoc (optional) | JSONB column OR child table dispute_resolution with nullable FK |
| Dispute | resolution.resolvedBy | User | N:1 | nested ObjectId within optional resolution | FK in child table or denormalized UUID in JSONB |
| Dispute | tags[] | embedded | 1:N | embedded array of strings | TEXT[] column (PG11+) or child table dispute_tags |
| FundsLedgerEntry | purchaseRequestId | PurchaseRequest | N:1 | Mixed/polymorphic (ObjectId | string) | Foreign key or denormalized text; if guaranteed ObjectId, use uuid FK; if mixed, store as text and validate application-side or add discriminator column |
| FundsLedgerEntry | paymentId | Payment | N:1 | Mixed/polymorphic (ObjectId | string) | Foreign key or denormalized text; recommend separate columns (payment_id_type, payment_id_value) if polymorphic |
| FundsLedgerEntry | createdBy | User | N:1 | Mixed/polymorphic (ObjectId | string), optional | Optional FK or text; only populated for certain entry types |
| Notification | userId | User (assumed to exist in target schema) | N:1 | ref (string-based FK; type unknown without users schema review) | FK column userId → users(id); validate types match |
| Notification | relatedId | PurchaseRequest | Offer | Payment | Delivery (polymorphic) | N:1 (polymorphic) | polymorphic string ID (no type discriminator column in current schema) | No FK constraint; store as TEXT. If enforcing referential integrity, add discriminator column (entity_type: VARCHAR(30)) to track target model and validate via application logic or triggers. |
| Payment | buyerId | User | N:1 | ref ObjectId | FK users.id with ON DELETE CASCADE |
| Payment | purchaseRequestId | PurchaseRequest | N:1 | polymorphic (ObjectId or string for template) | store as text, add type discriminator column or helper logic; if always correlates to PurchaseRequest row when ObjectId, add conditional FK |
| Payment | sellerOfferId | SellerOffer | N:1 | polymorphic (ObjectId or string for template) | store as text, add type discriminator column or helper logic; if always correlates to SellerOffer row when ObjectId, add conditional FK |
| Payment | sellerId | User | N:1 | polymorphic (ObjectId or string for template) | store as text, add type discriminator column or helper logic; if ObjectId always refers to User row, add conditional FK |
| PointTransaction | user | User | N:1 | ref ObjectId | FK column user_id uuid NOT NULL REFERENCES users(id) |
| PointTransaction | order | Order | N:1 | ref ObjectId | FK column order_id uuid REFERENCES orders(id) |
| PointTransaction | referredUser | User | N:1 | ref ObjectId | FK column referred_user_id uuid REFERENCES users(id) |
| PurchaseRequest | buyerId | User | N:1 | ref ObjectId | FK column buyer_id REFERENCES users(id) |
| PurchaseRequest | categoryId | Category | N:1 | ref ObjectId | FK column category_id REFERENCES categories(id) |
| PurchaseRequest | preferredSellerIds | User | N:M | embedded array of ObjectId | Create join table purchase_request_preferred_sellers(purchase_request_id, seller_id) with unique constraint |
| PurchaseRequest | offers | SellerOffer | 1:N | embedded array of ObjectId (denormalized) | Remove field from table; query SellerOffer.purchase_request_id instead. Denormalized copy that must be kept in sync. |
| PurchaseRequest | selectedOfferId | SellerOffer | N:1 | ref ObjectId | FK column selected_offer_id REFERENCES seller_offers(id), nullable |
| PurchaseRequest | deliveryInfo.deliveryCodeUsedBy | User | N:1 | ref ObjectId (nested) | Flatten to column delivery_code_used_by REFERENCES users(id), or include in delivery_info child table |
| PurchaseRequest | deliveryInfo.deliveryAttempts[].sellerId | User | 1:N | embedded array with ObjectId refs | Child table delivery_attempts(purchase_request_id, seller_id REFERENCES users(id), attempted_at, success, code) |
| PurchaseRequest | deliveryInfo (entire nested object) | embedded | embedded-1 | embedded subdocument | Option A (recommended for queries): Extract into child table purchase_request_delivery_info with 1:1 relationship. Option B: Store as JSONB column if queries stay simple and denormalization acceptable. |
| PurchaseRequest | serviceInfo (entire nested object) | embedded | embedded-1 | embedded subdocument | Extract into child table purchase_request_service_info with 1:1 relationship, or store as JSONB if product_type='service' is the only consumer. |
| RequestTemplate | sellerId | User | N:1 | ref ObjectId | FK column seller_id REFERENCES users(id) |
| RequestTemplate | categoryId | Category | N:1 | ref ObjectId | FK column category_id REFERENCES categories(id) |
| Review | sellerId | User | N:1 | ref ObjectId | FK column users.id |
| Review | reviewerId | User | N:1 | ref ObjectId | FK column users.id |
| Review | subjectId | User|RequestTemplate (polymorphic) | N:1 polymorphic | polymorphic ObjectId discriminated by subjectType | uuid column + discriminator column (subjectType); create CHECK constraint or document clear FK intent |
| Review | purchaseRequestId | PurchaseRequest | N:1 | ref ObjectId | FK column purchase_requests.id; nullable |
| SellerOffer | sellerId | User | N:1 | ref ObjectId | FK column seller_id references users(id) |
| SellerOffer | purchaseRequestId | PurchaseRequest | N:1 | ref ObjectId | FK column purchase_request_id references purchase_requests(id) |
| SellerOffer | price | embedded | embedded-1 | embedded subdoc | Inline columns: price_amount NUMERIC(18,8), price_currency VARCHAR(10) — OR single JSONB column price if querying/filtering not needed |
| SellerOffer | deliveryTime | embedded | embedded-1 | embedded subdoc | Inline columns: delivery_time_amount INTEGER, delivery_time_unit VARCHAR(10) — OR single JSONB column delivery_time if minimal filtering |
| SellerOffer | attachments | embedded | embedded-N | embedded array | TEXT[] PostgreSQL array (simple) — OR child table seller_offer_attachments(id, offer_id, url, position) if attachments need individual queries/cascade deletes |
| ShopSettings | sellerId | User | 1:1 | ref ObjectId | FK column (users.id) with UNIQUE constraint |
| TelegramLink | userId | User | 1:1 | ref ObjectId | FK column (userId UUID REFERENCES users(id) ON DELETE CASCADE) with UNIQUE constraint |
| TelegramSession | userId | User | N:1 | ref ObjectId | Foreign key column (uuid) with ON DELETE SET NULL; allows multiple TelegramSessions per User |
| TempVerification | User (implied) | 1:1 | implied foreign reference | No explicit FK in schema; email serves as logical key to match with User.email during account completion; use CHECK to prevent registered users from appearing in temp_verifications or enforce at application level | |
| TrezorAccount | userId | User | 1:1 | ref ObjectId | FK userId to users(id) UNIQUE |
| TrezorAccount | addresses[].paymentId | Payment | 1:N | ref ObjectId (embedded) | FK payment_id to payments(id) in trezor_derived_addresses child table, nullable |
| TrezorAccount | addresses | embedded | 1:N | embedded array | Child table trezor_derived_addresses with FK trezor_account_id, no _id field in Mongo (omit surrogate PK or use composite PK on trezor_account_id+index) |
| User | referredBy | User | N:1 | ref ObjectId | FK column (users.referred_by_id → users.id). Self-referential tree. |
[!warning] Polymorphic edges Rows whose Kind mentions Mixed, polymorphic, or stringified id cannot become simple foreign keys. They require a discriminator column plus typed nullable FKs (or a deliberate denormalization). The
Payment↔PurchaseRequest/SellerOfferedges are the canonical example. See the #5. Mongo-specific feature inventories for the recommended resolution.
3. Collection reference (data structures)
Full field-by-field documentation of every collection, with Mongo type → proposed Postgres type, relationships, indexes, gotchas, and a CREATE TABLE sketch. Each section corresponds to a file in backend/src/models/.
Address
Collection: addresses (Mongoose: Address)
Stores user shipping/billing address information with support for multiple addresses per user and exactly one primary address per user (enforced by pre-save hook).
Field Reference
| Path | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid or bigint PK | ✓ | Auto-generated; use gen_random_uuid() or SERIAL |
| userId | ObjectId (ref User) | uuid/bigint FK | ✓ | Foreign key to users table; indexed |
| name | String | varchar(255) | ✓ | Address label; auto-trimmed by schema |
| phoneNumber | String | varchar(20) | Optional contact phone; trimmed | |
| fullAddress | String | text/varchar(500) | ✓ | Street address; trimmed |
| city | String | varchar(100) | ✓ | City; trimmed |
| state | String | varchar(100) | ✓ | State/province; trimmed |
| country | String | varchar(100) | ✓ | Country; trimmed |
| zipCode | String | varchar(20) | Postal code; trimmed; nullable | |
| addressType | String (enum) | varchar(50) or enum type | 'Home' | 'Office' | 'Other'; default 'Home' | |
| primary | Boolean | boolean | Is primary address for user; default false | |
| createdAt | Date | timestamp tz | Auto-generated; DEFAULT CURRENT_TIMESTAMP | |
| updatedAt | Date | timestamp tz | Auto-updated; DEFAULT CURRENT_TIMESTAMP |
Indexes
- userId (single): For efficient lookups of all addresses for a user
- userId + primary desc: Compound index for finding the primary address quickly
Relationships
- userId → users (N:1): Each address belongs to one user; multiple addresses per user allowed
Pre-Save Hook Behavior
When primary is set to true, the pre-save middleware automatically sets primary: false on all other addresses for that user. This must be reimplemented in PostgreSQL as:
- Option A (Trigger): Write a BEFORE INSERT/UPDATE trigger that sets other rows' primary to false
- Option B (Unique Partial Index): Use
UNIQUE (user_id) WHERE primary = trueto enforce single primary at the database constraint level (requires handling nullability carefully)
Option B is cleaner but Option A is more analogous to the Mongoose implementation.
Gotchas
- Primary Address Constraint: The pre-save hook is application-enforced in Mongoose but not at the database level. Migrate this to a database trigger or unique constraint to prevent race conditions in concurrent updates.
- Enum Values: addressType is case-sensitive ('Home', 'Office', 'Other' — not lowercase). Enforce via enum type or CHECK constraint.
- Null vs. Empty: zipCode is nullable (required: false) but phoneNumber is not explicitly required in the schema either; handle nullability consistently during migration.
Proposed DDL Sketch
CREATE TYPE address_type_enum AS ENUM ('Home', 'Office', 'Other');
CREATE TABLE addresses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
phone_number VARCHAR(20),
full_address TEXT NOT NULL,
city VARCHAR(100) NOT NULL,
state VARCHAR(100) NOT NULL,
country VARCHAR(100) NOT NULL,
zip_code VARCHAR(20),
address_type address_type_enum DEFAULT 'Home',
primary BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Indexes
INDEX idx_user_id (user_id),
INDEX idx_user_id_primary (user_id DESC, primary DESC),
-- Enforce single primary per user
UNIQUE (user_id) WHERE primary = TRUE
);
-- OR implement with trigger for UPDATE/INSERT logic:
-- CREATE OR REPLACE FUNCTION ensure_one_primary_address()
-- RETURNS TRIGGER AS $$
-- BEGIN
-- IF NEW.primary THEN
-- UPDATE addresses SET primary = FALSE
-- WHERE user_id = NEW.user_id AND id != NEW.id;
-- END IF;
-- RETURN NEW;
-- END;
-- $$ LANGUAGE plpgsql;
--
-- CREATE TRIGGER trg_one_primary_address
-- BEFORE INSERT OR UPDATE ON addresses
-- FOR EACH ROW
-- EXECUTE FUNCTION ensure_one_primary_address();
BlogPost
Collection: blogposts
Model Location: /Users/manwe/CascadeProjects/escrow/backend/src/models/BlogPost.ts
Usage: Blog content management; published/draft posts, pagination, search, featured feed. Tightly coupled with User via author.id. Service layer: /src/services/blog/BlogService.ts.
Field Summary
| Field | Mongo Type | PG Type | Required | Notes |
|---|---|---|---|---|
| _id | ObjectId | UUID PRIMARY KEY | Yes | Convert on migration |
| title | String | VARCHAR(200) NOT NULL | Yes | Trimmed, maxlength 200; searched in searchPosts |
| slug | String | VARCHAR(255) UNIQUE NOT NULL | No in schema, Yes in practice | Auto-gen by pre-save hook; unique + sparse index |
| description | String | VARCHAR(500) NOT NULL | Yes | Excerpt; maxlength 500 |
| content | String | TEXT NOT NULL | Yes | Full post body; regex searched |
| coverImage | String | VARCHAR(500) | No | Cover image URL |
| images | [String] | TEXT[] OR child table | No | Array of image URLs. Recommend child table. |
| videos | [embedded subdoc] | JSONB OR child table | No | Array {url, title, platform, embedId}. Recommend child table. |
| videos[].url | String | VARCHAR(500) NOT NULL | Yes (if parent) | Video URL; auto-detected by service |
| videos[].title | String | VARCHAR(255) | No | Optional video title |
| videos[].platform | String (enum) | VARCHAR(50) CHECK (...) | No | youtube|vimeo|aparat|other; default youtube |
| videos[].embedId | String | VARCHAR(255) | No | Extracted embed ID; auto-detected |
| author.id | ObjectId ref | UUID NOT NULL FK→users | Yes | References User._id; denormalized author metadata follows. |
| author.name | String | VARCHAR(255) NOT NULL | Yes | Denormalized at post creation; stale if User.name updates |
| author.avatar | String | VARCHAR(500) | No | Denormalized at post creation; stale if User.avatar updates |
| category | String (enum) | VARCHAR(50) NOT NULL DEFAULT 'tutorial' | Yes | tutorial|news|guide|tips|announcement|other. Indexed. |
| tags | [String] | TEXT[] OR child table | No | Array of tag strings. Indexed for search. Recommend child table. |
| status | String (enum) | VARCHAR(50) NOT NULL DEFAULT 'draft' | Yes | draft|published|archived. Heavy indexing; primary visibility filter. |
| publishedAt | Date | TIMESTAMP WITH TIME ZONE | No | Auto-set by pre-save when status→published. Null for draft/archived. |
| views | Number | BIGINT NOT NULL DEFAULT 0 | Yes | View counter; incremented by incrementViews(). Use BIGINT. |
| likes | Number | INTEGER NOT NULL DEFAULT 0 | Yes | Like counter; default 0 |
| comments | Number | INTEGER NOT NULL DEFAULT 0 | Yes | Denormalized comment count; sync via trigger on comment create/delete |
| readTime | Number | INTEGER NOT NULL DEFAULT 5 | Yes | Est. read time (minutes); default 5 |
| featured | Boolean | BOOLEAN NOT NULL DEFAULT false | Yes | Featured flag; indexed with status for homepage queries |
| seo.metaTitle | String | VARCHAR(255) | No | SEO meta title override |
| seo.metaDescription | String | VARCHAR(500) | No | SEO meta description override |
| seo.metaKeywords | [String] | TEXT[] | No | SEO keyword array. Inline or child table. |
| createdAt | Date | TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP | Yes | Mongoose auto-set; sort key in getAllPosts |
| updatedAt | Date | TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP | Yes | Mongoose auto-set; updated on any change |
Relationships
- author.id → User._id (N:1): Denormalized; author.name + author.avatar frozen at post creation. Recommend FK with ON DELETE CASCADE or SET NULL per product design (can dead author's posts live independently?).
Indexes (Mongoose → PG)
1. UNIQUE INDEX slug
Mongo: unique: true, sparse: true
PG: UNIQUE INDEX idx_blog_posts_slug ON blog_posts(slug)
2. COMPOUND INDEX (status, publishedAt DESC)
Mongo: BlogPostSchema.index({ status: 1, publishedAt: -1 })
PG: CREATE INDEX idx_blog_posts_status_published
ON blog_posts(status, publishedAt DESC)
INCLUDE (title, slug, coverImage, readTime)
Usage: Feed pagination (getPublishedPosts, getFeaturedPosts, getRecentPosts)
3. COMPOUND INDEX (category, status)
Mongo: BlogPostSchema.index({ category: 1, status: 1 })
PG: CREATE INDEX idx_blog_posts_category_status
ON blog_posts(category, status)
Usage: Category filtering
4. SIMPLE INDEX tags
Mongo: BlogPostSchema.index({ tags: 1 })
PG: If child table: CREATE INDEX idx_blog_post_tags_tag ON blog_post_tags(tag)
If TEXT[]: CREATE INDEX idx_blog_posts_tags ON blog_posts USING GIN(tags)
Usage: Tag search in searchPosts
5. COMPOUND INDEX (featured, status)
Mongo: BlogPostSchema.index({ featured: 1, status: 1 })
PG: CREATE INDEX idx_blog_posts_featured_status
ON blog_posts(featured, status)
WHERE featured = true
Usage: getFeaturedPosts
Pre-save Hooks (Mongoose → PG Application Logic)
-
Slug auto-generation (lines 154–172)
- Triggered on every save if slug is falsy
- Converts title → lowercase, strips non-alphanumeric, replaces spaces with hyphens, appends
Date.now() - Fallback:
post-${Date.now()}if title has no English chars - PG Migration: Implement in application layer (BlogService.createPost) or DB BEFORE INSERT trigger. Application is safer (easier debugging, reusable slug logic).
-
publishedAt auto-set (lines 175–180)
- Triggered on every save if status changes to 'published' and publishedAt is null
- Sets publishedAt = NOW()
- PG Migration: Application-side in updatePost() before UPDATE. Cleaner than trigger.
Special Considerations
-
Denormalized author fields: author.name and author.avatar are snapshot copies at post creation. If User is updated, BlogPost author metadata does NOT auto-sync. This is intentional (author can be deleted without breaking post). In PG, either:
- Normalize: Store only author_id, JOIN to User on read (risk: missing User breaks queries; consider LEFT JOIN + coalesce fallback)
- Keep denormalized: Add async refresh job (cron: periodic sync of changed Users to BlogPosts) Current Mongoose design prefers denormalization for resilience. Recommend keeping this design in PG.
-
Counters (views, likes, comments): No Mongoose middleware visible for auto-increment. BlogService.incrementViews() is manual. In PG:
- views: Application increments OK (can add DB trigger for safety)
- comments: Likely needs trigger on Comment table to UPDATE blog_posts SET comments = comments + 1 Ensure trigger is idempotent (race condition safe).
-
Text search (searchPosts): Uses MongoDB $regex with $options: 'i' on title, description, tags. PG full-text search (tsvector + tsquery) is faster but requires refactor. Current regex path is slower but works.
-
Slug collision: Slug uniqueness relies on timestamp suffix, but collisions still possible if two posts created same millisecond. Application must handle UNIQUE constraint violation on slug and retry with new timestamp.
Proposed DDL
CREATE TABLE blog_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(200) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
cover_image VARCHAR(500),
category VARCHAR(50) NOT NULL DEFAULT 'tutorial'
CHECK (category IN ('tutorial', 'news', 'guide', 'tips', 'announcement', 'other')),
status VARCHAR(50) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
published_at TIMESTAMP WITH TIME ZONE,
views BIGINT NOT NULL DEFAULT 0,
likes INTEGER NOT NULL DEFAULT 0,
comments INTEGER NOT NULL DEFAULT 0,
read_time INTEGER NOT NULL DEFAULT 5,
featured BOOLEAN NOT NULL DEFAULT false,
-- Author (denormalized)
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
author_name VARCHAR(255) NOT NULL,
author_avatar VARCHAR(500),
-- SEO (inline; could separate if needed)
seo_meta_title VARCHAR(255),
seo_meta_description VARCHAR(500),
seo_meta_keywords TEXT[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE UNIQUE INDEX idx_blog_posts_slug ON blog_posts(slug);
CREATE INDEX idx_blog_posts_status_published
ON blog_posts(status, published_at DESC)
INCLUDE (title, slug, cover_image, read_time);
CREATE INDEX idx_blog_posts_category_status ON blog_posts(category, status);
CREATE INDEX idx_blog_posts_featured_status ON blog_posts(featured, status) WHERE featured = true;
CREATE INDEX idx_blog_posts_author_id ON blog_posts(author_id);
-- Child tables for arrays
CREATE TABLE blog_post_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_blog_post_images_post_id ON blog_post_images(post_id);
CREATE TABLE blog_post_videos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
title VARCHAR(255),
platform VARCHAR(50) NOT NULL DEFAULT 'youtube'
CHECK (platform IN ('youtube', 'vimeo', 'aparat', 'other')),
embed_id VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_blog_post_videos_post_id ON blog_post_videos(post_id);
CREATE TABLE blog_post_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
tag VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_blog_post_tags_post_id ON blog_post_tags(post_id);
CREATE INDEX idx_blog_post_tags_tag ON blog_post_tags(tag);
-- Update trigger for updated_at
CREATE OR REPLACE FUNCTION update_blog_posts_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_blog_posts_updated_at
BEFORE UPDATE ON blog_posts
FOR EACH ROW
EXECUTE FUNCTION update_blog_posts_updated_at();
Migration Checklist
- Dump MongoDB BlogPost collection
- Create PG schema (blog_posts + child tables)
- Migrate _id → UUID (or use ObjectId hex as string initially, UUID convert later)
- Migrate author subdoc: split to author_id + author_name + author_avatar in parent
- Migrate images array → blog_post_images child table
- Migrate videos array → blog_post_videos child table (auto-detect platform + embedId during insert if missing)
- Migrate tags array → blog_post_tags child table
- Migrate seo.meta* → inline seo_meta_* columns (or separate table if schema grows)
- Create all indexes (prioritize slug + (status, publishedAt DESC))
- Refactor BlogService to use PG ORM (Prisma, TypeORM, etc.):
- getPublishedPosts: Join with blog_post_tags for tag filter, order by (status, publishedAt DESC)
- searchPosts: Refactor $regex → ILIKE or full-text search (tsvector)
- createPost: Generate slug + publishedAt application-side
- updatePost: Skip publishedAt auto-set if already set
- getFeaturedPosts: Use (featured, status) index
- Add DB trigger for comment count sync (if Comment table exists)
- Test slug collision handling + cascade delete
- Load test: Verify (status, publishedAt DESC) index performance under pagination
Category
Hierarchical category system for classifying purchase requests and request templates. Supports nested categories via self-referential parentId and uses soft-delete pattern (isActive flag).
Fields
| Path | Mongo Type | PG Type | Required | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid PRIMARY KEY DEFAULT gen_random_uuid() | true | Auto-generated identifier |
| name | String | VARCHAR(255) NOT NULL | true | Non-English localized name; trimmed |
| nameEn | String | VARCHAR(255) NOT NULL | true | English translation; trimmed |
| description | String | TEXT | false | Optional category description; trimmed |
| icon | String | VARCHAR(255) | false | Optional icon reference; trimmed |
| isActive | Boolean | BOOLEAN NOT NULL DEFAULT true | false | Soft-delete flag; defaults to true |
| parentId | ObjectId (ref: Category) | uuid REFERENCES categories(_id) ON DELETE SET NULL | false | Self-referential FK; NULL for root categories |
| order | Number | INTEGER NOT NULL DEFAULT 0 | false | Sort order for rendering; defaults to 0 |
| createdAt | Date | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | true | Auto-generated by Mongoose timestamps |
| updatedAt | Date | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | true | Auto-updated on every save |
Relationships
- Self-referential (1:N):
parentId→categories._idfor hierarchical nesting; ON DELETE SET NULL - Referenced by PurchaseRequest (1:N):
purchase_requests.category_id→categories._id(required) - Referenced by RequestTemplate (1:N):
request_templates.category_id→categories._id(required)
Indexes
idx_categories_name(UNIQUE in Drizzle baseline): optimizes name lookupsidx_categories_name_en: optimizes English name lookupscategories_active_name_norm_uq(UNIQUE, partial): enforces one active category perlower(btrim(name))idx_categories_is_active: heavily used in filtered queries (active categories only)idx_categories_parent_id: supports tree-building and subcategory queries
Gotchas & Landmines
- Self-referential cascade: When deleting a parent category, use ON DELETE SET NULL to avoid orphaning subcategories. CategoryService.getCategoryTree() builds the tree client-side, so maintain referential integrity.
- Soft-delete pattern:
isActiveis the primary deletion mechanism; all queries filterisActive: true. Do not physically delete records in PG without migrating dependent logic. - Timestamps immutability: Mongoose auto-sets createdAt once; ensure PG constraints prevent manual updates to createdAt.
- Cache invalidation: CategoryService invalidates Redis cache on every mutation; ensure application layer continues to manage this post-migration.
- Dual localization: name (local language) and nameEn (English) are always paired; enforce as a unit in API validation.
- Duplicate active labels: visible category names must be unique after trimming/case-folding. Migration
0009_unique_active_categories.sqldeactivates duplicate active rows and repoints dependent category references before creating the partial unique index.
Migration Strategy
- Create
categoriestable with uuid PK and all fields as described. - Add self-referential FK constraint on
parent_idwith ON DELETE SET NULL. - Add composite index on (order, name) to match Mongoose sort patterns:
.sort({ order: 1, name: 1 }). - Migrate all ObjectId values from MongoDB to UUIDs using a UUID generation function or application-layer conversion.
- Ensure dependent tables (purchase_requests, request_templates) update their category_id FKs to point to new PG uuid PKs.
- Deactivate duplicate active rows by normalized
name, and repoint purchase request/category parent references to the kept row before addingcategories_active_name_norm_uq. - Validate tree structure: no cycles, all parentId refs are valid or NULL.
Proposed DDL
CREATE TABLE categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
name_en VARCHAR(255) NOT NULL,
description TEXT,
icon VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT true,
parent_id uuid REFERENCES categories(id) ON DELETE SET NULL,
"order" INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_categories_name ON categories(name);
CREATE INDEX idx_categories_name_en ON categories(name_en);
CREATE UNIQUE INDEX categories_active_name_norm_uq
ON categories (lower(btrim(name)))
WHERE is_active = true;
CREATE INDEX idx_categories_is_active ON categories(is_active);
CREATE INDEX idx_categories_parent_id ON categories(parent_id);
CREATE INDEX idx_categories_order_name ON categories("order", name);
Chat
Summary: Manages direct, group, and support conversations. Stores embedded message arrays with reactions, participant management with roles, and polymorphic references to PurchaseRequest/SellerOffer/Transaction. Includes pre-save hooks for automatic timestamp updates, virtuals for computed properties (participantsCount), and instance methods for message/read operations.
Field Table:
| Path | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid PK | ✓ | Auto-generated |
| type | String (direct|group|support) | chat_type_enum NOT NULL | ✓ | Create enum type |
| name | String (max 100) | VARCHAR(100) | ✗ | Group/support chat name |
| description | String (max 500) | VARCHAR(500) | ✗ | Group/support chat description |
| participants[] | embedded-N | child table: chat_participants | ✓ | User membership with role, joinedAt, lastSeen, leftAt, isActive |
| participants[].userId | ObjectId (ref User) | uuid FK | ✓ | User reference |
| participants[].role | String (member|admin|owner) | participant_role_enum | ✗ | Enum, default member |
| participants[].joinedAt | Date | TIMESTAMP | ✗ | Default CURRENT_TIMESTAMP |
| participants[].lastSeen | Date | TIMESTAMP | ✗ | Updated by markAsRead() |
| participants[].leftAt | Date | TIMESTAMP | ✗ | Tracks departed users |
| participants[].isActive | Boolean | BOOLEAN | ✗ | Default true |
| messages[] | DocumentArray | child table: chat_messages | ✓ | Message content, sender, type, reactions |
| messages[]._id | ObjectId | uuid | ✗ | Message ID |
| messages[].senderId | ObjectId (ref User) | uuid FK | ✓ | Author |
| messages[].senderType | String | VARCHAR(50) | ✗ | Always "User" |
| messages[].content | String (max 5000) | TEXT CHECK(len≤5000) | ✓ | Message body |
| messages[].messageType | String (text|image|file|system) | message_type_enum | ✗ | Default text |
| messages[].fileUrl | String | TEXT | ✗ | Attachment URL |
| messages[].fileName | String | VARCHAR(255) | ✗ | Original filename |
| messages[].fileSize | Number | BIGINT | ✗ | Bytes |
| messages[].timestamp | Date | TIMESTAMP | ✗ | Default now(); indexed DESC |
| messages[].isRead | Boolean | BOOLEAN | ✗ | Default false; consider read_receipts table |
| messages[].isEdited | Boolean | BOOLEAN | ✗ | Default false |
| messages[].editedAt | Date | TIMESTAMP | ✗ | Tracks edit history |
| messages[].replyTo | ObjectId | uuid | ✗ | Thread reply reference (within same array) |
| messages[].reactions[] | embedded array | child table: message_reactions OR JSONB | ✗ | Emoji reactions; userId + reaction |
| relatedTo | embedded (polymorphic) | JSONB OR FK+enum | ✗ | type (enum) + id (uuid); discriminator pattern |
| relatedTo.type | String (PurchaseRequest|SellerOffer|Transaction) | related_entity_type_enum | ✗ | 3-value enum |
| relatedTo.id | ObjectId | uuid | ✗ | Related entity ID; FK depends on type |
| lastMessage | embedded (denormalized) | JSONB OR separate cols | ✗ | {content, senderId, timestamp, messageType}; updated via trigger/app logic |
| unreadCounts[] | embedded array | child table: chat_unread_counts | ✗ | Per-user unread count; {userId, count} |
| settings | embedded | JSONB OR separate cols | ✗ | {isArchived, isMuted, mutedUntil, notifications} |
| settings.isArchived | Boolean | BOOLEAN | ✗ | Default false |
| settings.isMuted | Boolean | BOOLEAN | ✗ | Default false |
| settings.mutedUntil | Date | TIMESTAMP | ✗ | Temporary mute expiry |
| settings.notifications | Boolean | BOOLEAN | ✗ | Default true |
| metadata.createdBy | ObjectId (ref User) | uuid FK | ✓ | Chat initiator |
| metadata.createdAt | Date | TIMESTAMP | ✗ | Default now(); immutable |
| metadata.updatedAt | Date | TIMESTAMP | ✗ | Updated by pre-save hook |
| metadata.lastActivity | Date | TIMESTAMP | ✗ | Updated by pre-save when messages added |
Indexes:
1. (participants.userId) — query user's chats
2. (metadata.lastActivity DESC) — sort chats by recency
3. (relatedTo.type, relatedTo.id) — find chats by related entity
4. (messages.timestamp DESC) — sort messages chronologically
5. (type) — filter by chat type
PG DDL sketch:
CREATE TYPE chat_type_enum AS ENUM ('direct', 'group', 'support');
CREATE TYPE participant_role_enum AS ENUM ('member', 'admin', 'owner');
CREATE TYPE message_type_enum AS ENUM ('text', 'image', 'file', 'system');
CREATE TYPE related_entity_type_enum AS ENUM ('PurchaseRequest', 'SellerOffer', 'Transaction');
CREATE TABLE chats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type chat_type_enum NOT NULL DEFAULT 'direct',
name VARCHAR(100),
description VARCHAR(500),
related_to_type related_entity_type_enum,
related_to_id UUID,
last_message JSONB, -- {content, sender_id, timestamp, message_type}
settings JSONB DEFAULT '{"isArchived": false, "isMuted": false, "notifications": true}'::jsonb,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_activity TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE chat_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
role participant_role_enum NOT NULL DEFAULT 'member',
joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP,
left_at TIMESTAMP,
is_active BOOLEAN NOT NULL DEFAULT true,
UNIQUE(chat_id, user_id)
);
CREATE TABLE chat_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES users(id),
sender_type VARCHAR(50) DEFAULT 'User',
content TEXT NOT NULL CHECK(LENGTH(content) <= 5000),
message_type message_type_enum NOT NULL DEFAULT 'text',
file_url TEXT,
file_name VARCHAR(255),
file_size BIGINT,
is_read BOOLEAN NOT NULL DEFAULT false,
is_edited BOOLEAN NOT NULL DEFAULT false,
edited_at TIMESTAMP,
reply_to UUID, -- references another message id
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE message_reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
reaction VARCHAR(10) NOT NULL,
UNIQUE(message_id, user_id, reaction)
);
CREATE TABLE chat_unread_counts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
count INTEGER NOT NULL DEFAULT 0,
UNIQUE(chat_id, user_id)
);
-- Indexes
CREATE INDEX idx_chat_participants_user_id ON chat_participants(user_id);
CREATE INDEX idx_chats_last_activity ON chats(last_activity DESC);
CREATE INDEX idx_chats_related_to ON chats(related_to_type, related_to_id);
CREATE INDEX idx_chat_messages_timestamp ON chat_messages(chat_id, timestamp DESC);
CREATE INDEX idx_chats_type ON chats(type);
Relationships:
- participants[].userId → User (N:M): FK in chat_participants(user_id)
- messages[].senderId → User (N:1): FK in chat_messages(sender_id)
- messages[].reactions[].userId → User (N:M): FK in message_reactions(user_id)
- lastMessage.senderId → User (N:1): Denormalized; maintain via trigger or app
- unreadCounts[].userId → User (N:M): FK in chat_unread_counts(user_id)
- relatedTo.id (type=PurchaseRequest) → PurchaseRequest (N:1): Polymorphic FK with type enum
- relatedTo.id (type=SellerOffer) → SellerOffer (N:1): Polymorphic FK with type enum
- relatedTo.id (type=Transaction) → Payment (N:1): Polymorphic FK with type enum
- metadata.createdBy → User (N:1): FK chats(created_by) → users(id)
Gotchas & Special Handling:
-
Pre-save hook: Timestamps metadata.updatedAt and metadata.lastActivity on every save. In PG, use DEFAULT CURRENT_TIMESTAMP + application middleware or trigger to replicate updateAt behavior.
-
Virtual participantsCount: Computed dynamically from participants WHERE is_active=true. Query in app or SQL COUNT(id).
-
addMessage() method: High-complexity mutation: appends to messages array, updates lastMessage denormalized copy, auto-increments unread counts for other participants, updates metadata.lastActivity. In PG, use transaction + trigger or application-level coordination.
-
markAsRead() method: Marks messages as read, resets unread count, updates participant.lastSeen. High contention point. Use UPSERT (INSERT ... ON CONFLICT) in chat_unread_counts for atomic updates.
-
Embedded reactions within messages: If extracted to separate table, backfill carefully; consider denormalization if reaction queries are rare.
-
Polymorphic relatedTo: Type + ID pair must be validated by application or CHECK constraint. Consider generated column or materialized view.
-
DocumentArray behavior: Mongoose enforces schema within subdocuments. In PG child table, enforce via NOT NULL, CHECK constraints, and foreign keys.
Migration Path:
- Create PG schema with enums and child tables.
- Bulk export Mongo docs to NDJSON.
- Normalize embedded arrays into child tables.
- Resolve polymorphic relatedTo using type discriminator.
- Backfill lastMessage from MAX(chat_messages.timestamp) per chat.
- Backfill unreadCounts (may be stale; reset post-migration).
- Create trigger on chat_messages to update chats.last_activity.
- Enable application-side unreadCounts management via UPSERT or batch jobs.
ConfigSetting
Simple configuration store for system-wide settings (e.g., confirmation thresholds). Stores key-value pairs with optional description and audit trail via updatedBy.
Field Table
| Path | Mongoose Type | PostgreSQL Type | Required | Notes |
|---|---|---|---|---|
_id |
ObjectId | uuid PRIMARY KEY DEFAULT gen_random_uuid() | YES | Auto-generated; migrate as UUID |
key |
String | varchar(255) NOT NULL UNIQUE | YES | Setting key, e.g. 'confirmation_threshold:56'. Unique constraint ensures one value per key. |
value |
Number | numeric NOT NULL | YES | Numeric configuration value (thresholds, limits, etc.) |
description |
String | text | NO | Human-readable description of the setting |
updatedBy |
ObjectId (ref User) | uuid REFERENCES users(_id) ON DELETE SET NULL | NO | Audit trail: who last updated this setting. Indexed for filtering. |
createdAt |
Date | timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP | YES | Auto-set on insert via timestamps:true |
updatedAt |
Date | timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP | YES | Auto-updated on every modification via timestamps:true |
Relationships
- updatedBy (N:1): References User model. Optional; nullable if user is deleted.
Indexes
- UNIQUE (key) — enforces one-value-per-key constraint; critical for config consistency
- INDEX (updatedBy) — speeds up audit trail queries (e.g., "which settings did user X change?")
Gotchas
- Timestamps auto-managed: Mongoose
timestamps: trueauto-generatescreatedAtandupdatedAt. In PostgreSQL, use DEFAULT CURRENT_TIMESTAMP and a trigger to auto-updateupdatedAton UPDATE. - No population: Code uses
.lean()queries and does not populateupdatedBy. FK can be migrated cleanly. - Key format convention: Keys follow a pattern like
'confirmation_threshold:chainId'. Ensure VARCHAR is long enough; 255 chars is safe.
Proposed PostgreSQL DDL
CREATE TABLE configsettings (
_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key varchar(255) NOT NULL UNIQUE,
value numeric NOT NULL,
description text,
updated_by uuid REFERENCES users(_id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_configsettings_key ON configsettings(key);
CREATE INDEX idx_configsettings_updated_by ON configsettings(updated_by);
-- Auto-update updatedAt on modification
CREATE TRIGGER configsettings_update_updated_at
BEFORE UPDATE ON configsettings
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Migration Strategy: (1) Create table with all fields and constraints. (2) Migrate User first or defer FK until after. (3) Copy all documents from MongoDB, converting _id and updatedBy ObjectIds to UUIDs. (4) Verify unique constraint on key. (5) No data transformation required—direct column mapping.
ConfigSettingHistory
Audit log table for configuration value changes. Records each update to a config setting with the old and new numeric values, the user who made the change (optional), and the timestamp.
Fields
| path | Mongo Type | PG Type | Required | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid PRIMARY KEY DEFAULT gen_random_uuid() | ✓ | Autogenerated; convert ObjectId to uuid |
| key | String | varchar(255) NOT NULL | ✓ | Config setting key (e.g. 'confirmationThreshold'); indexed for key-based lookups |
| oldValue | Number | numeric(20,8) NULL | – | Previous value; nullable (default null). Preserve NULLs, do not coerce to 0 |
| newValue | Number | numeric(20,8) NOT NULL | ✓ | New value set during the change |
| changedBy | ObjectId (ref User) | uuid NULL REFERENCES users(id) | – | User who initiated the change; optional to support system-initiated changes |
| changedAt | Date | timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP | ✓ | When the change occurred; indexed |
Relationships
- changedBy → User (N:1, optional FK). Allow NULL for system-initiated changes.
Indexes
- idx_key: Single on
key— fast lookup by config key name - idx_changedAt: Single on
changedAt DESC— find recent changes; used for chronological queries - idx_key_changedAt_compound: Compound (key, changedAt DESC) — common pattern: retrieve audit trail for a specific config key sorted by time
Gotchas & Migration Notes
-
Optional FK with NULL handling:
changedByis a foreign key to User but is optional. Ensure the FK constraint allows NULL and uses ON DELETE SET NULL — this handles system-initiated or administrative changes where no specific user is recorded, while preserving audit history. -
No implicit timestamps: The Mongoose schema has
timestamps: false, so no automaticcreatedAtorupdatedAtfields are added. The only temporal field ischangedAt. -
Nullable oldValue: Do not coerce NULL to 0. Preserve the semantic difference: NULL means "no previous value recorded" (e.g., first change to a setting).
-
Numeric precision: Use
numeric(20,8)or similar foroldValueandnewValueto safely store any config numeric values without loss of precision. Adjust scale based on config value requirements. -
ObjectId to UUID conversion: Both
_idandchangedByuse MongoDB ObjectId; migrate both as uuid in PostgreSQL for consistency and compatibility with other models. -
No Mongoose hooks or virtuals: This model has no pre/post hooks, virtuals, instance methods, or statics — direct table schema is sufficient.
Proposed PostgreSQL DDL
CREATE TABLE config_setting_histories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key varchar(255) NOT NULL,
old_value numeric(20,8),
new_value numeric(20,8) NOT NULL,
changed_by uuid REFERENCES users(id) ON DELETE SET NULL,
changed_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_config_setting_histories_key
ON config_setting_histories (key);
CREATE INDEX idx_config_setting_histories_changed_at
ON config_setting_histories (changed_at DESC);
CREATE INDEX idx_config_setting_histories_key_changed_at
ON config_setting_histories (key, changed_at DESC);
Data Migration
Direct 1:1 mapping:
- Iterate all ConfigSettingHistory documents
- Convert
_idObjectId → uuid - Convert
changedByObjectId → uuid (or NULL if absent) - Map all numeric and string fields directly
- Preserve NULL values for
oldValueandchangedBy - Preserve all dates as-is in UTC
DerivedDestination
Deterministically-derived cryptocurrency addresses for (buyer, sellerOffer, chainId) triplets. Enables unique payment addresses per buyer-seller pair using BIP44 HD wallet derivation, with persistent tracking of derivation metadata, sweep history, and balance state.
Field Mapping
| Path | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid | ✓ | Default MongoDB _id; migrate to uuid v4 |
| buyerId | ObjectId | uuid FK | ✓ | ref: User; indexed; part of unique compound key |
| sellerOfferId | Mixed (ObjectId | string) | uuid or varchar(24) | ✓ | POLYMORPHIC: ObjectId or 24-char hex string; normalized at app layer; part of unique compound key |
| address | String | varchar(42) | ✓ | Ethereum address (checksummed); indexed |
| derivationPath | String | varchar(128) | ✓ | BIP44 path (e.g., "m/44'/60'/0'/0/123") |
| derivationIndex | Number | integer | ✓ | Globally-unique atomic counter; non-negative |
| chainId | Number | integer | ✓ | EVM chainId (56=BSC, 1=Eth, etc.); part of unique key and sweep index |
| status | String enum | varchar(32) | ✗ | Enum: 'active', 'swept', 'sweeping', 'quarantined'; default 'active' |
| lastSweepTxHash | String | varchar(66) | ✗ | Tx hash of most recent sweep |
| lastSweepAt | Date | timestamp | ✗ | Timestamp of last successful sweep |
| lastKnownBalance | String | numeric(78,0) | ✗ | Balance in wei (stored as string for BigInt precision); default '0' |
| lastBalanceCheckedAt | Date | timestamp | ✗ | Timestamp of last balance check |
| totalSwept | Number | numeric(78,0) | ✗ | Cumulative amount swept (wei); default 0 |
| sweepCount | Number | integer | ✗ | Count of successful sweeps; default 0 |
| metadata | Mixed object | jsonb | ✗ | Schemaless extension data; default {} |
| createdAt | Date | timestamp | ✓ | Auto-generated by timestamps: true |
| updatedAt | Date | timestamp | ✓ | Auto-updated by timestamps: true |
Indexes
-
Single Indexes
buyerId— FK lookup and relationshipsellerOfferId— compound key componentaddress— address lookups (optional but recommended)
-
Compound Indexes
status, chainId— sweep operation queriesbuyerId, sellerOfferId, chainId(UNIQUE, sparse: false) — enforces one address per buyer-seller-chain; name:uniq_destination_by_buyer_seller_chain; critical for correctness
Relationships
-
buyerId → User (N:1)
- Type: ref ObjectId
- PG Strategy: FK column (uuid) to users.id; ON DELETE CASCADE
- Required; indexed
-
sellerOfferId → SellerOffer? (N:1, likely)
- Type: Mixed (ObjectId | string) — POLYMORPHIC
- Current: sellerOfferId is Mixed in schema; TypeScript declares
ObjectId | string; code normalizes 24-char hex strings to ObjectId in queries - PG Strategy: If all existing docs are ObjectIds, migrate to uuid FK. If mixed, store as text(24) with app-layer validation or add discriminator column.
Mongoose Features
- Timestamps:
timestamps: true— auto-generates createdAt/updatedAt - Serialization:
toJSON: { virtuals: true }— includes virtuals on serialization (though no virtuals are defined) - Mixed Types:
sellerOfferId— polymorphic; normalized at app layermetadata— schemaless object
- No Custom Methods/Hooks: No pre/post save/validate hooks; no virtuals, statics, or instance methods
Migration Gotchas
-
Polymorphic sellerOfferId (LANDMINE)
- Mixed type can be ObjectId or hex string; app layer normalizes to ObjectId for queries
- Action: Audit all existing docs to determine actual type distribution; if mixed, use VARCHAR(24) + app-layer validation or add discriminator column
- Risk: Foreign key constraint will fail if stored as text
-
Unique Compound Index on Polymorphic Field
- The unique index includes
sellerOfferId, which is Mixed; MongoDB allows duplicate nulls/empty values - Action: Ensure PG unique constraint is sparse-compatible or add NOT NULL + default if needed
- The unique index includes
-
Balance/Sweep Totals May Overflow Number
totalSwept,lastKnownBalancestored as String/Number; wei precision ~10^18 exceeds safe JavaScript integer- Action: Use
numeric(78,0)in PG and validate conversion from string at app layer
-
External Counter Table
- Service uses separate
DerivedDestinationCountercollection for atomic derivation index allocation - Action: Migrate to PG sequence or shared counter table; ensure atomicity preserved
- Service uses separate
-
Schemaless metadata
- JSONB in PG; no pre-defined structure; validate at application layer if needed
Proposed PG DDL
CREATE TABLE derived_destinations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
buyer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
seller_offer_id VARCHAR(24) NOT NULL, -- or uuid if all are ObjectIds; add FK if needed
address VARCHAR(42) NOT NULL,
derivation_path VARCHAR(128) NOT NULL,
derivation_index INTEGER NOT NULL UNIQUE,
chain_id INTEGER NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'swept', 'sweeping', 'quarantined')),
last_sweep_tx_hash VARCHAR(66),
last_sweep_at TIMESTAMP WITHOUT TIME ZONE,
last_known_balance NUMERIC(78,0) DEFAULT 0,
last_balance_checked_at TIMESTAMP WITHOUT TIME ZONE,
total_swept NUMERIC(78,0) DEFAULT 0,
sweep_count INTEGER DEFAULT 0,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uniq_destination_by_buyer_seller_chain UNIQUE (buyer_id, seller_offer_id, chain_id)
);
CREATE INDEX idx_derived_destinations_buyer_id ON derived_destinations(buyer_id);
CREATE INDEX idx_derived_destinations_seller_offer_id ON derived_destinations(seller_offer_id);
CREATE INDEX idx_derived_destinations_address ON derived_destinations(address);
CREATE INDEX idx_derived_destinations_status_chain ON derived_destinations(status, chain_id);
-- If seller_offer_id is reliably ObjectId, add FK:
-- ALTER TABLE derived_destinations ADD CONSTRAINT fk_seller_offer_id
-- FOREIGN KEY (seller_offer_id) REFERENCES seller_offers(id) ON DELETE CASCADE;
Notes
- No TTL or auto-expiry; full data retention
- Addresses are immutable post-creation and deterministically derived from HD wallet xpub
- Sweep status and balance tracking enable off-chain accounting and dust collection
- The unique compound index ensures address reuse for the same (buyer, sellerOffer, chain) across multiple payments
Dispute
Summary: Manages dispute resolution workflows in the escrow system, tracking evidence, communication history, and resolution outcomes. Complex model with embedded arrays (evidence, timeline) and optional nested resolution details.
Fields:
| Path | Mongo Type | PG Type | Required | Notes |
|---|---|---|---|---|
| _id | ObjectId | UUID PRIMARY KEY | Yes | Auto-generated; use v4. |
| purchaseRequestId | ObjectId ref | UUID NOT NULL | Yes | FK to PurchaseRequest. Indexed. CASCADE DELETE. |
| buyerId | ObjectId ref | UUID NOT NULL | Yes | FK to User (buyer). Indexed. |
| sellerId | ObjectId ref | UUID | No | FK to User (seller). Nullable. Indexed. |
| adminId | ObjectId ref | UUID | No | FK to User (admin). Nullable. Indexed. |
| reason | String (max 200) | VARCHAR(200) NOT NULL | Yes | Dispute reason; trimmed. |
| description | String (max 2000) | TEXT NOT NULL | Yes | Detailed description; trimmed. |
| priority | String enum | VARCHAR(10) DEFAULT 'medium' | No | Values: low, medium, high, urgent. CHECK constraint. Indexed. |
| category | String enum | VARCHAR(30) NOT NULL | Yes | Values: product_quality, delivery_delay, wrong_item, payment_issue, seller_behavior, other. CHECK constraint. Indexed. |
| status | String enum | VARCHAR(20) DEFAULT 'pending' | No | Values: pending, in_progress, waiting_response, resolved, rejected, closed. CHECK constraint. Indexed. |
| evidence[] | Embedded array | Child table dispute_evidence | No | Array of evidence items. See child table. |
| evidence[].type | String enum | VARCHAR(15) NOT NULL | Yes | Values: image, document, screenshot, video. CHECK constraint. |
| evidence[].url | String | TEXT NOT NULL | Yes | URL to evidence file. |
| evidence[].description | String | TEXT | No | Optional description. |
| evidence[].uploadedBy | ObjectId ref | UUID NOT NULL | Yes | FK to User. |
| evidence[].uploadedAt | Date | TIMESTAMP DEFAULT NOW() | No | Timestamp of upload. |
| chatId | ObjectId ref | UUID | No | FK to Chat (dispute communication). Nullable. Indexed implicitly. |
| timeline[] | Embedded array | Child table dispute_timeline | No | Audit trail of actions. See child table. |
| timeline[].action | String | VARCHAR(100) NOT NULL | Yes | Action name: dispute_created, admin_assigned, status_changed, evidence_added, dispute_resolved. |
| timeline[].performedBy | ObjectId ref | UUID NOT NULL | Yes | FK to User who performed action. |
| timeline[].performedAt | Date | TIMESTAMP DEFAULT NOW() | No | Timestamp of action. |
| timeline[].details | String | TEXT | No | Additional context. |
| resolution | Embedded subdoc | JSONB or child table | No | Optional resolution details (set when resolved). See child table or JSONB schema below. |
| resolution.action | String enum | VARCHAR(20) | No | Values: refund, replacement, compensation, warning_seller, ban_seller, no_action. CHECK constraint. |
| resolution.amount | Number | DECIMAL(12,2) | No | Refund/compensation amount. |
| resolution.currency | String enum | VARCHAR(5) | No | Values: USD, EUR, IRR, USDT. CHECK constraint. |
| resolution.notes | String (max 1000) | TEXT | No | Resolution notes. |
| resolution.resolvedBy | ObjectId ref | UUID | No | FK to User (admin). |
| resolution.resolvedAt | Date | TIMESTAMP | No | Timestamp of resolution. |
| deadline | Date | TIMESTAMP | No | SLA deadline (typically 7 days). |
| responseDeadline | Date | TIMESTAMP | No | Response deadline (typically 48 hours). |
| tags[] | Array of Strings | TEXT[] or child table | No | String array for filtering. Use TEXT[] in PG11+. |
| createdAt | Date | TIMESTAMP DEFAULT NOW() | Yes | Auto-created. Indexed (DESC). |
| updatedAt | Date | TIMESTAMP DEFAULT NOW() | Yes | Auto-updated on save. |
| closedAt | Date | TIMESTAMP | No | When dispute was closed/resolved. |
Relationships:
- purchaseRequestId (N:1) → PurchaseRequest. FK with CASCADE DELETE.
- buyerId (N:1) → User. FK with NO ACTION or CASCADE (depends on policy).
- sellerId (N:1, nullable) → User. FK with NO ACTION.
- adminId (N:1, nullable) → User. FK with NO ACTION.
- chatId (1:1, nullable) → Chat. FK with NO ACTION or SET NULL.
- evidence[].uploadedBy (N:1) → User. FK in dispute_evidence child table.
- timeline[].performedBy (N:1) → User. FK in dispute_timeline child table.
- resolution.resolvedBy (N:1, nested, nullable) → User. FK in dispute_resolution child table or denormalized UUID in JSONB.
Indexes:
CREATE INDEX idx_disputes_purchase_request ON disputes(purchase_request_id);
CREATE INDEX idx_disputes_buyer ON disputes(buyer_id);
CREATE INDEX idx_disputes_seller ON disputes(seller_id);
CREATE INDEX idx_disputes_admin ON disputes(admin_id);
CREATE INDEX idx_disputes_status ON disputes(status);
CREATE INDEX idx_disputes_priority ON disputes(priority);
CREATE INDEX idx_disputes_category ON disputes(category);
CREATE INDEX idx_disputes_created_at ON disputes(created_at DESC);
CREATE INDEX idx_disputes_status_priority ON disputes(status ASC, priority DESC);
CREATE INDEX idx_disputes_admin_status ON disputes(admin_id, status);
Gotchas & Mongoose Features:
-
Pre-save Hook: Mongoose automatically adds a timeline entry
{ action: 'dispute_created', performedBy: buyerId, performedAt: now, details: 'Dispute created' }on new disputes. Migration action: Move this to application code — call it immediately afterDispute.create()inDisputeService.createDispute(). -
Timestamps Option:
timestamps: trueauto-generates and updatescreatedAtandupdatedAt. In PG, useDEFAULT CURRENT_TIMESTAMPfor creation and anON UPDATEtrigger or app-layer logic for updates. -
Embedded Arrays & Enums: Evidence and timeline are arrays of embedded subdocs with enum fields. Must become normalized child tables in PG for queryability (e.g., filter by evidence type or timeline action).
-
Optional Nested Subdoc: The
resolutionobject is only present when dispute status is 'resolved'. Strategy options:- JSONB column: Store as nullable JSONB with runtime schema validation (simpler, denormalized).
- Child table: Create
dispute_resolutionwith nullable FK to disputes (fully normalized, requires LEFT JOIN).
-
Enum Validation: Six enum fields (priority, category, status, evidence[].type, resolution.action, resolution.currency) require PG CHECK constraints.
-
ObjectId Conversion: All references are MongoDB ObjectIds. Convert to UUID v4 on migration.
Proposed DDL Sketch:
CREATE TABLE disputes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
purchase_request_id UUID NOT NULL,
buyer_id UUID NOT NULL,
seller_id UUID,
admin_id UUID,
reason VARCHAR(200) NOT NULL,
description TEXT NOT NULL,
priority VARCHAR(10) NOT NULL DEFAULT 'medium'
CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
category VARCHAR(30) NOT NULL
CHECK (category IN ('product_quality', 'delivery_delay', 'wrong_item', 'payment_issue', 'seller_behavior', 'other')),
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'in_progress', 'waiting_response', 'resolved', 'rejected', 'closed')),
chat_id UUID,
deadline TIMESTAMP,
response_deadline TIMESTAMP,
tags TEXT[],
resolution_action VARCHAR(20)
CHECK (resolution_action IN ('refund', 'replacement', 'compensation', 'warning_seller', 'ban_seller', 'no_action')),
resolution_amount DECIMAL(12,2),
resolution_currency VARCHAR(5)
CHECK (resolution_currency IN ('USD', 'EUR', 'IRR', 'USDT')),
resolution_notes TEXT,
resolution_resolved_by UUID,
resolution_resolved_at TIMESTAMP,
closed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (purchase_request_id) REFERENCES purchase_requests(id) ON DELETE CASCADE,
FOREIGN KEY (buyer_id) REFERENCES users(id),
FOREIGN KEY (seller_id) REFERENCES users(id),
FOREIGN KEY (admin_id) REFERENCES users(id),
FOREIGN KEY (chat_id) REFERENCES chats(id)
);
CREATE TABLE dispute_evidence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dispute_id UUID NOT NULL,
type VARCHAR(15) NOT NULL
CHECK (type IN ('image', 'document', 'screenshot', 'video')),
url TEXT NOT NULL,
description TEXT,
uploaded_by UUID NOT NULL,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (dispute_id) REFERENCES disputes(id) ON DELETE CASCADE,
FOREIGN KEY (uploaded_by) REFERENCES users(id)
);
CREATE TABLE dispute_timeline (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dispute_id UUID NOT NULL,
action VARCHAR(100) NOT NULL,
performed_by UUID NOT NULL,
performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
details TEXT,
FOREIGN KEY (dispute_id) REFERENCES disputes(id) ON DELETE CASCADE,
FOREIGN KEY (performed_by) REFERENCES users(id)
);
-- Indexes
CREATE INDEX idx_disputes_purchase_request ON disputes(purchase_request_id);
CREATE INDEX idx_disputes_buyer ON disputes(buyer_id);
CREATE INDEX idx_disputes_seller ON disputes(seller_id);
CREATE INDEX idx_disputes_admin ON disputes(admin_id);
CREATE INDEX idx_disputes_status ON disputes(status);
CREATE INDEX idx_disputes_priority ON disputes(priority);
CREATE INDEX idx_disputes_category ON disputes(category);
CREATE INDEX idx_disputes_created_at ON disputes(created_at DESC);
CREATE INDEX idx_disputes_status_priority ON disputes(status ASC, priority DESC);
CREATE INDEX idx_disputes_admin_status ON disputes(admin_id, status);
CREATE INDEX idx_dispute_evidence_dispute ON dispute_evidence(dispute_id);
CREATE INDEX idx_dispute_timeline_dispute ON dispute_timeline(dispute_id);
FundsLedgerEntry
Purpose: Immutable audit log of financial transactions (payments, fees, holds, releases, refunds, disputes, adjustments).
Key Constraint: All documents are append-only; updates after creation are blocked by pre-save hooks.
Field Table
| Path | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid PRIMARY KEY | Y | Auto-generated; migrate to uuid4 |
| purchaseRequestId | Mixed (ObjectId | string) | text NOT NULL | Y | Polymorphic ref; discriminator required or assume ObjectId → uuid FK |
| paymentId | Mixed (ObjectId | string) | text NOT NULL | Y | Polymorphic ref; consider separate type+value columns if mixed |
| entryType | String (enum) | varchar(20) NOT NULL CHECK (...) | Y | Enum: payment_detected, provider_fee, platform_fee, hold, release, refund, dispute_hold, adjustment |
| amount | Number | numeric(19,8) NOT NULL | Y | Min 0.00000001; use numeric for crypto |
| currency | String | varchar(10) NOT NULL | Y | Default 'USDT'; consider FK to currency table |
| idempotencyKey | String | varchar(255) UNIQUE SPARSE | N | Allows null; use UNIQUE WHERE IS NOT NULL in PG 15+ |
| metadata | Mixed | jsonb | N | Arbitrary JSON; default undefined → null in PG |
| createdBy | Mixed (ObjectId | string) | text | N | Optional polymorphic User ref; inspect usage |
| occurredAt | Date | timestamp NOT NULL | Y | When transaction occurred (distinct from createdAt); default Date.now |
| createdAt | Date | timestamp NOT NULL | Y | Auto-set by Mongoose; immutable after insert |
| updatedAt | Date | timestamp NOT NULL | Y | Auto-set by Mongoose; equals createdAt due to immutability |
Relationships
- purchaseRequestId → PurchaseRequest: N:1, Mixed ref (ObjectId or string). If guaranteed ObjectId, use uuid FK. If polymorphic, store as text.
- paymentId → Payment: N:1, Mixed ref (ObjectId or string). Recommend separate (type, value) columns if truly polymorphic.
- createdBy → User: N:1 optional, Mixed ref. Optional; storage depends on actual usage pattern.
Indexes
CREATE UNIQUE INDEX idx_funds_ledger_idempotency
ON funds_ledger_entries (idempotencyKey)
WHERE idempotencyKey IS NOT NULL;
CREATE INDEX idx_funds_ledger_purchase_created
ON funds_ledger_entries (purchaseRequestId, createdAt DESC);
CREATE INDEX idx_funds_ledger_payment_created
ON funds_ledger_entries (paymentId, createdAt DESC);
Immutability Enforcement
Mongoose blocks all updates via:
pre('save'): Raises error if not new and modifiedpre(['findOneAndUpdate', 'findByIdAndUpdate', 'updateOne', 'updateMany', 'replaceOne']): All raise "documents are immutable"
Migration: Implement equivalent in PG via trigger:
CREATE TRIGGER funds_ledger_immutable_update
BEFORE UPDATE ON funds_ledger_entries
FOR EACH ROW
RAISE EXCEPTION 'FundsLedgerEntry documents are immutable';
Or enforce immutability in application layer (deny UPDATE at API level).
Gotchas & Landmines
-
Mixed Types (purchaseRequestId, paymentId, createdBy): These are ObjectId | string; cannot be simple FK constraints in PG. Options:
- Inspect production data; if all ObjectId, map to uuid FK.
- If mixed, store as text and validate in application.
- Add discriminator column (e.g.,
purchaseRequestId_type: 'uuid' | 'string').
-
Sparse Unique Index:
idempotencyKeyis unique but allows nulls. In PG, useUNIQUE WHERE idempotencyKey IS NOT NULL(requires PG 15+). -
Metadata as Mixed: Arbitrary JSON; migrate to JSONB. No schema validation in Mongoose — ensure application validates before insert.
-
Default Date.now on occurredAt: Multiple entries may share exact same timestamp. Verify clock skew in ETL doesn't lose data during bulk insert.
-
Immutability Hooks: Pre-hooks on 5 different update methods. Reimplements as trigger or application guard (required; not default PG behavior).
-
Timestamps Always Set:
timestamps: trueauto-manages createdAt/updatedAt. Post-migration, ensure application does NOT attempt to manually set these. -
Aggregation Queries: Service layer uses
.aggregate()with groupBy entryType, $sum amounts. Retest all aggregations post-migration.
Proposed PG DDL Sketch
CREATE TABLE funds_ledger_entries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
purchaseRequestId text NOT NULL, -- TODO: add FK or type column if polymorphic
paymentId text NOT NULL, -- TODO: add FK or type column if polymorphic
entryType varchar(20) NOT NULL CHECK (entryType IN (
'payment_detected', 'provider_fee', 'platform_fee', 'hold',
'release', 'refund', 'dispute_hold', 'adjustment'
)),
amount numeric(19, 8) NOT NULL CHECK (amount >= 0.00000001),
currency varchar(10) NOT NULL DEFAULT 'USDT',
idempotencyKey varchar(255),
metadata jsonb,
createdBy text, -- TODO: add FK or type column if polymorphic
occurredAt timestamp NOT NULL,
createdAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_funds_ledger_idempotency
ON funds_ledger_entries (idempotencyKey)
WHERE idempotencyKey IS NOT NULL;
CREATE INDEX idx_funds_ledger_purchase_created
ON funds_ledger_entries (purchaseRequestId, createdAt DESC);
CREATE INDEX idx_funds_ledger_payment_created
ON funds_ledger_entries (paymentId, createdAt DESC);
CREATE TRIGGER funds_ledger_immutable_update
BEFORE UPDATE ON funds_ledger_entries
FOR EACH ROW
RAISE EXCEPTION 'FundsLedgerEntry documents are immutable';
Notes for ETL
- Ensure all Mongoose
.create()calls map directly; no manual _id management expected. - Idempotency key deduplication: migrate as-is; PG unique constraint will catch duplicates.
- Metadata is untyped JSON — validate schema in application before insert in PG.
- If purchaseRequestId or paymentId reference other collections, add FK constraints only if refs are guaranteed ObjectId; otherwise store as text and validate in app.
LevelConfig
Purpose: Defines membership/loyalty tier configurations with point thresholds and associated benefits (discounts, free shipping, priority support, special offers).
Field Reference
| Path | Mongo Type | PG Type | Required | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid | yes | Primary key; auto-generated |
| level | Number | INTEGER | yes | Tier number; UNIQUE constraint (line 29) |
| name | String | VARCHAR(255) | yes | Localized tier name |
| nameEn | String | VARCHAR(255) | yes | English tier name |
| minPoints | Number | INTEGER | yes | Min points threshold; default 0; indexed |
| maxPoints | Number | INTEGER | no | Max points threshold (optional ceiling) |
| benefits | embedded subdoc | JSONB or inline | yes | Contains: discountPercent, freeShipping, prioritySupport, specialOffers |
| benefits.discountPercent | Number | NUMERIC(5,2) | no | Default 0 |
| benefits.freeShipping | Boolean | BOOLEAN | no | Default false |
| benefits.prioritySupport | Boolean | BOOLEAN | no | Default false |
| benefits.specialOffers | Boolean | BOOLEAN | no | Default false |
| icon | String | VARCHAR(255) | no | Icon identifier; default 'solar:medal-star-bold' |
| color | String | VARCHAR(7) | no | Hex color; default '#94a3b8' |
| order | Number | INTEGER | yes | Display order; indexed |
| isActive | Boolean | BOOLEAN | no | Activation flag; default true; indexed (soft-delete pattern) |
| createdAt | Date | TIMESTAMP WITH TIME ZONE | yes | Auto-generated |
| updatedAt | Date | TIMESTAMP WITH TIME ZONE | yes | Auto-generated |
Relationships
None. LevelConfig is referenced by user.points.level in other models but is not a foreign key relationship in the traditional sense—it's a lookup/join-to context.
Indexes
level— UNIQUE (schema line 29)minPoints— single-field (line 89); used in.find({isActive: true}).sort({minPoints: 1})order— single-field (line 90); used in.find({isActive: true}).sort({order: 1})isActive— single-field (line 91); soft-delete filter in nearly all queries
Gotchas & Migration Notes
-
Embedded benefits subdoc: Stored as nested object in MongoDB. In PostgreSQL, choose either:
- Inline columns (recommended): 4 additional columns on levelconfigs table. Simpler, faster for small subdocs.
- JSONB: Keeps structure; trades query simplicity for flexibility.
-
Soft-delete pattern:
isActivedefaults true. All production queries filterWHERE isActive = true. No hard deletes observed in code; consider adding a unique index on(level, isActive)or at minimum keep the single-field index onisActive. -
Point thresholds:
minPointsandmaxPointsdefine a tier range.maxPointsis nullable (optional ceiling). Ensure NOT NULL constraint onminPoints, NULL allowed onmaxPoints. -
Timestamps: Auto-managed by Mongoose. In PG, use
DEFAULT CURRENT_TIMESTAMPandON UPDATE CURRENT_TIMESTAMP(or trigger). -
Default values: icon, color, isActive, discountPercent, freeShipping, prioritySupport, specialOffers all have defaults. Apply in DDL or application layer.
Usage Patterns (from code grep)
LevelConfig.find({isActive: true}).sort({minPoints: 1})— fetch active tiers sorted by minimum pointsLevelConfig.findOne({level: user.points.level, isActive: true})— lookup current user tierLevelConfig.insertMany(levels)— seed levels (one-time operation)
Proposed PostgreSQL Schema
CREATE TABLE levelconfigs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
level INTEGER NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
name_en VARCHAR(255) NOT NULL,
min_points INTEGER NOT NULL DEFAULT 0,
max_points INTEGER,
discount_percent NUMERIC(5,2) DEFAULT 0,
free_shipping BOOLEAN DEFAULT false,
priority_support BOOLEAN DEFAULT false,
special_offers BOOLEAN DEFAULT false,
icon VARCHAR(255) DEFAULT 'solar:medal-star-bold',
color VARCHAR(7) DEFAULT '#94a3b8',
"order" INTEGER NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_levelconfigs_min_points ON levelconfigs(min_points);
CREATE INDEX idx_levelconfigs_order ON levelconfigs("order");
CREATE INDEX idx_levelconfigs_is_active ON levelconfigs(is_active);
-- Optional: compound index for most common query pattern
CREATE INDEX idx_levelconfigs_active_order ON levelconfigs(is_active, "order");
Notes for Application Migration
- Rename snake_case columns when writing queries:
benefits.discountPercent→discount_percent, etc. - Ensure application layer applies defaults for icon, color, and other fields on insert.
- Retain the soft-delete pattern by always querying
WHERE is_active = trueunless listing all archived tiers. - Use a database trigger or ORM feature to auto-update
updated_aton row modification.
Notification
User-facing notifications system for transactional alerts (purchase requests, offers, payments, deliveries, system events). Each notification is tied to a user and optionally a related entity (polymorphic string ID). The metadata field stores flexible JSON event data. Documents auto-delete 90 days after creation via MongoDB TTL index (requires manual cleanup in PostgreSQL).
Field Table
| Path | Mongo Type | PG Type | Req. | Notes |
|---|---|---|---|---|
_id |
ObjectId | UUID PRIMARY KEY or BIGSERIAL | ✓ | Auto-generated; migrate to UUID or sequential during ETL |
userId |
String | TEXT or UUID (FK users.id) | ✓ | High-cardinality; indexed (simple + 2 compounds). Verify type alignment with users table |
title |
String | VARCHAR(200) NOT NULL | ✓ | Mongoose maxlength: 200 → CHECK constraint in PG |
message |
String | TEXT NOT NULL | ✓ | Mongoose maxlength: 1000 → CHECK constraint |
type |
String enum | VARCHAR(20) / ENUM | ✓ | Enum: info|success|warning|error; default 'info'; consider CREATE TYPE |
category |
String enum | VARCHAR(30) / ENUM | ✓ | Enum: purchase_request|offer|payment|delivery|system; indexed in compound (userId, category) |
relatedId |
String | TEXT | ✗ | Polymorphic string ID (PurchaseRequest, Offer, Payment, or Delivery). Indexed separately; no FK due to polymorphism |
metadata |
Mixed | JSONB | ✗ | Untyped JSON structure per notification type; use JSONB for flexible indexing |
actionUrl |
String | VARCHAR(500) | ✗ | Mongoose maxlength: 500 |
isRead |
Boolean | BOOLEAN NOT NULL DEFAULT false | ✓ | Read flag; indexed in compound (userId, isRead) for unread queries |
readAt |
Date | TIMESTAMP | ✗ | Nullable; set when marked as read; no auto-lifecycle hook in current schema |
createdAt |
Date | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | ✓ | Auto-managed; indexed (simple + compound DESC); subject to TTL (90 days) |
updatedAt |
Date | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | ✓ | Auto-managed; not critical to index |
Indexes
- Simple:
userId— high-selectivity filter in WHERE clauses - Compound:
(userId, createdAt DESC)— fetch sorted by recency (critical forgetUserNotifications) - Compound:
(userId, isRead)— unread count and filtering - Compound:
(userId, category)— filter by event type - Simple:
relatedId— polymorphic entity lookup - TTL:
createdAtwith 7776000 seconds (90 days) — MongoDB auto-deletes; PostgreSQL requires cron job or background cleanup
Relationships
- userId (N:1): Points to User. Store as TEXT or UUID depending on users.id type. High-cardinality, compound-indexed.
- relatedId (N:1 polymorphic): String ID pointing to PurchaseRequest, Offer, Payment, or Delivery. No discriminator column in current schema. No FK enforced; validate at application layer or add entity_type column during migration.
Gotchas
-
TTL Deletion (90 days): MongoDB's
expireAfterSecondshas no native PostgreSQL equivalent. Implement via:pg_cronextension with daily/hourly DELETE:DELETE FROM notifications WHERE created_at < NOW() - INTERVAL '90 days'- OR application-level background worker
- Test timing before production cutover to ensure no unintended data loss
-
Polymorphic relatedId: Points to variable entity types without type discriminator. Options:
- Add
entity_typeVARCHAR(30) column during migration to track target model - Keep as-is if application layer guarantees correctness; document relationship
- Add CHECK or trigger validation
- Add
-
Mixed/Untyped metadata: Store as JSONB in PG. No application validation in current schema. Document expected structures per category; consider post-migration validation layer.
-
userId Type Alignment: Verify userId matches users.id type (TEXT vs. UUID). Convert during ETL if needed.
-
Compound Index Sort Order: (userId, createdAt DESC) requires explicit
DESCin PostgreSQL index creation for optimal query plans. -
Enum Validation: Three enum fields (type, category, metadata implicitly). Use PG CREATE TYPE or CHECK constraints to enforce; application code must validate before INSERT.
Proposed PostgreSQL DDL Sketch
-- Create enum types for type-safety
CREATE TYPE notification_type AS ENUM ('info', 'success', 'warning', 'error');
CREATE TYPE notification_category AS ENUM ('purchase_request', 'offer', 'payment', 'delivery', 'system');
-- Main table
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL, -- or UUID if users.id is UUID; add FK if desired
title VARCHAR(200) NOT NULL,
message TEXT NOT NULL,
type notification_type NOT NULL DEFAULT 'info',
category notification_category NOT NULL,
related_id TEXT, -- Polymorphic string ID; no FK constraint
metadata JSONB,
action_url VARCHAR(500),
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT title_length CHECK (CHAR_LENGTH(title) <= 200),
CONSTRAINT message_length CHECK (CHAR_LENGTH(message) <= 1000),
CONSTRAINT action_url_length CHECK (CHAR_LENGTH(action_url) <= 500)
);
-- Indexes matching MongoDB strategy
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
CREATE INDEX idx_notifications_user_id_created_at ON notifications(user_id, created_at DESC);
CREATE INDEX idx_notifications_user_id_is_read ON notifications(user_id, is_read);
CREATE INDEX idx_notifications_user_id_category ON notifications(user_id, category);
CREATE INDEX idx_notifications_related_id ON notifications(related_id);
-- TTL cleanup (replace with pg_cron if extension available)
-- Manual: DELETE FROM notifications WHERE created_at < NOW() - INTERVAL '90 days';
-- Optional: Add FK to users if user_id type aligns
-- ALTER TABLE notifications ADD CONSTRAINT fk_notifications_user_id
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ETL Notes
- ObjectId → UUID or BIGSERIAL: Decide during ETL; use gen_random_uuid() or sequence
- Timestamp formats: Ensure ISO 8601 strings from MongoDB JSON export convert to TIMESTAMP without loss of precision
- Enum validation: Verify all type and category values match PG enum definitions before insert
- metadata JSONB: No validation applied; store as-is; document expected structures per category for downstream systems
- userId alignment: Match users.id type and add FK if referential integrity is desired
- TTL implementation: Set up cron job or background cleanup before migration completes; test with sample data
Payment
Collection: payments
Summary: Blockchain and payment provider transaction records for the escrow system. Tracks purchase-to-seller mappings, buyer/seller IDs, multi-provider payment flows (Request Network, SHKeeper, AMN Scanner), blockchain transaction details, and extensive provider-specific metadata. Contains polymorphic IDs (Mixed types) for template checkout support and sophisticated partial unique indexing for idempotency.
Field Schema
| Path | Mongoose Type | PG Type | Req | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid PRIMARY KEY | Yes | Auto-generated; convert to UUID |
| purchaseRequestId | Mixed | text NOT NULL | Yes | Polymorphic: ObjectId OR string (template); store as text + discriminator |
| sellerOfferId | Mixed | text NOT NULL | Yes | Polymorphic: ObjectId OR string (template); store as text + discriminator |
| buyerId | ObjectId (ref:User) | uuid NOT NULL (FK users.id) | Yes | Hard reference; add FK with CASCADE |
| sellerId | Mixed | text NOT NULL | Yes | Polymorphic: ObjectId OR string (template); store as text + discriminator |
| amount.amount | Number | numeric(18,8) NOT NULL | Yes | Payment amount; use numeric for precision |
| amount.currency | String | varchar(10) NOT NULL DEFAULT 'USDT' | Yes | Currency code |
| provider | String enum | varchar(20) NOT NULL DEFAULT 'request.network' | No | Values: request.network, amn.scanner, shkeeper, other; add CHECK |
| direction | String enum | varchar(10) NOT NULL DEFAULT 'in' | No | Values: in, out, refund; add CHECK |
| blockchain | object | JSONB | No | Nested: network, transactionHash, blockchain (enum), token, sender, receiver, confirmedAt, confirmations |
| blockchain.transactionHash | String | varchar(255) | No | TX hash; sparse index for idempotency |
| blockchain.blockchain | String enum | varchar(20) | No | Enum: ethereum, polygon, bsc, avalanche, solana, optimism, arbitrum, base, gnosis |
| blockchain.confirmedAt | Date | timestamp | No | On-chain confirmation time |
| blockchain.confirmations | Number | integer DEFAULT 0 | No | Block confirmation count |
| status | String enum | varchar(20) NOT NULL DEFAULT 'pending' | No | Values: pending, processing, confirmed, completed, failed, cancelled, refunded; add CHECK |
| escrowState | String enum | varchar(20) | No | Values: funded, releasable, released, refunded, releasing, failed, cancelled, partial |
| providerPaymentId | String | varchar(255) UNIQUE SPARSE | No | Idempotency key from provider; sparse index |
| metadata | object | JSONB | No | Complex provider-specific data (SHKeeper, Request Network, AMN Scanner) with Mixed sub-fields; store as JSONB |
| metadata.shkeeperData | Mixed | JSONB | No | Arbitrary SHKeeper payload |
| metadata.requestNetworkData | Mixed | JSONB | No | Arbitrary Request Network payload |
| metadata.webhookPayload | Mixed | JSONB | No | Last webhook body |
| metadata.transactionSafety | Mixed | JSONB | No | Fraud/safety signals |
| metadata.inHouseCheckout | Mixed | JSONB | No | AMN Scanner checkout data |
| metadata.derivedDestination | object | JSONB | No | Nested: address, derivationPath, derivationIndex, chainId |
| disputed | Boolean | boolean NOT NULL DEFAULT false | No | Dispute flag; backfill false for pre-existing |
| disputeHoldReason | String | text | No | Reason for hold |
| holdUntil | Date | timestamp | No | Hold expiration; backfill null for pre-existing |
| notes | String | text | No | Optional admin notes |
| createdAt | Date | timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP | Yes | Auto-set by timestamps:true |
| updatedAt | Date | timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP | Yes | Auto-set by timestamps:true |
Relationships
| Field | Target | Cardinality | Kind | PG Strategy |
|---|---|---|---|---|
| buyerId | User | N:1 | ref ObjectId | FK users.id with ON DELETE CASCADE |
| purchaseRequestId | PurchaseRequest | N:1 | polymorphic (ObjectId or string) | Store as text; add type discriminator or conditional FK if ObjectId always maps to row |
| sellerOfferId | SellerOffer | N:1 | polymorphic (ObjectId or string) | Store as text; add type discriminator or conditional FK if ObjectId always maps to row |
| sellerId | User | N:1 | polymorphic (ObjectId or string) | Store as text; add type discriminator or conditional FK if ObjectId always maps to row |
Indexes
- Composite:
(status ASC, createdAt DESC)— for status-based listing with date sorting - Composite:
(buyerId, status)— for buyer payment queries filtered by status - Composite:
(sellerId, status)— for seller payment queries filtered by status - Sparse:
(blockchain.transactionHash)— for TX hash idempotency lookup - Sparse:
(providerPaymentId)— for provider payment ID idempotency lookup - Unique with partial filter (named
uniq_pending_request_network_by_buyer_session_offer):Prevents duplicate pending Request Network pay-ins for the same buyer/session/offer pair. Multi-seller carts require one Payment per sellerOfferId, so sellerOfferId is included in the key.UNIQUE (buyerId, purchaseRequestId, sellerOfferId, provider, direction) WHERE provider = 'request.network' AND direction = 'in' AND status = 'pending'
Computed Virtual
- paymentRef:
PAY-${_id.toString().slice(-8).toUpperCase()}— reference code derived from ObjectId; include in serialized output (toJSON/toObject with virtuals:true)
Mongooseanisms
- Timestamps:
timestamps: trueauto-adds createdAt and updatedAt; configure update triggers in PG - Virtuals in serialization:
toJSONandtoObjectinclude virtuals — ensure paymentRef is computed on read in app layer post-migration - No hooks/methods: Schema defines no pre/post hooks, instance methods, or statics
Migration Gotchas & Strategy
1. Polymorphic Mixed fields (purchaseRequestId, sellerOfferId, sellerId)
- These accept either ObjectId OR string (for template checkout flows)
- Cannot be a simple FK in PG
- Solution: Store all as TEXT; add type discriminator column (e.g.,
purchaseRequestIdType= 'oid'|'template') or validate at app layer. If ObjectId always correlates to a real row, add conditional FK in PG (triggers or generated columns) or keep relationship logic in app.
2. Unique partial index with WHERE clause
- Mongo supports
partialFilterExpression; PG supportsWHEREin UNIQUE constraints - PG DDL:
UNIQUE (buyer_id, purchase_request_id, seller_offer_id, provider, direction) WHERE provider = 'request.network' AND direction = 'in' AND status = 'pending' - Ensure application code respects this uniqueness constraint (catch integrity errors)
3. Metadata JSONB explosion
- Field contains provider-specific data (shkeeperData, requestNetworkData, webhookPayload, transactionSafety, inHouseCheckout) with arbitrary shape (Mixed type)
- Option A: Store all as single JSONB column (simplest migration, complex queries)
- Option B: Extract frequently-queried fields (shkeeperInvoiceId, shkeeperStatus, amnScannerIntentId, shkeeperTaskId, requestNetworkRequestId) to top-level columns for indexing and filtering
- Recommendation: Start with JSONB; add top-level columns if query performance demands it
4. Blockchain nested object
- Optional embedded object with multiple fields
- Option A: JSONB (flexible, simpler migration)
- Option B: Extract frequently-used fields (transactionHash, blockchain enum, confirmedAt) to top-level columns if this is a query hot path
- Current sparse index on transactionHash suggests it's used for lookups; consider extracting
5. Data migration: disputed and holdUntil backfill
- Schema comment: "TODO: Backfill existing records — set disputed=false and holdUntil=null for all pre-existing docs"
- Before final cutover, run:
UPDATE payments SET disputed = false WHERE disputed IS NULL; UPDATE payments SET hold_until = NULL WHERE hold_until IS NULL;
6. TypeScript interface vs Mongoose schema mismatch
- Interface declares purchaseRequestId/sellerOfferId/sellerId as ObjectId
- Schema declares them as Mixed
- This is intentional per inline comments for template checkout support
- Ensure app validates type at runtime; document the polymorphism clearly
Proposed PostgreSQL DDL Sketch
-- Main table
CREATE TABLE payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Polymorphic IDs (template-aware)
purchase_request_id text NOT NULL,
purchase_request_id_type varchar(20) DEFAULT 'oid', -- discriminator
seller_offer_id text NOT NULL,
seller_offer_id_type varchar(20) DEFAULT 'oid', -- discriminator
seller_id text NOT NULL,
seller_id_type varchar(20) DEFAULT 'oid', -- discriminator
-- User references (hard FK)
buyer_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Amount
amount numeric(18, 8) NOT NULL,
currency varchar(10) NOT NULL DEFAULT 'USDT',
-- Provider & flow
provider varchar(20) NOT NULL DEFAULT 'request.network'
CHECK (provider IN ('request.network', 'amn.scanner', 'shkeeper', 'other')),
direction varchar(10) NOT NULL DEFAULT 'in'
CHECK (direction IN ('in', 'out', 'refund')),
-- Blockchain data (JSONB or extracted columns)
blockchain_hash varchar(255),
blockchain_network varchar(50),
blockchain_type varchar(20),
blockchain_token varchar(255),
blockchain_sender varchar(255),
blockchain_receiver varchar(255),
blockchain_confirmed_at timestamp,
blockchain_confirmations integer DEFAULT 0,
-- Status
status varchar(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'confirmed', 'completed', 'failed', 'cancelled', 'refunded')),
escrow_state varchar(20)
CHECK (escrow_state IN ('funded', 'releasable', 'released', 'refunded', 'releasing', 'failed', 'cancelled', 'partial')),
-- Provider tracking
provider_payment_id varchar(255) UNIQUE SPARSE,
-- Metadata (JSONB; can extract common fields later)
metadata jsonb,
-- Dispute & hold
disputed boolean NOT NULL DEFAULT false,
dispute_hold_reason text,
hold_until timestamp,
-- Notes
notes text,
-- Timestamps
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX idx_payments_status_created_at ON payments(status ASC, created_at DESC);
CREATE INDEX idx_payments_buyer_status ON payments(buyer_id, status);
CREATE INDEX idx_payments_seller_status ON payments(seller_id, status);
CREATE INDEX idx_payments_blockchain_hash ON payments(blockchain_hash) WHERE blockchain_hash IS NOT NULL;
CREATE INDEX idx_payments_provider_id ON payments(provider_payment_id) WHERE provider_payment_id IS NOT NULL;
-- Unique partial: prevent duplicate pending Request Network pay-ins per buyer/session/offer
CREATE UNIQUE INDEX uniq_pending_request_network_by_buyer_session_offer
ON payments(buyer_id, purchase_request_id, seller_offer_id, provider, direction)
WHERE provider = 'request.network' AND direction = 'in' AND status = 'pending';
-- Computed column for paymentRef (if using generated column)
ALTER TABLE payments ADD COLUMN payment_ref varchar(20) GENERATED ALWAYS AS
('PAY-' || RIGHT(id::text, 8)) STORED;
Notes on DDL:
- Polymorphic IDs stored as text with optional type discriminator columns
- Blockchain data initially split out; can condense to single JSONB if preferred
- metadata as JSONB; extract frequent fields if performance requires
- Computed
payment_refcolumn for easy reference; alternatively compute in app - Sparse indexes use WHERE clause; ensure app handles NULL values correctly
- Partial unique index prevents duplicates only for pending Request Network pay-ins
PointTransaction
Ledger of point transactions (earn, spend, expire) per user, recording transaction type, source, amount, and a snapshot of the resulting balance. Used by PointsService for referral tracking, bonus allocation, and point expiry management.
Field Table
| Path | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid PRIMARY KEY DEFAULT gen_random_uuid() | Y | Mongoose auto-generated |
| user | ObjectId (ref: User) | uuid NOT NULL | Y | FK to users; indexed |
| type | enum: earn|spend|expire | VARCHAR(10) NOT NULL CHECK (...) | Y | Transaction type |
| source | enum: purchase|referral|bonus|admin|redemption | VARCHAR(15) NOT NULL CHECK (...) | Y | Transaction source |
| amount | Number | DECIMAL(10,2) NOT NULL | Y | Points amount |
| balance | Number | DECIMAL(10,2) NOT NULL | Y | Balance after transaction (snapshot) |
| order | ObjectId (ref: Order) | uuid | N | Optional FK to orders |
| referredUser | ObjectId (ref: User) | uuid | N | Optional FK to users (referral only) |
| description | String | TEXT NOT NULL | Y | Human-readable description |
| metadata | Mixed (subdoc) | JSONB | N | Untyped: {orderAmount?, commission?, levelBefore?, levelAfter?, purchaseRequestId?} |
| expiresAt | Date | TIMESTAMP | N | Optional expiry date; sparse-indexed |
| createdAt | Date | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | Y | Auto-generated |
| updatedAt | Date | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | Y | Auto-generated |
Relationships
- user (N:1 to User): Required foreign key; primary accessor for transaction history.
- order (N:1 to Order): Optional; transaction may be unrelated to orders (e.g., referral bonuses).
- referredUser (N:1 to User): Optional; only populated for referral-source transactions.
Indexes
(user, createdAt DESC)— composite index for "get user's transactions, newest first" (main query pattern).(type, source)— filtering by transaction type and source.(expiresAt) SPARSE— sparse index on optional expiresAt for expiry queries; only indexed when non-null.
Gotchas & Migration Notes
- Untyped metadata: Mongoose does not enforce metadata shape; it's a flexible object with optional fields. PG should use JSONB and document the expected structure (orderAmount, commission, levelBefore, levelAfter, purchaseRequestId) in application code comments.
- Balance is denormalized: This is a snapshot taken at transaction time, not computed. Do not drop it or attempt to derive it from a running balance; it's an audit trail.
- Sparse expiresAt: Not a MongoDB TTL index (which auto-deletes), but a normal sparse index. Point expiry must be handled by application queries, not database-level deletion.
- Two FKs to User table: Both
userandreferredUserreference User; ensure indices and referential integrity are set up on both columns. - Enum types: Use CHECK constraints or native PG ENUM types for type and source; CHECK is more portable.
- Timestamps auto-generated: Mongoose
timestamps: truehandles createdAt and updatedAt; PG should use defaults and triggers or ORM features (e.g., Prisma, Sequelize) to mirror this.
Proposed CREATE TABLE
CREATE TABLE point_transactions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
type varchar(10) NOT NULL CHECK (type IN ('earn', 'spend', 'expire')),
source varchar(15) NOT NULL CHECK (source IN ('purchase', 'referral', 'bonus', 'admin', 'redemption')),
amount decimal(10,2) NOT NULL,
balance decimal(10,2) NOT NULL,
order_id uuid REFERENCES orders(id) ON DELETE SET NULL,
referred_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
description text NOT NULL,
metadata jsonb,
expires_at timestamp,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_point_transactions_user_created ON point_transactions(user_id, created_at DESC);
CREATE INDEX idx_point_transactions_type_source ON point_transactions(type, source);
CREATE INDEX idx_point_transactions_expires_at ON point_transactions(expires_at) WHERE expires_at IS NOT NULL;
PurchaseRequest
Collection: purchase_requests
Summary: Core entity for buyer-initiated purchase requests. Contains escrow/payment workflow, delivery logistics (physical/digital), service specs, offer tracking, and dispute management. Large embedded subdocuments (deliveryInfo, serviceInfo) with multiple nested levels. Timestamps auto-managed.
Field Table
| Path | Mongo Type | PG Type | Required | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid PK | yes | Auto-generated; migrate via gen_random_uuid() |
| buyerId | ObjectId ref User | uuid FK | yes | Foreign key to users(id). Indexed. |
| title | String | varchar(200) | yes | trim, maxlength=200 |
| description | String | text | yes | trim, maxlength=2000; use text type |
| categoryId | ObjectId ref Category | uuid FK | yes | Foreign key to categories(id). Indexed. |
| productType | enum | enum/varchar(20) | no | physical_product|digital_product|service|consultation. Default: physical_product. Use PG ENUM. |
| productLink | String | varchar(2000) | no | Optional URL (regex validated) |
| size | String | varchar(100) | no | Product size, trim, maxlength=100 |
| color | String | varchar(100) | no | Product color, trim, maxlength=100 |
| brand | String | varchar(100) | no | Brand name, trim, maxlength=100 |
| preferredSellerIds[] | [ObjectId] ref User | uuid[] or join table | no | N:M. Recommend join table purchase_request_preferred_sellers(purchase_request_id, seller_id) |
| quantity | Number | integer | no | Min: 1, Default: 1 |
| budget.min | Number | numeric(15,8) | no | Min budget. Min: 0. Use numeric for crypto precision. |
| budget.max | Number | numeric(15,8) | no | Max budget. Min: 0. |
| budget.currency | enum | varchar(10) | no | USD|EUR|IRR|USDT|USDC. Default: USDT. Use PG ENUM. |
| urgency | enum | enum/varchar(20) | yes | low|medium|high|urgent. Default: medium. Indexed. |
| status | enum | enum/varchar(30) | yes | 13 values (pending_payment, pending, active, received_offers, in_negotiation, payment, processing, delivery, delivered, confirming, completed, cancelled, seller_paid). Default: pending. Critical to escrow workflow. Indexed. |
| isPublic | Boolean | boolean | no | Default: true |
| tags[] | [String] | text[] or child table | no | Array of strings, trimmed. Use JSONB or child table depending on query patterns. |
| specifications[] | [Object] | jsonb or child table | no | Array of {key, value, label}. Recommend child table purchase_request_specifications(purchase_request_id, key, value, label) for querying. |
| deliveryInfo.deliveryType | enum | enum/varchar(20) | no | physical|online. Default: physical. Part of nested subdoc; extract to child table. |
| deliveryInfo.address | String | varchar(500) | no | Physical delivery address. Flatten or child table. |
| deliveryInfo.preferredDate | Date | timestamp | no | Preferred delivery date. |
| deliveryInfo.notes | String | text | no | Delivery notes. |
| deliveryInfo.deliveryAddress.name | String | varchar(200) | no | Recipient name. Nested; recommend flattening (delivery_recipient_name) or child table. |
| deliveryInfo.deliveryAddress.phoneNumber | String | varchar(20) | no | Recipient phone. |
| deliveryInfo.deliveryAddress.fullAddress | String | text | no | Full delivery address. |
| deliveryInfo.deliveryAddress.addressType | String | varchar(50) | no | home|office|etc |
| deliveryInfo.email | String | varchar(255) | no | Email for digital delivery (regex validated). Trim. |
| deliveryInfo.sellerDeliveryInfo.estimatedDeliveryDate | Date | timestamp | no | Seller's est. delivery date. Nested; extract or JSONB. |
| deliveryInfo.sellerDeliveryInfo.estimatedDeliveryTime | String | varchar(50) | no | Est. time (morning, afternoon, etc). |
| deliveryInfo.sellerDeliveryInfo.trackingNumber | String | varchar(100) | no | Shipping tracking number. |
| deliveryInfo.sellerDeliveryInfo.deliveryNotes | String | text | no | Seller's delivery notes. |
| deliveryInfo.sellerDeliveryInfo.shippingMethod | String | varchar(100) | no | Courier, mail, etc. |
| deliveryInfo.sellerDeliveryInfo.downloadLink | String | varchar(2000) | no | Download link (digital). |
| deliveryInfo.sellerDeliveryInfo.digitalFiles[] | [String] | text[] or jsonb | no | Array of file names/paths. |
| deliveryInfo.deliveryDateTime | Date | timestamp | no | Delivery confirmation datetime. |
| deliveryInfo.deliveryDate | Date | date | no | Delivery confirmation date. |
| deliveryInfo.shippedAt | Date | timestamp | no | When item was shipped. |
| deliveryInfo.deliveryCode | String | varchar(6) | no | 6-digit code, minlength=maxlength=6. Trim. |
| deliveryInfo.deliveryCodeGeneratedAt | Date | timestamp | no | Code generation timestamp. |
| deliveryInfo.deliveryCodeExpiresAt | Date | timestamp | no | Code expiry timestamp. |
| deliveryInfo.deliveryCodeUsed | Boolean | boolean | no | Whether code used. Default: false. |
| deliveryInfo.deliveryCodeUsedAt | Date | timestamp | no | Code usage timestamp. |
| deliveryInfo.deliveryCodeUsedBy | ObjectId ref User | uuid FK | no | User who used code. Flatten or child table. |
| deliveryInfo.deliveredAt | Date | timestamp | no | Final delivery confirmation timestamp. |
| deliveryInfo.deliveryAttempts[] | [Object] | child table | no | Array of {sellerId (ObjectId), attemptedAt (Date, default Date.now), success (Boolean), code (String, optional)}. Strongly recommend child table delivery_attempts(purchase_request_id, seller_id, attempted_at, success, code) for auditability and indexing. |
| serviceInfo.duration | Number | numeric(5,2) | no | Service duration in hours. Min: 0.5. Use numeric for fractional values. |
| serviceInfo.sessionType | enum | enum/varchar(20) | no | online|in_person|hybrid |
| serviceInfo.location | String | varchar(200) | no | Service location. Trim, maxlength=200. |
| serviceInfo.requirements[] | [String] | text[] or jsonb | no | Array of requirements, trimmed. Use JSONB or child table. |
| attachments[] | [String] | text[] or child table | no | Array of attachment/file URLs. Use JSONB or child table depending on query patterns. |
| offers[] | [ObjectId] ref SellerOffer | (remove) | no | DENORMALIZED COPY. Remove from PG table; query SellerOffer.purchase_request_id instead. Causes sync issues. |
| selectedOfferId | ObjectId ref SellerOffer | uuid FK | no | FK to selected SellerOffer. Nullable (default: null). |
| rating | Number | smallint | no | Star rating 1–5. Min: 1, Max: 5. Default: null. |
| feedback | String | text | no | Buyer feedback. Maxlength: 1000. Default: null. |
| deliveryConfirmed | Boolean | boolean | no | Delivery confirmed flag. Default: false. |
| deliveryConfirmedAt | Date | timestamp | no | Delivery confirmation timestamp. Default: null. |
| disputeRaised | Boolean | boolean | no | Dispute flag. Default: false. Escrow-critical. |
| disputeRaisedAt | Date | timestamp | no | Dispute raise timestamp. Default: null. |
| disputeResolved | Boolean | boolean | no | Dispute resolved flag. Default: false. |
| disputeResolvedAt | Date | timestamp | no | Dispute resolution timestamp. Default: null. |
| disputeHoldReason | String | text | no | Reason for escrow hold. Default: null. |
| holdUntil | Date | timestamp | no | Escrow hold expiry. Default: null. Index for expiry queries. |
| metadata.source | enum | enum/varchar(20) | no | manual|template|api. Default: manual. |
| metadata.templateId | String | varchar(100) | no | Template ID if from template. Trim. |
| metadata.version | String | varchar(50) | no | Schema version. Trim. |
| createdAt | Date | timestamp NOT NULL | yes | Auto-set by timestamps: true. Indexed DESC for recent-first queries. |
| updatedAt | Date | timestamp NOT NULL | yes | Auto-updated by timestamps: true. |
Indexes
CREATE INDEX idx_purchase_requests_buyer_id ON purchase_requests(buyer_id);
CREATE INDEX idx_purchase_requests_category_id ON purchase_requests(category_id);
CREATE INDEX idx_purchase_requests_product_type ON purchase_requests(product_type);
CREATE INDEX idx_purchase_requests_status ON purchase_requests(status);
CREATE INDEX idx_purchase_requests_created_at ON purchase_requests(created_at DESC);
CREATE INDEX idx_purchase_requests_urgency ON purchase_requests(urgency);
-- Compound indexes
CREATE INDEX idx_purchase_requests_product_type_status ON purchase_requests(product_type, status);
CREATE INDEX idx_purchase_requests_category_product_type ON purchase_requests(category_id, product_type);
-- Additional indexes for escrow/dispute queries:
CREATE INDEX idx_purchase_requests_hold_until ON purchase_requests(hold_until) WHERE hold_until IS NOT NULL;
CREATE INDEX idx_purchase_requests_dispute_raised ON purchase_requests(dispute_raised) WHERE dispute_raised = true;
Relationships
| Field | Target | Cardinality | Kind | PG Strategy |
|---|---|---|---|---|
| buyerId | User | N:1 | ref ObjectId | FK buyer_id → users(id) |
| categoryId | Category | N:1 | ref ObjectId | FK category_id → categories(id) |
| preferredSellerIds[] | User | N:M | embedded array of ObjectId | Join table purchase_request_preferred_sellers(purchase_request_id uuid, seller_id uuid, UNIQUE(purchase_request_id, seller_id)) |
| offers[] | SellerOffer | 1:N | denormalized array | Remove field. Query SellerOffer WHERE purchase_request_id = ?. Verify with SellerOffer schema. |
| selectedOfferId | SellerOffer | N:1 | ref ObjectId | FK selected_offer_id → seller_offers(id), nullable |
| deliveryInfo.deliveryCodeUsedBy | User | N:1 | ref ObjectId (nested) | FK delivery_code_used_by → users(id), nullable. Flatten from deliveryInfo. |
| deliveryInfo.deliveryAttempts[].sellerId | User | 1:N | embedded array of refs | Child table delivery_attempts(purchase_request_id uuid, seller_id uuid, ...) with FK to users(id) |
Migration Gotchas
-
Deep Nesting (deliveryInfo, serviceInfo)
deliveryInfohas 3 levels: root > sellerDeliveryInfo > digitalFiles.- Mongoose allows arbitrary nesting; PG requires flattening or JSONB.
- Recommendation: Create child tables:
purchase_request_delivery_info(purchase_request_id, delivery_type, address, preferred_date, ...)(1:1)delivery_attempts(purchase_request_id, seller_id, attempted_at, success, code)(1:N, audit log)seller_delivery_info(delivery_info_id, estimated_delivery_date, tracking_number, ...)(1:1 under delivery_info)
- Why: Maintains relational integrity, enables indexing, audit trail for delivery attempts.
- Risk: Queries that expected deliveryInfo embedded must now join. Cost: slightly higher query complexity.
-
Denormalized Offer Array
offers[]is a copy of SellerOffer IDs. Mongoose can't easily ref back from SellerOffer, so this is stored for convenience.- PG Solution: Remove the field. Instead, query SellerOffer where
purchase_request_id = ?. - Risk: Code that does
PurchaseRequest.offersmust change toSellerOffer.find({purchaseRequestId: ?}). - Verify: Check SellerOffer schema for
purchaseRequestIdref.
-
Polymorphic Refs in Nested Objects
deliveryInfo.deliveryCodeUsedByis a User ref inside a nested object.- If using child table for deliveryInfo, flatten to a column; if JSONB, store as string and validate manually.
-
Enum Safety
- 13 status values, 4 productType values, etc. PG ENUMs are strict.
- Action: Create PG ENUM types, validate existing data for malformed values, use CHECK constraints.
- Status enum is critical: Escrow workflow relies on exact status values. Audit all current statuses in MongoDB before migration.
-
Array Fields
preferredSellerIds,tags,specifications,attachments,serviceInfo.requirements,deliveryInfo.sellerDeliveryInfo.digitalFiles.- Options:
- Use PG
text[]/uuid[]if queries remain simple (e.g.,WHERE ? = ANY(tags)). - Use JSONB for flexibility but less queryability.
- Use child tables for full relational queries.
- Use PG
- Recommendation:
preferredSellerIds→ join table (N:M normalization);specifications→ child table (queryable); others → JSONB if lightweight.
-
Escrow Hold Logic
holdUntil,disputeRaised,disputeResolvedAtdrive escrow state machine.- Action: Index
holdUntilfor "find holds expiring soon" queries. Consider a derivedhold_statuscolumn (active, expired, resolved). - Risk: State transitions must be atomic; add application-layer transaction handling.
-
Validators
productLink(URL regex),deliveryInfo.email(email regex). Mongoose validates at save; PG does not.- PG: Add CHECK constraints or enforce at application layer.
Proposed PG DDL Sketch
-- Enums
CREATE TYPE product_type_enum AS ENUM ('physical_product', 'digital_product', 'service', 'consultation');
CREATE TYPE urgency_enum AS ENUM ('low', 'medium', 'high', 'urgent');
CREATE TYPE status_enum AS ENUM (
'pending_payment', 'pending', 'active', 'received_offers', 'in_negotiation',
'payment', 'processing', 'delivery', 'delivered', 'confirming',
'completed', 'cancelled', 'seller_paid'
);
CREATE TYPE delivery_type_enum AS ENUM ('physical', 'online');
CREATE TYPE session_type_enum AS ENUM ('online', 'in_person', 'hybrid');
CREATE TYPE metadata_source_enum AS ENUM ('manual', 'template', 'api');
CREATE TYPE currency_enum AS ENUM ('USDT', 'USDC');
-- Main table
CREATE TABLE purchase_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
buyer_id uuid NOT NULL REFERENCES users(id),
category_id uuid NOT NULL REFERENCES categories(id),
title varchar(200) NOT NULL,
description text NOT NULL,
product_type product_type_enum DEFAULT 'physical_product',
product_link varchar(2000) CHECK (product_link IS NULL OR product_link ~ '^https?:\/\/.+'),
size varchar(100),
color varchar(100),
brand varchar(100),
quantity integer DEFAULT 1 CHECK (quantity >= 1),
budget_min numeric(15, 8) CHECK (budget_min IS NULL OR budget_min >= 0),
budget_max numeric(15, 8) CHECK (budget_max IS NULL OR budget_max >= 0),
budget_currency currency_enum DEFAULT 'USDT',
urgency urgency_enum NOT NULL DEFAULT 'medium',
status status_enum NOT NULL DEFAULT 'pending',
is_public boolean DEFAULT true,
selected_offer_id uuid REFERENCES seller_offers(id),
rating smallint CHECK (rating IS NULL OR (rating >= 1 AND rating <= 5)),
feedback text CHECK (feedback IS NULL OR length(feedback) <= 1000),
delivery_confirmed boolean DEFAULT false,
delivery_confirmed_at timestamp,
dispute_raised boolean DEFAULT false,
dispute_raised_at timestamp,
dispute_resolved boolean DEFAULT false,
dispute_resolved_at timestamp,
dispute_hold_reason text,
hold_until timestamp,
metadata_source metadata_source_enum DEFAULT 'manual',
metadata_template_id varchar(100),
metadata_version varchar(50),
tags text[] DEFAULT '{}',
attachments text[] DEFAULT '{}',
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX idx_purchase_requests_buyer_id ON purchase_requests(buyer_id);
CREATE INDEX idx_purchase_requests_category_id ON purchase_requests(category_id);
CREATE INDEX idx_purchase_requests_product_type ON purchase_requests(product_type);
CREATE INDEX idx_purchase_requests_status ON purchase_requests(status);
CREATE INDEX idx_purchase_requests_created_at ON purchase_requests(created_at DESC);
CREATE INDEX idx_purchase_requests_urgency ON purchase_requests(urgency);
CREATE INDEX idx_purchase_requests_product_type_status ON purchase_requests(product_type, status);
CREATE INDEX idx_purchase_requests_category_product_type ON purchase_requests(category_id, product_type);
CREATE INDEX idx_purchase_requests_hold_until ON purchase_requests(hold_until) WHERE hold_until IS NOT NULL;
-- Child tables
CREATE TABLE purchase_request_preferred_sellers (
purchase_request_id uuid NOT NULL REFERENCES purchase_requests(id) ON DELETE CASCADE,
seller_id uuid NOT NULL REFERENCES users(id),
PRIMARY KEY (purchase_request_id, seller_id)
);
CREATE TABLE purchase_request_specifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
purchase_request_id uuid NOT NULL REFERENCES purchase_requests(id) ON DELETE CASCADE,
key varchar(255) NOT NULL,
value text NOT NULL,
label varchar(255)
);
CREATE TABLE purchase_request_delivery_info (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
purchase_request_id uuid NOT NULL UNIQUE REFERENCES purchase_requests(id) ON DELETE CASCADE,
delivery_type delivery_type_enum NOT NULL DEFAULT 'physical',
address varchar(500),
preferred_date timestamp,
notes text,
email varchar(255) CHECK (email IS NULL OR email ~ '^[^\s@]+@[^\s@]+\.[^\s@]+$'),
delivery_date_time timestamp,
delivery_date date,
shipped_at timestamp,
delivery_code varchar(6) CHECK (delivery_code IS NULL OR length(delivery_code) = 6),
delivery_code_generated_at timestamp,
delivery_code_expires_at timestamp,
delivery_code_used boolean DEFAULT false,
delivery_code_used_at timestamp,
delivery_code_used_by uuid REFERENCES users(id),
delivered_at timestamp
);
CREATE TABLE purchase_request_delivery_address (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
delivery_info_id uuid NOT NULL UNIQUE REFERENCES purchase_request_delivery_info(id) ON DELETE CASCADE,
recipient_name varchar(200),
phone_number varchar(20),
full_address text,
address_type varchar(50)
);
CREATE TABLE seller_delivery_info (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
delivery_info_id uuid NOT NULL UNIQUE REFERENCES purchase_request_delivery_info(id) ON DELETE CASCADE,
estimated_delivery_date timestamp,
estimated_delivery_time varchar(50),
tracking_number varchar(100),
delivery_notes text,
shipping_method varchar(100),
download_link varchar(2000),
digital_files text[] DEFAULT '{}'
);
CREATE TABLE delivery_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
delivery_info_id uuid NOT NULL REFERENCES purchase_request_delivery_info(id) ON DELETE CASCADE,
seller_id uuid NOT NULL REFERENCES users(id),
attempted_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
success boolean NOT NULL,
code varchar(100)
);
CREATE TABLE purchase_request_service_info (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
purchase_request_id uuid NOT NULL UNIQUE REFERENCES purchase_requests(id) ON DELETE CASCADE,
duration numeric(5, 2) CHECK (duration IS NULL OR duration >= 0.5),
session_type session_type_enum,
location varchar(200),
requirements text[] DEFAULT '{}'
);
-- Preferred sellers join table (already listed above, repeated for completeness)
-- CREATE TABLE purchase_request_preferred_sellers (...)
Notes for Implementation
- Timestamps: Use triggers or application layer to update
updated_aton row change. - Denormalization: Monitor
offersarray usage; if removed, update application queries. - Escrow State Machine: Ensure atomicity of status transitions; consider explicit transaction handling.
- Validator Replacement: Implement URL/email validation in application or via CHECK constraints.
- Migration Script: Extract nested objects row-by-row; validate referential integrity; test on dev schema first.
RequestTemplate
Template marketplace objects that sellers create to define reusable service request specifications. Each template includes product/service details, budget expectations, delivery requirements, and optional default proposals used in marketplace discovery.
Fields
| Path | Mongo Type | PG Type | Required | Notes |
|---|---|---|---|---|
| _id | ObjectId | UUID PRIMARY KEY | Y | Auto-generated, migrate via gen_random_uuid() |
| sellerId | ObjectId (ref User) | UUID NOT NULL FK | Y | References User, indexed with isActive |
| title | String | VARCHAR(200) NOT NULL | Y | maxlength: 200, trimmed |
| description | String | TEXT NOT NULL | Y | maxlength: 2000, trimmed |
| categoryId | ObjectId (ref Category) | UUID NOT NULL FK | Y | References Category, compound indexes exist |
| productType | String (enum) | VARCHAR(20) DEFAULT 'physical_product' | N | enum: [physical_product, digital_product, service, consultation] |
| productLink | String | VARCHAR(2048) | N | URL validation, optional |
| size | String | VARCHAR(100) | N | Optional product size |
| color | String | VARCHAR(100) | N | Optional product color |
| brand | String | VARCHAR(100) | N | Optional brand name |
| quantity | Number | INTEGER DEFAULT 1 | N | min: 1 |
| budget.min | Number | DECIMAL(19,4) | N | Min budget amount, min: 0 |
| budget.max | Number | DECIMAL(19,4) | N | Max budget amount, min: 0 |
| budget.currency | String (enum) | VARCHAR(10) DEFAULT 'USDT' | N | enum: [USD, EUR, IRR, USDT, USDC] |
| urgency | String (enum) | VARCHAR(20) DEFAULT 'medium' | N | enum: [low, medium, high, urgent] |
| tags[] | [String] | TEXT[] or JSONB | N | String array, optional |
| specifications[] | [Object] | JSONB or child table | N | Array of {key, value, label} objects |
| deliveryInfo.deliveryType | String (enum) | VARCHAR(20) DEFAULT 'physical' | N | enum: [physical, online] |
| deliveryInfo.notes | String | TEXT | N | Delivery notes, optional |
| deliveryInfo.email | String | VARCHAR(255) | N | Email for digital delivery, validated |
| serviceInfo.duration | Number | DECIMAL(5,2) | N | Duration in hours, min: 0.5 |
| serviceInfo.sessionType | String (enum) | VARCHAR(20) | N | enum: [online, in_person, hybrid] |
| serviceInfo.location | String | VARCHAR(200) | N | Service location, maxlength: 200 |
| serviceInfo.requirements[] | [String] | TEXT[] or JSONB | N | Requirement strings array |
| proposal.title | String | VARCHAR(200) | N | Default proposal title |
| proposal.price | Number | DECIMAL(19,4) | N | Default proposal price, min: 0.01 |
| proposal.deliveryTime | Number | INTEGER | N | Days to deliver, min: 1, max: 365 |
| proposal.description | String | VARCHAR(1000) | N | Proposal details |
| attachments[] | [String] | TEXT[] or JSONB | N | File attachment URLs |
| images[] | [String] | TEXT[] or JSONB | N | Image URLs array |
| metadata.source | String (enum) | VARCHAR(20) DEFAULT 'manual' | N | enum: [manual, template, api] |
| metadata.templateId | String | VARCHAR(255) | N | Source template ID |
| metadata.version | String | VARCHAR(50) | N | Template version |
| isActive | Boolean | BOOLEAN DEFAULT true | N | Active flag, indexed |
| shareableLink | String | VARCHAR(255) UNIQUE NOT NULL | Y | Shareable token, unique constraint |
| usageCount | Number | INTEGER DEFAULT 0 | N | Usage counter, min: 0 |
| maxUsage | Number | INTEGER | N | Optional max usage limit |
| expiresAt | Date | TIMESTAMP | N | Expiration date, optional |
| createdAt | Date | TIMESTAMP DEFAULT CURRENT_TIMESTAMP | Y | Indexed DESC |
| updatedAt | Date | TIMESTAMP DEFAULT CURRENT_TIMESTAMP | Y | Auto-updated |
Relationships
- sellerId (N:1 User): Foreign key to users table; sellers create templates
- categoryId (N:1 Category): Foreign key to categories table; templates belong to a category
Indexes
- Unique: shareableLink
- Single field: sellerId, categoryId, productType, isActive, createdAt (DESC), expiresAt
- Compound: (sellerId, isActive), (shareableLink, isActive), (productType, isActive), (categoryId, productType)
Gotchas & Migration Challenges
-
Embedded Subdocuments: budget, deliveryInfo, serviceInfo, proposal, and metadata are nested objects. Choose JSONB columns for simplicity or denormalize to separate tables for relational queries (not needed initially).
-
String Arrays: tags, attachments, images, specifications, and serviceInfo.requirements can use native PostgreSQL ARRAY type (faster queries) or JSONB (more flexible).
-
Validators: productLink (URL) and email validation currently in Mongoose. Move to application layer or PostgreSQL CHECK constraints.
-
Unique Shareable Link: Must explicitly create UNIQUE constraint; used for public template sharing.
-
Polymorphic References: Interface shows sellerId/categoryId can be string, but schema enforces ObjectId. Migration script must convert all IDs to UUIDs consistently.
-
Compound Index Sort Order: createdAt DESC index requires explicit DESC in PostgreSQL index definition.
-
TTL Expiration: expiresAt has no native PostgreSQL TTL. Implement application-level cleanup job or use pg_cron extension.
-
Usage Counter: usageCount requires atomic increment operations; use
UPDATE ... SET usageCount = usageCount + 1in application code. -
Aggregation Queries: RequestTemplateService uses MongoDB $lookup and $group heavily. Equivalent PostgreSQL queries via JOINs and GROUP BY; ensure query plans are optimized on migrated indexes.
Proposed PostgreSQL DDL
CREATE TABLE request_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seller_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL,
product_type VARCHAR(20) NOT NULL DEFAULT 'physical_product'
CHECK (product_type IN ('physical_product', 'digital_product', 'service', 'consultation')),
product_link VARCHAR(2048),
size VARCHAR(100),
color VARCHAR(100),
brand VARCHAR(100),
quantity INTEGER DEFAULT 1 CHECK (quantity >= 1),
budget JSONB, -- { "min": number, "max": number, "currency": "USD|EUR|IRR|USDT|USDC" }
urgency VARCHAR(20) DEFAULT 'medium'
CHECK (urgency IN ('low', 'medium', 'high', 'urgent')),
tags TEXT[],
specifications JSONB, -- [{ "key": string, "value": string, "label"?: string }]
delivery_info JSONB, -- { "deliveryType": string, "notes"?: string, "email"?: string }
service_info JSONB, -- { "duration"?: number, "sessionType"?: string, "location"?: string, "requirements"?: string[] }
proposal JSONB, -- { "title": string, "price": number, "deliveryTime": number, "description": string }
attachments TEXT[],
images TEXT[],
metadata JSONB, -- { "source": string, "templateId"?: string, "version"?: string }
is_active BOOLEAN NOT NULL DEFAULT true,
shareable_link VARCHAR(255) NOT NULL UNIQUE,
usage_count INTEGER NOT NULL DEFAULT 0 CHECK (usage_count >= 0),
max_usage INTEGER CHECK (max_usage IS NULL OR max_usage >= 1),
expires_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_request_templates_seller_id ON request_templates(seller_id);
CREATE INDEX idx_request_templates_category_id ON request_templates(category_id);
CREATE INDEX idx_request_templates_product_type ON request_templates(product_type);
CREATE INDEX idx_request_templates_is_active ON request_templates(is_active);
CREATE INDEX idx_request_templates_created_at_desc ON request_templates(created_at DESC);
CREATE INDEX idx_request_templates_expires_at ON request_templates(expires_at);
CREATE INDEX idx_request_templates_seller_is_active ON request_templates(seller_id, is_active);
CREATE INDEX idx_request_templates_shareable_link_active ON request_templates(shareable_link, is_active);
CREATE INDEX idx_request_templates_product_type_active ON request_templates(product_type, is_active);
CREATE INDEX idx_request_templates_category_product_type ON request_templates(category_id, product_type);
Review
Summary: Review documents store user ratings and feedback for sellers or templates. Each review is uniquely scoped to a (subjectType, subjectId, reviewerId) tuple and optionally links to a PurchaseRequest. Heavy indexing on subject and reviewer for aggregation queries.
Fields
| Path | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
_id |
ObjectId | uuid PRIMARY KEY DEFAULT gen_random_uuid() | Y | Auto-generated |
subjectType |
String | text NOT NULL CHECK(...IN ('seller', 'template')) | Y | Enum discriminator for polymorphic subjectId |
subjectId |
ObjectId | uuid NOT NULL | Y | Polymorphic: User (seller) OR RequestTemplate per subjectType |
sellerId |
ObjectId ref(User) | uuid NOT NULL REFERENCES users(id) | Y | Seller recipient of review |
reviewerId |
ObjectId ref(User) | uuid NOT NULL REFERENCES users(id) | Y | User who submitted review |
rating |
Number | smallint NOT NULL CHECK(rating>=1 AND rating<=5) | Y | 1–5 stars |
comment |
String | text (max 1000) | N | Optional review text; defaults to '' |
isVerifiedBuyer |
Boolean | boolean NOT NULL DEFAULT false | Y | Computed at write time from PurchaseRequest status |
purchaseRequestId |
ObjectId ref(PurchaseRequest) | uuid REFERENCES purchase_requests(id) | N | Optional link to source purchase |
status |
String | text NOT NULL DEFAULT 'published' CHECK(...IN ('published', 'pending', 'rejected')) | Y | Enum: publication state |
createdAt |
Date | timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP | Y | Auto-set |
updatedAt |
Date | timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP | Y | Auto-managed |
Indexes
{ subjectType: 1, subjectId: 1, createdAt: -1 }— Composite index for listing reviews by subject, sorted by recency{ reviewerId: 1, subjectType: 1 }— Composite index for finding user reviews by type{ subjectType: 1, subjectId: 1, reviewerId: 1 } UNIQUE— Unique constraint enforces one review per user per subject; critical for duplicate prevention
Relationships
| Field | Target | Cardinality | Kind | PG Strategy |
|---|---|---|---|---|
sellerId |
User | N:1 | ObjectId ref | FK reviews.seller_id → users.id |
reviewerId |
User | N:1 | ObjectId ref | FK reviews.reviewer_id → users.id |
subjectId |
User OR RequestTemplate | N:1 polymorphic | Discriminated by subjectType |
uuid column; document via CHECK or trigger that IF subject_type='seller' THEN validate FK to users; IF 'template' THEN validate FK to request_templates |
purchaseRequestId |
PurchaseRequest | N:1 optional | ObjectId ref | FK reviews.purchase_request_id → purchase_requests.id (nullable) |
Gotchas & Migration Landmines
-
Polymorphic subjectId — Unlike traditional FKs, subjectId references either a User or RequestTemplate depending on the value of
subjectType. PostgreSQL does not natively support this pattern. Recommended approaches:- Keep as uuid column and enforce in application code or via trigger
- Or: create two separate nullable FK columns (subject_user_id, subject_template_id) with a CHECK that exactly one is non-null (adds complexity)
- Document the rule clearly in code comments and ERD
-
Unique Compound Constraint — The unique index on (subjectType, subjectId, reviewerId) prevents duplicate reviews. During migration, verify no duplicates exist in source data. If duplicates are found, keep the most recent and discard others, or use INSERT ON CONFLICT strategy.
-
Enum Fields —
subjectType('seller' | 'template'),status('published' | 'pending' | 'rejected'). Enforce via CHECK constraints; consider PostgreSQL ENUM type if more stability is needed, but text + CHECK is simpler for this schema. -
Lazy Computed isVerifiedBuyer — This boolean is computed at review creation time based on PurchaseRequest status (lines 169–196 in reviewRoutes.ts). Preserve the computed value during migration; do not attempt to re-compute unless you exactly replicate the logic (checks for statuses 'delivery', 'delivered', 'seller_paid', 'completed', plus fallback checks on buyerId and preferredSellerIds).
-
Aggregation Queries — The computeStats() function uses MongoDB's aggregation framework to compute avg rating, count, and distribution by rating (lines 33–62). These queries are index-friendly in Mongo but must be rewritten for PostgreSQL with GROUP BY, AVG(), COUNT(), and conditional SUMs.
-
Nullable purchaseRequestId — Some reviews may have no link to a specific purchase. Ensure NULL handling is consistent; consider adding a CHECK that the field is either NULL or a valid FK to purchase_requests.
Proposed PostgreSQL DDL Sketch
CREATE TABLE reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
subject_type text NOT NULL CHECK (subject_type IN ('seller', 'template')),
subject_id uuid NOT NULL, -- polymorphic: refs User OR RequestTemplate per subject_type
seller_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reviewer_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating smallint NOT NULL CHECK (rating >= 1 AND rating <= 5),
comment text,
is_verified_buyer boolean NOT NULL DEFAULT false,
purchase_request_id uuid REFERENCES purchase_requests(id) ON DELETE SET NULL,
status text NOT NULL DEFAULT 'published' CHECK (status IN ('published', 'pending', 'rejected')),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Unique constraint: one review per user per subject
UNIQUE (subject_type, subject_id, reviewer_id)
);
-- Indexes for query patterns from reviewRoutes.ts
CREATE INDEX idx_reviews_subject_date ON reviews(subject_type, subject_id, created_at DESC);
CREATE INDEX idx_reviews_reviewer_type ON reviews(reviewer_id, subject_type);
-- Optional: Polymorphic foreign key enforcement via trigger
-- (Advanced; consider if application-layer validation is insufficient)
Notes:
- The polymorphic
subject_idfield is intentionally NOT constrained by a single FK. Instead, enforce referential integrity in the application or via a trigger that checks (subject_type = 'seller' AND EXISTS in users) OR (subject_type = 'template' AND EXISTS in request_templates). - Drop the comment field length constraint in PostgreSQL (text handles it implicitly); if max-length is critical, use VARCHAR(1000) and a CHECK.
- Preserve row-level security if used in the original system; add RLS policies if reviews should be visible/editable only by specific users.
SellerOffer
A seller's response to a PurchaseRequest, offering specific terms (price, delivery time) and tracking negotiation status.
Field Mapping
| Path | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid PRIMARY KEY | Y | Auto-generated. Use gen_random_uuid() default. |
| sellerId | ObjectId (ref: User) | uuid FK → users(id) | Y | Indexed. References the seller. Interface shows union (ObjectId | string) but schema enforces ObjectId only. |
| purchaseRequestId | ObjectId (ref: PurchaseRequest) | uuid FK → purchase_requests(id) | Y | Indexed. References the purchase request this offer is for. |
| title | String (max 200) | VARCHAR(200) NOT NULL | Y | Trimmed. Core offer title. |
| description | String (max 1000) | VARCHAR(1000) NOT NULL | Y | Trimmed. Detailed offer description. |
| price.amount | Number (embedded) | NUMERIC(18,8) NOT NULL | Y | Embedded in price subdoc. Min: 0. Use 8 decimals for crypto (USDT/USDC). |
| price.currency | String (embedded) | VARCHAR(10) NOT NULL | Y | Embedded in price subdoc. Enum: USD, EUR, IRR, USDT, USDC. Default: USDT. Use CHECK or ENUM type. |
| deliveryTime.amount | Number (embedded) | INTEGER NOT NULL | Y | Embedded in deliveryTime subdoc. Min: 1. Integer count. |
| deliveryTime.unit | String (embedded) | VARCHAR(10) NOT NULL | Y | Embedded in deliveryTime subdoc. Enum: hours, days, weeks. Use CHECK or ENUM type. |
| status | String | VARCHAR(20) NOT NULL | N | Enum: pending, accepted, rejected, withdrawn, active. Default: pending. Indexed. Use ENUM type. |
| attachments | [String] | TEXT[] or child table | N | Optional array of URLs/strings. Recommend TEXT[] for simplicity; child table if queryable. |
| notes | String | TEXT | N | Optional free-form notes. Trimmed. |
| validUntil | Date | TIMESTAMP WITH TIME ZONE | N | Optional offer expiry. No TTL index in Mongo; check app-side cleanup. |
| requireAmlCheck | Boolean | BOOLEAN NOT NULL | N | Opt-in AML screening. Default: false. |
| amlBlockOnFailure | Boolean | BOOLEAN NOT NULL | N | Block payment if AML provider fails. Default: false. Paired with requireAmlCheck. |
| createdAt | Date | TIMESTAMP WITH TIME ZONE NOT NULL | Y | Auto-set by timestamps: true. Indexed descending for pagination. |
| updatedAt | Date | TIMESTAMP WITH TIME ZONE NOT NULL | Y | Auto-set by timestamps: true. Updated on status/data changes. |
Relationships
- sellerId (N:1 to User): FK column, indexed for listing offers by seller.
- purchaseRequestId (N:1 to PurchaseRequest): FK column, indexed for finding offers on a request.
- price (embedded-1): Subdoc with amount + currency. Inline as price_amount, price_currency OR single JSONB column. Inlining recommended for frequent queries.
- deliveryTime (embedded-1): Subdoc with amount + unit. Inline as delivery_time_amount, delivery_time_unit OR single JSONB column. Inlining recommended.
- attachments (embedded-N array): Array of URL strings. Use TEXT[] for simplicity or child table seller_offer_attachments(offer_id, url, position) if queryable.
Indexes
CREATE INDEX idx_seller_offers_seller_id ON seller_offers(seller_id);
CREATE INDEX idx_seller_offers_purchase_request_id ON seller_offers(purchase_request_id);
CREATE INDEX idx_seller_offers_status ON seller_offers(status);
CREATE INDEX idx_seller_offers_created_at_desc ON seller_offers(created_at DESC);
Gotchas
-
Interface Polymorphism is a Red Herring: ISellerOffer declares sellerId and purchaseRequestId as
ObjectId | string, suggesting polymorphism. The schema enforces ObjectId only. No dual-path logic exists at runtime. Port as simple FK columns. -
Embedded Subdocuments: price and deliveryTime are always embedded, never referenced. Decide: inline columns (recommended for simplicity and query perf) vs. JSONB (flexibility but slower filtering).
-
Attachments Strategy: If attachments are simple URLs/paths (no metadata), TEXT[] is sufficient. If you need per-attachment timestamps, visibility, or deletion, use a child table.
-
validUntil Orphan: Field exists but has no TTL index or app-level cleanup documented. Decide: add PG constraint or document app-side expiry check.
-
AML Flags Dependency: requireAmlCheck and amlBlockOnFailure are paired. Ensure business logic in application layer enforces: if amlBlockOnFailure is true, requireAmlCheck should also be true.
-
Timestamps: Mongoose's timestamps: true auto-manages createdAt/updatedAt. In PG, use ON INSERT/UPDATE triggers or application code to maintain parity.
-
No Custom Hooks in Schema: Check SellerOfferService and paymentController for post-update logic (e.g., cascade to PurchaseRequest when status changes) that may need PG trigger equivalents.
Proposed CREATE TABLE
CREATE TYPE offer_status AS ENUM ('pending', 'accepted', 'rejected', 'withdrawn', 'active');
CREATE TYPE currency_enum AS ENUM ('USD', 'EUR', 'IRR', 'USDT', 'USDC');
CREATE TYPE delivery_unit AS ENUM ('hours', 'days', 'weeks');
CREATE TABLE seller_offers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seller_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
purchase_request_id UUID NOT NULL REFERENCES purchase_requests(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
description VARCHAR(1000) NOT NULL,
-- Embedded price subdoc → inlined
price_amount NUMERIC(18,8) NOT NULL CHECK (price_amount >= 0),
price_currency currency_enum NOT NULL DEFAULT 'USDT',
-- Embedded deliveryTime subdoc → inlined
delivery_time_amount INTEGER NOT NULL CHECK (delivery_time_amount >= 1),
delivery_time_unit delivery_unit NOT NULL,
status offer_status NOT NULL DEFAULT 'pending',
attachments TEXT[],
notes TEXT,
valid_until TIMESTAMP WITH TIME ZONE,
require_aml_check BOOLEAN NOT NULL DEFAULT FALSE,
aml_block_on_failure BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_seller_offers_seller_id ON seller_offers(seller_id);
CREATE INDEX idx_seller_offers_purchase_request_id ON seller_offers(purchase_request_id);
CREATE INDEX idx_seller_offers_status ON seller_offers(status);
CREATE INDEX idx_seller_offers_created_at_desc ON seller_offers(created_at DESC);
-- Optional: compound index for common query (purchaseRequestId, sellerId)
CREATE INDEX idx_seller_offers_req_seller ON seller_offers(purchase_request_id, seller_id);
-- Optional: trigger to auto-update updated_at
CREATE TRIGGER update_seller_offers_timestamp
BEFORE UPDATE ON seller_offers
FOR EACH ROW
EXECUTE FUNCTION update_timestamp_column();
ShopSettings
Collation: shopSettings
Summary: Stores shop-level configuration for sellers. One-to-one with User (each seller has exactly one ShopSettings). Contains branding (name, avatar, coverImage), visibility flags (isPublic), review permissions (allowSellerReviews, allowTemplateReviews), and social links.
Field Table
| Path | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
| _id | ObjectId | UUID PRIMARY KEY | ✗ | Auto-generated |
| sellerId | ObjectId | UUID NOT NULL UNIQUE FK (users.id) | ✓ | 1:1 with User; unique enforces one-to-one |
| name | String | VARCHAR(255) NOT NULL | ✓ | Trimmed; shop name |
| description | String | TEXT | ✗ | Default ''; trimmed |
| avatar | String | VARCHAR(1024) | ✗ | Default ''; avatar image URL/path |
| coverImage | String | VARCHAR(1024) | ✗ | Default ''; cover image URL/path |
| isPublic | Boolean | BOOLEAN DEFAULT true | ✗ | Shop visibility flag |
| allowSellerReviews | Boolean | BOOLEAN DEFAULT true | ✗ | Enable seller reviews |
| allowTemplateReviews | Boolean | BOOLEAN DEFAULT true | ✗ | Enable template reviews |
| socialLinks (subdoc) | embedded | JSONB or denormalized columns | ✗ | See below |
| socialLinks.facebook | String | VARCHAR(1024) or JSONB | ✗ | Default ''; optional |
| socialLinks.instagram | String | VARCHAR(1024) or JSONB | ✗ | Default ''; optional |
| socialLinks.linkedin | String | VARCHAR(1024) or JSONB | ✗ | Default ''; optional |
| socialLinks.twitter | String | VARCHAR(1024) or JSONB | ✗ | Default ''; optional |
| createdAt | Date | TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ✗ | Auto-set on insert |
| updatedAt | Date | TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ✗ | Auto-set on insert/update |
Relationships
- sellerId → User (1:1, NOT 1:N)
- Reference type: ObjectId FK
- Unique constraint ensures one shop per seller
- PG Strategy: FK column + UNIQUE constraint
Indexes
UNIQUE INDEX (sellerId)— created byunique: truein schema; enforces 1:1 cardinality
Mongoose Features
- timestamps: true — auto-manages createdAt/updatedAt
- trim: true on
nameanddescription— whitespace stripped on save - default values — empty strings for optional text fields, true for booleans
- unique constraint — sellerId is unique, no custom indexes needed
Migration Landmines & Recommendations
-
socialLinks subdocument strategy: Choose between:
- Denormalized columns (recommended):
social_links_facebook,social_links_instagram,social_links_linkedin,social_links_twitter— simpler indexes, easier querying - JSONB column: single
social_linksJSONB column — more flexible but slower queries - Pick denormalized if filtering by social links is expected; JSONB if they're mostly opaque data
- Denormalized columns (recommended):
-
Unique constraint on sellerId must be enforced before backfill: Ensure the UNIQUE constraint is applied before migrating data, then verify no duplicate sellers exist in source
-
Default values in PG: All boolean defaults should map to DEFAULT clauses in the CREATE TABLE; string defaults are optional (app can provide them)
-
Timestamp auto-update: If using an ORM with ON UPDATE CURRENT_TIMESTAMP, ensure it's explicitly set; many ORMs require explicit configuration
-
URL validation (optional): Consider CHECK constraints on avatar, coverImage, and social link fields if strict URL format validation is required
Proposed CREATE TABLE (denormalized socialLinks)
CREATE TABLE shop_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seller_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
avatar VARCHAR(1024),
cover_image VARCHAR(1024),
is_public BOOLEAN DEFAULT true,
allow_seller_reviews BOOLEAN DEFAULT true,
allow_template_reviews BOOLEAN DEFAULT true,
social_links_facebook VARCHAR(1024),
social_links_instagram VARCHAR(1024),
social_links_linkedin VARCHAR(1024),
social_links_twitter VARCHAR(1024),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_seller_id NOT NULL
);
CREATE UNIQUE INDEX idx_shop_settings_seller_id ON shop_settings(seller_id);
Alternative: JSONB socialLinks
CREATE TABLE shop_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seller_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
avatar VARCHAR(1024),
cover_image VARCHAR(1024),
is_public BOOLEAN DEFAULT true,
allow_seller_reviews BOOLEAN DEFAULT true,
allow_template_reviews BOOLEAN DEFAULT true,
social_links JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_shop_settings_seller_id ON shop_settings(seller_id);
TelegramLink
Purpose: Links User accounts to Telegram accounts, storing Telegram identity metadata (user ID, username, name, premium/bot status) and link management state (status, blocking reason, last activity).
Key Characteristics:
- One Telegram link per User (unique userId)
- One User per Telegram account (unique telegramUserId)
- Soft-delete pattern:
isActiveboolean +statusenum (active/blocked) - Source tracking: how user linked (miniapp, bot, or login_widget)
- All string fields trimmed; enums are STRING with CHECK constraints
- Timestamps auto-managed by Mongoose; compound index on (userId, status)
| Field | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid PRIMARY KEY DEFAULT gen_random_uuid() | Y | Auto-generated |
| userId | ObjectId (ref User) | uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE | Y | FK to users; UNIQUE; indexed |
| telegramUserId | String | VARCHAR(255) NOT NULL UNIQUE | Y | Telegram user ID; trimmed |
| telegramUsername | String | VARCHAR(255) | N | Optional; trimmed |
| telegramFirstName | String | VARCHAR(255) | N | Optional; trimmed |
| telegramLastName | String | VARCHAR(255) | N | Optional; trimmed |
| telegramLanguageCode | String | VARCHAR(10) | N | Optional ISO code (e.g. en, ru); trimmed |
| telegramIsPremium | Boolean | BOOLEAN NOT NULL DEFAULT false | N | Telegram premium flag |
| telegramIsBot | Boolean | BOOLEAN NOT NULL DEFAULT false | N | Telegram bot flag |
| source | String (enum) | VARCHAR(20) NOT NULL DEFAULT 'miniapp' | N | Enum: miniapp | bot | login_widget |
| status | String (enum) | VARCHAR(20) NOT NULL DEFAULT 'active' | N | Enum: active | blocked |
| isActive | Boolean | BOOLEAN NOT NULL DEFAULT true | N | Soft-delete flag |
| lastSeenAt | Date | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | N | Last activity; defaults to now |
| blockedAt | Date | TIMESTAMP | N | Optional; when link was blocked |
| blockedReason | String | TEXT | N | Optional; reason for block |
| createdAt | Date (timestamp) | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | Y | Auto-set by Mongoose |
| updatedAt | Date (timestamp) | TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP | Y | Auto-set by Mongoose; use trigger |
Relationships:
- userId → User (1:1, foreign key, CASCADE delete)
Indexes:
- UNIQUE on userId
- UNIQUE on telegramUserId
- Compound (userId, status) — critical for auth queries
- Index on isActive (soft-delete filtering)
Gotchas & Migration Strategy:
- Unique FK + Referential Integrity: userId is both unique and required. Add NOT NULL + REFERENCES constraint with ON DELETE CASCADE.
- Soft-Delete Pattern: Both
isActiveandstatusexist. Queries use both (e.g.,getTelegramLinkForUserfilters on userId+isActive+status='active'). Preserve both columns; document as soft-delete when both=false or status='blocked'. - String Trimming: Mongoose auto-trims. PG doesn't; add a migration script to TRIM all string columns on import, or document that application code must trim on write.
- Compound Index: (userId, status) is used in auth flow (
findOne({ userId, status: 'active' })). Replicate exactly. - Enum Types: Use VARCHAR with CHECK constraints (
CHECK (status IN ('active', 'blocked'))) for portability, or PostgreSQL ENUM types for stricter control. - Timestamps: Mongoose auto-sets createdAt/updatedAt. Use PG CURRENT_TIMESTAMP defaults; implement a trigger for ON UPDATE updatedAt.
- Default Date.now: lastSeenAt defaults to current timestamp; map to CURRENT_TIMESTAMP.
- No custom logic: No pre/post hooks, virtuals, or methods; straightforward schema migration.
Proposed PostgreSQL DDL:
CREATE TABLE telegram_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
telegram_user_id VARCHAR(255) NOT NULL UNIQUE,
telegram_username VARCHAR(255),
telegram_first_name VARCHAR(255),
telegram_last_name VARCHAR(255),
telegram_language_code VARCHAR(10),
telegram_is_premium BOOLEAN NOT NULL DEFAULT false,
telegram_is_bot BOOLEAN NOT NULL DEFAULT false,
source VARCHAR(20) NOT NULL DEFAULT 'miniapp' CHECK (source IN ('miniapp', 'bot', 'login_widget')),
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'blocked')),
is_active BOOLEAN NOT NULL DEFAULT true,
last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
blocked_at TIMESTAMP,
blocked_reason TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_telegram_user_id (telegram_user_id),
INDEX idx_user_status (user_id, status),
INDEX idx_is_active (is_active)
);
CREATE TRIGGER telegram_links_update_timestamp
BEFORE UPDATE ON telegram_links
FOR EACH ROW
SET new.updated_at = CURRENT_TIMESTAMP;
TelegramSession
Tracks Telegram authentication sessions for miniapp and bot integration, storing session tokens, Telegram user IDs, optional User references, source channel, auth fingerprint, and session lifecycle data with automatic expiration.
Field Reference
| Path | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid PRIMARY KEY | ✓ | Auto-generated |
| sessionToken | String | VARCHAR(255) UNIQUE NOT NULL | ✓ | Unique session ID; trimmed; indexed |
| telegramUserId | String | VARCHAR(100) NOT NULL | ✓ | Telegram user ID (numeric, stored as string); indexed |
| userId | ObjectId (ref User) | uuid FK REFERENCES users(id) ON DELETE SET NULL | Optional link to User account | |
| source | String (enum) | VARCHAR(10) NOT NULL CHECK (source IN ('miniapp', 'bot')) | ✓ | Enum: miniapp or bot |
| initDataFingerprint | String | TEXT NOT NULL | ✓ | Telegram auth fingerprint for verification |
| telegramAuthDate | Number | BIGINT NOT NULL | ✓ | Unix timestamp of auth event |
| expiresAt | Date | TIMESTAMP NOT NULL | ✓ | Expiration time; TTL index auto-deletes in MongoDB |
| lastSeenAt | Date | TIMESTAMP DEFAULT CURRENT_TIMESTAMP | Activity timestamp; defaults to now | |
| isActive | Boolean | BOOLEAN DEFAULT true | Session active flag; part of lookup index | |
| createdAt | Date | TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ✓ | Auto-populated by timestamps |
| updatedAt | Date | TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | ✓ | Auto-populated by timestamps |
Indexes
- UNIQUE(sessionToken) — Fast lookup by token; supports authentication
- INDEX(telegramUserId) — Lookups by Telegram ID
- UNIQUE(telegramUserId, sessionToken) — Prevents duplicate sessions for same user/token pair
- COMPOUND(telegramUserId, isActive, expiresAt) — Optimizes queries filtering by active status and expiration
- TTL(expiresAt) [expireAfterSeconds: 0] — MongoDB auto-deletes expired records; PostgreSQL requires scheduled cleanup job
Relationships
- userId → User (N:1, ObjectId reference)
- Optional; multiple TelegramSessions can reference one User
- PG Strategy: Foreign key with ON DELETE SET NULL
- Session can exist without linked account
Mongoose Features to Migrate
- Timestamps: Mongoose auto-manages createdAt/updatedAt on save
- PostgreSQL: Add DEFAULT CURRENT_TIMESTAMP and triggers if needed
- TTL Index: expireAfterSeconds: 0 auto-deletes expired docs
- PostgreSQL: No native TTL; implement scheduled job (cron)
-- Example: cleanup job DELETE FROM telegram_sessions WHERE expires_at < NOW(); - Enum Validation: source field validated by Mongoose schema
- PostgreSQL: Use CHECK constraint or application validation
Gotchas
- TTL Replacement: MongoDB's TTL daemon is automatic; PostgreSQL needs explicit cleanup logic—missing this leaves orphaned sessions consuming disk
- Optional userId: Some sessions may not be linked to a User yet; ensure ON DELETE SET NULL is configured
- Compound Index Ordering: The (telegramUserId, isActive, expiresAt) index is critical for session lookup performance; order matters
- lastSeenAt Default: JavaScript Date.now() is application-level; ensure application provides this on INSERT if schema default is missing
Proposed PostgreSQL DDL
CREATE TABLE telegram_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
session_token varchar(255) NOT NULL UNIQUE,
telegram_user_id varchar(100) NOT NULL,
user_id uuid REFERENCES users(id) ON DELETE SET NULL,
source varchar(10) NOT NULL CHECK (source IN ('miniapp', 'bot')),
init_data_fingerprint text NOT NULL,
telegram_auth_date bigint NOT NULL,
expires_at timestamp NOT NULL,
last_seen_at timestamp DEFAULT CURRENT_TIMESTAMP,
is_active boolean DEFAULT true,
created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP,
UNIQUE (telegram_user_id, session_token)
);
CREATE INDEX idx_telegram_sessions_telegram_user_id
ON telegram_sessions(telegram_user_id);
CREATE INDEX idx_telegram_sessions_lookup
ON telegram_sessions(telegram_user_id, is_active, expires_at);
-- Cleanup job: run periodically via pg_cron or external scheduler
-- DELETE FROM telegram_sessions WHERE expires_at < NOW();
TempVerification
Summary: Temporary registration holder for unverified sign-ups. Stores email, password, user details (name, role, referral code), and a one-time email verification code with expiry. Auto-deleted via TTL index once verification expires.
Usage: Created during /register request; used to store registration attempt until email is verified. On successful verification, data is migrated to User collection and TempVerification is deleted. Cleaned up automatically after ~24h expiry.
Field Table
| Path | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
_id |
ObjectId | UUID PK or BIGSERIAL | ✓ | Auto-generated primary key |
email |
String | VARCHAR(255) | ✓ | Unique, lowercased, trimmed; email normalization at schema |
password |
String | TEXT | Default empty string; likely hashed or placeholder | |
firstName |
String | VARCHAR(255) | ✓ | Trimmed; candidate user first name |
lastName |
String | VARCHAR(255) | ✓ | Trimmed; candidate user last name |
role |
String (enum: buyer | seller) | ENUM or VARCHAR(10) | Default 'buyer'; enum validation in schema | |
referralCode |
String | VARCHAR(255) | Optional referral code; trimmed | |
emailVerificationCode |
String | VARCHAR(255) | ✓ | One-time token sent to email; verified before account creation |
emailVerificationCodeExpires |
Date | TIMESTAMP WITH TIME ZONE | ✓ | TTL anchor; doc auto-deleted when date passes |
createdAt |
Date | TIMESTAMP WITH TIME ZONE | ✓ | Auto-set by timestamps middleware; audit trail |
updatedAt |
Date | TIMESTAMP WITH TIME ZONE | ✓ | Auto-updated on changes; tracks verification code updates |
Relationships
- No explicit foreign keys in schema. Email serves as logical reference to User table (matching on email during account completion). Application enforces: TempVerification → User creation → TempVerification deletion.
- PG strategy: No FK constraint; consider app-level enforcement or post-migration constraint if stricter integrity desired.
Indexes
| Index | Type | Notes |
|---|---|---|
email |
UNIQUE | Must not duplicate; case-insensitive recommended |
emailVerificationCodeExpires |
TTL (expireAfterSeconds: 0) | Critical: Auto-deletes expired tokens; PG needs pg_cron job or trigger |
Key Gotchas
-
TTL / Auto-Cleanup: MongoDB's TTL index deletes docs automatically at
emailVerificationCodeExpires. PostgreSQL has no native TTL—must use:- Preferred:
pg_cronextension with scheduled job:DELETE FROM temp_verifications WHERE email_verification_code_expires <= now();(runs every hour or as needed) - Or: application-side cleanup during token validation
- Do not skip this; table will accumulate expired records otherwise.
- Preferred:
-
Email Uniqueness: Mongoose enforces
unique: trueandlowercase: trueat the schema level. PostgreSQL must useUNIQUE(LOWER(email))or case-insensitive collation to match. -
Password Field: Default empty string, but schema allows NULL. Clarify with team whether this field is used or legacy; may want to remove or always default to empty string.
-
Role Enum: Hardcoded values (buyer | seller) in Mongoose. Use PostgreSQL
ENUM('buyer', 'seller')orVARCHAR(10) CHECK (role IN ('buyer', 'seller'))to enforce at DB level. -
Short Lifespan: TempVerifications are intentionally temporary (usually verified within hours). No partitioning needed; TTL cleanup keeps table lean. Data volume should remain manageable.
Proposed CREATE TABLE
CREATE TYPE user_role AS ENUM ('buyer', 'seller');
CREATE TABLE temp_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password TEXT NOT NULL DEFAULT '',
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'buyer',
referral_code VARCHAR(255),
email_verification_code VARCHAR(255) NOT NULL,
email_verification_code_expires TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT email_lowercase_check CHECK (email = LOWER(email))
);
CREATE UNIQUE INDEX idx_temp_verifications_email ON temp_verifications (LOWER(email));
CREATE INDEX idx_temp_verifications_expires ON temp_verifications (email_verification_code_expires);
-- TTL Cleanup (via pg_cron extension; run every hour)
-- SELECT cron.schedule('cleanup_expired_temp_verifications', '0 * * * *',
-- 'DELETE FROM temp_verifications WHERE email_verification_code_expires <= now();');
Migration Checklist
- Install
pg_cronextension if using scheduled cleanup - Create
user_roleENUM type - Create
temp_verificationstable with all constraints - Migrate live non-expired records from MongoDB
- Schedule TTL cleanup job (hourly or custom interval)
- Test email verification flow end-to-end
- Verify no orphaned records post-cutover
TrezorAccount
Purpose: Stores hardware Trezor wallet account metadata per user, including the extended public key (xpub), derivation path, and an array of derived addresses used in payment workflows (deposit, release, refund).
Cardinality: 1:1 with User (userId is unique).
| Field | Mongo Type | PG Type | Required | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid | ✓ | Primary key, auto-generated. |
| userId | ObjectId (User) | uuid FK | ✓ | 1:1 unique reference. Indexes: UNIQUE, compound (userId, active). |
| xpub | String | text | ✓ | Extended public key (BIP32 standard). |
| xpubFingerprint | String | text | ✓ | Fingerprint of xpub. INDEX. |
| basePath | String | text | ✓ | BIP44 derivation path, default: "m/44'/60'/0'". |
| registrationAddress | String | text | ✓ | Primary Ethereum address (lowercase). INDEX. |
| deviceLabel | String | text | — | Optional hardware device name. |
| nextAddressIndex | Number | integer | ✓ | Counter for next derived address (min: 0, default: 1). |
| addresses (array) | ITrezorDerivedAddress[] | child table | ✓ | Embedded array of derived addresses. Default: []. |
| addresses[].index | Number | integer | ✓ | Sequential address index (min: 0). |
| addresses[].address | String | text | ✓ | Ethereum address. |
| addresses[].derivationPath | String | text | ✓ | Full BIP32 path (e.g., m/44'/60'/0'/0/0). |
| addresses[].purpose | String (enum) | varchar(10) | ✓ | 'deposit' | 'release' | 'refund' | 'other' (default: 'deposit'). |
| addresses[].paymentId | ObjectId (Payment) | uuid FK | — | Optional reference to Payment. |
| addresses[].issuedAt | Date | timestamp | ✓ | Date address was derived. DEFAULT CURRENT_TIMESTAMP. |
| active | Boolean | boolean | ✓ | Soft-delete flag (default: true). INDEX, compound (userId, active). |
| createdAt | Date | timestamp | ✓ | Auto-managed (timestamps: true). |
| updatedAt | Date | timestamp | ✓ | Auto-managed (timestamps: true). |
Indexes:
- UNIQUE on userId
- SINGLE on xpubFingerprint
- SINGLE on registrationAddress
- SINGLE on active
- COMPOUND on (userId, active)
Relationships:
userId→ User (1:1, unique FK)addresses[].paymentId→ Payment (optional, within embedded array)addresses→ embedded array (normalize to child table trezor_derived_addresses)
Gotchas:
- Embedded array with nested ObjectId refs: addresses is an embedded array; each element has a paymentId ref. In PG, migrate addresses to a child table with FK back to trezor_accounts. Use composite PK (trezor_account_id, index) or add surrogate id + unique constraint.
- _id: false on subdocs: Mongoose subdocs have _id: false; PostgreSQL child tables need a PK. Strategy: (trezor_account_id, index) composite or add surrogate id.
- Date.now() defaults: issuedAt defaults to server-evaluated Date.now(). Use trigger DEFAULT CURRENT_TIMESTAMP in PG.
- Lowercase validation: registrationAddress implicitly .lowercase(). Ensure data is normalized before migration.
- Soft delete: active=true queries are common. Add partial index WHERE active=true.
- No hooks/virtuals: Model has no Mongoose hooks, virtuals, or custom methods—straightforward migration.
Proposed PostgreSQL DDL:
CREATE TABLE trezor_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES users(id),
xpub TEXT NOT NULL,
xpub_fingerprint TEXT NOT NULL,
base_path TEXT NOT NULL DEFAULT 'm/44''/60''/0''',
registration_address TEXT NOT NULL,
device_label TEXT,
next_address_index INTEGER NOT NULL DEFAULT 1 CHECK (next_address_index >= 0),
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_trezor_accounts_user_id ON trezor_accounts(user_id);
CREATE INDEX idx_trezor_accounts_xpub_fingerprint ON trezor_accounts(xpub_fingerprint);
CREATE INDEX idx_trezor_accounts_registration_address ON trezor_accounts(registration_address);
CREATE INDEX idx_trezor_accounts_active ON trezor_accounts(active) WHERE active = true;
CREATE INDEX idx_trezor_accounts_user_active ON trezor_accounts(user_id, active);
CREATE TABLE trezor_derived_addresses (
trezor_account_id UUID NOT NULL REFERENCES trezor_accounts(id) ON DELETE CASCADE,
address_index INTEGER NOT NULL CHECK (address_index >= 0),
address TEXT NOT NULL,
derivation_path TEXT NOT NULL,
purpose VARCHAR(10) NOT NULL DEFAULT 'deposit'
CHECK (purpose IN ('deposit', 'release', 'refund', 'other')),
payment_id UUID REFERENCES payments(id),
issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (trezor_account_id, address_index)
);
CREATE INDEX idx_trezor_derived_addresses_payment ON trezor_derived_addresses(payment_id);
User
Purpose: Core user account, authentication, profile, and referral system.
Storage Strategy: Inlined columns for scalar/enum fields + JSONB for nested objects (profile, preferences, points, referralStats) + child tables for arrays (passkeys, refreshTokens).
Field Summary
| Path | Mongo Type | PG Type | Req | Notes |
|---|---|---|---|---|
| _id | ObjectId | uuid PK | Y | Auto-generated |
| String | VARCHAR(255) | N | Unique, sparse; null for OAuth users | |
| password | String | VARCHAR(255) | N | Stripped in toJSON(); null for OAuth |
| firstName | String | VARCHAR(255) | N | Default: 'کاربر' (Persian) |
| lastName | String | VARCHAR(255) | N | Default: 'جدید' (Persian) |
| role | String (enum) | ENUM or VARCHAR(50) | Y | admin|buyer|seller|resolver; default: buyer |
| isEmailVerified | Boolean | BOOLEAN | Y | Default: false |
| authProvider | String (enum) | ENUM or VARCHAR(50) | Y | email|google|telegram; default: email |
| telegramVerified | Boolean | BOOLEAN | Y | Default: false |
| emailVerificationToken | String | VARCHAR(255) | N | Stripped in toJSON() |
| emailVerificationCode | String | VARCHAR(255) | N | Stripped in toJSON() |
| emailVerificationCodeExpires | Date | TIMESTAMP | N | Transient TTL field; stripped |
| passwordResetToken | String | VARCHAR(255) | N | Stripped in toJSON() |
| passwordResetExpires | Date | TIMESTAMP | N | Stripped in toJSON() |
| passwordResetCode | String | VARCHAR(255) | N | Stripped in toJSON() |
| passwordResetCodeExpires | Date | TIMESTAMP | N | Stripped in toJSON() |
| passkeys[] | Embedded array | user_passkeys table | N | WebAuthn credentials; each: id, publicKey, counter, deviceType, deviceName, createdAt |
| profile | Subdocument (JSONB) | JSONB or inlined | N | avatar, photoURL, phone, address{}, bio, website, wallet fields, isPublic |
| preferences | Subdocument (JSONB) | JSONB or inlined | Y | language (default: en), currency (default: USD), notifications{email, sms, push} |
| status | String (enum) | ENUM or VARCHAR(50) | Y | active|suspended|deleted; default: active |
| lastLoginAt | Date | TIMESTAMP | N | Last login time |
| refreshTokens[] | String array | user_refresh_tokens table | N | Active refresh tokens for this session |
| referralCode | String | VARCHAR(255) | Y | Unique, sparse; referral code for this user |
| referredBy | ObjectId | uuid FK | N | Self-ref to User._id; referral parent |
| points | Subdocument (JSONB) | JSONB or inlined | Y | total, available, used (counts), level (int, indexed) |
| referralStats | Subdocument (JSONB) | JSONB or inlined | Y | totalReferrals, activeReferrals, totalEarned (counts) |
| createdAt | Date | TIMESTAMP | Y | Auto-generated |
| updatedAt | Date | TIMESTAMP | Y | Auto-updated |
Relationships
- referredBy → User (self-referential N:1): FK to users(id), ON DELETE SET NULL. Denormalized referral hierarchy.
Indexes
- UNIQUE (email) WHERE email IS NOT NULL (sparse)
- INDEX (role)
- INDEX (status)
- INDEX (referralCode)
- INDEX (referredBy)
- INDEX on points.level (or (points->>'level') if JSONB)
- INDEX (authProvider)
Gotchas & Migrations
-
toJSON() method: Mongoose instance method filters 8 sensitive fields (password, refreshTokens, email verification tokens, password reset tokens). Must implement as application-layer filter or view—NOT stored in DB.
-
Default Date.now() for passkeys.createdAt: Mongoose
default(Date.now)is a function. PG needsDEFAULT NOW()at DDL or app-side INSERT logic. -
Embedded arrays (passkeys, refreshTokens):
- Child table approach (preferred):
user_passkeys(user_id uuid FK, id varchar PK, public_key text, counter int, device_type enum, device_name varchar, created_at timestamp)anduser_refresh_tokens(user_id uuid FK, token varchar PK). Allows indexed queries and easy cleanup. - JSONB array approach: Store as
JSONBcolumn; queries slower but schema simpler.
- Child table approach (preferred):
-
Subdocuments (profile, preferences, points, referralStats):
- JSONB option: Single JSONB column per subdoc. Simpler schema; queries need
->or#>>operators. - Inlined columns option:
profile_avatar,profile_phone,profile_address_street, etc. (20+ columns). Faster queries, schema bloat. - Recommended: Profile, preferences → JSONB. Points, referralStats → inlined (frequently queried for sorting/filtering).
- JSONB option: Single JSONB column per subdoc. Simpler schema; queries need
-
Virtual fullName: Computed in application. Not stored; returned via computed property or SELECT concat(first_name, ' ', last_name).
-
Self-referential FK: Add
FOREIGN KEY (referred_by_id) REFERENCES users(id) ON DELETE SET NULLand indexreferred_by_idfor parent-lookup queries. -
Enum types: Create PG
ENUMtypes for role, authProvider, status, walletType, deviceType. Enforce case-sensitivity matching Mongoose schema (admin, buyer, seller, resolver, etc.). -
Default values: Replicate all Mongoose defaults in PG DDL:
- firstName: 'کاربر', lastName: 'جدید'
- language: 'en', currency: 'USD'
- notifications.email: true, notifications.sms: false, notifications.push: true
- isEmailVerified, telegramVerified, walletProofVerified: false
- points.level: 1; points.total/available/used: 0
- referralStats counts: 0
- status: 'active'
-
Sparse index on referralCode & email: PG uses
WHERE col IS NOT NULLclause in index definition.
Proposed DDL Sketch
-- Enums
CREATE TYPE user_role AS ENUM ('admin', 'buyer', 'seller', 'resolver');
CREATE TYPE auth_provider AS ENUM ('email', 'google', 'telegram');
CREATE TYPE user_status AS ENUM ('active', 'suspended', 'deleted');
CREATE TYPE wallet_type AS ENUM ('evm', 'ton');
CREATE TYPE passkey_device_type AS ENUM ('platform', 'cross-platform');
-- Main users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE SPARSE,
password VARCHAR(255),
first_name VARCHAR(255) DEFAULT 'کاربر',
last_name VARCHAR(255) DEFAULT 'جدید',
role user_role NOT NULL DEFAULT 'buyer',
is_email_verified BOOLEAN NOT NULL DEFAULT false,
auth_provider auth_provider NOT NULL DEFAULT 'email',
telegram_verified BOOLEAN NOT NULL DEFAULT false,
email_verification_token VARCHAR(255),
email_verification_code VARCHAR(255),
email_verification_code_expires TIMESTAMP,
password_reset_token VARCHAR(255),
password_reset_expires TIMESTAMP,
password_reset_code VARCHAR(255),
password_reset_code_expires TIMESTAMP,
-- Subdocuments as JSONB
profile JSONB, -- {avatar, photoURL, phone, address{}, bio, website, walletAddress, walletType, walletProvider, walletProofVerified, walletProofTimestamp, isPublic}
preferences JSONB NOT NULL DEFAULT '{"language":"en","currency":"USD","notifications":{"email":true,"sms":false,"push":true}}'::jsonb,
status user_status NOT NULL DEFAULT 'active',
last_login_at TIMESTAMP,
referral_code VARCHAR(255) NOT NULL UNIQUE SPARSE,
referred_by_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- Points & referral stats as JSONB or inlined
points JSONB NOT NULL DEFAULT '{"total":0,"available":0,"used":0,"level":1}'::jsonb,
referral_stats JSONB NOT NULL DEFAULT '{"totalReferrals":0,"activeReferrals":0,"totalEarned":0}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
-- Indexes
INDEX (email) WHERE email IS NOT NULL,
INDEX (role),
INDEX (status),
INDEX (referral_code),
INDEX (referred_by_id),
INDEX ((points->>'level')),
INDEX (auth_provider)
);
-- Child tables for arrays
CREATE TABLE user_passkeys (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
id VARCHAR(255) PRIMARY KEY,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
device_type passkey_device_type NOT NULL,
device_name VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE user_refresh_tokens (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) PRIMARY KEY,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
Notes for App Code
- Compute
fullNamein SELECT:SELECT *, CONCAT(first_name, ' ', last_name) AS full_name FROM users; - Implement
toJSON()filter at application layer before serializing API responses. - For profile/preferences/points/referralStats JSONB queries, use PG's JSON operators:
->>(text),->(object),#>>(nested text). - Ensure passkey counter increments atomically (use
UPDATE ... SET counter = counter + 1 WHERE id = ?). - Maintain referral_stats denormalization via trigger or app-side aggregate queries.
4. Database access catalog (functions that read/write)
This is the migration port checklist: every place the application reads or writes the database, grouped by service domain. Each operation lists the file, function, model, operation type, and the Mongo-specific operators/features that need a SQL rewrite. 375 operations were catalogued across 19 domains.
address service
| File | Function | Model | Op Type | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| addressController.ts | getUserAddresses | Address | find | lean() | Returns plain objects for all user addresses, sorted by primary desc then createdAt desc. lean() is Mongoose-only; SQL queries return similar results by default. |
| addressController.ts | createAddress | Address | countDocuments | none | Enforces 3-address limit per user. Straightforward COUNT query in SQL. |
| addressController.ts | createAddress | Address | create | ObjectId instantiation | Creates new Address with new Types.ObjectId(userId) for FK. Must convert ObjectId to UUID/numeric and ensure FK constraint. |
| addressController.ts | updateAddress | Address | findOne | ObjectId comparison | Ownership verification before update. Standard SQL WHERE clause after ObjectId conversion. |
| addressController.ts | updateAddress | Address | findByIdAndUpdate | none | Updates conditional fields; returns updated doc with new: true. Use UPDATE ... RETURNING in Postgres. |
| addressController.ts | deleteAddress | Address | findOne | ObjectId comparison | Ownership check before deletion. |
| addressController.ts | deleteAddress | Address | findByIdAndDelete | none | Delete by ID; ensure proper FK cleanup. |
| addressController.ts | setPrimaryAddress | Address | findOne | ObjectId comparison | Ownership check before primary flag change. |
| addressController.ts | setPrimaryAddress | Address | save | pre-save hook triggers updateMany | User manually sets primary=true; pre-save middleware unsets primary on sibling addresses. CRITICAL MIGRATION POINT: pre-save hook uses updateMany with $ne operator to exclude current doc. |
| models/Address.ts | addressSchema.pre('save') | Address | updateMany | $ne operator, pre-save hook | Ensures only one primary address per user. Uses $ne: this._id to exclude current document. CRITICAL: Must be replicated in Postgres via triggers, application transactions, or explicit code—no direct pre-save hook equivalent. |
Cross-Cutting Concerns:
- Pre-save middleware enforcement (CRITICAL): The primary address constraint is enforced at schema level via pre-save hooks. In Postgres, this requires either: (1) application-level transaction wrapping the logic before save, (2) database trigger(s), or (3) explicit service-layer code. Not replicating this correctly will allow multiple primary addresses per user.
- ObjectId → FK conversion: All userId references use Mongoose Types.ObjectId. Must convert to UUID or numeric foreign key.
- No sessions/transactions: Address service does not use Mongoose sessions or multi-document ACID transactions.
- No external state: No Redis caching, blockchain calls, or third-party provider state detected.
- Foreign key constraint: userId field references User model (line 24-26 in Address.ts, indexed). Ensure FK constraint with optional cascading delete in Postgres.
- Indexes: Two indexes must be ported: (1) userId single index (line 25), (2) userId+primary compound index (line 75).
- No dual-writes or cache coherency: No secondary writes, Redis caching, or cache invalidation logic.
admin service
| file | fn | model | op | mongo-specific | notes |
|---|---|---|---|---|---|
| confirmationThresholdRoutes.ts | GET /history | ConfigSettingHistory | find | $regex query operator; .populate('changedBy'); .lean() | Retrieves last 50 threshold change history records using regex pattern matching on key field. Populates changedBy user reference. Uses lean() for read-only optimization. |
| awaitingConfirmationRoutes.ts | GET /awaiting-confirmation | Payment | find | $exists, $ne, $nin, $or, $and operators in complex query filter | Paginated query with complex nested conditions: filters payments with on-chain tx awaiting confirmations. Uses $exists/$ne for null checks, $nin for state exclusion, $and/$or for grouped logic. Chains .sort(), .skip(), .limit(), .lean() |
| awaitingConfirmationRoutes.ts | GET /awaiting-confirmation | Payment | countDocuments | same query filter ($exists, $ne, $nin, $or, $and) | Count total matching payments for pagination metadata. Executed in parallel with find() via Promise.all() |
| dataCleanupService.ts | executeCleanup | User, PurchaseRequest, SellerOffer, Chat, Notification, RequestTemplate, Address, TempVerification | countDocuments | none (plain query object) | Count documents matching cleanup filter (e.g., { createdAt: { $lt: cutoffDate } }) for dry-run reporting |
| dataCleanupService.ts | executeCleanup | User, PurchaseRequest, SellerOffer, Chat, Notification, RequestTemplate, Address, TempVerification | deleteMany | $lt, $ne, $or operators in query filters | Bulk deletion with filters: createdAt $lt cutoffDate, role $ne admin, $or for user ID matching. Returns { deletedCount } result object |
| dataCleanupService.ts | getCollectionStats | User | countDocuments | none | Counts all users; also counts with filter { role: 'admin'|'buyer'|'seller' }. Executed in parallel batch via Promise.all() |
| dataCleanupService.ts | getCollectionStats | PurchaseRequest, SellerOffer, Payment, Chat, Notification, RequestTemplate, Address, Category, TempVerification | countDocuments | none | Bulk count operations on 9 collections (no filters) to gather stats. Executed in parallel batch via Promise.all() |
| dataCleanupService.ts | cleanUserData | PurchaseRequest | deleteMany | mongoose.Types.ObjectId for userId matching | Deletes user's purchase requests by buyerId matching ObjectId. Query: { buyerId: userObjectId } |
| dataCleanupService.ts | cleanUserData | SellerOffer | deleteMany | mongoose.Types.ObjectId for userId matching | Deletes user's seller offers by sellerId matching ObjectId. Query: { sellerId: userObjectId } |
| dataCleanupService.ts | cleanUserData | Payment | deleteMany | $or operator for multi-field matching with ObjectId | Deletes user's payments (as buyer OR seller). Query: { $or: [{ buyerId: userObjectId }, { sellerId: userObjectId }] } |
| dataCleanupService.ts | cleanUserData | Chat | deleteMany | Array field matching (participants is array of ObjectIds) | Deletes user's chats by matching in participants array. Query: { participants: userObjectId } |
| dataCleanupService.ts | cleanUserData | Notification | deleteMany | none | Deletes user's notifications. Query: { userId: userObjectId } |
| dataCleanupService.ts | cleanUserData | RequestTemplate | deleteMany | none | Deletes user's request templates. Query: { sellerId: userObjectId } |
| dataCleanupService.ts | cleanUserData | Address | deleteMany | none | Deletes user's addresses. Query: { userId: userObjectId } |
| dataCleanupService.ts | cleanUserData | User | findByIdAndDelete | Mongoose convenience method for delete-by-ID | Deletes the user record itself after cleaning related data. Called only on non-dryRun. |
| dataCleanupService.ts | cleanTempData | TempVerification | deleteMany | $lt operator for date comparison | Deletes temp verifications older than 24 hours (configurable). Query: { createdAt: { $lt: cutoffDate } } |
| dataCleanupService.ts | cleanTempData | Notification | deleteMany | $lt operator for date comparison | Deletes old read notifications (older than 30 days). Query: { createdAt: { $lt: oldNotificationsDate }, read: true } |
Cross-cutting concerns
- No explicit session/transaction management: all operations are fire-and-forget (no startSession/withTransaction calls)
- No Redis usage detected in admin service
- Break-glass mode uses in-memory state (breakGlassExpiresAt, breakGlassActivatedBy) — not persisted to DB; resets on server restart
- Data cleanup operations perform cascading deletes (user deletion triggers deletion of PurchaseRequest, SellerOffer, Payment, Chat, Notification, RequestTemplate, Address) — migration must ensure foreign key constraints enforce same cascade or explicitly handle cleanup order
- Payment deletion is explicitly disabled (DEC-38 comment) to preserve escrow ledger records; soft-delete or reconciliation patterns should be preferred
- dry-run pattern: countDocuments() executed first to report impact before actual deleteMany() — both use same query object
- ObjectId casting via mongoose.Types.ObjectId required for user ID matching in GDPR cleanup
- Large batch operations (cleanupData, getCollectionStats) use Promise.all() for parallel execution — need index tuning on common filters (role, createdAt, userId, buyerId, sellerId, participants, read)
- AML config service does not persist config to DB — config lives in process.env; no migration concern there
- ConfigSettingHistory.populate('changedBy') creates implicit foreign key dependency on User model — migration must maintain referential integrity
auth service
| File | Function | Model | Operation | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| authController.ts | register | User | findOne | none | Check if user exists by email |
| authController.ts | register | TempVerification | findOne | none | Find existing temp verification by email |
| authController.ts | register | TempVerification | save | none | Save temp verification with email code/expiry |
| authController.ts | login | User | findOne | select +password | Find active user, include password field |
| authController.ts | login | User | save | array slice | Update lastLoginAt, manage max 10 refreshTokens |
| authController.ts | telegramAuth | TelegramLink | findOne | $ne operator | Find blocked link with blockedReason != "unlinked_by_user" |
| authController.ts | telegramAuth | TelegramLink | findOne | none | Find active TelegramLink by ID and status |
| authController.ts | telegramAuth | User | findById | none | Find existing user for link |
| authController.ts | telegramAuth | User | create | none | Create new user for Telegram first-time auth |
| authController.ts | telegramAuth | TelegramLink | findOneAndUpdate | $set, upsert, setDefaultsOnInsert | Upsert link with $set, returns new doc |
| authController.ts | telegramAuth | User | save | array push | Save via issueTokensForUser (refreshToken management) |
| authController.ts | refreshToken | User | findById | none | Find user to verify refresh token in array |
| authController.ts | refreshToken | User | save | array filter | Filter old token, push new one |
| authController.ts | logout | User | findById | none | Find user to remove refresh token |
| authController.ts | logout | User | save | array filter | Filter out logout token from array |
| authController.ts | getProfile | User | findById | none | Find user to return full profile |
| authController.ts | verifyEmailWithCode | TempVerification | findOne | $gt operator | Find by email, code, emailVerificationCodeExpires > now |
| authController.ts | verifyEmailWithCode | User | findOne | none | Find referrer by referralCode |
| authController.ts | verifyEmailWithCode | User | create | none | Create new user from temp verification |
| authController.ts | verifyEmailWithCode | User | save | nested update, array | Save referrer stats and user with referredBy |
| authController.ts | verifyEmailWithCode | TempVerification | findByIdAndDelete | none | Delete temp verification after verification |
| authController.ts | verifyEmail | User | findOne | none | Find user by emailVerificationToken (legacy) |
| authController.ts | verifyEmail | User | save | none | Mark email verified, clear token |
| authController.ts | resendVerificationEmail | User | findOne | none | Check if user exists and verified |
| authController.ts | resendVerificationEmail | TempVerification | findOne | none | Find temp verification to update |
| authController.ts | resendVerificationEmail | TempVerification | save | none | Update code and expiry |
| authController.ts | requestPasswordReset | User | findOne | none | Find active user by email |
| authController.ts | requestPasswordReset | User | save | none | Save passwordResetCode and expiry |
| authController.ts | resetPassword | User | findOne | $gt operator | Find by passwordResetToken, passwordResetExpires > now |
| authController.ts | resetPassword | User | save | array clear | Save after reset, clear refreshTokens |
| authController.ts | resetPasswordWithCode | User | findOne | $gt operator | Find by email, code, passwordResetCodeExpires > now |
| authController.ts | resetPasswordWithCode | User | save | array clear | Save after reset, invalidate tokens |
| authController.ts | changePassword | User | findById | select +password | Find user with password field |
| authController.ts | changePassword | User | save | array clear | Save new password, clear refreshTokens |
| authController.ts | updateProfile | User | findById | none | Find user for profile update |
| authController.ts | updateProfile | User | save | validateBeforeSave=false | Save with validation bypassed |
| authController.ts | deleteAccount | User | findById | select +password | Find with password for verification |
| authController.ts | deleteAccount | User | save | soft delete | Set status='deleted', clear tokens |
| authController.ts | googleSignUp | User | findOne | none | Check if user exists by email |
| authController.ts | googleSignUp | User | create | none | Create user from Google OAuth info |
| authController.ts | googleSignUp | User | findOne | none | Find referrer by referralCode |
| authController.ts | googleSignUp | User | save | nested update, array | Save referrer stats and user |
| authController.ts | googleSignIn | User | findOne | none | Find active user by email |
| authController.ts | googleSignIn | User | save | nested update, array | Update login time, profile, refreshTokens |
| authController.ts | forceVerifyUser | User | findOne | none | Find user by email (dev only) |
| authController.ts | forceVerifyUser | User | save | none | Mark verified, clear codes (dev only) |
| passkeyService.ts | verifyRegistration | User | findById | none | Find user to add passkey |
| passkeyService.ts | verifyRegistration | User | save | array push | Save after pushing passkey to array |
| passkeyService.ts | verifyAuthentication | User | findOne | array field query | Find user by passkeys.id (nested element) |
| passkeyService.ts | verifyAuthentication | User | find | $exists operator | Find users where 'passkeys.0' { $exists: true } |
| passkeyService.ts | verifyAuthentication | User | save | nested array update | Save after updating counter, adding token |
| passkeyService.ts | getUserPasskeys | User | findById | none | Find user to retrieve passkey metadata |
| passkeyService.ts | removePasskey | User | findById | none | Find user to remove passkey |
| passkeyService.ts | removePasskey | User | save | array filter | Save after filtering out passkey |
Cross-Cutting Concerns:
- Redis session/rate-limit integration: sessionService and rateLimitService used in login/logout flows (external state, error resilience required)
- Socket.io referral notifications: real-time emit to user channels on referrer signup (side effect, independent of DB)
- In-memory challenge store: PasskeyService uses Map with 5-min TTL; marked TODO for Redis migration
- Array management: refreshTokens (max 10), passkeys (array push/filter) require new schema in Postgres
- Soft deletes: status field = 'deleted' instead of document removal
- Password projection: .select('+password') excludes by default; needs ORM-level handling
- No transactions: Relies on single-document atomicity; no cross-document ACID
- External providers: Google OAuth, Telegram verification (read-only, independent)
blockchain service
Database Operations Catalog
| File | Function | Model | Operation | MongoDB-Specific | Notes |
|---|---|---|---|---|---|
| blockchain/walletMonitor.ts | confirmPayment | Payment | findById | none | Retrieves payment by ID to check status and prevent duplicate processing |
| blockchain/blockchainTxFetcher.ts | fetchTransactionHash | Payment | findById | none | Extracts wallet address, amount, and checks if hash already exists |
| blockchain/blockchainTxFetcher.ts | fetchTransactionHash | Payment | save | none | Persists transaction hash to nested (blockchain.transactionHash) and root level (transactionHash) |
| blockchain/blockchainTxFetcher.ts | autoFetchMissingHashes | Payment | find | $or, $exists, nested field queries | Finds completed payments missing transaction hash. Chains .limit() and .sort() |
| payment/paymentCoordinator.ts | coordinatePaymentUpdate | Payment | findOne | $or operator | Checks completion status to prevent duplicate updates. Matches _id OR providerPaymentId |
| payment/paymentCoordinator.ts | executePaymentUpdate | Payment | findOneAndUpdate | $or operator, $set | Core coordinated update with source tracking. Matches _id OR providerPaymentId |
| payment/paymentCoordinator.ts | executePaymentUpdate | Payment | findOne | nested queries (metadata.templateCheckout, metadata.isTemplateCheckout), $gte | Fallback: finds latest pending template checkout within 10-minute window |
| payment/paymentCoordinator.ts | executePaymentUpdate | Payment | findByIdAndUpdate | none | Fallback update for template checkout matches |
| payment/paymentCoordinator.ts | executePaymentUpdate | Payment | updateMany | $set, complex $or with $regex | Marks duplicate pending payments as cancelled with replacement tracking |
| payment/paymentCoordinator.ts | executePaymentUpdate | Payment | deleteMany | $ne, $in, $gte, $or with $regex | Hard-deletes old template checkout duplicates. Complex multi-condition filter |
| payment/paymentCoordinator.ts | updatePurchaseRequestStatus | PurchaseRequest | findById | none | Checks existence before update. ObjectId validation with fallback handling |
| payment/paymentCoordinator.ts | updatePurchaseRequestStatus | PurchaseRequest | findByIdAndUpdate | none | Updates status to 'payment' when payment completes |
Cross-Cutting Concerns
- External blockchain state: Ethers.js provider queries BSC RPC for USDT balance and ERC-20 Transfer events. In-memory wallet monitoring map with expiration times (no DB persistence). Transaction hash fetching is non-deterministic and depends on RPC availability.
- Redis coordination: PaymentCoordinator uses Redis for debounce state (3-second window, max 3 updates). Prevents race conditions between wallet-monitor, webhook, and manual sources. Source priority: manual > webhook > wallet-monitor > api.
- Dual-write pattern: Transaction hash written to both
payment.blockchain.transactionHash(nested) and root-levelpayment.transactionHashfor frontend compatibility. Both set during update operations. - Race condition prevention: Coordination logic includes snapshot check of completion status, debounce window validation, and update count throttling. Must port Redis state to database-backed equivalent (advisory locks or coordination table).
- Duplicate payment cleanup: Three-step process on completion: findOneAndUpdate (primary), updateMany (mark as cancelled with metadata), deleteMany (hard-delete old records). Complex $or filters with $regex patterns on template-specific fields.
- Cascading operations: Payment completion triggers: wallet monitoring removal, AML fee deduction (ledger entry), PurchaseRequest status update, duplicate cancellation, and socket.io events to multiple rooms.
- Metadata mutations: Uses dot notation for nested field updates (metadata.lastUpdateSource, metadata.lastUpdateAt, metadata.cancelledDueTo, metadata.replacedByPaymentId). Metadata is untyped Mixed document.
- MongoDB ObjectId validation: Defensive handling for non-ObjectId IDs (template-checkout-, template-tc- prefixes) to avoid CastError. Fallback matching on providerPaymentId.
- Socket.io emissions: Real-time events emitted to global channel and per-room (request and seller-specific) on purchase request status changes. Ensure event delivery doesn't block transaction commit.
blog service
| File | Function | Model | Operation | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| services/blog/BlogService.ts | getPublishedPosts | BlogPost | find | none | Finds published posts with query filters (status, category, tags), skip/limit pagination, .lean() for read-only. Indexes: (status, publishedAt) |
| services/blog/BlogService.ts | getPublishedPosts | BlogPost | countDocuments | none | Counts matching documents for pagination metadata |
| services/blog/BlogService.ts | getAllPosts | BlogPost | find | none | Admin: retrieves all posts across all statuses with pagination and .lean(). Index: (createdAt) |
| services/blog/BlogService.ts | getAllPosts | BlogPost | countDocuments | none | Counts total docs for admin pagination |
| services/blog/BlogService.ts | getFeaturedPosts | BlogPost | find | none | Finds featured published posts sorted by publishedAt desc, .lean(). Index: (featured, status) |
| services/blog/BlogService.ts | getPostBySlug | BlogPost | findOne | none | Single doc lookup by slug + status, .lean(). Slug has unique index (sparse) |
| services/blog/BlogService.ts | getPostById | BlogPost | findById | none | Single doc lookup by _id, .lean() |
| services/blog/BlogService.ts | createPost | BlogPost | save | pre-hook: slug generation, pre-hook: auto-set publishedAt | Creates new doc; Mongoose pre-hooks auto-generate slug and set publishedAt on publish. Requires trigger or app-layer replication |
| services/blog/BlogService.ts | updatePost | BlogPost | findByIdAndUpdate | none | Updates by _id with { new: true, runValidators: true }, returns updated doc |
| services/blog/BlogService.ts | deletePost | BlogPost | findByIdAndDelete | none | Finds and deletes by _id, returns deleted doc |
| services/blog/BlogService.ts | incrementViews | BlogPost | findOneAndUpdate | $inc: { views: 1 } | CRITICAL: Atomic increment by slug—replace with SQL UPDATE...SET views = views + 1 for atomicity |
| services/blog/BlogService.ts | getRecentPosts | BlogPost | find | none | Finds published posts with field projection (.select()) and .lean(). Index: (publishedAt desc) |
| services/blog/BlogService.ts | getPostsByCategory | BlogPost | find | none | Finds by category + status, .lean(). Index: (category, status) |
| services/blog/BlogService.ts | searchPosts | BlogPost | find | $or, $regex, $options: 'i' | CRITICAL: Text search on title/description/tags using $regex (case-insensitive)—replace with PostgreSQL ILIKE or trigram indexes (pg_trgm) |
Cross-Cutting Concerns:
- Redis caching: 10-minute TTL for published posts, featured, recent, slug-based queries. Cache invalidation via delPattern('posts:', 'post:') on all writes (create/update/delete).
- No transactions: All operations are single-document; no Mongo sessions or multi-doc transactions.
- Pre-hooks: Slug auto-generation and publishedAt auto-set on status change—must replicate via Postgres BEFORE INSERT/UPDATE triggers or application logic.
- Asynchronous views: incrementViews is fire-and-forget from controller—eventual consistency acceptable for read scaling, but ensure Postgres UPDATE is non-blocking.
- Array fields: videos, images, tags stored as simple arrays (not manipulated with $push/$pull)—migrate as native Postgres arrays or JSON columns.
Chat Service Database Operations
| File | Function | Model | Operation | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| ChatService.ts | createSupportChat | User | findOne | none | Email lookup for support@amn.gg |
| ChatService.ts | createSupportChat | Chat | findOne | $all on array field | Prevent duplicate support chats |
| ChatService.ts | createChat | Chat | findOne | $all on array field | Check for existing direct chat |
| ChatService.ts | createChat | Chat | create | Nested arrays + documents | Initialize chat with participants, unreadCounts, messages |
| ChatService.ts | createChat | Chat | save | none | Persist initial chat; called twice (welcome message) |
| ChatService.ts | createChat | PurchaseRequest | findById | none | Context for related chat |
| ChatService.ts | createChat | Chat | findById + populate | populate with field projection | Enrich participants and creator before return |
| ChatService.ts | sendMessage | Chat | findById | none | Validate chat and sender membership |
| ChatService.ts | sendMessage | Chat | save | none | Persist message to chat.messages |
| ChatService.ts | sendSystemMessage | Chat | findById | none | Retrieve chat for system message |
| ChatService.ts | sendSystemMessage | Chat | save | none | Persist system message |
| ChatService.ts | getUserChats | Chat | find | $or, $regex, $options, skip, limit, sort, lean | Complex filtering with search; lean() for perf |
| ChatService.ts | getUserChats | Chat | populate | populate nested array refs | Enrich participant and lastMessage sender |
| ChatService.ts | getUserChats | Chat | countDocuments | same as find | Pagination count |
| ChatService.ts | getChatMessages | Chat | findById + populate | populate nested arrays | Full chat retrieval with participant/sender details |
| ChatService.ts | markMessagesAsRead | Chat | findById | none | Retrieve chat |
| ChatService.ts | markMessagesAsRead | Chat | save | none | Persist isRead flags and unreadCounts reset |
| ChatService.ts | uploadChatFile | Chat | findById | none | Validate chat exists |
| ChatService.ts | editMessage | Chat | findById | none | Retrieve and mutate message in array |
| ChatService.ts | editMessage | Chat | save | none | Persist edited message |
| ChatService.ts | deleteMessage | Chat | findById | none | Retrieve chat |
| ChatService.ts | deleteMessage | Chat | deleteOne (embedded) | .pull() on DocumentArray | Remove message by _id; update lastMessage |
| ChatService.ts | deleteMessage | Chat | save | none | Persist removal |
| ChatService.ts | archiveChat | Chat | findById | none | Retrieve and validate participation |
| ChatService.ts | archiveChat | Chat | save | none | Persist isArchived flag |
| ChatService.ts | getChatStats | Chat | countDocuments | none | Count total active chats |
| ChatService.ts | getChatStats | Chat | countDocuments | $gt operator | Count chats with unread messages |
| ChatService.ts | getChatStats | Chat | aggregate | $match, $unwind, $group, $sum | Sum all unread counts per user |
| ChatService.ts | addParticipant | Chat | findById | none | Retrieve and validate |
| ChatService.ts | addParticipant | User | find | $in operator | Validate new participant IDs exist |
| ChatService.ts | addParticipant | Chat | updateOne (embedded) | .push() on DocumentArray | Add participants and unreadCounts |
| ChatService.ts | addParticipant | Chat | save | none | Persist additions |
| ChatService.ts | addParticipant | Chat | populate | populate nested array | Enrich before response |
| ChatService.ts | removeParticipant | Chat | findById | none | Retrieve and find participant |
| ChatService.ts | removeParticipant | Chat | updateOne (embedded) | .filter() on DocumentArray; isActive flag | Soft-delete participant; remove unreadCounts |
| ChatService.ts | removeParticipant | Chat | save | none | Persist deactivation |
Cross-Cutting Concerns
- WebSocket (Socket.io) real-time events: global.io?.to() broadcasts message add/edit/delete/read/participant changes; database write must complete before emit to avoid stale state
- Redis-based rate limiting & deduplication: chatRateLimiter checks via redisService (exists, set); 5-minute TTL; blocks aggressive users; affects write throughput
- File storage dependency: uploadChatFile offloads to fileService; fileUrl/fileName stored in message; orphaned files if message deleted without cleanup
- Embedded array mutations: chat.messages, chat.participants, chat.unreadCounts modified in-memory via DocumentArray methods (.push, .pull, .filter) then saved; atomic per chat but not atomic across chats
- No explicit transactions: Multi-step operations (find → mutate → save) lack session/transaction protection; concurrent requests to same chat risk race conditions on unreadCounts or participant lists
- Nested populate patterns: Two-level populates (participants.userId → User) create complex queries; potential N+1 on multiple chats without lean()
- In-memory pagination: getChatMessages fetches entire messages array then slices in application; scales poorly with large message counts
- Dual-write consistency: addMessage increments unreadCounts; markAsRead resets them; risk if save fails mid-operation
delivery service
| File | Function | Model | Operation | Mongo-Specific | Notes |
|---|---|---|---|---|---|
services/delivery/DeliveryService.ts |
generateDeliveryCode | PurchaseRequest | findByIdAndUpdate | $set with nested paths (deliveryInfo.*); .populate() chaining | Updates delivery code, expiration, and metadata atomically. Fetches buyer/seller details. Migration: nested update paths + relationship population. |
services/delivery/DeliveryService.ts |
verifyDeliveryCode | PurchaseRequest | findById | .populate() with field projection on buyer/seller names | Reads full request for code validation. Checks expiration and usage. Migration: complex nested population with selective field projection. |
services/delivery/DeliveryService.ts |
verifyDeliveryCode | PurchaseRequest | findByIdAndUpdate | $set with nested multi-field update (deliveryCodeUsed, deliveryCodeUsedAt, deliveryCodeUsedBy, deliveredAt) | Marks code as used, records confirmation time and verifier ID. No return. Migration: atomic multi-field nested update. |
services/delivery/DeliveryService.ts |
logDeliveryAttempt | PurchaseRequest | findByIdAndUpdate | $push to append object to deliveryInfo.deliveryAttempts array | Logs attempt history (success/failure). Called after each verification. Migration: array append operation; Postgres may need JSONB or separate table. |
services/delivery/DeliveryService.ts |
getDeliveryCode | PurchaseRequest | findById | .populate('selectedOfferId') to resolve nested offer | Reads request, extracts delivery code metadata, performs authorization check. Returns DTO. Migration: relationship population for auth logic. |
services/delivery/DeliveryService.ts |
isDeliveryCodeValid | PurchaseRequest | findById | none | Validation check: code exists, not used, not expired. Straightforward field reads. |
services/delivery/DeliveryService.ts |
getDeliveryAttempts | PurchaseRequest | findById | .select() field projection; .lean() to skip hydration | Returns deliveryAttempts array as plain JS object. Lean() for performance. Migration: array field projection + ORM optimization. |
services/delivery/DeliveryService.ts |
regenerateDeliveryCode | PurchaseRequest | findByIdAndUpdate | $set on nested deliveryCodeUsed, deliveryCodeUsedAt | Invalidates old code, then calls generateDeliveryCode for new one. Two sequential updates (not transactional). Migration: eventual consistency risk. |
Cross-Cutting Concerns:
- Socket.io events: emitToRoom() and global.io.to() emit real-time delivery status updates (delivery-code-generated, delivery-confirmed, buyer-confirmed-delivery) to connected clients. Not DB operations but indicate real-time architecture dependency.
- NotificationService integration: Every delivery state change triggers async createNotification() calls to create Notification documents. No transaction wrapping; if notification save fails after request update, audit trail is incomplete.
- Dual-write risk: PurchaseRequest update + NotificationService write are sequential, not atomic. Failure in notification creation does not roll back request update.
- No database sessions/transactions: Delivery operations are not wrapped in MongoDB sessions or transactions. Multiple sequential updates lack atomicity guarantees.
- Populate dependencies: Multiple .populate() calls on buyerId and selectedOfferId.sellerId require correct FK relationships in Postgres migration.
- Array field mutations: deliveryAttempts array is mutated via $push. Postgres requires JSONB support or denormalization into separate attempts table.
- Nested document design: All delivery metadata nested under deliveryInfo subdocument. Migration must choose: JSON/JSONB column vs. normalized sub-table design.
dispute service
Operations Catalog
| file | function | model | operation | mongo-specific | notes |
|---|---|---|---|---|---|
| DisputeService.ts | createDispute | PurchaseRequest | findById | populate('selectedOfferId') | Validates purchase request; derives sellerId from selectedOffer or preferredSellerIds |
| DisputeService.ts | createDispute | Dispute | create | Types.ObjectId, embedded timeline/evidence arrays, Date defaults | Creates dispute with status='pending', 48h response deadline, 7d deadline; pre-save hook adds timeline entry |
| DisputeService.ts | createDispute | Chat | create | Types.ObjectId casting, embedded participants/messages/relatedTo, nested arrays | Creates 3-way group chat (buyer, seller, admin); system message; relatedTo links to PurchaseRequest |
| DisputeService.ts | createDispute | Dispute | save | Document instance, chatId reference | Links Chat._id to Dispute.chatId after chat creation |
| DisputeService.ts | getDisputes | Dispute | find | populate(buyerId, sellerId, adminId, purchaseRequestId), sort, skip, limit, lean() | List with filters; pagination via skip/limit; lean() returns POJOs |
| DisputeService.ts | getDisputes | Dispute | countDocuments | Query filter | Total count for pagination; Promise.all parallelizes with find() |
| DisputeService.ts | getDisputeById | Dispute | findById | populate(buyerId, sellerId, adminId, purchaseRequestId, chatId) | Full dispute load with all references and chat history |
| DisputeService.ts | assignAdmin | Dispute | findById | Types.ObjectId adminId | Fetch dispute for admin assignment |
| DisputeService.ts | assignAdmin | Dispute | save | Append to timeline, update status to 'in_progress' | Persists admin assignment and status transition |
| DisputeService.ts | assignAdmin | Chat | findById | Chat._id lookup | Fetch chat if chatId exists |
| DisputeService.ts | assignAdmin | Chat | save | Append admin to participants array if not present | Adds admin as participant with role='admin' |
| DisputeService.ts | updateStatus | Dispute | findById | Types.ObjectId | Fetch before status change |
| DisputeService.ts | updateStatus | Dispute | save | Append to timeline, update status, conditionally set closedAt | Persists status transition and timeline entry; closedAt set for 'closed'/'resolved' |
| DisputeService.ts | resolveDispute | Dispute | findById | Types.ObjectId | Fetch before resolution |
| DisputeService.ts | resolveDispute | Dispute | save | Update resolution nested object (action, amount, currency, resolvedBy, resolvedAt), closedAt, append timeline | Final resolution: status='resolved', records decision (refund/replacement/compensation/warning/ban/no_action) |
| DisputeService.ts | resolveDispute | PurchaseRequest | update (indirect) | Called via releaseHoldResolve with findByIdAndUpdate | Clears escrow hold (disputeResolved=true, holdUntil=undefined) to unblock payment release |
| DisputeService.ts | addEvidence | Dispute | findById | Types.ObjectId | Fetch before evidence addition |
| DisputeService.ts | addEvidence | Dispute | save | Append to evidence array (type, url, description, uploadedBy, uploadedAt), append to timeline | Adds evidence document and timeline entry (action='evidence_added') |
| DisputeService.ts | getStatistics | Dispute | countDocuments | Query filter, optional adminId ObjectId | Count by status (pending, in_progress, resolved); called 4× in parallel |
| DisputeService.ts | getStatistics | Dispute | aggregate | $match, $group { _id: '$category', count: {$sum:1} } | Group disputes by category, count per category |
| DisputeService.ts | getStatistics | Dispute | aggregate | $match, $group { _id: '$priority', count: {$sum:1} } | Group disputes by priority, count per priority |
| releaseHoldService.ts | raiseDispute | PurchaseRequest | findByIdAndUpdate | { new: true }, inline fields (disputeRaised, holdUntil, updatedAt) | Sets hold flags and 14-day hold window; blocks payment release |
| releaseHoldService.ts | raiseDispute | Payment | updateMany | Query { purchaseRequestId }, update disputed=true, holdUntil | Marks all linked payments as disputed; implements escrow hold |
| releaseHoldService.ts | resolveDispute | PurchaseRequest | findByIdAndUpdate | { new: true }, inline fields (disputeResolved, holdUntil=undefined, updatedAt) | Clears hold flags; unblocks payment release |
| releaseHoldService.ts | resolveDispute | Payment | updateMany | Query { purchaseRequestId }, update disputed=false, holdUntil=undefined | Clears hold on all linked payments |
| releaseHoldService.ts | isReleaseBlockedById | PurchaseRequest | findById | lean(), ObjectId format validation | Efficient read-only check for hold state |
| disputeRoutes.ts | POST /raise | PurchaseRequest | findById | Authorization: buyerId.toString() === user.id | Verify purchase request exists and buyer owns it |
| disputeRoutes.ts | POST /resolve | PurchaseRequest | findById | No populate, exists check only, admin-only role auth | Verify purchase request exists before resolve |
| disputeRoutes.ts | GET /status | PurchaseRequest | findById | lean(), auth check against buyerId + preferredSellerIds array | Fetch hold flags for client; lean() for performance; seller list membership check |
Cross-Cutting Concerns
- No Transactions: Multi-document operations (raiseDispute, resolveDispute) lack ACID guarantees; PurchaseRequest + Payment updates could diverge on failure. Postgres migration must wrap both in transactions.
- WebSocket Events: global.io.emit() called after save() without transactional coupling; orphans if save fails mid-operation. Events are asynchronous and not guaranteed delivery.
- Pre-Save Middleware: Dispute.pre('save') auto-appends timeline entries; Chat.pre('save') updates metadata timestamps and lastActivity. SQL migration needs explicit INSERT/UPDATE logic in transaction.
- Chat Message Side Effects: Chat.methods.addMessage() modifies unreadCounts and lastMessage in response to message push; Chat.markAsRead() updates unreadCounts and participant.lastSeen. Embedded array mutations must become explicit table UPDATEs.
- Escrow Hold Logic: holdUntil date comparisons and disputeRaised/disputeResolved flags block payment release; critical business rules. Migration must preserve flag semantics and date comparisons.
- Authorization: Uses string/ObjectId equality (buyerId.toString() === user.id); migration must maintain consistent ID representation (UUID or consistent string format).
- No Caching: No Redis usage detected; no cache invalidation concerns.
- Evidence URLs: Stored but not uploaded in dispute service (external file storage system assumed).
- Array Queries: No $elemMatch,
push, or positionaloperators; no complex array filtering in queries.
email service
No database operations detected. This service is a stateless email delivery layer using nodemailer for SMTP and handling Resend webhook events for inbound mail. All functionality is external-facing (SMTP transport, HTTP requests to Resend API) with no persistence to Mongo, SQL, or any data store.
Operations Table:
| File | Function | Model | OpType | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| (none) | (none) | (none) | N/A | N/A | No database operations in email service |
Cross-Cutting Notes:
- Email delivery via nodemailer (SMTP or test accounts) is external to the database
- Resend webhook endpoint accepts inbound mail events but does not persist to database
- No transactions, sessions, or data consistency concerns
- No cache invalidation or dual-write patterns
file service
Database Operations
| File | Function | Model | Operation | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| fileController.ts | uploadAvatar | User | findByIdAndUpdate | $set (nested field updates) | Updates user.profile.photoURL and user.profile.avatar using nested $set operator. Returns updated document with { new: true }. Migration: convert to SQL UPDATE on User table with nested JSONB or separate columns. |
Cross-Cutting Concerns
- File System Storage: All file uploads stored on disk via Multer (temp, avatars, products, documents, request-templates, blog folders). File paths stored as strings in database, not as standalone file objects.
- Image Processing: Sharp library optimizes images (avatars: 400x400 webp, products: 800x800 webp, request-templates/blog: 1200x800 webp). Files processed before writing to disk.
- No Transactions: Single document update with no transaction boundaries or session management.
- No Caching: No Redis or cache invalidation concerns.
- No External State: File operations are purely filesystem-based; no blockchain, webhooks, or provider integrations.
- Multer Configuration: Uses diskStorage with field name validation and file type filtering at middleware level. Max file size: 10MB. Supports single and batch uploads (up to 10 files per route).
health service
No database operations found.
The health service is a diagnostic utility that checks system connectivity and component health. It contains:
checkDb(): Verifies MongoDB connectivity viamongoose.connection.db.admin().ping()(diagnostic only)checkRedis(): Verifies Redis connectivity viaredisService.ping()(diagnostic only)checkRnChainRegistry(): Loads supported chains from static configcheckRnTokenRegistry(): Loads tokens from static configcheckRnApi(): Checks external Request Network API reachability
No CRUD operations, aggregations, sessions, or data-modifying transactions present.
| File | Function | Model | Operation | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| healthCheckService.ts | checkDb | N/A | ping | none | Connectivity diagnostic; no data access |
marketplace service
| File | Function | Model | Operation | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| routes.ts | POST /payments | PurchaseRequest, SellerOffer, Payment | findById, create, save, findByIdAndUpdate | none | Validate request & offer, create Payment, update PurchaseRequest status |
| routes.ts | GET /payments | Payment | find, countDocuments | populate, lean | List with filters, .populate() for buyerId/sellerId, pagination |
| routes.ts | GET /payments/:id | Payment | findById | populate, lean | Fetch single payment with related field population |
| routes.ts | PUT /payments/:id | Payment, PurchaseRequest | findById, findByIdAndUpdate | none | Update payment status, conditionally update request status |
| routes.ts | POST /purchase-requests/stats | PurchaseRequest | countDocuments, aggregate | $match, $group, $sum | Get total count + breakdown by status |
| routes.ts | GET /purchase-requests | PurchaseRequest, SellerOffer | find, countDocuments | populate, lean, skip, limit | List with dynamic filters, pagination |
| routes.ts | GET /purchase-requests/:id | PurchaseRequest, SellerOffer, Payment | findById, find, populate | populate, lean | Fetch request with related offers & payments |
| routes.ts | PUT /purchase-requests/:id/select-offer | SellerOffer, PurchaseRequest | findByIdAndUpdate, updateMany | $ne, $nin | Accept one offer, reject others via updateMany filter |
| routes.ts | POST /offers | SellerOffer, PurchaseRequest | findOne, findById, create, save | none | Check duplicate, validate status, save offer |
| routes.ts | GET /offers | SellerOffer | find | populate, lean | List offers with seller info |
| routes.ts | POST /purchase-requests/:id/complete-payment | Payment, PurchaseRequest | findOne, find, create, findByIdAndUpdate, save | none | Manage payment record, update request status |
| routes.ts | POST /purchase-requests/:id/confirm-delivery | PurchaseRequest, SellerOffer, Payment, Notification | findById, findByIdAndUpdate, create | none | Mark delivered, update payment, create notifications |
| PurchaseRequestService.ts | createRequest | PurchaseRequest, User | findOne, findById, create, save | none | Check duplicates, find buyers/sellers, save request |
| PurchaseRequestService.ts | getPurchaseRequests | PurchaseRequest, SellerOffer | find, countDocuments | populate, lean, skip, limit | List with filters, pagination, field population |
| PurchaseRequestService.ts | getPurchaseRequestById | PurchaseRequest | findById | populate, lean | Fetch single request with related fields |
| PurchaseRequestService.ts | updateRequestStatus | PurchaseRequest, Payment | findById, findByIdAndUpdate | none | Update status with validation |
| PurchaseRequestService.ts | getBuyerRequests | PurchaseRequest, Payment | find, countDocuments | populate, lean | List buyer's requests with related payments |
| SellerOfferService.ts | createOffer | SellerOffer, PurchaseRequest | findOne, findById, save | none | Check duplicate offer, validate request state |
| SellerOfferService.ts | updateOffer | SellerOffer, PurchaseRequest | findOne, findByIdAndUpdate | none | Update with ownership check |
| SellerOfferService.ts | getOffers | SellerOffer | find | lean | List offers with optimization |
| SellerOfferService.ts | rejectOffer | SellerOffer, PurchaseRequest | findById, findByIdAndUpdate, updateMany | $ne | Reject one + update related |
| SellerOfferService.ts | acceptOffer | SellerOffer, PurchaseRequest | findById, findByIdAndUpdate, updateMany, find | $ne, $nin | Accept one, reject others, notify losers |
| SellerOfferService.ts | getOfferStats | SellerOffer | aggregate, countDocuments | $match, $group, $sum, $avg | Seller statistics aggregation |
| SellerOfferService.ts | expireOffers | SellerOffer | find, updateMany | none | Find expired, bulk status update |
| RequestTemplateService.ts | getSellersWithTemplates | RequestTemplate, User, ShopSettings | aggregate, cacheService | $match, $group, $lookup, $unwind, $project, $map, $ifNull, $concat, $trim, $sum, $sort | Complex multi-join with computed fields, Redis cached |
| RequestTemplateService.ts | getSellerWithTemplates | RequestTemplate, User, ShopSettings | aggregate | $match, $group, $lookup, $unwind, $project | Single seller aggregate pipeline |
| RequestTemplateService.ts | createTemplate | RequestTemplate | save | none | Create with defaults, generate shareableLink |
| RequestTemplateService.ts | getTemplates | RequestTemplate | find, countDocuments | populate, lean, skip, limit, sort | List seller's templates with pagination |
| RequestTemplateService.ts | getTemplateById | RequestTemplate | findById | populate, lean | Fetch single template |
| RequestTemplateService.ts | updateTemplate | RequestTemplate | findOne, findByIdAndUpdate | none | Update with ownership check |
| RequestTemplateService.ts | deleteTemplate | RequestTemplate | deleteOne | none | Delete with {_id, sellerId} filter |
| RequestTemplateService.ts | convertTemplateToRequest | RequestTemplate, PurchaseRequest, SellerOffer, Payment, Category | findById, save, findOne, deleteOne, deleteMany, find | none | Batch conversion with orphan payment cleanup |
| RequestTemplateService.ts | incrementUsageCount | RequestTemplate | findByIdAndUpdate | $inc | Increment usageCount, auto-disable if limit reached |
| RequestTemplateService.ts | getTemplateStats | RequestTemplate | countDocuments, aggregate, find | $match, $group, $sum | Get template statistics |
| RequestTemplateService.ts | toggleTemplateStatus | RequestTemplate | findOne, findByIdAndUpdate | populate, lean | Toggle isActive with ownership check |
| RequestTemplateService.ts | completeTemplateRequestsPayment | PurchaseRequest | find, updateMany | $in, $set | Batch update from pending_payment to final status |
| shopSettingsController.ts | getSellerShopSettings | ShopSettings | findOne | lean | Read-only fetch |
| shopSettingsController.ts | updateShopSettings | ShopSettings | findOneAndUpdate | $set, $setOnInsert, upsert | Upsert with conditional field init |
| CategoryService.ts | getAllCategories | Category | find, cacheService | lean, sort | List active, Redis cached with 30-min TTL |
| CategoryService.ts | getCategoryById | Category | findById | lean | Single fetch |
| CategoryService.ts | getCategoryTree | Category | find | lean, sort | Build hierarchy in application layer |
| CategoryService.ts | getSubCategories | Category | find | lean, sort | List by parentId |
| CategoryService.ts | createCategory | Category | save, cacheService.invalidate | none | Create & invalidate Redis |
| CategoryService.ts | updateCategory | Category | findByIdAndUpdate, cacheService.invalidate | none | Update & invalidate Redis |
| CategoryService.ts | deleteCategory | Category | findByIdAndUpdate, cacheService.invalidate | none | Soft delete (isActive:false) & invalidate |
| CategoryService.ts | getCategoryPath | Category | findById | lean | Walk parent chain via loop |
| reviewRoutes.ts | GET /reviews/:subjectType/:subjectId | Review, RequestTemplate, ShopSettings | find, countDocuments, aggregate | populate, lean, $match, $group, $sum, $avg, $cond, $eq | Fetch reviews + rating stats |
| reviewRoutes.ts | POST /reviews | Review, RequestTemplate, PurchaseRequest, ShopSettings | findById, findOne, create | none | Verify seller settings, check duplicate, create review |
| templateCheckoutWebhook.ts | POST /webhook/template-checkout | Payment | findOne | none | Webhook duplicate prevention via providerPaymentId |
| marketplaceController.ts | recordPayment | Payment, PurchaseRequest | save, findByIdAndUpdate | none | Create Payment, update PurchaseRequest |
| marketplaceController.ts | getPayments | Payment | find, countDocuments | populate, lean, skip, limit | List with pagination |
| marketplaceController.ts | updatePaymentStatus | Payment, PurchaseRequest | findById, findByIdAndUpdate | none | Update payment & related request |
Cross-Cutting Concerns
- Redis Caching: RequestTemplateService caches seller+templates list (5-min TTL); CategoryService caches categories (30-min TTL). Cache invalidation on create/update/delete operations.
- Blockchain Integration: Payment records dual-write transactionHash, blockchain provider, verification status. External state not managed by DB alone; requires on-chain verification service.
- Real-Time Events: Socket.io emits on status changes to room subscribers (request-{id}, user-{id}, seller-{id}). Async updates may delay real-time reflect.
- Multi-Step Workflows: Template→PurchaseRequest conversion creates intermediate states; orphan payments cleaned via deleteOne/deleteMany within 60-min window.
- Upsert Patterns: ShopSettings uses {$set, $setOnInsert, upsert:true} for conditional field initialization — map to Postgres INSERT...ON CONFLICT...DO UPDATE.
- Bulk Update/Delete: updateMany for rejecting offers, deleteMany for orphan cleanup — requires careful transaction boundary definition in SQL.
- No Explicit Transactions: Multi-document updates (accept offer → reject others → update request) rely on eventual consistency; no startSession/withTransaction usage observed.
- Soft Deletes: Categories use isActive flag; DeleteCategory sets isActive:false rather than hard delete.
notification service
| File | Function | Model | Operation | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| services/notification/NotificationService.ts | createNotification | Notification | create | none | Creates single notification via new Notification().save(); sets isRead=false, createdAt=now |
| services/notification/NotificationService.ts | createNotificationsBulk | Notification | insertMany | none | Bulk-inserts array of notifications in one write; used for fan-out scenarios (e.g., seller notifications) |
| services/notification/NotificationService.ts | getUserNotifications | Notification | find | lean() | Finds user notifications with pagination (skip/limit), sorted by createdAt desc; .lean() returns plain objects; filters by isRead if unreadOnly=true |
| services/notification/NotificationService.ts | getUserNotifications | Notification | countDocuments | none | Two separate count calls: total notifications and unread count (isRead=false) for user |
| services/notification/NotificationService.ts | markAsRead | Notification | findOneAndUpdate | none | Finds and updates single notification by _id+userId; sets isRead=true, readAt=now; returns updated doc |
| services/notification/NotificationService.ts | markAllAsRead | Notification | updateMany | none | Updates all unread notifications for user: isRead=true, readAt=now; returns modifiedCount |
| services/notification/NotificationService.ts | deleteNotification | Notification | deleteOne | none | Deletes single notification by _id+userId (permission-scoped); returns boolean from deletedCount |
| services/notification/NotificationService.ts | getUnreadCount | Notification | countDocuments | none | Counts unread notifications (userId, isRead=false); used for UI badge and real-time updates |
| services/notification/notificationController.ts | getNotificationById | Notification | findOne | none | Finds single notification by _id+userId (permission check); called directly from controller |
Cross-Cutting Concerns:
- Socket.IO Real-Time Push: After every DB write (create, markAsRead, markAllAsRead), global.io.to(
user-${userId}).emit() broadcasts new-notification and unread-count-update events to user's personal room. Must be maintained post-migration. - TTL Index: NotificationSchema has expireAfterSeconds: 7776000 (90 days) on createdAt field to auto-delete old notifications. Requires Postgres equivalent (INTERVAL-based cleanup job or scheduled task).
- Compound Indexes: userId+createdAt, userId+isRead, userId+category for query optimization; map 1:1 to Postgres composite indexes.
- Mixed Metadata Field: BSON Mixed type stores arbitrary JSON (e.g., trackingInfo for delivery); migrate to Postgres JSONB.
- Bulk Operation Optimization: bulkMarkAsRead and bulkDelete in controller loop over IDs calling single-doc methods; can be optimized to batch UPDATE WHERE _id IN (...) in SQL.
- No transactions, sessions, Redis caching, dual-writes, or external provider state in notification service.
payment service
Operations Catalog
| File | Function | Model | Op Type | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| paymentService.ts | createPaymentRecord | Payment | create | none | Inserts new payment; checks idempotency on legacyOrderId + buyerId |
| paymentService.ts | updatePaymentRecord | Payment | findByIdAndUpdate | $set: {blockchain.*, status, metadata} | Nested updates; returns new:true |
| paymentService.ts | updatePaymentByOrderId | Payment | findOneAndUpdate | $set: {blockchain.*, status, metadata} | Query by orderId; same nested pattern |
| paymentService.ts | getPaymentRecord | Payment | findById | none | Single read by _id |
| paymentService.ts | getUserPayments | Payment | countDocuments + find | none | Two-phase with sort, skip, limit |
| paymentService.ts | getPaymentStats | Payment | aggregate | $match, $group: {$sum, _id: null or $status} | Parallel pipelines: total stats + by-status breakdown |
| paymentController.ts | exportPayments | Payment | find | populate(buyerId, sellerId), lean(), $gte/$lte | Date range filter; user ref resolution |
| paymentController.ts | listPayments | Payment | find | populate(buyerId, sellerId), lean(), skip, limit | Paginated with dual populate |
| paymentController.ts | getPaymentById | Payment | findById | none | Single read |
| paymentController.ts | updatePaymentStatus | Payment | findByIdAndUpdate | $set: {blockchain.*, status} | Via PaymentCoordinator for race control |
| paymentController.ts | verifyTransactionById | Payment | findOne | $or: [{_id}, {transactionId}, {providerPaymentId}] | Multi-path lookup |
| paymentController.ts | updatePurchaseRequestOnPayment | Payment + PurchaseRequest + SellerOffer | findByIdAndUpdate | $set: {status, timestamps} | Coordinated cascade |
| paymentCoordinator.ts | coordinatePaymentUpdate | Payment | findOne | $or: [{_id}, {providerPaymentId}] | Race prevention; checks if already completed |
| paymentCoordinator.ts | executePaymentUpdate | Payment + PurchaseRequest + FundsLedgerEntry | findOneAndUpdate + updateMany + create | $set, updateMany (cancel), deleteMany (cleanup), create (ledger) | Main hub: status, cascades, ledger, duplicate cleanup |
| amnScannerPayInService.ts | createAmnScannerPayInIntent | Payment | findOne + create | none | Idempotency on pending state; template checkout support |
| request-network/requestNetworkService.ts | createPayInIntent | Payment | findOne + create | unique index on (buyerId, purchaseRequestId, provider) | Idempotency via findOne; template support |
| request-network/requestNetworkService.ts | updateLocalPaymentStatus | Payment | findByIdAndUpdate | $set: {status, blockchain.transactionHash, metadata} | Maps RN status enum |
| request-network/requestNetworkWebhook.ts | handleRequestNetworkWebhook | Payment + PurchaseRequest | findOne + save + findByIdAndUpdate | $set: {escrow.funded, escrow.fundedAt} | Webhook processing; triggers purchase request update |
| requestNetwork/requestNetworkPayInService.ts | markPaymentAsProcessing | Payment | findByIdAndUpdate | $set: {status, metadata.requestNetworkData.*} | Uses $set to avoid subdocument spread issues |
| requestNetwork/requestNetworkPayInService.ts | updatePaymentMetadataOnConfirmation | Payment | findByIdAndUpdate | $set: {providerPaymentId, metadata.*} | Persists provider ref + metadata |
| requestNetwork/requestNetworkRoutes.ts | route handlers | Payment + PurchaseRequest | findById + findByIdAndUpdate + save | $set | Status transitions, confirmation, payment marking |
| ledger/fundsLedgerService.ts | appendFundsLedgerEntry | FundsLedgerEntry | create | unique index on idempotencyKey | Immutable ledger; dedup on idempotency key |
| ledger/fundsLedgerService.ts | getFundsBalance* | FundsLedgerEntry | aggregate | $match, $group: {_id: $entryType, total: $sum, entries: $sum} | Balance calculation by entry type |
| wallets/derivedDestinations.ts | getNextDerivationIndex | Counter | findByIdAndUpdate | $inc: {seq: 1}, upsert: true | Atomic index allocation |
| wallets/derivedDestinations.ts | getDestinationFor | DerivedDestination | findOne + create | unique index (buyerId, sellerOfferId, chainId); 11000 race handling | Deterministic per-pair derivation |
| wallets/derivedDestinations.ts | listDerivedDestinations | DerivedDestination | find + countDocuments | lean(), skip, limit, sort | Paginated list with filters |
| wallets/derivedDestinations.ts | getDerivedDestinationById | DerivedDestination | findById | lean() | Read-only |
| wallets/derivedDestinations.ts | updateDerivedDestinationStatus | DerivedDestination | findByIdAndUpdate | $set: {status, lastSweep*}, $inc: {sweepCount} | Atomic counter increment on sweep |
| wallets/sweepService.ts | executeSweep | DerivedDestination | find + findByIdAndUpdate | lean(), $set, $inc | Finds active, updates on success |
| safety/confirmationThresholdService.ts | getConfirmationThreshold | ConfigSetting | findOne | lean() | Read with in-memory cache (30s TTL) |
| safety/confirmationThresholdService.ts | setConfirmationThreshold | ConfigSetting + ConfigSettingHistory | findOne + findOneAndUpdate + create | $set: {key, value, updatedBy}, upsert: true | Audit trail; cache invalidation |
| safety/confirmationThresholdService.ts | listConfirmationThresholds | ConfigSetting | find | lean(), $regex on key | Merge with defaults for missing chains |
| adapters/amnPayAdapter.ts | createPayInIntent + buildPaymentInstruction + getPaymentDetails + listPayments | Payment | findById + findOne + find + countDocuments | none | Adapter interface; read-only operations |
| adapters/requestNetworkAdapter.ts | (same as amnPayAdapter) | Payment | findById + findOne + find + countDocuments | none | Mirrors amnPayAdapter |
| decentralizedPaymentService.ts | recordDecentralizedPayment | Payment | save | none | Mongoose instance save |
| decentralizedPaymentService.ts | updatePaymentFromDecentralized | Payment | findByIdAndUpdate | $set: {status, blockchain.*} | Chain confirmation updates |
| decentralizedPaymentService.ts | finalizeDecentralizedPayment | Payment + PurchaseRequest | findByIdAndUpdate | $set: {status: completed, blockchain.*} | Completion cascade |
| decentralizedPaymentService.ts | getPendingDecentralizedPayments | Payment | find | none | Query pending/processing with blockchain data |
| reconciliation/requestNetworkReconciliationService.ts | reconcileRequestNetworkPayments | Payment | find + findByIdAndUpdate | none | Dry-run report + optional apply |
| cleanupPendingPayments.ts | cleanupOldPendingPayments | Payment | deleteMany | none | Cleans >2h old pending; excludes request.network |
| migration/reportService.ts | generateShkeeperMigrationReport | Payment + FundsLedgerEntry | find + aggregate | $match, $group, $addToSet | Read-only migration analysis |
| orchestration/releaseRefundService.ts | buildReleaseRefundInstruction | Payment | findById | none | Instruction generation |
Cross-Cutting Concerns
- Redis Coordination:
paymentRedisServicetracks coordination state (debounce window, source priority, update count) in PaymentCoordinator to prevent duplicate/race-condition updates. - Cascading Updates: Payment completion triggers PurchaseRequest status change + bulk SellerOffer status updates + FundsLedgerEntry deduction (AML fee). Choreography-based; at risk of partial failure.
- In-Memory Caching:
confirmationThresholdServicemaintains 30s TTL cache of ConfigSetting values; invalidated on write. - Idempotency Patterns:
FundsLedgerEntry.idempotencyKey: Unique index prevents duplicate ledger entries.Paymentunique index on(buyerId, purchaseRequestId, provider, direction, status)for query-level dedup on pay-in intent creation.DerivedDestinationunique on(buyerId, sellerOfferId, chainId)with 11000 error race handling.
- Atomic Counters:
Counter.findByIdAndUpdatewith$inc: {seq}for allocation of unique derivation indices. - Unbounded Metadata:
Payment.metadata(BSON) holds provider-specific blobs (shkeeperData, requestNetworkData, blockchain logs, callbackData); no schema validation. - Webhook Coordination: Request Network + AMN Scanner webhooks update DB independently. PaymentCoordinator guards via Redis debounce window and source priority rules.
- No Explicit Transactions: All updates are single-doc (
findByIdAndUpdate) or choreographed multi-doc operations without ACID guarantees. Cascade failures (e.g., payment updated but PurchaseRequest not) are possible. - Nested Field Updates: Frequent use of
$set: {blockchain.transactionHash, blockchain.network, ...}for nested document updates; ensures no subdocument loss (as noted in code comments). - Template Checkout Pattern: String IDs (not ObjectIds) used for synthetic purchase requests; Payment model fields marked Mixed to accept both types.
points service
Database Operations
| File | Function | Model | Operation | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| PointsService.ts | generateReferralCode | User | findOne | none | Check code uniqueness during generation loop |
| PointsService.ts | generateReferralCode | User | findByIdAndUpdate | none | Assign referralCode to user |
| PointsService.ts | addPoints | User | findById | session | Fetch user within Mongo transaction session |
| PointsService.ts | addPoints | PointTransaction | create | session | Create transaction record in array form, with session |
| PointsService.ts | addPoints | PointTransaction | save | session | Save transaction with metadata updates in session |
| PointsService.ts | addPoints | User | save | session | Save user with incremented points in transaction |
| PointsService.ts | redeemPoints | User | findById | session | Fetch user for points deduction within session |
| PointsService.ts | redeemPoints | PointTransaction | create | session | Create spend transaction in session |
| PointsService.ts | redeemPoints | User | save | session | Save user with decremented points in transaction |
| PointsService.ts | updateUserLevel | User | findById | session | Fetch user, supports optional session parameter |
| PointsService.ts | updateUserLevel | LevelConfig | find | none | Fetch active levels sorted by minPoints |
| PointsService.ts | updateUserLevel | User | save | session | Conditionally save user if level changed |
| PointsService.ts | getUserPoints | User | findById | none | Fetch user and lazy-initialize missing fields |
| PointsService.ts | getUserPoints | User | save | none | Conditionally save if fields initialized |
| PointsService.ts | getUserPoints | LevelConfig | findOne | none | Fetch current level config by level number |
| PointsService.ts | getUserPoints | LevelConfig | findOne | none | Fetch next level config |
| PointsService.ts | getTransactions | PointTransaction | find | populate, lean | Paginated transaction query with user details JOIN and read-only optimization |
| PointsService.ts | getTransactions | PointTransaction | countDocuments | none | Count transactions for pagination |
| PointsService.ts | getReferrals | User | find | lean | Paginated referral list with projection, read-only |
| PointsService.ts | getReferrals | User | countDocuments | none | Count referrals for pagination |
| PointsService.ts | getReferrals | PurchaseRequest | find | populate, $in | Find delivered/completed orders per referral with selectedOfferId JOIN |
| PointsService.ts | getReferrals | PointTransaction | aggregate | $match, $group, $sum, ObjectId | Aggregate earned points per referral using pipeline operators |
| PointsService.ts | getLevels | LevelConfig | find | none | Fetch active levels sorted by order |
| PointsService.ts | processReferralReward | PurchaseRequest | findById | populate | Fetch purchase with buyerId and selectedOfferId populated |
| PointsService.ts | processReferralReward | User | findById | none | Fetch buyer to check referredBy |
| PointsService.ts | processReferralReward | User | findById | none | Fetch referrer to award points |
| PointsService.ts | processReferralReward | User | countDocuments | none | Count active referrals to update stats |
| PointsService.ts | processReferralReward | User | save | none | Save referrer with updated referralStats |
| PointsService.ts | getLeaderboard | User | aggregate | $match, $project, $sort, $limit | Pipeline: filter users with referrals, project nested stats, sort by referrals/earned, limit results |
Cross-Cutting Concerns
-
Transactions/Sessions: addPoints() and redeemPoints() use mongoose.startSession() + startTransaction(). Both must wrap critical sections with explicit Postgres transactions (BEGIN/COMMIT/ROLLBACK).
-
Nested Objects: User.points (total, available, used, level) and User.referralStats (totalReferrals, activeReferrals, totalEarned) are embedded Mongo objects. Must normalize to separate tables or use Postgres JSONB columns. Recommend separate tables for indexing.
-
populate() References: 5 instances across 4 different foreign key relationships (referredUser, buyerId, selectedOfferId). Each must become an explicit SQL JOIN.
-
Aggregation Pipelines: getReferrals() and getLeaderboard() use MongoDB operators ($match, $group, $sum, $sort, $limit). Translate to SQL GROUP BY, HAVING, ORDER BY.
-
ObjectId Casting: new mongoose.Types.ObjectId(userId) in aggregate pipeline must be removed or replaced with native Postgres ID type.
-
Field Initialization: getUserPoints() lazy-initializes missing user fields. Ensure all users pre-migrated with default points/referralStats or keep upsert pattern.
-
Real-time Notifications: global.io.emit() calls in addPoints() and processReferralReward(). Independent of DB state; no migration impact but timing may shift.
-
Idempotency: processReferralReward() called on purchase completion — ensure no duplicate point awards if called multiple times.
redis service
Summary: Pure caching, session, and rate-limiting layer with NO direct MongoDB operations. All data is ephemeral (TTL-based).
Operations: None
This service performs zero database reads/writes. It exclusively uses Redis client operations (GET, SET, DEL, HSET, HGET, SADD, SREM, INCR, EXPIRE, KEYS patterns, etc.).
Cross-Cutting Concerns
- Session Management: User sessions stored with 24h default TTL; active user set tracked; multi-device logout support
- Cache Invalidation: Pattern-based deletion using glob patterns on keys (products:, templates:, categories:*, etc.)
- Rate Limiting: Request counting per endpoint/user/source with configurable windows and max thresholds
- Payment Coordination: Deduplication window logic (max 3 updates per 3-second window per paymentId)
- Wallet Caching: Manual expiration tracking (embedded
expiresAtfield) in addition to Redis TTL - Key Namespacing: All keys use prefixes to avoid collisions (session:, cache:, ratelimit:, payment:*, etc.)
- TTL Patterns: 5min default cache, 24h sessions, 2h wallet cache, 30min monitored wallets, 10min payment coordination, 7d webhook stats
- No Database Dependency: Data stored here is derived from or coordinated with database; never writes to database
telegram service
Operations
| File | Function | Model | Op Type | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| telegramService.ts | createTelegramSession | TelegramSession | findOneAndUpdate | $set, upsert: true, new: true, setDefaultsOnInsert | Upsert session with conditional userId assignment via spread operator. Uses $set for atomicity. Maps to SQL UPSERT. Requires expiresAt index for TTL cleanup. |
| telegramService.ts | linkTelegramUser | TelegramLink | findOne | .exec() context | Check if telegramUserId already linked to different user. Filters: telegramUserId, isActive. Requires unique index on (telegramUserId, isActive). |
| telegramService.ts | linkTelegramUser | TelegramLink | findOneAndUpdate | $set, upsert: true, new: true, setDefaultsOnInsert | Upsert Telegram link by userId with many metadata fields (username, firstName, lastName, languageCode, isPremium, isBot). Maps to SQL UPSERT or INSERT...ON CONFLICT. |
| telegramService.ts | getTelegramLinkForUser | TelegramLink | findOne | .exec() context | Query active link for user. Filters: userId, isActive, status='active'. Maps to SQL SELECT with WHERE clause. |
| telegramService.ts | unlinkTelegramUser | TelegramLink | updateOne | $set, modifiedCount return | Soft delete: isActive=false, status='blocked', blockedAt, blockedReason. Returns modifiedCount > 0 boolean. SQL needs ROWS_AFFECTED equivalent. |
| botService.ts | getLinkedUserId | TelegramLink | findOne | .lean(), .select() projection | Lookup userId by telegramUserId, filters: isActive, status='active'. lean() for read optimization. SELECT userId WHERE telegramUserId=? LIMIT 1. |
| botService.ts | handleStart | User | findById | .lean(), .select() projection | Fetch firstName, role by ObjectId. lean() optimization. SELECT firstName, role WHERE id=?. |
| botService.ts | handleLink | User | findById | .lean(), .select() projection | Fetch firstName, email by ObjectId for account info display. SELECT firstName, email WHERE id=?. |
| botService.ts | handleStatus | User | findById | .lean(), .select() projection | Fetch firstName, role by ObjectId. Part of Promise.all() batch. SELECT firstName, role WHERE id=?. |
| botService.ts | handleStatus | PurchaseRequest | countDocuments | $in operator, status array | Count active requests: buyerId=? AND status IN ['active', 'received_offers', 'in_negotiation', 'payment', 'processing', 'delivery']. Maps to COUNT(*) with IN clause. |
| botService.ts | handleStatus | SellerOffer | countDocuments | none | Count pending seller offers: sellerId=? AND status='pending'. COUNT(*) query. |
| botService.ts | handleStatus | Payment | countDocuments | $or, $in operators | Count pending/processing payments where (buyerId=? OR sellerId=?) AND status IN ['pending', 'processing']. Maps to COUNT(*) with OR and IN. |
| botService.ts | handleRequests | PurchaseRequest | find | .lean(), .select(), .sort(), .limit() | Fetch 5 latest requests for buyer: SELECT title, status, createdAt WHERE buyerId=? ORDER BY createdAt DESC LIMIT 5. |
| botService.ts | handleOffers | SellerOffer | find | .lean(), .select(), .sort(), .limit() | Fetch 5 latest pending offers: SELECT WHERE status='pending' ORDER BY createdAt DESC LIMIT 5. Accesses nested price (amount, currency). |
| botService.ts | handleOffers | PurchaseRequest | aggregate | $match, $count aggregation stage | Pipeline: $match buyerId + status='received_offers', then $count. Returns [{ n: number }]. Simplify to: COUNT(*) WHERE buyerId=? AND status='received_offers'. |
| botService.ts | handlePayments | Payment | find | .lean(), .select(), $or operator, .sort(), .limit() | Fetch 5 latest payments where (buyerId=? OR sellerId=?) ORDER BY createdAt DESC LIMIT 5. Accesses nested amount (value, currency) and direction field. |
| botService.ts | handleSettings | User | findById | .lean(), .select() nested field | Fetch preferences.notifications by userId. SELECT preferences->'notifications' WHERE id=? (requires JSON operators in Postgres). |
| botService.ts | sendTelegramNotification | TelegramLink | findOne | .lean(), .select() projection | Lookup link by telegramUserId, status='active', isActive. SELECT _id, telegramUserId WHERE telegramUserId=? AND isActive AND status='active' LIMIT 1. |
| botService.ts | sendTelegramNotification | TelegramLink | updateOne | $set nested fields, timestamp | Soft-block on 403 bot API error: status='blocked', blockedAt=NOW(), blockedReason='bot_blocked_by_user'. Maps to UPDATE statement. |
Cross-Cutting Concerns
- In-memory replay deduplication: Two Map objects (initDataReplayWindow, webhookReplayWindow) track seen fingerprints and update IDs to prevent replay attacks. Not persisted in DB — session-scoped only. Migration: no DB impact; deduplication logic must remain in application layer or move to Redis for distributed deployments.
- Async webhook dispatch: handleTelegramWebhook returns immediately after queueing bot service dispatch via dynamic import (fire-and-forget). Webhook ACK to Telegram is fast, but DB operations in command handlers (countDocuments, find) execute asynchronously afterward. Migration: ensure connection pooling supports concurrent queries; watch for race conditions on soft-delete operations.
- No explicit transactions: All telegram service DB operations are single-document CRUD — no multi-document transactions. Unique indexes on TelegramLink (userId, telegramUserId) provide constraint safety. Migration: leverage Postgres unique constraints and foreign key relationships instead of Mongoose compound indexes.
- Soft-delete pattern: TelegramLink consistently uses isActive: false + status='blocked' + timestamps (blockedAt, blockedReason) for logical deletion. No hard deletes observed. Migration: maintain NOT NULL constraints on soft-delete markers; ensure cleanup jobs use correct filter (isActive=false AND blockedAt < cutoff_date).
- External bot API state: Telegram Bot API calls (sendBotMessage) occur within handlers; link blocking (status='blocked') is updated in DB only after API call fails (403 or 400 error). Creates eventual consistency risk: blocked users accumulate in DB until next send attempt. Migration: no TX isolation concern, but monitor for stale blocked_at timestamps in production.
- Nested field access: Accesses to nested objects (preferences.notifications as JSON, price as struct, amount.value/.currency) and deeply selected fields require careful mapping. If stored as JSONB in Postgres, use -> or ->> operators; if normalized, update SELECT statements to join or unnest.
trezor service
Models touched: TrezorAccount
Database operations:
| File | Function | Model | Operation | Mongo-specific | Notes |
|---|---|---|---|---|---|
| services/trezor/trezorService.ts | registerTrezorAccount | TrezorAccount | findOneAndUpdate | $set, $setOnInsert, upsert: true, new: true | Upsert creates or updates account. Uses $set for updates and $setOnInsert to initialize nextAddressIndex=1 and initial addresses array with deposit address only on insert. Must map to SQL MERGE or ON CONFLICT INSERT with DEFAULT VALUES. |
| services/trezor/trezorService.ts | allocateTrezorAddress | TrezorAccount | findOne | none | Fetches active account by userId and active=true; reads full document including addresses array for mutation. |
| services/trezor/trezorService.ts | allocateTrezorAddress | TrezorAccount | save | array mutation via push(), auto-increment via += | In-memory mutation of addresses array (push new address) and nextAddressIndex scalar (+=1), then persist via save(). Maps to SQL UPDATE with JSON array append or separate INSERT into addresses junction table. |
| services/trezor/trezorService.ts | verifyTrezorOperationSignature | TrezorAccount | findOne | none | Reads active account by userId+active flag for signature verification; read-only. |
| services/trezor/trezorRoutes.ts | GET /account endpoint | TrezorAccount | findOne | .lean() | Retrieves account status as plain object (no Mongoose overhead). Returns xpubFingerprint, registrationAddress, basePath, nextAddressIndex, and addresses length. |
Cross-cutting concerns:
- Transactions: None. All operations are single-document; no multi-document transactions detected.
- Array handling: addresses is an embedded array within TrezorAccount. In allocateTrezorAddress, the array is mutated in memory and persisted via save(). Migration requires normalization: either use JSON operators in SQL or create a separate trezor_addresses junction table.
- Indexes: Compound index (userId, active) used in all active account lookups. Ensure index exists in Postgres.
- Upsert complexity: registerTrezorAccount uses conditional initialization ($setOnInsert). In SQL, this requires MERGE/INSERT ... ON CONFLICT with separate DEFAULT initialization logic.
- External state: Trezor account references external hardware wallet (xpub, signatures). Application depends on cryptographic validation but no blockchain data is persisted.
- No Redis, no dual-writes, no explicit cache invalidation detected.
user service
| File | Function | Model | Operation | Mongo-Specific | Notes |
|---|---|---|---|---|---|
| userRoutes.ts | POST /admin/create | User | findOne, create, save | none | Email uniqueness check before creation; uses document constructor pattern |
| userRoutes.ts | DELETE /admin/:userId | User | findById, findByIdAndUpdate | none | Soft delete via status='deleted'; requires READ then UPDATE |
| userRoutes.ts | PATCH /admin/:userId/status | User | findById, save | none | In-memory modification then persistence |
| userRoutes.ts | PATCH /admin/:userId/role | User | findById, save | none | Role enum: admin | buyer | seller |
| userRoutes.ts | GET /admin/list | User | find, countDocuments, aggregate | $in, $ne, $regex, $options, $or, $group, $sum | Pagination with skip/limit; sorting; stats aggregation; multiple countDocuments() |
| userRoutes.ts | GET /admin/stats | User | countDocuments (12 calls) | $ne, $gte, $or | Separate calls for total, active, inactive, verified, unverified, by role, by date ranges (last24h, 7d, 30d) |
| userRoutes.ts | GET /admin/:userId | User | findById | none | Field exclusion: -password, -emailVerificationToken, -passwordResetToken |
| userRoutes.ts | PUT /admin/:userId | User | findByIdAndUpdate | $set | Full document update via $set; runValidators enabled |
| userRoutes.ts | PATCH /admin/:userId/password | User | findById, save | none | Hash password (bcryptjs), clear refreshTokens array |
| userRoutes.ts | POST /admin/:userId/resend-verification | User | findById, save | none | Sets emailVerificationCode and expiry; triggers emailService call |
| userRoutes.ts | GET / (list users) | User | find, countDocuments | $in, $ne, $regex, $options, $or | Filters by role/status/search; pagination; sort createdAt |
| userRoutes.ts | GET /contacts | User | find | $in, $ne | Role-based contact filtering; excludes self; status='active' only |
| userRoutes.ts | GET /search | User | find | $or, $regex, $options | Full-text search on firstName, lastName, email; limit 20 results |
| userRoutes.ts | PUT /profile | User | findByIdAndUpdate | $set | Nested profile field updates via dot notation (profile.phone, profile.address.*, etc.) |
| userRoutes.ts | PUT /admin/update/:email | User | findOneAndUpdate | $set | Query by email (not _id); returns updated document |
| userRoutes.ts | GET /profile | User | findById | none | Current user profile fetch with field exclusion |
| userRoutes.ts | GET /profile/:userId | User | findById | none | Public profile with privacy check (profile.isPublic flag) |
| userRoutes.ts | PATCH /wallet-address | User | findByIdAndUpdate | $set | Updates profile.walletAddress, profile.walletType, profile.walletProvider with EVM/TON signature verification |
| userController.ts | createUser | User | findOne, create, save | none | Duplicate email prevention; document constructor pattern |
| userController.ts | deleteUser | User | findById, findByIdAndUpdate | none | Soft delete; prevents self-deletion and admin-deletion |
| userController.ts | updateUserStatus | User | findByIdAndUpdate | none | Updates status and isEmailVerified fields independently |
| userController.ts | toggleUserStatus | User | findById, save | none | Toggles status: active <-> suspended |
| userController.ts | updateUserRole | User | findByIdAndUpdate | none | Enum validation: admin, buyer, seller |
| userController.ts | getUsersList | User | find, countDocuments | $or, $regex, $options | Promise.all batch; lean() optimization |
| userController.ts | getCurrentUserProfile | User | findById, lean | lean | Returns plain JS object (no Mongoose overhead) |
| userController.ts | updateUserProfile | User | findById, findOne, findByIdAndUpdate | $ne | Email uniqueness check excludes current user; triggers emailService |
| userController.ts | resendCurrentUserEmailVerification | User | findById, save | none | Generates new verification code with 15min expiry |
| userController.ts | verifyCurrentUserEmail | User | findOne, save, findById, lean | $gt | Matches by _id, code, and expiry ($gt new Date()); clears verification fields on success |
| userController.ts | getWalletAddress | User | findById, lean | lean | Selects only wallet-related profile fields |
| userController.ts | updateWalletAddress | User | findByIdAndUpdate | none | Updates nested wallet fields; supports TON proof verification |
| userController.ts | getUserDependencies | User | findById, lean, + 5 countDocuments | lean, $or | Counts RequestTemplate, PurchaseRequest (2x with nested 'selectedOffer.sellerId' query), Payment ($or buyerId/sellerId), Chat |
Cross-cutting concerns:
- No explicit transactions or sessions; single-document operations only
- Password hashing: bcryptjs 12 rounds (bcrypt.hash)
- Array operations: refreshTokens array cleared on password reset; passkeys array in User schema (not modified in service layer)
- External integrations: ethers.js for EVM wallet signature verification; TON proof verification via tonProofService; emailService for async email delivery
- Soft deletes via status enum; no hard deletes
- Nested profile object updates via dot notation ($set with dotted keys)
- Field selection/exclusion (select) used consistently for security (never expose password, tokens)
- Lean() optimization for read-only queries in controller methods
- Multiple countDocuments() calls in admin/stats route — not consolidated into single aggregation pipeline
- Cascading dependency checks: getUserDependencies queries 5 other models (RequestTemplate, PurchaseRequest, Payment, Chat) but service does not handle cascade on user deletion
- Email verification code generation (crypto.randomInt 6-digit code); 15-minute expiry
- Wallet type polymorphism: EVM vs TON with different verification strategies (ethers.verifyMessage vs tonProofService)
5. Mongo-specific feature inventories
The cross-cutting features that make this a migration rather than a schema copy. These are the parts that do not translate 1:1 and therefore dominate effort and risk.
Populate Call Inventory and JOIN Migration Map
Summary Statistics
- Total .populate() calls: 133 across 10 service files
- Unique source models: 7 (PurchaseRequest, SellerOffer, Payment, Chat, Dispute, Review, PointTransaction)
- Unique target models: 5 (User, Category, SellerOffer, PurchaseRequest, Chat)
- Nested populate chains: 4 (multilevel joins needed)
- Polymorphic/Mixed fields: 4 (CRITICAL - cannot be simple FK joins)
Populate Calls by Source Model → Target Model
| Source Model | Field | Target Model | Occurrence Count | SELECT Fields | Join Type | Notes |
|---|---|---|---|---|---|---|
| PurchaseRequest | buyerId | User | 15+ | firstName, lastName, email, profile.avatar, profile.walletAddress | FK:1→1 | Simple FK join |
| PurchaseRequest | categoryId | Category | 12+ | name, nameEn, icon, description | FK:1→1 | Simple FK join |
| PurchaseRequest | preferredSellerIds | User | 10+ | firstName, lastName, email, profile.avatar, profile.walletAddress | FK:1→N (array) | Array join to User table |
| PurchaseRequest | selectedOfferId | SellerOffer | 8+ | nested: sellerId, price, deliveryTime, description, buyerId | FK:1→1 (nested to User) | 2-level join: Purchase→Offer→User |
| SellerOffer | sellerId | User | 12+ | _id, firstName, lastName, email, profile, profile.avatar | FK:1→1 | Simple FK join |
| SellerOffer | purchaseRequestId | PurchaseRequest | 8+ | title, description, status, budget | FK:1→1 | Simple FK join |
| Payment | buyerId | User | 6+ | firstName, lastName, email | FK:1→1 | Simple FK join |
| Payment | sellerId | User | 6+ | firstName, lastName, email | FK:1→1 | MIXED type field - string or ObjectId; cannot be pure FK join |
| Payment | purchaseRequestId | PurchaseRequest | – | (not explicitly populated) | Mixed type | MIXED field - string or ObjectId for template checkout |
| Payment | sellerOfferId | SellerOffer | – | (not explicitly populated) | Mixed type | MIXED field - string or ObjectId for template checkout |
| Chat | participants.userId | User | 6+ | firstName, lastName, profile.avatar, email | FK:1→N (nested array) | Array-of-objects join; traverse array then FK |
| Chat | metadata.createdBy | User | 4+ | firstName, lastName | FK:1→1 (nested in object) | Nested object field join |
| Chat | lastMessage.senderId | User | 3+ | firstName, lastName | FK:1→1 (nested in object) | Nested object field join |
| Chat | messages.senderId | User | 1+ | (full message array populate) | FK:1→N (embedded array) | Not a real join - embedded documents, not references |
| Chat | relatedTo (type + id) | PurchaseRequest / SellerOffer / Transaction | 2+ | (not explicitly selected) | Polymorphic | CRITICAL: Mixed discriminator type with untyped id field; cannot be single FK join |
| Dispute | purchaseRequestId | PurchaseRequest | 3+ | title, status | FK:1→1 | Simple FK join |
| Dispute | buyerId | User | 3+ | name, email, profile | FK:1→1 | Simple FK join |
| Dispute | sellerId | User | 3+ | name, email, profile | FK:1→1 | Simple FK join |
| Dispute | adminId | User | 2+ | name, email | FK:1→1 | Simple FK join |
| Dispute | chatId | Chat | 1+ | (full document) | FK:1→1 | Simple FK join |
| Dispute | evidence.uploadedBy | User | implicit | (traversing array) | FK:1→N (array) | Array-of-objects: traverse array then FK |
| Dispute | timeline.performedBy | User | implicit | (traversing array) | FK:1→N (array) | Array-of-objects: traverse array then FK |
| Review | reviewerId | User | 1+ | firstName, lastName, profile.avatar | FK:1→1 | Simple FK join |
| Review | purchaseRequestId | PurchaseRequest | implicit | (foreign key only) | FK:1→1 | Possible backward reference |
| PointTransaction | referredUser | User | 1+ | firstName, lastName, email | FK:1→1 | Simple FK join |
| PointTransaction | user | User | – | (implicit in query, not .populate) | FK:1→1 | Reverse: User → PointTransaction N:1 |
| RequestTemplate | sellerId | User | 9+ | firstName, lastName, email | FK:1→1 | Simple FK join |
| RequestTemplate | categoryId | Category | 9+ | name, nameEn | FK:1→1 | Simple FK join |
| RequestTemplate | preferredSellerIds (if added) | User | – | (not currently used) | FK:1→N (array) | Potential future join |
JOIN Migration Strategy by Pattern
1. Simple FK Joins (1:1 or 1:N flat arrays) — Straightforward SQL JOIN
Migrate to: INNER JOIN user ON purchaseRequest.buyerId = user.id
Examples:
PurchaseRequest.buyerId → UserSellerOffer.sellerId → UserDispute.adminId → UserReview.reviewerId → UserRequestTemplate.categoryId → Category
Action: Direct SQL INNER/LEFT JOIN; no intermediate tables needed.
2. Array-of-ObjectIds (1:N denormalized arrays) — SQL array columns + unnest/join table
Migrate to either:
- PostgreSQL
ARRAYtype with unnest:SELECT * FROM purchase_request WHERE preferredSellerIds && ARRAY['id1', 'id2'](if keeping denorm) - OR junction table:
purchase_request_preferred_sellers(purchase_request_id, user_id)withJOIN
Examples:
PurchaseRequest.preferredSellerIds → User(array of seller IDs)PurchaseRequest.offers → SellerOffer(array of offer IDs)
Action: Decide denormalization vs. normalization; if junction table, map 1:N via new table.
3. Array-of-Objects with Nested FK (1:N embedded objects) — Multiple approaches
Migrate to either:
- Normalize to separate table:
chat_participants(chat_id, user_id, role, joined_at, ...) - Keep embedded JSON column (PostgreSQL jsonb) + query on nested field
Examples:
Chat.participants[].userId → User(each participant has role, joinedAt, etc.)Chat.unreadCounts[].userId → User(each unread count tied to user)Dispute.evidence[].uploadedBy → User(each evidence tied to uploader)Dispute.timeline[].performedBy → User(each timeline action tied to performer)
Action: Extract to junction/detail tables or use JSONB if database supports; explicit FK foreign key constraint.
4. Nested Two-Level Joins — Chain SQL JOINs
Migrate to: JOIN seller_offer ON ... JOIN user ON seller_offer.seller_id = user.id
Examples:
PurchaseRequest.selectedOfferId.sellerId → User(PurchaseRequest → SellerOffer → User)- SQL:
SELECT * FROM purchase_request PR JOIN seller_offer SO ON PR.selectedOfferId = SO.id JOIN user U ON SO.sellerId = U.id
- SQL:
Action: No intermediate table; just chain JOINs with proper ON conditions.
5. CRITICAL: Polymorphic/Discriminator Fields — Cannot be simple FK; requires UNION or type-dispatch
Migrate to: Type discriminator column + conditional FK (UNION queries or application-level dispatch)
Examples:
Chat.relatedTohas discriminatortype(PurchaseRequest|SellerOffer|Transaction) + untypedid- Problem: Single
idcolumn cannot have FK to three different tables - Solution 1: Separate FK columns:
related_purchase_request_id,related_seller_offer_id,related_transaction_id(all nullable) - Solution 2: UNION query at application level; query all three tables and filter by type
- Solution 3: Polymorphic junction table (complex, rarely worth it)
- Problem: Single
Action: Refactor to nullable FK columns per type or application-level union query.
6. CRITICAL: Mixed-Type Foreign Keys — Cannot be single SQL column; refactor required
Migrate to: Type checking + application-level coercion or separate columns
Examples:
Payment.purchaseRequestId(sometimes ObjectId, sometimes string for template checkout)Payment.sellerOfferId(sometimes ObjectId, sometimes string for template checkout)Payment.sellerId(sometimes ObjectId, sometimes string for template checkout)
Problem: SQL does NOT allow a single FK column to conditionally reference different types; requires explicit type knowledge.
Solution:
- Option A: Strict validation: convert all strings to ObjectIds at insert time; store single type
- Option B: Separate columns:
purchase_request_id(UUID) vs.template_purchase_request_key(string) - Option C: Polymorphic columns:
purchase_request_ref_type(enum: 'id'|'string') +purchase_request_ref_value(string), validate at application layer
Action: Add data migration to normalize stored types; decide on single canonical reference format (e.g., always UUID).
7. Embedded Arrays (NOT real joins) — Keep as JSON or array
Examples:
Chat.messages[].senderId— messages are embedded in Chat; senderId is a simple FK that can be denormalized to JSONPurchaseRequest.deliveryInfo— complex nested object; can stay as JSONB/embedded
Action: Keep as JSONB column in PostgreSQL or text/JSON in MySQL; no separate table needed.
Nested/Deep Populate Patterns
| Pattern | Source | Path | SQL Equivalent |
|---|---|---|---|
| Two-level | PurchaseRequest | selectedOfferId → sellerId | JOIN SellerOffer ON PR.id=SO.id JOIN User ON SO.sellerId=User.id |
| Array traverse + FK | Chat | participants[].userId | JOIN chat_participants ON chat.id=cp.chat_id JOIN user ON cp.user_id=user.id |
| Nested object + FK | Chat | metadata.createdBy | LEFT JOIN user ON chat.metadata_created_by=user.id or JSONB query |
| Array of objects + FK | Dispute | evidence[].uploadedBy | JOIN dispute_evidence ON dispute.id=de.dispute_id JOIN user ON de.uploaded_by=user.id |
| Polymorphic discriminator | Chat | relatedTo (type + id) | Cannot be single JOIN; requires UNION or type dispatch |
Field Selection Patterns Observed
Most .populate() calls explicitly select subsets of fields:
- User:
firstName, lastName, email, profile.avatar, profile.walletAddress(avoiding password, sensitive data) - Category:
name, nameEn, icon(lightweight; skip description for lists) - SellerOffer:
price, deliveryTime, description, sellerId(denormalized for response) - Chat: implicit inclusion of all message objects (no field selection; embedded)
Migration note: Translate .populate("field", "a b c") field selection to SQL SELECT a, b, c projection.
Populate Locations by Service File
| File | Total Calls | Primary Pattern | Notes |
|---|---|---|---|
| PurchaseRequestService.ts | 12+ | buyerId, categoryId, preferredSellerIds, selectedOfferId | Complex filtering + nested joins |
| SellerOfferService.ts | 15+ | sellerId, purchaseRequestId | Bulk operations; repeated pattern |
| marketplaceController.ts | 8+ | purchaseRequestId, sellerOfferId, buyerId, sellerId | API response assembly |
| marketplace/routes.ts | 25+ | buyerId, categoryId, sellerId, selectedOfferId | Largest concentrator of populates |
| ChatService.ts | 8+ | participants.userId, metadata.createdBy, lastMessage.senderId, messages.senderId | Nested object fields; embedded array |
| paymentController.ts | 8+ | buyerId, sellerId | Two FK references per payment |
| paymentService.ts | 4+ | buyerId, preferredSellerIds | Dashboard query patterns |
| RequestTemplateService.ts | 12+ | sellerId, categoryId, preferredSellerIds (if applicable) | Template queries; same schema as PurchaseRequest |
| DisputeService.ts | 6+ | buyerId, sellerId, adminId, purchaseRequestId, chatId | Multi-party references |
| PointsService.ts | 3+ | referredUser, selectedOfferId, buyerId | Transactional queries |
| Delivery/reviewRoutes | 5+ | reviewerId, sellerId, buyerId | Lightweight populates |
Flags & Warnings for Migration
| Flag | Component | Severity | Issue | Mitigation |
|---|---|---|---|---|
| ⚠️ POLYMORPHIC | Chat.relatedTo | CRITICAL | Discriminator field type + untyped id cannot map to single FK | Refactor to nullable FK columns per type or union query |
| ⚠️ MIXED TYPE | Payment.purchaseRequestId, sellerOfferId, sellerId | CRITICAL | Mixed ObjectId/string values break SQL type safety | Normalize all to UUID; add migration script |
| ⚠️ ARRAY FK | PurchaseRequest.preferredSellerIds, offers | HIGH | Array of ObjectIds requires unnest/junction table decision | Create junction table or use PostgreSQL ARRAY type |
| ⚠️ NESTED OBJECTS | Chat.participants[], evidence[], timeline[] | HIGH | Objects with nested FKs require new tables or JSONB | Extract to tables or use JSONB with indexed queries |
| ⚠️ EMBEDDED ARRAY | Chat.messages[] | MEDIUM | Embedded documents not real joins; stays as JSONB/array | No action; keep as embedded JSON |
| ⚠️ DEEP CHAIN | PurchaseRequest.selectedOfferId.sellerId | MEDIUM | Two-level populate; two SQL JOINs needed | Chain JOINs; test performance with indexes |
| ✅ Simple FK | 60% of calls | LOW | Direct 1:1 references; straightforward LEFT/INNER JOINs | Standard migration; add foreign key constraints |
Recommendations for Migration
-
Phase 1: Normalize Mixed-Type Fields
- Audit all Payment records; convert strings to UUIDs or vice versa
- Add
purchase_request_id_typediscriminator column temporarily - Create migration script to enforce single type (UUID preferred)
- Update schemas: remove Mixed type, use strict ObjectId or string (with validation)
-
Phase 2: Extract Polymorphic Chat.relatedTo
- Create three nullable FK columns:
related_purchase_request_id,related_seller_offer_id,related_transaction_id - Add CHECK constraint: exactly one FK must be non-null
- Update ChatService query logic to build UNION or conditional JOINs
- Create three nullable FK columns:
-
Phase 3: Create Junction Tables for Arrays
purchase_request_preferred_sellers(purchase_request_id, user_id, created_at)purchase_request_offers(purchase_request_id, seller_offer_id)(if not already normalized)chat_participants(chat_id, user_id, role, joined_at, last_seen, is_active, left_at)dispute_evidence(dispute_id, uploader_id, type, url, description, uploaded_at)dispute_timeline(dispute_id, performed_by_id, action, performed_at, details)- Update service queries to use JOINs instead of populate
-
Phase 4: Deep-Level JOIN Testing
- Test performance of
PurchaseRequest → SellerOffer → Userchain - Add composite index:
(purchase_request.selected_offer_id, seller_offer.seller_id) - Verify EXPLAIN plans; use ANALYZE
- Test performance of
-
Phase 5: Backward Compatibility
- Keep MongoDB coexisting during transition
- Add dual-write logic in service layer (write to both MongoDB and SQL)
- Validate consistency; run data sync verification queries
Cost Estimate for JOIN Refactoring
| Task | Scope | Est. Effort | Notes |
|---|---|---|---|
| Schema normalization (arrays→tables) | 5 junction tables | 3–5 days | Data migration + foreign key constraints |
| Polymorphic Chat.relatedTo refactor | 1 model + service logic | 2–3 days | SQL UNION or conditional branch; testing |
| Mixed-type Payment fields | 1 model + validation | 1–2 days | Data audit + migration script |
| Service layer rewrites (populate→JOIN) | 10 service files | 5–7 days | Update all query builders; test each |
| Index tuning + EXPLAIN analysis | All JOIN queries | 2–3 days | Performance testing; composite indexes |
| Total | — | 13–20 days | Assumes 1–2 developers; parallel work possible |
Backend .aggregate() Pipeline Inventory
| # | File | Line | Model | Purpose | Stages Used | Cross-Collection | SQL Equivalent | Effort Flag |
|---|---|---|---|---|---|---|---|---|
| 1 | ChatService.ts | 678 | Chat | Total unread message count per user | $match, $unwind, $match, $group | No | GROUP BY user, SUM(count) | Low |
| 2 | FundsLedgerService.ts | 134 | FundsLedgerEntry | Ledger entries grouped by type | $match, $group, $project | No | GROUP BY entryType, SUM(amount), COUNT(*) | Low |
| 3 | PaymentService.ts | 492 | Payment | Total payment stats (count, amount) | $match, $group | No | GROUP BY NULL, SUM(amount), COUNT(*) | Low |
| 4 | PaymentService.ts | 502 | Payment | Payment status distribution | $match, $group | No | GROUP BY status, COUNT(*) | Low |
| 5 | ReportService.ts | 233 | FundsLedgerEntry | Ledger entries by payment ID | $match, $group, $addToSet | No | GROUP BY paymentId, COUNT(*), collect_set(entryType) | Low |
| 6 | routes.ts (marketplace) | 531 | PurchaseRequest | Purchase request status counts | $match, $group | No | GROUP BY status, COUNT(*) | Low |
| 7 | RequestTemplateService.ts | 26 | RequestTemplate | Sellers with templates (cached, public only) | $match, $group, $lookup, $unwind, $lookup, $unwind, $match, $project, $sort | Yes (users, shopsettings) | LEFT JOIN users ON sellerId, LEFT JOIN shopsettings, GROUP BY sellerId, collect_set(template) | HIGH |
| 8 | RequestTemplateService.ts | 168 | RequestTemplate | Seller details with filtered templates | $match, $group, $lookup, $unwind, $lookup, $unwind, $project | Yes (users, shopsettings) | LEFT JOIN users ON sellerId, LEFT JOIN shopsettings ON sellerId, GROUP BY sellerId | HIGH |
| 9 | RequestTemplateService.ts | 737 | RequestTemplate | Template usage stats | $match, $group | No | GROUP BY sellerId, SUM(usageCount) | Low |
| 10 | reviewRoutes.ts | 50 | Review | Review statistics (count, avg, distribution by rating) | $match, $group, $sum with conditionals | No | GROUP BY subjectId, COUNT(*), AVG(rating), SUM(CASE rating=1), SUM(CASE rating=2)... | Low |
| 11 | SellerOfferService.ts | 480 | SellerOffer | Offer statistics by status | $match, $group | No | GROUP BY status, COUNT(*), AVG(price) | Low |
| 12 | userRoutes.ts | 339 | User | Total user count | $group | No | SELECT COUNT(*) | Low |
| 13 | DisputeService.ts | 403 | Dispute | Dispute distribution by category | $match, $group | No | GROUP BY category, COUNT(*) | Low |
| 14 | DisputeService.ts | 407 | Dispute | Dispute distribution by priority | $match, $group | No | GROUP BY priority, COUNT(*) | Low |
| 15 | botService.ts | 360 | PurchaseRequest | Count requests with received offers | $match, $count | No | SELECT COUNT(*) FROM ... WHERE ... | Low |
| 16 | PointsService.ts | 325 | PointTransaction | Total earned points per referral | $match, $group | No | GROUP BY user+referredUser, SUM(amount) | Low |
| 17 | PointsService.ts | 435 | User | Referral leaderboard top 10 | $match, $project, $sort, $limit | No | SELECT * FROM User WHERE totalReferrals > 0 ORDER BY totalReferrals DESC, totalEarned DESC LIMIT 10 | Low |
Pipeline Complexity Breakdown
Highest Complexity (Lines 26 & 168 - RequestTemplateService):
- Multiple $lookup stages against different collections (users, shopsettings)
- Conditional field projection with $ifNull chains
- $map operations with nested $cond and $let
- Complex string concatenation and regex matching
- Multiple $match stages (before and after joins)
- Recommendation: Consider materializing user/shop profiles or caching to reduce cross-collection overhead
Medium Complexity (Line 50 - ReviewRoutes):
- Conditional aggregation ($sum with $cond) for rating distribution buckets
- Single $group stage but multiple counter fields
- Clean and maintainable pattern
Low Complexity (12 pipelines):
- Simple GROUP BY patterns
- Basic $match + $group + $sum/$count
- No cross-collection dependencies
- High performance potential
Multi-Document Atomicity Inventory
1. Points Service (Transactional Sites)
| Location | Pattern | Mutates | Consistency Requirement | Status |
|---|---|---|---|---|
/src/services/points/PointsService.ts:47-113 |
addPoints() |
User.points (total, available, level) + PointTransaction (create) + LevelConfig (read) | User balance + audit trail must be consistent; must not earn points without recording transaction | ✅ Transactional (startSession + commit/abort) |
/src/services/points/PointsService.ts:123-167 |
redeemPoints() |
User.points (available, used) + PointTransaction (create) | Points must be deducted atomically; cannot spend without recording spend event | ✅ Transactional (startSession + commit/abort) |
/src/services/points/PointsService.ts:172-197 |
updateUserLevel() |
User.points.level + LevelConfig (read) | Level must reflect total points; recalculated on every addPoints/redeemPoints within parent transaction | ⚠️ Optional session parameter — if undefined, behaves non-transactionally (line 173: .session(session || null)) |
/src/services/points/PointsService.ts:372-429 |
processReferralReward() |
Referrer.referralStats (activeReferrals, totalEarned) + calls addPoints() for points transaction | Referrer stats must stay consistent with actual referrals; addPoints is transactional but referralStats update at line 412-413 is separate (unprotected) | ⚠️ Partial — points mutation is transactional; referral stats update is not |
2. Payment & Funds Ledger (No Explicit Transactions)
| Location | Pattern | Mutates | Consistency Requirement | Status |
|---|---|---|---|---|
/src/services/payment/paymentCoordinator.ts:156-272 |
executePaymentUpdate() |
Payment (status, escrowState, completedAt, metadata) + validates dispute hold + optionally appends FundsLedgerEntry (line 247) | Status transition must be gated by dispute check; AML fee ledger entry must be created after payment confirmed (idempotency key ensures no double-charge, line 259) | ⚠️ Partial — validates before update but no transaction wrapping both operations. Validation and update are separate. If update succeeds and ledger fails, inconsistent state. |
/src/services/payment/ledger/fundsLedgerService.ts:164-215 |
appendFundsLedgerEntry() |
FundsLedgerEntry (create-only, immutable after save) | Funds ledger is append-only; idempotencyKey unique constraint prevents double-entry; entry must be created before any payout is allowed | ✅ Immutable-by-schema (line 93-107 pre-save hook blocks updates/deletes); idempotency enforced by unique index (line 88); but creation is NOT transactionally coordinated with Payment state |
/src/services/payment/orchestration/releaseRefundService.ts:57-118 |
buildReleaseRefundInstruction() + confirmReleaseRefundInstruction() |
Payment (read for amount) + optionally FundsLedgerEntry (create at line 102-114 if releaseLedgerEnforcement=true) | Available balance must be verified before payout; ledger entry must be created atomically with payout confirmation; transactionHash is idempotency key | ⚠️ Partial — ledger validation is inline (validateReleaseAvailability) but creates entry AFTER adapter confirms, not within same transaction. If ledger write fails after adapter succeeds, inconsistent. |
3. Dispute Hold & Release Blocking
| Location | Pattern | Mutates | Consistency Requirement | Status |
|---|---|---|---|---|
/src/services/dispute/releaseHoldService.ts:20-54 |
raiseDispute() |
PurchaseRequest (disputeRaised=true, holdUntil=+14d) + Payment.updateMany() for all related payments | Dispute flag and payment hold must be raised together; cannot have payment under dispute without PR showing dispute | ⚠️ Race condition — two separate writes (findByIdAndUpdate + updateMany). Between them, a concurrent payment update could slip through. No transaction. |
/src/services/dispute/releaseHoldService.ts:60-89 |
resolveDispute() |
PurchaseRequest (disputeResolved=true, clear holdUntil) + Payment.updateMany() | Clearing dispute on PR must atomically clear all payment holds | ⚠️ Race condition — same issue as raiseDispute |
/src/services/payment/paymentCoordinator.ts:163-176 |
executePaymentUpdate() — dispute gate |
Payment (blocked from completed/refunded/released transitions) if disputes are active | Cannot transition to money-out states while dispute active | ⚠️ TOCTOU — reads dispute state (line 170: isReleaseBlockedById), then updates Payment (line 198). Between check and update, dispute could be resolved (check becomes stale). |
4. Derived Destination Address Allocation (Atomic Counter)
| Location | Pattern | Mutates | Consistency Requirement | Status |
|---|---|---|---|---|
/src/services/payment/wallets/derivedDestinations.ts:46-53 |
getNextDerivationIndex() |
Counter (seq field, $inc atomicity) | Counter must never reuse index; index allocation must be sequential and non-repeating | ✅ Atomic $inc, but... |
/src/services/payment/wallets/derivedDestinations.ts:113-150 |
getDestinationFor() |
Counter (via getNextDerivationIndex) + DerivedDestination (create) | Index allocation and destination persist must be atomic; if allocation succeeds but persist fails, index is lost | ⚠️ Race condition — Counter.findByIdAndUpdate returns new seq, then DerivedDestination.create() is separate. If create() fails (unique constraint), the seq is wasted; another concurrent call races to create the same destination with the next index. No transaction wrapping both. |
5. Sweep State Machine (Destination Sweep Operations)
| Location | Pattern | Mutates | Consistency Requirement | Status |
|---|---|---|---|---|
/src/services/payment/wallets/sweepService.ts:720-727 |
Sweep execution with state transitions | DerivedDestination (status: active→sweeping→swept, sweepCount $inc, totalSwept) | Status must transition atomically: active→sweeping before broadcast, then sweeping→swept after confirmation; cannot lose sweep state | ⚠️ Race condition — status is set to 'sweeping', then tx is broadcast, then status is set to 'swept'. Between any two transitions, another process could see inconsistent state. No transaction. On PostgreSQL, $inc is not atomic. |
Atomicity Risk by Database
MongoDB (Current)
| Risk Level | Site | Issue | Mitigation |
|---|---|---|---|
| 🔴 High | Dispute raise/resolve | Two updateMany calls, race window | Needs transaction wrapper |
| 🔴 High | Payment release/refund + ledger | Ledger append after payout confirmation | Needs transaction wrapper |
| 🟠 Medium | Derived destination counter + create | Index allocation may be wasted on create() failure | Needs transaction wrapper |
| 🟠 Medium | Referral rewards | Points atomic, stats update not | Needs transaction for referrer stats update |
| 🟡 Low | Points level update | Optional session, may not roll back with parent | Needs mandatory session propagation |
| 🟡 Low | Sweep state machine | Status transitions not atomic | Needs transaction wrapper |
PostgreSQL (Migration Risk) — All Above + More
- $inc atomicity gone: Counter, sweepCount, totalSwept all become non-atomic. Need explicit locks or CTEs.
- No transactions at all: All 9 sites require transaction refactoring.
- Derived destination allocation: Becomes a critical distributed counter problem; needs RETURNING clause + FOR UPDATE lock.
- Dispute gate in payment update: Needs SELECT...FOR UPDATE of PurchaseRequest within payment transaction.
Critical Paths for ACID Migration to PostgreSQL
Priority 1: Money Flows
- Payment completion + dispute gate: Wrap in transaction (SELECT PurchaseRequest FOR UPDATE, check dispute, UPDATE Payment, append ledger)
- Release/refund + ledger: Wrap payout confirmation and ledger entry in transaction
- Referral rewards: Wrap referrer stats update in points transaction
Priority 2: State Consistency
- Dispute raise/resolve: Wrap PurchaseRequest + Payment.updateMany in single transaction
- Derived destination counter: Wrap index allocation + destination create in transaction (use RETURNING or LOCK)
- Sweep state transitions: Wrap all three status changes in transaction
Priority 3: Idempotency & Recovery
- All transactional operations should use idempotency keys (already present in ledger, payment confirmation)
- Add idempotency keys to dispute raise/resolve and referral reward processing
Schema-Level Migration Hazards Inventory
| Model | Field | Type | Location | Hazard | Root Cause | PG Migration Strategy |
|---|---|---|---|---|---|---|
| Payment | purchaseRequestId | Mixed | Line 7 | Accepts ObjectId or string | Template checkout support | BIGINT + CHAR(1) type column (T=template, P=purchase_request) |
| Payment | sellerOfferId | Mixed | Line 13 | Accepts ObjectId or string | Template checkout support | BIGINT + CHAR(1) type column (T=template, O=seller_offer) |
| Payment | sellerId | Mixed | Line 26 | Accepts ObjectId or string | Template sellers can be ephemeral | BIGINT seller_id + CHAR(1) seller_type + FK to users |
| Payment | shkeeperData | Mixed | Line 134 | Unstructured provider JSON | SHKeeper webhook/response blobs | JSONB column shkeeper_data with versioned JSON schema |
| Payment | requestNetworkData | Mixed | Line 146 | Unstructured provider JSON | Request Network API responses | JSONB column request_network_data with schema validation |
| Payment | webhookPayload | Mixed | Line 150 | Unstructured provider webhook | Generic provider webhook storage | JSONB webhook_payload with trigger-based provider type validation |
| Payment | transactionSafety | Mixed | Line 151 | Unstructured safety check data | Risk scoring/validation metadata | JSONB transaction_safety_metadata with schema version tracking |
| Payment | inHouseCheckout | Mixed | Line 162 | Unstructured AMN scanner data | AMN in-house checkout metadata | JSONB amn_scanner_checkout_data with structure validation |
| Notification | metadata | Mixed | Line 50 | Category-specific context | Arbitrary notification enrichment | JSONB with per-category schema (purchase_request, offer, payment, delivery, system) |
| FundsLedgerEntry | purchaseRequestId | Mixed | Line 30 | ObjectId or string (immutable) | Audit log immutability + legacy strings | BIGINT + CHAR(1) type + NOT NULL + UNIQUE index |
| FundsLedgerEntry | paymentId | Mixed | Line 34 | ObjectId or string (immutable) | Audit log immutability + legacy strings | BIGINT + CHAR(1) type + NOT NULL + UNIQUE index |
| FundsLedgerEntry | metadata | Mixed | Line 67 | Entry-type-specific metadata | Flexible audit trail decoration | JSONB per entryType (payment_detected, provider_fee, platform_fee, hold, release, refund, dispute_hold, adjustment) |
| FundsLedgerEntry | createdBy | Mixed | Line 71 | ObjectId or string, optional | Nullable user/system audit trail | NULL-able BIGINT fk_created_by_user_id + FK to users |
| DerivedDestination | sellerOfferId | Mixed | Line 12 | ObjectId or string | Template checkout pattern | BIGINT + CHAR(1) type column in unique index |
| DerivedDestination | metadata | Mixed | Line 60 | Protocol/chain-specific data | Destination tracking enrichment | JSONB with chain-specific schema versioning |
| PurchaseRequest | buyerId (interface only) | ObjectId | string | Line 5 | Interface-schema mismatch | Frontend/API accepts strings, Mongoose rejects | Tighten interface to ObjectId only or extend schema |
| PurchaseRequest | categoryId (interface only) | ObjectId | string | Line 8 | Interface-schema mismatch | Same as buyerId | Tighten interface or add Mixed to schema |
| PurchaseRequest | offers array (interface) | (ObjectId | string)[] | Line 81 | Interface-schema mismatch | Allows template offer strings in TS | Tighten interface or add Mixed support to schema |
| PurchaseRequest | selectedOfferId (interface) | ObjectId | string | Line 82 | Interface-schema mismatch | Same as offers | Tighten interface or add Mixed support to schema |
Recommended PostgreSQL Patterns
Pattern 1: Polymorphic ID References (for purchaseRequestId, sellerOfferId, sellerId, etc.)
-- Use composite approach: BIGINT + type discriminator
ALTER TABLE payments ADD COLUMN purchase_request_id BIGINT;
ALTER TABLE payments ADD COLUMN purchase_request_type CHAR(1); -- 'T' = template string, 'P' = PurchaseRequest._id
ALTER TABLE payments ADD CONSTRAINT chk_pr_type CHECK (purchase_request_type IN ('T', 'P'));
-- When migrating from MongoDB:
-- If original was ObjectId, set purchase_request_type='P' and parse BSON ObjectId to BIGINT
-- If original was string, set purchase_request_type='T' and store hash(string) or reference lookup table
-- Foreign key only for type 'P':
ALTER TABLE payments ADD CONSTRAINT fk_purchase_request
FOREIGN KEY (purchase_request_id, purchase_request_type)
REFERENCES purchase_requests(id)
WHERE purchase_request_type = 'P';
Pattern 2: Provider-Specific JSONB Columns
-- Instead of single 'metadata' Mixed field, separate by provider
ALTER TABLE payments ADD COLUMN shkeeper_data JSONB;
ALTER TABLE payments ADD COLUMN request_network_data JSONB;
ALTER TABLE payments ADD COLUMN amn_scanner_data JSONB;
ALTER TABLE payments ADD COLUMN transaction_safety_metadata JSONB;
-- Add versioned schema validation
ALTER TABLE payments ADD COLUMN shkeeper_schema_version INT DEFAULT 1;
ALTER TABLE payments ADD CONSTRAINT chk_shkeeper_data
CHECK (
(provider != 'shkeeper') OR
(shkeeper_data IS NULL) OR
(jsonb_typeof(shkeeper_data) = 'object')
);
Pattern 3: Audit Log Immutability with Type Tracking
-- FundsLedgerEntry equivalent with immutable pattern
CREATE TABLE funds_ledger_entries (
id BIGSERIAL PRIMARY KEY,
purchase_request_id BIGINT NOT NULL,
purchase_request_id_type CHAR(1) NOT NULL, -- 'I' = immutable string, 'P' = pr_id
payment_id BIGINT NOT NULL,
payment_id_type CHAR(1) NOT NULL, -- 'I' = immutable string, 'P' = payment_id
entry_type VARCHAR(32) NOT NULL,
amount NUMERIC(38,18) NOT NULL CHECK (amount > 0),
currency VARCHAR(10) NOT NULL,
metadata JSONB,
created_by_id BIGINT,
occurred_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT now(),
CONSTRAINT chk_immutable CHECK (false) -- Enforce via trigger instead
);
-- Trigger to prevent updates
CREATE OR REPLACE FUNCTION prevent_funds_ledger_update()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'FundsLedgerEntry documents are immutable';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tg_funds_ledger_immutable
BEFORE UPDATE OR DELETE ON funds_ledger_entries
FOR EACH ROW EXECUTE FUNCTION prevent_funds_ledger_update();
Pattern 4: JSONB with Per-Type Schema Validation
-- Notification metadata with category-specific validation
ALTER TABLE notifications ADD COLUMN metadata JSONB;
CREATE OR REPLACE FUNCTION validate_notification_metadata()
RETURNS TRIGGER AS $$
BEGIN
CASE NEW.category
WHEN 'purchase_request' THEN
-- metadata should have: purchase_request_id, title, status
IF NOT (NEW.metadata ? 'purchase_request_id' AND jsonb_typeof(NEW.metadata->'purchase_request_id') = 'number') THEN
RAISE EXCEPTION 'purchase_request metadata must have purchase_request_id as number';
END IF;
WHEN 'payment' THEN
-- metadata should have: payment_id, status, amount
IF NOT (NEW.metadata ? 'payment_id') THEN
RAISE EXCEPTION 'payment metadata must have payment_id';
END IF;
-- ... other cases
END CASE;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tg_notification_metadata_validate
BEFORE INSERT OR UPDATE ON notifications
FOR EACH ROW EXECUTE FUNCTION validate_notification_metadata();
Migration Priority Order
- High: Payment.purchaseRequestId/sellerOfferId/sellerId → breaks joins, must use discriminator pattern
- High: FundsLedgerEntry immutable IDs → audit trail integrity
- Medium: All provider-specific Mixed fields → enable strict querying and schema validation
- Medium: Notification.metadata → enable safe notification filtering
- Low: Interface-schema mismatches in PurchaseRequest → code correctness, no runtime risk if schema is enforced
Index & Constraint Inventory
Collections with Indexes
| Collection | Fields | Type/Options | Postgres Equivalent | Notes |
|---|---|---|---|---|
| User | email |
UNIQUE, SPARSE | UNIQUE CONSTRAINT + WHERE email IS NOT NULL partial index |
Runtime rebuild in connection.ts to ensure sparse; allows null |
referralCode |
UNIQUE, SPARSE | UNIQUE CONSTRAINT + WHERE referralCode IS NOT NULL |
Only indexed when present | |
role |
B-tree index | CREATE INDEX idx_user_role |
Simple lookup | |
status |
B-tree index | CREATE INDEX idx_user_status |
Simple lookup | |
referredBy |
B-tree index | CREATE INDEX idx_user_referredby |
Foreign key reference | |
points.level |
B-tree index | CREATE INDEX idx_user_points_level |
Nested field | |
authProvider |
B-tree index | CREATE INDEX idx_user_authprovider |
Simple lookup | |
| TempVerification | email |
UNIQUE | UNIQUE CONSTRAINT |
Hard delete constraint |
emailVerificationCodeExpires |
TTL (0 sec) | CREATE FUNCTION + pg_cron job |
NO NATIVE TTL - delete docs when emailVerificationCodeExpires <= NOW() every 1-5 min |
|
| TelegramSession | sessionToken |
UNIQUE | UNIQUE CONSTRAINT |
Session token uniqueness |
telegramUserId |
B-tree (also in field def) | CREATE INDEX idx_telegram_session_userid |
Simple lookup | |
expiresAt |
TTL (0 sec) | CREATE FUNCTION + pg_cron job |
NO NATIVE TTL - delete docs when expiresAt <= NOW() every 1-5 min |
|
(telegramUserId, sessionToken) |
UNIQUE compound | UNIQUE INDEX (telegram_user_id, session_token) |
Composite uniqueness | |
(telegramUserId, isActive, expiresAt) |
Compound B-tree | CREATE INDEX idx_telegram_session_compound |
Query optimization | |
| TelegramLink | userId |
UNIQUE | UNIQUE CONSTRAINT |
One link per user |
telegramUserId |
UNIQUE | UNIQUE CONSTRAINT |
One user per telegram ID | |
(userId, status) |
Compound B-tree | CREATE INDEX idx_telegram_link_compound |
User + status filter | |
| Notification | (userId, createdAt DESC) |
Compound B-tree | CREATE INDEX idx_notification_user_created |
Sort user notifications |
(userId, isRead) |
Compound B-tree | CREATE INDEX idx_notification_user_isread |
Unread message count | |
(userId, category) |
Compound B-tree | CREATE INDEX idx_notification_user_category |
Filter by category | |
relatedId |
B-tree index | CREATE INDEX idx_notification_relatedid |
Entity reference lookup | |
createdAt |
TTL (90 days = 7,776,000 sec) | CREATE FUNCTION + pg_cron job |
NO NATIVE TTL - delete docs when createdAt < NOW() - INTERVAL '90 days' via cron job daily |
|
| Payment | (status, createdAt DESC) |
Compound B-tree | CREATE INDEX idx_payment_status_created |
List by status |
(buyerId, status) |
Compound B-tree | CREATE INDEX idx_payment_buyer_status |
Buyer payments by status | |
(sellerId, status) |
Compound B-tree | CREATE INDEX idx_payment_seller_status |
Seller payments by status | |
blockchain.transactionHash |
Sparse B-tree | CREATE INDEX idx_payment_txhash WHERE blockchain_transaction_hash IS NOT NULL |
Partial/optional field | |
providerPaymentId |
Sparse B-tree | CREATE INDEX idx_payment_provider_id WHERE provider_payment_id IS NOT NULL |
Idempotency correlation | |
(buyerId, purchaseRequestId, sellerOfferId, provider, direction) |
UNIQUE, Partial Filter | UNIQUE INDEX WHERE provider='request.network' AND direction='in' AND status='pending' |
Partial unique - prevents duplicate pending Request Network pay-ins | |
| PointTransaction | (user, createdAt DESC) |
Compound B-tree | CREATE INDEX idx_point_transaction_user_created |
User transaction history |
(type, source) |
Compound B-tree | CREATE INDEX idx_point_transaction_type_source |
Type + source filter | |
expiresAt |
Sparse B-tree | CREATE INDEX idx_point_transaction_expires WHERE expires_at IS NOT NULL |
Optional expiry dates | |
| Review | (subjectType, subjectId, createdAt DESC) |
Compound B-tree | CREATE INDEX idx_review_subject_created |
Recent reviews by subject |
(reviewerId, subjectType) |
Compound B-tree | CREATE INDEX idx_review_reviewer_subject |
Reviewer's reviews by type | |
(subjectType, subjectId, reviewerId) |
UNIQUE compound | UNIQUE INDEX (subject_type, subject_id, reviewer_id) |
One review per reviewer per subject | |
| FundsLedgerEntry | idempotencyKey |
UNIQUE, SPARSE | UNIQUE INDEX WHERE idempotency_key IS NOT NULL |
Idempotent upserts |
(purchaseRequestId, createdAt DESC) |
Compound B-tree | CREATE INDEX idx_funds_ledger_pr_created |
PR ledger history | |
(paymentId, createdAt DESC) |
Compound B-tree | CREATE INDEX idx_funds_ledger_payment_created |
Payment ledger history | |
| RequestTemplate | shareableLink |
UNIQUE | UNIQUE CONSTRAINT |
Public share link |
categoryId |
B-tree index | CREATE INDEX idx_template_category |
Category filter | |
productType |
B-tree index | CREATE INDEX idx_template_producttype |
Type filter | |
isActive |
B-tree index | CREATE INDEX idx_template_isactive |
Active/inactive filter | |
createdAt |
B-tree DESC | CREATE INDEX idx_template_created DESC |
Sort by creation | |
expiresAt |
B-tree index | CREATE INDEX idx_template_expires |
Expiry lookups | |
(sellerId, isActive) |
Compound B-tree | CREATE INDEX idx_template_seller_active |
Seller's active templates | |
(shareableLink, isActive) |
Compound B-tree | CREATE INDEX idx_template_link_active |
Shared link + active | |
(productType, isActive) |
Compound B-tree | CREATE INDEX idx_template_type_active |
Type + active filter | |
(categoryId, productType) |
Compound B-tree | CREATE INDEX idx_template_cat_type |
Category + type | |
| ConfigSetting | key |
UNIQUE | UNIQUE CONSTRAINT |
Configuration key uniqueness |
| LevelConfig | level |
UNIQUE | UNIQUE CONSTRAINT |
Level uniqueness |
minPoints |
B-tree index | CREATE INDEX idx_levelconfig_minpoints |
Points threshold lookup | |
order |
B-tree index | CREATE INDEX idx_levelconfig_order |
Display order | |
isActive |
B-tree index | CREATE INDEX idx_levelconfig_isactive |
Active level filter | |
| ShopSettings | sellerId |
UNIQUE | UNIQUE CONSTRAINT |
One shop per seller |
| Category | name |
UNIQUE B-tree index | CREATE UNIQUE INDEX idx_category_name |
Name lookup |
nameEn |
B-tree index | CREATE INDEX idx_category_name_en |
English name lookup | |
lower(btrim(name)) WHERE is_active = true |
Partial UNIQUE expression index | CREATE UNIQUE INDEX categories_active_name_norm_uq |
Prevent duplicate active visible labels | |
isActive |
B-tree index | CREATE INDEX idx_category_isactive |
Active category filter | |
parentId |
B-tree index | CREATE INDEX idx_category_parentid |
Hierarchy traversal | |
| SellerOffer | sellerId |
B-tree index | CREATE INDEX idx_selleroffer_seller |
Seller's offers |
purchaseRequestId |
B-tree index | CREATE INDEX idx_selleroffer_pr |
PR's offers | |
status |
B-tree index | CREATE INDEX idx_selleroffer_status |
Status filter | |
createdAt |
B-tree DESC | CREATE INDEX idx_selleroffer_created DESC |
Sort by creation | |
| Chat | participants.userId |
B-tree index | CREATE INDEX idx_chat_participant_user |
User's chats |
metadata.lastActivity |
B-tree DESC | CREATE INDEX idx_chat_lastactivity DESC |
Recent chats | |
(relatedTo.type, relatedTo.id) |
Compound B-tree | CREATE INDEX idx_chat_relatedto |
Related entity lookups | |
messages.timestamp |
B-tree DESC | CREATE INDEX idx_chat_message_timestamp DESC |
Recent messages | |
type |
B-tree index | CREATE INDEX idx_chat_type |
Chat type filter | |
| Dispute | purchaseRequestId |
B-tree index | CREATE INDEX idx_dispute_pr |
PR disputes |
buyerId |
B-tree index | CREATE INDEX idx_dispute_buyer |
Buyer disputes | |
sellerId |
B-tree index | CREATE INDEX idx_dispute_seller |
Seller disputes | |
adminId |
B-tree index | CREATE INDEX idx_dispute_admin |
Admin assignments | |
status |
B-tree index | CREATE INDEX idx_dispute_status |
Status filter | |
priority |
B-tree index | CREATE INDEX idx_dispute_priority |
Priority sorting | |
category |
B-tree index | CREATE INDEX idx_dispute_category |
Category filter | |
createdAt |
B-tree DESC | CREATE INDEX idx_dispute_created DESC |
Sort by creation | |
(status, priority DESC) |
Compound B-tree | CREATE INDEX idx_dispute_status_priority DESC |
Triage queue | |
(adminId, status) |
Compound B-tree | CREATE INDEX idx_dispute_admin_status |
Admin workload | |
| PurchaseRequest | buyerId |
B-tree index | CREATE INDEX idx_pr_buyer |
Buyer's requests |
categoryId |
B-tree index | CREATE INDEX idx_pr_category |
Category filter | |
productType |
B-tree index | CREATE INDEX idx_pr_producttype |
Type filter | |
status |
B-tree index | CREATE INDEX idx_pr_status |
Status filter | |
createdAt |
B-tree DESC | CREATE INDEX idx_pr_created DESC |
Sort by creation | |
urgency |
B-tree index | CREATE INDEX idx_pr_urgency |
Urgency sort | |
(productType, status) |
Compound B-tree | CREATE INDEX idx_pr_type_status |
Type + status | |
(categoryId, productType) |
Compound B-tree | CREATE INDEX idx_pr_cat_type |
Category + type | |
| DerivedDestination | buyerId |
B-tree index | CREATE INDEX idx_derived_buyer |
Buyer lookups |
sellerOfferId |
B-tree index | CREATE INDEX idx_derived_offer |
Offer lookups | |
address |
B-tree index | CREATE INDEX idx_derived_address |
Address lookups | |
(buyerId, sellerOfferId, chainId) |
UNIQUE compound | UNIQUE INDEX (buyer_id, seller_offer_id, chain_id) |
One address per buyer-offer-chain | |
(status, chainId) |
Compound B-tree | CREATE INDEX idx_derived_status_chain |
Sweep queries | |
| Address | (userId, primary DESC) |
Compound B-tree | CREATE INDEX idx_address_user_primary DESC |
Primary address first |
| TrezorAccount | userId |
UNIQUE | UNIQUE CONSTRAINT |
One trezor per user |
xpubFingerprint |
B-tree index | CREATE INDEX idx_trezor_xpubfp |
Fingerprint lookup | |
registrationAddress |
B-tree index | CREATE INDEX idx_trezor_regaddr |
Registration tracking | |
active |
B-tree index | CREATE INDEX idx_trezor_active |
Active account filter | |
(userId, active) |
Compound B-tree | CREATE INDEX idx_trezor_user_active |
User's active accounts | |
| BlogPost | slug |
UNIQUE, SPARSE | UNIQUE INDEX WHERE slug IS NOT NULL |
Unique URL slug, optional |
(status, publishedAt DESC) |
Compound B-tree | CREATE INDEX idx_blogpost_status_published DESC |
Published posts | |
(category, status) |
Compound B-tree | CREATE INDEX idx_blogpost_cat_status |
Category filter | |
tags |
B-tree (array) | CREATE INDEX idx_blogpost_tags or GIN for array queries |
Tag filtering | |
(featured, status) |
Compound B-tree | CREATE INDEX idx_blogpost_featured_status |
Featured posts |
TTL Indexes (Require Postgres Scheduled Jobs)
MongoDB's expireAfterSeconds has NO NATIVE POSTGRES EQUIVALENT. Must implement via:
-
TempVerification -
emailVerificationCodeExpires(0 sec, deletes immediately when expired)- Postgres:
DELETE FROM temp_verifications WHERE email_verification_code_expires <= NOW()viapg_cronevery 1 minute
- Postgres:
-
TelegramSession -
expiresAt(0 sec, deletes immediately when expired)- Postgres:
DELETE FROM telegram_sessions WHERE expires_at <= NOW()viapg_cronevery 1 minute
- Postgres:
-
Notification -
createdAt(90 days = 7,776,000 seconds)- Postgres:
DELETE FROM notifications WHERE created_at < NOW() - INTERVAL '90 days'viapg_crondaily at 2 AM
- Postgres:
Implementation in Postgres:
-- Enable pg_cron extension
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- Delete expired temp verifications every minute
SELECT cron.schedule('cleanup-temp-verifications', '* * * * *',
'DELETE FROM temp_verifications WHERE email_verification_code_expires <= NOW()');
-- Delete expired telegram sessions every minute
SELECT cron.schedule('cleanup-telegram-sessions', '* * * * *',
'DELETE FROM telegram_sessions WHERE expires_at <= NOW()');
-- Delete notifications older than 90 days daily at 2 AM
SELECT cron.schedule('cleanup-old-notifications', '0 2 * * *',
'DELETE FROM notifications WHERE created_at < NOW() - INTERVAL ''90 days''');
Partial/Conditional Indexes
Payment Collection - Compound Unique with Partial Filter:
-- Prevent duplicate pending Request Network pay-ins per buyer/session/offer
CREATE UNIQUE INDEX uniq_pending_request_network_by_buyer_session_offer
ON payments (buyer_id, purchase_request_id, seller_offer_id, provider, direction)
WHERE provider = 'request.network'
AND direction = 'in'
AND status = 'pending';
Runtime Index Management (connection.ts)
File: /Users/manwe/CascadeProjects/escrow/backend/src/infrastructure/database/connection.ts
Logic:
- On database connection,
ensureUserEmailSparseIndex()is called - Checks if
users.emailindex exists and is NOT sparse+unique - If index exists but missing sparse flag: drops the old index and recreates with
{ unique: true, sparse: true } - This allows null emails for users with alternative auth (Telegram, Google)
- Postgres Equivalent: Add
WHERE email IS NOT NULLto partial index constraint
Database Connection & Pool Settings
Technology Stack:
- Database: MongoDB (NOT PostgreSQL) via Mongoose ODM
- Pool Configuration (
connection.ts):- maxPoolSize: 10
- serverSelectionTimeoutMS: 5000ms
- socketTimeoutMS: 45000ms
- Error handlers: Connection errors logged, auto-reconnect on disconnect
Connection Details:
- Entry point:
/backend/src/infrastructure/database/connection.ts - Config source:
/backend/src/shared/config/index.ts - URI from env var:
MONGODB_URI(e.g.,mongodb://localhost:27017/amanat-marketplace-dev) - DB name from env var:
DB_NAME(e.g.,amanat-marketplace-dev)
Startup Initialization & Admin Bootstrapping
Admin User Creation Flow:
- Source:
/backend/src/infrastructure/database/init-admin.ts - Trigger: Called in
app.tsduring server boot afterconnectDatabase() - Idempotency: Checks if admin exists before creation (safe for restart)
- Configuration:
- Email from:
ADMIN_EMAILenv var (default:admin@marketplace.com) - Password from:
ADMIN_PASSWORDenv var (required, no default) - First/Last name:
ADMIN_FIRST_NAME,ADMIN_LAST_NAME(Persian defaults: مدیر سیستم)
- Email from:
- Validation: Throws error if
ADMIN_PASSWORDis missing - Index Management: Ensures
users.emailindex is unique and sparse (for optional-email accounts)
Database Initialization Index:
- Email sparse index created/rebuilt in
ensureUserEmailSparseIndex()to allow null emails - Name:
email_1, unique=true, sparse=true
Environment Variables
Critical Auth/DB Vars (from .env.example):
NODE_ENV=production
MONGODB_URI=mongodb://admin:password123@mongodb:27017/marketplace?authSource=admin
DB_NAME=amn-db
ADMIN_EMAIL=manwe@manko.yoga
ADMIN_PASSWORD=change-me-strong-password
JWT_SECRET=change-me-use-a-long-random-secret
JWT_EXPIRES_IN=7d
REFRESH_TOKEN_EXPIRES_IN=30d
REDIS_URI=redis://:redis123@redis:6379
Development Override (.env.development):
MONGODB_URI=mongodb://localhost:27017/amanat-marketplace-devDB_NAME=amanat-marketplace-devREDIS_URI=redis://:localredis@localhost:6379
Docker Container Auth:
MONGO_INITDB_ROOT_USERNAME=adminMONGO_INITDB_ROOT_PASSWORD=password123MONGO_INITDB_DATABASE=marketplace
Seed Scripts Inventory
Location: /backend/src/seeds/
| Script | Purpose | DB Operations | Dependencies |
|---|---|---|---|
seedUsers.ts |
Create 5 test users (admin, support, buyer, 2x seller) | INSERT (delete existing) | None |
seedAddresses.ts |
Create 2-3 addresses per user (9 total) | INSERT (delete existing) | Users must exist |
seedCategories.ts |
Create 23 product categories (Persian/English) | INSERT (delete existing) | None |
seedRequestTemplates.ts |
Create 4 request templates (2 per seller) | UPSERT by shareableLink | Users, Categories |
seedBlogPosts.ts |
Create 3 sample blog posts | INSERT | Admin user required |
seedUsersAndAddresses.ts |
Combined: users + addresses in sequence | INSERT (both deleted) | None |
seedLevels.ts |
Create user levels/badges | INSERT | None |
migrateUserPoints.ts |
Add points/referralCode/stats to existing users | UPDATE (idempotent) | Existing users |
Execution Methods:
# Via npm scripts
npm run seed:users # seedUsers.ts
npm run seed:addresses # seedAddresses.ts
npm run seed:all # seedUsersAndAddresses.ts
npm run seed:categories # seedCategories.ts
# Direct ts-node
ts-node src/seeds/seedUsers.ts
ts-node src/seeds/migrateUserPoints.ts
Maintenance/Utility Scripts Inventory
Location: /backend/src/scripts/
| Script | Purpose | DB Operations | Params |
|---|---|---|---|
makeUserAdmin.ts |
Promote user to admin role | UPDATE role | email (required) |
createTestRequest.ts |
Create 2 test purchase requests | INSERT | None (auto-finds buyer) |
createSupportUser.ts |
Create support@amn.gg admin user | INSERT/UPDATE | None |
createTestMessage.ts |
Create test chat message | INSERT | None |
clearCategories.ts |
Delete all categories | DELETE | None |
clearChats.ts |
Delete all chat records | DELETE | None |
updateCategories.ts |
Update category fields | UPDATE | None |
updateRequestStatus.ts |
Change request status | UPDATE | None |
createDemoShops.ts |
Create shops + products via API | HTTP POST (not direct DB) | BACKEND_URL env var |
createFullDemo.sh |
Bash script: login sellers, create templates via API | HTTP (idempotent) | API_URL env var |
createDemoTemplates.sh |
Create seller templates via API | HTTP | API_URL, seller tokens |
createDemoWithPicsum.sh |
Demo with image generation from picsum.photos | HTTP | API_URL |
createProductionTemplates.sh |
Production-mode template creation | HTTP | API_URL |
deleteSellerTemplates.sh |
Delete all seller templates via API | HTTP DELETE | API_URL, tokens |
updateShopImages.sh |
Update shop images via API | HTTP PATCH | API_URL |
Database Operations Summary for Cutover/Tooling
Read Operations:
User.findOne({ email })— check existenceCategory.find({})— load all categoriesRequestTemplate.find()— load templates by sellerAddress.find({ userId })— user's addresses
Write Operations:
- Seed:
deleteMany()+insertMany()(destructive, idempotent checks before) - Admin Init:
findOne()+ conditionalsave()(safe, checks first) - Migrations:
updateMany(),bulkWrite()with upsert (idempotent by key)
No Migration Framework: Direct Mongoose operations; no version-tracked migrations. Admin must manually run seeds/scripts in order:
- seedCategories (independent)
- seedUsers (independent)
- seedAddresses (requires Users)
- seedRequestTemplates (requires Users + Categories)
Redis Dependency: Separate Redis instance for sessions/cache (not part of MongoDB).
6. Migration strategy, risk register & effort estimate
Mongo → Postgres Migration: Strategy, Risk & Estimate
This section turns the codebase analysis (23 models, 19 service domains, 375 catalogued DB operations, 133 .populate() sites, 18 aggregation pipelines, 85+ indexes, 9 atomicity-sensitive boundaries) into a concrete decision. The headline conclusion: do a hybrid, strangler-style migration that moves the relational/money core to Postgres first and leaves document-shaped, ephemeral, or append-only collections on MongoDB (or Postgres JSONB) until last — or indefinitely. A big-bang rewrite is not justified by the evidence.
Collection classification
The single most important input to the strategy is sorting the 23 models by their shape and consistency requirements, not by row count.
| Class | Collections | Why |
|---|---|---|
| Relational money/core (PG first, full normalization) | User, PurchaseRequest, SellerOffer, Payment, FundsLedgerEntry, Dispute, DerivedDestination, TrezorAccount, ConfigSetting, ConfigSettingHistory, PointTransaction, LevelConfig, Address, Review, ShopSettings, Category | Heavy FK relationships, financial correctness, the only withTransaction sites (PointsService), immutable ledger, escrow/hold invariants, partial-unique idempotency on Payment. These benefit most from ACID + referential integrity. |
| Document-shaped (PG late, or stay JSONB / stay Mongo) | Chat, Notification, BlogPost | Embedded message/participant/reaction arrays, polymorphic relatedTo, Mixed metadata, feed-pagination access patterns. Naturally document-oriented; lowest payoff from normalization. |
| Ephemeral / TTL (low value to migrate; if migrated, needs pg_cron) | TelegramSession, TempVerification, Notification (90-day TTL) | TTL-driven lifecycle. Postgres has no native TTL — every one of these becomes a pg_cron job. Migrating them buys little and adds operational surface. |
| Integration/link (simple, migrate with core) | TelegramLink, RequestTemplate, BlogPost | Mostly scalar, upsert-heavy, few invariants. |
Note that RequestTemplate and BlogPost carry embedded subdocs and aggregation/search usage, so they sit between classes; they are scheduled in a middle phase.
1. Migration strategy options
(a) Big-bang full rewrite to Postgres
Freeze writes, migrate all 23 collections + 85 indexes + 133 populates + 18 pipelines in one cutover, swap Mongoose for an ORM everywhere at once.
| Pros | Cons |
|---|---|
| One mental model afterwards (no dual-stack) | Must rewrite 375 ops, 133 populates, 18 pipelines, 9 atomicity boundaries before any value ships |
| No dual-write complexity | Highest blast radius: a single bug in Payment/FundsLedgerEntry remapping can corrupt money; no incremental confidence |
No long-lived ObjectId↔uuid mapping infrastructure |
Requires extended write-freeze/downtime, or a very large coordinated backfill |
| Mixed-type/polymorphic IDs (Payment, FundsLedgerEntry, Chat.relatedTo, DerivedDestination) must all be resolved simultaneously |
Verdict: rejected. The financial core (immutable ledger, escrow holds, idempotency partial index) and the documented "consistency gaps" mean the cost of getting everything right at once is disproportionate, and there is no migration framework or repository abstraction in place today (all changes go through direct Mongoose calls) — so the team would be building the abstraction and doing the big-bang in the same motion.
(b) Strangler / incremental per-bounded-context with dual-write
Introduce a repository layer per domain, migrate one bounded context at a time, dual-write to Mongo + PG during a soak window, verify, then flip reads.
| Pros | Cons |
|---|---|
| Incremental confidence; money core can be hardened first and proven before anything else moves | Dual-write window requires ObjectId↔uuid mapping tables and idempotent writes on both sides |
| Smaller, reviewable PRs per domain (address, points, payment…) | Temporary complexity: two stores live at once, cross-context reads may span both |
| Rollback per context is cheap (flip reads back) | Cross-domain populates (marketplace: 25+ populate calls touching 9 models) are awkward while the boundary cuts through them |
Verdict: strong candidate, and the recommended mechanism.
(c) Hybrid: keep Mongo for Chat/Notification/sessions + PG for money/relational core
Same strangler mechanics as (b), but with an explicit decision that document-shaped and TTL collections may never move (or move last, lazily).
| Pros | Cons |
|---|---|
| Avoids the worst migrations entirely (Chat embedded arrays, Notification TTL, session TTL → pg_cron) | Permanent two-database operational footprint (Mongo + PG + Redis) |
| Targets PG where it pays: ACID money + referential integrity + relational queries | Some cross-store joins (e.g., Dispute ↔ Chat) become application-level |
| Lowest total effort for the highest correctness payoff | Team must keep Mongoose + an ORM in the codebase indefinitely |
Verdict: recommended.
Recommendation
Adopt (c) as the target architecture, executed with the strangler mechanics of (b).
Reasoning grounded in the findings:
- The only explicit transactions in the codebase are 2
withTransactionsites in PointsService, and the inventory explicitly flags "consistency gaps" and "risk escalates significantly on PostgreSQL migration" for Payment/funds, dispute resolution, and referral flows. Postgres ACID directly fixes this — but only for the core, which is exactly where you want to spend effort. - Chat, Notification, and the TTL/session collections are where PG hurts most: embedded message/participant/reaction arrays, polymorphic
relatedTo, and three TTL indexes with no native PG support. The payoff for normalizing them is low and the rewrite cost is high. Leaving them on Mongo removes the single largest chunk of risky work. - BlogPost and RequestTemplate carry
$regexsearch and aggregation discovery pipelines; they can stay on Mongo or move late behind a search abstraction, decoupled from the money cutover.
So: PG for the relational-core class; Mongo (or deferred) for Chat/Notification/sessions/BlogPost.
2. Recommended phasing
Each phase has explicit entry/exit criteria. The unit of migration is a bounded context, and every context gets a repository interface first so the storage swap is invisible to callers.
Phase 0 — Foundations (no data moves)
- Build the repository/port layer for the relational-core domains (address, points, user, payment, dispute, marketplace-core). Today everything calls Mongoose directly; this phase introduces interfaces so reads/writes can be retargeted per context.
- Stand up Postgres, choose ORM + migration framework (see §6), wire CI, create the
id_mapinfrastructure (legacy_object_id TEXT ⇄ new_uuid UUID, per collection) for FK remapping and dual-write idempotency. - Entry: approved target architecture. Exit: repository layer merged behind feature flags; PG reachable in all envs;
id_mapschema created; row-count + checksum verification harness exists.
Phase 1 — Address (pilot)
- Smallest real domain (10 ops, 1 model, one pre-save invariant: one-primary-per-user). Proves the entire pipeline end to end on something low-risk.
- The pre-save
updateMany"demote other primaries" hook must be reimplemented as a transaction/trigger/app rule — this is the template for porting all hooks. - Entry: Phase 0 exit. Exit: Address fully on PG in prod, dual-write disabled, the primary-address invariant proven under concurrent writes, verification green.
Phase 2 — Reference/config data
- Category (self-referential FK, soft-delete), LevelConfig, ConfigSetting, ConfigSettingHistory, ShopSettings, Review. Mostly scalar, low write volume, few invariants. Builds the seed-in-dependency-order path.
- Entry: Phase 1 exit. Exit: these read from PG; seeds run in PG; FK/unique constraints (e.g.,
ShopSettings.sellerIdunique, CategoryparentIdON DELETE SET NULL, Category active normalized-name uniqueness) enforced.
Phase 3 — User + auth core
- User is the FK hub. Normalize nested
profile/preferences/points/referralStatsand extract thepasskeysandrefreshTokensarrays to child tables. ReimplementtoJSON()password stripping and passkeydefault: Date.now()in app code. Sparse+unique email index → partial unique indexWHERE email IS NOT NULL. - Auth's in-memory passkey challenge store and Redis session/rate-limit stay as-is (Redis is untouched by this migration).
- Entry: Phase 2 exit. Exit: User on PG; referral self-FK intact; auth flows pass;
id_mapfor users authoritative (every downstream FK depends on it).
Phase 4 — Money core (the point of the project)
- PurchaseRequest, SellerOffer, Payment, FundsLedgerEntry, DerivedDestination, TrezorAccount, PointTransaction.
- Resolve all Mixed/polymorphic IDs to
uuid + type discriminator(see §3). Wrap multi-doc writes that today lack transactions (raiseDispute touching PurchaseRequest+Payment; payment + ledger; referral reward) in real PG transactions. Preserve the Payment partial-unique idempotency index. Migrate PointsServicewithTransaction→ PGBEGIN/COMMIT. - TrezorAccount/DerivedDestination embedded address arrays → child tables.
- Entry: Phase 3 exit (User uuids authoritative). Exit: money core on PG, dual-write soak proven (checksums on
funds_ledger_entriestotals), idempotency verified, escrow-hold invariants pass concurrency tests, then read cutover.
Phase 5 — Dispute + delivery
- Dispute embedded evidence/timeline → child tables; pre-save timeline-append hook → explicit INSERT. Delivery
$set/$pushnested updates → SQL. Dispute ↔ Chat becomes a cross-store call (Chat stays on Mongo). - Entry: Phase 4 exit. Exit: dispute lifecycle on PG; release-hold sync transactional.
Phase 6 (optional / deferred) — RequestTemplate, BlogPost
- Move behind a search abstraction; replace
$regexwith PG trigram/FTS only if you choose to migrate. Otherwise leave on Mongo.
Permanent on Mongo (no phase): Chat, Notification, TelegramSession, TempVerification, TelegramLink (link state)
- Document-shaped + TTL-driven. Revisit only if operational cost of dual-stack outweighs migration cost.
Dual-write + backfill + verification + cutover (per collection)
- Backfill: batch-copy Mongo → PG, allocating uuids and recording them in
id_map; remap FKs usingid_mapof already-migrated parents (hence User before money). - Dual-write: repository writes to both stores, keyed through
id_map, idempotently. Mongo remains source of truth for reads. - Verify (continuous during soak): row counts per collection; column-level checksums (esp. ledger sums, payment amounts); shadow reads — serve from Mongo, asynchronously read PG, diff, alert on mismatch.
- Cutover: flip reads to PG behind the flag. Keep dual-write for a soak window so rollback = flip reads back.
- Decommission: stop writing Mongo for that collection; archive.
3. Data modeling decisions
Mixed / polymorphic IDs (the central hazard — 16 hazards across 6 models)
Affected: Payment.{purchaseRequestId, sellerOfferId, sellerId}, FundsLedgerEntry.{purchaseRequestId, paymentId}, DerivedDestination.sellerOfferId, Chat.relatedTo, PurchaseRequest.selectedOfferId/offers.
- Normalize to a single canonical type. Use
uuidcolumns plus an explicit*_typediscriminator only where the reference is genuinely polymorphic.Chat.relatedTo(type + id over PurchaseRequest | SellerOffer | Transaction) →related_type ENUM+ nullable FK columns per type, or keep on Mongo (recommended, since Chat stays).- Payment/FundsLedgerEntry Mixed IDs exist to support template checkouts (string IDs) and providers. Where the value is a real entity ref, store
uuidFK; where it is a free string (template checkout), keep a separateexternal_ref TEXTcolumn rather than overloading one column. Never store "uuid-or-string" in one column under PG.
- Add validation triggers / app-layer guards so a discriminator always matches a populated FK.
Embedded arrays
| Array | Decision | Reason |
|---|---|---|
| Chat.messages / participants / reactions / unreadCounts | Stay JSONB on Mongo (Chat not migrated) | Document-shaped; if ever migrated, normalize to chat_messages, chat_participants child tables. |
| Dispute.evidence[], timeline[] | Child tables (dispute_evidence, dispute_timeline) |
Queried/audited; timeline appended by hook → explicit INSERT. |
| PurchaseRequest.offers / preferredSellerIds | Junction tables (purchase_request_offers, pr_preferred_sellers) |
Array-of-ObjectId; junction gives FK integrity + query flexibility (preferred over PG ARRAY). |
| TrezorAccount.addresses, DerivedDestination sweep history | Child tables | Per-address rows referenced by payments; need indexing. |
| User.passkeys, refreshTokens | Child tables | Append/revoke semantics, lookups. |
| PurchaseRequest.deliveryInfo / serviceInfo, RequestTemplate.* subdocs | Child tables for deliveryInfo/serviceInfo; JSONB acceptable for metadata |
Nested logistics are queried; flat opaque metadata is not. |
| Payment provider metadata, blockchain details, Notification.metadata, PointTransaction.metadata | JSONB | Provider-specific, schema-varying, read whole, not filtered on. Version the JSON shape. |
Rule of thumb: JSONB when read-as-a-blob and never joined/filtered; child table when you query, index, FK, or aggregate it.
TTL indexes (no native PG TTL)
Three TTL collections: TempVerification (minute-level), TelegramSession (minute-level), Notification (90-day). If these move to PG, each needs a pg_cron scheduled DELETE (or a partitioned-table drop for Notification). Recommendation: keep TempVerification/TelegramSession on Mongo (ephemeral, zero relational value); if Notification moves, use pg_cron daily delete or monthly range-partition + drop.
ObjectId → uuid
Generate uuid per row at backfill, persist legacy_object_id in id_map and optionally as a column for traceability. All FK remapping flows through id_map, which is why parent contexts (User) migrate before children (Payment/Dispute).
4. Risk register
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Financial data loss / ledger corruption (Payment, FundsLedgerEntry) | Med | Critical | Immutable ledger preserved; checksum sums of funds_ledger_entries Mongo vs PG every soak cycle; idempotencyKey unique constraint enforced; money core migrated only after pipeline proven on Address/User. |
| Polymorphic/Mixed-ID ambiguity (8 fields incl. Payment, FundsLedgerEntry, Chat) | High | High | Canonical uuid + explicit *_type discriminator; separate external_ref column for template-checkout strings; validation triggers; pre-migration data audit to find which rows are ObjectId vs string. |
| Atomicity gaps becoming inconsistency (9 boundaries; only PointsService transactional today) | High | High | Wrap raiseDispute (PR+Payment), payment+ledger, referral reward, points in PG transactions; this is a correctness upgrade PG enables — explicitly test concurrency. |
| populate → N+1 query regressions (133 sites, 25+ in marketplace) | High | Med | Map populates to JOINs in the repository layer, not per-call lazy loads; load-test marketplace read paths; add composite indexes mirroring Mongo compound indexes. |
| ObjectId → uuid remapping errors (68 relationships) | Med | High | Central id_map; migrate parents before children; FK constraints catch dangling refs at backfill; row-count reconciliation per FK. |
Aggregation pipeline mistranslation (18 pipelines, 4 with $lookup, 1 $facet) |
Med | Med | Rewrite as SQL with golden-output tests (same input → identical aggregate); the $facet/$lookup ones get dedicated review. |
| Downtime at cutover | Low | Med | Dual-write + read-flag = near-zero-downtime; rollback = flip reads back; no global write freeze except possibly final ledger reconciliation. |
| ORM/driver swap regressions (no abstraction exists today) | High | Med | Repository layer in Phase 0 isolates the swap; keep Mongoose live for Mongo-resident collections; feature-flag per context. |
| Query/operator rewrites ($inc, $push, $regex, $or, $in, $exists, dot-notation) | High | Med | Encapsulate in repositories; $inc→UPDATE ... SET x = x + n; $regex→trigram/FTS or ILIKE; soft-deletes→status/deleted_at columns; per-operator test coverage. |
| TTL accumulation if migrated without cron | Med | Med | Keep TTL collections on Mongo; if moved, pg_cron jobs are a hard exit criterion for that phase. |
| Dual-stack operational burden (Mongo + PG + Redis) | High (accepted) | Low | Conscious trade-off in hybrid; revisit Chat/Notification migration later if burden grows. |
| Partial-unique idempotency index loss (Payment pending-RN uniqueness) | Med | High | Recreate as PG partial unique index ... WHERE provider='request.network' AND status='pending'; test duplicate-insert rejection. |
5. Effort & time estimate
Engineer-weeks (one mid/senior backend engineer-week). Ranges reflect genuine uncertainty — the polymorphic-ID and aggregation work is hard to size until the data is audited.
| Workstream | Low | High | Notes / assumptions |
|---|---|---|---|
| Phase 0 foundations (repo layer, ORM, migration fw, id_map, verification harness) | 3 | 5 | No abstraction exists today; this is real greenfield work. |
| Phase 1 Address pilot | 1 | 2 | Includes proving dual-write/backfill/verify mechanics. |
| Phase 2 reference/config (6 models) | 2 | 3 | Low-invariant; Category self-FK + soft-delete. |
| Phase 3 User + auth (nested subdocs, arrays, sparse-unique email) | 3 | 5 | FK hub; passkeys/refreshTokens child tables; toJSON reimpl. |
| Phase 4 money core (7 models, Mixed IDs, transactions, idempotency) | 6 | 10 | Highest-risk; bulk of polymorphic-ID + atomicity work. |
| Phase 5 Dispute + delivery (embedded arrays, hooks) | 2 | 4 | Cross-store Dispute↔Chat. |
| populate → JOIN refactor (cross-cutting, 133 sites) | 3 | 5 | Overlaps phases; marketplace concentration. Aligns with the inventory's 13–20 day estimate. |
| Aggregation pipeline rewrites (18, incl. $lookup/$facet) | 2 | 4 | Golden-output testing. |
| Verification, load-testing, cutover ops per context | 2 | 4 | Shadow-read tooling, soak monitoring. |
| Partial migration — money/relational core only (Phases 0–5 + cross-cutting) | ~16 | ~28 | This delivers the stated goal: ACID for money + relational integrity. |
| Phase 6 RequestTemplate + BlogPost (search/aggregation, $regex→FTS) | 2 | 4 | Optional. |
| Chat + Notification + sessions full migration (embedded arrays, 3 TTL→pg_cron) | 5 | 9 | Only if going full-PG; this is the expensive long tail. |
| Full migration (all 23 collections) | ~23 | ~40 | Plus integration/hardening contingency. |
Assumptions: one focused engineer (parallelize to compress wall-clock, not effort); Redis stays as-is; no schema features added during migration; test coverage built alongside (included). Add ~20% contingency for data-audit surprises in the Mixed-ID fields. Honest take: the partial migration (16–28 eng-weeks) captures ~90% of the value; the full migration's extra 7–12+ weeks mostly buys Chat/Notification normalization that the access patterns don't reward. Recommend stopping at partial.
6. Recommended tooling / approach
ORM choice
| Option | Fit for this codebase | Verdict |
|---|---|---|
| Prisma | Strong TS types, owns migrations, great DX. But: historically awkward with polymorphic relations and partial unique indexes (exactly our Payment idempotency + Mixed-ID cases), and its generated-client transaction ergonomics are coarser. Team already uses Prisma elsewhere per project memory. | Viable, but the polymorphic/partial-index friction is a real cost here. |
| Drizzle | SQL-first, thin, excellent TS inference, trivial partial/composite/WHERE indexes, easy raw SQL for the 18 aggregations and complex JOINs. Lightweight migration tooling (drizzle-kit). |
Recommended. Best match for partial unique indexes, polymorphic discriminators, and pipeline→SQL rewrites where you want control. |
| Kysely | Pure type-safe query builder, total SQL control, no migration framework of its own. | Strong alternative / complement; pair with a dedicated migration tool. Good if the team prefers query-builder over schema-first. |
| TypeORM | Mature, decorators familiar to Mongoose users, but heavier, historically buggy migrations and surprising query generation. | Not recommended for a money-critical migration. |
Recommendation: Drizzle as the primary, with raw SQL for the heavy aggregation pipelines ($lookup/$facet) and partial indexes. If org standardization on Prisma is a hard requirement, Prisma is acceptable but budget extra time for polymorphic-ID and partial-index workarounds.
Migration framework
There is no migration framework today — all changes are direct Mongoose ops. Introduce a real one (drizzle-kit migrations, or Kysely-migrator) with versioned, reversible, CI-checked migrations. Seed scripts must be ported to run in dependency order (User → Category → templates → …) as already noted in the tooling inventory.
Dual-write / backfill mechanics
- Repository layer is the dual-write seam. Each domain repo, behind a feature flag, writes Mongo + PG idempotently through
id_map. - Backfill in batches with checkpointing; allocate uuids, record in
id_map, remap FKs from already-migrated parents. Parents-before-children ordering is enforced by phase order. - Keep writes idempotent (upsert on natural/idempotency keys — Payment idempotencyKey, FundsLedgerEntry idempotencyKey already exist) so backfill + live dual-write can overlap safely.
Verification (three layers, all required before any read cutover)
- Row counts per collection, Mongo vs PG, per FK relationship — catches dropped/dangling rows.
- Checksums — column-level hashes, with special attention to financial sums (
SUMof ledger amounts, payment totals) and the partial-unique idempotency set. - Shadow reads — in production, serve from source-of-truth (Mongo) while asynchronously reading PG, diffing, and alerting on mismatch. A clean shadow-read soak window is the exit criterion for cutover.
Cron / TTL
For any migrated TTL collection: pg_cron jobs are mandatory (minute-level for sessions/temp-verification if moved, daily for 90-day notification cleanup) — recommended instead to leave these on Mongo.
[!note] Provenance Generated 2026-05-31 by a multi-agent scan of
backend/src(23 model agents + 19 service agents + 6 cross-cutting agents + 1 synthesis agent). Per-section markdown is reproduced as authored by the analyzing agents. When code changes, re-run themongo-to-pg-migration-auditworkflow to refresh.