Skip to main content

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

TypeDescriptionParticipantsTitle
DIRECT1:1 conversation between two usersExactly 2Auto-generated from participant names
GROUPMulti-user conversation2–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:

ScenarioResult
User A ↔ User B with companyId = XReturns existing conversation scoped to company X
User A ↔ User B with companyId = YCreates a new conversation scoped to company Y
User A ↔ User B with companyId = NULLReturns existing personal conversation (null scope)
User A ↔ User B with companyId = NULL when one already exists with companyId = XCreates 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

ActionOWNERADMINMEMBER
Rename groupYesYesNo
Add participantsYesYesNo
Remove other participantsYesYesNo
Leave groupYesYesYes

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.

note

There is no mutation to change participant roles after creation. The creator is always OWNER, added participants are always MEMBER.

Message Lifecycle

Sending

  1. Verify sender is an active participant (leftAt IS NULL)
  2. Validate message has content OR attachments (at least one required; whitespace-only content is rejected)
  3. Validate replyToId belongs to the same conversation (if provided)
  4. If attachments provided, validate each against allowlist (content type, size ≤ 25MB, objectPath prefix)
  5. Create Message record and MessageAttachment records (if any) in a single transaction
  6. Update Conversation denormalized fields (lastMessageAt, lastMessageText, messageCount)
    • For attachment-only messages, preview text is "Sent an attachment" / "Sent N attachments"
  7. Auto-mark conversation as read for the sender
  8. Publish message to all participants (including the sender) via Redis PubSub, with isSelf: true flag on the sender's copy
  9. Publish updated unread count to all non-sender participants
  10. Emit message.sent event for offline notifications

Editing

  • Only the original sender can edit their message
  • Cannot edit a deleted message
  • Sets isEdited = true and editedAt timestamp
  • Publishes the updated message to all participants

Deleting (Soft Delete)

  • Only the original sender can delete their message
  • Sets status = DELETED, deletedAt timestamp, and clears content
  • 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:

SubscriptionChannel PatternPurpose
messageReceivedmessages:user:{userId}New, edited, and deleted messages
unreadMessageCountChangedmessages:unread:{userId}Unread count updates
typingIndicatorChangedmessages:typing:{conversationId}Typing start/stop per user per conversation
presenceChangedmessages:presence:{userId}Online/offline status changes
readReceiptChangedmessages: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:

  • sendTypingIndicator mutation publishes a typing event to messages: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 setTimeout per user per conversation
  • Message arrival: When a messageReceived event 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.lastSeen timestamp
  • Online threshold: 2 minutes — users are considered online if lastSeen is 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 presenceChanged events via Redis PubSub when a user transitions online/offline

Frontend (useMessaging hook):

  • Initial load: contactPresence query seeds the presenceMap with current status for all conversation participants
  • Real-time: presenceChanged subscription updates the map as users come online/offline
  • Heartbeat: Sends a heartbeat mutation every 60 seconds to maintain online status (stored in a ref to prevent interval resets on re-render)
  • Offline signal: On page close, a goOffline mutation fires via fetch with keepalive: true to 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:

TriggerWhenRead Receipt Published To
markConversationAsRead mutationUser opens a conversation or receives a message in the active conversationAll other active participants
sendMessage mutationSender's lastReadAt is auto-updated to the new message's timestampAll other active participants

UI Display

The ReadReceiptTicks component in MessageBubble renders tick indicators on the sender's own messages:

StateIconCondition
SentSingle gray tickDefault for own messages
ReadDouble blue tickAny 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

  1. readReceiptChanged subscription event arrives via WebSocket
  2. useMessaging hook updates wsConversations[].participants[].lastReadAt in React state
  3. This triggers re-render of activeConversationMessageThreadMessageBubbleReadReceiptTicks
  4. The useMemo in ReadReceiptTicks recalculates isRead and 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 TypeNotification TitleNotification 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:

  1. The resolveMentions() method extracts @Name patterns from the message content using a regex
  2. Extracted names are matched against participant full names and first names (case-insensitive)
  3. The sender is excluded from self-mention matching
  4. Resolved user IDs are passed to the notification listener via the message.sent event 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:

AspectMESSAGE_RECEIVEDMESSAGE_MENTION
PriorityNORMALHIGH
Respects muteYesNo (bypasses mute)
Respects notificationsEnabledYesNo (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.

warning

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 companyId is 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 via UserCompanyRole
  • The myConversations query supports an optional companyId filter
  • unreadMessageCount supports an optional companyId filter for company-specific badge counts
  • Direct messages with companyId = NULL are personal conversations visible regardless of company context

When an HR person messages a candidate:

  • HR view: Conversation has companyId set → visible in their company-scoped list
  • Candidate view: No companyId filter → 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:

QueryDefault LimitMax LimitOrder
myConversations2050lastMessageAt DESC, then createdAt DESC
messages30100createdAt 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

CategoryTypes
Imagesimage/jpeg, image/png, image/gif, image/webp, image/svg+xml
Documentsapplication/pdf, application/msword, .docx, .xls, .xlsx, .ppt, .pptx
Texttext/plain, text/csv
Archivesapplication/zip, application/x-zip-compressed

Limits

ConstraintValue
Max file size25 MB
Max attachments per message10
objectPath prefixMust start with uploads/
Download URL expiry1 hour (presigned)

Cleanup

When a message with attachments is deleted, the backend:

  1. Fetches attachment objectPaths before the transaction
  2. Deletes MessageAttachment records in the same transaction as the message soft-delete
  3. 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:

  1. WebSocket messageReceived event → message appended to optimisticMessages state
  2. Apollo refetchMessages() triggers in parallel
  3. When Apollo cache updates, optimistic messages are deduped (matched by id) and cleared
  4. A secondary refetch fires after 500ms to handle race conditions
  5. 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

FilePurpose
src/messaging/messaging.module.tsModule definition with all providers
src/messaging/messaging.service.tsCore service: CRUD, transactions, denormalization
src/messaging/messaging.resolver.ts27 GraphQL resolvers (queries, mutations, subscriptions)
src/messaging/presence.service.tsOnline presence tracking (heartbeat, lastSeen, debounce)
src/messaging/listeners/messaging-notification.listener.tsEvent listener for in-app notifications

Frontend

FilePurpose
lib/hooks/useMessaging.tsCentral hook (934 lines): state, typing, presence, WebSocket
components/messaging/MessagesLayout.tsxShared layout (conversation list + thread + empty state)
components/messaging/ConversationList.tsxScrollable list with search, pinned-first, unread badges
components/messaging/MessageThread.tsxMessage area with typing indicator, presence, group management
components/messaging/MessageBubble.tsxMessage display with edit/delete/reply actions
components/messaging/MessageInput.tsxComposition with typing indicator emission
components/messaging/NewConversationModal.tsxCreate new direct or group conversation
components/navigation/NavbarMessages.tsxNavbar dropdown with unread badge and conversation previews
graphql/operations/messaging/Fragments, queries, mutations, subscriptions
app/(shared)/messages/page.tsxUnified messaging route (both roles)

Future Phases

PhaseFeaturesStatus
Phase 1Direct & group conversations, message CRUD, attachments, read tracking, typing indicators, online presence, notifications, company scopingLive
Phase 2Real-time read receipts (double-tick UI with readReceiptChanged subscription)Live
Phase 3Seen-by indicator (list of readers per message), participant role managementPlanned
Phase 4Full-text message search, conversation context links (link to a job, candidate, or talent)Planned
FutureAI agents as conversation participants, message reactions, message pinningConsidered

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.