diff --git a/.changeset/rotten-camels-pretend.md b/.changeset/rotten-camels-pretend.md new file mode 100644 index 000000000000..5145bbaa5050 --- /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 69e9b11c57b9..6820bd4664bd 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 109f49f440b5..9553e9fe981b 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 3b7c6a3051bf..c0be707ba212 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 e2084adda934..9532fd4214ab 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 c0a559bbcba7..52c405d4a922 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 694225dc71a4..205cbaccd466 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);