Files
nick-doc/02 - Data Models/Chat.md

8.9 KiB

title, tags, aliases
title tags aliases
Chat
data-model
postgres
drizzle
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):

stateDiagram-v2
    [*] --> active
    active --> muted : user mutes
    muted --> active : unmute / mute expires
    active --> archived : user archives
    archived --> active : restore

Common Queries

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