Messaging System
The messaging system enables direct user-to-user communication and group conversations within the AIQLick platform. It supports 1:1 direct messages, multi-user group chats, company-scoped conversations, typing indicators, and online presence tracking — all powered by real-time WebSocket subscriptions via Redis PubSub.
Conversation Types
| Type | Description | Participants | Title |
|---|---|---|---|
DIRECT | 1:1 conversation between two users | Exactly 2 | Auto-generated from participant names |
GROUP | Multi-user conversation | 2–51 (creator + up to 50) | Required, set by creator |
Data Model
Denormalized Fields
The Conversation model stores lastMessageAt, lastMessageText, and messageCount directly to avoid expensive JOIN queries when rendering the conversation list. These are updated atomically inside the same transaction that creates the message.
Unread Count
Unread messages per conversation are computed as:
COUNT(messages WHERE createdAt > participant.lastReadAt AND senderId != currentUser AND status != DELETED)
If lastReadAt is NULL (user has never read the conversation), all messages from other senders count as unread. Unread counts are batch-computed in a single query to avoid N+1 problems.
Conversation Lifecycle
Direct Messages
Direct conversations are deduplicated per companyId — if a 1:1 conversation already exists between two users within the same company context, the existing one is returned. The deduplication uses a raw SQL query with FOR UPDATE locking to prevent race conditions.
Company-scoped deduplication rules:
| Scenario | Result |
|---|---|
User A ↔ User B with companyId = X | Returns existing conversation scoped to company X |
User A ↔ User B with companyId = Y | Creates a new conversation scoped to company Y |
User A ↔ User B with companyId = NULL | Returns existing personal conversation (null scope) |
User A ↔ User B with companyId = NULL when one already exists with companyId = X | Creates a new personal conversation (null and non-null are separate) |
The SQL uses explicit IS NULL branching to handle null companyId correctly (since NULL != NULL in SQL):
WHERE c.type = 'DIRECT'
AND (
($companyId::uuid IS NOT NULL AND c."companyId" = $companyId::uuid)
OR ($companyId::uuid IS NULL AND c."companyId" IS NULL)
)
AND (SELECT COUNT(*) FROM "ConversationParticipant" cp
WHERE cp."conversationId" = c.id
AND cp."userId" IN ($userA::uuid, $userB::uuid)
AND cp."leftAt" IS NULL) = 2
This ensures that the same two users can have separate direct conversations in different company contexts without collisions, while personal conversations (null companyId) are still properly deduplicated among themselves.
Group Conversations
Group Management Permissions
| Action | OWNER | ADMIN | MEMBER |
|---|---|---|---|
| Rename group | Yes | Yes | No |
| Add participants | Yes | Yes | No |
| Remove other participants | Yes | Yes | No |
| Leave group | Yes | Yes | Yes |
When a user leaves a group, their ConversationParticipant.leftAt is set (soft-leave). They can be re-added later via addGroupParticipants, which clears leftAt and resets joinedAt.
There is no mutation to change participant roles after creation. The creator is always OWNER, added participants are always MEMBER.
Message Lifecycle
Sending
- Verify sender is an active participant (
leftAt IS NULL) - Validate message has
contentORattachments(at least one required; whitespace-only content is rejected) - Validate
replyToIdbelongs to the same conversation (if provided) - If attachments provided, validate each against allowlist (content type, size ≤ 25MB, objectPath prefix)
- Create
Messagerecord andMessageAttachmentrecords (if any) in a single transaction - Update
Conversationdenormalized fields (lastMessageAt,lastMessageText,messageCount)- For attachment-only messages, preview text is "Sent an attachment" / "Sent N attachments"
- Auto-mark conversation as read for the sender
- Publish message to all participants (including the sender) via Redis PubSub, with
isSelf: trueflag on the sender's copy - Publish updated unread count to all non-sender participants
- Emit
message.sentevent for offline notifications
Editing
- Only the original sender can edit their message
- Cannot edit a deleted message
- Sets
isEdited = trueandeditedAttimestamp - Publishes the updated message to all participants
Deleting (Soft Delete)
- Only the original sender can delete their message
- Sets
status = DELETED,deletedAttimestamp, and clearscontent - If the message has attachments, they are deleted from the database in the same transaction, then cleaned up from S3 (fire-and-forget)
- The message record remains in the database (for reply references)
- Frontend should render as "This message was deleted"
Real-Time Delivery
Five WebSocket subscriptions power the real-time features, all using the graphql-ws protocol over the shared backendWsClient singleton:
| Subscription | Channel Pattern | Purpose |
|---|---|---|
messageReceived | messages:user:{userId} | New, edited, and deleted messages |
unreadMessageCountChanged | messages:unread:{userId} | Unread count updates |
typingIndicatorChanged | messages:typing:{conversationId} | Typing start/stop per user per conversation |
presenceChanged | messages:presence:{userId} | Online/offline status changes |
readReceiptChanged | messages:readreceipt:{userId} | Read receipt updates from other participants |
Messages are published to every active participant's user channel, including the sender. The sender's copy includes an isSelf: true flag so the frontend can distinguish between own messages (for confirmation/sync) and messages from others.
Typing Indicators
The typing indicator system provides real-time "X is typing..." feedback:
Backend:
sendTypingIndicatormutation publishes a typing event tomessages:typing:{conversationId}- Events are broadcast to all participants except the sender
- Each event includes
conversationId,userId,isTyping,firstName,lastName
Frontend:
- Sending: Debounced at 2.5 seconds — the mutation fires at most every 2.5s while the user types in
MessageInput - Receiving: Each indicator auto-expires after 4 seconds via
setTimeoutper user per conversation - Message arrival: When a
messageReceivedevent arrives from a user, their typing indicator is cleared immediately - Display: Shows up to 2 names ("Alice is typing...", "Alice and Bob are typing..."), then "+N more" for larger groups
- Cleanup: All pending typing timers are cleared on unmount and company context switch
Online Presence
The presence system tracks which users are currently online:
Backend (PresenceService):
- Stores online status in
User.lastSeentimestamp - Online threshold: 2 minutes — users are considered online if
lastSeenis within the last 120 seconds - Debounce: Updates are throttled to every 30 seconds in-memory to reduce database writes
- Eviction: In-memory tracking map is cleaned every 5 minutes to prevent memory leaks
- Publishes
presenceChangedevents via Redis PubSub when a user transitions online/offline
Frontend (useMessaging hook):
- Initial load:
contactPresencequery seeds thepresenceMapwith current status for all conversation participants - Real-time:
presenceChangedsubscription updates the map as users come online/offline - Heartbeat: Sends a
heartbeatmutation every 60 seconds to maintain online status (stored in a ref to prevent interval resets on re-render) - Offline signal: On page close, a
goOfflinemutation fires viafetchwithkeepalive: trueto reliably signal disconnect - Display: Direct messages show the other user's online status or "last seen X ago" under the conversation title
Read Receipts
Read receipts provide real-time feedback on whether a message has been read by the recipient. The system uses a combination of database tracking and WebSocket push to deliver instant "blue double tick" indicators.
How It Works
Each ConversationParticipant tracks lastReadAt (timestamp) and lastReadMessageId (the most recent message read). When a participant reads a conversation, the backend updates these fields and publishes a readReceiptChanged event to all other participants via Redis PubSub.
Trigger Points
Read receipts are published in two scenarios:
| Trigger | When | Read Receipt Published To |
|---|---|---|
markConversationAsRead mutation | User opens a conversation or receives a message in the active conversation | All other active participants |
sendMessage mutation | Sender's lastReadAt is auto-updated to the new message's timestamp | All other active participants |
UI Display
The ReadReceiptTicks component in MessageBubble renders tick indicators on the sender's own messages:
| State | Icon | Condition |
|---|---|---|
| Sent | Single gray tick | Default for own messages |
| Read | Double blue tick | Any other participant has lastReadAt >= message.createdAt |
For group conversations, a message shows as "read" if at least one other participant has read it.
Frontend State Flow
readReceiptChangedsubscription event arrives via WebSocketuseMessaginghook updateswsConversations[].participants[].lastReadAtin React state- This triggers re-render of
activeConversation→MessageThread→MessageBubble→ReadReceiptTicks - The
useMemoinReadReceiptTicksrecalculatesisReadand updates the tick display
Notification Integration
When a message is sent, the MessagingNotificationListener creates an in-app notification for each participant (except the sender) via the existing NotificationService. Notifications respect:
isMuted— muted conversations produce no notifications (unless the user is @mentioned)notificationsEnabled— per-conversation notification toggle (unless the user is @mentioned)
| Conversation Type | Notification Title | Notification Message |
|---|---|---|
| DIRECT | "New message from {senderName}" | Message preview (max 100 chars) |
| GROUP | "New message in {groupTitle}" | "{senderName}: {preview}" |
Notification type: MESSAGE_RECEIVED, category: MESSAGING. The actionUrl is set to /company/messages/{conversationId}.
@Mention Notifications
Users can mention other participants in group chat messages by typing @Name. When the backend processes a sent message, it extracts mentions, resolves them to participant user IDs, and creates high-priority MESSAGE_MENTION notifications.
How mentions are resolved:
- The
resolveMentions()method extracts@Namepatterns from the message content using a regex - Extracted names are matched against participant full names and first names (case-insensitive)
- The sender is excluded from self-mention matching
- Resolved user IDs are passed to the notification listener via the
message.sentevent payload
Mention regex pattern:
/@([A-Z][a-zA-Z\u00C0-\u00FF'-]*(?:\s[A-Z][a-zA-Z\u00C0-\u00FF'-]*){0,2})/g
This matches @ followed by 1 to 3 capitalized name words, supporting accented characters (e.g., @Jean-Pierre Dupont, @Maria, @Ana Sofia).
Notification flow:
Mention vs regular notification comparison:
| Aspect | MESSAGE_RECEIVED | MESSAGE_MENTION |
|---|---|---|
| Priority | NORMAL | HIGH |
| Respects mute | Yes | No (bypasses mute) |
| Respects notificationsEnabled | Yes | No (bypasses toggle) |
| Title (group) | "New message in {groupTitle}" | "{senderName} mentioned you in {groupTitle}" |
| Title (direct) | "New message from {senderName}" | "{senderName} mentioned you" |
| Metadata | { messageId, senderId, senderName } | { messageId, senderId, senderName, mentioned: true } |
Deduplication: Before creating a notification, the listener checks for an existing notification with the same userId + type + resourceId + messageId combination. This prevents duplicate notifications if the event is processed more than once.
Mentions are only resolved against active participants in the conversation. If a user has left the group (leftAt is set), they will not receive mention notifications even if their name appears in the message.
Company Scoping
Conversations can optionally be scoped to a company via companyId:
- When
companyIdis set, the conversation appears when filtering by that company - When creating a group conversation with a
companyId, the creator's access to that company is validated viaUserCompanyRole - The
myConversationsquery supports an optionalcompanyIdfilter unreadMessageCountsupports an optionalcompanyIdfilter for company-specific badge counts- Direct messages with
companyId = NULLare personal conversations visible regardless of company context
When an HR person messages a candidate:
- HR view: Conversation has
companyIdset → visible in their company-scoped list - Candidate view: No
companyIdfilter → sees all conversations they participate in, including the HR conversation - HR switches company: Hook resets all state, new company's conversations load, old conversation disappears from the list
Pagination
Both conversation lists and message histories use cursor-based pagination:
| Query | Default Limit | Max Limit | Order |
|---|---|---|---|
myConversations | 20 | 50 | lastMessageAt DESC, then createdAt DESC |
messages | 30 | 100 | createdAt DESC (newest first) |
The cursor is the id of the last item in the current page. Pass it as cursor in the next request to get the next page. The response includes hasMore and nextCursor.
File Attachments
Messages can include file attachments (up to 10 per message). Attachments use the existing S3 upload infrastructure — the frontend obtains a presigned upload URL, uploads directly to S3, then passes the objectPath in the sendMessage mutation.
Upload Flow
Allowed Content Types
| Category | Types |
|---|---|
| Images | image/jpeg, image/png, image/gif, image/webp, image/svg+xml |
| Documents | application/pdf, application/msword, .docx, .xls, .xlsx, .ppt, .pptx |
| Text | text/plain, text/csv |
| Archives | application/zip, application/x-zip-compressed |
Limits
| Constraint | Value |
|---|---|
| Max file size | 25 MB |
| Max attachments per message | 10 |
| objectPath prefix | Must start with uploads/ |
| Download URL expiry | 1 hour (presigned) |
Cleanup
When a message with attachments is deleted, the backend:
- Fetches attachment objectPaths before the transaction
- Deletes
MessageAttachmentrecords in the same transaction as the message soft-delete - Fires off S3
deleteFile()calls asynchronously (fire-and-forget) for each attachment
Frontend Architecture
Optimistic Message Rendering
When a message arrives via WebSocket, the frontend displays it immediately using local state — without waiting for the Apollo cache refetch round-trip:
- WebSocket
messageReceivedevent → message appended tooptimisticMessagesstate - Apollo
refetchMessages()triggers in parallel - When Apollo cache updates, optimistic messages are deduped (matched by
id) and cleared - A secondary refetch fires after 500ms to handle race conditions
- On conversation switch, optimistic state is reset
Connection Status Indicator
The messaging page shows a connection status banner when the WebSocket is disconnected:
- Connected: No banner shown; page description shows "Live"
- Disconnected/Reconnecting: Amber banner with pulsing dot and "Reconnecting..." text
Unread Message Badge (Navbar)
The navbar message icon (NavbarMessages.tsx) uses a lightweight useUnreadMessageCount hook that combines:
- Apollo polling (30-second interval) as the primary data source
- WebSocket subscription (
unreadMessageCountChanged) for instant badge updates
The WebSocket count takes precedence when available, falling back to the polled count.
WebSocket Authentication
Both messaging subscriptions authenticate via the graphql-ws protocol. The backend's onConnect handler extracts the JWT from connectionParams.Authorization, verifies it, and attaches it to the request headers so JwtAuthGuard works identically for HTTP and WebSocket connections.
Implementation Files
Backend
| File | Purpose |
|---|---|
src/messaging/messaging.module.ts | Module definition with all providers |
src/messaging/messaging.service.ts | Core service: CRUD, transactions, denormalization |
src/messaging/messaging.resolver.ts | 27 GraphQL resolvers (queries, mutations, subscriptions) |
src/messaging/presence.service.ts | Online presence tracking (heartbeat, lastSeen, debounce) |
src/messaging/listeners/messaging-notification.listener.ts | Event listener for in-app notifications |
Frontend
| File | Purpose |
|---|---|
lib/hooks/useMessaging.ts | Central hook (934 lines): state, typing, presence, WebSocket |
components/messaging/MessagesLayout.tsx | Shared layout (conversation list + thread + empty state) |
components/messaging/ConversationList.tsx | Scrollable list with search, pinned-first, unread badges |
components/messaging/MessageThread.tsx | Message area with typing indicator, presence, group management |
components/messaging/MessageBubble.tsx | Message display with edit/delete/reply actions |
components/messaging/MessageInput.tsx | Composition with typing indicator emission |
components/messaging/NewConversationModal.tsx | Create new direct or group conversation |
components/navigation/NavbarMessages.tsx | Navbar dropdown with unread badge and conversation previews |
graphql/operations/messaging/ | Fragments, queries, mutations, subscriptions |
app/(shared)/messages/page.tsx | Unified messaging route (both roles) |
Future Phases
| Phase | Features | Status |
|---|---|---|
| Phase 1 | Direct & group conversations, message CRUD, attachments, read tracking, typing indicators, online presence, notifications, company scoping | Live |
| Phase 2 | Real-time read receipts (double-tick UI with readReceiptChanged subscription) | Live |
| Phase 3 | Seen-by indicator (list of readers per message), participant role management | Planned |
| Phase 4 | Full-text message search, conversation context links (link to a job, candidate, or talent) | Planned |
| Future | AI agents as conversation participants, message reactions, message pinning | Considered |
Display Name Fallback
When a user has null firstName or lastName (e.g., newly invited users who haven't completed their profile), the messaging system falls back to the email address prefix as the display name. This ensures participants are never shown as "unknown" in conversation lists or message threads.
Related
- Messaging API — Full GraphQL API reference (types, queries, mutations, subscriptions)
- Messaging (Frontend) — Frontend hook, components, and routing
- WebSocket Subscriptions — Backend WebSocket authentication and subscription protocol
- Notifications — Notification system that messaging integrates with