Files
nick-doc/02 - Data Models/Chat.md
Siavash Sameni d072238fe8 docs: update PG migration status, data models, architecture + add Telegram Mini App flow (v2.8.59)
- Postgres Runtime Cutover Status: 17 migrations (0000–0017), dual-write repo matrix
- Backend Architecture: dual-DB architecture, repo factory, MONGO_CONNECT_MODE modes
- Data Model Overview: 23-model index with PG table names and migration status
- User, PurchaseRequest, SellerOffer, Chat, Dispute: Drizzle schema + cutover status added
- 04 - Flows/Telegram Mini App.md: new doc covering Mini App architecture and flows
- mongo-to-pg-migration-prd.md: status block prepended with 2026-06-03 milestone tracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:30:51 +04:00

12 KiB

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

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