diff --git a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts index 7c7bff067f9a..07802b75553c 100644 --- a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts +++ b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts @@ -1,79 +1,84 @@ -import { isEditedMessage, isMessageFromVisitor } from '@rocket.chat/core-typings'; +import { isEditedMessage } from '@rocket.chat/core-typings'; +import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { normalizeMessageFileUpload } from '../../../utils/server/functions/normalizeMessageFileUpload'; +const getMetricValue = (metric: T | undefined, defaultValue: T): T => metric ?? defaultValue; +const calculateTimeDifference = (startTime: T, now: Date): number => + (now.getTime() - new Date(startTime).getTime()) / 1000; +const calculateAvgResponseTime = (totalResponseTime: number, newResponseTime: number, responseCount: number) => + (totalResponseTime + newResponseTime) / (responseCount + 1); + +const getFirstResponseAnalytics = ( + visitorLastQuery: Date, + agentJoinTime: Date, + totalResponseTime: number, + responseCount: number, + now: Date, +) => { + const responseTime = calculateTimeDifference(visitorLastQuery, now); + const reactionTime = calculateTimeDifference(agentJoinTime, now); + const avgResponseTime = calculateAvgResponseTime(totalResponseTime, responseTime, responseCount); + + return { + firstResponseDate: now, + firstResponseTime: responseTime, + responseTime, + avgResponseTime, + firstReactionDate: now, + firstReactionTime: reactionTime, + reactionTime, + }; +}; + +const getSubsequentResponseAnalytics = (visitorLastQuery: Date, totalResponseTime: number, responseCount: number, now: Date) => { + const responseTime = calculateTimeDifference(visitorLastQuery, now); + const avgResponseTime = calculateAvgResponseTime(totalResponseTime, responseTime, responseCount); + + return { + responseTime, + avgResponseTime, + reactionTime: responseTime, + }; +}; + +const getAnalyticsData = (room: IOmnichannelRoom, now: Date): Record | undefined => { + const visitorLastQuery = getMetricValue(room.metrics?.v?.lq, room.ts); + const agentLastReply = getMetricValue(room.metrics?.servedBy?.lr, room.ts); + const agentJoinTime = getMetricValue(room.servedBy?.ts, room.ts); + const totalResponseTime = getMetricValue(room.metrics?.response?.tt, 0); + const responseCount = getMetricValue(room.metrics?.response?.total, 0); + + if (agentLastReply === room.ts) { + return getFirstResponseAnalytics(visitorLastQuery, agentJoinTime, totalResponseTime, responseCount, now); + } + if (visitorLastQuery > agentLastReply) { + return getSubsequentResponseAnalytics(visitorLastQuery, totalResponseTime, responseCount, now); + } +}; + callbacks.add( 'afterOmnichannelSaveMessage', async (message, { room }) => { - // skips this callback if the message was edited if (!message || isEditedMessage(message)) { return message; } - // if the message has a token, it was sent by the visitor - if (isMessageFromVisitor(message)) { - // When visitor sends a mesage, most metrics wont be calculated/served. - // But, v.lq (last query) will be updated to the message time. This has to be done - // As not doing it will cause the metrics to be crazy and not have real values. - await LivechatRooms.saveAnalyticsDataByRoomId(room, message); - return message; - } - if (message.file) { message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } }; } - const now = new Date(); - let analyticsData; - - const visitorLastQuery = room.metrics?.v ? room.metrics.v.lq : room.ts; - const agentLastReply = room.metrics?.servedBy ? room.metrics.servedBy.lr : room.ts; - const agentJoinTime = room.servedBy?.ts ? room.servedBy.ts : room.ts; - - const isResponseTt = room.metrics?.response?.tt; - const isResponseTotal = room.metrics?.response?.total; - - if (agentLastReply === room.ts) { - // first response - const firstResponseDate = now; - const firstResponseTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000; - const responseTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000; - const avgResponseTime = - ((isResponseTt ? room.metrics?.response?.tt : 0) || 0 + responseTime) / - ((isResponseTotal ? room.metrics?.response?.total : 0) || 0 + 1); + const analyticsData = getAnalyticsData(room, new Date()); + const updater = await LivechatRooms.getAnalyticsUpdateQueryByRoomId(room, message, analyticsData); - const firstReactionDate = now; - const firstReactionTime = (now.getTime() - new Date(agentJoinTime).getTime()) / 1000; - const reactionTime = (now.getTime() - new Date(agentJoinTime).getTime()) / 1000; - - analyticsData = { - firstResponseDate, - firstResponseTime, - responseTime, - avgResponseTime, - firstReactionDate, - firstReactionTime, - reactionTime, - }; - } else if (visitorLastQuery > agentLastReply) { - // response, not first - const responseTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000; - const avgResponseTime = - ((isResponseTt ? room.metrics?.response?.tt : 0) || 0 + responseTime) / - ((isResponseTotal ? room.metrics?.response?.total : 0) || 0 + 1); - - const reactionTime = (now.getTime() - new Date(visitorLastQuery).getTime()) / 1000; - - analyticsData = { - responseTime, - avgResponseTime, - reactionTime, - }; - } // ignore, its continuing response + if (updater.hasChanges()) { + await updater.persist({ + _id: room._id, + }); + } - await LivechatRooms.saveAnalyticsDataByRoomId(room, message, analyticsData); return message; }, callbacks.priority.LOW, diff --git a/apps/meteor/server/models/raw/LivechatRooms.ts b/apps/meteor/server/models/raw/LivechatRooms.ts index 648af95ed180..dfb0143cb984 100644 --- a/apps/meteor/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/server/models/raw/LivechatRooms.ts @@ -9,8 +9,9 @@ import type { ReportResult, MACStats, } from '@rocket.chat/core-typings'; -import { UserStatus } from '@rocket.chat/core-typings'; +import { isMessageFromVisitor, UserStatus } from '@rocket.chat/core-typings'; import type { ILivechatRoomsModel } from '@rocket.chat/model-typings'; +import type { Updater } from '@rocket.chat/models'; import { Settings } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { @@ -2020,52 +2021,71 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive ); } - saveAnalyticsDataByRoomId(room: IOmnichannelRoom, message: IMessage, analyticsData: Record) { - const update: DeepWritable> = { - $set: { - ...(analyticsData && { - 'metrics.response.avg': analyticsData.avgResponseTime, - }), - ...(analyticsData?.firstResponseTime && { - 'metrics.reaction.fd': analyticsData.firstReactionDate, - 'metrics.reaction.ft': analyticsData.firstReactionTime, - 'metrics.response.fd': analyticsData.firstResponseDate, - 'metrics.response.ft': analyticsData.firstResponseTime, - }), - }, - ...(analyticsData && { - $inc: { - 'metrics.response.total': 1, - 'metrics.response.tt': analyticsData.responseTime as number, - 'metrics.reaction.tt': analyticsData.reactionTime as number, - }, - }), - }; + private getAnalyticsUpdateQuery( + analyticsData: Record | undefined, + updater: Updater = this.getUpdater(), + ) { + if (analyticsData) { + updater.set('metrics.response.avg', analyticsData.avgResponseTime); + updater.inc('metrics.response.total', 1); + updater.inc('metrics.response.tt', analyticsData.responseTime as number); + updater.inc('metrics.reaction.tt', analyticsData.reactionTime as number); + } + + if (analyticsData?.firstResponseTime) { + updater.set('metrics.reaction.fd', analyticsData.firstReactionDate); + updater.set('metrics.reaction.ft', analyticsData.firstReactionTime); + updater.set('metrics.response.fd', analyticsData.firstResponseDate); + updater.set('metrics.response.ft', analyticsData.firstResponseTime); + } + + return updater; + } + private getAnalyticsUpdateQueryBySentByAgent( + room: IOmnichannelRoom, + message: IMessage, + analyticsData: Record | undefined, + updater: Updater = this.getUpdater(), + ) { // livechat analytics : update last message timestamps const visitorLastQuery = room.metrics?.v ? room.metrics.v.lq : room.ts; const agentLastReply = room.metrics?.servedBy ? room.metrics.servedBy.lr : room.ts; - if (message.token) { - // update visitor timestamp, only if its new inquiry and not continuing message - if (agentLastReply >= visitorLastQuery) { - // if first query, not continuing query from visitor - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - update.$set!['metrics.v.lq'] = message.ts; - } - } else if (visitorLastQuery > agentLastReply) { - // update agent timestamp, if first response, not continuing - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - update.$set!['metrics.servedBy.lr'] = message.ts; + if (visitorLastQuery > agentLastReply) { + return this.getAnalyticsUpdateQuery(analyticsData, updater).set('metrics.servedBy.lr', message.ts); } - return this.updateOne( - { - _id: room._id, - t: 'l', - }, - update, - ); + return updater; + } + + private getAnalyticsUpdateQueryBySentByVisitor( + room: IOmnichannelRoom, + message: IMessage, + analyticsData: Record | undefined, + updater: Updater = this.getUpdater(), + ) { + // livechat analytics : update last message timestamps + const visitorLastQuery = room.metrics?.v ? room.metrics.v.lq : room.ts; + const agentLastReply = room.metrics?.servedBy ? room.metrics.servedBy.lr : room.ts; + + // update visitor timestamp, only if its new inquiry and not continuing message + if (agentLastReply >= visitorLastQuery) { + return this.getAnalyticsUpdateQuery(analyticsData).set('metrics.v.lq', message.ts); + } + + return updater; + } + + async getAnalyticsUpdateQueryByRoomId( + room: IOmnichannelRoom, + message: IMessage, + analyticsData: Record | undefined, + updater: Updater = this.getUpdater(), + ) { + return isMessageFromVisitor(message) + ? this.getAnalyticsUpdateQueryBySentByVisitor(room, message, analyticsData, updater) + : this.getAnalyticsUpdateQueryBySentByAgent(room, message, analyticsData, updater); } getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lt: Date }, { departmentId }: { departmentId?: string } = {}) { diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index ac31c2cc6a3a..4bc07c5a8ad4 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -275,9 +275,12 @@ export interface IOmnichannelRoom extends IOmnichannelGenericRoom { total: number; avg: number; ft: number; + fd?: number; }; reaction?: { + tt: number; ft: number; + fd?: number; }; }; diff --git a/packages/model-typings/src/models/ILivechatRoomsModel.ts b/packages/model-typings/src/models/ILivechatRoomsModel.ts index d4da1d7d8159..8f364ad66a89 100644 --- a/packages/model-typings/src/models/ILivechatRoomsModel.ts +++ b/packages/model-typings/src/models/ILivechatRoomsModel.ts @@ -9,6 +9,7 @@ import type { import type { FindCursor, UpdateResult, AggregationCursor, Document, FindOptions, DeleteResult, Filter } from 'mongodb'; import type { FindPaginated } from '..'; +import type { Updater } from '../updater'; import type { IBaseModel } from './IBaseModel'; type Period = { @@ -208,11 +209,12 @@ export interface ILivechatRoomsModel extends IBaseModel { setResponseByRoomId(roomId: string, responseBy: IOmnichannelRoom['responseBy']): Promise; setNotResponseByRoomId(roomId: string): Promise; setAgentLastMessageTs(roomId: string): Promise; - saveAnalyticsDataByRoomId( + getAnalyticsUpdateQueryByRoomId( room: IOmnichannelRoom, message: IMessage, - analyticsData?: Record, - ): Promise; + analyticsData: Record | undefined, + updater?: Updater, + ): Promise>; getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lt: Date }, data?: { departmentId: string }): Promise; getAnalyticsMetricsBetweenDate( t: 'l',