docs: sync from backend 8fc2309 — M43/M44 missing FKs + H37 dispute enums

This commit is contained in:
Siavash Sameni
2026-06-07 07:16:02 +04:00
parent a2967ec594
commit 0bb60dbc98
24 changed files with 3428 additions and 906 deletions

View File

@@ -1,95 +1,29 @@
---
title: Chat
tags: [data-model, mongoose, postgres]
tags: [data-model, postgres, drizzle]
aliases: [Conversation, IChat, IMessage]
---
# Chat
> **Last updated:** 2026-06-03added Postgres/Drizzle schema section; migration status clarified.
> **Last updated:** 2026-06-06MongoDB fully removed; PostgreSQL + Drizzle is the sole data layer (backend v2.9.12).
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
> `backend/src/db/schema/chat.ts` — PostgreSQL schema (Drizzle)
> `backend/src/repositories/drizzle/DrizzleChatRepo.ts` — repository implementation
> [!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] Embedded messages (JSONB)
> Messages and participants are stored as JSONB arrays inside the `chats` table (`messages jsonb`, `participants jsonb`), not as separate relational child tables. Very long-running chats can accumulate large blobs. Chat normalization (JSONB → relational child tables) is a **future improvement**, not yet done.
> [!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)
## Schema — `chats` table (PostgreSQL / 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.
PostgreSQL is the **only** database layer. MongoDB and Mongoose have been completely removed from the backend runtime. Participants and messages are stored as **JSONB blobs** (`ChatParticipant[]` and `ChatMessage[]`) inside the `chats` table. Chat normalization (splitting messages and participants into separate relational child tables with proper FKs and threading support) is a known future improvement.
### Enums (declared in `_enums.ts`)
@@ -104,13 +38,13 @@ Conversation container with embedded messages. Used for buyer-seller direct chat
| 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 |
| `id` | `uuid` | NOT NULL | `gen_random_uuid()` | Primary key (PostgreSQL UUID — use `.id`, not `._id`) |
| `legacy_object_id` | `text` | nullable | — | Former Mongo ObjectId; 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** |
| `participants` | `jsonb` | nullable | — | `ChatParticipant[]` blob — stored as JSONB array |
| `messages` | `jsonb` | nullable | — | `ChatMessage[]` blob — stored as JSONB array |
| `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 |
@@ -118,7 +52,7 @@ Conversation container with embedded messages. Used for buyer-seller direct chat
| `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_by` | `text` | nullable | — | 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 |
@@ -134,68 +68,79 @@ Conversation container with embedded messages. Used for buyer-seller direct chat
| 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.
> `created_by` is stored as `text` (not `uuid` FK) to accommodate both legacy Mongo ObjectIds (in `legacy_object_id`) and PG UUIDs during the transition period.
## Virtuals
## Chat Schema — participants and messages (JSONB field shapes)
| Virtual | Returns | Definition |
### `participants` JSONB array element
| Field | Type | Description |
| --- | --- | --- |
| `participantsCount` | Count of active participants | `backend/src/models/Chat.ts:259` |
| `userId` | string (UUID) | Member id. |
| `role` | `member` / `admin` / `owner` | Member role (default `member`). |
| `joinedAt` | ISO date string | Join time. |
| `lastSeen` | ISO date string? | Last activity. |
| `leftAt` | ISO date string? | Set on soft removal. |
| `isActive` | boolean | Still a participant (default `true`). Set to `false` on soft removal. |
## Indexes (MongoDB)
> [!note] Soft removal of participants
> Removing a participant does **not** delete the array element. It is a soft removal: `isActive` is set to `false` and `leftAt` is timestamped, preserving message attribution and history.
Defined at `backend/src/models/Chat.ts:243-247`:
### `messages` JSONB array element
- `{ 'participants.userId': 1 }`
- `{ 'metadata.lastActivity': -1 }`
- `{ 'relatedTo.type': 1, 'relatedTo.id': 1 }`
- `{ 'messages.timestamp': -1 }`
- `{ type: 1 }`
| Field | Type | Description |
| --- | --- | --- |
| `senderId` | string (UUID) | Author. |
| `senderType` | string | Currently fixed to `User`. |
| `content` | string | Message body. **maxlength 5000** enforced at controller. |
| `messageType` | `text` / `image` / `file` / `system` | Body kind (default `text`). |
| `fileUrl` | string? | If file/image. |
| `fileName` | string? | Original filename. |
| `fileSize` | number? | Bytes. |
| `timestamp` | ISO date string | Sent time. |
| `isRead` | boolean | Read flag (default `false`). |
| `isEdited` | boolean | Edited flag (default `false`). |
| `editedAt` | ISO date string? | When edited. |
| `deletedAt` | ISO date string? | Set on soft-delete; `content` is cleared but the element is kept. |
| `replyTo` | string? | Reply target message id. |
| `reactions` | `{ userId: string, reaction: string }[]` | Emoji reactions. |
## Pre/Post Hooks
> [!note] Messages are soft-deleted
> Deleting a message sets `deletedAt` and clears `content` (the body becomes empty). The message element is **not** physically removed from the `messages[]` JSONB array, and a `message-deleted` socket event is emitted.
| Hook | Behaviour |
## ID Field
The primary key is `id` (PostgreSQL UUID string). There is no `_id` field. The `legacy_object_id` column preserves the original MongoDB ObjectId for records migrated from Mongo, but is not used in application logic.
## Instance / Document Methods (removed)
Mongoose document methods `.addMessage()`, `.pull()`, and `.markAsRead()` no longer exist. The repository layer (`DrizzleChatRepo`) performs equivalent operations using plain array operations on the JSONB blobs (read → mutate array in JS → write back).
| Former Mongoose method | Replacement |
| --- | --- |
| `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>): 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.
| `chat.addMessage(data)` + `chat.save()` | `DrizzleChatRepo.addMessage(chatId, messageData)` — appends to JSONB array, updates `last_message`, increments unread counts, bumps `last_activity` |
| `chat.markAsRead(userId, messageIds?)` + `chat.save()` | `DrizzleChatRepo.markAsRead(chatId, userId, messageIds?)` — mutates `messages` JSONB array and zeroes `unread_counts` for that user |
| `chat.participants.pull(...)` | `DrizzleChatRepo.removeParticipant(chatId, participantId)` — soft-removes by setting `isActive: false`, `leftAt` in JSONB array |
## 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`.
- **References**: [[User]] (`participants[].userId`, `messages[].senderId`, `messages[].reactions[].userId`, `last_message.senderId`, `unread_counts[].userId`, `created_by`).
- **Referenced by**: [[Dispute]] (`chatId`), and any [[PurchaseRequest]] / [[SellerOffer]] indirectly through `related_to`.
## Migration Status
## Future Work: Chat Normalization
| 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 current JSONB-blob design unblocked the Mongo → PG migration but leaves these as known future improvements:
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)
1. Design a `chat_messages` table with proper threading/reply support (currently `replyTo` is embedded in the 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.
Until that work is complete, participants and messages in the `chats` table are not queryable relationally.
## State Transitions
No top-level status. Chat-level archival is a boolean flag (`settings.isArchived`):
No top-level status. Chat-level archival is a boolean flag (`settings_is_archived`):
```mermaid
stateDiagram-v2
@@ -209,21 +154,17 @@ stateDiagram-v2
## Common Queries
```ts
// A user's recent chats
Chat.find({ 'participants.userId': userId, 'participants.isActive': true })
.sort({ 'metadata.lastActivity': -1 });
// A user's recent chats (DrizzleChatRepo)
await chatRepo.findByParticipant(userId); // filters on participants JSONB, orders by last_activity desc
// Chat for a purchase request
Chat.findOne({ 'relatedTo.type': 'PurchaseRequest', 'relatedTo.id': prId });
await chatRepo.findByRelatedTo('PurchaseRequest', purchaseRequestId);
// Append a message
const chat = await Chat.findById(id);
chat.addMessage({ senderId, content: 'hi', messageType: 'text' });
await chat.save();
await chatRepo.addMessage(chatId, { senderId, content: 'hi', messageType: 'text' });
// Mark read
chat.markAsRead(userId);
await chat.save();
await chatRepo.markAsRead(chatId, userId);
```
Related: [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Dispute]].