--- title: Chat tags: [data-model, mongoose, postgres] aliases: [Conversation, IChat, IMessage] --- # Chat > **Last updated:** 2026-06-03 — added Postgres/Drizzle schema section; migration status clarified. Conversation container with embedded messages. Used for buyer-seller direct chats, group chats, and support tickets. Each chat carries a list of participants, an embedded `messages[]` array (with reactions, replies, edit history), a denormalised `lastMessage` snapshot for list views, and per-user `unreadCounts`. A chat can be linked to any other entity through the `relatedTo` discriminator (currently `PurchaseRequest`, `SellerOffer`, or `Transaction`). > [!note] Source > `backend/src/models/Chat.ts:130` — chat schema definition > `backend/src/models/Chat.ts:69` — message subdocument schema > `backend/src/models/Chat.ts:348` — model export > `backend/src/db/schema/chat.ts` — Drizzle/Postgres schema > [!warning] Embedded messages > Messages live inside the chat document. Very long-running chats can grow past the 16 MB document limit. Treat this as a known constraint of the current schema. > [!warning] `relatedTo` is NOT set via `POST /api/chat` > Although `relatedTo` exists in the schema, it is **not accepted** by the `POST /api/chat` create endpoint. Purchase-request linkage is established server-side through the dedicated `POST /api/chat/purchase-request`, not by passing `relatedTo` to the generic create endpoint. > [!danger] Migration status — DUAL-WRITE, reads still on Mongo > Chat writes go to **both** MongoDB and Postgres (via `DrizzleChatRepo`). However, **all reads still come from MongoDB**. The Postgres `chats` table is a conservative shim: `participants` and `messages` are stored as JSONB blobs, not normalised child tables. Full normalisation (splitting messages into a separate table with proper threading) is a **known open blocker** for the Mongo → PG read cutover. Do not assume PG data is queryable relationally until that work is complete. ## Schema — Chat (MongoDB / Mongoose) | Field | Type | Required | Default | Validation | Index | Description | | --- | --- | --- | --- | --- | --- | --- | | `type` | String | yes | `direct` | enum: `direct` / `group` / `support` | yes | Conversation type. | | `name` | String | no | — | maxlength 100 | — | Display name (group chats). | | `description` | String | no | — | maxlength 500 | — | Optional description. | | `participants[].userId` | ObjectId → [[User]] | yes | — | — | yes | Member id. | | `participants[].role` | String | no | `member` | enum: `member` / `admin` / `owner` | — | Member role. | | `participants[].joinedAt` | Date | no | `Date.now` | — | — | Join time. | | `participants[].lastSeen` | Date | no | — | — | — | Last activity. | | `participants[].leftAt` | Date | no | — | — | — | Set when the participant is removed (soft removal). | | `participants[].isActive` | Boolean | no | `true` | — | — | Still a participant. Set to `false` on soft removal (subdocument is kept). | | `messages[]` | Subdocument[] | no | `[]` | — | yes (`messages.timestamp`) | Embedded messages. | | `relatedTo.type` | String | no | — | enum: `PurchaseRequest` / `SellerOffer` / `Transaction` | yes (compound) | Linked entity kind. **Not accepted via `POST /api/chat`** — set only via `POST /api/chat/purchase-request`. | | `relatedTo.id` | ObjectId | no | — | — | yes (compound) | Linked entity id. | | `lastMessage.content` | String | no | — | — | — | Snapshot for list views. | | `lastMessage.senderId` | ObjectId → [[User]] | no | — | — | — | Last sender. | | `lastMessage.timestamp` | Date | no | — | — | — | Last message time. | | `lastMessage.messageType` | String | no | — | — | — | Last message type. | | `unreadCounts[].userId` | ObjectId → [[User]] | no | — | — | — | User the counter belongs to. | | `unreadCounts[].count` | Number | no | `0` | — | — | Number of unread messages. | | `settings.isArchived` | Boolean | no | `false` | — | — | Archived flag. | | `settings.isMuted` | Boolean | no | `false` | — | — | Muted flag. | | `settings.mutedUntil` | Date | no | — | — | — | Mute expiry. | | `settings.notifications` | Boolean | no | `true` | — | — | Per-chat notification toggle. | | `metadata.createdBy` | ObjectId → [[User]] | yes | — | — | — | Original creator. | | `metadata.createdAt` | Date | no | `Date.now` | — | — | Created timestamp. | | `metadata.updatedAt` | Date | no | `Date.now` | — | — | Touched by pre-save. | | `metadata.lastActivity` | Date | no | `Date.now` | — | yes (desc) | Sort key for chat lists. | > [!note] No top-level `timestamps` > Unlike most models, this schema does not pass `{ timestamps: true }`. It uses its own `metadata.createdAt` / `metadata.updatedAt` instead, maintained by the pre-save hook. > [!note] Soft removal of participants > Removing a participant (via `DELETE /api/chat/:id/participants/:participantId`) does **not** delete the subdocument. It is a soft removal: `isActive` is set to `false` and `leftAt` is timestamped, preserving message attribution and history. ## Schema — Message (embedded, MongoDB) | Field | Type | Required | Default | Validation | Description | | --- | --- | --- | --- | --- | --- | | `senderId` | ObjectId → [[User]] | yes | — | — | Author. | | `senderType` | String | no | `User` | — | Currently fixed. | | `content` | String | yes | — | **maxlength 5000** | Message body. Enforced at both schema and controller. | | `messageType` | String | no | `text` | enum: `text` / `image` / `file` / `system` | Body kind. | | `fileUrl` | String | no | — | — | If file/image. | | `fileName` | String | no | — | — | Original filename. | | `fileSize` | Number | no | — | — | Bytes. | | `timestamp` | Date | no | `Date.now` | — | Sent time. | | `isRead` | Boolean | no | `false` | — | Read flag. | | `isEdited` | Boolean | no | `false` | — | Edited flag. | | `editedAt` | Date | no | — | — | When edited. | | `deletedAt` | Date | no | — | — | Set on soft-delete; `content` is cleared but the subdocument is kept. | | `replyTo` | ObjectId | no | — | — | Reply target message id. | | `reactions[].userId` | ObjectId → [[User]] | no | — | — | Reacting user. | | `reactions[].reaction` | String | no | — | maxlength 10 | Emoji. | > [!note] Messages are soft-deleted > Deleting a message sets `deletedAt` and clears `content` (the body becomes empty). The message subdocument is **not** physically removed from `messages[]`, and a `message-deleted` socket event is emitted. ## Schema — `chats` table (Postgres / Drizzle) > Source: `backend/src/db/schema/chat.ts` > [!warning] Conservative JSONB shim — not normalised > Unlike most other migrated tables, participants and messages are stored as **JSONB blobs** (`ChatParticipant[]` and `ChatMessage[]`), not as separate relational child tables. This was a deliberate trade-off to unblock dual-write without committing to a normalisation design. The normalised schema (separate `chat_messages` and `chat_participants` tables with proper FKs and threading support) is the **primary blocker** for cutting reads over to Postgres. ### Enums (declared in `_enums.ts`) | Enum | Values | | --- | --- | | `chat_type` | `direct`, `group`, `support` | | `chat_participant_role` | `member`, `admin`, `owner` | | `chat_message_type` | `text`, `image`, `file`, `system` | | `chat_related_to_type` | `PurchaseRequest`, `SellerOffer`, `Transaction` | ### Table: `chats` | Column | PG type | Nullable | Default | Notes | | --- | --- | --- | --- | --- | | `id` | `uuid` | NOT NULL | `gen_random_uuid()` | Primary key | | `legacy_object_id` | `text` | nullable | — | Mongo ObjectId bridge; partial-unique index WHERE NOT NULL | | `type` | `chat_type` enum | NOT NULL | `'direct'` | | | `name` | `text` | nullable | — | Group chat display name | | `description` | `text` | nullable | — | | | `participants` | `jsonb` | nullable | — | `ChatParticipant[]` blob — **not normalised** | | `messages` | `jsonb` | nullable | — | `ChatMessage[]` blob — **not normalised** | | `related_to` | `jsonb` | nullable | — | `{ type: chat_related_to_type, id: string }` blob | | `last_message` | `jsonb` | nullable | — | Denormalised snapshot | | `unread_counts` | `jsonb` | nullable | — | `{ userId, count }[]` blob | | `settings_is_archived` | `boolean` | nullable | `false` | | | `settings_is_muted` | `boolean` | nullable | `false` | | | `settings_muted_until` | `timestamp with time zone` | nullable | — | | | `settings_notifications` | `boolean` | nullable | `true` | | | `created_by` | `text` | nullable | — | Mongo ObjectId or UUID string of creator | | `created_at` | `timestamp with time zone` | NOT NULL | `now()` | | | `updated_at` | `timestamp with time zone` | NOT NULL | `now()` | | | `last_activity` | `timestamp with time zone` | nullable | `now()` | Sort key for chat lists | ### Indexes on `chats` | Index | Definition | Notes | | --- | --- | --- | | PK | `id` | | | partial-unique | `legacy_object_id` WHERE NOT NULL | Idempotent backfill upsert | | regular | `type` | | | regular | `created_by` | | | regular | `last_activity` | | > [!note] No FK to `users` > `created_by` is stored as `text` (not `uuid` FK) to accommodate both Mongo ObjectIds and PG UUIDs during the transition period. ## Virtuals | Virtual | Returns | Definition | | --- | --- | --- | | `participantsCount` | Count of active participants | `backend/src/models/Chat.ts:259` | ## Indexes (MongoDB) Defined at `backend/src/models/Chat.ts:243-247`: - `{ 'participants.userId': 1 }` - `{ 'metadata.lastActivity': -1 }` - `{ 'relatedTo.type': 1, 'relatedTo.id': 1 }` - `{ 'messages.timestamp': -1 }` - `{ type: 1 }` ## Pre/Post Hooks | Hook | Behaviour | | --- | --- | | `pre('save')` (`backend/src/models/Chat.ts:250`) | Updates `metadata.updatedAt` and refreshes `metadata.lastActivity` when there are messages. | ## Instance Methods | Signature | Purpose | | --- | --- | | `getUnreadCount(userId: Types.ObjectId): number` | Returns the unread counter for a participant. `backend/src/models/Chat.ts:264` | | `addMessage(messageData: Partial): IMessage` | Pushes a message, updates `lastMessage`, increments unread counters for everyone except the sender, and bumps `lastActivity`. `backend/src/models/Chat.ts:270` | | `markAsRead(userId, messageIds?: Types.ObjectId[]): void` | Marks listed messages (or all when `messageIds` is empty/omitted) as read for the user, zeros their unread counter, and updates `lastSeen`. `backend/src/models/Chat.ts:308` | ## Static Methods None defined. ## Relationships - **References**: [[User]] (`participants[].userId`, `messages[].senderId`, `messages[].reactions[].userId`, `lastMessage.senderId`, `unreadCounts[].userId`, `metadata.createdBy`). - **Referenced by**: [[Dispute]] (`chatId`), and any [[PurchaseRequest]] / [[SellerOffer]] indirectly through `relatedTo`. ## Migration Status | Dimension | Status | | --- | --- | | Dual-write repo | `DrizzleChatRepo` — active | | Writes | Both MongoDB and Postgres receive writes | | Reads | **MongoDB only** — not yet cut over | | Postgres schema style | JSONB shim (participants + messages as blobs) | | Normalisation blocker | Chat message threading design not finalised — blocks PG read cutover | The normalisation work required before reads can be cut to PG: 1. Design a `chat_messages` table with proper threading/reply support (currently `replyTo` is an ObjectId embedded in a JSONB blob) 2. Design a `chat_participants` table (currently a JSONB blob with soft-removal semantics) 3. Migrate reactions, edit history, and read tracking to relational rows 4. Align unread counts with the new structure Until that work is complete, the Postgres `chats` table is treated as a write-ahead log / backup, not the source of truth for reads. ## State Transitions No top-level status. Chat-level archival is a boolean flag (`settings.isArchived`): ```mermaid stateDiagram-v2 [*] --> active active --> muted : user mutes muted --> active : unmute / mute expires active --> archived : user archives archived --> active : restore ``` ## Common Queries ```ts // A user's recent chats Chat.find({ 'participants.userId': userId, 'participants.isActive': true }) .sort({ 'metadata.lastActivity': -1 }); // Chat for a purchase request Chat.findOne({ 'relatedTo.type': 'PurchaseRequest', 'relatedTo.id': prId }); // Append a message const chat = await Chat.findById(id); chat.addMessage({ senderId, content: 'hi', messageType: 'text' }); await chat.save(); // Mark read chat.markAsRead(userId); await chat.save(); ``` Related: [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Dispute]].