diff --git a/.changeset/rotten-camels-pretend.md b/.changeset/rotten-camels-pretend.md new file mode 100644 index 0000000000000..5145bbaa5050b --- /dev/null +++ b/.changeset/rotten-camels-pretend.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +--- + +Fixed issue with system messages being counted as agents' first responses in livechat rooms (which caused the "best first response time" and "average first response time" metrics to be unreliable for all agents) diff --git a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts index 69e9b11c57b94..6820bd4664bd2 100644 --- a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts +++ b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts @@ -1,5 +1,5 @@ import type { IOmnichannelRoom, IMessage } from '@rocket.chat/core-typings'; -import { isEditedMessage, isMessageFromVisitor } from '@rocket.chat/core-typings'; +import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings'; import type { Updater } from '@rocket.chat/models'; import { LivechatRooms, LivechatVisitors, LivechatInquiry } from '@rocket.chat/models'; import moment from 'moment'; @@ -12,7 +12,7 @@ export async function markRoomResponded( room: IOmnichannelRoom, roomUpdater: Updater, ): Promise { - if (message.t || isEditedMessage(message) || isMessageFromVisitor(message)) { + if (isSystemMessage(message) || isEditedMessage(message) || isMessageFromVisitor(message)) { return; } @@ -62,7 +62,7 @@ export async function markRoomResponded( callbacks.add( 'afterOmnichannelSaveMessage', async (message, { room, roomUpdater }) => { - if (!message || message.t || isEditedMessage(message) || isMessageFromVisitor(message)) { + if (!message || isEditedMessage(message) || isMessageFromVisitor(message) || isSystemMessage(message)) { return; } diff --git a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts index 109f49f440b56..9553e9fe981b8 100644 --- a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts +++ b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts @@ -1,4 +1,4 @@ -import { isEditedMessage, isMessageFromVisitor } from '@rocket.chat/core-typings'; +import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings'; import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; @@ -62,7 +62,7 @@ const getAnalyticsData = (room: IOmnichannelRoom, now: Date): Record { - if (!message || isEditedMessage(message)) { + if (!message || isEditedMessage(message) || isSystemMessage(message)) { return message; } diff --git a/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts b/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts index 3b7c6a3051bf4..c0be707ba2122 100644 --- a/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts +++ b/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts @@ -1,7 +1,10 @@ import { OmnichannelAnalytics } from '@rocket.chat/core-services'; import mem from 'mem'; -export const getAgentOverviewDataCached = mem(OmnichannelAnalytics.getAgentOverviewData, { maxAge: 60000, cacheKey: JSON.stringify }); +export const getAgentOverviewDataCached = mem(OmnichannelAnalytics.getAgentOverviewData, { + maxAge: process.env.TEST_MODE === 'true' ? 1 : 60000, + cacheKey: JSON.stringify, +}); // Agent overview data on realtime is cached for 5 seconds // while the data on the overview page is cached for 1 minute export const getAnalyticsOverviewDataCached = mem(OmnichannelAnalytics.getAnalyticsOverviewData, { diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index e2084adda9347..9532fd4214ab5 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -240,11 +240,11 @@ export const uploadFile = (roomId: string, visitorToken: string): Promise => { +export const sendAgentMessage = (roomId: string, msg?: string, userCredentials: Credentials = credentials): Promise => { return new Promise((resolve, reject) => { void request .post(methodCall('sendMessage')) - .set(credentials) + .set(userCredentials) .send({ message: JSON.stringify({ method: 'sendMessage', diff --git a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts index c0a559bbcba75..52c405d4a9223 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts @@ -3,7 +3,7 @@ import type { Credentials } from '@rocket.chat/api-client'; import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; import { Random } from '@rocket.chat/random'; import { expect } from 'chai'; -import { before, describe, it } from 'mocha'; +import { before, after, describe, it } from 'mocha'; import moment from 'moment'; import type { Response } from 'supertest'; @@ -19,6 +19,7 @@ import { import { createAnOnlineAgent } from '../../../data/livechat/users'; import { sleep } from '../../../data/livechat/utils'; import { removePermissionFromAllRoles, restorePermissionToRoles, updateSetting } from '../../../data/permissions.helper'; +import { deleteUser } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; describe('LIVECHAT - dashboards', function () { @@ -777,6 +778,198 @@ describe('LIVECHAT - dashboards', function () { }); }); + describe('[livechat/analytics/agent-overview] - Average first response time', () => { + let agent: { credentials: Credentials; user: IUser & { username: string } }; + let originalFirstResponseTimeInSeconds: number; + let roomId: string; + const firstDelayInSeconds = 4; + const secondDelayInSeconds = 8; + + before(async () => { + agent = await createAnOnlineAgent(); + }); + + after(async () => { + await deleteUser(agent.user); + }); + + it('should return no average response time for an agent if no response has been sent in the period', async () => { + await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + expect(result.body.data).to.not.deep.include({ name: agent.user.username }); + }); + + it("should not consider system messages in agents' first response time metric", async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomId = response.room._id; + + await sleep(firstDelayInSeconds * 1000); + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + originalFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(originalFirstResponseTimeInSeconds).to.be.greaterThanOrEqual(firstDelayInSeconds); + }); + + it('should correctly calculate the average time of first responses for an agent', async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomId = response.room._id; + + await sleep(secondDelayInSeconds * 1000); + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Avg_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.not.empty; + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + const averageFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(averageFirstResponseTimeInSeconds).to.be.greaterThan(originalFirstResponseTimeInSeconds); + expect(averageFirstResponseTimeInSeconds).to.be.greaterThanOrEqual((firstDelayInSeconds + secondDelayInSeconds) / 2); + expect(averageFirstResponseTimeInSeconds).to.be.lessThan(secondDelayInSeconds); + }); + }); + + describe('[livechat/analytics/agent-overview] - Best first response time', () => { + let agent: { credentials: Credentials; user: IUser & { username: string } }; + let originalBestFirstResponseTimeInSeconds: number; + let roomId: string; + + before(async () => { + agent = await createAnOnlineAgent(); + }); + + after(() => deleteUser(agent.user)); + + it('should return no best response time for an agent if no response has been sent in the period', async () => { + await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Best_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + expect(result.body.data).to.not.deep.include({ name: agent.user.username }); + }); + + it("should not consider system messages in agents' best response time metric", async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomId = response.room._id; + + const delayInSeconds = 4; + await sleep(delayInSeconds * 1000); + + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Best_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array').that.is.not.empty; + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + originalBestFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(originalBestFirstResponseTimeInSeconds).to.be.greaterThanOrEqual(delayInSeconds); + }); + + it('should correctly calculate the best first response time for an agent and there are multiple first responses in the period', async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + roomId = response.room._id; + + const delayInSeconds = 6; + await sleep(delayInSeconds * 1000); + + await sendAgentMessage(roomId, 'first response from agent', agent.credentials); + + const today = moment().startOf('day').format('YYYY-MM-DD'); + const result = await request + .get(api('livechat/analytics/agent-overview')) + .query({ from: today, to: today, name: 'Best_first_response_time' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(result.body).to.have.property('success', true); + expect(result.body).to.have.property('head'); + expect(result.body).to.have.property('data'); + expect(result.body.data).to.be.an('array'); + + const agentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === agent.user.username, + ); + expect(agentData).to.not.be.undefined; + expect(agentData).to.have.property('name', agent.user.username); + expect(agentData).to.have.property('value'); + const bestFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); + expect(bestFirstResponseTimeInSeconds).to.be.equal(originalBestFirstResponseTimeInSeconds); + }); + }); + describe('livechat/analytics/overview', () => { it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { await removePermissionFromAllRoles('view-livechat-manager'); @@ -835,12 +1028,12 @@ describe('LIVECHAT - dashboards', function () { expect(result.body).to.be.an('array'); const expectedResult = [ - { title: 'Total_conversations', value: 7 }, - { title: 'Open_conversations', value: 4 }, + { title: 'Total_conversations', value: 13 }, + { title: 'Open_conversations', value: 10 }, { title: 'On_Hold_conversations', value: 1 }, // { title: 'Total_messages', value: 6 }, // { title: 'Busiest_day', value: moment().format('dddd') }, - { title: 'Conversations_per_day', value: '3.50' }, + { title: 'Conversations_per_day', value: '6.50' }, // { title: 'Busiest_time', value: '' }, ]; diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 694225dc71a47..205cbaccd4662 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -22,90 +22,95 @@ export type MessageUrl = { parsedUrl?: Pick; }; -type VoipMessageTypesValues = - | 'voip-call-started' - | 'voip-call-declined' - | 'voip-call-on-hold' - | 'voip-call-unhold' - | 'voip-call-ended' - | 'voip-call-duration' - | 'voip-call-wrapup' - | 'voip-call-ended-unexpectedly'; - -type TeamMessageTypes = - | 'removed-user-from-team' - | 'added-user-to-team' - | 'ult' - | 'user-converted-to-team' - | 'user-converted-to-channel' - | 'user-removed-room-from-team' - | 'user-deleted-room-from-team' - | 'user-added-room-to-team' - | 'ujt'; - -type LivechatMessageTypes = - | 'livechat_navigation_history' - | 'livechat_transfer_history' - | 'omnichannel_priority_change_history' - | 'omnichannel_sla_change_history' - | 'livechat_transcript_history' - | 'livechat_video_call' - | 'livechat_transfer_history_fallback' - | 'livechat-close' - | 'livechat_webrtc_video_call' - | 'livechat-started'; - -type OmnichannelTypesValues = 'omnichannel_placed_chat_on_hold' | 'omnichannel_on_hold_chat_resumed'; - -type OtrMessageTypeValues = 'otr' | 'otr-ack'; - -export type OtrSystemMessages = 'user_joined_otr' | 'user_requested_otr_key_refresh' | 'user_key_refreshed_successfully'; - -export type MessageTypesValues = - | 'e2e' - | 'uj' - | 'ul' - | 'ru' - | 'au' - | 'mute_unmute' - | 'r' - | 'ut' - | 'wm' - | 'rm' - | 'subscription-role-added' - | 'subscription-role-removed' - | 'room-archived' - | 'room-unarchived' - | 'room_changed_privacy' - | 'room_changed_description' - | 'room_changed_announcement' - | 'room_changed_avatar' - | 'room_changed_topic' - | 'room_e2e_enabled' - | 'room_e2e_disabled' - | 'user-muted' - | 'user-unmuted' - | 'room-removed-read-only' - | 'room-set-read-only' - | 'room-allowed-reacting' - | 'room-disallowed-reacting' - | 'command' - | 'videoconf' - | 'message_pinned' - | 'message_pinned_e2e' - | 'new-moderator' - | 'moderator-removed' - | 'new-owner' - | 'owner-removed' - | 'new-leader' - | 'leader-removed' - | 'discussion-created' - | LivechatMessageTypes - | TeamMessageTypes - | VoipMessageTypesValues - | OmnichannelTypesValues - | OtrMessageTypeValues - | OtrSystemMessages; +const VoipMessageTypesValues = [ + 'voip-call-started', + 'voip-call-declined', + 'voip-call-on-hold', + 'voip-call-unhold', + 'voip-call-ended', + 'voip-call-duration', + 'voip-call-wrapup', + 'voip-call-ended-unexpectedly', +] as const; + +const TeamMessageTypesValues = [ + 'removed-user-from-team', + 'added-user-to-team', + 'ult', + 'user-converted-to-team', + 'user-converted-to-channel', + 'user-removed-room-from-team', + 'user-deleted-room-from-team', + 'user-added-room-to-team', + 'ujt', +] as const; + +const LivechatMessageTypesValues = [ + 'livechat_navigation_history', + 'livechat_transfer_history', + 'livechat_transcript_history', + 'livechat_video_call', + 'livechat_transfer_history_fallback', + 'livechat-close', + 'livechat_webrtc_video_call', + 'livechat-started', + 'omnichannel_priority_change_history', + 'omnichannel_sla_change_history', + 'omnichannel_placed_chat_on_hold', + 'omnichannel_on_hold_chat_resumed', +] as const; + +const OtrMessageTypeValues = ['otr', 'otr-ack'] as const; + +const OtrSystemMessagesValues = ['user_joined_otr', 'user_requested_otr_key_refresh', 'user_key_refreshed_successfully'] as const; +export type OtrSystemMessages = (typeof OtrSystemMessagesValues)[number]; + +const MessageTypes = [ + 'e2e', + 'uj', + 'ul', + 'ru', + 'au', + 'mute_unmute', + 'r', + 'ut', + 'wm', + 'rm', + 'subscription-role-added', + 'subscription-role-removed', + 'room-archived', + 'room-unarchived', + 'room_changed_privacy', + 'room_changed_description', + 'room_changed_announcement', + 'room_changed_avatar', + 'room_changed_topic', + 'room_e2e_enabled', + 'room_e2e_disabled', + 'user-muted', + 'user-unmuted', + 'room-removed-read-only', + 'room-set-read-only', + 'room-allowed-reacting', + 'room-disallowed-reacting', + 'command', + 'videoconf', + 'message_pinned', + 'message_pinned_e2e', + 'new-moderator', + 'moderator-removed', + 'new-owner', + 'owner-removed', + 'new-leader', + 'leader-removed', + 'discussion-created', + ...TeamMessageTypesValues, + ...LivechatMessageTypesValues, + ...VoipMessageTypesValues, + ...OtrMessageTypeValues, + ...OtrSystemMessagesValues, +] as const; +export type MessageTypesValues = (typeof MessageTypes)[number]; export type TokenType = 'code' | 'inlinecode' | 'bold' | 'italic' | 'strike' | 'link'; export type Token = { @@ -231,9 +236,9 @@ export interface IMessage extends IRocketChatRecord { }; } -export type MessageSystem = { - t: 'system'; -}; +export interface ISystemMessage extends IMessage { + t: MessageTypesValues; +} export interface IEditedMessage extends IMessage { editedAt: Date; @@ -249,6 +254,9 @@ export const isEditedMessage = (message: IMessage): message is IEditedMessage => '_id' in (message as IEditedMessage).editedBy && typeof (message as IEditedMessage).editedBy._id === 'string'; +export const isSystemMessage = (message: IMessage): message is ISystemMessage => + message.t !== undefined && MessageTypes.includes(message.t); + export const isDeletedMessage = (message: IMessage): message is IEditedMessage => isEditedMessage(message) && message.t === 'rm'; export const isMessageFromMatrixFederation = (message: IMessage): boolean => 'federation' in message && Boolean(message.federation?.eventId);