Skip to Content
ChatChat & Presence — Data Schema

Chat & Presence — Data Schema

PostgreSQL (Prisma)

New model: ChatMessage

Add to packages/db/prisma/schema.prisma:

model ChatMessage { id String @id @default(cuid()) tenantId String // tenant scope; for super-admin DMs use recipient's tenantId senderId String recipientId String content String createdAt DateTime @default(now()) readAt DateTime? // null = unread tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) sender User @relation("SentMessages", fields: [senderId], references: [id]) recipient User @relation("ReceivedMessages", fields: [recipientId], references: [id]) @@index([tenantId, createdAt]) @@index([senderId, recipientId, createdAt]) // conversation thread lookup @@index([recipientId, readAt]) // unread count lookup @@map("chat_messages") }

Additions to existing models

// Append to User model model User { // ... existing fields ... sentMessages ChatMessage[] @relation("SentMessages") receivedMessages ChatMessage[] @relation("ReceivedMessages") } // Append to Tenant model model Tenant { // ... existing fields ... chatMessages ChatMessage[] }

Why no ChatThread / Conversation table?

A conversation between user A and user B is always identified by the sorted pair of their IDs. Queries are by (senderId, recipientId) OR (senderId=B, recipientId=A). A separate conversation table adds a join with no benefit until we need group chat or conversation metadata (title, archived state). That can be added later.

Migration

# From packages/db/ pnpm prisma migrate dev --name add_chat_messages pnpm prisma generate

Redis Key Conventions

All keys are namespaced under chat: or presence: to avoid collision with existing BullMQ and audit-log keys.

Presence

KeyTypeValueTTLPurpose
presence:{userId}STRING"1"90sOnline heartbeat; expiry = offline
presence:tenant:{tenantId}ZSETmember=userId, score=unix timestampnoneFast lookup of all users ever connected to tenant

The sorted set is a roster — it never shrinks automatically. The presence:{userId} key is the source of truth for online/offline. Before serving the list, filter the sorted set by checking which members have a live presence:{userId} key.

Unread counts (optional optimisation)

KeyTypeValueTTLPurpose
unread:{userId}:{senderId}STRINGintegernoneUnread messages from a specific sender

Increment on chat:send, reset to 0 when recipient emits chat:read for that sender. Allows the UI to show badge counts without a database query. Cleared (DEL) when the user reads the conversation.

Socket.IO adapter

The @socket.io/redis-adapter uses keys prefixed socket.io#. Do not manually read or write these keys.


Retention & Cleanup

Chat messages are retained indefinitely by default (same as other content in the platform). A future cleanup job could archive messages older than 90 days to cold storage if needed. No schema changes are required for that — it is an operational concern.

© 2026 Leadmetrics — Internal use only