Skip to Content
ChatChat & Presence — Implementation Plan

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

Deviation: ioredis and fastify-plugin were added to apps/api directly. 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 new IORedis instance 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 generate

Deviation: prisma migrate dev --name add_chat_messages could not run due to schema drift. prisma db push was 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:

  1. JWT from socket.handshake.auth.token (explicit pass from any portal)
  2. dashboard_access_token cookie (Dashboard HttpOnly)
  3. dm_access_token cookie (DM Portal HttpOnly)

applyManageAuthMiddleware(ns) — for /manage namespace. Tries:

  1. JWT from socket.handshake.auth.token
  2. manage_access_token cookie

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 /tenant with withCredentials: true (dashboard_access_token JWT cookie)
  • apps/manage/src/lib/socket.ts — connects to /manage with withCredentials: true (manage_access_token cookie)
  • apps/dm/src/lib/socket.ts — connects to /tenant with withCredentials: true (dm_access_token cookie)

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:3003

Note: Uses 127.0.0.1 (not localhost) 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 providers

Dashboard 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 on chat:send, deleted on chat:read)
  • Fetched on mount via GET /tenant/v1/chat/unread (proxied through Next.js API route at /api/chat/unread in Dashboard)
  • Updated in real-time via chat:read_ack socket 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 button
  • ConversationView.tsx — header, avatar, messages area, typing indicator, input
  • OnlineUsersList.tsx — labels, rows, avatars, hover
  • MessageBubble.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-client

Environment 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:3003

Rollout order

  1. Phase 1 (API backend) — ✅ shipped
  2. Phase 2 (presence indicators) — ✅ shipped
  3. Phase 3 (chat panel) — ✅ shipped
  4. 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]);

© 2026 Leadmetrics — Internal use only