---
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]].
