- 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>
12 KiB
title, tags, aliases
| title | tags | aliases | ||||||
|---|---|---|---|---|---|---|---|---|
| Chat |
|
|
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 definitionbackend/src/models/Chat.ts:69— message subdocument schemabackend/src/models/Chat.ts:348— model exportbackend/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]
relatedTois NOT set viaPOST /api/chatAlthoughrelatedToexists in the schema, it is not accepted by thePOST /api/chatcreate endpoint. Purchase-request linkage is established server-side through the dedicatedPOST /api/chat/purchase-request, not by passingrelatedToto 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 Postgreschatstable is a conservative shim:participantsandmessagesare 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
timestampsUnlike most models, this schema does not pass{ timestamps: true }. It uses its ownmetadata.createdAt/metadata.updatedAtinstead, 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:isActiveis set tofalseandleftAtis 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
deletedAtand clearscontent(the body becomes empty). The message subdocument is not physically removed frommessages[], and amessage-deletedsocket 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[]andChatMessage[]), 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 (separatechat_messagesandchat_participantstables 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
userscreated_byis stored astext(notuuidFK) 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 throughrelatedTo.
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:
- Design a
chat_messagestable with proper threading/reply support (currentlyreplyTois an ObjectId embedded in a JSONB blob) - Design a
chat_participantstable (currently a JSONB blob with soft-removal semantics) - Migrate reactions, edit history, and read tracking to relational rows
- 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.