- Postgres Runtime Cutover Status: 17 migrations (0000–0017), dual-write repo matrix - Backend Architecture: dual-DB architecture, repo factory, MONGO_CONNECT_MODE modes - Data Model Overview: 23-model index with PG table names and migration status - User, PurchaseRequest, SellerOffer, Chat, Dispute: Drizzle schema + cutover status added - 04 - Flows/Telegram Mini App.md: new doc covering Mini App architecture and flows - mongo-to-pg-migration-prd.md: status block prepended with 2026-06-03 milestone tracking Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
16 KiB
title, tags, aliases
| title | tags | aliases | |||||||
|---|---|---|---|---|---|---|---|---|---|
| User |
|
|
User
Last updated: 2026-06-03 — added Postgres/Drizzle schema,
guardrole (migration 0017), dual-write status. Previous update: 2026-05-29 (see Doc vs Code Audit Report)
The core identity document for every actor in the marketplace: buyers, sellers, and admins. Stores credentials (password + WebAuthn passkeys), profile/preference data, referral bookkeeping, point balances, and a soft-delete status flag. Almost every other model carries an ObjectId (Mongo) or uuid (Postgres) reference back to User, so this collection is the relational hub of the system.
[!info] Migration status: DUAL-WRITE Writes go to both MongoDB (
Usercollection) and Postgres (userstable) viaDualWriteUserRepo. Reads still come from MongoDB — PG reads are not yet enabled. Repositories:DrizzleUserRepo,MongoUserRepo,DualWriteUserRepoPostgres table:users—backend/src/db/schema/users.ts
Postgres Table: users
[!note] Source
backend/src/db/schema/users.ts
Columns
| Column | PG Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
uuid |
no | gen_random_uuid() |
Primary key |
legacy_object_id |
text |
yes | — | Mongo ObjectId; partial-unique index WHERE NOT NULL; used for idempotent backfill upserts |
email |
varchar(255) |
yes | — | Partial-unique index WHERE NOT NULL |
password |
varchar(255) |
yes | — | Hashed |
first_name |
text |
yes | — | — |
last_name |
text |
yes | — | — |
role |
user_role enum |
no | buyer |
Values: admin, buyer, seller, resolver, guard (added migration 0017) |
is_email_verified |
bool |
yes | false |
— |
auth_provider |
auth_provider enum |
no | email |
Values: email, google, telegram |
telegram_verified |
bool |
yes | false |
— |
email_verification_token |
text |
yes | — | Legacy token flow |
email_verification_code |
text |
yes | — | OTP code |
email_verification_code_expires |
timestamptz |
yes | — | — |
password_reset_token |
text |
yes | — | — |
password_reset_expires |
timestamptz |
yes | — | — |
password_reset_code |
text |
yes | — | — |
password_reset_code_expires |
timestamptz |
yes | — | — |
profile |
jsonb |
yes | — | Stores avatar, photoURL, phone, address, bio, website, walletAddress, walletType, walletProvider, walletProofVerified, walletProofTimestamp, isPublic |
preferences |
jsonb |
yes | — | Stores language, currency, notifications.{email,sms,push} |
status |
user_status enum |
yes | active |
Values: active, suspended, deleted |
last_login_at |
timestamptz |
yes | — | — |
referral_code |
varchar(255) |
yes | — | Partial-unique index |
referred_by_id |
uuid |
yes | — | Self-FK → users(id); index |
points_total |
int |
yes | 0 |
— |
points_available |
int |
yes | 0 |
— |
points_used |
int |
yes | 0 |
— |
points_level |
int |
yes | 1 |
Indexed |
referral_stats_total |
int |
yes | 0 |
— |
referral_stats_active |
int |
yes | 0 |
— |
referral_stats_total_earned |
int |
yes | 0 |
— |
created_at |
timestamptz |
no | now() |
— |
updated_at |
timestamptz |
no | now() |
— |
Child Tables
user_passkeys — WebAuthn credentials extracted from the embedded array:
| Column | Type | Notes |
|---|---|---|
id |
text (PK) |
WebAuthn credential ID |
user_id |
uuid FK→users CASCADE |
Owner |
public_key |
text |
Stored public key |
counter |
int |
Signature counter |
device_type |
passkey_device_type enum |
platform / cross-platform |
device_name |
text |
Optional human label |
created_at |
timestamptz |
— |
user_refresh_tokens — Active JWT refresh tokens extracted from the Mongo array:
| Column | Type | Notes |
|---|---|---|
token |
text (PK) |
The refresh token string |
user_id |
uuid FK→users CASCADE |
Owner |
Indexes (Postgres)
| Index | Type | Condition |
|---|---|---|
users_email_unique |
partial-unique | WHERE email IS NOT NULL |
users_referral_code_unique |
partial-unique | WHERE referral_code IS NOT NULL |
users_legacy_object_id_unique |
partial-unique | WHERE legacy_object_id IS NOT NULL |
users_role_idx |
btree | — |
users_status_idx |
btree | — |
users_auth_provider_idx |
btree | — |
users_referral_code_idx |
btree | — |
users_referred_by_id_idx |
btree | — |
users_points_level_idx |
btree | — |
Relations
- Self-referential:
referred_by_id → users.id(parent/children for referral tree) - One-to-many:
user_passkeys.user_id,user_refresh_tokens.user_id
MongoDB Collection: User (legacy — reads still active)
[!note] Source
backend/src/models/User.ts:70— schema definitionbackend/src/models/User.ts:257— model export
[!note] Email change re-verification When a profile update (
PUT /api/user/profile,userController.updateUserProfile) changesisEmailVerified = false, generates a 6-digitemailVerificationCode(valid 15 minutes), stores it onemailVerificationCode/emailVerificationCodeExpires, and emails the code to the new address. The user must then confirm viaPOST /api/user/profile/email/verify(or request a new code withPOST /api/user/profile/email/resend-verification).
[!note] Wallet ownership proof
PATCH /api/user/wallet-addressaccepts both EVM and TON wallets. EVM addresses require an EIP-191 signature (ethers.verifyMessage); TON addresses are format-validated and may include an optional TonProof. A successful proof setsprofile.walletProofVerified = trueandprofile.walletProofTimestamp.
Schema
| Field | Type | Required | Default | Validation | Index | Description |
|---|---|---|---|---|---|---|
email |
String | no | — | lowercase, trim | unique, sparse | Primary email login identifier. Nullable for Telegram-only accounts. |
password |
String | no | — | minlength 6 | — | Hashed password. Optional to support passkey-only, Google, and Telegram accounts. |
firstName |
String | no | "کاربر" |
trim | — | Persian default ("user"). |
lastName |
String | no | "جدید" |
trim | — | Persian default ("new"). |
role |
String | yes | "buyer" |
enum: admin / buyer / seller / resolver / guard |
yes | Authorisation tier. resolver (commit fce8a19): can view/resolve disputes and bypass chat membership checks. guard (migration 0017): added in PG schema; purpose TBD. |
isEmailVerified |
Boolean | no | false |
— | — | Set to true after the email verification code is consumed. Warning: Changing the email via PUT /api/user/profile resets this to false and dispatches a fresh 6-digit verification code to the new address. |
authProvider |
String | yes | "email" |
enum: email / google / telegram |
yes | Provider used to create the account. Existing email/password accounts remain email; Telegram-only users are telegram. |
telegramVerified |
Boolean | no | false |
— | — | Set when Telegram identity has been signature-verified and linked through TelegramLink. |
emailVerificationToken |
String | no | — | — | — | Legacy token-based email verification. |
emailVerificationCode |
String | no | — | — | — | OTP code for email verification. |
emailVerificationCodeExpires |
Date | no | — | — | — | Expiry for emailVerificationCode. |
passwordResetToken |
String | no | — | — | — | Token for reset link flow. |
passwordResetExpires |
Date | no | — | — | — | Expiry of passwordResetToken. |
passwordResetCode |
String | no | — | — | — | OTP reset code. |
passwordResetCodeExpires |
Date | no | — | — | — | Expiry for OTP reset code. |
passkeys[] |
Subdocument array | no | [] |
— | — | WebAuthn credentials. Extracted to user_passkeys table in PG. |
passkeys[].id |
String | yes | — | — | — | Credential ID. |
passkeys[].publicKey |
String | yes | — | — | — | Stored public key. |
passkeys[].counter |
Number | yes | 0 |
— | — | Signature counter. |
passkeys[].deviceType |
String | yes | — | enum: platform / cross-platform |
— | Authenticator class. |
passkeys[].deviceName |
String | no | — | — | — | Optional human label. |
passkeys[].createdAt |
Date | no | Date.now |
— | — | Registration timestamp. |
profile.avatar |
String | no | — | — | — | Avatar URL. |
profile.photoURL |
String | no | — | — | — | Alternative photo URL. |
profile.phone |
String | no | — | — | — | Contact phone. |
profile.address.street |
String | no | — | — | — | Inline address (separate from Address book). |
profile.address.city |
String | no | — | — | — | — |
profile.address.state |
String | no | — | — | — | — |
profile.address.zipCode |
String | no | — | — | — | — |
profile.address.country |
String | no | — | — | — | — |
profile.bio |
String | no | — | — | — | Free-form bio. |
profile.website |
String | no | — | — | — | Personal website URL. |
profile.walletAddress |
String | no | — | — | — | On-chain wallet address (EVM 0x… or TON). Set via PATCH /api/user/wallet-address. |
profile.walletType |
String | no | — | enum: evm / ton |
— | Which chain family the stored walletAddress belongs to. |
profile.walletProvider |
String | no | — | — | — | Wallet provider label (e.g. evm, telegram-wallet). Defaults to telegram-wallet for TON, evm otherwise. |
profile.walletProofVerified |
Boolean | no | — | — | — | True when ownership was proven — EIP-191 signature for EVM, or a verified TonProof for TON. |
profile.walletProofTimestamp |
Date | no | — | — | — | When the wallet proof was last verified (only set when walletProofVerified is true). |
profile.isPublic |
Boolean | no | false |
— | — | Whether the profile is publicly visible. |
preferences.language |
String | no | "en" |
— | — | UI language. |
preferences.currency |
String | no | "USD" |
— | — | Display currency. |
preferences.notifications.email |
Boolean | no | true |
— | — | Opt-in for email notifications. |
preferences.notifications.sms |
Boolean | no | false |
— | — | Opt-in for SMS notifications. |
preferences.notifications.push |
Boolean | no | true |
— | — | Opt-in for push notifications. |
status |
String | no | "active" |
enum: active / suspended / deleted |
yes | Soft-delete and moderation flag. |
lastLoginAt |
Date | no | — | — | — | Updated by auth middleware. |
refreshTokens[] |
String[] | no | [] |
— | — | Array of currently active JWT refresh tokens. Warning: Reset to [] on password change and on password reset, invalidating every outstanding session. Extracted to user_refresh_tokens table in PG. |
referralCode |
String | no | — | — | unique, sparse | Not yet implemented — planned for referral programme. |
referredBy |
ObjectId -> User | no | — | — | yes | Not yet implemented — planned for referral programme. |
points.total |
Number | no | 0 |
— | — | Not yet implemented — planned for loyalty system. |
points.available |
Number | no | 0 |
— | — | Not yet implemented. |
points.used |
Number | no | 0 |
— | — | Not yet implemented. |
points.level |
Number | no | 1 |
— | yes | Not yet implemented — planned for LevelConfig lookup. |
referralStats.totalReferrals |
Number | no | 0 |
— | — | Not yet implemented. |
referralStats.activeReferrals |
Number | no | 0 |
— | — | Not yet implemented. |
referralStats.totalEarned |
Number | no | 0 |
— | — | Not yet implemented. |
createdAt |
Date | auto | — | — | — | Mongoose timestamp. |
updatedAt |
Date | auto | — | — | — | Mongoose timestamp. |
Virtuals
| Virtual | Returns | Definition |
|---|---|---|
fullName |
${firstName} ${lastName} |
backend/src/models/User.ts:238 |
Indexes (MongoDB)
Defined explicitly:
{ email: 1 }unique sparse — allows multiple Telegram-only users without email while preserving uniqueness for email-bearing users.{ role: 1 }—backend/src/models/User.ts:178{ status: 1 }—backend/src/models/User.ts:179{ authProvider: 1 }— supports provider-level account reporting and cleanup.
[!warning] Missing indexes in Mongo schema The schema currently defines only
roleandstatusindexes. ThereferralCode,referredBy, andpoints.levelindexes documented below are not yet present inUser.ts.
Pre/Post Hooks
None declared at the schema level.
Instance Methods
| Signature | Purpose |
|---|---|
toJSON(): object |
Strips password, refreshTokens, all emailVerification* and passwordReset* fields before serialisation. Defined at backend/src/models/User.ts:243. |
Static Methods
None defined on the schema.
Roles
| Role | Added | Capabilities |
|---|---|---|
admin |
original | Full platform access |
buyer |
original | Place purchase requests, confirm delivery |
seller |
original | Submit offers, manage shop |
resolver |
commit fce8a19 |
View/resolve disputes; bypass chat membership checks; no other admin privileges |
guard |
migration 0017 (PG only) | Purpose TBD — defined in user_role PG enum, not yet in Mongo schema |
[!warning] Role enum drift The Postgres
user_roleenum includesguard; the Mongo schema enum does not. Until the Mongo schema is updated, anyguard-role user created through PG will not be representable in Mongo and will break dual-write for that record.
Relationships
- References: User (self, via
referredBy/referred_by_id). - Referenced by: PurchaseRequest (
buyerId,preferredSellerIds,deliveryInfo.deliveryCodeUsedBy,deliveryInfo.deliveryAttempts[].sellerId), SellerOffer (sellerId), Payment (buyerId,sellerId), Chat (participants[].userId,messages[].senderId,metadata.createdBy), Notification (userIdas string), RequestTemplate (sellerId), Dispute (buyerId,sellerId,adminId), BlogPost (author.id), Address (userId), Review (sellerId,reviewerId), PointTransaction (user,referredUser), ShopSettings (sellerId).
State Transitions
stateDiagram-v2
[*] --> active : signup verified
active --> suspended : admin action
suspended --> active : admin restore
active --> deleted : self-delete
suspended --> deleted : admin purge
deleted --> [*]
Common Queries
// Mongo — Find by email (login)
User.findOne({ email: email.toLowerCase() });
// Mongo — Active sellers
User.find({ role: 'seller', status: 'active' });
// Mongo — Validate referral
User.findOne({ referralCode: code });
// Mongo — Leaderboard by points
User.find({ status: 'active' }).sort({ 'points.total': -1 }).limit(10);
// Mongo — Promote level
User.updateOne({ _id: id }, { $set: { 'points.level': newLevel } });
-- PG — Find by email
SELECT * FROM users WHERE email = lower($1) AND email IS NOT NULL;
-- PG — Active sellers
SELECT * FROM users WHERE role = 'seller' AND status = 'active';
-- PG — Leaderboard by points
SELECT * FROM users WHERE status = 'active' ORDER BY points_total DESC LIMIT 10;
Related: TempVerification, LevelConfig, PointTransaction, ShopSettings.