Files
nick-doc/02 - Data Models/MongoDB to PostgreSQL Migration Guide.md

340 KiB
Raw Blame History

title, tags, aliases, created, source, updated
title tags aliases created source updated
MongoDB → PostgreSQL Migration Guide
data-model
migration
mongodb
postgres
mongoose
helper
Mongo to Postgres
DB Migration Guide
Postgres Migration
2026-05-31 backend/src (automated multi-agent scan) 2026-06-01 for backend integrate-main-into-development@6df113d

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@6df113d now contains the first Postgres implementation layer: Drizzle schemas/migrations through 0008, src/db/client.ts, id_map, pg_dualwrite_gaps, repository implementations/factory, backfill/verify scripts, conditional payment_quotes persistence, and aligned purchase/template request budget validation. Backend 2.8.13 also hardens the PurchaseRequest/SellerOffer backfill runner for marketplace-core dry-runs and selected-offer remapping. 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. #1. Executive summary & migration difficulty index — the shape of the problem and a per-collection difficulty rating.
  2. #2. Relationship map — every cross-collection edge and its proposed Postgres modeling (FK / join table / JSONB / child table).
  3. #3. Collection reference (data structures) — full field-by-field schema per collection with a proposed CREATE TABLE.
  4. #4. Database access catalog (functions that read/write) — every service domain's DB operations; this is the port checklist.
  5. #5. Mongo-specific feature inventories — the cross-cutting cost-drivers (joins via populate, aggregations, atomicity, polymorphic ids, indexes, tooling).
  6. #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

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:

  1. 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.
  2. Schema.Types.Mixed / polymorphic ids — notably Payment.purchaseRequestId, sellerOfferId, sellerId which can each be an ObjectId or a string (template checkout). These cannot be plain foreign keys and need a discriminator + typed columns. See #5. Mongo-specific feature inventories.
  3. Embedded arrays — messages in Chat, offers/timeline in PurchaseRequest, evidence/timeline in Dispute — each is a candidate child table vs. JSONB decision.
  4. 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.
  5. 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 email 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 PaymentPurchaseRequest/SellerOffer edges 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 = true to 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

  1. 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.
  2. Enum Values: addressType is case-sensitive ('Home', 'Office', 'Other' — not lowercase). Enforce via enum type or CHECK constraint.
  3. 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)

  1. Slug auto-generation (lines 154172)

    • 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).
  2. publishedAt auto-set (lines 175180)

    • 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): parentIdcategories._id for hierarchical nesting; ON DELETE SET NULL
  • Referenced by PurchaseRequest (1:N): purchase_requests.category_idcategories._id (required)
  • Referenced by RequestTemplate (1:N): request_templates.category_idcategories._id (required)

Indexes

  • idx_categories_name (UNIQUE): optimizes name lookups
  • idx_categories_name_en: optimizes English name lookups
  • idx_categories_is_active: heavily used in filtered queries (active categories only)
  • idx_categories_parent_id: supports tree-building and subcategory queries

Gotchas & Landmines

  1. 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.
  2. Soft-delete pattern: isActive is the primary deletion mechanism; all queries filter isActive: true. Do not physically delete records in PG without migrating dependent logic.
  3. Timestamps immutability: Mongoose auto-sets createdAt once; ensure PG constraints prevent manual updates to createdAt.
  4. Cache invalidation: CategoryService invalidates Redis cache on every mutation; ensure application layer continues to manage this post-migration.
  5. Dual localization: name (local language) and nameEn (English) are always paired; enforce as a unit in API validation.

Migration Strategy

  1. Create categories table with uuid PK and all fields as described.
  2. Add self-referential FK constraint on parent_id with ON DELETE SET NULL.
  3. Add composite index on (order, name) to match Mongoose sort patterns: .sort({ order: 1, name: 1 }).
  4. Migrate all ObjectId values from MongoDB to UUIDs using a UUID generation function or application-layer conversion.
  5. Ensure dependent tables (purchase_requests, request_templates) update their category_id FKs to point to new PG uuid PKs.
  6. 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 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:

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

  2. Virtual participantsCount: Computed dynamically from participants WHERE is_active=true. Query in app or SQL COUNT(id).

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

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

  5. Embedded reactions within messages: If extracted to separate table, backfill carefully; consider denormalization if reaction queries are rare.

  6. Polymorphic relatedTo: Type + ID pair must be validated by application or CHECK constraint. Consider generated column or materialized view.

  7. DocumentArray behavior: Mongoose enforces schema within subdocuments. In PG child table, enforce via NOT NULL, CHECK constraints, and foreign keys.

Migration Path:

  1. Create PG schema with enums and child tables.
  2. Bulk export Mongo docs to NDJSON.
  3. Normalize embedded arrays into child tables.
  4. Resolve polymorphic relatedTo using type discriminator.
  5. Backfill lastMessage from MAX(chat_messages.timestamp) per chat.
  6. Backfill unreadCounts (may be stale; reset post-migration).
  7. Create trigger on chat_messages to update chats.last_activity.
  8. 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

  1. UNIQUE (key) — enforces one-value-per-key constraint; critical for config consistency
  2. INDEX (updatedBy) — speeds up audit trail queries (e.g., "which settings did user X change?")

Gotchas

  • Timestamps auto-managed: Mongoose timestamps: true auto-generates createdAt and updatedAt. In PostgreSQL, use DEFAULT CURRENT_TIMESTAMP and a trigger to auto-update updatedAt on UPDATE.
  • No population: Code uses .lean() queries and does not populate updatedBy. 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

  1. Optional FK with NULL handling: changedBy is 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.

  2. No implicit timestamps: The Mongoose schema has timestamps: false, so no automatic createdAt or updatedAt fields are added. The only temporal field is changedAt.

  3. 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).

  4. Numeric precision: Use numeric(20,8) or similar for oldValue and newValue to safely store any config numeric values without loss of precision. Adjust scale based on config value requirements.

  5. ObjectId to UUID conversion: Both _id and changedBy use MongoDB ObjectId; migrate both as uuid in PostgreSQL for consistency and compatibility with other models.

  6. 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 _id ObjectId → uuid
  • Convert changedBy ObjectId → uuid (or NULL if absent)
  • Map all numeric and string fields directly
  • Preserve NULL values for oldValue and changedBy
  • 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 relationship
    • sellerOfferId — compound key component
    • address — address lookups (optional but recommended)
  • Compound Indexes

    • status, chainId — sweep operation queries
    • buyerId, sellerOfferId, chainId (UNIQUE, sparse: false) — enforces one address per buyer-seller-chain; name: uniq_destination_by_buyer_seller_chain; critical for correctness

Relationships

  1. buyerId → User (N:1)

    • Type: ref ObjectId
    • PG Strategy: FK column (uuid) to users.id; ON DELETE CASCADE
    • Required; indexed
  2. 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 layer
    • metadata — schemaless object
  • No Custom Methods/Hooks: No pre/post save/validate hooks; no virtuals, statics, or instance methods

Migration Gotchas

  1. 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
  2. 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
  3. Balance/Sweep Totals May Overflow Number

    • totalSwept, lastKnownBalance stored 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
  4. External Counter Table

    • Service uses separate DerivedDestinationCounter collection for atomic derivation index allocation
    • Action: Migrate to PG sequence or shared counter table; ensure atomicity preserved
  5. 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:

  1. 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 after Dispute.create() in DisputeService.createDispute().

  2. Timestamps Option: timestamps: true auto-generates and updates createdAt and updatedAt. In PG, use DEFAULT CURRENT_TIMESTAMP for creation and an ON UPDATE trigger or app-layer logic for updates.

  3. 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).

  4. Optional Nested Subdoc: The resolution object 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_resolution with nullable FK to disputes (fully normalized, requires LEFT JOIN).
  5. Enum Validation: Six enum fields (priority, category, status, evidence[].type, resolution.action, resolution.currency) require PG CHECK constraints.

  6. 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 modified
  • pre(['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

  1. 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').
  2. Sparse Unique Index: idempotencyKey is unique but allows nulls. In PG, use UNIQUE WHERE idempotencyKey IS NOT NULL (requires PG 15+).

  3. Metadata as Mixed: Arbitrary JSON; migrate to JSONB. No schema validation in Mongoose — ensure application validates before insert.

  4. Default Date.now on occurredAt: Multiple entries may share exact same timestamp. Verify clock skew in ETL doesn't lose data during bulk insert.

  5. Immutability Hooks: Pre-hooks on 5 different update methods. Reimplements as trigger or application guard (required; not default PG behavior).

  6. Timestamps Always Set: timestamps: true auto-manages createdAt/updatedAt. Post-migration, ensure application does NOT attempt to manually set these.

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

  1. 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.
  2. Soft-delete pattern: isActive defaults true. All production queries filter WHERE isActive = true. No hard deletes observed in code; consider adding a unique index on (level, isActive) or at minimum keep the single-field index on isActive.

  3. Point thresholds: minPoints and maxPoints define a tier range. maxPoints is nullable (optional ceiling). Ensure NOT NULL constraint on minPoints, NULL allowed on maxPoints.

  4. Timestamps: Auto-managed by Mongoose. In PG, use DEFAULT CURRENT_TIMESTAMP and ON UPDATE CURRENT_TIMESTAMP (or trigger).

  5. 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 points
  • LevelConfig.findOne({level: user.points.level, isActive: true}) — lookup current user tier
  • LevelConfig.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.discountPercentdiscount_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 = true unless listing all archived tiers.
  • Use a database trigger or ORM feature to auto-update updated_at on 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

  1. Simple: userId — high-selectivity filter in WHERE clauses
  2. Compound: (userId, createdAt DESC) — fetch sorted by recency (critical for getUserNotifications)
  3. Compound: (userId, isRead) — unread count and filtering
  4. Compound: (userId, category) — filter by event type
  5. Simple: relatedId — polymorphic entity lookup
  6. TTL: createdAt with 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

  1. TTL Deletion (90 days): MongoDB's expireAfterSeconds has no native PostgreSQL equivalent. Implement via:

    • pg_cron extension 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
  2. Polymorphic relatedId: Points to variable entity types without type discriminator. Options:

    • Add entity_type VARCHAR(30) column during migration to track target model
    • Keep as-is if application layer guarantees correctness; document relationship
    • Add CHECK or trigger validation
  3. Mixed/Untyped metadata: Store as JSONB in PG. No application validation in current schema. Document expected structures per category; consider post-migration validation layer.

  4. userId Type Alignment: Verify userId matches users.id type (TEXT vs. UUID). Convert during ETL if needed.

  5. Compound Index Sort Order: (userId, createdAt DESC) requires explicit DESC in PostgreSQL index creation for optimal query plans.

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

  1. Composite: (status ASC, createdAt DESC) — for status-based listing with date sorting
  2. Composite: (buyerId, status) — for buyer payment queries filtered by status
  3. Composite: (sellerId, status) — for seller payment queries filtered by status
  4. Sparse: (blockchain.transactionHash) — for TX hash idempotency lookup
  5. Sparse: (providerPaymentId) — for provider payment ID idempotency lookup
  6. Unique with partial filter (named uniq_pending_request_network_by_buyer_session_offer):
    UNIQUE (buyerId, purchaseRequestId, sellerOfferId, provider, direction)
    WHERE provider = 'request.network' AND direction = 'in' AND status = 'pending'
    
    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.

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: true auto-adds createdAt and updatedAt; configure update triggers in PG
  • Virtuals in serialization: toJSON and toObject include 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 supports WHERE in 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_ref column 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

  1. user (N:1 to User): Required foreign key; primary accessor for transaction history.
  2. order (N:1 to Order): Optional; transaction may be unrelated to orders (e.g., referral bonuses).
  3. referredUser (N:1 to User): Optional; only populated for referral-source transactions.

Indexes

  1. (user, createdAt DESC) — composite index for "get user's transactions, newest first" (main query pattern).
  2. (type, source) — filtering by transaction type and source.
  3. (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 user and referredUser reference 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: true handles 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 15. 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_idusers(id)
categoryId Category N:1 ref ObjectId FK category_idcategories(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_idseller_offers(id), nullable
deliveryInfo.deliveryCodeUsedBy User N:1 ref ObjectId (nested) FK delivery_code_used_byusers(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

  1. Deep Nesting (deliveryInfo, serviceInfo)

    • deliveryInfo has 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.
  2. 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.offers must change to SellerOffer.find({purchaseRequestId: ?}).
    • Verify: Check SellerOffer schema for purchaseRequestId ref.
  3. Polymorphic Refs in Nested Objects

    • deliveryInfo.deliveryCodeUsedBy is 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.
  4. 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.
  5. 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.
    • Recommendation: preferredSellerIds → join table (N:M normalization); specifications → child table (queryable); others → JSONB if lightweight.
  6. Escrow Hold Logic

    • holdUntil, disputeRaised, disputeResolvedAt drive escrow state machine.
    • Action: Index holdUntil for "find holds expiring soon" queries. Consider a derived hold_status column (active, expired, resolved).
    • Risk: State transitions must be atomic; add application-layer transaction handling.
  7. 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_at on row change.
  • Denormalization: Monitor offers array 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

  1. 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).

  2. String Arrays: tags, attachments, images, specifications, and serviceInfo.requirements can use native PostgreSQL ARRAY type (faster queries) or JSONB (more flexible).

  3. Validators: productLink (URL) and email validation currently in Mongoose. Move to application layer or PostgreSQL CHECK constraints.

  4. Unique Shareable Link: Must explicitly create UNIQUE constraint; used for public template sharing.

  5. Polymorphic References: Interface shows sellerId/categoryId can be string, but schema enforces ObjectId. Migration script must convert all IDs to UUIDs consistently.

  6. Compound Index Sort Order: createdAt DESC index requires explicit DESC in PostgreSQL index definition.

  7. TTL Expiration: expiresAt has no native PostgreSQL TTL. Implement application-level cleanup job or use pg_cron extension.

  8. Usage Counter: usageCount requires atomic increment operations; use UPDATE ... SET usageCount = usageCount + 1 in application code.

  9. 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 15 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 } UNIQUEUnique 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

  1. 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
  2. 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.

  3. Enum FieldssubjectType ('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.

  4. Lazy Computed isVerifiedBuyer — This boolean is computed at review creation time based on PurchaseRequest status (lines 169196 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).

  5. Aggregation Queries — The computeStats() function uses MongoDB's aggregation framework to compute avg rating, count, and distribution by rating (lines 3362). These queries are index-friendly in Mongo but must be rewritten for PostgreSQL with GROUP BY, AVG(), COUNT(), and conditional SUMs.

  6. 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_id field 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

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

  2. 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).

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

  4. validUntil Orphan: Field exists but has no TTL index or app-level cleanup documented. Decide: add PG constraint or document app-side expiry check.

  5. AML Flags Dependency: requireAmlCheck and amlBlockOnFailure are paired. Ensure business logic in application layer enforces: if amlBlockOnFailure is true, requireAmlCheck should also be true.

  6. Timestamps: Mongoose's timestamps: true auto-manages createdAt/updatedAt. In PG, use ON INSERT/UPDATE triggers or application code to maintain parity.

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

  • sellerIdUser (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 by unique: true in schema; enforces 1:1 cardinality

Mongoose Features

  • timestamps: true — auto-manages createdAt/updatedAt
  • trim: true on name and description — 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

  1. 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_links JSONB column — more flexible but slower queries
    • Pick denormalized if filtering by social links is expected; JSONB if they're mostly opaque data
  2. 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

  3. Default values in PG: All boolean defaults should map to DEFAULT clauses in the CREATE TABLE; string defaults are optional (app can provide them)

  4. Timestamp auto-update: If using an ORM with ON UPDATE CURRENT_TIMESTAMP, ensure it's explicitly set; many ORMs require explicit configuration

  5. URL validation (optional): Consider CHECK constraints on avatar, coverImage, and social link fields if strict URL format validation is required

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);
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);

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: isActive boolean + status enum (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:

  1. Unique FK + Referential Integrity: userId is both unique and required. Add NOT NULL + REFERENCES constraint with ON DELETE CASCADE.
  2. Soft-Delete Pattern: Both isActive and status exist. Queries use both (e.g., getTelegramLinkForUser filters on userId+isActive+status='active'). Preserve both columns; document as soft-delete when both=false or status='blocked'.
  3. 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.
  4. Compound Index: (userId, status) is used in auth flow (findOne({ userId, status: 'active' })). Replicate exactly.
  5. Enum Types: Use VARCHAR with CHECK constraints (CHECK (status IN ('active', 'blocked'))) for portability, or PostgreSQL ENUM types for stricter control.
  6. Timestamps: Mongoose auto-sets createdAt/updatedAt. Use PG CURRENT_TIMESTAMP defaults; implement a trigger for ON UPDATE updatedAt.
  7. Default Date.now: lastSeenAt defaults to current timestamp; map to CURRENT_TIMESTAMP.
  8. 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

  • userIdUser (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

  1. Timestamps: Mongoose auto-manages createdAt/updatedAt on save
    • PostgreSQL: Add DEFAULT CURRENT_TIMESTAMP and triggers if needed
  2. 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();
    
  3. 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

  1. TTL / Auto-Cleanup: MongoDB's TTL index deletes docs automatically at emailVerificationCodeExpires. PostgreSQL has no native TTL—must use:

    • Preferred: pg_cron extension 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.
  2. Email Uniqueness: Mongoose enforces unique: true and lowercase: true at the schema level. PostgreSQL must use UNIQUE(LOWER(email)) or case-insensitive collation to match.

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

  4. Role Enum: Hardcoded values (buyer | seller) in Mongoose. Use PostgreSQL ENUM('buyer', 'seller') or VARCHAR(10) CHECK (role IN ('buyer', 'seller')) to enforce at DB level.

  5. 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_cron extension if using scheduled cleanup
  • Create user_role ENUM type
  • Create temp_verifications table 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:

  1. 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.
  2. _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.
  3. Date.now() defaults: issuedAt defaults to server-evaluated Date.now(). Use trigger DEFAULT CURRENT_TIMESTAMP in PG.
  4. Lowercase validation: registrationAddress implicitly .lowercase(). Ensure data is normalized before migration.
  5. Soft delete: active=true queries are common. Add partial index WHERE active=true.
  6. 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
email 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

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

  2. Default Date.now() for passkeys.createdAt: Mongoose default(Date.now) is a function. PG needs DEFAULT NOW() at DDL or app-side INSERT logic.

  3. 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) and user_refresh_tokens(user_id uuid FK, token varchar PK). Allows indexed queries and easy cleanup.
    • JSONB array approach: Store as JSONB column; queries slower but schema simpler.
  4. 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).
  5. Virtual fullName: Computed in application. Not stored; returned via computed property or SELECT concat(first_name, ' ', last_name).

  6. Self-referential FK: Add FOREIGN KEY (referred_by_id) REFERENCES users(id) ON DELETE SET NULL and index referred_by_id for parent-lookup queries.

  7. Enum types: Create PG ENUM types for role, authProvider, status, walletType, deviceType. Enforce case-sensitivity matching Mongoose schema (admin, buyer, seller, resolver, etc.).

  8. 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'
  9. Sparse index on referralCode & email: PG uses WHERE col IS NOT NULL clause 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 fullName in 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-level payment.transactionHash for 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 positional operators; 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 via mongoose.connection.db.admin().ping() (diagnostic only)
  • checkRedis(): Verifies Redis connectivity via redisService.ping() (diagnostic only)
  • checkRnChainRegistry(): Loads supported chains from static config
  • checkRnTokenRegistry(): Loads tokens from static config
  • checkRnApi(): 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: paymentRedisService tracks 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: confirmationThresholdService maintains 30s TTL cache of ConfigSetting values; invalidated on write.
  • Idempotency Patterns:
    • FundsLedgerEntry.idempotencyKey: Unique index prevents duplicate ledger entries.
    • Payment unique index on (buyerId, purchaseRequestId, provider, direction, status) for query-level dedup on pay-in intent creation.
    • DerivedDestination unique on (buyerId, sellerOfferId, chainId) with 11000 error race handling.
  • Atomic Counters: Counter.findByIdAndUpdate with $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 expiresAt field) 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 → User
  • SellerOffer.sellerId → User
  • Dispute.adminId → User
  • Review.reviewerId → User
  • RequestTemplate.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 ARRAY type 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) with JOIN

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

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.relatedTo has discriminator type (PurchaseRequest|SellerOffer|Transaction) + untyped id
    • Problem: Single id column 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)

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 JSON
  • PurchaseRequest.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

  1. Phase 1: Normalize Mixed-Type Fields

    • Audit all Payment records; convert strings to UUIDs or vice versa
    • Add purchase_request_id_type discriminator column temporarily
    • Create migration script to enforce single type (UUID preferred)
    • Update schemas: remove Mixed type, use strict ObjectId or string (with validation)
  2. 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
  3. 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
  4. Phase 4: Deep-Level JOIN Testing

    • Test performance of PurchaseRequest → SellerOffer → User chain
    • Add composite index: (purchase_request.selected_offer_id, seller_offer.seller_id)
    • Verify EXPLAIN plans; use ANALYZE
  5. 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 35 days Data migration + foreign key constraints
Polymorphic Chat.relatedTo refactor 1 model + service logic 23 days SQL UNION or conditional branch; testing
Mixed-type Payment fields 1 model + validation 12 days Data audit + migration script
Service layer rewrites (populate→JOIN) 10 service files 57 days Update all query builders; test each
Index tuning + EXPLAIN analysis All JOIN queries 23 days Performance testing; composite indexes
Total 1320 days Assumes 12 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

  1. Payment completion + dispute gate: Wrap in transaction (SELECT PurchaseRequest FOR UPDATE, check dispute, UPDATE Payment, append ledger)
  2. Release/refund + ledger: Wrap payout confirmation and ledger entry in transaction
  3. Referral rewards: Wrap referrer stats update in points transaction

Priority 2: State Consistency

  1. Dispute raise/resolve: Wrap PurchaseRequest + Payment.updateMany in single transaction
  2. Derived destination counter: Wrap index allocation + destination create in transaction (use RETURNING or LOCK)
  3. 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

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

  1. High: Payment.purchaseRequestId/sellerOfferId/sellerId → breaks joins, must use discriminator pattern
  2. High: FundsLedgerEntry immutable IDs → audit trail integrity
  3. Medium: All provider-specific Mixed fields → enable strict querying and schema validation
  4. Medium: Notification.metadata → enable safe notification filtering
  5. 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 B-tree index CREATE INDEX idx_category_name Name lookup
nameEn B-tree index CREATE INDEX idx_category_name_en English name lookup
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:

  1. TempVerification - emailVerificationCodeExpires (0 sec, deletes immediately when expired)

    • Postgres: DELETE FROM temp_verifications WHERE email_verification_code_expires <= NOW() via pg_cron every 1 minute
  2. TelegramSession - expiresAt (0 sec, deletes immediately when expired)

    • Postgres: DELETE FROM telegram_sessions WHERE expires_at <= NOW() via pg_cron every 1 minute
  3. Notification - createdAt (90 days = 7,776,000 seconds)

    • Postgres: DELETE FROM notifications WHERE created_at < NOW() - INTERVAL '90 days' via pg_cron daily at 2 AM

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.email index 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 NULL to 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:

  1. Source: /backend/src/infrastructure/database/init-admin.ts
  2. Trigger: Called in app.ts during server boot after connectDatabase()
  3. Idempotency: Checks if admin exists before creation (safe for restart)
  4. Configuration:
    • Email from: ADMIN_EMAIL env var (default: admin@marketplace.com)
    • Password from: ADMIN_PASSWORD env var (required, no default)
    • First/Last name: ADMIN_FIRST_NAME, ADMIN_LAST_NAME (Persian defaults: مدیر سیستم)
  5. Validation: Throws error if ADMIN_PASSWORD is missing
  6. Index Management: Ensures users.email index 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-dev
  • DB_NAME=amanat-marketplace-dev
  • REDIS_URI=redis://:localredis@localhost:6379

Docker Container Auth:

  • MONGO_INITDB_ROOT_USERNAME=admin
  • MONGO_INITDB_ROOT_PASSWORD=password123
  • MONGO_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 existence
  • Category.find({}) — load all categories
  • RequestTemplate.find() — load templates by seller
  • Address.find({ userId }) — user's addresses

Write Operations:

  • Seed: deleteMany() + insertMany() (destructive, idempotent checks before)
  • Admin Init: findOne() + conditional save() (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:

  1. seedCategories (independent)
  2. seedUsers (independent)
  3. seedAddresses (requires Users)
  4. 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 withTransaction sites 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 $regex search 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.


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_map infrastructure (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_map schema 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.sellerId unique, Category parentId ON DELETE SET NULL) enforced.

Phase 3 — User + auth core

  • User is the FK hub. Normalize nested profile/preferences/points/referralStats and extract the passkeys and refreshTokens arrays to child tables. Reimplement toJSON() password stripping and passkey default: Date.now() in app code. Sparse+unique email index → partial unique index WHERE 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_map for 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 PointsService withTransaction → PG BEGIN/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_entries totals), 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/$push nested 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 $regex with PG trigram/FTS only if you choose to migrate. Otherwise leave on Mongo.
  • Document-shaped + TTL-driven. Revisit only if operational cost of dual-stack outweighs migration cost.

Dual-write + backfill + verification + cutover (per collection)

  1. Backfill: batch-copy Mongo → PG, allocating uuids and recording them in id_map; remap FKs using id_map of already-migrated parents (hence User before money).
  2. Dual-write: repository writes to both stores, keyed through id_map, idempotently. Mongo remains source of truth for reads.
  3. 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.
  4. Cutover: flip reads to PG behind the flag. Keep dual-write for a soak window so rollback = flip reads back.
  5. 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 uuid columns plus an explicit *_type discriminator 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 uuid FK; where it is a free string (template checkout), keep a separate external_ref TEXT column 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; $incUPDATE ... 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 1320 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 05 + 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 (1628 eng-weeks) captures ~90% of the value; the full migration's extra 712+ weeks mostly buys Chat/Notification normalization that the access patterns don't reward. Recommend stopping at partial.


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)

  1. Row counts per collection, Mongo vs PG, per FK relationship — catches dropped/dangling rows.
  2. Checksums — column-level hashes, with special attention to financial sums (SUM of ledger amounts, payment totals) and the partial-unique idempotency set.
  3. 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 the mongo-to-pg-migration-audit workflow to refresh.