From dc457acae1459d2a349924796e8fddcfa7943b31 Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Mon, 21 Oct 2024 16:14:09 -0300 Subject: [PATCH 1/5] fix: add `withErrorBoundary` to `BannerRegion` (#33689) --- .changeset/real-jeans-worry.md | 5 +++++ apps/meteor/client/views/banners/BannerRegion.tsx | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/real-jeans-worry.md diff --git a/.changeset/real-jeans-worry.md b/.changeset/real-jeans-worry.md new file mode 100644 index 000000000000..9b16e7681a98 --- /dev/null +++ b/.changeset/real-jeans-worry.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes banner breaking the UI with specific payloads diff --git a/apps/meteor/client/views/banners/BannerRegion.tsx b/apps/meteor/client/views/banners/BannerRegion.tsx index 6bf51148ac03..e70b6c608eab 100644 --- a/apps/meteor/client/views/banners/BannerRegion.tsx +++ b/apps/meteor/client/views/banners/BannerRegion.tsx @@ -2,6 +2,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import { withErrorBoundary } from '../../components/withErrorBoundary'; import * as banners from '../../lib/banners'; import LegacyBanner from './LegacyBanner'; import UiKitBanner from './UiKitBanner'; @@ -23,4 +24,4 @@ const BannerRegion = (): ReactElement | null => { return ; }; -export default BannerRegion; +export default withErrorBoundary(BannerRegion); From d69be40738f81de625e0175a35ce2b816b3c2954 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 22 Oct 2024 03:44:26 -0600 Subject: [PATCH 2/5] chore: Replace `cursor.count` with `countDocuments` or `estimatedDocumentCount` (#33693) --- apps/meteor/app/api/server/v1/roles.ts | 6 +-- .../server/methods/deleteRole.ts | 2 +- .../server/PendingAvatarImporter.ts | 3 +- .../server/functions/updateGroupDMsName.ts | 4 +- .../app/lib/server/methods/leaveRoom.ts | 3 +- .../slashcommands-inviteall/server/server.ts | 10 ++--- .../server/configuration/videoConference.ts | 2 +- apps/meteor/server/methods/browseChannels.ts | 2 +- apps/meteor/server/methods/removeRoomOwner.ts | 5 +-- .../server/methods/removeUserFromRoom.ts | 6 +-- .../server/methods/requestDataDownload.ts | 2 +- .../server/models/raw/ExportOperations.ts | 9 +++++ apps/meteor/server/models/raw/Roles.ts | 16 ++++++++ apps/meteor/server/models/raw/Rooms.ts | 7 ++++ .../meteor/server/models/raw/Subscriptions.ts | 11 ++++++ apps/meteor/server/models/raw/Users.js | 20 ++++++++++ apps/meteor/server/startup/initialData.js | 9 ++--- .../unit/server/startup/initialData.tests.ts | 38 +++++++++---------- .../src/models/IExportOperationsModel.ts | 1 + .../model-typings/src/models/IRolesModel.ts | 1 + .../model-typings/src/models/IRoomsModel.ts | 1 + .../src/models/ISubscriptionsModel.ts | 1 + .../model-typings/src/models/IUsersModel.ts | 2 + yarn.lock | 2 - 24 files changed, 111 insertions(+), 52 deletions(-) diff --git a/apps/meteor/app/api/server/v1/roles.ts b/apps/meteor/app/api/server/v1/roles.ts index fc9bd273996d..20f6e38f3567 100644 --- a/apps/meteor/app/api/server/v1/roles.ts +++ b/apps/meteor/app/api/server/v1/roles.ts @@ -165,9 +165,7 @@ API.v1.addRoute( throw new Meteor.Error('error-role-protected', 'Cannot delete a protected role'); } - const existingUsers = await Roles.findUsersInRole(role._id); - - if (existingUsers && (await existingUsers.count()) > 0) { + if ((await Roles.countUsersInRole(role._id)) > 0) { throw new Meteor.Error('error-role-in-use', "Cannot delete role because it's in use"); } @@ -217,7 +215,7 @@ API.v1.addRoute( } if (role._id === 'admin') { - const adminCount = await (await Roles.findUsersInRole('admin')).count(); + const adminCount = await Roles.countUsersInRole('admin'); if (adminCount === 1) { throw new Meteor.Error('error-admin-required', 'You need to have at least one admin'); } diff --git a/apps/meteor/app/authorization/server/methods/deleteRole.ts b/apps/meteor/app/authorization/server/methods/deleteRole.ts index 140852e0f1ec..512468f2d6d7 100644 --- a/apps/meteor/app/authorization/server/methods/deleteRole.ts +++ b/apps/meteor/app/authorization/server/methods/deleteRole.ts @@ -56,7 +56,7 @@ Meteor.methods({ }); } - const users = await (await Roles.findUsersInRole(role._id)).count(); + const users = await Roles.countUsersInRole(role._id); if (users > 0) { throw new Meteor.Error('error-role-in-use', "Cannot delete role because it's in use", { diff --git a/apps/meteor/app/importer-pending-avatars/server/PendingAvatarImporter.ts b/apps/meteor/app/importer-pending-avatars/server/PendingAvatarImporter.ts index de37ba200289..f057da4a625d 100644 --- a/apps/meteor/app/importer-pending-avatars/server/PendingAvatarImporter.ts +++ b/apps/meteor/app/importer-pending-avatars/server/PendingAvatarImporter.ts @@ -10,8 +10,7 @@ export class PendingAvatarImporter extends Importer { this.logger.debug('start preparing import operation'); await super.updateProgress(ProgressStep.PREPARING_STARTED); - const users = Users.findAllUsersWithPendingAvatar(); - const fileCount = await users.count(); + const fileCount = await Users.countAllUsersWithPendingAvatar(); if (fileCount === 0) { await super.updateProgress(ProgressStep.DONE); diff --git a/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts b/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts index feb26ce6a1b0..ac204af51439 100644 --- a/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts +++ b/apps/meteor/app/lib/server/functions/updateGroupDMsName.ts @@ -8,14 +8,14 @@ const getName = (members: IUser[]): string => members.map(({ username }) => user async function getUsersWhoAreInTheSameGroupDMsAs(user: IUser) { // add all users to single array so we can fetch details from them all at once - const rooms = Rooms.findGroupDMsByUids([user._id], { projection: { uids: 1 } }); - if ((await rooms.count()) === 0) { + if ((await Rooms.countGroupDMsByUids([user._id])) === 0) { return; } const userIds = new Set(); const users = new Map(); + const rooms = Rooms.findGroupDMsByUids([user._id], { projection: { uids: 1 } }); await rooms.forEach((room) => { if (!room.uids) { return; diff --git a/apps/meteor/app/lib/server/methods/leaveRoom.ts b/apps/meteor/app/lib/server/methods/leaveRoom.ts index 4fc85b35fd05..8ded8b56ce2b 100644 --- a/apps/meteor/app/lib/server/methods/leaveRoom.ts +++ b/apps/meteor/app/lib/server/methods/leaveRoom.ts @@ -45,8 +45,7 @@ export const leaveRoomMethod = async (user: IUser, rid: string): Promise = // If user is room owner, check if there are other owners. If there isn't anyone else, warn user to set a new owner. if (await hasRoleAsync(user._id, 'owner', room._id)) { - const cursor = await Roles.findUsersInRole('owner', room._id); - const numOwners = await cursor.count(); + const numOwners = await Roles.countUsersInRole('owner', room._id); if (numOwners === 1) { throw new Meteor.Error('error-you-are-last-owner', 'You are the last owner. Please set new owner before leaving the room.', { method: 'leaveRoom', diff --git a/apps/meteor/app/slashcommands-inviteall/server/server.ts b/apps/meteor/app/slashcommands-inviteall/server/server.ts index e74bb89899c2..436d283d4b5a 100644 --- a/apps/meteor/app/slashcommands-inviteall/server/server.ts +++ b/apps/meteor/app/slashcommands-inviteall/server/server.ts @@ -64,20 +64,20 @@ function inviteAll(type: T): SlashCommand['callback'] { return; } - const cursor = Subscriptions.findByRoomIdWhenUsernameExists(baseChannel._id, { - projection: { 'u.username': 1 }, - }); - try { const APIsettings = settings.get('API_User_Limit'); if (!APIsettings) { return; } - if ((await cursor.count()) > APIsettings) { + if ((await Subscriptions.countByRoomIdWhenUsernameExists(baseChannel._id)) > APIsettings) { throw new Meteor.Error('error-user-limit-exceeded', 'User Limit Exceeded', { method: 'addAllToRoom', }); } + + const cursor = Subscriptions.findByRoomIdWhenUsernameExists(baseChannel._id, { + projection: { 'u.username': 1 }, + }); const users = (await cursor.toArray()).map((s: ISubscription) => s.u.username).filter(isTruthy); if (!targetChannel && ['c', 'p'].indexOf(baseChannel.t) > -1) { diff --git a/apps/meteor/ee/server/configuration/videoConference.ts b/apps/meteor/ee/server/configuration/videoConference.ts index 035110904840..2fd6e31fba95 100644 --- a/apps/meteor/ee/server/configuration/videoConference.ts +++ b/apps/meteor/ee/server/configuration/videoConference.ts @@ -38,7 +38,7 @@ Meteor.startup(async () => { } } - if ((await Subscriptions.findByRoomId(_id).count()) > 10) { + if ((await Subscriptions.countByRoomId(_id)) > 10) { return false; } diff --git a/apps/meteor/server/methods/browseChannels.ts b/apps/meteor/server/methods/browseChannels.ts index 965accc83954..0b02f009672e 100644 --- a/apps/meteor/server/methods/browseChannels.ts +++ b/apps/meteor/server/methods/browseChannels.ts @@ -116,7 +116,7 @@ const getChannelsAndGroups = async ( }; }; -const getChannelsCountForTeam = mem((teamId) => Rooms.findByTeamId(teamId, { projection: { _id: 1 } }).count(), { +const getChannelsCountForTeam = mem((teamId) => Rooms.countByTeamId(teamId), { maxAge: 2000, }); diff --git a/apps/meteor/server/methods/removeRoomOwner.ts b/apps/meteor/server/methods/removeRoomOwner.ts index 91046655a4a6..bc4ddf7964ee 100644 --- a/apps/meteor/server/methods/removeRoomOwner.ts +++ b/apps/meteor/server/methods/removeRoomOwner.ts @@ -1,11 +1,10 @@ import { api, Message, Team } from '@rocket.chat/core-services'; import { isRoomFederated } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Subscriptions, Rooms, Users } from '@rocket.chat/models'; +import { Subscriptions, Rooms, Users, Roles } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { getUsersInRole } from '../../app/authorization/server'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; @@ -64,7 +63,7 @@ Meteor.methods({ }); } - const numOwners = await (await getUsersInRole('owner', rid)).count(); + const numOwners = await Roles.countUsersInRole('owner', rid); if (numOwners === 1) { throw new Meteor.Error('error-remove-last-owner', 'This is the last owner. Please set a new owner before removing this one.', { diff --git a/apps/meteor/server/methods/removeUserFromRoom.ts b/apps/meteor/server/methods/removeUserFromRoom.ts index 781ffe3a2671..8d1cb2e2ba2b 100644 --- a/apps/meteor/server/methods/removeUserFromRoom.ts +++ b/apps/meteor/server/methods/removeUserFromRoom.ts @@ -2,11 +2,11 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { Message, Team, Room } from '@rocket.chat/core-services'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Subscriptions, Rooms, Users } from '@rocket.chat/models'; +import { Subscriptions, Rooms, Users, Roles } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { canAccessRoomAsync, getUsersInRole } from '../../app/authorization/server'; +import { canAccessRoomAsync } from '../../app/authorization/server'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { hasRoleAsync } from '../../app/authorization/server/functions/hasRole'; import { notifyOnRoomChanged, notifyOnSubscriptionChanged } from '../../app/lib/server/lib/notifyListener'; @@ -70,7 +70,7 @@ export const removeUserFromRoomMethod = async (fromId: string, data: { rid: stri } if (await hasRoleAsync(removedUser._id, 'owner', room._id)) { - const numOwners = await (await getUsersInRole('owner', room._id)).count(); + const numOwners = await Roles.countUsersInRole('owner', room._id); if (numOwners === 1) { throw new Meteor.Error('error-you-are-last-owner', 'You are the last owner. Please set new owner before leaving the room.', { diff --git a/apps/meteor/server/methods/requestDataDownload.ts b/apps/meteor/server/methods/requestDataDownload.ts index 80769aae6340..cf30a6768b7c 100644 --- a/apps/meteor/server/methods/requestDataDownload.ts +++ b/apps/meteor/server/methods/requestDataDownload.ts @@ -34,7 +34,7 @@ Meteor.methods({ const lastOperation = await ExportOperations.findLastOperationByUser(userId, fullExport); const requestDay = lastOperation ? lastOperation.createdAt : new Date(); - const pendingOperationsBeforeMyRequestCount = await ExportOperations.findAllPendingBeforeMyRequest(requestDay).count(); + const pendingOperationsBeforeMyRequestCount = await ExportOperations.countAllPendingBeforeMyRequest(requestDay); if (lastOperation) { const yesterday = new Date(); diff --git a/apps/meteor/server/models/raw/ExportOperations.ts b/apps/meteor/server/models/raw/ExportOperations.ts index d7afb8ad6010..0b544ddaa6c9 100644 --- a/apps/meteor/server/models/raw/ExportOperations.ts +++ b/apps/meteor/server/models/raw/ExportOperations.ts @@ -48,6 +48,15 @@ export class ExportOperationsRaw extends BaseRaw implements IE return this.find(query); } + countAllPendingBeforeMyRequest(requestDay: Date): Promise { + const query = { + status: { $nin: ['completed', 'skipped'] }, + createdAt: { $lt: requestDay }, + }; + + return this.countDocuments(query); + } + updateOperation(data: IExportOperation): Promise { const update = { $set: { diff --git a/apps/meteor/server/models/raw/Roles.ts b/apps/meteor/server/models/raw/Roles.ts index 4e1cb09348c4..c3dfab7a702a 100644 --- a/apps/meteor/server/models/raw/Roles.ts +++ b/apps/meteor/server/models/raw/Roles.ts @@ -254,6 +254,22 @@ export class RolesRaw extends BaseRaw implements IRolesModel { } } + async countUsersInRole(roleId: IRole['_id'], scope?: IRoom['_id']): Promise { + const role = await this.findOneById>(roleId, { projection: { scope: 1 } }); + + if (!role) { + throw new Error('RolesRaw.countUsersInRole: role not found'); + } + + switch (role.scope) { + case 'Subscriptions': + return Subscriptions.countUsersInRoles([role._id], scope); + case 'Users': + default: + return Users.countUsersInRoles([role._id]); + } + } + async createWithRandomId( name: IRole['name'], scope: IRole['scope'] = 'Users', diff --git a/apps/meteor/server/models/raw/Rooms.ts b/apps/meteor/server/models/raw/Rooms.ts index 7fc3be7479ed..0d0770994fbd 100644 --- a/apps/meteor/server/models/raw/Rooms.ts +++ b/apps/meteor/server/models/raw/Rooms.ts @@ -1464,6 +1464,13 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { ); } + countGroupDMsByUids(uids: NonNullable): Promise { + return this.countDocuments({ + usersCount: { $gt: 2 }, + uids: { $in: uids }, + }); + } + find1On1ByUserId(userId: IRoom['_id'], options: FindOptions = {}): FindCursor { return this.find( { diff --git a/apps/meteor/server/models/raw/Subscriptions.ts b/apps/meteor/server/models/raw/Subscriptions.ts index 829261e7f94a..7d3eda60edd3 100644 --- a/apps/meteor/server/models/raw/Subscriptions.ts +++ b/apps/meteor/server/models/raw/Subscriptions.ts @@ -264,6 +264,17 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return Users.find

({ _id: { $in: users } }, options || {}); } + async countUsersInRoles(roles: IRole['_id'][], rid: IRoom['_id'] | undefined): Promise { + const query = { + roles: { $in: roles }, + ...(rid && { rid }), + }; + + // Ideally, the count of subscriptions would be the same (or really similar) to the count in users + // As sub/user/room is a 1:1 relation. + return this.countDocuments(query); + } + addRolesByUserId(uid: IUser['_id'], roles: IRole['_id'][], rid?: IRoom['_id']): Promise { if (!Array.isArray(roles)) { roles = [roles]; diff --git a/apps/meteor/server/models/raw/Users.js b/apps/meteor/server/models/raw/Users.js index a5bd1d4fc16b..3c1badc55e25 100644 --- a/apps/meteor/server/models/raw/Users.js +++ b/apps/meteor/server/models/raw/Users.js @@ -135,6 +135,16 @@ export class UsersRaw extends BaseRaw { return this.find(query, options); } + countUsersInRoles(roles) { + roles = [].concat(roles); + + const query = { + roles: { $in: roles }, + }; + + return this.countDocuments(query); + } + findPaginatedUsersInRoles(roles, options) { roles = [].concat(roles); @@ -3074,6 +3084,16 @@ export class UsersRaw extends BaseRaw { return this.find(query, options); } + countAllUsersWithPendingAvatar() { + const query = { + _pendingAvatarUrl: { + $exists: true, + }, + }; + + return this.countDocuments(query); + } + updateCustomFieldsById(userId, customFields) { return this.updateOne( { _id: userId }, diff --git a/apps/meteor/server/startup/initialData.js b/apps/meteor/server/startup/initialData.js index cefed3eef922..db8ebeddc8cc 100644 --- a/apps/meteor/server/startup/initialData.js +++ b/apps/meteor/server/startup/initialData.js @@ -1,9 +1,8 @@ -import { Settings, Rooms, Users } from '@rocket.chat/models'; +import { Settings, Rooms, Users, Roles } from '@rocket.chat/models'; import colors from 'colors/safe'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; -import { getUsersInRole } from '../../app/authorization/server'; import { FileUpload } from '../../app/file-upload/server'; import { RocketChatFile } from '../../app/file/server'; import { addUserToDefaultChannels } from '../../app/lib/server/functions/addUserToDefaultChannels'; @@ -15,7 +14,7 @@ import { addUserRolesAsync } from '../lib/roles/addUserRoles'; export async function insertAdminUserFromEnv() { if (process.env.ADMIN_PASS) { - if ((await (await getUsersInRole('admin')).count()) === 0) { + if ((await Roles.countUsersInRole('admin')) === 0) { const adminUser = { name: 'Administrator', username: 'admin', @@ -188,7 +187,7 @@ Meteor.startup(async () => { } } - if ((await (await getUsersInRole('admin')).count()) === 0) { + if ((await Roles.countUsersInRole('admin')) === 0) { const oldestUser = await Users.getOldest({ projection: { _id: 1, username: 1, name: 1 } }); if (oldestUser) { @@ -197,7 +196,7 @@ Meteor.startup(async () => { } } - if ((await (await getUsersInRole('admin')).count()) !== 0) { + if ((await Roles.countUsersInRole('admin')) !== 0) { if (settings.get('Show_Setup_Wizard') === 'pending') { console.log('Setting Setup Wizard to "in_progress" because, at least, one admin was found'); diff --git a/apps/meteor/tests/unit/server/startup/initialData.tests.ts b/apps/meteor/tests/unit/server/startup/initialData.tests.ts index c9980db5ba3e..b139ea571e1a 100644 --- a/apps/meteor/tests/unit/server/startup/initialData.tests.ts +++ b/apps/meteor/tests/unit/server/startup/initialData.tests.ts @@ -3,7 +3,6 @@ import { beforeEach, it } from 'mocha'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; -const getUsersInRole = sinon.stub(); const checkUsernameAvailability = sinon.stub(); const validateEmail = sinon.stub(); const addUserRolesAsync = sinon.stub(); @@ -14,6 +13,9 @@ const models = { create: sinon.stub(), findOneByEmailAddress: sinon.stub(), }, + Roles: { + countUsersInRole: sinon.stub(), + }, }; const setPasswordAsync = sinon.stub(); const settingsGet = sinon.stub(); @@ -29,9 +31,6 @@ const { insertAdminUserFromEnv } = proxyquire.noCallThru().load('../../../../ser startup: sinon.stub(), }, }, - '../../app/authorization/server': { - getUsersInRole, - }, '../../app/file-upload/server': {}, '../../app/file/server': {}, '../../app/lib/server/functions/addUserToDefaultChannels': {}, @@ -52,7 +51,6 @@ const { insertAdminUserFromEnv } = proxyquire.noCallThru().load('../../../../ser describe('insertAdminUserFromEnv', () => { beforeEach(() => { - getUsersInRole.reset(); checkUsernameAvailability.reset(); validateEmail.reset(); addUserRolesAsync.reset(); @@ -61,30 +59,30 @@ describe('insertAdminUserFromEnv', () => { setPasswordAsync.reset(); settingsGet.reset(); process.env.ADMIN_PASS = 'pass'; + models.Roles.countUsersInRole.reset(); }); it('should do nothing if process.env.ADMIN_PASS is empty', async () => { process.env.ADMIN_PASS = ''; const result = await insertAdminUserFromEnv(); - expect(getUsersInRole.called).to.be.false; expect(result).to.be.undefined; }); it('should do nothing if theres already an admin user', async () => { - getUsersInRole.returns({ count: () => 1 }); + models.Roles.countUsersInRole.resolves(1); const result = await insertAdminUserFromEnv(); - expect(getUsersInRole.called).to.be.true; + expect(models.Roles.countUsersInRole.called).to.be.true; expect(validateEmail.called).to.be.false; expect(result).to.be.undefined; }); it('should try to validate an email when process.env.ADMIN_EMAIL is set', async () => { process.env.ADMIN_EMAIL = 'email'; - getUsersInRole.returns({ count: () => 0 }); + models.Roles.countUsersInRole.resolves(0); validateEmail.returns(false); models.Users.create.returns({ insertedId: 'newuserid' }); const result = await insertAdminUserFromEnv(); - expect(getUsersInRole.called).to.be.true; + expect(models.Roles.countUsersInRole.called).to.be.true; expect(validateEmail.called).to.be.true; expect(validateEmail.calledWith('email')).to.be.true; expect(models.Users.create.called).to.be.true; @@ -94,7 +92,7 @@ describe('insertAdminUserFromEnv', () => { it('should override the admins name when process.env.ADMIN_NAME is set', async () => { process.env.ADMIN_EMAIL = 'email'; process.env.ADMIN_NAME = 'name'; - getUsersInRole.returns({ count: () => 0 }); + models.Roles.countUsersInRole.resolves(0); validateEmail.returns(true); validateEmail.returns(false); models.Users.create.returns({ insertedId: 'newuserid' }); @@ -117,7 +115,7 @@ describe('insertAdminUserFromEnv', () => { }); it('should ignore the admin email when another user already has it set', async () => { process.env.ADMIN_EMAIL = 'email'; - getUsersInRole.returns({ count: () => 0 }); + models.Roles.countUsersInRole.resolves(0); validateEmail.returns(true); models.Users.create.returns({ insertedId: 'newuserid' }); models.Users.findOneByEmailAddress.returns({ _id: 'someuser' }); @@ -128,7 +126,7 @@ describe('insertAdminUserFromEnv', () => { }); it('should add the email from env when its valid and no users are using it', async () => { process.env.ADMIN_EMAIL = 'email'; - getUsersInRole.returns({ count: () => 0 }); + models.Roles.countUsersInRole.resolves(0); validateEmail.returns(true); models.Users.create.returns({ insertedId: 'newuserid' }); models.Users.findOneByEmailAddress.returns(undefined); @@ -142,7 +140,7 @@ describe('insertAdminUserFromEnv', () => { it('should mark the admin email as verified when process.env.ADMIN_EMAIL_VERIFIED is set to true', async () => { process.env.ADMIN_EMAIL = 'email'; process.env.ADMIN_EMAIL_VERIFIED = 'true'; - getUsersInRole.returns({ count: () => 0 }); + models.Roles.countUsersInRole.resolves(0); validateEmail.returns(true); models.Users.create.returns({ insertedId: 'newuserid' }); models.Users.findOneByEmailAddress.returns(undefined); @@ -155,7 +153,7 @@ describe('insertAdminUserFromEnv', () => { }); it('should validate a username with setting UTF8_User_Names_Validation when process.env.ADMIN_USERNAME is set', async () => { process.env.ADMIN_USERNAME = '1234'; - getUsersInRole.returns({ count: () => 0 }); + models.Roles.countUsersInRole.resolves(0); validateEmail.returns(true); settingsGet.returns('[0-9]+'); models.Users.create.returns({ insertedId: 'newuserid' }); @@ -166,7 +164,7 @@ describe('insertAdminUserFromEnv', () => { }); it('should override the username from admin if the env ADMIN_USERNAME is set, is valid and the username is available', async () => { process.env.ADMIN_USERNAME = '1234'; - getUsersInRole.returns({ count: () => 0 }); + models.Roles.countUsersInRole.resolves(0); validateEmail.returns(true); settingsGet.returns('[0-9]+'); checkUsernameAvailability.returns(true); @@ -178,7 +176,7 @@ describe('insertAdminUserFromEnv', () => { }); it('should ignore the username when it does not pass setting regexp validation', async () => { process.env.ADMIN_USERNAME = '1234'; - getUsersInRole.returns({ count: () => 0 }); + models.Roles.countUsersInRole.resolves(0); validateEmail.returns(true); settingsGet.returns('[A-Z]+'); checkUsernameAvailability.returns(true); @@ -194,7 +192,7 @@ describe('insertAdminUserFromEnv', () => { process.env.ADMIN_USERNAME = '1234'; process.env.ADMIN_EMAIL_VERIFIED = 'true'; - getUsersInRole.returns({ count: () => 0 }); + models.Roles.countUsersInRole.resolves(0); validateEmail.returns(true); settingsGet.returns('[0-9]+'); checkUsernameAvailability.returns(true); @@ -212,7 +210,7 @@ describe('insertAdminUserFromEnv', () => { process.env.ADMIN_NAME = 'name'; process.env.ADMIN_USERNAME = '$$$$$$'; - getUsersInRole.returns({ count: () => 0 }); + models.Roles.countUsersInRole.resolves(0); settingsGet.returns('['); checkUsernameAvailability.returns(true); models.Users.create.returns({ insertedId: 'newuserid' }); @@ -224,7 +222,7 @@ describe('insertAdminUserFromEnv', () => { it('should ignore the username when is not available', async () => { process.env.ADMIN_USERNAME = '1234'; - getUsersInRole.returns({ count: () => 0 }); + models.Roles.countUsersInRole.resolves(0); checkUsernameAvailability.throws('some error'); models.Users.create.returns({ insertedId: 'newuserid' }); diff --git a/packages/model-typings/src/models/IExportOperationsModel.ts b/packages/model-typings/src/models/IExportOperationsModel.ts index c834f1bc46ef..c6ef5212b23d 100644 --- a/packages/model-typings/src/models/IExportOperationsModel.ts +++ b/packages/model-typings/src/models/IExportOperationsModel.ts @@ -9,4 +9,5 @@ export interface IExportOperationsModel extends IBaseModel { findLastOperationByUser(userId: string, fullExport: boolean): Promise; findAllPendingBeforeMyRequest(requestDay: Date): FindCursor; updateOperation(data: IExportOperation): Promise; + countAllPendingBeforeMyRequest(requestDay: Date): Promise; } diff --git a/packages/model-typings/src/models/IRolesModel.ts b/packages/model-typings/src/models/IRolesModel.ts index a6e8354a0bc0..f039253f7bc3 100644 --- a/packages/model-typings/src/models/IRolesModel.ts +++ b/packages/model-typings/src/models/IRolesModel.ts @@ -61,4 +61,5 @@ export interface IRolesModel extends IBaseModel { ): Promise; canAddUserToRole(uid: IUser['_id'], roleId: IRole['_id'], scope?: IRoom['_id']): Promise; + countUsersInRole(roleId: IRole['_id'], scope?: IRoom['_id']): Promise; } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 6419d021887e..d1235fc58958 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -308,4 +308,5 @@ export interface IRoomsModel extends IBaseModel { e2eKeyId: string, e2eQueue?: IRoom['usersWaitingForE2EKeys'], ): Promise>; + countGroupDMsByUids(uids: NonNullable): Promise; } diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 27c5f80e20e7..67a21945261c 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -321,4 +321,5 @@ export interface ISubscriptionsModel extends IBaseModel { countByRoomIdAndNotUserId(rid: string, uid: string): Promise; countByRoomIdWhenUsernameExists(rid: string): Promise; setE2EKeyByUserIdAndRoomId(userId: string, rid: string, key: string): Promise>; + countUsersInRoles(roles: IRole['_id'][], rid: IRoom['_id'] | undefined): Promise; } diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index 83c499a1356e..6029b4913f2d 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -406,4 +406,6 @@ export interface IUsersModel extends IBaseModel { setFreeSwitchExtension(userId: string, extension: string | undefined): Promise; findAssignedFreeSwitchExtensions(): FindCursor; findUsersWithAssignedFreeSwitchExtensions(options?: FindOptions): FindCursor; + countUsersInRoles(roles: IRole['_id'][]): Promise; + countAllUsersWithPendingAvatar(): Promise; } diff --git a/yarn.lock b/yarn.lock index 663944e2b63f..75e77637a557 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8133,8 +8133,6 @@ __metadata: typescript: "npm:~5.1.6" uglify-es: "npm:^3.3.10" uuid: "npm:~8.3.2" - peerDependencies: - "@rocket.chat/ui-kit": "workspace:^" languageName: unknown linkType: soft From fd5e54f67a028222e2ee1b1711d2e6c6906eced5 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 22 Oct 2024 07:33:33 -0600 Subject: [PATCH 3/5] chore: Break big LivechatTyped file into smaller modules (#33656) --- .../app/apps/server/bridges/livechat.ts | 6 +- .../lib/server/functions/closeLivechatRoom.ts | 2 +- .../imports/server/rest/departments.ts | 12 +- .../app/livechat/imports/server/rest/sms.ts | 2 +- .../meteor/app/livechat/server/api/v1/room.ts | 2 +- .../hooks/sendEmailTranscriptOnClose.ts | 2 +- apps/meteor/app/livechat/server/lib/Helper.ts | 3 +- .../app/livechat/server/lib/LivechatTyped.ts | 271 +----------------- .../app/livechat/server/lib/QueueManager.ts | 3 +- .../app/livechat/server/lib/departmentsLib.ts | 201 +++++++++++++ .../livechat/server/lib/getOnlineAgents.ts | 24 ++ .../livechat/server/lib/getRoomMessages.ts | 23 ++ .../app/livechat/server/lib/localTypes.ts | 26 +- .../livechat/server/methods/saveDepartment.ts | 4 +- .../server/methods/sendMessageLivechat.ts | 2 +- .../server/hooks/scheduleAutoTransfer.ts | 2 +- .../server/hooks/sendPdfTranscriptOnClose.ts | 2 +- apps/meteor/lib/callbacks.ts | 2 +- 18 files changed, 299 insertions(+), 290 deletions(-) create mode 100644 apps/meteor/app/livechat/server/lib/departmentsLib.ts create mode 100644 apps/meteor/app/livechat/server/lib/getOnlineAgents.ts create mode 100644 apps/meteor/app/livechat/server/lib/getRoomMessages.ts diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 8e9a2780820b..235c2975ae19 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -10,7 +10,9 @@ import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@roc import { callbacks } from '../../../../lib/callbacks'; import { deasyncPromise } from '../../../../server/deasync/deasync'; -import { type ILivechatMessage, Livechat as LivechatTyped } from '../../../livechat/server/lib/LivechatTyped'; +import { Livechat as LivechatTyped } from '../../../livechat/server/lib/LivechatTyped'; +import { getRoomMessages } from '../../../livechat/server/lib/getRoomMessages'; +import type { ILivechatMessage } from '../../../livechat/server/lib/localTypes'; import { settings } from '../../../settings/server'; declare module '@rocket.chat/apps/dist/converters/IAppMessagesConverter' { @@ -379,7 +381,7 @@ export class AppLivechatBridge extends LivechatBridge { throw new Error('Could not get the message converter to process livechat room messages'); } - const livechatMessages = await LivechatTyped.getRoomMessages({ rid: roomId }); + const livechatMessages = await getRoomMessages({ rid: roomId }); return Promise.all(await livechatMessages.map((message) => messageConverter.convertMessage(message, livechatMessages)).toArray()); } diff --git a/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts index 263b137ae00c..2ba29ecd32d2 100644 --- a/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts +++ b/apps/meteor/app/lib/server/functions/closeLivechatRoom.ts @@ -3,8 +3,8 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms, Subscriptions } from '@rocket.chat/models'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import type { CloseRoomParams } from '../../../livechat/server/lib/LivechatTyped'; import { Livechat } from '../../../livechat/server/lib/LivechatTyped'; +import type { CloseRoomParams } from '../../../livechat/server/lib/localTypes'; import { notifyOnSubscriptionChanged } from '../lib/notifyListener'; export const closeLivechatRoom = async ( diff --git a/apps/meteor/app/livechat/imports/server/rest/departments.ts b/apps/meteor/app/livechat/imports/server/rest/departments.ts index e56feeac2fa3..580356876dad 100644 --- a/apps/meteor/app/livechat/imports/server/rest/departments.ts +++ b/apps/meteor/app/livechat/imports/server/rest/departments.ts @@ -15,7 +15,7 @@ import { findArchivedDepartments, } from '../../../server/api/lib/departments'; import { DepartmentHelper } from '../../../server/lib/Departments'; -import { Livechat as LivechatTs } from '../../../server/lib/LivechatTyped'; +import { saveDepartment, archiveDepartment, unarchiveDepartment, saveDepartmentAgents } from '../../../server/lib/departmentsLib'; import { isDepartmentCreationAvailable } from '../../../server/lib/isDepartmentCreationAvailable'; API.v1.addRoute( @@ -62,7 +62,7 @@ API.v1.addRoute( const agents = this.bodyParams.agents ? { upsert: this.bodyParams.agents } : {}; const { departmentUnit } = this.bodyParams; - const department = await LivechatTs.saveDepartment( + const department = await saveDepartment( this.userId, null, this.bodyParams.department as ILivechatDepartment, @@ -131,7 +131,7 @@ API.v1.addRoute( } const agentParam = permissionToAddAgents && agents ? { upsert: agents } : {}; - await LivechatTs.saveDepartment(this.userId, _id, department, agentParam, departmentUnit || {}); + await saveDepartment(this.userId, _id, department, agentParam, departmentUnit || {}); return API.v1.success({ department: await LivechatDepartment.findOneById(_id), @@ -191,7 +191,7 @@ API.v1.addRoute( }, { async post() { - await LivechatTs.archiveDepartment(this.urlParams._id); + await archiveDepartment(this.urlParams._id); return API.v1.success(); }, @@ -206,7 +206,7 @@ API.v1.addRoute( }, { async post() { - await LivechatTs.unarchiveDepartment(this.urlParams._id); + await unarchiveDepartment(this.urlParams._id); return API.v1.success(); }, }, @@ -271,7 +271,7 @@ API.v1.addRoute( remove: Array, }), ); - await LivechatTs.saveDepartmentAgents(this.urlParams._id, this.bodyParams); + await saveDepartmentAgents(this.urlParams._id, this.bodyParams); return API.v1.success(); }, diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index 3004423c64ba..8c6701ac67eb 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -21,8 +21,8 @@ import { FileUpload } from '../../../../file-upload/server'; import { checkUrlForSsrf } from '../../../../lib/server/functions/checkUrlForSsrf'; import { settings } from '../../../../settings/server'; import { setCustomField } from '../../../server/api/lib/customFields'; -import type { ILivechatMessage } from '../../../server/lib/LivechatTyped'; import { Livechat as LivechatTyped } from '../../../server/lib/LivechatTyped'; +import type { ILivechatMessage } from '../../../server/lib/localTypes'; const logger = new Logger('SMS'); diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 7d52617e074a..5d327e3d4ad7 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -23,8 +23,8 @@ import { addUserToRoom } from '../../../../lib/server/functions/addUserToRoom'; import { closeLivechatRoom } from '../../../../lib/server/functions/closeLivechatRoom'; import { settings as rcSettings } from '../../../../settings/server'; import { normalizeTransferredByData } from '../../lib/Helper'; -import type { CloseRoomParams } from '../../lib/LivechatTyped'; import { Livechat as LivechatTyped } from '../../lib/LivechatTyped'; +import type { CloseRoomParams } from '../../lib/localTypes'; import { findGuest, findRoom, settings, findAgent, onCheckRoomParams } from '../lib/livechat'; const isAgentWithInfo = (agentObj: ILivechatAgent | { hiddenInfo: boolean }): agentObj is ILivechatAgent => !('hiddenInfo' in agentObj); diff --git a/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts b/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts index f6a35f4dd7f9..f0c445a78a78 100644 --- a/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts +++ b/apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts @@ -3,7 +3,7 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatRooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; -import type { CloseRoomParams } from '../lib/LivechatTyped'; +import type { CloseRoomParams } from '../lib/localTypes'; import { sendTranscript } from '../lib/sendTranscript'; type LivechatCloseCallbackParams = { diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index a050e06c1943..76a192dc1078 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -47,6 +47,7 @@ import { settings } from '../../../settings/server'; import { Livechat as LivechatTyped } from './LivechatTyped'; import { queueInquiry, saveQueueInquiry } from './QueueManager'; import { RoutingManager } from './RoutingManager'; +import { getOnlineAgents } from './getOnlineAgents'; const logger = new Logger('LivechatHelper'); export const allowAgentSkipQueue = (agent: SelectedAgent) => { @@ -403,7 +404,7 @@ export const dispatchInquiryQueued = async (inquiry: ILivechatInquiryRecord, age await saveQueueInquiry(inquiry); // Alert only the online agents of the queued request - const onlineAgents = await LivechatTyped.getOnlineAgents(department, agent); + const onlineAgents = await getOnlineAgents(department, agent); if (!onlineAgents) { logger.debug('Cannot notify agents of queued inquiry. No online agents found'); return; diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 447a25987476..b76dae2cd9c4 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -7,7 +7,6 @@ import type { IOmnichannelRoom, IOmnichannelRoomClosingInfo, IUser, - MessageTypesValues, ILivechatVisitor, SelectedAgent, ILivechatAgent, @@ -15,11 +14,7 @@ import type { ILivechatDepartment, AtLeast, TransferData, - MessageAttachment, - IMessageInbox, IOmnichannelAgent, - ILivechatDepartmentAgents, - LivechatDepartmentDTO, ILivechatInquiryRecord, ILivechatContact, ILivechatContactChannel, @@ -43,7 +38,7 @@ import { import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import type { Filter, FindCursor, ClientSession, MongoError } from 'mongodb'; +import type { Filter, ClientSession, MongoError } from 'mongodb'; import UAParser from 'ua-parser-js'; import { callbacks } from '../../../../lib/callbacks'; @@ -65,7 +60,6 @@ import { notifyOnRoomChangedById, notifyOnLivechatInquiryChangedByToken, notifyOnUserChange, - notifyOnLivechatDepartmentAgentChangedByDepartmentId, notifyOnSubscriptionChangedByRoomId, notifyOnSubscriptionChanged, } from '../../../lib/server/lib/notifyListener'; @@ -77,8 +71,7 @@ import { createContact, createContactFromVisitor, isSingleContactEnabled } from import { parseAgentCustomFields, updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; -import { isDepartmentCreationAvailable } from './isDepartmentCreationAvailable'; -import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes'; +import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor, ILivechatMessage } from './localTypes'; import { parseTranscriptRequest } from './parseTranscriptRequest'; type RegisterGuestType = Partial> & { @@ -96,30 +89,6 @@ type OfflineMessageData = { host?: string; }; -type UploadedFile = { - _id: string; - name?: string; - type?: string; - size?: number; - description?: string; - identify?: { size: { width: number; height: number } }; - format?: string; -}; - -export interface ILivechatMessage { - token: string; - _id: string; - rid: string; - msg: string; - file?: UploadedFile; - files?: UploadedFile[]; - attachments?: MessageAttachment[]; - alias?: string; - groupable?: boolean; - blocks?: IMessage['blocks']; - email?: IMessageInbox['email']; -} - type AKeyOf = { [K in keyof T]?: T[K]; }; @@ -189,27 +158,6 @@ class LivechatClass { return agentsOnline; } - async getOnlineAgents(department?: string, agent?: SelectedAgent | null): Promise | undefined> { - if (agent?.agentId) { - return Users.findOnlineAgents(agent.agentId); - } - - if (department) { - const departmentAgents = await LivechatDepartmentAgents.getOnlineForDepartment(department); - if (!departmentAgents) { - return; - } - - const agentIds = await departmentAgents.map(({ agentId }) => agentId).toArray(); - if (!agentIds.length) { - return; - } - - return Users.findByIds([...new Set(agentIds)]); - } - return Users.findOnlineAgents(); - } - async closeRoom(params: CloseRoomParams, attempts = 2): Promise { let newRoom: IOmnichannelRoom; let chatCloser: ChatCloser; @@ -966,57 +914,6 @@ class LivechatClass { }); } - async getRoomMessages({ rid }: { rid: string }) { - const room = await Rooms.findOneById(rid, { projection: { t: 1 } }); - if (room?.t !== 'l') { - throw new Meteor.Error('invalid-room'); - } - - const ignoredMessageTypes: MessageTypesValues[] = [ - 'livechat_navigation_history', - 'livechat_transcript_history', - 'command', - 'livechat-close', - 'livechat-started', - 'livechat_video_call', - ]; - - return Messages.findVisibleByRoomIdNotContainingTypes(rid, ignoredMessageTypes, { - sort: { ts: 1 }, - }); - } - - async archiveDepartment(_id: string) { - const department = await LivechatDepartment.findOneById>(_id, { - projection: { _id: 1, businessHourId: 1 }, - }); - - if (!department) { - throw new Error('department-not-found'); - } - - await Promise.all([LivechatDepartmentAgents.disableAgentsByDepartmentId(_id), LivechatDepartment.archiveDepartment(_id)]); - - void notifyOnLivechatDepartmentAgentChangedByDepartmentId(_id); - - await callbacks.run('livechat.afterDepartmentArchived', department); - } - - async unarchiveDepartment(_id: string) { - const department = await LivechatDepartment.findOneById(_id, { projection: { _id: 1 } }); - - if (!department) { - throw new Meteor.Error('department-not-found'); - } - - // TODO: these kind of actions should be on events instead of here - await Promise.all([LivechatDepartmentAgents.enableAgentsByDepartmentId(_id), LivechatDepartment.unarchiveDepartment(_id)]); - - void notifyOnLivechatDepartmentAgentChangedByDepartmentId(_id); - - return true; - } - async updateMessage({ guest, message }: { guest: ILivechatVisitor; message: AtLeast }) { check(message, Match.ObjectIncluding({ _id: String })); @@ -1736,41 +1633,6 @@ class LivechatClass { return false; } - async saveDepartmentAgents( - _id: string, - departmentAgents: { - upsert?: Pick[]; - remove?: Pick[]; - }, - ) { - check(_id, String); - check(departmentAgents, { - upsert: Match.Maybe([ - Match.ObjectIncluding({ - agentId: String, - username: String, - count: Match.Maybe(Match.Integer), - order: Match.Maybe(Match.Integer), - }), - ]), - remove: Match.Maybe([ - Match.ObjectIncluding({ - agentId: String, - username: Match.Maybe(String), - count: Match.Maybe(Match.Integer), - order: Match.Maybe(Match.Integer), - }), - ]), - }); - - const department = await LivechatDepartment.findOneById>(_id, { projection: { enabled: 1 } }); - if (!department) { - throw new Meteor.Error('error-department-not-found', 'Department not found'); - } - - return updateDepartmentAgents(_id, departmentAgents, department.enabled); - } - async saveRoomInfo( roomData: { _id: string; @@ -1842,135 +1704,6 @@ class LivechatClass { return true; } - - /** - * @param {string|null} _id - The department id - * @param {Partial} departmentData - * @param {{upsert?: { agentId: string; count?: number; order?: number; }[], remove?: { agentId: string; count?: number; order?: number; }}} [departmentAgents] - The department agents - * @param {{_id?: string}} [departmentUnit] - The department's unit id - */ - async saveDepartment( - userId: string, - _id: string | null, - departmentData: LivechatDepartmentDTO, - departmentAgents?: { - upsert?: { agentId: string; count?: number; order?: number }[]; - remove?: { agentId: string; count?: number; order?: number }; - }, - departmentUnit?: { _id?: string }, - ) { - check(_id, Match.Maybe(String)); - if (departmentUnit?._id !== undefined && typeof departmentUnit._id !== 'string') { - throw new Meteor.Error('error-invalid-department-unit', 'Invalid department unit id provided', { - method: 'livechat:saveDepartment', - }); - } - - const department = _id - ? await LivechatDepartment.findOneById(_id, { projection: { _id: 1, archived: 1, enabled: 1, parentId: 1 } }) - : null; - - if (departmentUnit && !departmentUnit._id && department && department.parentId) { - const isLastDepartmentInUnit = (await LivechatDepartment.countDepartmentsInUnit(department.parentId)) === 1; - if (isLastDepartmentInUnit) { - throw new Meteor.Error('error-unit-cant-be-empty', "The last department in a unit can't be removed", { - method: 'livechat:saveDepartment', - }); - } - } - - if (!department && !(await isDepartmentCreationAvailable())) { - throw new Meteor.Error('error-max-departments-number-reached', 'Maximum number of departments reached', { - method: 'livechat:saveDepartment', - }); - } - - if (department?.archived && departmentData.enabled) { - throw new Meteor.Error('error-archived-department-cant-be-enabled', 'Archived departments cant be enabled', { - method: 'livechat:saveDepartment', - }); - } - - const defaultValidations: Record | BooleanConstructor | StringConstructor> = { - enabled: Boolean, - name: String, - description: Match.Optional(String), - showOnRegistration: Boolean, - email: String, - showOnOfflineForm: Boolean, - requestTagBeforeClosingChat: Match.Optional(Boolean), - chatClosingTags: Match.Optional([String]), - fallbackForwardDepartment: Match.Optional(String), - departmentsAllowedToForward: Match.Optional([String]), - allowReceiveForwardOffline: Match.Optional(Boolean), - }; - - // The Livechat Form department support addition/custom fields, so those fields need to be added before validating - Object.keys(departmentData).forEach((field) => { - if (!defaultValidations.hasOwnProperty(field)) { - defaultValidations[field] = Match.OneOf(String, Match.Integer, Boolean); - } - }); - - check(departmentData, defaultValidations); - check( - departmentAgents, - Match.Maybe({ - upsert: Match.Maybe(Array), - remove: Match.Maybe(Array), - }), - ); - - const { requestTagBeforeClosingChat, chatClosingTags, fallbackForwardDepartment } = departmentData; - if (requestTagBeforeClosingChat && (!chatClosingTags || chatClosingTags.length === 0)) { - throw new Meteor.Error( - 'error-validating-department-chat-closing-tags', - 'At least one closing tag is required when the department requires tag(s) on closing conversations.', - { method: 'livechat:saveDepartment' }, - ); - } - - if (_id && !department) { - throw new Meteor.Error('error-department-not-found', 'Department not found', { - method: 'livechat:saveDepartment', - }); - } - - if (fallbackForwardDepartment === _id) { - throw new Meteor.Error( - 'error-fallback-department-circular', - 'Cannot save department. Circular reference between fallback department and department', - ); - } - - if (fallbackForwardDepartment) { - const fallbackDep = await LivechatDepartment.findOneById(fallbackForwardDepartment, { - projection: { _id: 1, fallbackForwardDepartment: 1 }, - }); - if (!fallbackDep) { - throw new Meteor.Error('error-fallback-department-not-found', 'Fallback department not found', { - method: 'livechat:saveDepartment', - }); - } - } - - const departmentDB = await LivechatDepartment.createOrUpdateDepartment(_id, departmentData); - if (departmentDB && departmentAgents) { - await updateDepartmentAgents(departmentDB._id, departmentAgents, departmentDB.enabled); - } - - // Disable event - if (department?.enabled && !departmentDB?.enabled) { - await callbacks.run('livechat.afterDepartmentDisabled', departmentDB); - } - - if (departmentUnit) { - await callbacks.run('livechat.manageDepartmentUnit', { userId, departmentId: departmentDB._id, unitId: departmentUnit._id }); - } - - return departmentDB; - } } export const Livechat = new LivechatClass(); -export * from './localTypes'; diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index c6728d470870..1644316e9920 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -28,6 +28,7 @@ import { i18n } from '../../../utils/lib/i18n'; import { createLivechatRoom, createLivechatInquiry, allowAgentSkipQueue } from './Helper'; import { Livechat } from './LivechatTyped'; import { RoutingManager } from './RoutingManager'; +import { getOnlineAgents } from './getOnlineAgents'; import { getInquirySortMechanismSetting } from './settings'; const logger = new Logger('QueueManager'); @@ -333,7 +334,7 @@ export class QueueManager { const { department, rid, v } = inquiry; // Alert only the online agents of the queued request - const onlineAgents = await Livechat.getOnlineAgents(department, agent); + const onlineAgents = await getOnlineAgents(department, agent); if (!onlineAgents) { logger.debug('Cannot notify agents of queued inquiry. No online agents found'); diff --git a/apps/meteor/app/livechat/server/lib/departmentsLib.ts b/apps/meteor/app/livechat/server/lib/departmentsLib.ts new file mode 100644 index 000000000000..79b8d40ed387 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/departmentsLib.ts @@ -0,0 +1,201 @@ +import type { LivechatDepartmentDTO, ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; +import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { callbacks } from '../../../../lib/callbacks'; +import { notifyOnLivechatDepartmentAgentChangedByDepartmentId } from '../../../lib/server/lib/notifyListener'; +import { updateDepartmentAgents } from './Helper'; +import { isDepartmentCreationAvailable } from './isDepartmentCreationAvailable'; +/** + * @param {string|null} _id - The department id + * @param {Partial} departmentData + * @param {{upsert?: { agentId: string; count?: number; order?: number; }[], remove?: { agentId: string; count?: number; order?: number; }}} [departmentAgents] - The department agents + * @param {{_id?: string}} [departmentUnit] - The department's unit id + */ +export async function saveDepartment( + userId: string, + _id: string | null, + departmentData: LivechatDepartmentDTO, + departmentAgents?: { + upsert?: { agentId: string; count?: number; order?: number }[]; + remove?: { agentId: string; count?: number; order?: number }; + }, + departmentUnit?: { _id?: string }, +) { + check(_id, Match.Maybe(String)); + if (departmentUnit?._id !== undefined && typeof departmentUnit._id !== 'string') { + throw new Meteor.Error('error-invalid-department-unit', 'Invalid department unit id provided', { + method: 'livechat:saveDepartment', + }); + } + + const department = _id + ? await LivechatDepartment.findOneById(_id, { projection: { _id: 1, archived: 1, enabled: 1, parentId: 1 } }) + : null; + + if (departmentUnit && !departmentUnit._id && department && department.parentId) { + const isLastDepartmentInUnit = (await LivechatDepartment.countDepartmentsInUnit(department.parentId)) === 1; + if (isLastDepartmentInUnit) { + throw new Meteor.Error('error-unit-cant-be-empty', "The last department in a unit can't be removed", { + method: 'livechat:saveDepartment', + }); + } + } + + if (!department && !(await isDepartmentCreationAvailable())) { + throw new Meteor.Error('error-max-departments-number-reached', 'Maximum number of departments reached', { + method: 'livechat:saveDepartment', + }); + } + + if (department?.archived && departmentData.enabled) { + throw new Meteor.Error('error-archived-department-cant-be-enabled', 'Archived departments cant be enabled', { + method: 'livechat:saveDepartment', + }); + } + + const defaultValidations: Record | BooleanConstructor | StringConstructor> = { + enabled: Boolean, + name: String, + description: Match.Optional(String), + showOnRegistration: Boolean, + email: String, + showOnOfflineForm: Boolean, + requestTagBeforeClosingChat: Match.Optional(Boolean), + chatClosingTags: Match.Optional([String]), + fallbackForwardDepartment: Match.Optional(String), + departmentsAllowedToForward: Match.Optional([String]), + allowReceiveForwardOffline: Match.Optional(Boolean), + }; + + // The Livechat Form department support addition/custom fields, so those fields need to be added before validating + Object.keys(departmentData).forEach((field) => { + if (!defaultValidations.hasOwnProperty(field)) { + defaultValidations[field] = Match.OneOf(String, Match.Integer, Boolean); + } + }); + + check(departmentData, defaultValidations); + check( + departmentAgents, + Match.Maybe({ + upsert: Match.Maybe(Array), + remove: Match.Maybe(Array), + }), + ); + + const { requestTagBeforeClosingChat, chatClosingTags, fallbackForwardDepartment } = departmentData; + if (requestTagBeforeClosingChat && (!chatClosingTags || chatClosingTags.length === 0)) { + throw new Meteor.Error( + 'error-validating-department-chat-closing-tags', + 'At least one closing tag is required when the department requires tag(s) on closing conversations.', + { method: 'livechat:saveDepartment' }, + ); + } + + if (_id && !department) { + throw new Meteor.Error('error-department-not-found', 'Department not found', { + method: 'livechat:saveDepartment', + }); + } + + if (fallbackForwardDepartment === _id) { + throw new Meteor.Error( + 'error-fallback-department-circular', + 'Cannot save department. Circular reference between fallback department and department', + ); + } + + if (fallbackForwardDepartment) { + const fallbackDep = await LivechatDepartment.findOneById(fallbackForwardDepartment, { + projection: { _id: 1, fallbackForwardDepartment: 1 }, + }); + if (!fallbackDep) { + throw new Meteor.Error('error-fallback-department-not-found', 'Fallback department not found', { + method: 'livechat:saveDepartment', + }); + } + } + + const departmentDB = await LivechatDepartment.createOrUpdateDepartment(_id, departmentData); + if (departmentDB && departmentAgents) { + await updateDepartmentAgents(departmentDB._id, departmentAgents, departmentDB.enabled); + } + + // Disable event + if (department?.enabled && !departmentDB?.enabled) { + await callbacks.run('livechat.afterDepartmentDisabled', departmentDB); + } + + if (departmentUnit) { + await callbacks.run('livechat.manageDepartmentUnit', { userId, departmentId: departmentDB._id, unitId: departmentUnit._id }); + } + + return departmentDB; +} + +export async function archiveDepartment(_id: string) { + const department = await LivechatDepartment.findOneById>(_id, { + projection: { _id: 1, businessHourId: 1 }, + }); + + if (!department) { + throw new Error('department-not-found'); + } + + await Promise.all([LivechatDepartmentAgents.disableAgentsByDepartmentId(_id), LivechatDepartment.archiveDepartment(_id)]); + + void notifyOnLivechatDepartmentAgentChangedByDepartmentId(_id); + + await callbacks.run('livechat.afterDepartmentArchived', department); +} + +export async function unarchiveDepartment(_id: string) { + const department = await LivechatDepartment.findOneById(_id, { projection: { _id: 1 } }); + + if (!department) { + throw new Meteor.Error('department-not-found'); + } + + // TODO: these kind of actions should be on events instead of here + await Promise.all([LivechatDepartmentAgents.enableAgentsByDepartmentId(_id), LivechatDepartment.unarchiveDepartment(_id)]); + + void notifyOnLivechatDepartmentAgentChangedByDepartmentId(_id); + + return true; +} + +export async function saveDepartmentAgents( + _id: string, + departmentAgents: { + upsert?: Pick[]; + remove?: Pick[]; + }, +) { + check(_id, String); + check(departmentAgents, { + upsert: Match.Maybe([ + Match.ObjectIncluding({ + agentId: String, + username: String, + count: Match.Maybe(Match.Integer), + order: Match.Maybe(Match.Integer), + }), + ]), + remove: Match.Maybe([ + Match.ObjectIncluding({ + agentId: String, + username: Match.Maybe(String), + count: Match.Maybe(Match.Integer), + order: Match.Maybe(Match.Integer), + }), + ]), + }); + + const department = await LivechatDepartment.findOneById>(_id, { projection: { enabled: 1 } }); + if (!department) { + throw new Meteor.Error('error-department-not-found', 'Department not found'); + } + + return updateDepartmentAgents(_id, departmentAgents, department.enabled); +} diff --git a/apps/meteor/app/livechat/server/lib/getOnlineAgents.ts b/apps/meteor/app/livechat/server/lib/getOnlineAgents.ts new file mode 100644 index 000000000000..be92a7cfcc54 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/getOnlineAgents.ts @@ -0,0 +1,24 @@ +import type { ILivechatAgent, SelectedAgent } from '@rocket.chat/core-typings'; +import { Users, LivechatDepartmentAgents } from '@rocket.chat/models'; +import type { FindCursor } from 'mongodb'; + +export async function getOnlineAgents(department?: string, agent?: SelectedAgent | null): Promise | undefined> { + if (agent?.agentId) { + return Users.findOnlineAgents(agent.agentId); + } + + if (department) { + const departmentAgents = await LivechatDepartmentAgents.getOnlineForDepartment(department); + if (!departmentAgents) { + return; + } + + const agentIds = await departmentAgents.map(({ agentId }) => agentId).toArray(); + if (!agentIds.length) { + return; + } + + return Users.findByIds([...new Set(agentIds)]); + } + return Users.findOnlineAgents(); +} diff --git a/apps/meteor/app/livechat/server/lib/getRoomMessages.ts b/apps/meteor/app/livechat/server/lib/getRoomMessages.ts new file mode 100644 index 000000000000..8a9af72b7e23 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/getRoomMessages.ts @@ -0,0 +1,23 @@ +import type { MessageTypesValues, IRoom } from '@rocket.chat/core-typings'; +import { Rooms, Messages } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +export async function getRoomMessages({ rid }: { rid: string }) { + const room = await Rooms.findOneById>(rid, { projection: { t: 1 } }); + if (room?.t !== 'l') { + throw new Meteor.Error('invalid-room'); + } + + const ignoredMessageTypes: MessageTypesValues[] = [ + 'livechat_navigation_history', + 'livechat_transcript_history', + 'command', + 'livechat-close', + 'livechat-started', + 'livechat_video_call', + ]; + + return Messages.findVisibleByRoomIdNotContainingTypes(rid, ignoredMessageTypes, { + sort: { ts: 1 }, + }); +} diff --git a/apps/meteor/app/livechat/server/lib/localTypes.ts b/apps/meteor/app/livechat/server/lib/localTypes.ts index c6acbbc5bcbd..d58cad8f50bc 100644 --- a/apps/meteor/app/livechat/server/lib/localTypes.ts +++ b/apps/meteor/app/livechat/server/lib/localTypes.ts @@ -1,4 +1,4 @@ -import type { IOmnichannelRoom, IUser, ILivechatVisitor } from '@rocket.chat/core-typings'; +import type { IOmnichannelRoom, IUser, ILivechatVisitor, IMessage, MessageAttachment, IMessageInbox } from '@rocket.chat/core-typings'; export type GenericCloseRoomParams = { room: IOmnichannelRoom; @@ -29,3 +29,27 @@ export type CloseRoomParamsByVisitor = { } & GenericCloseRoomParams; export type CloseRoomParams = CloseRoomParamsByUser | CloseRoomParamsByVisitor; + +type UploadedFile = { + _id: string; + name?: string; + type?: string; + size?: number; + description?: string; + identify?: { size: { width: number; height: number } }; + format?: string; +}; + +export interface ILivechatMessage { + token: string; + _id: string; + rid: string; + msg: string; + file?: UploadedFile; + files?: UploadedFile[]; + attachments?: MessageAttachment[]; + alias?: string; + groupable?: boolean; + blocks?: IMessage['blocks']; + email?: IMessageInbox['email']; +} diff --git a/apps/meteor/app/livechat/server/methods/saveDepartment.ts b/apps/meteor/app/livechat/server/methods/saveDepartment.ts index 659f85f49945..484cf275088d 100644 --- a/apps/meteor/app/livechat/server/methods/saveDepartment.ts +++ b/apps/meteor/app/livechat/server/methods/saveDepartment.ts @@ -3,7 +3,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { Livechat } from '../lib/LivechatTyped'; +import { saveDepartment } from '../lib/departmentsLib'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -44,6 +44,6 @@ Meteor.methods({ }); } - return Livechat.saveDepartment(uid, _id, departmentData, { upsert: departmentAgents }, departmentUnit); + return saveDepartment(uid, _id, departmentData, { upsert: departmentAgents }, departmentUnit); }, }); diff --git a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts index badf4149081f..2efecb711b85 100644 --- a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts +++ b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts @@ -6,7 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { settings } from '../../../settings/server'; import { Livechat } from '../lib/LivechatTyped'; -import type { ILivechatMessage } from '../lib/LivechatTyped'; +import type { ILivechatMessage } from '../lib/localTypes'; interface ILivechatMessageAgent { agentId: string; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts index c0f4b1b9da1d..3f0183687e84 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts @@ -1,6 +1,6 @@ import type { IMessage, IOmnichannelRoom } from '@rocket.chat/core-typings'; -import type { CloseRoomParams } from '../../../../../app/livechat/server/lib/LivechatTyped'; +import type { CloseRoomParams } from '../../../../../app/livechat/server/lib/localTypes'; import { settings } from '../../../../../app/settings/server'; import { callbacks } from '../../../../../lib/callbacks'; import { AutoTransferChatScheduler } from '../lib/AutoTransferChatScheduler'; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/sendPdfTranscriptOnClose.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/sendPdfTranscriptOnClose.ts index ee7cfc6a4c2f..ee3fa414433c 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/sendPdfTranscriptOnClose.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/sendPdfTranscriptOnClose.ts @@ -2,7 +2,7 @@ import { OmnichannelTranscript } from '@rocket.chat/core-services'; import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { isOmnichannelRoom } from '@rocket.chat/core-typings'; -import type { CloseRoomParams } from '../../../../../app/livechat/server/lib/LivechatTyped'; +import type { CloseRoomParams } from '../../../../../app/livechat/server/lib/localTypes'; import { callbacks } from '../../../../../lib/callbacks'; type LivechatCloseCallbackParams = { diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index f8f5a324a638..ae2ea140bde1 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -28,7 +28,7 @@ import type { FilterOperators } from 'mongodb'; import type { ILoginAttempt } from '../app/authentication/server/ILoginAttempt'; import type { IBusinessHourBehavior } from '../app/livechat/server/business-hour/AbstractBusinessHour'; -import type { CloseRoomParams } from '../app/livechat/server/lib/LivechatTyped'; +import type { CloseRoomParams } from '../app/livechat/server/lib/localTypes'; import { Callbacks } from './callbacks/callbacksBase'; /** From c11a62a666210af7b4b92898c00cc47dffc11b92 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 22 Oct 2024 13:17:50 -0300 Subject: [PATCH 4/5] fix merge of array fields --- apps/meteor/app/livechat/server/lib/ContactMerger.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/ContactMerger.ts b/apps/meteor/app/livechat/server/lib/ContactMerger.ts index 9347c8d0409d..d944a013871e 100644 --- a/apps/meteor/app/livechat/server/lib/ContactMerger.ts +++ b/apps/meteor/app/livechat/server/lib/ContactMerger.ts @@ -268,10 +268,10 @@ export class ContactMerger { // Phones, Emails and Channels are simply added to the contact's existing list const dataToAdd: UpdateFilter['$addToSet'] = { - ...(newPhones.length ? { phones: newPhones.map((phoneNumber) => ({ phoneNumber })) } : {}), - ...(newEmails.length ? { emails: newEmails.map((address) => ({ address })) } : {}), - ...(newChannels.length ? { channels: newChannels } : {}), - ...(allConflicts.length ? { conflictingFields: allConflicts } : {}), + ...(newPhones.length ? { phones: { $each: newPhones.map((phoneNumber) => ({ phoneNumber })) } } : {}), + ...(newEmails.length ? { emails: { $each: newEmails.map((address) => ({ address })) } } : {}), + ...(newChannels.length ? { channels: { $each: newChannels } } : {}), + ...(allConflicts.length ? { conflictingFields: { $each: allConflicts } } : {}), }; const updateData: UpdateFilter = { From b4acee894eabb24a65100fe85a7b97a67a66068e Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:21:24 -0300 Subject: [PATCH 5/5] chore: split Contacts.ts file and its tests into separate files for every function (#33698) --- .../server/lib/maybeMigrateLivechatRoom.ts | 2 +- .../meteor/app/apps/server/bridges/contact.ts | 3 +- .../classes/converters/ContactConverter.ts | 4 +- .../app/livechat/server/api/lib/visitors.ts | 3 +- .../app/livechat/server/api/v1/contact.ts | 9 +- .../app/livechat/server/lib/Contacts.ts | 611 ------------------ apps/meteor/app/livechat/server/lib/Helper.ts | 3 +- .../app/livechat/server/lib/LivechatTyped.ts | 4 +- .../app/livechat/server/lib/QueueManager.ts | 2 +- .../lib/{ => contacts}/ContactMerger.ts | 2 +- .../server/lib/contacts/addContactEmail.ts | 26 + .../server/lib/contacts/createContact.ts | 47 ++ .../lib/contacts/createContactFromVisitor.ts | 19 + .../lib/contacts/getAllowedCustomFields.ts | 12 + .../server/lib/contacts/getContact.ts | 34 + .../server/lib/contacts/getContactHistory.ts | 65 ++ .../lib/contacts/getContactIdByVisitorId.ts | 10 + .../contacts/getContactManagerIdByUsername.ts | 12 + .../server/lib/contacts/getContacts.ts | 30 + .../lib/contacts/getVisitorNewestSource.ts | 14 + .../lib/contacts/mapVisitorToContact.ts | 24 + .../server/lib/contacts/mergeContacts.ts | 4 + .../migrateVisitorIfMissingContact.ts | 25 + .../lib/contacts/migrateVisitorToContactId.ts | 38 ++ .../server/lib/contacts/registerContact.ts | 133 ++++ .../server/lib/contacts/registerGuestData.ts | 39 ++ .../contacts/shouldTriggerVerificationApp.ts | 6 + .../server/lib/contacts/updateContact.ts | 45 ++ .../lib/contacts/validateContactManager.ts | 9 + .../lib/contacts/validateCustomFields.ts | 49 ++ .../lib/contacts/verifyContactChannel.ts | 12 + .../livechat/server/methods/takeInquiry.ts | 3 +- .../meteor/ee/server/patches/mergeContacts.ts | 4 +- .../patches/shouldTriggerVerificationApp.ts | 2 +- .../ee/server/patches/verifyContactChannel.ts | 3 +- .../server/lib/mergeContacts.spec.ts | 4 +- .../server/lib/verifyContactChannel.spec.ts | 3 +- .../app/livechat/server/lib/Contacts.spec.ts | 229 ------- .../livechat/server/lib/getContact.spec.ts | 103 +++ .../server/lib/mapVisitorToContact.spec.ts | 100 +++ .../lib/migrateVisitorToContactId.spec.ts | 90 +++ .../server/lib/registerContact.spec.ts | 138 ++++ .../livechat/server/lib/updateContact.spec.ts | 48 ++ .../server/lib/validateContactManager.spec.ts | 34 + .../server/lib/validateCustomFields.spec.ts | 43 ++ yarn.lock | 2 - 46 files changed, 1241 insertions(+), 861 deletions(-) delete mode 100644 apps/meteor/app/livechat/server/lib/Contacts.ts rename apps/meteor/app/livechat/server/lib/{ => contacts}/ContactMerger.ts (99%) create mode 100644 apps/meteor/app/livechat/server/lib/contacts/addContactEmail.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/createContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/createContactFromVisitor.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getAllowedCustomFields.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getContactHistory.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getContactIdByVisitorId.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getContactManagerIdByUsername.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getContacts.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getVisitorNewestSource.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/mergeContacts.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/migrateVisitorIfMissingContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/migrateVisitorToContactId.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/registerContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/registerGuestData.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/shouldTriggerVerificationApp.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/updateContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/validateContactManager.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/verifyContactChannel.ts delete mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/getContact.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/mapVisitorToContact.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/migrateVisitorToContactId.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/registerContact.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/updateContact.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/validateContactManager.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/validateCustomFields.spec.ts diff --git a/apps/meteor/app/api/server/lib/maybeMigrateLivechatRoom.ts b/apps/meteor/app/api/server/lib/maybeMigrateLivechatRoom.ts index ca38189991ed..797500941e12 100644 --- a/apps/meteor/app/api/server/lib/maybeMigrateLivechatRoom.ts +++ b/apps/meteor/app/api/server/lib/maybeMigrateLivechatRoom.ts @@ -2,7 +2,7 @@ import { isOmnichannelRoom, type IRoom } from '@rocket.chat/core-typings'; import { Rooms } from '@rocket.chat/models'; import type { FindOptions } from 'mongodb'; -import { migrateVisitorIfMissingContact } from '../../../livechat/server/lib/Contacts'; +import { migrateVisitorIfMissingContact } from '../../../livechat/server/lib/contacts/migrateVisitorIfMissingContact'; export async function maybeMigrateLivechatRoom(room: IRoom | null, options: FindOptions = {}): Promise { if (!room || !isOmnichannelRoom(room)) { diff --git a/apps/meteor/app/apps/server/bridges/contact.ts b/apps/meteor/app/apps/server/bridges/contact.ts index 275149935e59..10f8a6d00632 100644 --- a/apps/meteor/app/apps/server/bridges/contact.ts +++ b/apps/meteor/app/apps/server/bridges/contact.ts @@ -4,7 +4,8 @@ import { ContactBridge } from '@rocket.chat/apps-engine/server/bridges'; import type { IVisitor } from '@rocket.chat/core-typings'; import { LivechatContacts } from '@rocket.chat/models'; -import { addContactEmail, verifyContactChannel } from '../../../livechat/server/lib/Contacts'; +import { addContactEmail } from '../../../livechat/server/lib/contacts/addContactEmail'; +import { verifyContactChannel } from '../../../livechat/server/lib/contacts/verifyContactChannel'; export class AppContactBridge extends ContactBridge { constructor(private readonly orch: IAppServerOrchestrator) { diff --git a/apps/meteor/app/importer/server/classes/converters/ContactConverter.ts b/apps/meteor/app/importer/server/classes/converters/ContactConverter.ts index 306c0f77e2c7..d6eb31a8cb44 100644 --- a/apps/meteor/app/importer/server/classes/converters/ContactConverter.ts +++ b/apps/meteor/app/importer/server/classes/converters/ContactConverter.ts @@ -1,7 +1,9 @@ import type { IImportContact, IImportContactRecord } from '@rocket.chat/core-typings'; import { LivechatVisitors } from '@rocket.chat/models'; -import { createContact, getAllowedCustomFields, validateCustomFields } from '../../../../livechat/server/lib/Contacts'; +import { createContact } from '../../../../livechat/server/lib/contacts/createContact'; +import { getAllowedCustomFields } from '../../../../livechat/server/lib/contacts/getAllowedCustomFields'; +import { validateCustomFields } from '../../../../livechat/server/lib/contacts/validateCustomFields'; import { RecordConverter } from './RecordConverter'; export class ContactConverter extends RecordConverter { diff --git a/apps/meteor/app/livechat/server/api/lib/visitors.ts b/apps/meteor/app/livechat/server/api/lib/visitors.ts index f5b1a1993f93..7fe8e8ab23f9 100644 --- a/apps/meteor/app/livechat/server/api/lib/visitors.ts +++ b/apps/meteor/app/livechat/server/api/lib/visitors.ts @@ -4,7 +4,8 @@ import type { FindOptions } from 'mongodb'; import { callbacks } from '../../../../../lib/callbacks'; import { canAccessRoomAsync } from '../../../../authorization/server/functions/canAccessRoom'; -import { migrateVisitorToContactId, getContactIdByVisitorId } from '../../lib/Contacts'; +import { getContactIdByVisitorId } from '../../lib/contacts/getContactIdByVisitorId'; +import { migrateVisitorToContactId } from '../../lib/contacts/migrateVisitorToContactId'; export async function findVisitorInfo({ visitorId }: { visitorId: IVisitor['_id'] }) { const visitor = await LivechatVisitors.findOneEnabledById(visitorId); diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index a384d4ca9158..0c4d12905c04 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -13,7 +13,12 @@ import { Meteor } from 'meteor/meteor'; import { API } from '../../../../api/server'; import { getPaginationItems } from '../../../../api/server/helpers/getPaginationItems'; -import { getContactHistory, Contacts, createContact, getContact, updateContact, getContacts } from '../../lib/Contacts'; +import { createContact } from '../../lib/contacts/createContact'; +import { getContact } from '../../lib/contacts/getContact'; +import { getContactHistory } from '../../lib/contacts/getContactHistory'; +import { getContacts } from '../../lib/contacts/getContacts'; +import { registerContact } from '../../lib/contacts/registerContact'; +import { updateContact } from '../../lib/contacts/updateContact'; API.v1.addRoute( 'omnichannel/contact', @@ -36,7 +41,7 @@ API.v1.addRoute( }), }); - const contact = await Contacts.registerContact(this.bodyParams); + const contact = await registerContact(this.bodyParams); return API.v1.success({ contact }); }, diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts deleted file mode 100644 index da394e7fa6be..000000000000 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ /dev/null @@ -1,611 +0,0 @@ -import type { - IOmnichannelSource, - AtLeast, - ILivechatContact, - ILivechatContactChannel, - ILivechatCustomField, - ILivechatVisitor, - IOmnichannelRoom, - IUser, -} from '@rocket.chat/core-typings'; -import { - LivechatVisitors, - Users, - LivechatRooms, - LivechatCustomField, - LivechatInquiry, - Rooms, - Subscriptions, - LivechatContacts, -} from '@rocket.chat/models'; -import { makeFunction } from '@rocket.chat/patch-injection'; -import type { PaginatedResult, VisitorSearchChatsResult } from '@rocket.chat/rest-typings'; -import { wrapExceptions } from '@rocket.chat/tools'; -import { check } from 'meteor/check'; -import { Meteor } from 'meteor/meteor'; -import type { MatchKeysAndValues, OnlyFieldsOfType, FindOptions, Sort } from 'mongodb'; - -import { callbacks } from '../../../../lib/callbacks'; -import { trim } from '../../../../lib/utils/stringUtils'; -import { - notifyOnRoomChangedById, - notifyOnSubscriptionChangedByRoomId, - notifyOnLivechatInquiryChangedByRoom, -} from '../../../lib/server/lib/notifyListener'; -import { i18n } from '../../../utils/lib/i18n'; -import type { FieldAndValue } from './ContactMerger'; -import { ContactMerger } from './ContactMerger'; -import { validateEmail } from './Helper'; -import type { RegisterGuestType } from './LivechatTyped'; -import { Livechat } from './LivechatTyped'; - -type RegisterContactProps = { - _id?: string; - token: string; - name: string; - username?: string; - email?: string; - phone?: string; - customFields?: Record; - contactManager?: { - username: string; - }; -}; - -type CreateContactParams = { - name: string; - emails?: string[]; - phones?: string[]; - unknown: boolean; - customFields?: Record; - contactManager?: string; - channels?: ILivechatContactChannel[]; - importIds?: string[]; -}; - -type VerifyContactChannelParams = { - contactId: string; - field: string; - value: string; - visitorId: string; - roomId: string; -}; - -type UpdateContactParams = { - contactId: string; - name?: string; - emails?: string[]; - phones?: string[]; - customFields?: Record; - contactManager?: string; - channels?: ILivechatContactChannel[]; - wipeConflicts?: boolean; -}; - -type GetContactsParams = { - searchText?: string; - count: number; - offset: number; - sort: Sort; -}; - -type GetContactHistoryParams = { - contactId: string; - source?: string; - count: number; - offset: number; - sort: Sort; -}; - -export const Contacts = { - async registerContact({ - token, - name, - email = '', - phone, - username, - customFields = {}, - contactManager, - }: RegisterContactProps): Promise { - check(token, String); - - const visitorEmail = email.trim().toLowerCase(); - - if (contactManager?.username) { - // verify if the user exists with this username and has a livechat-agent role - const user = await Users.findOneByUsername(contactManager.username, { projection: { roles: 1 } }); - if (!user) { - throw new Meteor.Error('error-contact-manager-not-found', `No user found with username ${contactManager.username}`); - } - if (!user.roles || !Array.isArray(user.roles) || !user.roles.includes('livechat-agent')) { - throw new Meteor.Error('error-invalid-contact-manager', 'The contact manager must have the role "livechat-agent"'); - } - } - - let contactId; - - const user = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); - - if (user) { - contactId = user._id; - } else { - if (!username) { - username = await LivechatVisitors.getNextVisitorUsername(); - } - - let existingUser = null; - - if (visitorEmail !== '' && (existingUser = await LivechatVisitors.findOneGuestByEmailAddress(visitorEmail))) { - contactId = existingUser._id; - } else { - const userData = { - username, - ts: new Date(), - token, - }; - - contactId = (await LivechatVisitors.insertOne(userData)).insertedId; - } - } - - const allowedCF = await getAllowedCustomFields(); - const livechatData: Record = validateCustomFields(allowedCF, customFields, { ignoreAdditionalFields: true }); - - const fieldsToRemove = { - // if field is explicitely set to empty string, remove - ...(phone === '' && { phone: 1 }), - ...(visitorEmail === '' && { visitorEmails: 1 }), - ...(!contactManager?.username && { contactManager: 1 }), - }; - - const updateUser: { $set: MatchKeysAndValues; $unset?: OnlyFieldsOfType } = { - $set: { - token, - name, - livechatData, - // if phone has some value, set - ...(phone && { phone: [{ phoneNumber: phone }] }), - ...(visitorEmail && { visitorEmails: [{ address: visitorEmail }] }), - ...(contactManager?.username && { contactManager: { username: contactManager.username } }), - }, - ...(Object.keys(fieldsToRemove).length && { $unset: fieldsToRemove }), - }; - - await LivechatVisitors.updateOne({ _id: contactId }, updateUser); - - const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); - const rooms: IOmnichannelRoom[] = await LivechatRooms.findByVisitorId(contactId, {}, extraQuery).toArray(); - - if (rooms?.length) { - for await (const room of rooms) { - const { _id: rid } = room; - - const responses = await Promise.all([ - Rooms.setFnameById(rid, name), - LivechatInquiry.setNameByRoomId(rid, name), - Subscriptions.updateDisplayNameByRoomId(rid, name), - ]); - - if (responses[0]?.modifiedCount) { - void notifyOnRoomChangedById(rid); - } - - if (responses[1]?.modifiedCount) { - void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); - } - - if (responses[2]?.modifiedCount) { - void notifyOnSubscriptionChangedByRoomId(rid); - } - } - } - - return contactId; - }, - - async registerGuestData( - { name, phone, email, username }: Pick, - visitor: AtLeast, - ): Promise { - // If a visitor was updated who already had a contact, load up that contact and update that information as well - const contact = await LivechatContacts.findOneByVisitorId(visitor._id); - if (!contact) { - return; - } - - const validatedEmail = - email && - wrapExceptions(() => { - const trimmedEmail = email.trim().toLowerCase(); - validateEmail(trimmedEmail); - return trimmedEmail; - }).suppress(); - - const fields = [ - { type: 'name', value: name }, - { type: 'phone', value: phone?.number }, - { type: 'email', value: validatedEmail }, - { type: 'username', value: username || visitor.username }, - ].filter((field) => Boolean(field.value)) as FieldAndValue[]; - - if (!fields.length) { - return; - } - - await ContactMerger.mergeFieldsIntoContact(fields, contact, contact.unknown ? 'overwrite' : 'conflict'); - }, -}; - -export async function getContactManagerIdByUsername(username?: IUser['username']): Promise { - if (!username) { - return; - } - - const user = await Users.findOneByUsername>(username, { projection: { _id: 1 } }); - - return user?._id; -} - -export async function getContactIdByVisitorId(visitorId: ILivechatVisitor['_id']): Promise { - const contact = await LivechatContacts.findOneByVisitorId>(visitorId, { projection: { _id: 1 } }); - if (!contact) { - return null; - } - return contact._id; -} - -export async function migrateVisitorIfMissingContact( - visitorId: ILivechatVisitor['_id'], - source: IOmnichannelSource, -): Promise { - Livechat.logger.debug(`Detecting visitor's contact ID`); - // Check if there is any contact already linking to this visitorId - const contactId = await getContactIdByVisitorId(visitorId); - if (contactId) { - return contactId; - } - - const visitor = await LivechatVisitors.findOneById(visitorId); - if (!visitor) { - throw new Error('Failed to migrate visitor data into Contact information: visitor not found.'); - } - - return migrateVisitorToContactId(visitor, source); -} - -export async function findContactMatchingVisitor( - visitor: AtLeast, -): Promise { - // Search for any contact that is not yet associated with any visitor and that have the same email or phone number as this visitor. - return LivechatContacts.findContactMatchingVisitor(visitor); -} - -async function getVisitorNewestSource(visitor: ILivechatVisitor): Promise { - const room = await LivechatRooms.findNewestByVisitorIdOrToken>(visitor._id, visitor.token, { - projection: { source: 1 }, - }); - - if (!room) { - return null; - } - - return room.source; -} - -/** - This function assumes you already ensured that the visitor is not yet linked to any contact -**/ -export async function migrateVisitorToContactId( - visitor: ILivechatVisitor, - source?: IOmnichannelSource, - useVisitorId = false, -): Promise { - // If we haven't received any source and the visitor doesn't have any room yet, then there's no need to migrate it - const visitorSource = source || (await getVisitorNewestSource(visitor)); - if (!visitorSource) { - return null; - } - - const existingContact = await findContactMatchingVisitor(visitor); - if (!existingContact) { - Livechat.logger.debug(`Creating a new contact for existing visitor ${visitor._id}`); - return createContactFromVisitor(visitor, visitorSource, useVisitorId); - } - - // There is already an existing contact with no linked visitors and matching this visitor's phone or email, so let's use it - Livechat.logger.debug(`Adding channel to existing contact ${existingContact._id}`); - await ContactMerger.mergeVisitorIntoContact(visitor, existingContact); - - // Update all existing rooms of that visitor to add the contactId to them - await LivechatRooms.setContactIdByVisitorIdOrToken(existingContact._id, visitor._id, visitor.token); - - return existingContact._id; -} - -export async function getContact(contactId: ILivechatContact['_id']): Promise { - const contact = await LivechatContacts.findOneById(contactId); - if (contact) { - return contact; - } - - // If the contact was not found, search for a visitor with the same ID - const visitor = await LivechatVisitors.findOneById(contactId); - // If there's also no visitor with that ID, then there's nothing for us to get - if (!visitor) { - return null; - } - - // ContactId is actually the ID of a visitor, so let's get the contact that is linked to this visitor - const linkedContact = await LivechatContacts.findOneByVisitorId(contactId); - if (linkedContact) { - return linkedContact; - } - - // If this is the ID of a visitor and there is no contact linking to it yet, then migrate it into a contact - const newContactId = await migrateVisitorToContactId(visitor, undefined, true); - // If no contact was created by the migration, this visitor doesn't need a contact yet, so let's return null - if (!newContactId) { - return null; - } - - // Finally, let's return the data of the migrated contact - return LivechatContacts.findOneById(newContactId); -} - -export async function mapVisitorToContact(visitor: ILivechatVisitor, source: IOmnichannelSource): Promise { - return { - name: visitor.name || visitor.username, - emails: visitor.visitorEmails?.map(({ address }) => address), - phones: visitor.phone?.map(({ phoneNumber }) => phoneNumber), - unknown: true, - channels: [ - { - name: source.label || source.type.toString(), - visitorId: visitor._id, - blocked: false, - verified: false, - details: source, - }, - ], - customFields: visitor.livechatData, - contactManager: await getContactManagerIdByUsername(visitor.contactManager?.username), - }; -} - -export async function createContactFromVisitor( - visitor: ILivechatVisitor, - source: IOmnichannelSource, - useVisitorId = false, -): Promise { - const contactData = await mapVisitorToContact(visitor, source); - - const contactId = await createContact(contactData, useVisitorId ? visitor._id : undefined); - - await LivechatRooms.setContactIdByVisitorIdOrToken(contactId, visitor._id, visitor.token); - - return contactId; -} - -export async function createContact(params: CreateContactParams, upsertId?: ILivechatContact['_id']): Promise { - const { name, emails, phones, customFields: receivedCustomFields = {}, contactManager, channels, unknown, importIds } = params; - - if (contactManager) { - await validateContactManager(contactManager); - } - - const allowedCustomFields = await getAllowedCustomFields(); - const customFields = validateCustomFields(allowedCustomFields, receivedCustomFields); - - const updateData = { - name, - emails: emails?.map((address) => ({ address })), - phones: phones?.map((phoneNumber) => ({ phoneNumber })), - contactManager, - channels, - customFields, - unknown, - ...(importIds?.length ? { importIds } : {}), - } as const; - - // Use upsert when doing auto-migration so that if there's multiple requests processing at the same time, they won't interfere with each other - if (upsertId) { - await LivechatContacts.upsertContact(upsertId, updateData); - return upsertId; - } - - return LivechatContacts.insertContact(updateData); -} - -export async function updateContact(params: UpdateContactParams): Promise { - const { contactId, name, emails, phones, customFields: receivedCustomFields, contactManager, channels, wipeConflicts } = params; - - const contact = await LivechatContacts.findOneById>(contactId, { projection: { _id: 1 } }); - - if (!contact) { - throw new Error('error-contact-not-found'); - } - - if (contactManager) { - await validateContactManager(contactManager); - } - - const customFields = receivedCustomFields && validateCustomFields(await getAllowedCustomFields(), receivedCustomFields); - - const updatedContact = await LivechatContacts.updateContact(contactId, { - name, - emails: emails?.map((address) => ({ address })), - phones: phones?.map((phoneNumber) => ({ phoneNumber })), - contactManager, - channels, - customFields, - ...(wipeConflicts && { conflictingFields: [], hasConflict: false }), - }); - - return updatedContact; -} - -/** - * Adds a new email into the contact's email list, if the email is already in the list it does not add anything - * and simply return the data, since the email was aready registered :P - * - * @param contactId the id of the contact that will be updated - * @param email the email that will be added to the contact - * @returns the updated contact - */ -export async function addContactEmail(contactId: ILivechatContact['_id'], email: string): Promise { - const contact = await LivechatContacts.findOneById(contactId); - if (!contact) { - throw new Error('error-contact-not-found'); - } - - const emails = contact.emails?.map(({ address }) => address) || []; - if (!emails.includes(email)) { - return LivechatContacts.updateContact(contactId, { - emails: [...emails.map((e) => ({ address: e })), { address: email }], - }); - } - - return contact; -} - -export async function getContacts(params: GetContactsParams): Promise> { - const { searchText, count, offset, sort } = params; - - const { cursor, totalCount } = LivechatContacts.findPaginatedContacts(searchText, { - limit: count, - skip: offset, - sort: sort ?? { name: 1 }, - }); - - const [contacts, total] = await Promise.all([cursor.toArray(), totalCount]); - - return { - contacts, - count, - offset, - total, - }; -} - -export async function getContactHistory( - params: GetContactHistoryParams, -): Promise> { - const { contactId, source, count, offset, sort } = params; - - const contact = await LivechatContacts.findOneById>(contactId, { projection: { channels: 1 } }); - - if (!contact) { - throw new Error('error-contact-not-found'); - } - - const visitorsIds = new Set(contact.channels?.map((channel: ILivechatContactChannel) => channel.visitorId)); - - if (!visitorsIds?.size) { - return { history: [], count: 0, offset, total: 0 }; - } - - const options: FindOptions = { - sort: sort || { ts: -1 }, - skip: offset, - limit: count, - projection: { - fname: 1, - ts: 1, - v: 1, - msgs: 1, - servedBy: 1, - closedAt: 1, - closedBy: 1, - closer: 1, - tags: 1, - source: 1, - lastMessage: 1, - verified: 1, - }, - }; - - const { totalCount, cursor } = LivechatRooms.findPaginatedRoomsByVisitorsIdsAndSource({ - visitorsIds: Array.from(visitorsIds), - source, - options, - }); - - const [total, history] = await Promise.all([totalCount, cursor.toArray()]); - - return { - history, - count: history.length, - offset, - total, - }; -} - -export async function getAllowedCustomFields(): Promise[]> { - return LivechatCustomField.findByScope( - 'visitor', - { - projection: { _id: 1, label: 1, regexp: 1, required: 1 }, - }, - false, - ).toArray(); -} - -export function validateCustomFields( - allowedCustomFields: AtLeast[], - customFields: Record, - options?: { ignoreAdditionalFields?: boolean }, -): Record { - const validValues: Record = {}; - - for (const cf of allowedCustomFields) { - if (!customFields.hasOwnProperty(cf._id)) { - if (cf.required) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - continue; - } - const cfValue: string = trim(customFields[cf._id]); - - if (!cfValue || typeof cfValue !== 'string') { - if (cf.required) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - continue; - } - - if (cf.regexp) { - const regex = new RegExp(cf.regexp); - if (!regex.test(cfValue)) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - } - - validValues[cf._id] = cfValue; - } - - if (!options?.ignoreAdditionalFields) { - const allowedCustomFieldIds = new Set(allowedCustomFields.map((cf) => cf._id)); - for (const key in customFields) { - if (!allowedCustomFieldIds.has(key)) { - throw new Error(i18n.t('error-custom-field-not-allowed', { key })); - } - } - } - - return validValues; -} - -export async function validateContactManager(contactManagerUserId: string) { - const contactManagerUser = await Users.findOneAgentById>(contactManagerUserId, { projection: { _id: 1 } }); - if (!contactManagerUser) { - throw new Error('error-contact-manager-not-found'); - } -} - -export const verifyContactChannel = makeFunction(async (_params: VerifyContactChannelParams): Promise => null); - -export const mergeContacts = makeFunction(async (_contactId: string, _visitorId: string): Promise => null); - -export const shouldTriggerVerificationApp = makeFunction( - async (_contactId: ILivechatContact['_id'], _source: IOmnichannelSource): Promise => false, -); diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 9cf393613801..672665e0aec1 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -45,10 +45,11 @@ import { notifyOnSubscriptionChanged, } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; -import { getContactIdByVisitorId, migrateVisitorIfMissingContact } from './Contacts'; import { Livechat as LivechatTyped } from './LivechatTyped'; import { queueInquiry, saveQueueInquiry } from './QueueManager'; import { RoutingManager } from './RoutingManager'; +import { getContactIdByVisitorId } from './contacts/getContactIdByVisitorId'; +import { migrateVisitorIfMissingContact } from './contacts/migrateVisitorIfMissingContact'; const logger = new Logger('LivechatHelper'); export const allowAgentSkipQueue = (agent: SelectedAgent) => { diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index e8ad55a8de13..c824a8fb6ed6 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -73,11 +73,11 @@ import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; -import { Contacts } from './Contacts'; import { parseAgentCustomFields, updateDepartmentAgents, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; import { Visitors } from './Visitors'; +import { registerGuestData } from './contacts/registerGuestData'; import { isDepartmentCreationAvailable } from './isDepartmentCreationAvailable'; import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes'; import { parseTranscriptRequest } from './parseTranscriptRequest'; @@ -608,7 +608,7 @@ class LivechatClass { const result = await Visitors.registerGuest(newData); if (result) { - await Contacts.registerGuestData(newData, result); + await registerGuestData(newData, result); } return result; diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index 3814f20927a6..acb85c7c1407 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -25,10 +25,10 @@ import { } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; import { i18n } from '../../../utils/lib/i18n'; -import { shouldTriggerVerificationApp } from './Contacts'; import { createLivechatRoom, createLivechatInquiry, allowAgentSkipQueue } from './Helper'; import { Livechat } from './LivechatTyped'; import { RoutingManager } from './RoutingManager'; +import { shouldTriggerVerificationApp } from './contacts/shouldTriggerVerificationApp'; import { getInquirySortMechanismSetting } from './settings'; const logger = new Logger('QueueManager'); diff --git a/apps/meteor/app/livechat/server/lib/ContactMerger.ts b/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts similarity index 99% rename from apps/meteor/app/livechat/server/lib/ContactMerger.ts rename to apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts index d944a013871e..ca2275eb0060 100644 --- a/apps/meteor/app/livechat/server/lib/ContactMerger.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts @@ -10,7 +10,7 @@ import type { import { LivechatContacts } from '@rocket.chat/models'; import type { UpdateFilter } from 'mongodb'; -import { getContactManagerIdByUsername } from './Contacts'; +import { getContactManagerIdByUsername } from './getContactManagerIdByUsername'; type ManagerValue = { id: string } | { username: string }; type ContactFields = { diff --git a/apps/meteor/app/livechat/server/lib/contacts/addContactEmail.ts b/apps/meteor/app/livechat/server/lib/contacts/addContactEmail.ts new file mode 100644 index 000000000000..8ce4f38577e0 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/addContactEmail.ts @@ -0,0 +1,26 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +/** + * Adds a new email into the contact's email list, if the email is already in the list it does not add anything + * and simply return the data, since the email was aready registered :P + * + * @param contactId the id of the contact that will be updated + * @param email the email that will be added to the contact + * @returns the updated contact + */ +export async function addContactEmail(contactId: ILivechatContact['_id'], email: string): Promise { + const contact = await LivechatContacts.findOneById(contactId); + if (!contact) { + throw new Error('error-contact-not-found'); + } + + const emails = contact.emails?.map(({ address }) => address) || []; + if (!emails.includes(email)) { + return LivechatContacts.updateContact(contactId, { + emails: [...emails.map((e) => ({ address: e })), { address: email }], + }); + } + + return contact; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/createContact.ts b/apps/meteor/app/livechat/server/lib/contacts/createContact.ts new file mode 100644 index 000000000000..7e0009ffd6ed --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/createContact.ts @@ -0,0 +1,47 @@ +import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { validateContactManager } from './validateContactManager'; +import { validateCustomFields } from './validateCustomFields'; + +export type CreateContactParams = { + name: string; + emails?: string[]; + phones?: string[]; + unknown: boolean; + customFields?: Record; + contactManager?: string; + channels?: ILivechatContactChannel[]; + importIds?: string[]; +}; + +export async function createContact(params: CreateContactParams, upsertId?: ILivechatContact['_id']): Promise { + const { name, emails, phones, customFields: receivedCustomFields = {}, contactManager, channels, unknown, importIds } = params; + + if (contactManager) { + await validateContactManager(contactManager); + } + + const allowedCustomFields = await getAllowedCustomFields(); + const customFields = validateCustomFields(allowedCustomFields, receivedCustomFields); + + const updateData = { + name, + emails: emails?.map((address) => ({ address })), + phones: phones?.map((phoneNumber) => ({ phoneNumber })), + contactManager, + channels, + customFields, + unknown, + ...(importIds?.length ? { importIds } : {}), + } as const; + + // Use upsert when doing auto-migration so that if there's multiple requests processing at the same time, they won't interfere with each other + if (upsertId) { + await LivechatContacts.upsertContact(upsertId, updateData); + return upsertId; + } + + return LivechatContacts.insertContact(updateData); +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/createContactFromVisitor.ts b/apps/meteor/app/livechat/server/lib/contacts/createContactFromVisitor.ts new file mode 100644 index 000000000000..1516f839cb86 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/createContactFromVisitor.ts @@ -0,0 +1,19 @@ +import type { ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; + +import { createContact } from './createContact'; +import { mapVisitorToContact } from './mapVisitorToContact'; + +export async function createContactFromVisitor( + visitor: ILivechatVisitor, + source: IOmnichannelSource, + useVisitorId = false, +): Promise { + const contactData = await mapVisitorToContact(visitor, source); + + const contactId = await createContact(contactData, useVisitorId ? visitor._id : undefined); + + await LivechatRooms.setContactIdByVisitorIdOrToken(contactId, visitor._id, visitor.token); + + return contactId; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getAllowedCustomFields.ts b/apps/meteor/app/livechat/server/lib/contacts/getAllowedCustomFields.ts new file mode 100644 index 000000000000..d71f902c1122 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getAllowedCustomFields.ts @@ -0,0 +1,12 @@ +import type { ILivechatCustomField } from '@rocket.chat/core-typings'; +import { LivechatCustomField } from '@rocket.chat/models'; + +export async function getAllowedCustomFields(): Promise[]> { + return LivechatCustomField.findByScope( + 'visitor', + { + projection: { _id: 1, label: 1, regexp: 1, required: 1 }, + }, + false, + ).toArray(); +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getContact.ts b/apps/meteor/app/livechat/server/lib/contacts/getContact.ts new file mode 100644 index 000000000000..33f0c8366e37 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getContact.ts @@ -0,0 +1,34 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatVisitors } from '@rocket.chat/models'; + +import { migrateVisitorToContactId } from './migrateVisitorToContactId'; + +export async function getContact(contactId: ILivechatContact['_id']): Promise { + const contact = await LivechatContacts.findOneById(contactId); + if (contact) { + return contact; + } + + // If the contact was not found, search for a visitor with the same ID + const visitor = await LivechatVisitors.findOneById(contactId); + // If there's also no visitor with that ID, then there's nothing for us to get + if (!visitor) { + return null; + } + + // ContactId is actually the ID of a visitor, so let's get the contact that is linked to this visitor + const linkedContact = await LivechatContacts.findOneByVisitorId(contactId); + if (linkedContact) { + return linkedContact; + } + + // If this is the ID of a visitor and there is no contact linking to it yet, then migrate it into a contact + const newContactId = await migrateVisitorToContactId(visitor, undefined, true); + // If no contact was created by the migration, this visitor doesn't need a contact yet, so let's return null + if (!newContactId) { + return null; + } + + // Finally, let's return the data of the migrated contact + return LivechatContacts.findOneById(newContactId); +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getContactHistory.ts b/apps/meteor/app/livechat/server/lib/contacts/getContactHistory.ts new file mode 100644 index 000000000000..1f378df9d430 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getContactHistory.ts @@ -0,0 +1,65 @@ +import type { ILivechatContact, ILivechatContactChannel, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; +import type { PaginatedResult, VisitorSearchChatsResult } from '@rocket.chat/rest-typings'; +import type { FindOptions, Sort } from 'mongodb'; + +export type GetContactHistoryParams = { + contactId: string; + source?: string; + count: number; + offset: number; + sort: Sort; +}; + +export async function getContactHistory( + params: GetContactHistoryParams, +): Promise> { + const { contactId, source, count, offset, sort } = params; + + const contact = await LivechatContacts.findOneById>(contactId, { projection: { channels: 1 } }); + + if (!contact) { + throw new Error('error-contact-not-found'); + } + + const visitorsIds = new Set(contact.channels?.map((channel: ILivechatContactChannel) => channel.visitorId)); + + if (!visitorsIds?.size) { + return { history: [], count: 0, offset, total: 0 }; + } + + const options: FindOptions = { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: { + fname: 1, + ts: 1, + v: 1, + msgs: 1, + servedBy: 1, + closedAt: 1, + closedBy: 1, + closer: 1, + tags: 1, + source: 1, + lastMessage: 1, + verified: 1, + }, + }; + + const { totalCount, cursor } = LivechatRooms.findPaginatedRoomsByVisitorsIdsAndSource({ + visitorsIds: Array.from(visitorsIds), + source, + options, + }); + + const [total, history] = await Promise.all([totalCount, cursor.toArray()]); + + return { + history, + count: history.length, + offset, + total, + }; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getContactIdByVisitorId.ts b/apps/meteor/app/livechat/server/lib/contacts/getContactIdByVisitorId.ts new file mode 100644 index 000000000000..e4cffaff645e --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getContactIdByVisitorId.ts @@ -0,0 +1,10 @@ +import type { ILivechatContact, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +export async function getContactIdByVisitorId(visitorId: ILivechatVisitor['_id']): Promise { + const contact = await LivechatContacts.findOneByVisitorId>(visitorId, { projection: { _id: 1 } }); + if (!contact) { + return null; + } + return contact._id; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getContactManagerIdByUsername.ts b/apps/meteor/app/livechat/server/lib/contacts/getContactManagerIdByUsername.ts new file mode 100644 index 000000000000..57c845ec570f --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getContactManagerIdByUsername.ts @@ -0,0 +1,12 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +export async function getContactManagerIdByUsername(username?: IUser['username']): Promise { + if (!username) { + return; + } + + const user = await Users.findOneByUsername>(username, { projection: { _id: 1 } }); + + return user?._id; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getContacts.ts b/apps/meteor/app/livechat/server/lib/contacts/getContacts.ts new file mode 100644 index 000000000000..b12bd6734b31 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getContacts.ts @@ -0,0 +1,30 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; +import type { Sort } from 'mongodb'; + +export type GetContactsParams = { + searchText?: string; + count: number; + offset: number; + sort: Sort; +}; + +export async function getContacts(params: GetContactsParams): Promise> { + const { searchText, count, offset, sort } = params; + + const { cursor, totalCount } = LivechatContacts.findPaginatedContacts(searchText, { + limit: count, + skip: offset, + sort: sort ?? { name: 1 }, + }); + + const [contacts, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + contacts, + count, + offset, + total, + }; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getVisitorNewestSource.ts b/apps/meteor/app/livechat/server/lib/contacts/getVisitorNewestSource.ts new file mode 100644 index 000000000000..fab2525df729 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getVisitorNewestSource.ts @@ -0,0 +1,14 @@ +import type { ILivechatVisitor, IOmnichannelSource, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; + +export async function getVisitorNewestSource(visitor: ILivechatVisitor): Promise { + const room = await LivechatRooms.findNewestByVisitorIdOrToken>(visitor._id, visitor.token, { + projection: { source: 1 }, + }); + + if (!room) { + return null; + } + + return room.source; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.ts b/apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.ts new file mode 100644 index 000000000000..13552d55c34d --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.ts @@ -0,0 +1,24 @@ +import type { ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings'; + +import type { CreateContactParams } from './createContact'; +import { getContactManagerIdByUsername } from './getContactManagerIdByUsername'; + +export async function mapVisitorToContact(visitor: ILivechatVisitor, source: IOmnichannelSource): Promise { + return { + name: visitor.name || visitor.username, + emails: visitor.visitorEmails?.map(({ address }) => address), + phones: visitor.phone?.map(({ phoneNumber }) => phoneNumber), + unknown: true, + channels: [ + { + name: source.label || source.type.toString(), + visitorId: visitor._id, + blocked: false, + verified: false, + details: source, + }, + ], + customFields: visitor.livechatData, + contactManager: await getContactManagerIdByUsername(visitor.contactManager?.username), + }; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/mergeContacts.ts b/apps/meteor/app/livechat/server/lib/contacts/mergeContacts.ts new file mode 100644 index 000000000000..c327e6a38c87 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/mergeContacts.ts @@ -0,0 +1,4 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const mergeContacts = makeFunction(async (_contactId: string, _visitorId: string): Promise => null); diff --git a/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorIfMissingContact.ts b/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorIfMissingContact.ts new file mode 100644 index 000000000000..2264fd96c622 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorIfMissingContact.ts @@ -0,0 +1,25 @@ +import type { ILivechatVisitor, IOmnichannelSource, ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; + +import { Livechat } from '../LivechatTyped'; +import { getContactIdByVisitorId } from './getContactIdByVisitorId'; +import { migrateVisitorToContactId } from './migrateVisitorToContactId'; + +export async function migrateVisitorIfMissingContact( + visitorId: ILivechatVisitor['_id'], + source: IOmnichannelSource, +): Promise { + Livechat.logger.debug(`Detecting visitor's contact ID`); + // Check if there is any contact already linking to this visitorId + const contactId = await getContactIdByVisitorId(visitorId); + if (contactId) { + return contactId; + } + + const visitor = await LivechatVisitors.findOneById(visitorId); + if (!visitor) { + throw new Error('Failed to migrate visitor data into Contact information: visitor not found.'); + } + + return migrateVisitorToContactId(visitor, source); +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorToContactId.ts b/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorToContactId.ts new file mode 100644 index 000000000000..8c8e9cb27585 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorToContactId.ts @@ -0,0 +1,38 @@ +import type { ILivechatVisitor, IOmnichannelSource, ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; + +import { Livechat } from '../LivechatTyped'; +import { ContactMerger } from './ContactMerger'; +import { createContactFromVisitor } from './createContactFromVisitor'; +import { getVisitorNewestSource } from './getVisitorNewestSource'; + +/** + This function assumes you already ensured that the visitor is not yet linked to any contact +**/ +export async function migrateVisitorToContactId( + visitor: ILivechatVisitor, + source?: IOmnichannelSource, + useVisitorId = false, +): Promise { + // If we haven't received any source and the visitor doesn't have any room yet, then there's no need to migrate it + const visitorSource = source || (await getVisitorNewestSource(visitor)); + if (!visitorSource) { + return null; + } + + // Search for any contact that is not yet associated with any visitor and that have the same email or phone number as this visitor. + const existingContact = await LivechatContacts.findContactMatchingVisitor(visitor); + if (!existingContact) { + Livechat.logger.debug(`Creating a new contact for existing visitor ${visitor._id}`); + return createContactFromVisitor(visitor, visitorSource, useVisitorId); + } + + // There is already an existing contact with no linked visitors and matching this visitor's phone or email, so let's use it + Livechat.logger.debug(`Adding channel to existing contact ${existingContact._id}`); + await ContactMerger.mergeVisitorIntoContact(visitor, existingContact); + + // Update all existing rooms of that visitor to add the contactId to them + await LivechatRooms.setContactIdByVisitorIdOrToken(existingContact._id, visitor._id, visitor.token); + + return existingContact._id; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/registerContact.ts b/apps/meteor/app/livechat/server/lib/contacts/registerContact.ts new file mode 100644 index 000000000000..22425e4714c2 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/registerContact.ts @@ -0,0 +1,133 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatVisitors, Users, LivechatRooms, LivechatInquiry, Rooms, Subscriptions } from '@rocket.chat/models'; +import type { MatchKeysAndValues, OnlyFieldsOfType } from 'mongodb'; + +import { callbacks } from '../../../../../lib/callbacks'; +import { + notifyOnRoomChangedById, + notifyOnSubscriptionChangedByRoomId, + notifyOnLivechatInquiryChangedByRoom, +} from '../../../../lib/server/lib/notifyListener'; +import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { validateCustomFields } from './validateCustomFields'; + +type RegisterContactProps = { + _id?: string; + token: string; + name: string; + username?: string; + email?: string; + phone?: string; + customFields?: Record; + contactManager?: { + username: string; + }; +}; + +export async function registerContact({ + token, + name, + email = '', + phone, + username, + customFields = {}, + contactManager, +}: RegisterContactProps): Promise { + if (!token || typeof token !== 'string') { + throw new MeteorError('error-invalid-contact-data', 'Invalid visitor token'); + } + + const visitorEmail = email.trim().toLowerCase(); + + if (contactManager?.username) { + // verify if the user exists with this username and has a livechat-agent role + const manager = await Users.findOneByUsername(contactManager.username, { projection: { roles: 1 } }); + if (!manager) { + throw new MeteorError('error-contact-manager-not-found', `No user found with username ${contactManager.username}`); + } + if (!manager.roles || !Array.isArray(manager.roles) || !manager.roles.includes('livechat-agent')) { + throw new MeteorError('error-invalid-contact-manager', 'The contact manager must have the role "livechat-agent"'); + } + } + + let visitorId; + + const user = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); + + if (user) { + visitorId = user._id; + } else { + if (!username) { + username = await LivechatVisitors.getNextVisitorUsername(); + } + + let existingUser = null; + + if (visitorEmail !== '' && (existingUser = await LivechatVisitors.findOneGuestByEmailAddress(visitorEmail))) { + visitorId = existingUser._id; + } else { + const userData = { + username, + ts: new Date(), + token, + }; + + visitorId = (await LivechatVisitors.insertOne(userData)).insertedId; + } + } + + const allowedCF = await getAllowedCustomFields(); + const livechatData: Record = validateCustomFields(allowedCF, customFields, { ignoreAdditionalFields: true }); + + const fieldsToRemove = { + // if field is explicitely set to empty string, remove + ...(phone === '' && { phone: 1 }), + ...(visitorEmail === '' && { visitorEmails: 1 }), + ...(!contactManager?.username && { contactManager: 1 }), + }; + + const updateUser: { $set: MatchKeysAndValues; $unset?: OnlyFieldsOfType } = { + $set: { + token, + name, + livechatData, + // if phone has some value, set + ...(phone && { phone: [{ phoneNumber: phone }] }), + ...(visitorEmail && { visitorEmails: [{ address: visitorEmail }] }), + ...(contactManager?.username && { contactManager: { username: contactManager.username } }), + }, + ...(Object.keys(fieldsToRemove).length && { $unset: fieldsToRemove }), + }; + + await LivechatVisitors.updateOne({ _id: visitorId }, updateUser); + + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); + const rooms: IOmnichannelRoom[] = await LivechatRooms.findByVisitorId(visitorId, {}, extraQuery).toArray(); + + if (rooms?.length) { + for await (const room of rooms) { + const { _id: rid } = room; + + const responses = await Promise.all([ + Rooms.setFnameById(rid, name), + LivechatInquiry.setNameByRoomId(rid, name), + Subscriptions.updateDisplayNameByRoomId(rid, name), + ]); + + if (responses[0]?.modifiedCount) { + void notifyOnRoomChangedById(rid); + } + + if (responses[1]?.modifiedCount) { + void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); + } + + if (responses[2]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + } + } + + return visitorId; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/registerGuestData.ts b/apps/meteor/app/livechat/server/lib/contacts/registerGuestData.ts new file mode 100644 index 000000000000..2186c976671f --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/registerGuestData.ts @@ -0,0 +1,39 @@ +import type { AtLeast, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; +import { wrapExceptions } from '@rocket.chat/tools'; + +import { validateEmail } from '../Helper'; +import type { RegisterGuestType } from '../LivechatTyped'; +import { ContactMerger, type FieldAndValue } from './ContactMerger'; + +export async function registerGuestData( + { name, phone, email, username }: Pick, + visitor: AtLeast, +): Promise { + // If a visitor was updated who already had a contact, load up that contact and update that information as well + const contact = await LivechatContacts.findOneByVisitorId(visitor._id); + if (!contact) { + return; + } + + const validatedEmail = + email && + wrapExceptions(() => { + const trimmedEmail = email.trim().toLowerCase(); + validateEmail(trimmedEmail); + return trimmedEmail; + }).suppress(); + + const fields = [ + { type: 'name', value: name }, + { type: 'phone', value: phone?.number }, + { type: 'email', value: validatedEmail }, + { type: 'username', value: username || visitor.username }, + ].filter((field) => Boolean(field.value)) as FieldAndValue[]; + + if (!fields.length) { + return; + } + + await ContactMerger.mergeFieldsIntoContact(fields, contact, contact.unknown ? 'overwrite' : 'conflict'); +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/shouldTriggerVerificationApp.ts b/apps/meteor/app/livechat/server/lib/contacts/shouldTriggerVerificationApp.ts new file mode 100644 index 000000000000..bfb9f242ef37 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/shouldTriggerVerificationApp.ts @@ -0,0 +1,6 @@ +import type { ILivechatContact, IOmnichannelSource } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const shouldTriggerVerificationApp = makeFunction( + async (_contactId: ILivechatContact['_id'], _source: IOmnichannelSource): Promise => false, +); diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts new file mode 100644 index 000000000000..6582ec1bd075 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts @@ -0,0 +1,45 @@ +import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { validateContactManager } from './validateContactManager'; +import { validateCustomFields } from './validateCustomFields'; + +export type UpdateContactParams = { + contactId: string; + name?: string; + emails?: string[]; + phones?: string[]; + customFields?: Record; + contactManager?: string; + channels?: ILivechatContactChannel[]; + wipeConflicts?: boolean; +}; + +export async function updateContact(params: UpdateContactParams): Promise { + const { contactId, name, emails, phones, customFields: receivedCustomFields, contactManager, channels, wipeConflicts } = params; + + const contact = await LivechatContacts.findOneById>(contactId, { projection: { _id: 1 } }); + + if (!contact) { + throw new Error('error-contact-not-found'); + } + + if (contactManager) { + await validateContactManager(contactManager); + } + + const customFields = receivedCustomFields && validateCustomFields(await getAllowedCustomFields(), receivedCustomFields); + + const updatedContact = await LivechatContacts.updateContact(contactId, { + name, + emails: emails?.map((address) => ({ address })), + phones: phones?.map((phoneNumber) => ({ phoneNumber })), + contactManager, + channels, + customFields, + ...(wipeConflicts && { conflictingFields: [], hasConflict: false }), + }); + + return updatedContact; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/validateContactManager.ts b/apps/meteor/app/livechat/server/lib/contacts/validateContactManager.ts new file mode 100644 index 000000000000..cea2c0fe0c37 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/validateContactManager.ts @@ -0,0 +1,9 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +export async function validateContactManager(contactManagerUserId: string) { + const contactManagerUser = await Users.findOneAgentById>(contactManagerUserId, { projection: { _id: 1 } }); + if (!contactManagerUser) { + throw new Error('error-contact-manager-not-found'); + } +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.ts b/apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.ts new file mode 100644 index 000000000000..e389d1b34ac9 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.ts @@ -0,0 +1,49 @@ +import type { AtLeast, ILivechatCustomField } from '@rocket.chat/core-typings'; + +import { trim } from '../../../../../lib/utils/stringUtils'; +import { i18n } from '../../../../utils/lib/i18n'; + +export function validateCustomFields( + allowedCustomFields: AtLeast[], + customFields: Record, + options?: { ignoreAdditionalFields?: boolean }, +): Record { + const validValues: Record = {}; + + for (const cf of allowedCustomFields) { + if (!customFields.hasOwnProperty(cf._id)) { + if (cf.required) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + continue; + } + const cfValue: string = trim(customFields[cf._id]); + + if (!cfValue || typeof cfValue !== 'string') { + if (cf.required) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + continue; + } + + if (cf.regexp) { + const regex = new RegExp(cf.regexp); + if (!regex.test(cfValue)) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + } + + validValues[cf._id] = cfValue; + } + + if (!options?.ignoreAdditionalFields) { + const allowedCustomFieldIds = new Set(allowedCustomFields.map((cf) => cf._id)); + for (const key in customFields) { + if (!allowedCustomFieldIds.has(key)) { + throw new Error(i18n.t('error-custom-field-not-allowed', { key })); + } + } + } + + return validValues; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/verifyContactChannel.ts b/apps/meteor/app/livechat/server/lib/contacts/verifyContactChannel.ts new file mode 100644 index 000000000000..77bc1e4653d2 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/verifyContactChannel.ts @@ -0,0 +1,12 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export type VerifyContactChannelParams = { + contactId: string; + field: string; + value: string; + visitorId: string; + roomId: string; +}; + +export const verifyContactChannel = makeFunction(async (_params: VerifyContactChannelParams): Promise => null); diff --git a/apps/meteor/app/livechat/server/methods/takeInquiry.ts b/apps/meteor/app/livechat/server/methods/takeInquiry.ts index 98dab4e1ed6b..6b312557c351 100644 --- a/apps/meteor/app/livechat/server/methods/takeInquiry.ts +++ b/apps/meteor/app/livechat/server/methods/takeInquiry.ts @@ -5,8 +5,9 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { settings } from '../../../settings/server'; -import { shouldTriggerVerificationApp, migrateVisitorIfMissingContact } from '../lib/Contacts'; import { RoutingManager } from '../lib/RoutingManager'; +import { migrateVisitorIfMissingContact } from '../lib/contacts/migrateVisitorIfMissingContact'; +import { shouldTriggerVerificationApp } from '../lib/contacts/shouldTriggerVerificationApp'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/ee/server/patches/mergeContacts.ts b/apps/meteor/ee/server/patches/mergeContacts.ts index 367e568eaf8e..433bf2d0723c 100644 --- a/apps/meteor/ee/server/patches/mergeContacts.ts +++ b/apps/meteor/ee/server/patches/mergeContacts.ts @@ -2,8 +2,8 @@ import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/cor import { License } from '@rocket.chat/license'; import { LivechatContacts } from '@rocket.chat/models'; -import { ContactMerger } from '../../../app/livechat/server/lib/ContactMerger'; -import { mergeContacts } from '../../../app/livechat/server/lib/Contacts'; +import { ContactMerger } from '../../../app/livechat/server/lib/contacts/ContactMerger'; +import { mergeContacts } from '../../../app/livechat/server/lib/contacts/mergeContacts'; import { logger } from '../../app/livechat-enterprise/server/lib/logger'; export const runMergeContacts = async (_next: any, contactId: string, visitorId: string): Promise => { diff --git a/apps/meteor/ee/server/patches/shouldTriggerVerificationApp.ts b/apps/meteor/ee/server/patches/shouldTriggerVerificationApp.ts index 58d7b9994338..035206baca15 100644 --- a/apps/meteor/ee/server/patches/shouldTriggerVerificationApp.ts +++ b/apps/meteor/ee/server/patches/shouldTriggerVerificationApp.ts @@ -2,7 +2,7 @@ import type { IOmnichannelSource, ILivechatContact } from '@rocket.chat/core-typ import { License } from '@rocket.chat/license'; import { LivechatContacts } from '@rocket.chat/models'; -import { shouldTriggerVerificationApp } from '../../../app/livechat/server/lib/Contacts'; +import { shouldTriggerVerificationApp } from '../../../app/livechat/server/lib/contacts/shouldTriggerVerificationApp'; import { settings } from '../../../app/settings/server'; const runShouldTriggerVerificationApp = async ( diff --git a/apps/meteor/ee/server/patches/verifyContactChannel.ts b/apps/meteor/ee/server/patches/verifyContactChannel.ts index e70330632520..2447bf6251b4 100644 --- a/apps/meteor/ee/server/patches/verifyContactChannel.ts +++ b/apps/meteor/ee/server/patches/verifyContactChannel.ts @@ -2,8 +2,9 @@ import type { ILivechatContact } from '@rocket.chat/core-typings'; import { License } from '@rocket.chat/license'; import { LivechatContacts, LivechatInquiry, LivechatRooms } from '@rocket.chat/models'; -import { verifyContactChannel, mergeContacts } from '../../../app/livechat/server/lib/Contacts'; import { saveQueueInquiry } from '../../../app/livechat/server/lib/QueueManager'; +import { mergeContacts } from '../../../app/livechat/server/lib/contacts/mergeContacts'; +import { verifyContactChannel } from '../../../app/livechat/server/lib/contacts/verifyContactChannel'; export const runVerifyContactChannel = async ( _next: any, diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts index aee20cd6c340..2eaa70d33303 100644 --- a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts @@ -16,8 +16,8 @@ const contactMergerStub = { }; const { runMergeContacts } = proxyquire.noCallThru().load('../../../../../../server/patches/mergeContacts', { - '../../../app/livechat/server/lib/Contacts': { mergeContacts: { patch: sinon.stub() } }, - '../../../app/livechat/server/lib/ContactMerger': { ContactMerger: contactMergerStub }, + '../../../app/livechat/server/lib/contacts/mergeContacts': { mergeContacts: { patch: sinon.stub() } }, + '../../../app/livechat/server/lib/contacts/ContactMerger': { ContactMerger: contactMergerStub }, '../../app/livechat-enterprise/server/lib/logger': { logger: { info: sinon.stub() } }, '@rocket.chat/models': modelsMock, }); diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/verifyContactChannel.spec.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/verifyContactChannel.spec.ts index 58a01f265405..1ce8e32809e0 100644 --- a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/verifyContactChannel.spec.ts +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/verifyContactChannel.spec.ts @@ -19,7 +19,8 @@ const mergeContactsStub = sinon.stub(); const saveQueueInquiryStub = sinon.stub(); const { runVerifyContactChannel } = proxyquire.noCallThru().load('../../../../../../server/patches/verifyContactChannel', { - '../../../app/livechat/server/lib/Contacts': { mergeContacts: mergeContactsStub, verifyContactChannel: { patch: sinon.stub() } }, + '../../../app/livechat/server/lib/contacts/mergeContacts': { mergeContacts: mergeContactsStub }, + '../../../app/livechat/server/lib/contacts/verifyContactChannel': { verifyContactChannel: { patch: sinon.stub() } }, '../../../app/livechat/server/lib/QueueManager': { saveQueueInquiry: saveQueueInquiryStub }, '@rocket.chat/models': modelsMock, }); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts deleted file mode 100644 index f485f59850b0..000000000000 --- a/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { ILivechatContact } from '@rocket.chat/core-typings'; -import { expect } from 'chai'; -import proxyquire from 'proxyquire'; -import sinon from 'sinon'; - -const modelsMock = { - Users: { - findOneAgentById: sinon.stub(), - findOneByUsername: sinon.stub(), - }, - LivechatContacts: { - findOneById: sinon.stub(), - insertOne: sinon.stub(), - upsertContact: sinon.stub(), - updateContact: sinon.stub(), - findContactMatchingVisitor: sinon.stub(), - findOneByVisitorId: sinon.stub(), - }, - LivechatRooms: { - findNewestByVisitorIdOrToken: sinon.stub(), - setContactIdByVisitorIdOrToken: sinon.stub(), - }, - LivechatVisitors: { - findOneById: sinon.stub(), - updateById: sinon.stub(), - updateOne: sinon.stub(), - }, - LivechatCustomField: { - findByScope: sinon.stub(), - }, -}; -const { validateCustomFields, validateContactManager, updateContact, getContact } = proxyquire - .noCallThru() - .load('../../../../../../app/livechat/server/lib/Contacts', { - 'meteor/check': sinon.stub(), - 'meteor/meteor': sinon.stub(), - '@rocket.chat/models': modelsMock, - '@rocket.chat/tools': { wrapExceptions: sinon.stub() }, - './Helper': { validateEmail: sinon.stub() }, - './LivechatTyped': { - Livechat: { - logger: { - debug: sinon.stub(), - }, - }, - }, - }); - -describe('[OC] Contacts', () => { - describe('validateCustomFields', () => { - const mockCustomFields = [{ _id: 'cf1', label: 'Custom Field 1', regexp: '^[0-9]+$', required: true }]; - - it('should validate custom fields correctly', () => { - expect(() => validateCustomFields(mockCustomFields, { cf1: '123' })).to.not.throw(); - }); - - it('should throw an error if a required custom field is missing', () => { - expect(() => validateCustomFields(mockCustomFields, {})).to.throw(); - }); - - it('should NOT throw an error when a non-required custom field is missing', () => { - const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; - const customFields = {}; - - expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); - }); - - it('should throw an error if a custom field value does not match the regexp', () => { - expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' })).to.throw(); - }); - - it('should handle an empty customFields input without throwing an error', () => { - const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; - const customFields = {}; - - expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); - }); - - it('should throw an error if a extra custom field is passed', () => { - const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; - const customFields = { field2: 'value' }; - - expect(() => validateCustomFields(allowedCustomFields, customFields)).to.throw(); - }); - }); - - describe('validateContactManager', () => { - beforeEach(() => { - modelsMock.Users.findOneAgentById.reset(); - }); - - it('should throw an error if the user does not exist', async () => { - modelsMock.Users.findOneAgentById.resolves(undefined); - await expect(validateContactManager('any_id')).to.be.rejectedWith('error-contact-manager-not-found'); - }); - - it('should not throw an error if the user has the "livechat-agent" role', async () => { - const user = { _id: 'userId' }; - modelsMock.Users.findOneAgentById.resolves(user); - - await expect(validateContactManager('userId')).to.not.be.rejected; - expect(modelsMock.Users.findOneAgentById.getCall(0).firstArg).to.be.equal('userId'); - }); - }); - - describe('updateContact', () => { - beforeEach(() => { - modelsMock.LivechatContacts.findOneById.reset(); - modelsMock.LivechatContacts.updateContact.reset(); - }); - - it('should throw an error if the contact does not exist', async () => { - modelsMock.LivechatContacts.findOneById.resolves(undefined); - await expect(updateContact('any_id')).to.be.rejectedWith('error-contact-not-found'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; - }); - - it('should update the contact with correct params', async () => { - modelsMock.LivechatContacts.findOneById.resolves({ _id: 'contactId' }); - modelsMock.LivechatContacts.updateContact.resolves({ _id: 'contactId', name: 'John Doe' } as any); - - const updatedContact = await updateContact({ contactId: 'contactId', name: 'John Doe' }); - - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'John Doe' }); - expect(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' }); - }); - }); - - describe('getContact', () => { - beforeEach(() => { - modelsMock.LivechatContacts.findOneById.reset(); - modelsMock.LivechatContacts.upsertContact.reset(); - modelsMock.LivechatContacts.insertOne.reset(); - modelsMock.LivechatVisitors.findOneById.reset(); - modelsMock.LivechatVisitors.updateById.reset(); - modelsMock.Users.findOneByUsername.reset(); - }); - - describe('contact not found', () => { - it('should search for visitor when the contact is not found', async () => { - modelsMock.LivechatContacts.findOneById.resolves(undefined); - modelsMock.LivechatVisitors.findOneById.resolves(undefined); - expect(await getContact('any_id')).to.be.null; - - expect(modelsMock.LivechatContacts.upsertContact.getCall(0)).to.be.null; - expect(modelsMock.LivechatVisitors.updateById.getCall(0)).to.be.null; - expect(modelsMock.LivechatVisitors.findOneById.getCall(0).args[0]).to.be.equal('any_id'); - }); - - it('should create a contact if there is a visitor with that id', async () => { - let createdContact: ILivechatContact | null = null; - modelsMock.LivechatContacts.findOneById.callsFake(() => createdContact); - modelsMock.Users.findOneByUsername.resolves({ _id: 'manager_id' }); - modelsMock.LivechatCustomField.findByScope.returns({ toArray: () => [] }); - modelsMock.LivechatVisitors.findOneById.resolves({ - _id: 'any_id', - contactManager: { username: 'username' }, - name: 'VisitorName', - username: 'VisitorUsername', - visitorEmails: [{ address: 'email@domain.com' }, { address: 'email2@domain.com' }], - phone: [{ phoneNumber: '1' }, { phoneNumber: '2' }], - }); - - modelsMock.LivechatContacts.upsertContact.callsFake((contactId, data) => { - createdContact = { - ...data, - _id: contactId, - }; - }); - modelsMock.LivechatContacts.insertOne.callsFake((data) => { - createdContact = { - ...data, - _id: 'random_id', - }; - return { - insertedId: 'random_id', - }; - }); - modelsMock.LivechatRooms.findNewestByVisitorIdOrToken.resolves({ - _id: 'room_id', - visitorId: 'any_id', - source: { - type: 'widget', - }, - }); - - expect(await getContact('any_id')).to.be.deep.equal({ - _id: 'any_id', - name: 'VisitorName', - emails: [{ address: 'email@domain.com' }, { address: 'email2@domain.com' }], - phones: [{ phoneNumber: '1' }, { phoneNumber: '2' }], - contactManager: 'manager_id', - unknown: true, - channels: [ - { - name: 'widget', - visitorId: 'any_id', - blocked: false, - verified: false, - details: { type: 'widget' }, - }, - ], - customFields: {}, - }); - - expect(modelsMock.LivechatContacts.findOneById.getCall(0).args[0]).to.be.equal('any_id'); - expect(modelsMock.LivechatContacts.findOneById.getCall(0).returnValue).to.be.equal(null); - expect(modelsMock.LivechatContacts.findOneById.getCall(1).args[0]).to.be.equal('any_id'); - expect(modelsMock.LivechatContacts.findOneById.getCall(1).returnValue).to.be.equal(createdContact); - - expect(modelsMock.LivechatContacts.insertOne.getCall(0)).to.be.null; - expect(modelsMock.Users.findOneByUsername.getCall(0).args[0]).to.be.equal('username'); - }); - }); - - describe('contact found', () => { - it('should not search for visitor data.', async () => { - modelsMock.LivechatContacts.findOneById.resolves({ _id: 'any_id' }); - - expect(await getContact('any_id')).to.be.deep.equal({ _id: 'any_id' }); - - expect(modelsMock.LivechatVisitors.findOneById.getCall(0)).to.be.null; - expect(modelsMock.LivechatContacts.insertOne.getCall(0)).to.be.null; - expect(modelsMock.LivechatContacts.upsertContact.getCall(0)).to.be.null; - }); - }); - }); -}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/getContact.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/getContact.spec.ts new file mode 100644 index 000000000000..dee3c0bf678f --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/getContact.spec.ts @@ -0,0 +1,103 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findOneById: sinon.stub(), + findOneByVisitorId: sinon.stub(), + }, + LivechatVisitors: { + findOneById: sinon.stub(), + }, +}; + +const migrateVisitorToContactId = sinon.stub(); + +const { getContact } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/contacts/getContact', { + './migrateVisitorToContactId': { + migrateVisitorToContactId, + }, + '@rocket.chat/models': modelsMock, +}); + +describe('getContact', () => { + beforeEach(() => { + modelsMock.LivechatContacts.findOneById.reset(); + modelsMock.LivechatContacts.findOneByVisitorId.reset(); + modelsMock.LivechatVisitors.findOneById.reset(); + migrateVisitorToContactId.reset(); + }); + + describe('using contactId', () => { + it('should return the contact data without searching for visitor.', async () => { + const contact = { _id: 'any_id' }; + modelsMock.LivechatContacts.findOneById.resolves(contact); + + expect(await getContact('any_id')).to.be.deep.equal(contact); + + expect(modelsMock.LivechatVisitors.findOneById.getCall(0)).to.be.null; + expect(migrateVisitorToContactId.getCall(0)).to.be.null; + }); + }); + + describe('using visitorId', () => { + it('should return null if neither a contact nor visitor match the ID', async () => { + modelsMock.LivechatContacts.findOneById.resolves(undefined); + modelsMock.LivechatVisitors.findOneById.resolves(undefined); + expect(await getContact('any_id')).to.be.null; + + expect(modelsMock.LivechatVisitors.findOneById.getCall(0).args[0]).to.be.equal('any_id'); + expect(migrateVisitorToContactId.getCall(0)).to.be.null; + }); + + it('should return an existing contact already associated with that visitor', async () => { + modelsMock.LivechatContacts.findOneById.resolves(undefined); + modelsMock.LivechatVisitors.findOneById.resolves({ + _id: 'visitor1', + }); + + const contact = { _id: 'contact1' }; + modelsMock.LivechatContacts.findOneByVisitorId.resolves(contact); + + expect(await getContact('any_id')).to.be.deep.equal(contact); + + expect(migrateVisitorToContactId.getCall(0)).to.be.null; + }); + + it('should attempt to migrate the visitor into a new contact if there is no existing contact', async () => { + modelsMock.LivechatContacts.findOneById.resolves(undefined); + const visitor = { _id: 'visitor1' }; + modelsMock.LivechatVisitors.findOneById.resolves(visitor); + modelsMock.LivechatContacts.findOneByVisitorId.resolves(undefined); + + expect(await getContact('any_id')).to.be.null; + expect(migrateVisitorToContactId.getCall(0)).to.not.be.null; + expect(migrateVisitorToContactId.getCall(0).args[0]).to.be.deep.equal(visitor); + }); + + it('should load data for the created contact when migration happens successfully', async () => { + modelsMock.LivechatContacts.findOneById.callsFake((id) => { + if (id === 'contact1') { + return { + _id: 'new_id', + }; + } + return undefined; + }); + const visitor = { _id: 'visitor1' }; + modelsMock.LivechatVisitors.findOneById.resolves(visitor); + modelsMock.LivechatContacts.findOneByVisitorId.resolves(undefined); + + migrateVisitorToContactId.resolves('contact1'); + + expect(await getContact('any_id')).to.be.deep.equal({ _id: 'new_id' }); + expect(migrateVisitorToContactId.getCall(0)).to.not.be.null; + expect(migrateVisitorToContactId.getCall(0).args[0]).to.be.deep.equal(visitor); + + expect(modelsMock.LivechatContacts.findOneById.getCall(0)).to.not.be.null; + expect(modelsMock.LivechatContacts.findOneById.getCall(1)).to.not.be.null; + expect(modelsMock.LivechatContacts.findOneById.getCall(1).args[0]).to.be.equal('contact1'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/mapVisitorToContact.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/mapVisitorToContact.spec.ts new file mode 100644 index 000000000000..707eac8d442e --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/mapVisitorToContact.spec.ts @@ -0,0 +1,100 @@ +import { OmnichannelSourceType, type ILivechatVisitor, type IOmnichannelSource } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import type { CreateContactParams } from '../../../../../../app/livechat/server/lib/contacts/createContact'; + +const getContactManagerIdByUsername = sinon.stub(); + +const { mapVisitorToContact } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/contacts/mapVisitorToContact', { + './getContactManagerIdByUsername': { + getContactManagerIdByUsername, + }, +}); + +const dataMap: [Partial, IOmnichannelSource, CreateContactParams][] = [ + [ + { + _id: 'visitor1', + username: 'Username', + name: 'Name', + visitorEmails: [{ address: 'email1@domain.com' }, { address: 'email2@domain.com' }], + phone: [{ phoneNumber: '10' }, { phoneNumber: '20' }], + contactManager: { + username: 'user1', + }, + }, + { + type: OmnichannelSourceType.WIDGET, + }, + { + name: 'Name', + emails: ['email1@domain.com', 'email2@domain.com'], + phones: ['10', '20'], + unknown: true, + channels: [ + { + name: 'widget', + visitorId: 'visitor1', + blocked: false, + verified: false, + details: { + type: OmnichannelSourceType.WIDGET, + }, + }, + ], + customFields: undefined, + contactManager: 'manager1', + }, + ], + + [ + { + _id: 'visitor1', + username: 'Username', + }, + { + type: OmnichannelSourceType.SMS, + }, + { + name: 'Username', + emails: undefined, + phones: undefined, + unknown: true, + channels: [ + { + name: 'sms', + visitorId: 'visitor1', + blocked: false, + verified: false, + details: { + type: OmnichannelSourceType.SMS, + }, + }, + ], + customFields: undefined, + contactManager: undefined, + }, + ], +]; + +describe('mapVisitorToContact', () => { + beforeEach(() => { + getContactManagerIdByUsername.reset(); + getContactManagerIdByUsername.callsFake((username) => { + if (username === 'user1') { + return 'manager1'; + } + + return undefined; + }); + }); + + const index = 0; + for (const [visitor, source, contact] of dataMap) { + it(`should map an ILivechatVisitor + IOmnichannelSource to an ILivechatContact [${index}]`, async () => { + expect(await mapVisitorToContact(visitor, source)).to.be.deep.equal(contact); + }); + } +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/migrateVisitorToContactId.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/migrateVisitorToContactId.spec.ts new file mode 100644 index 000000000000..5676a21f04d9 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/migrateVisitorToContactId.spec.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findContactMatchingVisitor: sinon.stub(), + }, + LivechatRooms: { + setContactIdByVisitorIdOrToken: sinon.stub(), + }, +}; + +const createContactFromVisitor = sinon.stub(); +const getVisitorNewestSource = sinon.stub(); +const mergeVisitorIntoContact = sinon.stub(); + +const { migrateVisitorToContactId } = proxyquire + .noCallThru() + .load('../../../../../../app/livechat/server/lib/contacts/migrateVisitorToContactId', { + './createContactFromVisitor': { + createContactFromVisitor, + }, + './getVisitorNewestSource': { + getVisitorNewestSource, + }, + './ContactMerger': { + ContactMerger: { + mergeVisitorIntoContact, + }, + }, + '@rocket.chat/models': modelsMock, + '../LivechatTyped': { + Livechat: { + logger: { + debug: sinon.stub(), + }, + }, + }, + }); + +describe('migrateVisitorToContactId', () => { + beforeEach(() => { + modelsMock.LivechatContacts.findContactMatchingVisitor.reset(); + modelsMock.LivechatRooms.setContactIdByVisitorIdOrToken.reset(); + createContactFromVisitor.reset(); + getVisitorNewestSource.reset(); + mergeVisitorIntoContact.reset(); + }); + + it('should not create a contact if there is no source for the visitor', async () => { + expect(await migrateVisitorToContactId({ _id: 'visitor1' })).to.be.null; + expect(getVisitorNewestSource.getCall(0)).to.not.be.null; + }); + + it('should attempt to create a new contact if there is no free existing contact matching the visitor data', async () => { + modelsMock.LivechatContacts.findContactMatchingVisitor.resolves(undefined); + createContactFromVisitor.resolves('contactCreated'); + + expect(await migrateVisitorToContactId({ _id: 'visitor1' }, { type: 'other' })).to.be.equal('contactCreated'); + expect(getVisitorNewestSource.getCall(0)).to.be.null; + }); + + it('should load the source from existing visitor rooms if none is provided', async () => { + modelsMock.LivechatContacts.findContactMatchingVisitor.resolves(undefined); + const source = { type: 'sms' }; + getVisitorNewestSource.resolves(source); + createContactFromVisitor.resolves('contactCreated'); + + const visitor = { _id: 'visitor1' }; + + expect(await migrateVisitorToContactId(visitor)).to.be.equal('contactCreated'); + expect(getVisitorNewestSource.getCall(0)).to.not.be.null; + expect(createContactFromVisitor.getCall(0).args[0]).to.be.deep.equal(visitor); + expect(createContactFromVisitor.getCall(0).args[1]).to.be.deep.equal(source); + }); + + it('should not attempt to create a new contact if one is found for the visitor', async () => { + const visitor = { _id: 'visitor1' }; + const contact = { _id: 'contact1' }; + const source = { type: 'sms' }; + modelsMock.LivechatContacts.findContactMatchingVisitor.resolves(contact); + createContactFromVisitor.resolves('contactCreated'); + + expect(await migrateVisitorToContactId(visitor, source)).to.be.equal('contact1'); + expect(mergeVisitorIntoContact.getCall(0)).to.not.be.null; + expect(mergeVisitorIntoContact.getCall(0).args[0]).to.be.deep.equal(visitor); + expect(mergeVisitorIntoContact.getCall(0).args[1]).to.be.deep.equal(contact); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/registerContact.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/registerContact.spec.ts new file mode 100644 index 000000000000..2fd8ab7ff960 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/registerContact.spec.ts @@ -0,0 +1,138 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + 'Users': { + findOneAgentById: sinon.stub(), + findOneByUsername: sinon.stub(), + }, + 'LivechatContacts': { + findOneById: sinon.stub(), + insertOne: sinon.stub(), + upsertContact: sinon.stub(), + updateContact: sinon.stub(), + findContactMatchingVisitor: sinon.stub(), + findOneByVisitorId: sinon.stub(), + }, + 'LivechatRooms': { + findNewestByVisitorIdOrToken: sinon.stub(), + setContactIdByVisitorIdOrToken: sinon.stub(), + findByVisitorId: sinon.stub(), + }, + 'LivechatVisitors': { + findOneById: sinon.stub(), + updateById: sinon.stub(), + updateOne: sinon.stub(), + getVisitorByToken: sinon.stub(), + findOneGuestByEmailAddress: sinon.stub(), + }, + 'LivechatCustomField': { + findByScope: sinon.stub(), + }, + '@global': true, +}; + +const { registerContact } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/contacts/registerContact', { + 'meteor/meteor': sinon.stub(), + '@rocket.chat/models': modelsMock, + '@rocket.chat/tools': { wrapExceptions: sinon.stub() }, + './Helper': { validateEmail: sinon.stub() }, + './LivechatTyped': { + Livechat: { + logger: { + debug: sinon.stub(), + }, + }, + }, +}); + +describe('registerContact', () => { + beforeEach(() => { + modelsMock.Users.findOneByUsername.reset(); + modelsMock.LivechatVisitors.getVisitorByToken.reset(); + modelsMock.LivechatVisitors.updateOne.reset(); + modelsMock.LivechatVisitors.findOneGuestByEmailAddress.reset(); + modelsMock.LivechatCustomField.findByScope.reset(); + modelsMock.LivechatRooms.findByVisitorId.reset(); + }); + + it(`should throw an error if there's no token`, async () => { + modelsMock.Users.findOneByUsername.returns(undefined); + + await expect( + registerContact({ + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'unknown', + }, + }), + ).to.eventually.be.rejectedWith('error-invalid-contact-data'); + }); + + it(`should throw an error if the token is not a string`, async () => { + modelsMock.Users.findOneByUsername.returns(undefined); + + await expect( + registerContact({ + token: 15, + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'unknown', + }, + }), + ).to.eventually.be.rejectedWith('error-invalid-contact-data'); + }); + + it(`should throw an error if there's an invalid manager username`, async () => { + modelsMock.Users.findOneByUsername.returns(undefined); + + await expect( + registerContact({ + token: 'token', + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'unknown', + }, + }), + ).to.eventually.be.rejectedWith('error-contact-manager-not-found'); + }); + + it(`should throw an error if the manager username does not belong to a livechat agent`, async () => { + modelsMock.Users.findOneByUsername.returns({ roles: ['user'] }); + + await expect( + registerContact({ + token: 'token', + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'username', + }, + }), + ).to.eventually.be.rejectedWith('error-invalid-contact-manager'); + }); + + it('should register a contact when passing valid data', async () => { + modelsMock.LivechatVisitors.getVisitorByToken.returns({ _id: 'visitor1' }); + modelsMock.LivechatCustomField.findByScope.returns({ toArray: () => [] }); + modelsMock.LivechatRooms.findByVisitorId.returns({ toArray: () => [] }); + modelsMock.LivechatVisitors.updateOne.returns(undefined); + + await expect( + registerContact({ + token: 'token', + email: 'test@test.com', + username: 'username', + name: 'Name', + }), + ).to.eventually.be.equal('visitor1'); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/updateContact.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/updateContact.spec.ts new file mode 100644 index 000000000000..b15d5d10dbe9 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/updateContact.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findOneById: sinon.stub(), + updateContact: sinon.stub(), + }, +}; + +const { updateContact } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/contacts/updateContact', { + './getAllowedCustomFields': { + getAllowedCustomFields: sinon.stub(), + }, + './validateContactManager': { + validateContactManager: sinon.stub(), + }, + './validateCustomFields': { + validateCustomFields: sinon.stub(), + }, + + '@rocket.chat/models': modelsMock, +}); + +describe('updateContact', () => { + beforeEach(() => { + modelsMock.LivechatContacts.findOneById.reset(); + modelsMock.LivechatContacts.updateContact.reset(); + }); + + it('should throw an error if the contact does not exist', async () => { + modelsMock.LivechatContacts.findOneById.resolves(undefined); + await expect(updateContact('any_id')).to.be.rejectedWith('error-contact-not-found'); + expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + }); + + it('should update the contact with correct params', async () => { + modelsMock.LivechatContacts.findOneById.resolves({ _id: 'contactId' }); + modelsMock.LivechatContacts.updateContact.resolves({ _id: 'contactId', name: 'John Doe' } as any); + + const updatedContact = await updateContact({ contactId: 'contactId', name: 'John Doe' }); + + expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'John Doe' }); + expect(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' }); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/validateContactManager.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/validateContactManager.spec.ts new file mode 100644 index 000000000000..91ffb862556c --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/validateContactManager.spec.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + Users: { + findOneAgentById: sinon.stub(), + }, +}; + +const { validateContactManager } = proxyquire + .noCallThru() + .load('../../../../../../app/livechat/server/lib/contacts/validateContactManager', { + '@rocket.chat/models': modelsMock, + }); + +describe('validateContactManager', () => { + beforeEach(() => { + modelsMock.Users.findOneAgentById.reset(); + }); + + it('should throw an error if the user does not exist', async () => { + modelsMock.Users.findOneAgentById.resolves(undefined); + await expect(validateContactManager('any_id')).to.be.rejectedWith('error-contact-manager-not-found'); + }); + + it('should not throw an error if the user has the "livechat-agent" role', async () => { + const user = { _id: 'userId' }; + modelsMock.Users.findOneAgentById.resolves(user); + + await expect(validateContactManager('userId')).to.not.be.rejected; + expect(modelsMock.Users.findOneAgentById.getCall(0).firstArg).to.be.equal('userId'); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/validateCustomFields.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/validateCustomFields.spec.ts new file mode 100644 index 000000000000..37e31744ab51 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/validateCustomFields.spec.ts @@ -0,0 +1,43 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; + +const { validateCustomFields } = proxyquire + .noCallThru() + .load('../../../../../../app/livechat/server/lib/contacts/validateCustomFields', {}); + +describe('validateCustomFields', () => { + const mockCustomFields = [{ _id: 'cf1', label: 'Custom Field 1', regexp: '^[0-9]+$', required: true }]; + + it('should validate custom fields correctly', () => { + expect(() => validateCustomFields(mockCustomFields, { cf1: '123' })).to.not.throw(); + }); + + it('should throw an error if a required custom field is missing', () => { + expect(() => validateCustomFields(mockCustomFields, {})).to.throw(); + }); + + it('should NOT throw an error when a non-required custom field is missing', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = {}; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); + }); + + it('should throw an error if a custom field value does not match the regexp', () => { + expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' })).to.throw(); + }); + + it('should handle an empty customFields input without throwing an error', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = {}; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); + }); + + it('should throw an error if a extra custom field is passed', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = { field2: 'value' }; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).to.throw(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 3fb389c255ae..1836871ef9e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8133,8 +8133,6 @@ __metadata: typescript: "npm:~5.1.6" uglify-es: "npm:^3.3.10" uuid: "npm:~8.3.2" - peerDependencies: - "@rocket.chat/ui-kit": "workspace:^" languageName: unknown linkType: soft