Inbox & Channels

Work the unified inbox: conversations and messages scoped by channel membership, team and personal channels, response templates, and conversation-to-card conversion.

Backend modules: backend/plugins/frontline_api/src/modules/{inbox,channel,response}. Frontend: frontend/plugins/frontline_ui/src/modules/{inbox,channels,responseTemplate} and the /frontline/inbox route.

Concepts

Conversations and messages

A Conversation belongs to one Integration and one customer, carries status (new, open, closed, engageVisitorAuto), assignedUserId, participatedUserIds, readUserIds, tagIds, hasSurvey, and automatedReplyControl (status, reason, pausedUntil). A ConversationMessage carries content, attachments, internal (agent-only note), fromBot, botData, mailData for the mail channel, and extraData (survey snapshots, Discord polls).

Customer messages land through receiveInboxMessage (modules/inbox/receiveMessage.ts), which creates the conversation and message, then publishes conversationClientMessageInserted to every member of the conversation's channel. One GraphQL subscription covers all of a user's channels.

Membership-scoped visibility

An agent only sees conversations whose integration sits on a channel they belong to. Builder in src/conversationQueryBuilder.ts maps the caller's ChannelMembers rows to integration ids and intersects them with channelId, brandId, and integrationId filters. A system-role user bypasses the membership filter. Channels the user does not belong to return an empty integrationId.$in; there is no error, just no rows.

Team vs personal channels

Team channelPersonal channel
scopeteam (default; missing scope on legacy records means team)personal
MembersMany, via channelAddMembersExactly one: the owner, as admin
ProvisioningchannelAddLazy: created the first time getPersonalChannel runs or an integration is created without a channelId
IntegrationsAny kindAny kind; integrationsCreateExternalIntegration without channelId attaches to the caller's personal channel

Members carry a role of admin, lead, or member (ChannelMembers, unique on (channelId, memberId)). A partial unique index on Channels allows at most one personal channel per createdBy, and removing or demoting the last admin of a channel is refused.

Response templates

Per-channel canned replies (ResponseTemplates: name, content, channelId, files). conversationMessageAdd accepts a responseTemplateId to send one.

Conversation filters and counts

Every conversation list/count query accepts the same filter params: channelId, integrationId, integrationType, status, brandId, tag, customerId, segment, searchValue, startDate/endDate, and the string flags unassigned, participating, mentioned, unread, starred, awaitingResponse, withSurvey, automationStatus.

  • withSurvey: "true" keeps only conversations carrying a survey (hasSurvey). An integrationType-scoped list without withSurvey excludes them instead, so the inbox's Messenger row and Surveys row are disjoint.
  • automationStatus maps standbyhandoff_requested and handoffhuman_active on automatedReplyControl.status; responded matches any conversation automation touched.
  • awaitingResponse: "true" filters to isCustomerRespondedLast: true.
  • searchValue matches customers via Core (name/email/phone; ≥4 digits triggers phone search, which also scans CallCdrs by src/dst) plus conversation content.

Two builders exist. The Mongo Builder (src/conversationQueryBuilder.ts) backs conversations, conversationsTotalCount, conversationsGetLast, conversationsTotalUnreadCount, and the scalar buckets of conversationCounts. The Elasticsearch-backed CommonBuilder (modules/inbox/conversationUtils.ts) serves the conversationCounts(only: "byChannels"|"byIntegrationTypes"|"byTags"|"byIntegrations") buckets. CommonBuilder.runQueries currently returns 0 (the conversations Elasticsearch index is not populated), so the sidebar reads per-kind counts from integrationsGetUsedTypesByChannel instead.

Queries and mutations

Schema: modules/inbox/graphql/schemas/conversation.ts.

OperationKindPurpose
conversations(filter…)queryCursor-paginated inbox list
conversationDetail(_id) / conversationMessages(conversationId, …)queryDetail and message history
conversationCounts(only, …) / conversationsTotalCount / conversationsTotalUnreadCountquerySidebar counts
conversationMessageAdd(conversationId, content, internal, attachments, poll, replyToMessageId, …)mutationAgent reply, internal note, or native poll
conversationMessageEdit, conversationMarkAsReadmutationEdit / mark read
conversationsAssign / conversationsUnassign / conversationsChangeStatus / conversationsResolvemutationTriage actions
conversationSetAutomatedReplyControl(_id, status, reason, pausedUntil)mutationPause/hand off automation on a conversation
conversationConvertToCard(_id, type, itemName, stageId, …)mutationConvert to ticket, deal, or task
conversationConvertedItems(_id)queryWhat a conversation was already converted into
getMyChannels / getChannels / getChannel(_id) / getPersonalChannel / getChannelMembersqueryChannel reads
channelAdd / channelUpdate / channelRemove / channelAddMembers / channelRemoveMember(s) / channelUpdateMembermutationChannel management
responseTemplates(filter) / responseTemplatesAdd / responseTemplatesEdit / responseTemplatesRemovequery/mutationResponse templates
integrations / integrationsGetUsedTypes / integrationsGetUsedTypesByChannel / integrationDetailqueryIntegration list and sidebar kinds
conversationClientMessageInserted(userId)subscriptionLive customer messages for the whole inbox

Convert a conversation to a ticket, deal, or task

conversationConvertToCard(_id, type, …) dispatches to a per-kind handler in modules/inbox/services/conversationConvertTargets.ts:

typeCreated byPermission checkedURL returned by conversationConvertedItems
ticketTicket.addTicket in this plugincreateTicket + pipeline access/frontline/tickets?ticketId=<id>
dealsales deal.createItem over tRPCdealsAdd/sales/deals?boardId=…&pipelineId=…&salesItemId=<id>
taskoperation task.createFromSource over tRPCtaskCreate/operation/tasks/<id>

The new item is related to the conversation (and its customer for tickets/tasks) through Core relations. A second item of the same kind for one conversation is refused; stageId is required (a status id for tickets/tasks, a stage id for deals).

mutation {
  conversationConvertToCard(
    _id: "conv_abc123"
    type: "ticket"
    itemName: "Billing issue from messenger"
    stageId: "status_xyz"
    assignedUserIds: ["user_1"]
    tagIds: ["tag_9"]
  )
}

Widget and client portal resolvers

The unauthenticated messenger widget talks to the widgets* operations (widgetsMessengerConnect, widgetsInsertMessage, widgetsConversations, widgetsConversationDetail, widgetsMessages, widgetsTotalUnreadCount, widgetsMessengerSupporters, widgetsGetEngageMessage, widgetsSendTypingInfo, widgetsReadConversationMessages) and the ticket-widget operations (widgetTicketCreated, widgetTicketCheckProgress, widgetTicketComments, widgetTicketActivityLogs, widgetTicketsByCustomer). Signed-in portal users use the cp* conversation operations (cpConnect, cpConversations, cpInsertMessage, cpReadConversationMessages). For installation, see Messenger Widget.

tRPC procedures

inbox.* router highlights (src/modules/inbox/trpc/inbox.ts):

ProcedureKindPurpose
inbox.createConversationAndMessagemutationCreate a conversation and its first message (used by survey submit and other services)
inbox.createOnlyMessagemutationAppend a message to an existing conversation
inbox.integrations.receivemutationInbound webhook entrypoint → receiveInboxMessage
inbox.integrations.removemutationRemove an integration plus its conversations and messages
inbox.conversationClientMessageInsertedmutationPublish the live-message subscription event
inbox.getConversationsListqueryCursor-paginated conversation list
inbox.conversations.find / findOne / countqueryConversation reads
inbox.conversations.changeStatusquerySet status (new/open/closed/resolved)
inbox.conversationMessages.find / findOnequeryMessage reads
inbox.integrations.find / findOne / countqueryIntegration reads
inbox.getIntegrationKindsqueryKind → label map (messenger, lead, webhook, mail, facebook-messenger, …)
inbox.channels.find, inbox.getConversations, inbox.removeCustomersConversations, inbox.changeCustomer, inbox.updateUserChannels, inbox.sendNotifications, inbox.widgetsGetUnreadMessagesCountquery/mutationService-to-service helpers

Also merged into the root router: conversation.find/conversation.tag, relation.onRelationAdded (logs a conversation's form submissions onto a related ticket or other entity), and integration.find.

Permissions

Permission modules: inbox (showConversations always granted, conversationMessageAdd, conversationMessageEdit, conversationsAssign, conversationsUnassign, conversationsChangeStatus, conversationsResolve, conversationConvertToCard, conversationEditCustomFields), channel (showChannels, showAllChannels, channelAdd, channelUpdate, channelRemove, channelManageMembers), integration (showIntegrations, integrationsAdd, integrationsEdit, integrationsRemove), and responseTemplate (showResponseTemplates, responseTemplatesAdd, responseTemplatesEdit, responseTemplatesRemove). Default groups: frontline:admin, frontline:user, frontline:viewer.

Automations, segments, notifications

  • Automation triggers: frontline:inbox.conversations "Conversation event" (assignee/status/tag changes) and "Erxes Messenger Message" (direct message, Get Started, quick reply, request-create-ticket, ticket-form-submission conditions); action "Send Erxes Messenger Message".
  • Segment content types and field definitions are contributed for conversations and messages (modules/inbox/meta/segments/).
  • Notification events include conversationAddMessage, conversationAssigneeChange, conversationCreated, conversationParticipantAdded, conversationStateChange, conversationTagged.
  • meta/afterProcess handlers maintain unread counters and channel counts after conversation changes.
Was this helpful?