Chat & Presence — Architecture
Component Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ Browser (React) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Manage │ │ Dashboard │ │ DM Portal │ │
│ │ (port 3001) │ │ (port 3000) │ │ (port 3002) │ │
│ │ │ │ │ │ │ │
│ │ OnlineUsers │ │ ChatPanel │ │ ChatPanel │ │
│ │ ChatPanel │ │ UserStatus │ │ UserStatus │ │
│ └──────┬───────┘ └──────┬───────┘ └────────────┬─────────────┘ │
│ │ │ │ │
│ └──────────────────┴────────────────────────┘ │
│ │ socket.io-client │
└─────────────────────────────┼───────────────────────────────────────────┘
│ WebSocket (ws://api/socket.io)
│ fallback: HTTP long-poll
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Fastify API (apps/api, port 3003) │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ fastify-socket.io │ │
│ │ │ │
│ │ Namespace /manage ←─ super_admin role only │ │
│ │ Namespace /tenant ←─ admin, member, reviewer roles │ │
│ │ │ │
│ │ Middleware (runs at handshake): │ │
│ │ • verify JWT token from auth header / query param │ │
│ │ • attach user + tenantId to socket.data │ │
│ │ │ │
│ │ On connect: │ │
│ │ • join room tenant:{tenantId} │ │
│ │ • set Redis presence key (SETEX, 90s TTL) │ │
│ │ • emit user:online to room │ │
│ │ │ │
│ │ On disconnect: │ │
│ │ • delete Redis presence key │ │
│ │ • emit user:offline to room │ │
│ │ │ │
│ │ On chat:send: │ │
│ │ • validate sender can reach recipient (tenant check) │ │
│ │ • persist ChatMessage to PostgreSQL │ │
│ │ • emit chat:message to recipient's personal room │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ @socket.io/redis-adapter │ │
│ │ Fans out events across all API instances via Redis pub/sub │ │
│ └──────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
Redis (existing)
┌──────────────────────────┐
│ presence:{userId} │ 90s TTL — online heartbeat
│ presence:tenant:{id} │ sorted set — online users list
│ socket.io adapter keys │ auto-managed by adapter
└──────────────────────────┘
│
PostgreSQL (existing)
┌──────────────────────────┐
│ ChatMessage │ persisted messages
└──────────────────────────┘Authentication Flow
All three portals use the same JWT (HS256) auth system. The Socket.IO client sends the access token in the auth option at connection time.
Dashboard, DM Portal, and Manage (JWT)
Each portal reads its httpOnly access token cookie and passes it to Socket.IO at connection time:
- Cookie header (sent automatically by browser for HTTP-upgrade requests)
- Query parameter
?token=<jwt>(fallback for environments that strip cookies on WebSocket upgrade)
The Socket.IO middleware on the API verifies the JWT with verifyToken() and attaches socket.data.userId, socket.data.tenantId, socket.data.role before allowing the connection.
DM Portal (example)
The DM portal reads its JWT cookie and passes it explicitly:
// apps/dm/src/lib/socket.ts
const socket = io(process.env.NEXT_PUBLIC_API_URL, {
auth: { token: getJwtFromCookie() },
namespace: '/tenant',
});The middleware verifies it with the same verifyToken() helper used by the REST routes.
Super Admin (Manage)
Connects to /manage namespace. Middleware checks socket.data.role === 'super_admin'. Any other role is rejected with a 403. In the /manage namespace, the socket joins a global room manage:global in addition to any tenant rooms it monitors.
Rooms Layout
Namespace /tenant
├── tenant:{tenantId} — all users of a tenant (presence broadcasts)
└── user:{userId} — personal room for targeted DM delivery
Namespace /manage
├── manage:global — all super admins
└── tenant:{tenantId} — super admin can join any tenant room to monitor
(joined on demand when admin opens a tenant's user list)Why a personal user:{userId} room?
When user A sends a DM to user B, the server emits chat:message to user:{userB-id}. This works even if userB is connected to a different API instance (the Redis adapter handles the fan-out). It also handles the case where userB has multiple tabs open — all tabs receive the message because all sockets for userB join the same personal room.
Presence Tracking
Online presence uses Redis TTL keys rather than a database column, because:
- Presence is ephemeral — it does not need to survive a Redis restart
- Database writes on every heartbeat (every 60s × N users) would create unnecessary load
- Redis SETEX is O(1) and sub-millisecond
Keys
presence:{userId} STRING "online" TTL: 90s
presence:tenant:{tenantId} ZSET member=userId, score=connectedAt timestampHeartbeat
The client emits presence:heartbeat every 60 seconds. The server handler refreshes the TTL with EXPIRE presence:{userId} 90. If a browser tab freezes or network drops without a clean disconnect event, the key expires after 90 seconds and subsequent presence checks return offline.
Querying online users
To get all online users in a tenant:
// Server side
const members = await redis.zrangebyscore(`presence:tenant:${tenantId}`, '-inf', '+inf');
// Filter to only those with a live presence key
const online = await Promise.all(
members.map(async (userId) => {
const alive = await redis.exists(`presence:${userId}`);
return alive ? userId : null;
})
).then((results) => results.filter(Boolean));This is called once when a client requests presence:list and thereafter updated incrementally via user:online / user:offline events.
Tenant Isolation Enforcement
All server-side event handlers validate tenant membership before acting:
// Pseudocode — actual implementation in apps/api/src/socket/handlers/chat.ts
socket.on('chat:send', async (payload) => {
const { recipientId, content } = payload;
const senderId = socket.data.userId;
const tenantId = socket.data.tenantId;
// Super admin bypass: no tenant check needed
if (socket.data.role !== 'super_admin') {
const isSameTenant = await db.tenantMember.findFirst({
where: { userId: recipientId, tenantId },
});
if (!isSameTenant) {
socket.emit('chat:error', { code: 'FORBIDDEN', message: 'Recipient not in your tenant' });
return;
}
}
// Proceed with persisting and delivering the message
});The frontend never has a “bypass” — all permission checks run server-side.
Multi-Instance Scaling
The @socket.io/redis-adapter is configured at API startup:
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'ioredis';
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));Once configured, io.to('tenant:abc123').emit(...) automatically reaches all sockets across all running API instances. No application-level coordination is needed.