Skip to content

Commit f85da08

Browse files
feat: use Livechat Contacts as a reference for the MAC limit (#33816)
1 parent e744ac8 commit f85da08

File tree

17 files changed

+156
-118
lines changed

17 files changed

+156
-118
lines changed

.changeset/slow-flies-hear.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@rocket.chat/meteor": minor
3+
"@rocket.chat/core-typings": minor
4+
"@rocket.chat/model-typings": minor
5+
---
6+
7+
Replaces Livechat Visitors by Contacts on workspaces' MAC count.
8+
This allows a more accurate and potentially smaller MAC count in case Contact Identification is enabled, since multiple visitors may be associated to the same contact.

apps/meteor/app/cloud/server/functions/buildRegistrationData.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { LivechatRooms, Statistics, Users } from '@rocket.chat/models';
1+
import { LivechatContacts, Statistics, Users } from '@rocket.chat/models';
22
import moment from 'moment';
33

44
import { settings } from '../../../settings/server';
@@ -69,7 +69,7 @@ export async function buildWorkspaceRegistrationData<T extends string | undefine
6969
const workspaceType = settings.get<string>('Server_Type');
7070

7171
const seats = await Users.getActiveLocalUserCount();
72-
const [macs] = await LivechatRooms.getMACStatisticsForPeriod(moment.utc().format('YYYY-MM'));
72+
const MAC = await LivechatContacts.countContactsOnPeriod(moment.utc().format('YYYY-MM'));
7373

7474
const license = settings.get<string>('Enterprise_License');
7575

@@ -102,7 +102,7 @@ export async function buildWorkspaceRegistrationData<T extends string | undefine
102102
setupComplete: setupWizardState === 'completed',
103103
connectionDisable: false,
104104
npsEnabled,
105-
MAC: macs?.contactsCount ?? 0,
105+
MAC,
106106
// activeContactsBillingMonth: stats.omnichannelContactsBySource.contactsCount,
107107
// activeContactsYesterday: stats.uniqueContactsOfYesterday.contactsCount,
108108
statsToken: stats.statsToken,

apps/meteor/app/livechat/server/hooks/markRoomResponded.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { IOmnichannelRoom, IMessage } from '@rocket.chat/core-typings';
22
import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings';
33
import type { Updater } from '@rocket.chat/models';
4-
import { LivechatRooms, LivechatVisitors, LivechatInquiry } from '@rocket.chat/models';
4+
import { LivechatRooms, LivechatContacts, LivechatInquiry } from '@rocket.chat/models';
55
import moment from 'moment';
66

77
import { callbacks } from '../../../../lib/callbacks';
@@ -17,11 +17,11 @@ export async function markRoomResponded(
1717
}
1818

1919
const monthYear = moment().format('YYYY-MM');
20-
const isVisitorActive = await LivechatVisitors.isVisitorActiveOnPeriod(room.v._id, monthYear);
20+
const isContactActive = await LivechatContacts.isContactActiveOnPeriod({ visitorId: room.v._id, source: room.source }, monthYear);
2121

2222
// Case: agent answers & visitor is not active, we mark visitor as active
23-
if (!isVisitorActive) {
24-
await LivechatVisitors.markVisitorActiveForPeriod(room.v._id, monthYear);
23+
if (!isContactActive) {
24+
await LivechatContacts.markContactActiveForPeriod({ visitorId: room.v._id, source: room.source }, monthYear);
2525
}
2626

2727
if (!room.v?.activity?.includes(monthYear)) {

apps/meteor/app/livechat/server/lib/Helper.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,6 @@ export const prepareLivechatRoom = async (
8787
const extraRoomInfo = await callbacks.run('livechat.beforeRoom', roomInfo, extraData);
8888
const { _id, username, token, department: departmentId, status = 'online' } = guest;
8989
const newRoomAt = new Date();
90-
91-
const { activity } = guest;
92-
logger.debug({
93-
msg: `Creating livechat room for visitor ${_id}`,
94-
visitor: { _id, username, departmentId, status, activity },
95-
});
96-
9790
const source = extraRoomInfo.source || roomInfo.source;
9891

9992
if (settings.get<string>('Livechat_Require_Contact_Verification') === 'always') {
@@ -103,14 +96,20 @@ export const prepareLivechatRoom = async (
10396
const contactId = await migrateVisitorIfMissingContact(_id, source);
10497
const contact =
10598
contactId &&
106-
(await LivechatContacts.findOneById<Pick<ILivechatContact, '_id' | 'name' | 'channels'>>(contactId, {
107-
projection: { name: 1, channels: 1 },
99+
(await LivechatContacts.findOneById<Pick<ILivechatContact, '_id' | 'name' | 'channels' | 'activity'>>(contactId, {
100+
projection: { name: 1, channels: 1, activity: 1 },
108101
}));
109102
if (!contact) {
110103
throw new Error('error-invalid-contact');
111104
}
112105
const verified = Boolean(contact.channels.some((channel) => isVerifiedChannelInSource(channel, _id, source)));
113106

107+
const activity = guest.activity || contact.activity;
108+
logger.debug({
109+
msg: `Creating livechat room for visitor ${_id}`,
110+
visitor: { _id, username, departmentId, status, activity },
111+
});
112+
114113
// TODO: Solve `u` missing issue
115114
return {
116115
_id: rid,
@@ -199,7 +198,11 @@ export const createLivechatInquiry = async ({
199198

200199
const extraInquiryInfo = await callbacks.run('livechat.beforeInquiry', extraData);
201200

202-
const { _id, username, token, department, status = UserStatus.ONLINE, activity } = guest;
201+
const { _id, username, token, department, status = UserStatus.ONLINE } = guest;
202+
const inquirySource = extraData?.source || { type: OmnichannelSourceType.OTHER };
203+
const activity =
204+
guest.activity ||
205+
(await LivechatContacts.findOneByVisitor({ visitorId: guest._id, source: inquirySource }, { projection: { activity: 1 } }))?.activity;
203206

204207
const ts = new Date();
205208

apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type ContactFields = {
2121
username: string;
2222
manager: ManagerValue;
2323
channel: ILivechatContactChannel;
24+
activity: string[];
2425
};
2526

2627
type CustomFieldAndValue = { type: `customFields.${string}`; value: string };
@@ -29,6 +30,7 @@ export type FieldAndValue =
2930
| { type: keyof Omit<ContactFields, 'manager' | 'channel'>; value: string }
3031
| { type: 'manager'; value: ManagerValue }
3132
| { type: 'channel'; value: ILivechatContactChannel }
33+
| { type: 'activity'; value: string[] }
3234
| CustomFieldAndValue;
3335

3436
type ConflictHandlingMode = 'conflict' | 'overwrite' | 'ignore';
@@ -118,7 +120,7 @@ export class ContactMerger {
118120
}
119121

120122
static getAllFieldsFromContact(contact: ILivechatContact): FieldAndValue[] {
121-
const { customFields = {}, name, contactManager } = contact;
123+
const { customFields = {}, name, contactManager, activity } = contact;
122124

123125
const fields = new Set<FieldAndValue>();
124126

@@ -134,6 +136,10 @@ export class ContactMerger {
134136
fields.add({ type: 'manager', value: { id: contactManager } });
135137
}
136138

139+
if (activity) {
140+
fields.add({ type: 'activity', value: activity });
141+
}
142+
137143
Object.keys(customFields).forEach((key) =>
138144
fields.add({ type: `customFields.${key}`, value: customFields[key] } as CustomFieldAndValue),
139145
);
@@ -222,6 +228,7 @@ export class ContactMerger {
222228
const newPhones = ContactMerger.getFieldValuesByType(newFields, 'phone');
223229
const newEmails = ContactMerger.getFieldValuesByType(newFields, 'email');
224230
const newChannels = ContactMerger.getFieldValuesByType(newFields, 'channel');
231+
const newActivities = ContactMerger.getFieldValuesByType(newFields, 'activity');
225232
const newNamesOnly = ContactMerger.getFieldValuesByType(newFields, 'name');
226233
const newCustomFields = newFields.filter(({ type }) => type.startsWith('customFields.')) as CustomFieldAndValue[];
227234
// Usernames are ignored unless the contact has no other name
@@ -254,6 +261,15 @@ export class ContactMerger {
254261
}
255262
}
256263

264+
if (newActivities.length) {
265+
const newActivity = newActivities.shift();
266+
if (newActivity) {
267+
const distinctActivities = new Set([...newActivity, ...(contact.activity || [])]);
268+
const latestActivities = Array.from(distinctActivities).sort().slice(-12);
269+
dataToSet.activity = latestActivities;
270+
}
271+
}
272+
257273
const customFieldsPerName = new Map<string, CustomFieldAndValue[]>();
258274
for (const customField of newCustomFields) {
259275
if (!customFieldsPerName.has(customField.type)) {

apps/meteor/app/livechat/server/lib/contacts/registerContact.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ const modelsMock = {
1313
upsertContact: sinon.stub(),
1414
updateContact: sinon.stub(),
1515
findContactMatchingVisitor: sinon.stub(),
16-
findOneByVisitorId: sinon.stub(),
1716
},
1817
'LivechatRooms': {
1918
findNewestByVisitorIdOrToken: sinon.stub(),

apps/meteor/ee/app/license/server/startup.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { api } from '@rocket.chat/core-services';
22
import type { LicenseLimitKind } from '@rocket.chat/core-typings';
33
import { applyLicense, applyLicenseOrRemove, License } from '@rocket.chat/license';
4-
import { Subscriptions, Users, Settings, LivechatVisitors } from '@rocket.chat/models';
4+
import { Subscriptions, Users, Settings, LivechatContacts } from '@rocket.chat/models';
55
import { wrapExceptions } from '@rocket.chat/tools';
66
import moment from 'moment';
77

@@ -110,7 +110,7 @@ export const startLicense = async () => {
110110
License.setLicenseLimitCounter('roomsPerGuest', async (context) => (context?.userId ? Subscriptions.countByUserId(context.userId) : 0));
111111
License.setLicenseLimitCounter('privateApps', () => getAppCount('private'));
112112
License.setLicenseLimitCounter('marketplaceApps', () => getAppCount('marketplace'));
113-
License.setLicenseLimitCounter('monthlyActiveContacts', () => LivechatVisitors.countVisitorsOnPeriod(moment.utc().format('YYYY-MM')));
113+
License.setLicenseLimitCounter('monthlyActiveContacts', () => LivechatContacts.countContactsOnPeriod(moment.utc().format('YYYY-MM')));
114114

115115
return new Promise<void>((resolve) => {
116116
// When settings are loaded, apply the current license if there is one.

apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { faker } from '@faker-js/faker';
22
import type { ILivechatVisitor } from '@rocket.chat/core-typings';
33
import { expect } from 'chai';
44
import { before, describe, it, after } from 'mocha';
5-
import moment from 'moment';
65
import { type Response } from 'supertest';
76

87
import { getCredentials, api, request, credentials } from '../../../data/api-data';
@@ -484,27 +483,6 @@ describe('LIVECHAT - visitors', () => {
484483
});
485484
});
486485

487-
it('should return visitor activity field when visitor was active on month', async () => {
488-
// Activity is determined by a conversation in which an agent has engaged (sent a message)
489-
// For a visitor to be considered active, they must have had a conversation in the last 30 days
490-
const period = moment().format('YYYY-MM');
491-
const { visitor, room } = await startANewLivechatRoomAndTakeIt();
492-
// agent should send a message on the room
493-
await request
494-
.post(api('chat.sendMessage'))
495-
.set(credentials)
496-
.send({
497-
message: {
498-
rid: room._id,
499-
msg: 'test',
500-
},
501-
});
502-
503-
const activeVisitor = await getLivechatVisitorByToken(visitor.token);
504-
expect(activeVisitor).to.have.property('activity');
505-
expect(activeVisitor.activity).to.include(period);
506-
});
507-
508486
it('should not affect MAC count when a visitor is removed via GDPR', async () => {
509487
const { visitor, room } = await startANewLivechatRoomAndTakeIt();
510488
// agent should send a message on the room
Lines changed: 64 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ILivechatVisitor } from '@rocket.chat/core-typings';
22
import { expect } from 'chai';
3-
import { before, describe, it } from 'mocha';
3+
import { before, afterEach, after, describe, it } from 'mocha';
44
import moment from 'moment';
55

66
import { api, getCredentials, request, credentials } from '../../../data/api-data';
@@ -13,6 +13,7 @@ import {
1313
getLivechatRoomInfo,
1414
fetchInquiry,
1515
closeOmnichannelRoom,
16+
deleteVisitor,
1617
} from '../../../data/livechat/rooms';
1718

1819
describe('MAC', () => {
@@ -25,14 +26,29 @@ describe('MAC', () => {
2526

2627
describe('MAC rooms', () => {
2728
let visitor: ILivechatVisitor;
28-
it('Should create an innactive room by default', async () => {
29-
const visitor = await createVisitor();
29+
let multipleContactsVisitor: ILivechatVisitor;
30+
31+
afterEach(() => deleteVisitor(visitor.token));
32+
33+
after(() => deleteVisitor(multipleContactsVisitor.token));
34+
35+
it('Should create an innactive room and contact by default', async () => {
36+
visitor = await createVisitor();
3037
const room = await createLivechatRoom(visitor.token);
3138

3239
expect(room).to.be.an('object');
3340
expect(room.v.activity).to.be.undefined;
3441
});
3542

43+
it('Should create an innactive contact by default', async () => {
44+
visitor = await createVisitor();
45+
const room = await createLivechatRoom(visitor.token);
46+
47+
const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId });
48+
expect(res.body.contact.channels[0].visitor.visitorId).to.be.equal(visitor._id);
49+
expect(res.body.contact).not.to.have.property('activity');
50+
});
51+
3652
it('should mark room as active when agent sends a message', async () => {
3753
visitor = await createVisitor();
3854
const room = await createLivechatRoom(visitor.token);
@@ -44,50 +60,75 @@ describe('MAC', () => {
4460
expect(updatedRoom).to.have.nested.property('v.activity').and.to.be.an('array');
4561
});
4662

47-
it('should mark multiple rooms as active when they come from same visitor after an agent sends a message', async () => {
48-
const room = await createLivechatRoom(visitor.token);
63+
it('should mark contact as active when agent sends a message', async () => {
64+
multipleContactsVisitor = await createVisitor();
65+
const room = await createLivechatRoom(multipleContactsVisitor.token);
66+
67+
await sendAgentMessage(room._id);
68+
69+
const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId });
70+
expect(res.body.contact.channels[0].visitor.visitorId).to.be.equal(multipleContactsVisitor._id);
71+
expect(res.body.contact).to.have.property('activity').that.is.an('array').with.lengthOf(1);
72+
expect(res.body.contact.activity[0]).to.equal(moment.utc().format('YYYY-MM'));
73+
});
74+
75+
it('should mark multiple rooms as active when they come from same contact after an agent sends a message', async () => {
76+
const room = await createLivechatRoom(multipleContactsVisitor.token);
4977

5078
await sendAgentMessage(room._id);
5179

5280
const updatedRoom = await getLivechatRoomInfo(room._id);
5381

5482
expect(updatedRoom).to.have.nested.property('v.activity').and.to.be.an('array');
83+
await closeOmnichannelRoom(room._id);
84+
});
85+
86+
it('should keep contact active when reusing it and an agent response is received', async () => {
87+
const room = await createLivechatRoom(multipleContactsVisitor.token);
88+
89+
await sendAgentMessage(room._id);
5590

91+
const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId });
92+
expect(res.body.contact.channels[0].visitor.visitorId).to.be.equal(multipleContactsVisitor._id);
93+
expect(res.body.contact).to.have.property('activity').that.is.an('array').with.lengthOf(1);
94+
expect(res.body.contact.activity[0]).to.equal(moment.utc().format('YYYY-MM'));
5695
await closeOmnichannelRoom(room._id);
5796
});
5897

59-
it('should mark room as active when it comes from same visitor on same period, even without agent interaction', async () => {
60-
const room = await createLivechatRoom(visitor.token);
98+
it('should mark room as active when it comes from same contact on same period, even without agent interaction', async () => {
99+
const room = await createLivechatRoom(multipleContactsVisitor.token);
61100

62101
expect(room).to.have.nested.property('v.activity').and.to.be.an('array');
63102
expect(room.v.activity?.includes(moment.utc().format('YYYY-MM'))).to.be.true;
64-
65103
await closeOmnichannelRoom(room._id);
66104
});
67105

68-
it('should mark an inquiry as active when it comes from same visitor on same period, even without agent interaction', async () => {
69-
const room = await createLivechatRoom(visitor.token);
106+
it('should mark an inquiry as active when it comes from same contact on same period, even without agent interaction', async () => {
107+
const room = await createLivechatRoom(multipleContactsVisitor.token);
70108
const inquiry = await fetchInquiry(room._id);
71109

72110
expect(inquiry).to.have.nested.property('v.activity').and.to.be.an('array');
73111
expect(inquiry.v.activity?.includes(moment.utc().format('YYYY-MM'))).to.be.true;
74112
expect(room.v.activity?.includes(moment.utc().format('YYYY-MM'))).to.be.true;
75-
76113
await closeOmnichannelRoom(room._id);
77114
});
78115

79-
it('visitor should be marked as active for period', async () => {
80-
const { body } = await request
81-
.get(api('livechat/visitors.info'))
82-
.query({ visitorId: visitor._id })
83-
.set(credentials)
84-
.expect('Content-Type', 'application/json')
85-
.expect(200);
86-
87-
expect(body).to.have.nested.property('visitor').and.to.be.an('object');
88-
expect(body.visitor).to.have.nested.property('activity').and.to.be.an('array');
89-
expect(body.visitor.activity).to.have.lengthOf(1);
90-
expect(body.visitor.activity[0]).to.equal(moment.utc().format('YYYY-MM'));
116+
it('contact should be marked as active for period', async () => {
117+
visitor = await createVisitor();
118+
const room = await createLivechatRoom(visitor.token);
119+
await sendAgentMessage(room._id);
120+
const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: room.contactId });
121+
122+
expect(res.status).to.be.equal(200);
123+
expect(res.body).to.have.property('success', true);
124+
expect(res.body).to.have.nested.property('contact').and.to.be.an('object');
125+
expect(res.body.contact.channels).to.be.an('array').with.lengthOf(1);
126+
expect(res.body.contact.channels[0].name).to.be.equal('api');
127+
expect(res.body.contact.channels[0].visitor.visitorId).to.be.equal(visitor._id);
128+
129+
expect(res.body.contact).to.have.nested.property('activity').and.to.be.an('array').with.lengthOf(1);
130+
expect(res.body.contact.activity[0]).to.equal(moment.utc().format('YYYY-MM'));
131+
await closeOmnichannelRoom(room._id);
91132
});
92133
});
93134
});

0 commit comments

Comments
 (0)