From c31f93ed9677e43d947615c5e2ace233c73df7ad Mon Sep 17 00:00:00 2001 From: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com> Date: Fri, 30 Jun 2023 01:31:44 +0530 Subject: [PATCH] fix: Newly added agent not following business hours (#29529) Co-authored-by: Kevin Aleman <11577696+KevLehman@users.noreply.github.com> --- .changeset/quick-coats-protect.md | 6 + .../app/lib/server/functions/saveUser.js | 7 +- .../business-hour/AbstractBusinessHour.ts | 34 +++++- .../business-hour/BusinessHourManager.ts | 32 ++--- .../livechat/server/business-hour/Helper.ts | 3 + .../livechat/server/business-hour/Single.ts | 2 + .../livechat/server/hooks/afterUserActions.ts | 50 ++++++-- .../server/hooks/saveAnalyticsData.ts | 2 +- .../app/livechat/server/lib/Livechat.js | 9 +- .../lib/{callbackLogger.ts => logger.ts} | 1 + .../app/livechat/server/sendMessageBySMS.ts | 2 +- apps/meteor/app/livechat/server/startup.ts | 8 +- .../server/business-hour/Helper.ts | 32 +++-- .../server/business-hour/Multiple.ts | 112 ++---------------- .../server/hooks/onCloseLivechat.ts | 2 +- .../server/hooks/resumeOnHold.ts | 2 +- .../app/livechat-enterprise/server/startup.ts | 4 + apps/meteor/lib/callbacks.ts | 2 + apps/meteor/server/models/raw/Users.js | 39 +++--- .../tests/data/livechat/businessHours.ts | 20 +++- apps/meteor/tests/data/livechat/users.ts | 6 + .../api/livechat/19-business-hours.ts | 86 +++++++++++++- packages/core-typings/src/ILivechatAgent.ts | 2 + .../model-typings/src/models/IUsersModel.ts | 10 +- 24 files changed, 301 insertions(+), 172 deletions(-) create mode 100644 .changeset/quick-coats-protect.md rename apps/meteor/app/livechat/server/lib/{callbackLogger.ts => logger.ts} (67%) diff --git a/.changeset/quick-coats-protect.md b/.changeset/quick-coats-protect.md new file mode 100644 index 00000000000..f47a439443e --- /dev/null +++ b/.changeset/quick-coats-protect.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +--- + +fix: newly added agent not following business hours diff --git a/apps/meteor/app/lib/server/functions/saveUser.js b/apps/meteor/app/lib/server/functions/saveUser.js index 849a96260dd..650a9b05bc3 100644 --- a/apps/meteor/app/lib/server/functions/saveUser.js +++ b/apps/meteor/app/lib/server/functions/saveUser.js @@ -419,11 +419,14 @@ export const saveUser = async function (userId, userData) { await Users.updateOne({ _id: userData._id }, updateUser); - await callbacks.run('afterSaveUser', userData); - // App IPostUserUpdated event hook const userUpdated = await Users.findOneById(userId); + await callbacks.run('afterSaveUser', { + user: userUpdated, + oldUser: oldUserData, + }); + await Apps.triggerEvent(AppEvents.IPostUserUpdated, { user: userUpdated, previousUser: oldUserData, diff --git a/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts b/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts index 2bafd2f0fa2..80cdc5aea29 100644 --- a/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts +++ b/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts @@ -1,23 +1,28 @@ import moment from 'moment-timezone'; +import { ILivechatAgentStatus } from '@rocket.chat/core-typings'; import type { ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings'; import type { ILivechatBusinessHoursModel, IUsersModel } from '@rocket.chat/model-typings'; import { LivechatBusinessHours, Users } from '@rocket.chat/models'; import type { UpdateFilter } from 'mongodb'; import type { IWorkHoursCronJobsWrapper } from '../../../../server/models/raw/LivechatBusinessHours'; +import { businessHourLogger } from '../lib/logger'; +import { filterBusinessHoursThatMustBeOpened } from './Helper'; export interface IBusinessHourBehavior { findHoursToCreateJobs(): Promise; openBusinessHoursByDayAndHour(day: string, hour: string): Promise; closeBusinessHoursByDayAndHour(day: string, hour: string): Promise; onDisableBusinessHours(): Promise; - onAddAgentToDepartment(options?: Record): Promise; + onAddAgentToDepartment(options?: { departmentId: string; agentsId: string[] }): Promise; onRemoveAgentFromDepartment(options?: Record): Promise; onRemoveDepartment(department?: ILivechatDepartment): Promise; onStartBusinessHours(): Promise; afterSaveBusinessHours(businessHourData: ILivechatBusinessHour): Promise; allowAgentChangeServiceStatus(agentId: string): Promise; changeAgentActiveStatus(agentId: string, status: string): Promise; + // If a new agent is created, this callback will be called + onNewAgentCreated(agentId: string): Promise; } export interface IBusinessHourType { @@ -44,9 +49,7 @@ export abstract class AbstractBusinessHourBehavior { return this.UsersRepository.isAgentWithinBusinessHours(agentId); } - // After logout, users are turned not-available by default - // This will turn them available unless they put themselves offline (manual status change) - async changeAgentActiveStatus(agentId: string, status: string): Promise { + async changeAgentActiveStatus(agentId: string, status: ILivechatAgentStatus): Promise { return this.UsersRepository.setLivechatStatusIf( agentId, status, @@ -56,6 +59,29 @@ export abstract class AbstractBusinessHourBehavior { { livechatStatusSystemModified: true }, ); } + + async onNewAgentCreated(agentId: string): Promise { + businessHourLogger.debug(`Executing onNewAgentCreated for agentId: ${agentId}`); + + const defaultBusinessHour = await LivechatBusinessHours.findOneDefaultBusinessHour(); + if (!defaultBusinessHour) { + businessHourLogger.debug(`No default business hour found for agentId: ${agentId}`); + return; + } + + const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([defaultBusinessHour]); + if (!businessHourToOpen.length) { + businessHourLogger.debug( + `No business hour to open found for agentId: ${agentId}. Default business hour is closed. Setting agentId: ${agentId} to status: ${ILivechatAgentStatus.NOT_AVAILABLE}`, + ); + await Users.setLivechatStatus(agentId, ILivechatAgentStatus.NOT_AVAILABLE); + return; + } + + await Users.addBusinessHourByAgentIds([agentId], defaultBusinessHour._id); + + businessHourLogger.debug(`Setting agentId: ${agentId} to status: ${ILivechatAgentStatus.AVAILABLE}`); + } } export abstract class AbstractBusinessHourType { diff --git a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts index b128fa64485..7e72a175635 100644 --- a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts +++ b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts @@ -7,16 +7,7 @@ import { Users } from '@rocket.chat/models'; import type { IBusinessHourBehavior, IBusinessHourType } from './AbstractBusinessHour'; import { settings } from '../../../settings/server'; import { callbacks } from '../../../../lib/callbacks'; - -const cronJobDayDict: Record = { - Sunday: 0, - Monday: 1, - Tuesday: 2, - Wednesday: 3, - Thursday: 4, - Friday: 5, - Saturday: 6, -}; +import { businessHourLogger } from '../lib/logger'; export class BusinessHourManager { private types: Map = new Map(); @@ -35,6 +26,7 @@ export class BusinessHourManager { async startManager(): Promise { await this.createCronJobsForWorkHours(); + businessHourLogger.debug('Cron jobs created, setting up callbacks'); this.setupCallbacks(); await this.behavior.onStartBusinessHours(); } @@ -115,12 +107,19 @@ export class BusinessHourManager { callbacks.priority.HIGH, 'business-hour-livechat-on-save-agent-department', ); + callbacks.add( + 'livechat.onNewAgentCreated', + this.behavior.onNewAgentCreated.bind(this), + callbacks.priority.HIGH, + 'business-hour-livechat-on-agent-created', + ); } private removeCallbacks(): void { callbacks.remove('livechat.removeAgentDepartment', 'business-hour-livechat-on-remove-agent-department'); callbacks.remove('livechat.afterRemoveDepartment', 'business-hour-livechat-after-remove-department'); callbacks.remove('livechat.saveAgentDepartment', 'business-hour-livechat-on-save-agent-department'); + callbacks.remove('livechat.onNewAgentCreated', 'business-hour-livechat-on-agent-created'); } private async createCronJobsForWorkHours(): Promise { @@ -137,12 +136,17 @@ export class BusinessHourManager { await Promise.all(finish.map(({ day, times }) => this.scheduleCronJob(times, day, 'close', this.closeWorkHoursCallback))); } - private async scheduleCronJob(items: string[], day: string, type: string, job: (day: string, hour: string) => void): Promise { + private async scheduleCronJob( + items: string[], + day: string, + type: 'open' | 'close', + job: (day: string, hour: string) => void, + ): Promise { await Promise.all( items.map((hour) => { - const jobName = `${day}/${hour}/${type}`; - const time = moment(hour, 'HH:mm'); - const scheduleAt = `${time.minutes()} ${time.hours()} * * ${cronJobDayDict[day]}`; + const time = moment(hour, 'HH:mm').day(day); + const jobName = `${time.format('dddd')}/${time.format('HH:mm')}/${type}`; + const scheduleAt = `${time.minutes()} ${time.hours()} * * ${time.day()}`; this.addToCache(jobName); return this.cronJobs.add(jobName, scheduleAt, () => job(day, hour)); }), diff --git a/apps/meteor/app/livechat/server/business-hour/Helper.ts b/apps/meteor/app/livechat/server/business-hour/Helper.ts index ba2b48089af..ccf0381f0c0 100644 --- a/apps/meteor/app/livechat/server/business-hour/Helper.ts +++ b/apps/meteor/app/livechat/server/business-hour/Helper.ts @@ -4,6 +4,7 @@ import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; import { LivechatBusinessHours, Users } from '@rocket.chat/models'; import { createDefaultBusinessHourRow } from './LivechatBusinessHours'; +import { businessHourLogger } from '../lib/logger'; export const filterBusinessHoursThatMustBeOpened = async ( businessHours: ILivechatBusinessHour[], @@ -52,8 +53,10 @@ export const openBusinessHourDefault = async (): Promise => { }, }); const businessHoursToOpenIds = (await filterBusinessHoursThatMustBeOpened(activeBusinessHours)).map((businessHour) => businessHour._id); + businessHourLogger.debug({ msg: 'Opening default business hours', businessHoursToOpenIds }); await Users.openAgentsBusinessHoursByBusinessHourId(businessHoursToOpenIds); await Users.updateLivechatStatusBasedOnBusinessHours(); + businessHourLogger.debug('Done opening default business hours'); }; export const createDefaultBusinessHourIfNotExists = async (): Promise => { diff --git a/apps/meteor/app/livechat/server/business-hour/Single.ts b/apps/meteor/app/livechat/server/business-hour/Single.ts index fed214305e7..2996d5f1c2c 100644 --- a/apps/meteor/app/livechat/server/business-hour/Single.ts +++ b/apps/meteor/app/livechat/server/business-hour/Single.ts @@ -3,6 +3,7 @@ import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; import type { IBusinessHourBehavior } from './AbstractBusinessHour'; import { AbstractBusinessHourBehavior } from './AbstractBusinessHour'; import { openBusinessHourDefault } from './Helper'; +import { businessHourLogger } from '../lib/logger'; export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior implements IBusinessHourBehavior { async openBusinessHoursByDayAndHour(day: string, hour: string): Promise { @@ -25,6 +26,7 @@ export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior imp } async onStartBusinessHours(): Promise { + businessHourLogger.debug('Starting Single Business Hours'); return openBusinessHourDefault(); } diff --git a/apps/meteor/app/livechat/server/hooks/afterUserActions.ts b/apps/meteor/app/livechat/server/hooks/afterUserActions.ts index 8b47b8af133..3ec0bd81785 100644 --- a/apps/meteor/app/livechat/server/hooks/afterUserActions.ts +++ b/apps/meteor/app/livechat/server/hooks/afterUserActions.ts @@ -1,23 +1,51 @@ import type { IUser } from '@rocket.chat/core-typings'; -import { Users } from '@rocket.chat/models'; -import type { UsersUpdateParamsPOST } from '@rocket.chat/rest-typings'; import { callbacks } from '../../../../lib/callbacks'; +import { Livechat } from '../lib/Livechat'; +import { callbackLogger } from '../lib/logger'; -type UserData = UsersUpdateParamsPOST['data'] & { _id: string }; +type IAfterSaveUserProps = { + user: IUser; + oldUser: IUser | null; +}; + +const wasAgent = (user: Pick | null) => user?.roles?.includes('livechat-agent'); +const isAgent = (user: Pick | null) => user?.roles?.includes('livechat-agent'); + +const handleAgentUpdated = async (userData: IAfterSaveUserProps) => { + const { + user: { _id: userId, username }, + user: newUser, + oldUser, + } = userData; + + if (wasAgent(oldUser) && !isAgent(newUser)) { + callbackLogger.debug('Removing agent', userId); + await Livechat.removeAgent(username); + } -const handleAgentUpdated = async (userData: UserData) => { - if (!userData?.roles?.includes('livechat-agent')) { - await Users.unsetExtension(userData._id); + if (!wasAgent(oldUser) && isAgent(newUser)) { + callbackLogger.debug('Adding agent', userId); + await Livechat.addAgent(username); } }; -const handleDeactivateUser = async (userData: IUser) => { - if (userData?.roles?.includes('livechat-agent')) { - await Users.unsetExtension(userData._id); +const handleDeactivateUser = async (user: IUser) => { + if (wasAgent(user)) { + callbackLogger.debug('Removing agent', user._id); + await Livechat.removeAgent(user.username); } }; -callbacks.add('afterSaveUser', handleAgentUpdated, callbacks.priority.LOW, 'livechat-after-save-user-remove-extension'); +const handleActivateUser = async (user: IUser) => { + if (isAgent(user)) { + callbackLogger.debug('Adding agent', user._id); + await Livechat.addAgent(user.username); + } +}; + +callbacks.add('afterSaveUser', handleAgentUpdated, callbacks.priority.LOW, 'livechat-after-save-user-update-agent'); + +callbacks.add('afterDeactivateUser', handleDeactivateUser, callbacks.priority.LOW, 'livechat-after-deactivate-user-remove-agent'); -callbacks.add('afterDeactivateUser', handleDeactivateUser, callbacks.priority.LOW, 'livechat-after-deactivate-user-remove-extension'); +callbacks.add('afterActivateUser', handleActivateUser, callbacks.priority.LOW, 'livechat-after-activate-user-add-agent'); diff --git a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts index db3dca5f0a8..53a8c180c61 100644 --- a/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts +++ b/apps/meteor/app/livechat/server/hooks/saveAnalyticsData.ts @@ -3,7 +3,7 @@ import { LivechatRooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { normalizeMessageFileUpload } from '../../../utils/server/functions/normalizeMessageFileUpload'; -import { callbackLogger } from '../lib/callbackLogger'; +import { callbackLogger } from '../lib/logger'; callbacks.add( 'afterSaveMessage', diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index 6ba4a653b21..1fcf39c30a3 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -533,6 +533,9 @@ export const Livechat = { if (await addUserRolesAsync(user._id, ['livechat-agent'])) { await Users.setOperator(user._id, true); await this.setUserStatusLivechat(user._id, user.status !== 'offline' ? 'available' : 'not-available'); + + callbacks.runAsync('livechat.onNewAgentCreated', user._id); + return user; } @@ -571,14 +574,10 @@ export const Livechat = { const { _id } = user; if (await removeUserFromRolesAsync(_id, ['livechat-agent'])) { - await Users.setOperator(_id, false); - await Users.removeLivechatData(_id); - await this.setUserStatusLivechat(_id, 'not-available'); - await Promise.all([ + Users.removeAgent(_id), LivechatDepartmentAgents.removeByAgentId(_id), LivechatVisitors.removeContactManagerByUsername(username), - Users.unsetExtension(_id), ]); return true; } diff --git a/apps/meteor/app/livechat/server/lib/callbackLogger.ts b/apps/meteor/app/livechat/server/lib/logger.ts similarity index 67% rename from apps/meteor/app/livechat/server/lib/callbackLogger.ts rename to apps/meteor/app/livechat/server/lib/logger.ts index 7b56495bba3..ee6b2969c5c 100644 --- a/apps/meteor/app/livechat/server/lib/callbackLogger.ts +++ b/apps/meteor/app/livechat/server/lib/logger.ts @@ -1,3 +1,4 @@ import { Logger } from '../../../../server/lib/logger/Logger'; export const callbackLogger = new Logger('[Omnichannel] Callback'); +export const businessHourLogger = new Logger('Business Hour'); diff --git a/apps/meteor/app/livechat/server/sendMessageBySMS.ts b/apps/meteor/app/livechat/server/sendMessageBySMS.ts index 7df67b44b2f..8449c2c7bac 100644 --- a/apps/meteor/app/livechat/server/sendMessageBySMS.ts +++ b/apps/meteor/app/livechat/server/sendMessageBySMS.ts @@ -5,7 +5,7 @@ import { OmnichannelIntegration } from '@rocket.chat/core-services'; import { callbacks } from '../../../lib/callbacks'; import { settings } from '../../settings/server'; import { normalizeMessageFileUpload } from '../../utils/server/functions/normalizeMessageFileUpload'; -import { callbackLogger } from './lib/callbackLogger'; +import { callbackLogger } from './lib/logger'; callbacks.add( 'afterSaveMessage', diff --git a/apps/meteor/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index e268fd7aa6d..d8c5ba098d6 100644 --- a/apps/meteor/app/livechat/server/startup.ts +++ b/apps/meteor/app/livechat/server/startup.ts @@ -62,10 +62,14 @@ Meteor.startup(async () => { await createDefaultBusinessHourIfNotExists(); settings.watch('Livechat_enable_business_hours', async (value) => { + Livechat.logger.debug(`Changing business hour type to ${value}`); if (value) { - return businessHourManager.startManager(); + await businessHourManager.startManager(); + Livechat.logger.debug(`Business hour manager started`); + return; } - return businessHourManager.stopManager(); + await businessHourManager.stopManager(); + Livechat.logger.debug(`Business hour manager stopped`); }); settings.watch('Livechat_Routing_Method', function (value) { diff --git a/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Helper.ts b/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Helper.ts index b2e74835617..6575f13bbde 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Helper.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Helper.ts @@ -4,11 +4,13 @@ import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; import { LivechatBusinessHours, LivechatDepartment, LivechatDepartmentAgents, Users } from '@rocket.chat/models'; import { isEnterprise } from '../../../license/server/license'; +import { businessHourLogger } from '../../../../../app/livechat/server/lib/logger'; const getAllAgentIdsWithoutDepartment = async (): Promise => { const agentIdsWithDepartment = ( await LivechatDepartmentAgents.find({ departmentEnabled: true }, { projection: { agentId: 1 } }).toArray() - ).map((dept: any) => dept.agentId); + ).map((dept) => dept.agentId); + const agentIdsWithoutDepartment = ( await Users.findUsersInRolesWithQuery( 'livechat-agent', @@ -17,7 +19,8 @@ const getAllAgentIdsWithoutDepartment = async (): Promise => { }, { projection: { _id: 1 } }, ).toArray() - ).map((user: any) => user._id); + ).map((user) => user._id); + return agentIdsWithoutDepartment; }; @@ -43,7 +46,7 @@ const getAllAgentIdsForDefaultBusinessHour = async (): Promise => { return [...new Set([...withoutDepartment, ...withDepartmentNotConnectedToBusinessHour])]; }; -const getAgentIdsToHandle = async (businessHour: Record): Promise => { +const getAgentIdsToHandle = async (businessHour: Pick): Promise => { if (businessHour.type === LivechatBusinessHourTypes.DEFAULT) { return getAllAgentIdsForDefaultBusinessHour(); } @@ -51,22 +54,35 @@ const getAgentIdsToHandle = async (businessHour: Record): Promise dept._id); + ).map((dept) => dept._id); return ( await LivechatDepartmentAgents.findByDepartmentIds(departmentIds, { projection: { agentId: 1 }, }).toArray() - ).map((dept: any) => dept.agentId); + ).map((dept) => dept.agentId); }; -export const openBusinessHour = async (businessHour: Record): Promise => { - const agentIds: string[] = await getAgentIdsToHandle(businessHour); +export const openBusinessHour = async (businessHour: Pick): Promise => { + const agentIds = await getAgentIdsToHandle(businessHour); + businessHourLogger.debug({ + msg: 'Opening business hour', + businessHour: businessHour._id, + totalAgents: agentIds.length, + top10AgentIds: agentIds.slice(0, 10), + }); + await Users.addBusinessHourByAgentIds(agentIds, businessHour._id); await Users.updateLivechatStatusBasedOnBusinessHours(); }; -export const closeBusinessHour = async (businessHour: Record): Promise => { +export const closeBusinessHour = async (businessHour: Pick): Promise => { const agentIds: string[] = await getAgentIdsToHandle(businessHour); + businessHourLogger.debug({ + msg: 'Closing business hour', + businessHour: businessHour._id, + totalAgents: agentIds.length, + top10AgentIds: agentIds.slice(0, 10), + }); await Users.removeBusinessHourByAgentIds(agentIds, businessHour._id); await Users.updateLivechatStatusBasedOnBusinessHours(); }; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Multiple.ts b/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Multiple.ts index 2c0db1516f8..0fa768453e3 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Multiple.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/business-hour/Multiple.ts @@ -1,16 +1,12 @@ import moment from 'moment'; -import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; import type { ILivechatDepartment, ILivechatBusinessHour } from '@rocket.chat/core-typings'; import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models'; import type { IBusinessHourBehavior } from '../../../../../app/livechat/server/business-hour/AbstractBusinessHour'; import { AbstractBusinessHourBehavior } from '../../../../../app/livechat/server/business-hour/AbstractBusinessHour'; -import { - filterBusinessHoursThatMustBeOpened, - filterBusinessHoursThatMustBeOpenedByDay, -} from '../../../../../app/livechat/server/business-hour/Helper'; +import { filterBusinessHoursThatMustBeOpened } from '../../../../../app/livechat/server/business-hour/Helper'; import { closeBusinessHour, openBusinessHour, removeBusinessHourByAgentIds } from './Helper'; -import { bhLogger } from '../lib/logger'; +import { businessHourLogger } from '../../../../../app/livechat/server/lib/logger'; interface IBusinessHoursExtraProperties extends ILivechatBusinessHour { timezoneName: string; @@ -23,6 +19,7 @@ export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior this.onAddAgentToDepartment = this.onAddAgentToDepartment.bind(this); this.onRemoveAgentFromDepartment = this.onRemoveAgentFromDepartment.bind(this); this.onRemoveDepartment = this.onRemoveDepartment.bind(this); + this.onNewAgentCreated = this.onNewAgentCreated.bind(this); } async onStartBusinessHours(): Promise { @@ -39,6 +36,11 @@ export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior }, }); const businessHoursToOpen = await filterBusinessHoursThatMustBeOpened(activeBusinessHours); + businessHourLogger.debug({ + msg: 'Starting Multiple Business Hours', + totalBusinessHoursToOpen: businessHoursToOpen.length, + top10BusinessHoursToOpen: businessHoursToOpen.slice(0, 10), + }); for (const businessHour of businessHoursToOpen) { void this.openBusinessHour(businessHour); } @@ -84,7 +86,7 @@ export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior return openBusinessHour(businessHour); } - async onAddAgentToDepartment(options: Record = {}): Promise { + async onAddAgentToDepartment(options: { departmentId: string; agentsId: string[] }): Promise { const { departmentId, agentsId } = options; const department = await LivechatDepartment.findOneById>(departmentId, { projection: { businessHourId: 1 }, @@ -132,96 +134,8 @@ export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior return this.handleRemoveAgentsFromDepartments(deletedDepartment, agentsIds, options); } - async allowAgentChangeServiceStatus(agentId: string): Promise { - const isWithinBushinessHours = await this.UsersRepository.isAgentWithinBusinessHours(agentId); - if (isWithinBushinessHours) { - return true; - } - - bhLogger.debug(`No active business hour found for agent with id: ${agentId} based on user's cache. Attempting to recheck the status`); - - // double check to see if user is actually within business hours - // this is required since the cache of businessHour Ids we maintain within user's collection might be stale - // in many scenario's like, if the agent is created when a business is active, - // or if a normal user is converted to agent when a business hour is active - const currentTime = moment.utc(moment().utc().format('dddd:HH:mm'), 'dddd:HH:mm'); - const day = currentTime.format('dddd'); - const allActiveBusinessHoursForEntireWeek = await this.BusinessHourRepository.findActiveBusinessHours({ - projection: { - workHours: 1, - timezone: 1, - type: 1, - active: 1, - }, - }); - const openedBusinessHours = await filterBusinessHoursThatMustBeOpenedByDay(allActiveBusinessHoursForEntireWeek, day); - if (!openedBusinessHours.length) { - bhLogger.debug(`Business hour status recheck failed for agentId: ${agentId}. No opened business hour found`); - return false; - } - - const agentDepartments = await LivechatDepartmentAgents.find( - { departmentEnabled: true, agentId }, - { projection: { agentId: 1, departmentId: 1 } }, - ).toArray(); - - if (agentDepartments.length) { - // check if any one these departments have a opened business hour linked to it - const departments = await LivechatDepartment.findInIds( - agentDepartments.map(({ departmentId }) => departmentId), - { projection: { _id: 1, businessHourId: 1 } }, - ).toArray(); - - const departmentsWithActiveBH = departments.filter( - ({ businessHourId }) => businessHourId && openedBusinessHours.findIndex(({ _id }) => _id === businessHourId) !== -1, - ); - - if (!departmentsWithActiveBH.length) { - bhLogger.debug( - `No opened business hour found for any of the departments connected to the agent with id: ${agentId}. Now, checking if the default business hour can be used`, - ); - - // check if this agent has any departments that is connected to any non-default business hour - // if no such departments found then check default BH and if it is active, then allow the agent to change service status - const hasAtLeastOneDepartmentWithNonDefaultBH = departments.some(({ businessHourId }) => { - // check if business hour is active - return businessHourId && allActiveBusinessHoursForEntireWeek.findIndex(({ _id }) => _id === businessHourId) !== -1; - }); - if (!hasAtLeastOneDepartmentWithNonDefaultBH) { - const isDefaultBHActive = openedBusinessHours.find(({ type }) => type === LivechatBusinessHourTypes.DEFAULT); - if (isDefaultBHActive?._id) { - await this.UsersRepository.openAgentBusinessHoursByBusinessHourIdsAndAgentId([isDefaultBHActive._id], agentId); - - bhLogger.debug(`Business hour status recheck passed for agentId: ${agentId}. Found default business hour to be active`); - return true; - } - bhLogger.debug(`Business hour status recheck failed for agentId: ${agentId}. Found default business hour to be inactive`); - } - return false; - } - - const activeBusinessHoursForAgent = departmentsWithActiveBH.map(({ businessHourId }) => businessHourId); - await this.UsersRepository.openAgentBusinessHoursByBusinessHourIdsAndAgentId(activeBusinessHoursForAgent, agentId); - - bhLogger.debug( - `Business hour status recheck passed for agentId: ${agentId}. Found following business hours to be active:`, - activeBusinessHoursForAgent, - ); - return true; - } - - // check if default businessHour is active - const isDefaultBHActive = openedBusinessHours.find(({ type }) => type === LivechatBusinessHourTypes.DEFAULT); - if (isDefaultBHActive?._id) { - await this.UsersRepository.openAgentBusinessHoursByBusinessHourIdsAndAgentId([isDefaultBHActive._id], agentId); - - bhLogger.debug(`Business hour status recheck passed for agentId: ${agentId}. Found default business hour to be active`); - return true; - } - - bhLogger.debug(`Business hour status recheck failed for agentId: ${agentId}. No opened business hour found`); - - return false; + allowAgentChangeServiceStatus(agentId: string): Promise { + return this.UsersRepository.isAgentWithinBusinessHours(agentId); } private async handleRemoveAgentsFromDepartments(department: Record, agentsIds: string[], options: any): Promise { @@ -255,7 +169,7 @@ export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior return options; } - private async openBusinessHour(businessHour: Record): Promise { + private async openBusinessHour(businessHour: Pick): Promise { return openBusinessHour(businessHour); } @@ -270,7 +184,7 @@ export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior await removeBusinessHourByAgentIds(agentIds, businessHourId); } - private async closeBusinessHour(businessHour: Record): Promise { + private async closeBusinessHour(businessHour: Pick): Promise { await closeBusinessHour(businessHour); } } diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.ts index 59d96b3fac1..3f93967ae9b 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.ts @@ -4,8 +4,8 @@ import { LivechatRooms, Subscriptions } from '@rocket.chat/models'; import { callbacks } from '../../../../../lib/callbacks'; import { settings } from '../../../../../app/settings/server'; import { debouncedDispatchWaitingQueueStatus } from '../lib/Helper'; -import { callbackLogger } from '../../../../../app/livechat/server/lib/callbackLogger'; import { AutoCloseOnHoldScheduler } from '../lib/AutoCloseOnHoldScheduler'; +import { callbackLogger } from '../../../../../app/livechat/server/lib/logger'; type LivechatCloseCallbackParams = { room: IOmnichannelRoom; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/resumeOnHold.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/resumeOnHold.ts index 8682f03852b..de6b0861d87 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/resumeOnHold.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/resumeOnHold.ts @@ -4,8 +4,8 @@ import { LivechatRooms, LivechatVisitors, Users } from '@rocket.chat/models'; import { OmnichannelEEService } from '@rocket.chat/core-services'; import { callbacks } from '../../../../../lib/callbacks'; -import { callbackLogger } from '../../../../../app/livechat/server/lib/callbackLogger'; import { i18n } from '../../../../../server/lib/i18n'; +import { callbackLogger } from '../../../../../app/livechat/server/lib/logger'; const resumeOnHoldCommentAndUser = async (room: IOmnichannelRoom): Promise<{ comment: string; resumedBy: IUser }> => { const { diff --git a/apps/meteor/ee/app/livechat-enterprise/server/startup.ts b/apps/meteor/ee/app/livechat-enterprise/server/startup.ts index 26a7cacce4c..7ba2479dd2f 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/startup.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/startup.ts @@ -8,6 +8,7 @@ import { MultipleBusinessHoursBehavior } from './business-hour/Multiple'; import { SingleBusinessHourBehavior } from '../../../../app/livechat/server/business-hour/Single'; import { businessHourManager } from '../../../../app/livechat/server/business-hour'; import { resetDefaultBusinessHourIfNeeded } from './business-hour/Helper'; +import { logger } from './lib/logger'; const visitorActivityMonitor = new VisitorInactivityMonitor(); const businessHours = { @@ -31,12 +32,15 @@ Meteor.startup(async function () { await updatePredictedVisitorAbandonment(); }); settings.change('Livechat_business_hour_type', async (value) => { + logger.debug(`Changing business hour type to ${value}`); if (!Object.keys(businessHours).includes(value)) { + logger.error(`Invalid business hour type ${value}`); return; } businessHourManager.registerBusinessHourBehavior(businessHours[value as keyof typeof businessHours]); if (settings.get('Livechat_enable_business_hours')) { await businessHourManager.startManager(); + logger.debug(`Business hour manager started`); } }); diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index 32a4e59a7b4..f84b927c89e 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -61,6 +61,7 @@ interface EventLikeCallbackSignatures { 'livechat:afterReturnRoomAsInquiry': (params: { room: IRoom }) => void; 'livechat.setUserStatusLivechat': (params: { userId: IUser['_id']; status: OmnichannelAgentStatus }) => void; 'livechat.agentStatusChanged': (params: { userId: IUser['_id']; status: OmnichannelAgentStatus }) => void; + 'livechat.onNewAgentCreated': (agentId: string) => void; 'livechat.afterTakeInquiry': (inq: InquiryWithAgentInfo, agent: { agentId: string; username: string }) => void; 'afterAddedToRoom': (params: { user: IUser; inviter?: IUser }, room: IRoom) => void; 'beforeAddedToRoom': (params: { user: IUser; inviter: IUser }) => void; @@ -91,6 +92,7 @@ interface EventLikeCallbackSignatures { 'afterValidateLogin': (login: { user: IUser }) => void; 'afterJoinRoom': (user: IUser, room: IRoom) => void; 'beforeCreateRoom': (data: { type: IRoom['t']; extraData: { encrypted: boolean } }) => void; + 'afterSaveUser': ({ user, oldUser }: { user: IUser; oldUser: IUser | null }) => void; } /** diff --git a/apps/meteor/server/models/raw/Users.js b/apps/meteor/server/models/raw/Users.js index f43a1522a54..f8ae05e11d1 100644 --- a/apps/meteor/server/models/raw/Users.js +++ b/apps/meteor/server/models/raw/Users.js @@ -842,9 +842,6 @@ export class UsersRaw extends BaseRaw { }; const update = { - $set: { - statusLivechat: 'available', - }, $addToSet: { openBusinessHours: { $each: businessHourIds }, }, @@ -878,9 +875,6 @@ export class UsersRaw extends BaseRaw { }; const update = { - $set: { - statusLivechat: 'available', - }, $addToSet: { openBusinessHours: businessHourId, }, @@ -985,15 +979,14 @@ export class UsersRaw extends BaseRaw { } async isAgentWithinBusinessHours(agentId) { - return ( - (await this.find({ - _id: agentId, - openBusinessHours: { - $exists: true, - $not: { $size: 0 }, - }, - }).count()) > 0 - ); + const query = { + _id: agentId, + openBusinessHours: { + $exists: true, + $not: { $size: 0 }, + }, + }; + return (await this.col.countDocuments(query)) > 0; } removeBusinessHoursFromAllUsers() { @@ -2887,4 +2880,20 @@ export class UsersRaw extends BaseRaw { countRoomMembers(roomId) { return this.col.countDocuments({ __rooms: roomId, active: true }); } + + removeAgent(_id) { + const update = { + $set: { + operator: false, + }, + $unset: { + livechat: 1, + statusLivechat: 1, + extension: 1, + openBusinessHours: 1, + }, + }; + + return this.updateOne({ _id }, update); + } } diff --git a/apps/meteor/tests/data/livechat/businessHours.ts b/apps/meteor/tests/data/livechat/businessHours.ts index 59b232a38d2..73ccdf75d09 100644 --- a/apps/meteor/tests/data/livechat/businessHours.ts +++ b/apps/meteor/tests/data/livechat/businessHours.ts @@ -1,7 +1,9 @@ -import { ILivechatBusinessHour } from "@rocket.chat/core-typings"; +import { ILivechatBusinessHour, LivechatBusinessHourTypes } from "@rocket.chat/core-typings"; import { api, credentials, methodCall, request } from "../api-data"; import { updateEESetting, updateSetting } from "../permissions.helper" +import { saveBusinessHour } from "./business-hours"; import moment from "moment"; + type ISaveBhApiWorkHour = Omit & { workHours: { day: string, start: string, finish: string, open: boolean }[] } & { departmentsToApplyBusinessHour?: string } & { timezoneName: string }; export const makeDefaultBusinessHourActiveAndClosed = async () => { @@ -76,6 +78,22 @@ export const disableDefaultBusinessHour = async () => { }); } +export const getDefaultBusinessHour = async (): Promise => { + const response = await request.get(api('livechat/business-hour')).set(credentials).query({ type: LivechatBusinessHourTypes.DEFAULT }).expect(200); + return response.body.businessHour; +}; + +export const openOrCloseBusinessHour = async (businessHour: ILivechatBusinessHour, open: boolean) => { + const enabledBusinessHour = { + ...businessHour, + timezoneName: businessHour.timezone.name, + workHours: getWorkHours(open), + departmentsToApplyBusinessHour: businessHour.departments?.map((department) => department._id).join(',') || '', + } + + await saveBusinessHour(enabledBusinessHour as any); +} + export const getWorkHours = (open = true): ISaveBhApiWorkHour['workHours'] => { const workHours: ISaveBhApiWorkHour['workHours'] = []; diff --git a/apps/meteor/tests/data/livechat/users.ts b/apps/meteor/tests/data/livechat/users.ts index 401ba463e57..7a5dc23b4cc 100644 --- a/apps/meteor/tests/data/livechat/users.ts +++ b/apps/meteor/tests/data/livechat/users.ts @@ -3,6 +3,7 @@ import type { IUser } from "@rocket.chat/core-typings"; import { password } from "../user"; import { createUser, login } from "../users.helper"; import { createAgent, makeAgentAvailable } from "./rooms"; +import { api, credentials, request } from "../api-data"; export const createBotAgent = async (): Promise<{ credentials: { 'X-Auth-Token': string; 'X-User-Id': string; }; @@ -23,3 +24,8 @@ export const createBotAgent = async (): Promise<{ export const getRandomVisitorToken = (): string => faker.string.alphanumeric(17); +export const removeAgent = async (userId: string): Promise => { + await request.delete(api(`livechat/users/agent/${userId}`)) + .set(credentials) + .expect(200); +} diff --git a/apps/meteor/tests/end-to-end/api/livechat/19-business-hours.ts b/apps/meteor/tests/end-to-end/api/livechat/19-business-hours.ts index eb5393920c2..d2e41fa11be 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/19-business-hours.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/19-business-hours.ts @@ -1,14 +1,20 @@ /* eslint-env mocha */ -import { LivechatBusinessHourTypes, LivechatBusinessHourBehaviors } from '@rocket.chat/core-typings'; +import type { ILivechatAgent, ILivechatBusinessHour } from '@rocket.chat/core-typings'; +import { ILivechatAgentStatus, LivechatBusinessHourBehaviors, LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { getCredentials, api, request, credentials } from '../../../data/api-data'; import { saveBusinessHour } from '../../../data/livechat/business-hours'; -import { updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { updateEESetting, updatePermission, updateSetting } from '../../../data/permissions.helper'; import { IS_EE } from '../../../e2e/config/constants'; +import { createUser, deleteUser, getMe, login } from '../../../data/users.helper'; import { createAgent, makeAgentAvailable } from '../../../data/livechat/rooms'; -import { getWorkHours } from '../../../data/livechat/businessHours'; +import { sleep } from '../../../../lib/utils/sleep'; +import { getDefaultBusinessHour, openOrCloseBusinessHour, getWorkHours } from '../../../data/livechat/businessHours'; +import type { IUserCredentialsHeader } from '../../../data/user'; +import { password } from '../../../data/user'; +import { removeAgent } from '../../../data/livechat/users'; describe('[CE] LIVECHAT - business hours', function () { this.retries(0); @@ -226,4 +232,78 @@ describe('[CE] LIVECHAT - business hours', function () { expect(result).to.have.property('error'); }); }); + + describe('BH behavior upon new agent creation/deletion', () => { + let defaultBH: ILivechatBusinessHour; + let agent: ILivechatAgent; + let agentCredentials: IUserCredentialsHeader; + + before(async () => { + await updateSetting('Livechat_enable_business_hours', true); + await updateEESetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.SINGLE); + // wait for callbacks to run + await sleep(2000); + + defaultBH = await getDefaultBusinessHour(); + await openOrCloseBusinessHour(defaultBH, true); + + agent = await createUser(); + agentCredentials = await login(agent.username, password); + }); + + it('should create a new agent and verify if it is assigned to the default business hour which is open', async () => { + agent = await createAgent(agent.username); + + const latestAgent: ILivechatAgent = await getMe(agentCredentials as any); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(defaultBH._id); + }); + + it('should create a new agent and verify if it is assigned to the default business hour which is closed', async () => { + await openOrCloseBusinessHour(defaultBH, false); + + const newUser: ILivechatAgent = await createUser(); + const newUserCredentials = await login(newUser.username, password); + await createAgent(newUser.username); + + const latestAgent: ILivechatAgent = await getMe(newUserCredentials); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.undefined; + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.NOT_AVAILABLE); + }); + + it('should verify if agent is assigned to BH when it is opened', async () => { + // first verify if agent is not assigned to any BH + let latestAgent: ILivechatAgent = await getMe(agentCredentials as any); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(0); + expect(latestAgent.statusLivechat).to.be.equal(ILivechatAgentStatus.NOT_AVAILABLE); + + // now open BH + await openOrCloseBusinessHour(defaultBH, true); + + // verify if agent is assigned to BH + latestAgent = await getMe(agentCredentials as any); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(latestAgent?.openBusinessHours?.[0]).to.be.equal(defaultBH._id); + + // verify if agent is able to make themselves available + await makeAgentAvailable(agentCredentials as any); + }); + + it('should verify if BH related props are cleared when an agent is deleted', async () => { + await removeAgent(agent._id); + + const latestAgent: ILivechatAgent = await getMe(agentCredentials as any); + expect(latestAgent).to.be.an('object'); + expect(latestAgent.openBusinessHours).to.be.undefined; + expect(latestAgent.statusLivechat).to.be.undefined; + }); + + after(async () => { + await deleteUser(agent._id); + }); + }); }); diff --git a/packages/core-typings/src/ILivechatAgent.ts b/packages/core-typings/src/ILivechatAgent.ts index 35e5763e57c..68a99c2723d 100644 --- a/packages/core-typings/src/ILivechatAgent.ts +++ b/packages/core-typings/src/ILivechatAgent.ts @@ -13,4 +13,6 @@ export interface ILivechatAgent extends IUser { livechatCount: number; lastRoutingTime: Date; livechatStatusSystemModified?: boolean; + + openBusinessHours?: string[]; } diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index bc279190ea8..5900b5a1409 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -8,6 +8,7 @@ import type { ILoginToken, IPersonalAccessToken, AtLeast, + ILivechatAgentStatus, } from '@rocket.chat/core-typings'; import type { FindPaginated, IBaseModel } from './IBaseModel'; @@ -92,7 +93,7 @@ export interface IUsersModel extends IBaseModel { setLastRoutingTime(userId: any): Promise; - setLivechatStatusIf(userId: any, status: any, conditions?: any, extraFields?: any): Promise; + setLivechatStatusIf(userId: string, status: ILivechatAgentStatus, conditions?: any, extraFields?: any): Promise; getAgentAndAmountOngoingChats( userId: any, ): Promise<{ agentId: string; username: string; lastAssignTime: Date; lastRoutingTime: Date; queueInfo: { chats: number } }>; @@ -128,7 +129,7 @@ export interface IUsersModel extends IBaseModel { openAgentBusinessHoursByBusinessHourIdsAndAgentId(businessHourIds: any, agentId: any): any; - addBusinessHourByAgentIds(agentIds: any, businessHourId: any): any; + addBusinessHourByAgentIds(agentIds: string[], businessHourId: string): any; removeBusinessHourByAgentIds(agentIds: any, businessHourId: any): any; @@ -142,7 +143,7 @@ export interface IUsersModel extends IBaseModel { setLivechatStatusActiveBasedOnBusinessHours(userId: any): any; - isAgentWithinBusinessHours(agentId: any): Promise; + isAgentWithinBusinessHours(agentId: string): Promise; removeBusinessHoursFromAllUsers(): any; @@ -253,7 +254,7 @@ export interface IUsersModel extends IBaseModel { countAgents(): Promise; getNextAgent(ignoreAgentId?: string, extraQuery?: Filter): Promise<{ agentId: string; username: string } | null>; getNextBotAgent(ignoreAgentId?: string): Promise<{ agentId: string; username: string } | null>; - setLivechatStatus(userId: string, status: UserStatus): Promise; + setLivechatStatus(userId: string, status: ILivechatAgentStatus): Promise; setLivechatData(userId: string, data?: Record): Promise; closeOffice(): Promise; openOffice(): Promise; @@ -374,4 +375,5 @@ export interface IUsersModel extends IBaseModel { countRoomMembers(roomId: string): Promise; countRemote(options?: FindOptions): Promise; findOneByImportId(importId: string, options?: FindOptions): Promise; + removeAgent(_id: string): Promise; }