---
title: User
tags: [data-model, postgres, drizzle]
aliases: [User Model, IUser, Account]
---

# User

> **Last updated:** 2026-06-06 — MongoDB fully removed; PostgreSQL + Drizzle is the only database layer (backend v2.9.12). Previous update: 2026-06-03 (dual-write status, guard role).

The core identity document for every actor in the marketplace: buyers, sellers, and admins. Stores credentials (password + WebAuthn passkeys), profile/preference data, referral bookkeeping, point balances, and a soft-delete status flag. Almost every other model carries a `uuid` (Postgres) reference back to `User`, so this table is the relational hub of the system.

> [!info] Migration status: COMPLETE
> MongoDB and Mongoose have been fully removed from the backend runtime. PostgreSQL + Drizzle ORM is the sole database layer (19 migrations landed, 0000–0019, 32 tables).
> Repository: `DrizzleUserRepo` (returned exclusively by the repository factory)
> Postgres table: **`users`** — `backend/src/db/schema/users.ts`

---

## ID Duality

| Field | Storage | Purpose |
| --- | --- | --- |
| `id` (PG column) / `pgId` (domain object) | `uuid`, PG primary key | Used for all marketplace foreign keys: `offer.sellerId`, `purchaseRequest.buyerId`, `payment.buyerId/sellerId`, etc. |
| `legacy_object_id` (PG column) / `_id` (domain/auth tokens) | `text`, 24-hex ObjectId string | Kept for backward compatibility with socket rooms (rooms keyed by legacy id) and auth tokens issued before migration. Partial-unique index WHERE NOT NULL. |

> [!warning] Always match marketplace FKs on `pgId` (UUID), not on `legacy_object_id`. Notifications and socket rooms use the legacy id string.

---

## PostgreSQL Schema (Drizzle): `users`

> [!note] Source
> `backend/src/db/schema/users.ts`

### Columns

| Column | PG Type | Nullable | Default | Notes |
| --- | --- | --- | --- | --- |
| `id` | `uuid` | no | `gen_random_uuid()` | Primary key (`pgId` in domain objects) |
| `legacy_object_id` | `text` | yes | — | 24-hex ObjectId string; partial-unique index WHERE NOT NULL; kept for socket rooms and legacy auth token compatibility |
| `email` | `varchar(255)` | yes | — | Partial-unique index WHERE NOT NULL |
| `password` | `varchar(255)` | yes | — | Hashed |
| `first_name` | `text` | yes | — | — |
| `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:

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

| Column | Type | Notes |
| --- | --- | --- |
| `token` | `text` (PK) | The refresh token string |
| `user_id` | `uuid FK→users CASCADE` | Owner |

### Indexes

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

---

## Field Reference

> [!note] Email change re-verification
> When a profile update (`PUT /api/user/profile`, `userController.updateUserProfile`) changes `email` to a new value, the controller sets `isEmailVerified = false`, generates a **6-digit** `emailVerificationCode` (valid 15 minutes), stores it on `emailVerificationCode` / `emailVerificationCodeExpires`, and emails the code to the new address. The user must then confirm via `POST /api/user/profile/email/verify` (or request a new code with `POST /api/user/profile/email/resend-verification`).

> [!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`.

| Field (domain / camelCase) | PG Column | Notes |
| --- | --- | --- |
| `id` / `pgId` | `id` (uuid PK) | Used for all marketplace FKs |
| `_id` / `legacyObjectId` | `legacy_object_id` | 24-hex string; socket rooms + legacy auth tokens |
| `email` | `email` | Primary email login; nullable for Telegram-only accounts |
| `password` | `password` | Hashed; optional for passkey/Google/Telegram accounts |
| `firstName` | `first_name` | Persian default "کاربر" |
| `lastName` | `last_name` | Persian default "جدید" |
| `role` | `role` | enum: `admin` / `buyer` / `seller` / `resolver` / `guard` |
| `isEmailVerified` | `is_email_verified` | Reset to false on email change |
| `authProvider` | `auth_provider` | enum: `email` / `google` / `telegram` |
| `telegramVerified` | `telegram_verified` | Set after Telegram signature-verify + link |
| `emailVerificationToken` | `email_verification_token` | Legacy token flow |
| `emailVerificationCode` | `email_verification_code` | OTP code |
| `emailVerificationCodeExpires` | `email_verification_code_expires` | — |
| `passwordResetToken` | `password_reset_token` | Token for reset link flow |
| `passwordResetExpires` | `password_reset_expires` | — |
| `passwordResetCode` | `password_reset_code` | OTP reset code |
| `passwordResetCodeExpires` | `password_reset_code_expires` | — |
| `passkeys[]` | `user_passkeys` child table | WebAuthn credentials |
| `passkeys[].id` | `user_passkeys.id` | Credential ID (PK) |
| `passkeys[].publicKey` | `user_passkeys.public_key` | Stored public key |
| `passkeys[].counter` | `user_passkeys.counter` | Signature counter |
| `passkeys[].deviceType` | `user_passkeys.device_type` | enum: `platform` / `cross-platform` |
| `passkeys[].deviceName` | `user_passkeys.device_name` | Optional human label |
| `passkeys[].createdAt` | `user_passkeys.created_at` | Registration timestamp |
| `profile.avatar` | `profile` jsonb | Avatar URL |
| `profile.photoURL` | `profile` jsonb | Alternative photo URL |
| `profile.phone` | `profile` jsonb | Contact phone |
| `profile.address.*` | `profile` jsonb | street, city, state, zipCode, country |
| `profile.bio` | `profile` jsonb | Free-form bio |
| `profile.website` | `profile` jsonb | Personal website URL |
| `profile.walletAddress` | `profile` jsonb | EVM `0x…` or TON address; set via `PATCH /api/user/wallet-address` |
| `profile.walletType` | `profile` jsonb | enum: `evm` / `ton` |
| `profile.walletProvider` | `profile` jsonb | e.g. `evm`, `telegram-wallet` |
| `profile.walletProofVerified` | `profile` jsonb | True when ownership proven (EIP-191 or TonProof) |
| `profile.walletProofTimestamp` | `profile` jsonb | Last verified timestamp |
| `profile.isPublic` | `profile` jsonb | Whether profile is publicly visible |
| `preferences.language` | `preferences` jsonb | UI language; default `"en"` |
| `preferences.currency` | `preferences` jsonb | Display currency; default `"USD"` |
| `preferences.notifications.email` | `preferences` jsonb | Opt-in email notifications; default `true` |
| `preferences.notifications.sms` | `preferences` jsonb | Opt-in SMS notifications; default `false` |
| `preferences.notifications.push` | `preferences` jsonb | Opt-in push notifications; default `true` |
| `status` | `status` | enum: `active` / `suspended` / `deleted` |
| `lastLoginAt` | `last_login_at` | Updated by auth middleware |
| `refreshTokens[]` | `user_refresh_tokens` child table | Active JWT refresh tokens; reset on password change/reset |
| `referralCode` | `referral_code` | Planned referral programme |
| `referredBy` | `referred_by_id` (uuid FK) | Planned referral programme |
| `points.total` | `points_total` | Planned loyalty system |
| `points.available` | `points_available` | Planned loyalty system |
| `points.used` | `points_used` | Planned loyalty system |
| `points.level` | `points_level` | Planned LevelConfig lookup |
| `referralStats.totalReferrals` | `referral_stats_total` | Planned |
| `referralStats.activeReferrals` | `referral_stats_active` | Planned |
| `referralStats.totalEarned` | `referral_stats_total_earned` | Planned |
| `createdAt` | `created_at` | Drizzle timestamp |
| `updatedAt` | `updated_at` | Drizzle timestamp |

### Computed / Virtual

| Virtual | Returns | Notes |
| --- | --- | --- |
| `fullName` | `${firstName} ${lastName}` | Computed in domain layer (was Mongoose virtual) |

### Serialisation

`toJSON()` strips `password`, `refreshTokens`, all `emailVerification*` and `passwordReset*` fields before serialisation.

---

## 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 | Defined in `user_role` PG enum; purpose TBD |

---

## Relationships

- **References**: User (self, via `referred_by_id`).
- **Referenced by**: PurchaseRequest (`buyerId`, `preferredSellerIds`, `deliveryInfo.deliveryCodeUsedBy`, `deliveryInfo.deliveryAttempts[].sellerId`), SellerOffer (`sellerId`), Payment (`buyerId`, `sellerId`), Chat (`participants[].userId`, `messages[].senderId`, `metadata.createdBy`), Notification (`userId` as string), RequestTemplate (`sellerId`), Dispute (`buyerId`, `sellerId`, `adminId`), BlogPost (`author.id`), Address (`userId`), Review (`sellerId`, `reviewerId`), PointTransaction (`user`, `referredUser`), ShopSettings (`sellerId`).

## State Transitions

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

```sql
-- Find by email (login)
SELECT * FROM users WHERE email = lower($1) AND email IS NOT NULL;

-- Active sellers
SELECT * FROM users WHERE role = 'seller' AND status = 'active';

-- Validate referral code
SELECT * FROM users WHERE referral_code = $1 AND referral_code IS NOT NULL;

-- Leaderboard by points
SELECT * FROM users WHERE status = 'active' ORDER BY points_total DESC LIMIT 10;

-- Promote level
UPDATE users SET points_level = $1, updated_at = now() WHERE id = $2;

-- Lookup by legacy ObjectId (socket rooms / auth token migration)
SELECT * FROM users WHERE legacy_object_id = $1;
```

Related: TempVerification, LevelConfig, PointTransaction, ShopSettings.
