From dc457acae1459d2a349924796e8fddcfa7943b31 Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Mon, 21 Oct 2024 16:14:09 -0300 Subject: [PATCH 1/3] 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/3] 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/3] 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'; /**