Chat & Presence — Implementation Plan
Status: All four phases implemented (2026-04-03). Browser-tested and verified 2026-04-03. See deviation notes after each phase for what differed from the plan. See Bugs found & fixed section at the bottom for issues discovered during browser testing.
Work is split into four phases. Each phase is independently shippable — Phase 1 alone (presence tracking with no UI) is a valid first deploy, and each subsequent phase adds visible user value.
Phase 1 — Socket.IO server + presence tracking ✅ Done
Goal: Users connecting to any portal appear online. Presence data is queryable via REST and real-time via Socket.IO. No UI yet.
1.1 Install packages
# In apps/api
pnpm add socket.io fastify-socket.io @socket.io/redis-adapter ioredis fastify-plugin
# In apps/dashboard, apps/manage, apps/dm
pnpm add socket.io-clientDeviation:
ioredisandfastify-pluginwere added toapps/apidirectly. The plan referenced@leadmetrics/nosqldb’s Redis client, but that package doesn’t export a general-purpose client singleton — only the audit publisher/subscriber. Creating a newIORedisinstance in the socket plugin is the correct approach.
1.2 Database schema
File: packages/db/prisma/schema.prisma
Add the ChatMessage model and its relations to User and Tenant as described in schema.md.
# Schema was applied via db push (migrate dev failed due to existing schema drift)
cd packages/db
DATABASE_URL=... pnpm prisma db push
# Regenerate client (must stop dev server first on Windows — DLL file lock)
pnpm prisma generateDeviation:
prisma migrate dev --name add_chat_messagescould not run due to schema drift.prisma db pushwas used instead. A future cleanup migration can be created once drift is resolved. The Prisma client may need manual regeneration after restarting the API dev server (Windows DLL file lock prevents it while the server is running).
1.3 Socket.IO server setup
File: apps/api/src/socket/index.ts
Fastify plugin wrapping fastify-socket.io. Attaches @socket.io/redis-adapter inside fastify.after() (required — fastify.io is not yet decorated until the inner plugin runs). Mounts /tenant and /manage namespaces, creates a third general-purpose Redis client for presence/unread ops.
Registered in apps/api/src/index.ts:
import socketPlugin from "./socket/index";
await fastify.register(socketPlugin);1.4 Auth middleware
File: apps/api/src/socket/middleware/auth.ts
Two exported functions:
applyTenantAuthMiddleware(ns) — for /tenant namespace. Tries in order:
- JWT from
socket.handshake.auth.token(explicit pass from any portal) dashboard_access_tokencookie (Dashboard HttpOnly)dm_access_tokencookie (DM Portal HttpOnly)
applyManageAuthMiddleware(ns) — for /manage namespace. Tries:
- JWT from
socket.handshake.auth.token manage_access_tokencookie
Rejects any role other than super_admin for the manage namespace.
1.5 Tenant namespace
File: apps/api/src/socket/namespaces/tenant.ts
Joins tenant:{tenantId} + user:{userId} rooms. Sets presence:{userId} SETEX 90s. Broadcasts user:online/user:offline. Handles presence:heartbeat, presence:list. Delegates chat to registerChatHandlers().
1.6 Manage namespace
File: apps/api/src/socket/namespaces/manage.ts
Same pattern but joins manage:global. presence:list scans all presence:tenant:* ZSET keys across tenants.
1.7 REST endpoints for initial presence (non-WebSocket fallback)
Added to apps/api/src/routers/tenant.ts:
GET /tenant/v1/presence → { users: [{ id, name, image, role }] }
GET /tenant/v1/chat/unread → { unread: { [senderId]: count } }Phase 2 — Online/Offline indicators in portal UIs ✅ Done
Goal: Every place a user’s avatar or name appears shows a green/grey dot.
2.1 Shared Socket.IO client hook
Each app has its own socket singleton:
apps/dashboard/src/lib/socket.ts— connects to/tenantwithwithCredentials: true(dashboard_access_tokenJWT cookie)apps/manage/src/lib/socket.ts— connects to/managewithwithCredentials: true(manage_access_tokencookie)apps/dm/src/lib/socket.ts— connects to/tenantwithwithCredentials: true(dm_access_tokencookie)
2.2 Online status context
PresenceProvider component in each app’s components/chat/ directory. Wrapped at the layout level.
2.3 OnlineIndicator component
File: {app}/src/components/chat/OnlineIndicator.tsx
Green dot (bg-green-500) or grey dot (bg-gray-300). Sizes: sm (10px) or md (14px). Reads from usePresence(userId).
2.4 Environment variables
Added to each app’s .env.local:
NEXT_PUBLIC_API_SOCKET_URL=http://127.0.0.1:3003Note: Uses
127.0.0.1(notlocalhost) consistent with the Windows IPv4 pattern already established for the API URL.
Phase 3 — Chat panel UI ✅ Done
Goal: A slide-in chat panel accessible from all portals.
3.1 File layout (per app)
{app}/src/components/chat/
PresenceProvider.tsx — online users context (WebSocket lifecycle + heartbeat)
ChatProvider.tsx — chat state (messages, unread, typing, optimistic sends)
ChatFAB.tsx — fixed bottom-right button with unread badge
ChatPanel.tsx — 320×480px floating panel, switches between list/conversation
OnlineUsersList.tsx — list of online peers with unread badges + Message button
ConversationView.tsx — message thread + send input + typing indicator
MessageBubble.tsx — individual bubble with ✓/✓✓ read receipts
OnlineIndicator.tsx — green/grey dot component
ChatShell.tsx — (Manage + DM only) client wrapper composing all providersDashboard wraps directly in layout.tsx (server component can import client providers because Next.js handles the boundary). Manage and DM use ChatShell as a client wrapper imported from the server layout.
3.2 Layout integration
// apps/dashboard/src/app/(dashboard)/layout.tsx
<PresenceProvider>
<ChatProvider currentUserId={session.user.id}>
{/* app shell */}
<ChatFAB />
<ChatPanel currentUserId={session.user.id} />
</ChatProvider>
</PresenceProvider>
// apps/manage/src/app/(manage)/layout.tsx (uses ChatShell wrapper)
<ChatShell currentUserId={payload.sub}>
{/* app shell */}
</ChatShell>
// apps/dm/src/app/(dm)/layout.tsx (same ChatShell pattern)3.3 Optimistic sends
chat:send immediately inserts a pending: true message into the conversation. chat:sent ack from server replaces clientMsgId with real messageId. chat:error removes the optimistic message.
Phase 4 — Unread counts, notifications, polish ✅ Partially Done
4.1 Unread counts — Done
- Persisted in Redis:
unread:{userId}:{senderId}(incremented onchat:send, deleted onchat:read) - Fetched on mount via
GET /tenant/v1/chat/unread(proxied through Next.js API route at/api/chat/unreadin Dashboard) - Updated in real-time via
chat:read_acksocket event
4.2 Notification sound on new message — ✅ Done (2026-04-14)
playNotificationSound() added to packages/ui/src/chat/ChatProvider.tsx. Uses the Web Audio API to synthesise a short descending tone (880 Hz → 440 Hz, 0.25 s) — no audio file required. Called inside the chat:message socket listener on every inbound message. Silently no-ops on SSR or before the first user gesture (browsers block audio until interaction).
4.3 Dark mode support — ✅ Done (2026-04-14)
All chat components in all three apps (dashboard, dm, manage) updated with full dark mode Tailwind classes. Every hardcoded light class (bg-white, bg-slate-100, border-slate-100, text-slate-800, hover:bg-slate-100) is now paired with its dark: counterpart. Input field also gets explicit text-slate-900 dark:text-slate-100 and selection:bg-violet-300 selection:text-slate-900 dark:selection:bg-violet-500 dark:selection:text-white to fix selected-text-invisible bug in dark mode.
Files updated per app:
ChatPanel.tsx— panel bg, borders, header, close buttonConversationView.tsx— header, avatar, messages area, typing indicator, inputOnlineUsersList.tsx— labels, rows, avatars, hoverMessageBubble.tsx— received bubbles (dark:bg-slate-700 dark:text-slate-100)
4.4 Offline-peer unread display — ✅ Done (2026-04-14)
OnlineUsersList now shows peers who sent unread messages even when they are no longer online (i.e. not in the presence list). Peer name is resolved from the senderName field in the stored ChatMessage. Offline peers appear under a separate “Unread Messages” section below the “Online Now” section, without an online indicator dot.
This fixes the race condition where a user sends a message then disconnects — the recipient’s chat panel previously showed an empty list despite having an unread badge.
Implementation: derives offline peers from unreadCounts.entries() filtered against onlinePeerIds, then looks up name from conversations.get(peerId).
4.5 Connecting state — ✅ Done (2026-04-14)
OnlineUsersList now reads isConnected from usePresence() and renders "Connecting…" instead of "No one else is online right now." while the socket is establishing. This prevents a false “empty” state during the ~200 ms reconnect window after Next.js hot-module replacement causes PresenceProvider to remount.
4.6 Push notification (when user is offline) — Not yet done
BullMQ job enqueue when no active socket for recipient. Deferred.
4.7 Rate limiting — Not yet done
Redis counter per user per minute. Deferred.
4.8 Input sanitisation — Partial
Content is .trim()-ed and length-capped at 4000 chars. Full HTML strip (e.g. sanitize-html) not yet added.
Package install summary (actual)
# API
pnpm add --filter @leadmetrics/api socket.io fastify-socket.io @socket.io/redis-adapter ioredis fastify-plugin
# Dashboard
pnpm add --filter @leadmetrics/dashboard socket.io-client
# Manage
pnpm add --filter @leadmetrics/manage socket.io-client
# DM
pnpm add --filter @leadmetrics/dm socket.io-clientEnvironment variables
# apps/dashboard/.env.local (added)
NEXT_PUBLIC_API_SOCKET_URL=http://127.0.0.1:3003
# apps/manage/.env.local (added)
NEXT_PUBLIC_API_SOCKET_URL=http://127.0.0.1:3003
# apps/dm/.env.local (created; was missing)
NEXT_PUBLIC_API_SOCKET_URL=http://127.0.0.1:3003
API_URL=http://127.0.0.1:3003Rollout order
- Phase 1 (API backend) — ✅ shipped
- Phase 2 (presence indicators) — ✅ shipped
- Phase 3 (chat panel) — ✅ shipped
- Phase 4 (unread/notifications) — partially shipped; push notifications and rate limiting deferred
Bugs found & fixed (2026-04-03 browser testing)
Bug 1 — tenantId: null in Dashboard socket JWT
File: apps/dashboard/src/app/api/socket-token/route.ts
Problem: Better Auth’s session.user object does not include custom fields like tenantId. The route was casting it as { tenantId?: string } which always resolved to null. With no tenantId in the JWT, the socket middleware set socket.data.tenantId = null, preventing the socket from joining the tenant:{tenantId} room. Presence list always returned empty.
Fix: Added a db.user.findUnique call to look up tenantId and role from the database before signing the JWT.
Bug 2 — presence:list returned id instead of userId
Files: apps/api/src/socket/namespaces/tenant.ts, apps/api/src/socket/namespaces/manage.ts
Problem: The presence:list handler fetched users from the DB (which returns { id, name, ... }) and sent them directly. But all client-side presence code (PresenceProvider, OnlineIndicator) maps users by u.userId — so the Map was keyed on undefined and isOnline() always returned false.
Fix: Map id → userId before emitting: dbUsers.map(u => ({ userId: u.id, ...u })).
Bug 3 — Duplicate messages on history load
Files: apps/dashboard/src/components/chat/ChatProvider.tsx, apps/manage/src/components/chat/ChatProvider.tsx, apps/dm/src/components/chat/ChatProvider.tsx
Problem: When chat:history was received (triggered on conversation open), it prepended all history to the current messages. If a chat:message event had already added the same message to state (e.g., a live message received before history loaded, or a socket reconnect), the message appeared multiple times and React threw “duplicate key” warnings.
Fix: Deduplicate by messageId before prepending history:
const existingIds = new Set(existing.map((m) => m.messageId));
const newMsgs = messages.filter((m) => !existingIds.has(m.messageId));
next.set(peerId, [...newMsgs, ...existing]);