171 lines
8.9 KiB
Markdown
171 lines
8.9 KiB
Markdown
---
|
|
title: Chat
|
|
tags: [data-model, postgres, drizzle]
|
|
aliases: [Conversation, IChat, IMessage]
|
|
---
|
|
|
|
# Chat
|
|
> **Last updated:** 2026-06-06 — MongoDB 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/db/schema/chat.ts` — PostgreSQL schema (Drizzle)
|
|
> `backend/src/repositories/drizzle/DrizzleChatRepo.ts` — repository implementation
|
|
|
|
> [!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.
|
|
|
|
## Schema — `chats` table (PostgreSQL / Drizzle)
|
|
|
|
> Source: `backend/src/db/schema/chat.ts`
|
|
|
|
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`)
|
|
|
|
| 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 (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 — 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 |
|
|
| `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 | — | 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 legacy Mongo ObjectIds (in `legacy_object_id`) and PG UUIDs during the transition period.
|
|
|
|
## Chat Schema — participants and messages (JSONB field shapes)
|
|
|
|
### `participants` JSONB array element
|
|
|
|
| Field | Type | Description |
|
|
| --- | --- | --- |
|
|
| `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. |
|
|
|
|
> [!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.
|
|
|
|
### `messages` JSONB array element
|
|
|
|
| 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. |
|
|
|
|
> [!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.
|
|
|
|
## 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 |
|
|
| --- | --- |
|
|
| `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`, `last_message.senderId`, `unread_counts[].userId`, `created_by`).
|
|
- **Referenced by**: [[Dispute]] (`chatId`), and any [[PurchaseRequest]] / [[SellerOffer]] indirectly through `related_to`.
|
|
|
|
## Future Work: Chat Normalization
|
|
|
|
The current JSONB-blob design unblocked the Mongo → PG migration but leaves these as known future improvements:
|
|
|
|
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, 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_is_archived`):
|
|
|
|
```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 (DrizzleChatRepo)
|
|
await chatRepo.findByParticipant(userId); // filters on participants JSONB, orders by last_activity desc
|
|
|
|
// Chat for a purchase request
|
|
await chatRepo.findByRelatedTo('PurchaseRequest', purchaseRequestId);
|
|
|
|
// Append a message
|
|
await chatRepo.addMessage(chatId, { senderId, content: 'hi', messageType: 'text' });
|
|
|
|
// Mark read
|
|
await chatRepo.markAsRead(chatId, userId);
|
|
```
|
|
|
|
Related: [[User]], [[PurchaseRequest]], [[SellerOffer]], [[Dispute]].
|