diff --git a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts index 3e9164554d47..d9c539182b06 100644 --- a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts +++ b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts @@ -1,74 +1,74 @@ -import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { isEditedMessage } from '@rocket.chat/core-typings'; +import type { IOmnichannelRoom, IMessage } from '@rocket.chat/core-typings'; +import { isEditedMessage, isMessageFromVisitor } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; import { LivechatRooms, LivechatVisitors, LivechatInquiry } from '@rocket.chat/models'; import moment from 'moment'; import { callbacks } from '../../../../lib/callbacks'; import { notifyOnLivechatInquiryChanged } from '../../../lib/server/lib/notifyListener'; -callbacks.add( - 'afterOmnichannelSaveMessage', - async (message, { room }) => { - // skips this callback if the message was edited - if (!message || isEditedMessage(message)) { - return message; - } +export async function markRoomResponded( + message: IMessage, + room: IOmnichannelRoom, + roomUpdater: Updater, +): Promise { + if (message.t || isEditedMessage(message) || isMessageFromVisitor(message)) { + return; + } - // skips this callback if the message is a system message - if (message.t) { - return message; - } + const monthYear = moment().format('YYYY-MM'); + const isVisitorActive = await LivechatVisitors.isVisitorActiveOnPeriod(room.v._id, monthYear); - // if the message has a token, it was sent by the visitor, so ignore it - if (message.token) { - return message; - } + // Case: agent answers & visitor is not active, we mark visitor as active + if (!isVisitorActive) { + await LivechatVisitors.markVisitorActiveForPeriod(room.v._id, monthYear); + } - // Return YYYY-MM from moment - const monthYear = moment().format('YYYY-MM'); - const isVisitorActive = await LivechatVisitors.isVisitorActiveOnPeriod(room.v._id, monthYear); + if (!room.v?.activity?.includes(monthYear)) { + const [, livechatInquiry] = await Promise.all([ + LivechatRooms.markVisitorActiveForPeriod(room._id, monthYear), + LivechatInquiry.markInquiryActiveForPeriod(room._id, monthYear), + ]); - // Case: agent answers & visitor is not active, we mark visitor as active - if (!isVisitorActive) { - await LivechatVisitors.markVisitorActiveForPeriod(room.v._id, monthYear); + if (livechatInquiry) { + void notifyOnLivechatInquiryChanged(livechatInquiry, 'updated', { v: livechatInquiry.v }); } + } - if (!room.v?.activity?.includes(monthYear)) { - const [, livechatInquiry] = await Promise.all([ - LivechatRooms.markVisitorActiveForPeriod(room._id, monthYear), - LivechatInquiry.markInquiryActiveForPeriod(room._id, monthYear), - ]); - if (livechatInquiry) { - void notifyOnLivechatInquiryChanged(livechatInquiry, 'updated', { v: livechatInquiry.v }); - } - } + if (room.responseBy) { + LivechatRooms.getAgentLastMessageTsUpdateQuery(roomUpdater); + } + if (!room.waitingResponse) { + // case where agent sends second message or any subsequent message in a room before visitor responds to the first message + // in this case, we just need to update the lastMessageTs of the responseBy object if (room.responseBy) { - await LivechatRooms.setAgentLastMessageTs(room._id); + LivechatRooms.getAgentLastMessageTsUpdateQuery(roomUpdater); } - // check if room is yet awaiting for response from visitor - if (!room.waitingResponse) { - // case where agent sends second message or any subsequent message in a room before visitor responds to the first message - // in this case, we just need to update the lastMessageTs of the responseBy object - if (room.responseBy) { - await LivechatRooms.setAgentLastMessageTs(room._id); - } - return message; - } + return room.responseBy; + } + + const responseBy: IOmnichannelRoom['responseBy'] = room.responseBy || { + _id: message.u._id, + username: message.u.username, + firstResponseTs: new Date(message.ts), + lastMessageTs: new Date(message.ts), + }; + + LivechatRooms.getResponseByRoomIdUpdateQuery(responseBy, roomUpdater); - // This is the first message from agent after visitor had last responded - const responseBy: IOmnichannelRoom['responseBy'] = room.responseBy || { - _id: message.u._id, - username: message.u.username, - firstResponseTs: new Date(message.ts), - lastMessageTs: new Date(message.ts), - }; + return responseBy; +} - // this unsets waitingResponse and sets responseBy object - await LivechatRooms.setResponseByRoomId(room._id, responseBy); +callbacks.add( + 'afterOmnichannelSaveMessage', + async (message, { room, roomUpdater }) => { + if (!message || message.t || isEditedMessage(message) || isMessageFromVisitor(message)) { + return; + } - return message; + await markRoomResponded(message, room, roomUpdater); }, callbacks.priority.HIGH, 'markRoomResponded', diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/setPredictedVisitorAbandonmentTime.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/setPredictedVisitorAbandonmentTime.ts index c244022689dc..0fc630b5c260 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/setPredictedVisitorAbandonmentTime.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/setPredictedVisitorAbandonmentTime.ts @@ -1,53 +1,46 @@ -import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { isEditedMessage } from '@rocket.chat/core-typings'; -import { LivechatRooms } from '@rocket.chat/models'; +import type { IMessage } from '@rocket.chat/core-typings'; +import { isEditedMessage, isMessageFromVisitor } from '@rocket.chat/core-typings'; import moment from 'moment'; +import { markRoomResponded } from '../../../../../app/livechat/server/hooks/markRoomResponded'; import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../lib/callbacks'; import { setPredictedVisitorAbandonmentTime } from '../lib/Helper'; +function shouldSaveInactivity(message: IMessage): boolean { + if (message.t || isEditedMessage(message) || isMessageFromVisitor(message)) { + return false; + } + + const abandonedRoomsAction = settings.get('Livechat_abandoned_rooms_action'); + const visitorInactivityTimeout = settings.get('Livechat_visitor_inactivity_timeout'); + + if (!abandonedRoomsAction || abandonedRoomsAction === 'none' || visitorInactivityTimeout <= 0) { + return false; + } + + return true; +} + +callbacks.remove('afterOmnichannelSaveMessage', 'markRoomResponded'); + callbacks.add( 'afterOmnichannelSaveMessage', - async (message, { room }) => { - if ( - !settings.get('Livechat_abandoned_rooms_action') || - settings.get('Livechat_abandoned_rooms_action') === 'none' || - settings.get('Livechat_visitor_inactivity_timeout') <= 0 - ) { - return message; - } - // skips this callback if the message was edited - if (isEditedMessage(message)) { - return message; - } - // if the message has a type means it is a special message (like the closing comment), so skip it - if (message.t) { - return message; - } - // message from visitor - if (message.token) { - return message; - } - - const latestRoom = await LivechatRooms.findOneById>(room._id, { - projection: { - _id: 1, - responseBy: 1, - departmentId: 1, - }, - }); + async (message, { room, roomUpdater }) => { + const responseBy = await markRoomResponded(message, room, roomUpdater); - if (!latestRoom?.responseBy) { + if (!shouldSaveInactivity(message)) { return message; } - if (moment(latestRoom.responseBy.firstResponseTs).isSame(moment(message.ts))) { - await setPredictedVisitorAbandonmentTime(latestRoom); + if (!responseBy) { + return; } - return message; + if (moment(responseBy.firstResponseTs).isSame(moment(message.ts))) { + await setPredictedVisitorAbandonmentTime({ ...room, responseBy }, roomUpdater); + } }, callbacks.priority.MEDIUM, 'save-visitor-inactivity', -); // This hook priority should always be less than the priority of hook "markRoomResponded" bcs, the room.responseBy.firstMessage property set there is being used here for determining visitor abandonment +); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts index 9d4b413d218a..3206fe9f94cc 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/Helper.ts @@ -1,5 +1,6 @@ import { api } from '@rocket.chat/core-services'; import type { IOmnichannelRoom, IOmnichannelServiceLevelAgreements, InquiryWithAgentInfo } from '@rocket.chat/core-typings'; +import type { Updater } from '@rocket.chat/models'; import { Rooms as RoomRaw, LivechatRooms, @@ -139,7 +140,10 @@ const dispatchWaitingQueueStatus = async (department?: string) => { // but we don't need to notify _each_ change that takes place, just their final position export const debouncedDispatchWaitingQueueStatus = memoizeDebounce(dispatchWaitingQueueStatus, 1200); -export const setPredictedVisitorAbandonmentTime = async (room: Pick) => { +export const setPredictedVisitorAbandonmentTime = async ( + room: Pick, + roomUpdater?: Updater, +) => { if ( !room.responseBy?.firstResponseTs || !settings.get('Livechat_abandoned_rooms_action') || @@ -160,7 +164,11 @@ export const setPredictedVisitorAbandonmentTime = async (room: Pick { diff --git a/apps/meteor/ee/server/models/raw/LivechatRooms.ts b/apps/meteor/ee/server/models/raw/LivechatRooms.ts index 3295af1b6179..5b89704a522c 100644 --- a/apps/meteor/ee/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/ee/server/models/raw/LivechatRooms.ts @@ -7,6 +7,7 @@ import type { } from '@rocket.chat/core-typings'; import { LivechatPriorityWeight, DEFAULT_SLA_CONFIG } from '@rocket.chat/core-typings'; import type { ILivechatRoomsModel } from '@rocket.chat/model-typings'; +import type { Updater } from '@rocket.chat/models'; import type { FindCursor, UpdateResult, Document, FindOptions, Db, Collection, Filter, AggregationCursor } from 'mongodb'; import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; @@ -20,6 +21,7 @@ declare module '@rocket.chat/model-typings' { unsetPredictedVisitorAbandonmentByRoomId(rid: string): Promise; findAbandonedOpenRooms(date: Date, extraQuery?: Filter): FindCursor; setPredictedVisitorAbandonmentByRoomId(roomId: string, date: Date): Promise; + getPredictedVisitorAbandonmentByRoomIdUpdateQuery(date: Date, roomUpdater: Updater): Updater; unsetAllPredictedVisitorAbandonment(): Promise; setOnHoldByRoomId(roomId: string): Promise; unsetOnHoldByRoomId(roomId: string): Promise; @@ -209,6 +211,13 @@ export class LivechatRoomsRawEE extends LivechatRoomsRaw implements ILivechatRoo ); } + getPredictedVisitorAbandonmentByRoomIdUpdateQuery( + date: Date, + roomUpdater: Updater = this.getUpdater(), + ): Updater { + return roomUpdater.set('omnichannel.predictedVisitorAbandonmentAt', date); + } + setPredictedVisitorAbandonmentByRoomId(rid: string, willBeAbandonedAt: Date): Promise { const query = { _id: rid, diff --git a/apps/meteor/server/models/raw/LivechatRooms.ts b/apps/meteor/server/models/raw/LivechatRooms.ts index b88ae1eb767a..a5e32133e773 100644 --- a/apps/meteor/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/server/models/raw/LivechatRooms.ts @@ -1977,21 +1977,10 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive return this.find(query, options); } - setResponseByRoomId(roomId: string, responseBy: IOmnichannelRoom['responseBy']) { - return this.updateOne( - { - _id: roomId, - t: 'l', - }, - { - $set: { - responseBy, - }, - $unset: { - waitingResponse: 1, - }, - }, - ); + getResponseByRoomIdUpdateQuery(responseBy: IOmnichannelRoom['responseBy'], updater: Updater = this.getUpdater()) { + updater.set('responseBy', responseBy); + updater.unset('waitingResponse'); + return updater; } setNotResponseByRoomId(roomId: string) { @@ -2011,18 +2000,8 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive ); } - setAgentLastMessageTs(roomId: string) { - return this.updateOne( - { - _id: roomId, - t: 'l', - }, - { - $set: { - 'responseBy.lastMessageTs': new Date(), - }, - }, - ); + getAgentLastMessageTsUpdateQuery(updater: Updater = this.getUpdater()) { + return updater.set('responseBy.lastMessageTs', new Date()); } private getAnalyticsUpdateQuery( @@ -2637,6 +2616,13 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive throw new Error('Method not implemented.'); } + getPredictedVisitorAbandonmentByRoomIdUpdateQuery( + _willBeAbandonedAt: Date, + _updater: Updater, + ): Updater { + throw new Error('Method not implemented.'); + } + setPredictedVisitorAbandonmentByRoomId(_rid: string, _willBeAbandonedAt: Date): Promise { throw new Error('Method not implemented.'); } diff --git a/packages/model-typings/src/models/ILivechatRoomsModel.ts b/packages/model-typings/src/models/ILivechatRoomsModel.ts index 345ecb2d768d..05c00e44951f 100644 --- a/packages/model-typings/src/models/ILivechatRoomsModel.ts +++ b/packages/model-typings/src/models/ILivechatRoomsModel.ts @@ -208,9 +208,12 @@ export interface ILivechatRoomsModel extends IBaseModel { options?: FindOptions, extraQuery?: Filter, ): FindCursor; - setResponseByRoomId(roomId: string, responseBy: IOmnichannelRoom['responseBy']): Promise; + getResponseByRoomIdUpdateQuery( + responseBy: IOmnichannelRoom['responseBy'], + updater?: Updater, + ): Updater; setNotResponseByRoomId(roomId: string): Promise; - setAgentLastMessageTs(roomId: string): Promise; + getAgentLastMessageTsUpdateQuery(updater?: Updater): Updater; getAnalyticsUpdateQueryByRoomId( room: IOmnichannelRoom, message: IMessage,