From dea1fe919171a6c6b2568ce3cc8c2c5fd367ad56 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 5 Oct 2023 11:56:04 -0600 Subject: [PATCH] feat: Save visitor's activity on agent's interaction (#30222) --- .changeset/brown-comics-cheat.md | 8 ++ .changeset/khaki-feet-dance.md | 5 + .changeset/warm-melons-type.md | 7 + .../app/apps/server/bridges/livechat.ts | 2 +- .../app/apps/server/converters/rooms.js | 2 +- .../app/apps/server/converters/visitors.js | 2 +- .../server/functions/buildRegistrationData.ts | 8 +- .../app/livechat/imports/server/rest/sms.js | 2 +- .../app/livechat/server/api/lib/visitors.ts | 2 +- .../app/livechat/server/api/v1/contact.ts | 2 +- .../app/livechat/server/api/v1/message.ts | 2 +- .../meteor/app/livechat/server/api/v1/room.ts | 2 +- .../app/livechat/server/api/v1/visitor.ts | 6 +- .../server/hooks/markRoomResponded.ts | 16 ++- .../app/livechat/server/lib/Livechat.js | 26 ++-- .../app/livechat/server/lib/LivechatTyped.ts | 2 +- .../app/livechat/server/methods/transfer.ts | 2 +- .../app/statistics/server/lib/statistics.ts | 32 +++++ .../admin/info/DeploymentCard.stories.tsx | 4 + .../admin/info/InformationPage.stories.tsx | 4 + .../views/admin/info/UsageCard.stories.tsx | 4 + .../hooks/onMessageSentParsePlaceholder.ts | 2 +- .../hooks/handleNextAgentPreferredEvents.ts | 2 +- .../server/hooks/resumeOnHold.ts | 2 +- .../server/lib/VisitorInactivityMonitor.ts | 2 +- .../server/methods/resumeOnHold.ts | 2 +- .../EmailInbox/EmailInbox_Incoming.ts | 6 +- .../server/lib/rooms/roomTypes/livechat.ts | 2 +- .../meteor/server/models/raw/LivechatRooms.ts | 136 ++++++++++++++++++ .../server/models/raw/LivechatVisitors.ts | 86 ++++++++++- .../end-to-end/api/livechat/09-visitors.ts | 24 +++- .../src/OmnichannelTranscript.ts | 3 +- packages/core-typings/src/ILivechatVisitor.ts | 2 + packages/core-typings/src/IRoom.ts | 6 +- packages/core-typings/src/IStats.ts | 5 + .../core-typings/src/omnichannel/index.ts | 1 + packages/core-typings/src/omnichannel/mac.ts | 5 + .../src/models/ILivechatRoomsModel.ts | 12 +- .../src/models/ILivechatVisitorsModel.ts | 12 ++ 39 files changed, 404 insertions(+), 46 deletions(-) create mode 100644 .changeset/brown-comics-cheat.md create mode 100644 .changeset/khaki-feet-dance.md create mode 100644 .changeset/warm-melons-type.md create mode 100644 packages/core-typings/src/omnichannel/mac.ts diff --git a/.changeset/brown-comics-cheat.md b/.changeset/brown-comics-cheat.md new file mode 100644 index 000000000000..a7907979881b --- /dev/null +++ b/.changeset/brown-comics-cheat.md @@ -0,0 +1,8 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/model-typings": patch +--- + +chore: Calculate & Store MAC stats +Added new info to the stats: `omnichannelContactsBySource`, `uniqueContactsOfLastMonth`, `uniqueContactsOfLastWeek`, `uniqueContactsOfYesterday` diff --git a/.changeset/khaki-feet-dance.md b/.changeset/khaki-feet-dance.md new file mode 100644 index 000000000000..a419afa34143 --- /dev/null +++ b/.changeset/khaki-feet-dance.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +feat: Save visitor's activity on agent's interaction diff --git a/.changeset/warm-melons-type.md b/.changeset/warm-melons-type.md new file mode 100644 index 000000000000..5b187b8a7f11 --- /dev/null +++ b/.changeset/warm-melons-type.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/omnichannel-services": patch +--- + +feat: Disable and annonimize visitors instead of removing diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 71f7387e1aa5..76a0545c8801 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -223,7 +223,7 @@ export class AppLivechatBridge extends LivechatBridge { } return Promise.all( - (await LivechatVisitors.find(query).toArray()).map( + (await LivechatVisitors.findEnabled(query).toArray()).map( async (visitor) => visitor && this.orch.getConverters()?.get('visitors').convertVisitor(visitor), ), ); diff --git a/apps/meteor/app/apps/server/converters/rooms.js b/apps/meteor/app/apps/server/converters/rooms.js index ae38feff5eff..905534212836 100644 --- a/apps/meteor/app/apps/server/converters/rooms.js +++ b/apps/meteor/app/apps/server/converters/rooms.js @@ -37,7 +37,7 @@ export class AppRoomsConverter { let v; if (room.visitor) { - const visitor = await LivechatVisitors.findOneById(room.visitor.id); + const visitor = await LivechatVisitors.findOneEnabledById(room.visitor.id); const { lastMessageTs, phone } = room.visitorChannelInfo; diff --git a/apps/meteor/app/apps/server/converters/visitors.js b/apps/meteor/app/apps/server/converters/visitors.js index ba288c96d7b8..a9f5d450efad 100644 --- a/apps/meteor/app/apps/server/converters/visitors.js +++ b/apps/meteor/app/apps/server/converters/visitors.js @@ -9,7 +9,7 @@ export class AppVisitorsConverter { } async convertById(id) { - const visitor = await LivechatVisitors.findOneById(id); + const visitor = await LivechatVisitors.findOneEnabledById(id); return this.convertVisitor(visitor); } diff --git a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts index 2ad8ba29072a..f00718d2e779 100644 --- a/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts +++ b/apps/meteor/app/cloud/server/functions/buildRegistrationData.ts @@ -32,7 +32,10 @@ export type WorkspaceRegistrationData = { setupComplete: boolean; connectionDisable: boolean; npsEnabled: string; + // TODO: Evaluate naming MAC: number; + // activeContactsBillingMonth: number; + // activeContactsYesterday: number; }; export async function buildWorkspaceRegistrationData(contactEmail: T): Promise> { @@ -80,7 +83,8 @@ export async function buildWorkspaceRegistrationData { } const id = await LivechatTyped.registerGuest(data); - return LivechatVisitors.findOneById(id); + return LivechatVisitors.findOneEnabledById(id); }; const normalizeLocationSharing = (payload) => { diff --git a/apps/meteor/app/livechat/server/api/lib/visitors.ts b/apps/meteor/app/livechat/server/api/lib/visitors.ts index e559aecc892e..0abed5197d78 100644 --- a/apps/meteor/app/livechat/server/api/lib/visitors.ts +++ b/apps/meteor/app/livechat/server/api/lib/visitors.ts @@ -6,7 +6,7 @@ import { callbacks } from '../../../../../lib/callbacks'; import { canAccessRoomAsync } from '../../../../authorization/server/functions/canAccessRoom'; export async function findVisitorInfo({ visitorId }: { visitorId: IVisitor['_id'] }) { - const visitor = await LivechatVisitors.findOneById(visitorId); + const visitor = await LivechatVisitors.findOneEnabledById(visitorId); if (!visitor) { throw new Error('visitor-not-found'); } diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 517acf33f137..57c1d117f1b0 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -33,7 +33,7 @@ API.v1.addRoute( contactId: String, }); - const contact = await LivechatVisitors.findOneById(this.queryParams.contactId); + const contact = await LivechatVisitors.findOneEnabledById(this.queryParams.contactId); return API.v1.success({ contact }); }, diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 2b6f4c00af53..104e2ece94d5 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -269,7 +269,7 @@ API.v1.addRoute( guest.connectionData = normalizeHttpHeaderData(this.request.headers); const visitorId = await LivechatTyped.registerGuest(guest); - visitor = await LivechatVisitors.findOneById(visitorId); + visitor = await LivechatVisitors.findOneEnabledById(visitorId); } const sentMessages = await Promise.all( diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 0fe60248bfba..86629e636bf8 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -326,7 +326,7 @@ API.v1.addRoute( throw new Error('This_conversation_is_already_closed'); } - const guest = await LivechatVisitors.findOneById(room.v?._id); + const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); const transferedBy = this.user satisfies TransferByData; transferData.transferredBy = normalizeTransferredByData(transferedBy, room); if (transferData.userId) { diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index 012b412639ea..ae9d1ea4fd83 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -45,7 +45,7 @@ API.v1.addRoute('livechat/visitor', { const visitorId = await LivechatTyped.registerGuest(guest); - let visitor = await VisitorsRaw.findOneById(visitorId, {}); + let visitor = await VisitorsRaw.findOneEnabledById(visitorId, {}); if (visitor) { const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); // If it's updating an existing visitor, it must also update the roomInfo @@ -65,7 +65,7 @@ API.v1.addRoute('livechat/visitor', { } } - visitor = await VisitorsRaw.findOneById(visitorId, {}); + visitor = await VisitorsRaw.findOneEnabledById(visitorId, {}); } if (!visitor) { @@ -122,7 +122,7 @@ API.v1.addRoute('livechat/visitor/:token', { const { _id } = visitor; const result = await Livechat.removeGuest(_id); - if (!result) { + if (!result.modifiedCount) { throw new Meteor.Error('error-removing-visitor', 'An error ocurred while deleting visitor'); } diff --git a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts index 5ebf924e7334..ad68fcf5ce5c 100644 --- a/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts +++ b/apps/meteor/app/livechat/server/hooks/markRoomResponded.ts @@ -1,6 +1,7 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { isOmnichannelRoom, isEditedMessage } from '@rocket.chat/core-typings'; -import { LivechatRooms } from '@rocket.chat/models'; +import { LivechatRooms, LivechatVisitors } from '@rocket.chat/models'; +import moment from 'moment'; import { callbacks } from '../../../../lib/callbacks'; @@ -26,6 +27,19 @@ callbacks.add( return message; } + // Return YYYY-MM from moment + const monthYear = moment().format('YYYY-MM'); + const isVisitorActive = await LivechatVisitors.isVisitorActiveOnPeriod(room.v._id, monthYear); + if (!isVisitorActive) { + await LivechatVisitors.markVisitorActiveForPeriod(room.v._id, monthYear); + } + + await LivechatRooms.markVisitorActiveForPeriod(room._id, monthYear); + + if (room.responseBy) { + await LivechatRooms.setAgentLastMessageTs(room._id); + } + // 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 diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index 52138740e295..ffd3a29b229f 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -298,7 +298,7 @@ export const Livechat = { async forwardOpenChats(userId) { Livechat.logger.debug(`Transferring open chats for user ${userId}`); for await (const room of LivechatRooms.findOpenByAgent(userId)) { - const guest = await LivechatVisitors.findOneById(room.v._id); + const guest = await LivechatVisitors.findOneEnabledById(room.v._id); const user = await Users.findOneById(userId); const { _id, username, name } = user; const transferredBy = normalizeTransferredByData({ _id, username, name }, room); @@ -462,7 +462,7 @@ export const Livechat = { }, async getLivechatRoomGuestInfo(room) { - const visitor = await LivechatVisitors.findOneById(room.v._id); + const visitor = await LivechatVisitors.findOneEnabledById(room.v._id); const agent = await Users.findOneById(room.servedBy && room.servedBy._id); const ua = new UAParser(); @@ -604,16 +604,15 @@ export const Livechat = { }, async removeGuest(_id) { - check(_id, String); - const guest = await LivechatVisitors.findOneById(_id, { projection: { _id: 1 } }); + const guest = await LivechatVisitors.findOneEnabledById(_id, { projection: { _id: 1, token: 1 } }); if (!guest) { throw new Meteor.Error('error-invalid-guest', 'Invalid guest', { method: 'livechat:removeGuest', }); } - await this.cleanGuestHistory(_id); - return LivechatVisitors.removeById(_id); + await this.cleanGuestHistory(guest); + return LivechatVisitors.disableById(_id); }, async setUserStatusLivechat(userId, status) { @@ -628,16 +627,13 @@ export const Livechat = { return user; }, - async cleanGuestHistory(_id) { - const guest = await LivechatVisitors.findOneById(_id); - if (!guest) { - throw new Meteor.Error('error-invalid-guest', 'Invalid guest', { - method: 'livechat:cleanGuestHistory', - }); - } - + async cleanGuestHistory(guest) { const { token } = guest; - check(token, String); + + // This shouldn't be possible, but just in case + if (!token) { + throw new Error('error-invalid-guest'); + } const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); const cursor = LivechatRooms.findByVisitorToken(token, extraQuery); diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 1c60a257d319..c443bc7873c7 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -305,7 +305,7 @@ class LivechatClass { !(await LivechatDepartment.findOneById>(guest.department, { projection: { _id: 1 } })) ) { await LivechatVisitors.removeDepartmentById(guest._id); - const tmpGuest = await LivechatVisitors.findOneById(guest._id); + const tmpGuest = await LivechatVisitors.findOneEnabledById(guest._id); if (tmpGuest) { guest = tmpGuest; } diff --git a/apps/meteor/app/livechat/server/methods/transfer.ts b/apps/meteor/app/livechat/server/methods/transfer.ts index 2dc796fc6c94..3817b10bf42b 100644 --- a/apps/meteor/app/livechat/server/methods/transfer.ts +++ b/apps/meteor/app/livechat/server/methods/transfer.ts @@ -58,7 +58,7 @@ Meteor.methods({ }); } - const guest = await LivechatVisitors.findOneById(room.v?._id); + const guest = await LivechatVisitors.findOneEnabledById(room.v?._id); const user = await Meteor.userAsync(); diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index 89b068c11341..c6180c189124 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -24,8 +24,10 @@ import { LivechatCustomField, Subscriptions, Users, + LivechatRooms, } from '@rocket.chat/models'; import { MongoInternals } from 'meteor/mongo'; +import moment from 'moment'; import { getStatistics as getEnterpriseStatistics } from '../../../../ee/app/license/server/getStatistics'; import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; @@ -269,6 +271,36 @@ export const statistics = { }), ); + const defaultValue = { contactsCount: 0, conversationsCount: 0, sources: [] }; + const billablePeriod = moment.utc().format('YYYY-MM'); + statsPms.push( + LivechatRooms.getMACStatisticsForPeriod(billablePeriod).then(([result]) => { + statistics.omnichannelContactsBySource = result || defaultValue; + }), + ); + + const monthAgo = moment.utc().subtract(30, 'days').toDate(); + const today = moment.utc().toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(monthAgo, today).then(([result]) => { + statistics.uniqueContactsOfLastMonth = result || defaultValue; + }), + ); + + const weekAgo = moment.utc().subtract(7, 'days').toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(weekAgo, today).then(([result]) => { + statistics.uniqueContactsOfLastWeek = result || defaultValue; + }), + ); + + const yesterday = moment.utc().subtract(1, 'days').toDate(); + statsPms.push( + LivechatRooms.getMACStatisticsBetweenDates(yesterday, today).then(([result]) => { + statistics.uniqueContactsOfYesterday = result || defaultValue; + }), + ); + // Message statistics statistics.totalChannelMessages = (await Rooms.findByType('c', { projection: { msgs: 1 } }).toArray()).reduce( function _countChannelMessages(num: number, room: IRoom) { diff --git a/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx b/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx index 98aa3a7073ff..7570cb71bd1c 100644 --- a/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx +++ b/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx @@ -170,6 +170,10 @@ export default { uniqueOSOfYesterday: { data: [], day: 0, month: 0, year: 0 }, uniqueOSOfLastWeek: { data: [], day: 0, month: 0, year: 0 }, uniqueOSOfLastMonth: { data: [], day: 0, month: 0, year: 0 }, + omnichannelContactsBySource: { contactsCount: 0, conversationsCount: 0, sources: [] }, + uniqueContactsOfLastMonth: { contactsCount: 0, conversationsCount: 0, sources: [] }, + uniqueContactsOfLastWeek: { contactsCount: 0, conversationsCount: 0, sources: [] }, + uniqueContactsOfYesterday: { contactsCount: 0, conversationsCount: 0, sources: [] }, apps: { engineVersion: 'x.y.z', enabled: false, diff --git a/apps/meteor/client/views/admin/info/InformationPage.stories.tsx b/apps/meteor/client/views/admin/info/InformationPage.stories.tsx index a6ef0c8e9289..0a8e97710ca5 100644 --- a/apps/meteor/client/views/admin/info/InformationPage.stories.tsx +++ b/apps/meteor/client/views/admin/info/InformationPage.stories.tsx @@ -200,6 +200,10 @@ export default { uniqueOSOfYesterday: { data: [], day: 0, month: 0, year: 0 }, uniqueOSOfLastWeek: { data: [], day: 0, month: 0, year: 0 }, uniqueOSOfLastMonth: { data: [], day: 0, month: 0, year: 0 }, + omnichannelContactsBySource: { contactsCount: 0, conversationsCount: 0, sources: [] }, + uniqueContactsOfLastMonth: { contactsCount: 0, conversationsCount: 0, sources: [] }, + uniqueContactsOfLastWeek: { contactsCount: 0, conversationsCount: 0, sources: [] }, + uniqueContactsOfYesterday: { contactsCount: 0, conversationsCount: 0, sources: [] }, apps: { engineVersion: 'x.y.z', enabled: false, diff --git a/apps/meteor/client/views/admin/info/UsageCard.stories.tsx b/apps/meteor/client/views/admin/info/UsageCard.stories.tsx index da49ee88fa6b..bfe56b6db2d8 100644 --- a/apps/meteor/client/views/admin/info/UsageCard.stories.tsx +++ b/apps/meteor/client/views/admin/info/UsageCard.stories.tsx @@ -148,6 +148,10 @@ export default { uniqueOSOfYesterday: { data: [], day: 0, month: 0, year: 0 }, uniqueOSOfLastWeek: { data: [], day: 0, month: 0, year: 0 }, uniqueOSOfLastMonth: { data: [], day: 0, month: 0, year: 0 }, + omnichannelContactsBySource: { contactsCount: 0, conversationsCount: 0, sources: [] }, + uniqueContactsOfLastMonth: { contactsCount: 0, conversationsCount: 0, sources: [] }, + uniqueContactsOfLastWeek: { contactsCount: 0, conversationsCount: 0, sources: [] }, + uniqueContactsOfYesterday: { contactsCount: 0, conversationsCount: 0, sources: [] }, apps: { engineVersion: 'x.y.z', enabled: false, diff --git a/apps/meteor/ee/app/canned-responses/server/hooks/onMessageSentParsePlaceholder.ts b/apps/meteor/ee/app/canned-responses/server/hooks/onMessageSentParsePlaceholder.ts index 4d13df42c104..1cb016a33be6 100644 --- a/apps/meteor/ee/app/canned-responses/server/hooks/onMessageSentParsePlaceholder.ts +++ b/apps/meteor/ee/app/canned-responses/server/hooks/onMessageSentParsePlaceholder.ts @@ -48,7 +48,7 @@ const handleBeforeSaveMessage = async (message: IMessage, room?: IOmnichannelRoo } const visitorId = room?.v?._id; const agent = (await Users.findOneById(agentId, { projection: { name: 1, _id: 1, emails: 1 } })) || {}; - const visitor = visitorId && ((await LivechatVisitors.findOneById(visitorId, {})) || {}); + const visitor = visitorId && ((await LivechatVisitors.findOneEnabledById(visitorId, {})) || {}); Object.keys(placeholderFields).map((field) => { const templateKey = `{{${field}}}`; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts index b7026e3b2da4..21fae96e3555 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts @@ -90,7 +90,7 @@ callbacks.add( } const { _id: guestId } = defaultGuest; - const guest = await LivechatVisitors.findOneById(guestId, { + const guest = await LivechatVisitors.findOneEnabledById(guestId, { projection: { lastAgent: 1, token: 1, contactManager: 1 }, }); if (!guest) { diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/resumeOnHold.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/resumeOnHold.ts index 8c9ce3e65aaf..8a04166e1b72 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/resumeOnHold.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/resumeOnHold.ts @@ -12,7 +12,7 @@ const resumeOnHoldCommentAndUser = async (room: IOmnichannelRoom): Promise<{ com v: { _id: visitorId }, _id: rid, } = room; - const visitor = await LivechatVisitors.findOneById>(visitorId, { + const visitor = await LivechatVisitors.findOneEnabledById>(visitorId, { projection: { name: 1, username: 1 }, }); if (!visitor) { diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.ts index 824296d1e673..12233a9127a8 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.ts @@ -148,7 +148,7 @@ export class VisitorInactivityMonitor { } private async getDefaultAbandonedCustomMessage(abandonmentAction: 'close' | 'on-hold', visitorId: string) { - const visitor = await LivechatVisitors.findOneById>(visitorId, { + const visitor = await LivechatVisitors.findOneEnabledById>(visitorId, { projection: { name: 1, username: 1, diff --git a/apps/meteor/ee/app/livechat-enterprise/server/methods/resumeOnHold.ts b/apps/meteor/ee/app/livechat-enterprise/server/methods/resumeOnHold.ts index 2507dd683145..99a80b3b0b88 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/methods/resumeOnHold.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/methods/resumeOnHold.ts @@ -19,7 +19,7 @@ async function resolveOnHoldCommentInfo(options: { clientAction: boolean }, room const { v: { _id: visitorId }, } = room; - const visitor = await LivechatVisitors.findOneById>(visitorId, { + const visitor = await LivechatVisitors.findOneEnabledById>(visitorId, { projection: { name: 1, username: 1 }, }); if (!visitor) { diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index d71190cb0b6d..939d91661650 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -35,7 +35,7 @@ async function getGuestByEmail(email: string, name: string, department = ''): Pr return guest; } await LivechatTyped.setDepartmentForGuest({ token: guest.token, department }); - return LivechatVisitors.findOneById(guest._id, {}); + return LivechatVisitors.findOneEnabledById(guest._id, {}); } return guest; } @@ -47,7 +47,9 @@ async function getGuestByEmail(email: string, name: string, department = ''): Pr department, }); - const newGuest = await LivechatVisitors.findOneById(userId); + const newGuest = await LivechatVisitors.findOneEnabledById(userId); + logger.debug(`Guest ${userId} for visitor ${email} created`); + if (newGuest) { return newGuest; } diff --git a/apps/meteor/server/lib/rooms/roomTypes/livechat.ts b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts index 88393088541e..92d722ac2bb0 100644 --- a/apps/meteor/server/lib/rooms/roomTypes/livechat.ts +++ b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts @@ -39,7 +39,7 @@ roomCoordinator.add(LivechatRoomType, { }, async getMsgSender(senderId) { - return LivechatVisitors.findOneById(senderId); + return LivechatVisitors.findOneEnabledById(senderId); }, getReadReceiptsExtraData(message) { diff --git a/apps/meteor/server/models/raw/LivechatRooms.ts b/apps/meteor/server/models/raw/LivechatRooms.ts index 38eab9056586..bf44a51b7f64 100644 --- a/apps/meteor/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/server/models/raw/LivechatRooms.ts @@ -8,6 +8,7 @@ import type { ILivechatPriority, IOmnichannelServiceLevelAgreements, ReportResult, + MACStats, } from '@rocket.chat/core-typings'; import { UserStatus } from '@rocket.chat/core-typings'; import type { ILivechatRoomsModel } from '@rocket.chat/model-typings'; @@ -74,6 +75,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive { key: { departmentId: 1, ts: 1 }, partialFilterExpression: { departmentId: { $exists: true }, t: 'l' } }, { key: { 'tags.0': 1, 'ts': 1 }, partialFilterExpression: { 'tags.0': { $exists: true }, 't': 'l' } }, { key: { servedBy: 1, ts: 1 }, partialFilterExpression: { servedBy: { $exists: true }, t: 'l' } }, + { key: { 'v.activity': 1, 'ts': 1 }, partialFilterExpression: { 'v.activity': { $exists: true }, 't': 'l' } }, ]; } @@ -2448,6 +2450,140 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive return this.updateOne(query, update); } + markVisitorActiveForPeriod(rid: string, period: string): Promise { + const query = { + _id: rid, + }; + + const update = { + $addToSet: { + 'v.activity': period, + }, + }; + + return this.updateOne(query, update); + } + + async getMACStatisticsForPeriod(period: string): Promise { + return this.col + .aggregate([ + { + $match: { + 't': 'l', + 'v.activity': period, + }, + }, + { + $group: { + _id: { + source: { + $ifNull: ['$source.alias', '$source.type'], + }, + }, + contactsCount: { + $addToSet: '$v._id', + }, + conversationsCount: { + $sum: 1, + }, + }, + }, + { + $group: { + _id: null, + sources: { + $push: { + source: '$_id.source', + contactsCount: { + $size: '$contactsCount', + }, + conversationsCount: '$conversationsCount', + }, + }, + totalContactsCount: { + $sum: { + $size: '$contactsCount', + }, + }, + totalConversationsCount: { + $sum: '$conversationsCount', + }, + }, + }, + { + $project: { + _id: 0, + contactsCount: '$totalContactsCount', + conversationsCount: '$totalConversationsCount', + sources: 1, + }, + }, + ]) + .toArray(); + } + + async getMACStatisticsBetweenDates(start: Date, end: Date): Promise { + return this.col + .aggregate([ + { + $match: { + 't': 'l', + 'v.activity': { $exists: true }, + 'ts': { + $gte: start, + $lt: end, + }, + }, + }, + { + $group: { + _id: { + source: { + $ifNull: ['$source.alias', '$source.type'], + }, + }, + contactsCount: { + $addToSet: '$v._id', + }, + conversationsCount: { + $sum: 1, + }, + }, + }, + { + $group: { + _id: null, + sources: { + $push: { + source: '$_id.source', + contactsCount: { + $size: '$contactsCount', + }, + conversationsCount: '$conversationsCount', + }, + }, + totalContactsCount: { + $sum: { + $size: '$contactsCount', + }, + }, + totalConversationsCount: { + $sum: '$conversationsCount', + }, + }, + }, + { + $project: { + _id: 0, + contactsCount: '$totalContactsCount', + conversationsCount: '$totalConversationsCount', + sources: 1, + }, + }, + ]) + .toArray(); + } + async unsetAllPredictedVisitorAbandonment(): Promise { throw new Error('Method not implemented.'); } diff --git a/apps/meteor/server/models/raw/LivechatVisitors.ts b/apps/meteor/server/models/raw/LivechatVisitors.ts index 2df2ae09882b..7b478bab43d6 100644 --- a/apps/meteor/server/models/raw/LivechatVisitors.ts +++ b/apps/meteor/server/models/raw/LivechatVisitors.ts @@ -32,6 +32,8 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL { key: { username: 1 } }, { key: { 'contactMananger.username': 1 }, sparse: true }, { key: { 'livechatData.$**': 1 } }, + { key: { activity: 1 }, partialFilterExpression: { activity: { $exists: true } } }, + { key: { disabled: 1 }, partialFilterExpression: { disabled: { $exists: true } } }, ]; } @@ -63,9 +65,29 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.find(query, options); } + findEnabled(query: Filter, options?: FindOptions): FindCursor { + return this.find( + { + ...query, + disabled: { $ne: true }, + }, + options, + ); + } + + findOneEnabledById(_id: string, options?: FindOptions): Promise { + const query = { + _id, + disabled: { $ne: true }, + }; + + return this.findOne(query, options); + } + findVisitorByToken(token: string): FindCursor { const query = { token, + disabled: { $ne: true }, }; return this.find(query); @@ -81,6 +103,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL getVisitorsBetweenDate({ start, end, department }: { start: Date; end: Date; department?: string }): FindCursor { const query = { + disabled: { $ne: true }, _updatedAt: { $gte: new Date(start), $lt: new Date(end), @@ -166,7 +189,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL options?: FindOptions, ): Promise>> { if (!emailOrPhone && !nameOrUsername && allowedCustomFields.length === 0) { - return this.findPaginated({}, options); + return this.findPaginated({ disabled: { $ne: true } }, options); } const query: Filter = { @@ -193,6 +216,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL : []), ...allowedCustomFields.map((c: string) => ({ [`livechatData.${c}`]: nameOrUsername })), ], + disabled: { $ne: true }, }; return this.findPaginated(query, options); @@ -204,7 +228,9 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL customFields?: { [key: string]: RegExp }, ): Promise { const query = Object.assign( - {}, + { + disabled: { $ne: true }, + }, { ...(email && { visitorEmails: { address: email } }), ...(phone && { phone: { phoneNumber: phone } }), @@ -212,7 +238,7 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL }, ); - if (Object.keys(query).length === 0) { + if (Object.keys(query).length === 1) { return null; } @@ -365,6 +391,60 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL }, ); } + + isVisitorActiveOnPeriod(visitorId: string, period: string): Promise { + const query = { + _id: visitorId, + activity: period, + }; + + return this.findOne(query, { projection: { _id: 1 } }).then(Boolean); + } + + markVisitorActiveForPeriod(visitorId: string, period: string): Promise { + const query = { + _id: visitorId, + }; + + const update = { + $push: { + activity: { + $each: [period], + $slice: -12, + }, + }, + }; + + return this.updateOne(query, update); + } + + disableById(_id: string): Promise { + return this.updateOne( + { _id }, + { + $set: { disabled: true }, + $unset: { + department: 1, + contactManager: 1, + token: 1, + visitorEmails: 1, + phone: 1, + name: 1, + livechatData: 1, + lastChat: 1, + ip: 1, + host: 1, + userAgent: 1, + }, + }, + ); + } + + countVisitorsOnPeriod(period: string): Promise { + return this.countDocuments({ + activity: period, + }); + } } type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; diff --git a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts index a3ca544de20c..55ef4402da39 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts @@ -2,7 +2,8 @@ import { faker } from '@faker-js/faker'; import type { ILivechatVisitor } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { before, describe, it } from 'mocha'; -import type { Response } from 'supertest'; +import moment from 'moment'; +import { type Response } from 'supertest'; import { getCredentials, api, request, credentials } from '../../../data/api-data'; import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; @@ -334,6 +335,27 @@ describe('LIVECHAT - visitors', function () { }); }); + it('should return visitor activity field when visitor was active on month', async () => { + // Activity is determined by a conversation in which an agent has engaged (sent a message) + // For a visitor to be considered active, they must have had a conversation in the last 30 days + const period = moment().format('YYYY-MM'); + const { visitor, room } = await startANewLivechatRoomAndTakeIt(); + // agent should send a message on the room + await request + .post(api('chat.sendMessage')) + .set(credentials) + .send({ + message: { + rid: room._id, + msg: 'test', + }, + }); + + const activeVisitor = await getLivechatVisitorByToken(visitor.token); + expect(activeVisitor).to.have.property('activity'); + expect(activeVisitor.activity).to.include(period); + }); + it("should return a 'error-removing-visitor' error when removeGuest's result is false", async () => { await request .delete(api('livechat/visitor/123')) diff --git a/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts b/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts index ce21e963911b..0e135d5ed263 100644 --- a/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts +++ b/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts @@ -304,7 +304,8 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT const messages = await this.getMessagesFromRoom({ rid: room._id }); const visitor = - room.v && (await LivechatVisitors.findOneById(room.v._id, { projection: { _id: 1, name: 1, username: 1, visitorEmails: 1 } })); + room.v && + (await LivechatVisitors.findOneEnabledById(room.v._id, { projection: { _id: 1, name: 1, username: 1, visitorEmails: 1 } })); const agent = room.servedBy && (await Users.findOneAgentById(room.servedBy._id, { projection: { _id: 1, name: 1, username: 1, utcOffset: 1 } })); diff --git a/packages/core-typings/src/ILivechatVisitor.ts b/packages/core-typings/src/ILivechatVisitor.ts index d22ea36aa7c6..e80d63ab15d0 100644 --- a/packages/core-typings/src/ILivechatVisitor.ts +++ b/packages/core-typings/src/ILivechatVisitor.ts @@ -47,6 +47,8 @@ export interface ILivechatVisitor extends IRocketChatRecord { contactManager?: { username: string; }; + activity?: string[]; + disabled?: boolean; } export interface ILivechatVisitorDTO { diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 875dea70781e..523450e9594d 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -151,7 +151,11 @@ export enum OmnichannelSourceType { export interface IOmnichannelGenericRoom extends Omit { t: 'l' | 'v'; - v: Pick & { lastMessageTs?: Date; phone?: string }; + v: Pick & { + lastMessageTs?: Date; + phone?: string; + activity?: string[]; + }; email?: { // Data used when the room is created from an email, via email Integration. inbox: string; diff --git a/packages/core-typings/src/IStats.ts b/packages/core-typings/src/IStats.ts index 6bbc2da81b74..70f9f638358f 100644 --- a/packages/core-typings/src/IStats.ts +++ b/packages/core-typings/src/IStats.ts @@ -3,6 +3,7 @@ import type { CpuInfo } from 'os'; import type { DeviceSessionAggregationResult, OSSessionAggregationResult, UserSessionAggregationResult } from './ISession'; import type { ISettingStatisticsObject } from './ISetting'; import type { ITeamStats } from './ITeam'; +import type { MACStats } from './omnichannel'; export interface IStats { _id: string; @@ -93,6 +94,10 @@ export interface IStats { mongoStorageEngine: string; pushQueue: number; omnichannelSources: { [key: string]: number | string }[]; + omnichannelContactsBySource: MACStats; + uniqueContactsOfLastMonth: MACStats; + uniqueContactsOfLastWeek: MACStats; + uniqueContactsOfYesterday: MACStats; departments: number; archivedDepartments: number; routingAlgorithm: string; diff --git a/packages/core-typings/src/omnichannel/index.ts b/packages/core-typings/src/omnichannel/index.ts index 703cf3b4ca77..c6235175dafc 100644 --- a/packages/core-typings/src/omnichannel/index.ts +++ b/packages/core-typings/src/omnichannel/index.ts @@ -2,3 +2,4 @@ export * from './sms'; export * from './routing'; export * from './queue'; export * from './reports'; +export * from './mac'; diff --git a/packages/core-typings/src/omnichannel/mac.ts b/packages/core-typings/src/omnichannel/mac.ts new file mode 100644 index 000000000000..8591edbb0287 --- /dev/null +++ b/packages/core-typings/src/omnichannel/mac.ts @@ -0,0 +1,5 @@ +export type MACStats = { + contactsCount: number; + conversationsCount: number; + sources: { source: string; contactsCount: number; conversationsCount: number }[]; +}; diff --git a/packages/model-typings/src/models/ILivechatRoomsModel.ts b/packages/model-typings/src/models/ILivechatRoomsModel.ts index 68b72be33ba8..20100cbb4f61 100644 --- a/packages/model-typings/src/models/ILivechatRoomsModel.ts +++ b/packages/model-typings/src/models/ILivechatRoomsModel.ts @@ -1,4 +1,11 @@ -import type { IMessage, IOmnichannelRoom, IOmnichannelRoomClosingInfo, ISetting, ILivechatVisitor } from '@rocket.chat/core-typings'; +import type { + IMessage, + IOmnichannelRoom, + IOmnichannelRoomClosingInfo, + ISetting, + ILivechatVisitor, + MACStats, +} from '@rocket.chat/core-typings'; import type { FindCursor, UpdateResult, AggregationCursor, Document, FindOptions, DeleteResult, Filter } from 'mongodb'; import type { FindPaginated } from '..'; @@ -234,4 +241,7 @@ export interface ILivechatRoomsModel extends IBaseModel { setVisitorInactivityInSecondsById(roomId: string, visitorInactivity: any): Promise; changeVisitorByRoomId(roomId: string, visitor: { _id: string; username: string; token: string }): Promise; unarchiveOneById(roomId: string): Promise; + markVisitorActiveForPeriod(rid: string, period: string): Promise; + getMACStatisticsForPeriod(period: string): Promise; + getMACStatisticsBetweenDates(start: Date, end: Date): Promise; } diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index 370db511dadf..5c598c6a6a97 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -48,4 +48,16 @@ export interface ILivechatVisitorsModel extends IBaseModel { updateById(_id: string, update: UpdateFilter): Promise; saveGuestEmailPhoneById(_id: string, emails: string[], phones: string[]): Promise; + + isVisitorActiveOnPeriod(visitorId: string, period: string): Promise; + + markVisitorActiveForPeriod(visitorId: string, period: string): Promise; + + findOneEnabledById(_id: string, options?: FindOptions): Promise; + + disableById(_id: string): Promise; + + findEnabled(query: Filter, options?: FindOptions): FindCursor; + + countVisitorsOnPeriod(period: string): Promise; }