--- title: User tags: [data-model, mongoose, postgres, dual-write] aliases: [User Model, IUser, Account] --- # User > **Last updated:** 2026-06-03 — added Postgres/Drizzle schema, `guard` role (migration 0017), dual-write status. Previous update: 2026-05-29 (see [Doc vs Code Audit Report](../09%20-%20Audits/Doc%20vs%20Code%20Audit%20Report%20-%202026-05-29.md)) 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 (`User` collection) and Postgres (`users` table) via `DualWriteUserRepo`. > Reads still come from **MongoDB** — PG reads are not yet enabled. > Repositories: `DrizzleUserRepo`, `MongoUserRepo`, `DualWriteUserRepo` > Postgres 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 definition > `backend/src/models/User.ts:257` — model export > [!note] Email change re-verification > When a profile update (`PUT /api/user/profile`, `userController.updateUserProfile`) changes `email` to a new value, the controller sets `isEmailVerified = false`, generates a **6-digit** `emailVerificationCode` (valid 15 minutes), stores it on `emailVerificationCode` / `emailVerificationCodeExpires`, and emails the code to the new address. The user must then confirm via `POST /api/user/profile/email/verify` (or request a new code with `POST /api/user/profile/email/resend-verification`). > [!note] Wallet ownership proof > `PATCH /api/user/wallet-address` accepts both EVM and TON wallets. EVM addresses require an EIP-191 signature (`ethers.verifyMessage`); TON addresses are format-validated and may include an optional TonProof. A successful proof sets `profile.walletProofVerified = true` and `profile.walletProofTimestamp`. ### Schema | Field | Type | Required | Default | Validation | Index | Description | | --- | --- | --- | --- | --- | --- | --- | | `email` | String | no | — | lowercase, trim | unique, sparse | Primary email login identifier. Nullable for Telegram-only accounts. | | `password` | String | no | — | minlength 6 | — | Hashed password. Optional to support passkey-only, Google, and Telegram accounts. | | `firstName` | String | no | `"کاربر"` | trim | — | Persian default ("user"). | | `lastName` | String | no | `"جدید"` | trim | — | Persian default ("new"). | | `role` | String | yes | `"buyer"` | enum: `admin` / `buyer` / `seller` / `resolver` / `guard` | yes | Authorisation tier. `resolver` (commit `fce8a19`): can view/resolve disputes and bypass chat membership checks. `guard` (migration 0017): added in PG schema; purpose TBD. | | `isEmailVerified` | Boolean | no | `false` | — | — | Set to true after the email verification code is consumed. Warning: Changing the email via `PUT /api/user/profile` **resets this to `false`** and dispatches a fresh **6-digit** verification code to the new address. | | `authProvider` | String | yes | `"email"` | enum: `email` / `google` / `telegram` | yes | Provider used to create the account. Existing email/password accounts remain `email`; Telegram-only users are `telegram`. | | `telegramVerified` | Boolean | no | `false` | — | — | Set when Telegram identity has been signature-verified and linked through `TelegramLink`. | | `emailVerificationToken` | String | no | — | — | — | Legacy token-based email verification. | | `emailVerificationCode` | String | no | — | — | — | OTP code for email verification. | | `emailVerificationCodeExpires` | Date | no | — | — | — | Expiry for `emailVerificationCode`. | | `passwordResetToken` | String | no | — | — | — | Token for reset link flow. | | `passwordResetExpires` | Date | no | — | — | — | Expiry of `passwordResetToken`. | | `passwordResetCode` | String | no | — | — | — | OTP reset code. | | `passwordResetCodeExpires` | Date | no | — | — | — | Expiry for OTP reset code. | | `passkeys[]` | Subdocument array | no | `[]` | — | — | WebAuthn credentials. Extracted to `user_passkeys` table in PG. | | `passkeys[].id` | String | yes | — | — | — | Credential ID. | | `passkeys[].publicKey` | String | yes | — | — | — | Stored public key. | | `passkeys[].counter` | Number | yes | `0` | — | — | Signature counter. | | `passkeys[].deviceType` | String | yes | — | enum: `platform` / `cross-platform` | — | Authenticator class. | | `passkeys[].deviceName` | String | no | — | — | — | Optional human label. | | `passkeys[].createdAt` | Date | no | `Date.now` | — | — | Registration timestamp. | | `profile.avatar` | String | no | — | — | — | Avatar URL. | | `profile.photoURL` | String | no | — | — | — | Alternative photo URL. | | `profile.phone` | String | no | — | — | — | Contact phone. | | `profile.address.street` | String | no | — | — | — | Inline address (separate from Address book). | | `profile.address.city` | String | no | — | — | — | — | | `profile.address.state` | String | no | — | — | — | — | | `profile.address.zipCode` | String | no | — | — | — | — | | `profile.address.country` | String | no | — | — | — | — | | `profile.bio` | String | no | — | — | — | Free-form bio. | | `profile.website` | String | no | — | — | — | Personal website URL. | | `profile.walletAddress` | String | no | — | — | — | On-chain wallet address (EVM `0x…` or TON). Set via `PATCH /api/user/wallet-address`. | | `profile.walletType` | String | no | — | enum: `evm` / `ton` | — | Which chain family the stored `walletAddress` belongs to. | | `profile.walletProvider` | String | no | — | — | — | Wallet provider label (e.g. `evm`, `telegram-wallet`). Defaults to `telegram-wallet` for TON, `evm` otherwise. | | `profile.walletProofVerified` | Boolean | no | — | — | — | True when ownership was proven — EIP-191 signature for EVM, or a verified TonProof for TON. | | `profile.walletProofTimestamp` | Date | no | — | — | — | When the wallet proof was last verified (only set when `walletProofVerified` is true). | | `profile.isPublic` | Boolean | no | `false` | — | — | Whether the profile is publicly visible. | | `preferences.language` | String | no | `"en"` | — | — | UI language. | | `preferences.currency` | String | no | `"USD"` | — | — | Display currency. | | `preferences.notifications.email` | Boolean | no | `true` | — | — | Opt-in for email notifications. | | `preferences.notifications.sms` | Boolean | no | `false` | — | — | Opt-in for SMS notifications. | | `preferences.notifications.push` | Boolean | no | `true` | — | — | Opt-in for push notifications. | | `status` | String | no | `"active"` | enum: `active` / `suspended` / `deleted` | yes | Soft-delete and moderation flag. | | `lastLoginAt` | Date | no | — | — | — | Updated by auth middleware. | | `refreshTokens[]` | String[] | no | `[]` | — | — | Array of currently active JWT refresh tokens. Warning: Reset to `[]` on password change and on password reset, invalidating every outstanding session. Extracted to `user_refresh_tokens` table in PG. | | `referralCode` | String | no | — | — | unique, sparse | **Not yet implemented** — planned for referral programme. | | `referredBy` | ObjectId -> User | no | — | — | yes | **Not yet implemented** — planned for referral programme. | | `points.total` | Number | no | `0` | — | — | **Not yet implemented** — planned for loyalty system. | | `points.available` | Number | no | `0` | — | — | **Not yet implemented.** | | `points.used` | Number | no | `0` | — | — | **Not yet implemented.** | | `points.level` | Number | no | `1` | — | yes | **Not yet implemented** — planned for LevelConfig lookup. | | `referralStats.totalReferrals` | Number | no | `0` | — | — | **Not yet implemented.** | | `referralStats.activeReferrals` | Number | no | `0` | — | — | **Not yet implemented.** | | `referralStats.totalEarned` | Number | no | `0` | — | — | **Not yet implemented.** | | `createdAt` | Date | auto | — | — | — | Mongoose timestamp. | | `updatedAt` | Date | auto | — | — | — | Mongoose timestamp. | ### Virtuals | Virtual | Returns | Definition | | --- | --- | --- | | `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 `role` and `status` indexes. The `referralCode`, `referredBy`, and `points.level` indexes documented below are **not yet present** in `User.ts`. ### Pre/Post Hooks None declared at the schema level. ### Instance Methods | Signature | Purpose | | --- | --- | | `toJSON(): object` | Strips `password`, `refreshTokens`, all `emailVerification*` and `passwordReset*` fields before serialisation. Defined at `backend/src/models/User.ts:243`. | ### Static Methods None defined on the schema. --- ## 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_role` enum includes `guard`; the Mongo schema enum does not. Until the Mongo schema is updated, any `guard`-role user created through PG will not be representable in Mongo and will break dual-write for that record. --- ## 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 (`userId` as string), RequestTemplate (`sellerId`), Dispute (`buyerId`, `sellerId`, `adminId`), BlogPost (`author.id`), Address (`userId`), Review (`sellerId`, `reviewerId`), PointTransaction (`user`, `referredUser`), ShopSettings (`sellerId`). ## State Transitions ```mermaid stateDiagram-v2 [*] --> active : signup verified active --> suspended : admin action suspended --> active : admin restore active --> deleted : self-delete suspended --> deleted : admin purge deleted --> [*] ``` ## Common Queries ```ts // Mongo — Find by email (login) User.findOne({ email: email.toLowerCase() }); // Mongo — Active sellers User.find({ role: 'seller', status: 'active' }); // Mongo — Validate referral User.findOne({ referralCode: code }); // Mongo — Leaderboard by points User.find({ status: 'active' }).sort({ 'points.total': -1 }).limit(10); // Mongo — Promote level User.updateOne({ _id: id }, { $set: { 'points.level': newLevel } }); ``` ```sql -- PG — Find by email 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.