diff --git a/.changeset/fair-carrots-trade.md b/.changeset/fair-carrots-trade.md new file mode 100644 index 000000000000..9c479c941ddf --- /dev/null +++ b/.changeset/fair-carrots-trade.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +--- + +Fixes "Average first response time" and "Best first response time" metrics being associated with the last agent who served the room (instead of the first one) diff --git a/apps/meteor/server/models/raw/LivechatRooms.ts b/apps/meteor/server/models/raw/LivechatRooms.ts index e422615fecbd..de18a8ec3d22 100644 --- a/apps/meteor/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/server/models/raw/LivechatRooms.ts @@ -2147,7 +2147,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive }; return this.find(query, { - projection: { ts: 1, departmentId: 1, open: 1, servedBy: 1, metrics: 1, msgs: 1 }, + projection: { ts: 1, departmentId: 1, open: 1, servedBy: 1, responseBy: 1, metrics: 1, msgs: 1 }, }); } diff --git a/apps/meteor/server/services/omnichannel-analytics/AgentData.ts b/apps/meteor/server/services/omnichannel-analytics/AgentData.ts index 40ce0f1236cb..e92f7c7b6716 100644 --- a/apps/meteor/server/services/omnichannel-analytics/AgentData.ts +++ b/apps/meteor/server/services/omnichannel-analytics/AgentData.ts @@ -235,15 +235,15 @@ export class AgentOverviewData { data: [], }; - await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { - if (servedBy && metrics && metrics.response && metrics.response.ft) { - if (agentAvgRespTime.has(servedBy.username)) { - agentAvgRespTime.set(servedBy.username, { - frt: agentAvgRespTime.get(servedBy.username).frt + metrics.response.ft, - total: agentAvgRespTime.get(servedBy.username).total + 1, + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, responseBy }) => { + if (responseBy && metrics && metrics.response && metrics.response.ft) { + if (agentAvgRespTime.has(responseBy.username)) { + agentAvgRespTime.set(responseBy.username, { + frt: agentAvgRespTime.get(responseBy.username).frt + metrics.response.ft, + total: agentAvgRespTime.get(responseBy.username).total + 1, }); } else { - agentAvgRespTime.set(servedBy.username, { + agentAvgRespTime.set(responseBy.username, { frt: metrics.response.ft, total: 1, }); @@ -267,7 +267,7 @@ export class AgentOverviewData { } async Best_first_response_time(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter = {}) { - const agentFirstRespTime = new Map(); // stores avg response time for each agent + const agentFirstRespTime = new Map(); // stores best response time for each agent const date = { gte: from.toDate(), lte: to.toDate(), @@ -285,12 +285,12 @@ export class AgentOverviewData { data: [], }; - await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => { - if (servedBy && metrics && metrics.response && metrics.response.ft) { - if (agentFirstRespTime.has(servedBy.username)) { - agentFirstRespTime.set(servedBy.username, Math.min(agentFirstRespTime.get(servedBy.username), metrics.response.ft)); + await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, responseBy }) => { + if (responseBy && metrics && metrics.response && metrics.response.ft) { + if (agentFirstRespTime.has(responseBy.username)) { + agentFirstRespTime.set(responseBy.username, Math.min(agentFirstRespTime.get(responseBy.username), metrics.response.ft)); } else { - agentFirstRespTime.set(servedBy.username, metrics.response.ft); + agentFirstRespTime.set(responseBy.username, metrics.response.ft); } } }); 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 33cb2f1f26b0..351abecff21c 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 @@ -922,6 +922,7 @@ describe('LIVECHAT - dashboards', function () { describe('[livechat/analytics/agent-overview] - Average first response time', () => { let agent: { credentials: Credentials; user: IUser & { username: string } }; + let forwardAgent: { credentials: Credentials; user: IUser & { username: string } }; let originalFirstResponseTimeInSeconds: number; let roomId: string; const firstDelayInSeconds = 4; @@ -929,11 +930,10 @@ describe('LIVECHAT - dashboards', function () { before(async () => { agent = await createAnOnlineAgent(); + forwardAgent = await createAnOnlineAgent(); }); - after(async () => { - await deleteUser(agent.user); - }); + after(async () => Promise.all([deleteUser(agent.user), deleteUser(forwardAgent.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 }); @@ -984,6 +984,62 @@ describe('LIVECHAT - dashboards', function () { expect(originalFirstResponseTimeInSeconds).to.be.greaterThanOrEqual(firstDelayInSeconds); }); + it('should correctly associate the first response time to the first agent who responded the room', async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: forwardAgent.credentials }); + roomId = response.room._id; + + await sendAgentMessage(roomId, 'first response from agent', forwardAgent.credentials); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId, + userId: agent.user._id, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await sendAgentMessage(roomId, 'first response from forwarded 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'); + + // The agent to whom the room has been forwarded shouldn't have their average first response time changed + 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(originalFirstResponseTimeInSeconds).to.be.equal(averageFirstResponseTimeInSeconds); + + // A room's first response time should be attached to the agent who first responded to it even if it has been forwarded + const forwardAgentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === forwardAgent.user.username, + ); + expect(forwardAgentData).to.not.be.undefined; + expect(forwardAgentData).to.have.property('name', forwardAgent.user.username); + expect(forwardAgentData).to.have.property('value'); + const forwardAgentAverageFirstResponseTimeInSeconds = moment.duration(forwardAgentData.value).asSeconds(); + expect(originalFirstResponseTimeInSeconds).to.be.greaterThan(forwardAgentAverageFirstResponseTimeInSeconds); + }); + 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; @@ -1019,14 +1075,16 @@ describe('LIVECHAT - dashboards', function () { describe('[livechat/analytics/agent-overview] - Best first response time', () => { let agent: { credentials: Credentials; user: IUser & { username: string } }; + let forwardAgent: { credentials: Credentials; user: IUser & { username: string } }; let originalBestFirstResponseTimeInSeconds: number; let roomId: string; before(async () => { agent = await createAnOnlineAgent(); + forwardAgent = await createAnOnlineAgent(); }); - after(() => deleteUser(agent.user)); + after(() => Promise.all([deleteUser(agent.user), deleteUser(forwardAgent.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 }); @@ -1110,6 +1168,62 @@ describe('LIVECHAT - dashboards', function () { const bestFirstResponseTimeInSeconds = moment.duration(agentData.value).asSeconds(); expect(bestFirstResponseTimeInSeconds).to.be.equal(originalBestFirstResponseTimeInSeconds); }); + + it('should correctly associate best first response time to the first agent who responded the room', async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: forwardAgent.credentials }); + roomId = response.room._id; + + await sendAgentMessage(roomId, 'first response from agent', forwardAgent.credentials); + + await request + .post(api('livechat/room.forward')) + .set(credentials) + .send({ + roomId, + userId: agent.user._id, + comment: 'test comment', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await sendAgentMessage(roomId, 'first response from forwarded 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'); + + // The agent to whom the room has been forwarded shouldn't have their best first response time changed + 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); + + // A room's first response time should be attached to the agent who first responded to it even if it has been forwarded + const forwardAgentData = result.body.data.find( + (agentOverviewData: { name: string; value: string }) => agentOverviewData.name === forwardAgent.user.username, + ); + expect(forwardAgentData).to.not.be.undefined; + expect(forwardAgentData).to.have.property('name', forwardAgent.user.username); + expect(forwardAgentData).to.have.property('value'); + const forwardAgentBestFirstResponseTimeInSeconds = moment.duration(forwardAgentData.value).asSeconds(); + expect(forwardAgentBestFirstResponseTimeInSeconds).to.be.lessThan(originalBestFirstResponseTimeInSeconds); + }); }); describe('livechat/analytics/overview', () => { @@ -1170,12 +1284,12 @@ describe('LIVECHAT - dashboards', function () { expect(result.body).to.be.an('array'); const expectedResult = [ - { title: 'Total_conversations', value: 13 }, - { title: 'Open_conversations', value: 10 }, + { title: 'Total_conversations', value: 15 }, + { title: 'Open_conversations', value: 12 }, { title: 'On_Hold_conversations', value: 1 }, // { title: 'Total_messages', value: 6 }, // { title: 'Busiest_day', value: moment().format('dddd') }, - { title: 'Conversations_per_day', value: '6.50' }, + { title: 'Conversations_per_day', value: '7.50' }, // { title: 'Busiest_time', value: '' }, ]; diff --git a/apps/meteor/tests/unit/server/services/omnichannel-analytics/AgentData.tests.ts b/apps/meteor/tests/unit/server/services/omnichannel-analytics/AgentData.tests.ts index 4c9320163774..7e3003332d50 100644 --- a/apps/meteor/tests/unit/server/services/omnichannel-analytics/AgentData.tests.ts +++ b/apps/meteor/tests/unit/server/services/omnichannel-analytics/AgentData.tests.ts @@ -735,7 +735,7 @@ describe('AgentData Analytics', () => { getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { return [ { - servedBy: { + responseBy: { username: 'agent 1', }, metrics: { @@ -772,7 +772,7 @@ describe('AgentData Analytics', () => { getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { return [ { - servedBy: { + responseBy: { username: 'agent 1', }, metrics: { @@ -782,7 +782,7 @@ describe('AgentData Analytics', () => { }, }, { - servedBy: { + responseBy: { username: 'agent 2', }, metrics: { @@ -818,12 +818,15 @@ describe('AgentData Analytics', () => { ], }); }); - it('should calculate correctly when agents have multiple conversations', async () => { + it('should associate average first response time with the agent who first responded to the room', async () => { const modelMock = { getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { return [ { servedBy: { + username: 'agent 3', + }, + responseBy: { username: 'agent 1', }, metrics: { @@ -834,6 +837,9 @@ describe('AgentData Analytics', () => { }, { servedBy: { + username: 'agent 4', + }, + responseBy: { username: 'agent 2', }, metrics: { @@ -844,6 +850,9 @@ describe('AgentData Analytics', () => { }, { servedBy: { + username: 'agent 5', + }, + responseBy: { username: 'agent 1', }, metrics: { @@ -879,12 +888,73 @@ describe('AgentData Analytics', () => { ], }); }); - it('should ignore conversations not being served by any agent', async () => { + it('should calculate correctly when agents have multiple conversations', async () => { const modelMock = { getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { return [ { - servedBy: undefined, + responseBy: { + username: 'agent 1', + }, + metrics: { + response: { + ft: 100, + }, + }, + }, + { + responseBy: { + username: 'agent 2', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + { + responseBy: { + username: 'agent 1', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { + name: 'agent 1', + value: '00:02:30', + }, + { + name: 'agent 2', + value: '00:03:20', + }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_first_response_time' }, + ], + }); + }); + it('should ignore conversations not responded by any agent', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + responseBy: undefined, metrics: { response: { ft: 100, @@ -909,7 +979,7 @@ describe('AgentData Analytics', () => { ], }); }); - it('should ignore conversations with no metrics', async () => { + it('should ignore conversations served, but not responded by any agent', async () => { const modelMock = { getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { return [ @@ -917,6 +987,39 @@ describe('AgentData Analytics', () => { servedBy: { username: 'agent 1', }, + responseBy: undefined, + metrics: { + response: { + ft: 100, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Avg_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Avg_first_response_time' }, + ], + }); + }); + it('should ignore conversations with no metrics', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + responseBy: { + username: 'agent 1', + }, metrics: undefined, }, ]; @@ -966,7 +1069,7 @@ describe('AgentData Analytics', () => { getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { return [ { - servedBy: { + responseBy: { username: 'agent 1', }, metrics: { @@ -976,7 +1079,7 @@ describe('AgentData Analytics', () => { }, }, { - servedBy: { + responseBy: { username: 'agent 2', }, metrics: { @@ -986,7 +1089,7 @@ describe('AgentData Analytics', () => { }, }, { - servedBy: { + responseBy: { username: 'agent 3', }, metrics: { @@ -996,7 +1099,7 @@ describe('AgentData Analytics', () => { }, }, { - servedBy: { + responseBy: { username: 'agent 4', }, metrics: { @@ -1006,7 +1109,7 @@ describe('AgentData Analytics', () => { }, }, { - servedBy: { + responseBy: { username: 'agent 5', }, metrics: { @@ -1016,9 +1119,116 @@ describe('AgentData Analytics', () => { }, }, { + responseBy: { + username: 'agent 6', + }, + metrics: { + response: { + ft: 300, + }, + }, + }, + ]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Best_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [ + { name: 'agent 1', value: '00:01:40' }, + { name: 'agent 2', value: '00:03:20' }, + { name: 'agent 3', value: '00:00:50' }, + { name: 'agent 4', value: '00:02:30' }, + { name: 'agent 5', value: '00:04:10' }, + { name: 'agent 6', value: '00:05:00' }, + ], + head: [ + { + name: 'Agent', + }, + { name: 'Best_first_response_time' }, + ], + }); + }); + it('should associate best first response time with the agent who first responded to the room', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [ + { + responseBy: { + username: 'agent 1', + }, + servedBy: { + username: 'agent 2', + }, + metrics: { + response: { + ft: 100, + }, + }, + }, + { + responseBy: { + username: 'agent 2', + }, + servedBy: { + username: 'agent 3', + }, + metrics: { + response: { + ft: 200, + }, + }, + }, + { + responseBy: { + username: 'agent 3', + }, + servedBy: { + username: 'agent 4', + }, + metrics: { + response: { + ft: 50, + }, + }, + }, + { + responseBy: { + username: 'agent 4', + }, servedBy: { + username: 'agent 5', + }, + metrics: { + response: { + ft: 150, + }, + }, + }, + { + responseBy: { + username: 'agent 5', + }, + servedBy: { + username: 'agent 6', + }, + metrics: { + response: { + ft: 250, + }, + }, + }, + { + responseBy: { username: 'agent 6', }, + servedBy: { + username: 'agent 7', + }, metrics: { response: { ft: 300, @@ -1055,7 +1265,7 @@ describe('AgentData Analytics', () => { getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { return [ { - servedBy: { + responseBy: { username: 'agent 1', }, metrics: { @@ -1065,7 +1275,7 @@ describe('AgentData Analytics', () => { }, }, { - servedBy: { + responseBy: { username: 'agent 2', }, metrics: { @@ -1075,7 +1285,7 @@ describe('AgentData Analytics', () => { }, }, { - servedBy: { + responseBy: { username: 'agent 3', }, metrics: { @@ -1085,7 +1295,7 @@ describe('AgentData Analytics', () => { }, }, { - servedBy: { + responseBy: { username: 'agent 4', }, metrics: { @@ -1095,7 +1305,7 @@ describe('AgentData Analytics', () => { }, }, { - servedBy: { + responseBy: { username: 'agent 5', }, metrics: { @@ -1105,7 +1315,7 @@ describe('AgentData Analytics', () => { }, }, { - servedBy: { + responseBy: { username: 'agent 6', }, metrics: { @@ -1115,7 +1325,7 @@ describe('AgentData Analytics', () => { }, }, { - servedBy: { + responseBy: { username: 'agent 1', }, metrics: { @@ -1149,10 +1359,31 @@ describe('AgentData Analytics', () => { ], }); }); - it('should ignore conversations not being served by any agent', async () => { + it('should ignore conversations not responded by any agent', async () => { + const modelMock = { + getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { + return [{ responseBy: undefined, metrics: { response: { ft: 100 } } }]; + }, + }; + + const agentOverview = new AgentOverviewData(modelMock as any); + + const result = await agentOverview.Best_first_response_time(moment(), moment(), 'departmentId'); + + expect(result).to.be.deep.equal({ + data: [], + head: [ + { + name: 'Agent', + }, + { name: 'Best_first_response_time' }, + ], + }); + }); + it('should ignore conversations served, but not responded by any agent', async () => { const modelMock = { getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { - return [{ servedBy: undefined, metrics: { response: { ft: 100 } } }]; + return [{ servedBy: { username: 'agent1' }, responseBy: undefined, metrics: { response: { ft: 100 } } }]; }, }; @@ -1173,7 +1404,7 @@ describe('AgentData Analytics', () => { it('should ignore conversations with no metrics', async () => { const modelMock = { getAnalyticsMetricsBetweenDate(_params: ILivechatRoomsModel['getAnalyticsMetricsBetweenDate']) { - return [{ servedBy: { username: 'agent 1' }, metrics: undefined }]; + return [{ responseBy: { username: 'agent 1' }, metrics: undefined }]; }, }; diff --git a/packages/model-typings/src/models/ILivechatRoomsModel.ts b/packages/model-typings/src/models/ILivechatRoomsModel.ts index 7ba6f9e74a3b..00dea51969c7 100644 --- a/packages/model-typings/src/models/ILivechatRoomsModel.ts +++ b/packages/model-typings/src/models/ILivechatRoomsModel.ts @@ -238,7 +238,7 @@ export interface ILivechatRoomsModel extends IBaseModel { date: { gte: Date; lte: Date }, data?: { departmentId?: string }, extraQuery?: Filter, - ): FindCursor>; + ): FindCursor>; getAnalyticsMetricsBetweenDateWithMessages( t: string, date: { gte: Date; lte: Date },