From b837cb9f2a00979934861818e3f07fe357dc9b70 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 5 Jul 2023 13:52:08 -0600 Subject: [PATCH] fix: Remove the association between BH and disabled/archived departments (#29543) Co-authored-by: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com> Co-authored-by: Guilherme Gazzo <5263975+ggazzo@users.noreply.github.com> --- .changeset/funny-coins-trade.md | 6 + .changeset/rotten-spoons-teach.md | 6 + .../imports/server/rest/departments.ts | 6 +- .../business-hour/AbstractBusinessHour.ts | 4 +- .../business-hour/BusinessHourManager.ts | 60 ++- .../livechat/server/business-hour/Single.ts | 8 + .../app/livechat/server/lib/Livechat.js | 8 +- .../AutoCompleteDepartmentMultiple.tsx | 6 +- .../server/business-hour/Helper.ts | 18 +- .../server/business-hour/Multiple.ts | 104 +++- .../server/business-hour/lib/business-hour.ts | 19 +- .../server/lib/LivechatEnterprise.ts | 8 +- .../app/livechat-enterprise/server/startup.ts | 2 +- .../additionalForms/BusinessHoursMultiple.js | 2 +- apps/meteor/lib/callbacks.ts | 2 + .../models/raw/LivechatBusinessHours.ts | 4 + .../server/models/raw/LivechatDepartment.ts | 78 ++- .../models/raw/LivechatDepartmentAgents.ts | 10 +- .../tests/data/livechat/business-hours.ts | 12 - .../tests/data/livechat/businessHours.ts | 76 ++- apps/meteor/tests/data/livechat/department.ts | 68 ++- apps/meteor/tests/data/livechat/rooms.ts | 16 - .../end-to-end/api/livechat/10-departments.ts | 3 +- .../api/livechat/19-business-hours.ts | 466 +++++++++++++++++- .../src/models/ILivechatBusinessHoursModel.ts | 2 + .../models/ILivechatDepartmentAgentsModel.ts | 2 + .../src/models/ILivechatDepartmentModel.ts | 9 + 27 files changed, 908 insertions(+), 97 deletions(-) create mode 100644 .changeset/funny-coins-trade.md create mode 100644 .changeset/rotten-spoons-teach.md delete mode 100644 apps/meteor/tests/data/livechat/business-hours.ts diff --git a/.changeset/funny-coins-trade.md b/.changeset/funny-coins-trade.md new file mode 100644 index 000000000000..e7fffad5d960 --- /dev/null +++ b/.changeset/funny-coins-trade.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +--- + +Fixed a problem where disabled department agent's where still being activated when applicable business hours met. diff --git a/.changeset/rotten-spoons-teach.md b/.changeset/rotten-spoons-teach.md new file mode 100644 index 000000000000..bd046064eb13 --- /dev/null +++ b/.changeset/rotten-spoons-teach.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +--- + +Fixed logic around Default Business Hours where agents from disabled/archived departments where being omitted from processing at closing time diff --git a/apps/meteor/app/livechat/imports/server/rest/departments.ts b/apps/meteor/app/livechat/imports/server/rest/departments.ts index f05b8026bf53..4f7886f83792 100644 --- a/apps/meteor/app/livechat/imports/server/rest/departments.ts +++ b/apps/meteor/app/livechat/imports/server/rest/departments.ts @@ -192,11 +192,9 @@ API.v1.addRoute( }, { async post() { - if (await Livechat.archiveDepartment(this.urlParams._id)) { - return API.v1.success(); - } + await Livechat.archiveDepartment(this.urlParams._id); - return API.v1.failure(); + return API.v1.success(); }, }, ); diff --git a/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts b/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts index 80cdc5aea296..aeaf6bc1f287 100644 --- a/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts +++ b/apps/meteor/app/livechat/server/business-hour/AbstractBusinessHour.ts @@ -16,7 +16,9 @@ export interface IBusinessHourBehavior { onDisableBusinessHours(): Promise; onAddAgentToDepartment(options?: { departmentId: string; agentsId: string[] }): Promise; onRemoveAgentFromDepartment(options?: Record): Promise; - onRemoveDepartment(department?: ILivechatDepartment): Promise; + onRemoveDepartment(options: { department: ILivechatDepartment; agentsIds: string[] }): Promise; + onDepartmentDisabled(department?: ILivechatDepartment): Promise; + onDepartmentArchived(department: Pick): Promise; onStartBusinessHours(): Promise; afterSaveBusinessHours(businessHourData: ILivechatBusinessHour): Promise; allowAgentChangeServiceStatus(agentId: string): Promise; diff --git a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts index 7e72a1756358..6b14f82856a0 100644 --- a/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts +++ b/apps/meteor/app/livechat/server/business-hour/BusinessHourManager.ts @@ -2,11 +2,12 @@ import moment from 'moment'; import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; import type { AgendaCronJobs } from '@rocket.chat/cron'; -import { Users } from '@rocket.chat/models'; +import { LivechatDepartment, Users } from '@rocket.chat/models'; import type { IBusinessHourBehavior, IBusinessHourType } from './AbstractBusinessHour'; import { settings } from '../../../settings/server'; import { callbacks } from '../../../../lib/callbacks'; +import { closeBusinessHour } from '../../../../ee/app/livechat-enterprise/server/business-hour/Helper'; import { businessHourLogger } from '../lib/logger'; export class BusinessHourManager { @@ -28,6 +29,7 @@ export class BusinessHourManager { await this.createCronJobsForWorkHours(); businessHourLogger.debug('Cron jobs created, setting up callbacks'); this.setupCallbacks(); + await this.cleanupDisabledDepartmentReferences(); await this.behavior.onStartBusinessHours(); } @@ -38,6 +40,40 @@ export class BusinessHourManager { await this.behavior.onDisableBusinessHours(); } + async restartManager(): Promise { + await this.stopManager(); + await this.startManager(); + } + + async cleanupDisabledDepartmentReferences(): Promise { + // Get business hours with departments enabled and disabled + const bhWithDepartments = await LivechatDepartment.getBusinessHoursWithDepartmentStatuses(); + + if (!bhWithDepartments.length) { + // If there are no bh, skip + return; + } + + for await (const { _id: businessHourId, validDepartments, invalidDepartments } of bhWithDepartments) { + if (!invalidDepartments.length) { + continue; + } + + // If there are no enabled departments, close the business hour + const allDepsAreDisabled = validDepartments.length === 0 && invalidDepartments.length > 0; + if (allDepsAreDisabled) { + const businessHour = await this.getBusinessHour(businessHourId, LivechatBusinessHourTypes.CUSTOM); + if (!businessHour) { + continue; + } + await closeBusinessHour(businessHour); + } + + // Remove business hour from disabled departments + await LivechatDepartment.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId(invalidDepartments, businessHourId); + } + } + async allowAgentChangeServiceStatus(agentId: string): Promise { if (!settings.get('Livechat_enable_business_hours')) { return true; @@ -88,6 +124,14 @@ export class BusinessHourManager { return Users.setLivechatStatusActiveBasedOnBusinessHours(agentId); } + async restartCronJobsIfNecessary(): Promise { + if (!settings.get('Livechat_enable_business_hours')) { + return; + } + + await this.createCronJobsForWorkHours(); + } + private setupCallbacks(): void { callbacks.add( 'livechat.removeAgentDepartment', @@ -107,6 +151,18 @@ export class BusinessHourManager { callbacks.priority.HIGH, 'business-hour-livechat-on-save-agent-department', ); + callbacks.add( + 'livechat.afterDepartmentDisabled', + this.behavior.onDepartmentDisabled.bind(this), + callbacks.priority.HIGH, + 'business-hour-livechat-on-department-disabled', + ); + callbacks.add( + 'livechat.afterDepartmentArchived', + this.behavior.onDepartmentArchived.bind(this), + callbacks.priority.HIGH, + 'business-hour-livechat-on-department-archived', + ); callbacks.add( 'livechat.onNewAgentCreated', this.behavior.onNewAgentCreated.bind(this), @@ -119,6 +175,8 @@ export class BusinessHourManager { 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.afterDepartmentDisabled', 'business-hour-livechat-on-department-disabled'); + callbacks.remove('livechat.afterDepartmentArchived', 'business-hour-livechat-on-department-archived'); callbacks.remove('livechat.onNewAgentCreated', 'business-hour-livechat-on-agent-created'); } diff --git a/apps/meteor/app/livechat/server/business-hour/Single.ts b/apps/meteor/app/livechat/server/business-hour/Single.ts index 2996d5f1c2c7..b351c480ab28 100644 --- a/apps/meteor/app/livechat/server/business-hour/Single.ts +++ b/apps/meteor/app/livechat/server/business-hour/Single.ts @@ -49,4 +49,12 @@ export class SingleBusinessHourBehavior extends AbstractBusinessHourBehavior imp onRemoveDepartment(): Promise { return Promise.resolve(); } + + onDepartmentDisabled(): Promise { + return Promise.resolve(); + } + + onDepartmentArchived(): Promise { + return Promise.resolve(); + } } diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index 1fcf39c30a31..950e8450df4b 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -740,6 +740,8 @@ export const Livechat = { }); } + // TODO: these kind of actions should be on events instead of here + await LivechatDepartmentAgents.enableAgentsByDepartmentId(_id); return LivechatDepartmentRaw.unarchiveDepartment(_id); }, @@ -754,7 +756,11 @@ export const Livechat = { }); } - return LivechatDepartmentRaw.archiveDepartment(_id); + await LivechatDepartmentAgents.disableAgentsByDepartmentId(_id); + await LivechatDepartmentRaw.archiveDepartment(_id); + + this.logger.debug({ msg: 'Running livechat.afterDepartmentArchived callback for department:', departmentId: _id }); + await callbacks.run('livechat.afterDepartmentArchived', department); }, showConnecting() { diff --git a/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx b/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx index ea08b18ca609..50d53da351bc 100644 --- a/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx +++ b/apps/meteor/client/components/AutoCompleteDepartmentMultiple.tsx @@ -13,12 +13,14 @@ type AutoCompleteDepartmentMultipleProps = { onChange: (value: PaginatedMultiSelectOption[]) => void; onlyMyDepartments?: boolean; showArchived?: boolean; + enabled?: boolean; }; const AutoCompleteDepartmentMultiple = ({ value, onlyMyDepartments = false, showArchived = false, + enabled = false, onChange = () => undefined, }: AutoCompleteDepartmentMultipleProps) => { const t = useTranslation(); @@ -28,8 +30,8 @@ const AutoCompleteDepartmentMultiple = ({ const { itemsList: departmentsList, loadMoreItems: loadMoreDepartments } = useDepartmentsList( useMemo( - () => ({ filter: debouncedDepartmentsFilter, onlyMyDepartments, ...(showArchived && { showArchived: true }) }), - [debouncedDepartmentsFilter, onlyMyDepartments, showArchived], + () => ({ filter: debouncedDepartmentsFilter, onlyMyDepartments, ...(showArchived && { showArchived: true }), enabled }), + [debouncedDepartmentsFilter, enabled, onlyMyDepartments, showArchived], ), ); 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 6575f13bbde2..b51294287d55 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 @@ -7,9 +7,12 @@ 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) => dept.agentId); + // Fetch departments with agents excluding archived ones (disabled ones still can be tied to business hours) + // Then find the agents that are not in any of those departments + + const departmentIds = (await LivechatDepartment.findNotArchived({ projection: { _id: 1 } }).toArray()).map(({ _id }) => _id); + + const agentIdsWithDepartment = await LivechatDepartmentAgents.findAllAgentsConnectedToListOfDepartments(departmentIds); const agentIdsWithoutDepartment = ( await Users.findUsersInRolesWithQuery( @@ -62,7 +65,10 @@ const getAgentIdsToHandle = async (businessHour: Pick dept.agentId); }; -export const openBusinessHour = async (businessHour: Pick): Promise => { +export const openBusinessHour = async ( + businessHour: Pick, + updateLivechatStatus = true, +): Promise => { const agentIds = await getAgentIdsToHandle(businessHour); businessHourLogger.debug({ msg: 'Opening business hour', @@ -72,7 +78,9 @@ export const openBusinessHour = async (businessHour: Pick): Promise => { 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 0fa768453e3a..3e1eb7aea652 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,12 +1,14 @@ import moment from 'moment'; import type { ILivechatDepartment, ILivechatBusinessHour } from '@rocket.chat/core-typings'; -import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models'; +import { LivechatDepartment, LivechatDepartmentAgents, Users } 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 } from '../../../../../app/livechat/server/business-hour/Helper'; import { closeBusinessHour, openBusinessHour, removeBusinessHourByAgentIds } from './Helper'; -import { businessHourLogger } from '../../../../../app/livechat/server/lib/logger'; +import { bhLogger } from '../lib/logger'; +import { settings } from '../../../../../app/settings/server'; +import { businessHourManager } from '../../../../../app/livechat/server/business-hour'; interface IBusinessHoursExtraProperties extends ILivechatBusinessHour { timezoneName: string; @@ -19,6 +21,8 @@ export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior this.onAddAgentToDepartment = this.onAddAgentToDepartment.bind(this); this.onRemoveAgentFromDepartment = this.onRemoveAgentFromDepartment.bind(this); this.onRemoveDepartment = this.onRemoveDepartment.bind(this); + this.onDepartmentArchived = this.onDepartmentArchived.bind(this); + this.onDepartmentDisabled = this.onDepartmentDisabled.bind(this); this.onNewAgentCreated = this.onNewAgentCreated.bind(this); } @@ -36,7 +40,7 @@ export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior }, }); const businessHoursToOpen = await filterBusinessHoursThatMustBeOpened(activeBusinessHours); - businessHourLogger.debug({ + bhLogger.debug({ msg: 'Starting Multiple Business Hours', totalBusinessHoursToOpen: businessHoursToOpen.length, top10BusinessHoursToOpen: businessHoursToOpen.slice(0, 10), @@ -125,13 +129,100 @@ export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior return this.handleRemoveAgentsFromDepartments(department, agentsId, options); } - async onRemoveDepartment(options: Record = {}): Promise { + async onRemoveDepartment(options: { department: ILivechatDepartment; agentsIds: string[] }): Promise { + bhLogger.debug(`onRemoveDepartment: department ${options.department._id} removed`); const { department, agentsIds } = options; if (!department || !agentsIds?.length) { return options; } - const deletedDepartment = LivechatDepartment.trashFindOneById(department._id); - return this.handleRemoveAgentsFromDepartments(deletedDepartment, agentsIds, options); + return this.onDepartmentDisabled(department); + } + + async onDepartmentDisabled(department: ILivechatDepartment): Promise { + if (!department.businessHourId) { + bhLogger.debug({ + msg: 'onDepartmentDisabled: department has no business hour', + departmentId: department._id, + }); + return; + } + + // Get business hour + let businessHour = await this.BusinessHourRepository.findOneById(department.businessHourId); + if (!businessHour) { + bhLogger.error({ + msg: 'onDepartmentDisabled: business hour not found', + businessHourId: department.businessHourId, + }); + return; + } + + // Unlink business hour from department + await LivechatDepartment.removeBusinessHourFromDepartmentsByIdsAndBusinessHourId([department._id], businessHour._id); + + // cleanup user's cache for default business hour and this business hour + const defaultBH = await this.BusinessHourRepository.findOneDefaultBusinessHour(); + if (!defaultBH) { + bhLogger.error('onDepartmentDisabled: default business hour not found'); + throw new Error('Default business hour not found'); + } + await this.UsersRepository.closeAgentsBusinessHoursByBusinessHourIds([businessHour._id, defaultBH._id]); + + // If i'm the only one, disable the business hour + const imTheOnlyOne = !(await LivechatDepartment.countByBusinessHourIdExcludingDepartmentId(businessHour._id, department._id)); + if (imTheOnlyOne) { + bhLogger.warn({ + msg: 'onDepartmentDisabled: department is the only one on business hour, disabling it', + departmentId: department._id, + businessHourId: businessHour._id, + }); + await this.BusinessHourRepository.disableBusinessHour(businessHour._id); + + businessHour = await this.BusinessHourRepository.findOneById(department.businessHourId); + if (!businessHour) { + bhLogger.error({ + msg: 'onDepartmentDisabled: business hour not found', + businessHourId: department.businessHourId, + }); + + throw new Error(`Business hour ${department.businessHourId} not found`); + } + } + + // start default business hour and this BH if needed + if (!settings.get('Livechat_enable_business_hours')) { + bhLogger.debug(`onDepartmentDisabled: business hours are disabled. skipping`); + return; + } + const businessHourToOpen = await filterBusinessHoursThatMustBeOpened([businessHour, defaultBH]); + for await (const bh of businessHourToOpen) { + bhLogger.debug({ + msg: 'onDepartmentDisabled: opening business hour', + businessHourId: bh._id, + }); + await openBusinessHour(bh, false); + } + + await Users.updateLivechatStatusBasedOnBusinessHours(); + + await businessHourManager.restartCronJobsIfNecessary(); + + bhLogger.debug({ + msg: 'onDepartmentDisabled: successfully processed department disabled event', + departmentId: department._id, + }); + } + + async onDepartmentArchived(department: Pick): Promise { + bhLogger.debug('Processing department archived event on multiple business hours', department); + const dbDepartment = await LivechatDepartment.findOneById(department._id, { projection: { businessHourId: 1, _id: 1 } }); + + if (!dbDepartment) { + bhLogger.error(`No department found with id: ${department._id} when archiving it`); + return; + } + + return this.onDepartmentDisabled(dbDepartment); } allowAgentChangeServiceStatus(agentId: string): Promise { @@ -147,7 +238,6 @@ export class MultipleBusinessHoursBehavior extends AbstractBusinessHourBehavior } // TODO: We're doing a full fledged aggregation with lookups and getting the whole array just for getting the length? :( if (!(await LivechatDepartmentAgents.findAgentsByAgentIdAndBusinessHourId(agentId, department.businessHourId)).length) { - // eslint-disable-line no-await-in-loop agentIdsToRemoveCurrentBusinessHour.push(agentId); } } diff --git a/apps/meteor/ee/app/livechat-enterprise/server/business-hour/lib/business-hour.ts b/apps/meteor/ee/app/livechat-enterprise/server/business-hour/lib/business-hour.ts index 216a345a7ee3..f82eb65f6624 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/business-hour/lib/business-hour.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/business-hour/lib/business-hour.ts @@ -1,6 +1,6 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; -import { LivechatBusinessHours } from '@rocket.chat/models'; +import { LivechatBusinessHours, LivechatDepartment } from '@rocket.chat/models'; import { hasPermissionAsync } from '../../../../../../app/authorization/server/functions/hasPermission'; import type { IPaginatedResponse, IPagination } from '../../api/lib/definition'; @@ -26,8 +26,23 @@ export async function findBusinessHours(userId: string, { offset, count, sort }: const [businessHours, total] = await Promise.all([cursor.toArray(), totalCount]); + // add departments to businessHours + const businessHoursWithDepartments = await Promise.all( + businessHours.map(async (businessHour) => { + const currentDepartments = await LivechatDepartment.findByBusinessHourId(businessHour._id, { + projection: { _id: 1 }, + }).toArray(); + + if (currentDepartments.length) { + businessHour.departments = currentDepartments; + } + + return businessHour; + }), + ); + return { - businessHours, + businessHours: businessHoursWithDepartments, count: businessHours.length, offset, total, diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts index 995ac4f8caba..653dfbda74e4 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts @@ -26,6 +26,7 @@ import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingMa import { settings } from '../../../../../app/settings/server'; import { queueLogger } from './logger'; import { getInquirySortMechanismSetting } from '../../../../../app/livechat/server/lib/settings'; +import { callbacks } from '../../../../../lib/callbacks'; export const LivechatEnterprise = { async addMonitor(username: string) { @@ -201,7 +202,7 @@ export const LivechatEnterprise = { ) { check(_id, Match.Maybe(String)); - const department = _id ? await LivechatDepartmentRaw.findOneById(_id, { projection: { _id: 1, archived: 1 } }) : null; + const department = _id ? await LivechatDepartmentRaw.findOneById(_id, { projection: { _id: 1, archived: 1, enabled: 1 } }) : null; if (!hasLicense('livechat-enterprise')) { const totalDepartments = await LivechatDepartmentRaw.countTotal(); @@ -278,6 +279,11 @@ export const LivechatEnterprise = { await updateDepartmentAgents(departmentDB._id, departmentAgents, departmentDB.enabled); } + // Disable event + if (department?.enabled && !departmentDB?.enabled) { + void callbacks.run('livechat.afterDepartmentDisabled', departmentDB); + } + return departmentDB; }, diff --git a/apps/meteor/ee/app/livechat-enterprise/server/startup.ts b/apps/meteor/ee/app/livechat-enterprise/server/startup.ts index 7ba2479dd2fb..0b63ae2c587a 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/startup.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/startup.ts @@ -39,7 +39,7 @@ Meteor.startup(async function () { } businessHourManager.registerBusinessHourBehavior(businessHours[value as keyof typeof businessHours]); if (settings.get('Livechat_enable_business_hours')) { - await businessHourManager.startManager(); + await businessHourManager.restartManager(); logger.debug(`Business hour manager started`); } }); diff --git a/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursMultiple.js b/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursMultiple.js index 4606e3f790d0..ba168826b435 100644 --- a/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursMultiple.js +++ b/apps/meteor/ee/client/omnichannel/additionalForms/BusinessHoursMultiple.js @@ -30,7 +30,7 @@ const BusinessHoursMultiple = ({ values = {}, handlers = {}, className }) => { {t('Departments')} - + diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index 69913b79e3c5..2fad995dfd1f 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -92,6 +92,8 @@ interface EventLikeCallbackSignatures { 'afterValidateLogin': (login: { user: IUser }) => void; 'afterJoinRoom': (user: IUser, room: IRoom) => void; 'beforeCreateRoom': (data: { type: IRoom['t']; extraData: { encrypted: boolean } }) => void; + 'livechat.afterDepartmentDisabled': (department: ILivechatDepartmentRecord) => void; + 'livechat.afterDepartmentArchived': (department: Pick) => void; 'afterSaveUser': ({ user, oldUser }: { user: IUser; oldUser: IUser | null }) => void; } diff --git a/apps/meteor/server/models/raw/LivechatBusinessHours.ts b/apps/meteor/server/models/raw/LivechatBusinessHours.ts index 1659740a55ef..e17cf27d6006 100644 --- a/apps/meteor/server/models/raw/LivechatBusinessHours.ts +++ b/apps/meteor/server/models/raw/LivechatBusinessHours.ts @@ -171,4 +171,8 @@ export class LivechatBusinessHoursRaw extends BaseRaw imp } return this.col.find(query, options).toArray(); } + + disableBusinessHour(businessHourId: string): Promise { + return this.updateOne({ _id: businessHourId }, { $set: { active: false } }); + } } diff --git a/apps/meteor/server/models/raw/LivechatDepartment.ts b/apps/meteor/server/models/raw/LivechatDepartment.ts index 5b1b4010dd38..2c9997b4b048 100644 --- a/apps/meteor/server/models/raw/LivechatDepartment.ts +++ b/apps/meteor/server/models/raw/LivechatDepartment.ts @@ -83,6 +83,12 @@ export class LivechatDepartmentRaw extends BaseRaw implemen }, sparse: true, }, + { + key: { + archived: 1, + }, + sparse: true, + }, ]; } @@ -123,6 +129,11 @@ export class LivechatDepartmentRaw extends BaseRaw implemen return this.find(query, options); } + countByBusinessHourIdExcludingDepartmentId(businessHourId: string, departmentId: string): Promise { + const query = { businessHourId, _id: { $ne: departmentId } }; + return this.col.countDocuments(query); + } + findEnabledByBusinessHourId(businessHourId: string, options: FindOptions): FindCursor { const query = { businessHourId, enabled: true }; return this.find(query, options); @@ -236,7 +247,12 @@ export class LivechatDepartmentRaw extends BaseRaw implemen await LivechatDepartmentAgents.setDepartmentEnabledByDepartmentId(_id, data.enabled); } - return Object.assign(record, { _id }); + const latestDept = await this.findOneById(_id); + if (!latestDept) { + throw new Error(`Department ${_id} not found`); + } + + return latestDept; } unsetFallbackDepartmentByDepartmentId(departmentId: string): Promise { @@ -354,6 +370,66 @@ export class LivechatDepartmentRaw extends BaseRaw implemen return this.find(query, options); } + findNotArchived(options: FindOptions = {}): FindCursor { + const query = { archived: { $ne: false } }; + + return this.find(query, options); + } + + getBusinessHoursWithDepartmentStatuses(): Promise< + { + _id: string; + validDepartments: string[]; + invalidDepartments: string[]; + }[] + > { + return this.col + .aggregate<{ _id: string; validDepartments: string[]; invalidDepartments: string[] }>([ + { + $match: { + businessHourId: { + $exists: true, + }, + }, + }, + { + $group: { + _id: '$businessHourId', + validDepartments: { + $push: { + $cond: { + if: { + $or: [ + { + $eq: ['$enabled', true], + }, + { + $ne: ['$archived', true], + }, + ], + }, + then: '$_id', + else: '$$REMOVE', + }, + }, + }, + invalidDepartments: { + $push: { + $cond: { + if: { + $or: [{ $eq: ['$enabled', false] }, { $eq: ['$archived', true] }], + }, + then: '$_id', + else: '$$REMOVE', + }, + }, + }, + }, + }, + ]) + .toArray(); + } + checkIfMonitorIsMonitoringDepartmentById(monitorId: string, departmentId: string): Promise { const aggregation = [ { diff --git a/apps/meteor/server/models/raw/LivechatDepartmentAgents.ts b/apps/meteor/server/models/raw/LivechatDepartmentAgents.ts index da709ff1136e..52f946f34835 100644 --- a/apps/meteor/server/models/raw/LivechatDepartmentAgents.ts +++ b/apps/meteor/server/models/raw/LivechatDepartmentAgents.ts @@ -357,8 +357,16 @@ export class LivechatDepartmentAgentsRaw extends BaseRaw { + return this.updateMany({ departmentId }, { $set: { departmentEnabled: false } }); + } + + enableAgentsByDepartmentId(departmentId: string): Promise { + return this.updateMany({ departmentId }, { $set: { departmentEnabled: true } }); + } + findAllAgentsConnectedToListOfDepartments(departmentIds: string[]): Promise { - return this.col.distinct('agentId', { departmentId: { $in: departmentIds } }); + return this.col.distinct('agentId', { departmentId: { $in: departmentIds }, departmentEnabled: true }); } } diff --git a/apps/meteor/tests/data/livechat/business-hours.ts b/apps/meteor/tests/data/livechat/business-hours.ts deleted file mode 100644 index f3335047cca3..000000000000 --- a/apps/meteor/tests/data/livechat/business-hours.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ILivechatBusinessHour } from '@rocket.chat/core-typings'; -import { credentials, methodCall, request } from '../api-data'; - -export const saveBusinessHour = async (businessHour: ILivechatBusinessHour) => { - const { body } = await request - .post(methodCall('livechat:saveBusinessHour')) - .set(credentials) - .send({ message: JSON.stringify({ params: [businessHour], msg: 'method', method: 'livechat:saveBusinessHour', id: '101' }) }) - .expect(200); - - return JSON.parse(body.message); -}; diff --git a/apps/meteor/tests/data/livechat/businessHours.ts b/apps/meteor/tests/data/livechat/businessHours.ts index 73ccdf75d096..8ce1d91a79b9 100644 --- a/apps/meteor/tests/data/livechat/businessHours.ts +++ b/apps/meteor/tests/data/livechat/businessHours.ts @@ -1,11 +1,49 @@ 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 }; +// TODO: Migrate to an API call and return the business hour updated/created +export const saveBusinessHour = async (businessHour: ISaveBhApiWorkHour) => { + const { body } = await request + .post(methodCall('livechat:saveBusinessHour')) + .set(credentials) + .send({ message: JSON.stringify({ params: [businessHour], msg: 'method', method: 'livechat:saveBusinessHour', id: '101' }) }) + .expect(200); + + return JSON.parse(body.message); +}; + +export const createCustomBusinessHour = async (departments: string[]): Promise => { + const name = `business-hour-${Date.now()}`; + const businessHour: ISaveBhApiWorkHour = { + name, + active: true, + type: LivechatBusinessHourTypes.CUSTOM, + workHours: getWorkHours(), + timezoneName: 'Asia/Calcutta', + departmentsToApplyBusinessHour: '', + }; + + if (departments.length) { + businessHour.departmentsToApplyBusinessHour = departments.join(','); + } + + await saveBusinessHour(businessHour); + + + const existingBusinessHours: ILivechatBusinessHour[] = await getAllCustomBusinessHours(); + const createdBusinessHour = existingBusinessHours.find((bh) => bh.name === name); + if (!createdBusinessHour) { + throw new Error('Could not create business hour'); + } + + return createdBusinessHour; +}; + + export const makeDefaultBusinessHourActiveAndClosed = async () => { // enable settings await updateSetting('Livechat_enable_business_hours', true); @@ -17,6 +55,7 @@ export const makeDefaultBusinessHourActiveAndClosed = async () => { .set(credentials) .send(); + // TODO: Refactor this to use openOrCloseBusinessHour() instead const workHours = businessHour.workHours as { start: string; finish: string; day: string, open: boolean }[]; const allEnabledWorkHours = workHours.map((workHour) => { workHour.open = true; @@ -53,6 +92,7 @@ export const disableDefaultBusinessHour = async () => { .set(credentials) .send(); + // TODO: Refactor this to use openOrCloseBusinessHour() instead const workHours = businessHour.workHours as { start: string; finish: string; day: string, open: boolean }[]; const allDisabledWorkHours = workHours.map((workHour) => { workHour.open = false; @@ -78,16 +118,47 @@ export const disableDefaultBusinessHour = async () => { }); } +export const removeCustomBusinessHour = async (businessHourId: string) => { + await request + .post(methodCall('livechat:removeBusinessHour')) + .set(credentials) + .send({ message: JSON.stringify({ params: [businessHourId, LivechatBusinessHourTypes.CUSTOM], msg: 'method', method: 'livechat:removeBusinessHour', id: '101' }) }) + .expect(200); +}; + +const getAllCustomBusinessHours = async (): Promise => { + const response = await request.get(api('livechat/business-hours')).set(credentials).expect(200); + return (response.body.businessHours || []).filter((businessHour: ILivechatBusinessHour) => businessHour.type === LivechatBusinessHourTypes.CUSTOM); +}; + + +export const removeAllCustomBusinessHours = async () => { + const existingBusinessHours: ILivechatBusinessHour[] = await getAllCustomBusinessHours(); + + const promises = existingBusinessHours.map((businessHour) => removeCustomBusinessHour(businessHour._id)); + await Promise.all(promises); +}; + 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 getCustomBusinessHourById = async (businessHourId: string): Promise => { + const response = await request.get(api('livechat/business-hour')).set(credentials).query({ type: LivechatBusinessHourTypes.CUSTOM, _id: businessHourId }).expect(200); + return response.body.businessHour; +}; + export const openOrCloseBusinessHour = async (businessHour: ILivechatBusinessHour, open: boolean) => { const enabledBusinessHour = { ...businessHour, timezoneName: businessHour.timezone.name, - workHours: getWorkHours(open), + workHours: getWorkHours().map((workHour) => { + return { + ...workHour, + open, + } + }), departmentsToApplyBusinessHour: businessHour.departments?.map((department) => department._id).join(',') || '', } @@ -102,6 +173,7 @@ export const getWorkHours = (open = true): ISaveBhApiWorkHour['workHours'] => { day: moment().day(i).format('dddd'), start: '00:00', finish: '23:59', + open, }); } diff --git a/apps/meteor/tests/data/livechat/department.ts b/apps/meteor/tests/data/livechat/department.ts index 884ffaeade64..1780a83c8c07 100644 --- a/apps/meteor/tests/data/livechat/department.ts +++ b/apps/meteor/tests/data/livechat/department.ts @@ -1,33 +1,33 @@ import { faker } from '@faker-js/faker'; -import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import type { ILivechatDepartment, IUser, LivechatDepartmentDTO } from '@rocket.chat/core-typings'; import { api, credentials, methodCall, request } from '../api-data'; import { IUserCredentialsHeader, password } from '../user'; import { createUser, login } from '../users.helper'; import { createAgent, makeAgentAvailable } from './rooms'; -import type { DummyResponse } from './utils'; -export const createDepartment = (): Promise => - new Promise((resolve, reject) => { - request - .post(api('livechat/department')) - .send({ - department: { - enabled: false, - email: 'email@email.com', - showOnRegistration: true, - showOnOfflineForm: true, - name: `new department ${Date.now()}`, - description: 'created from api', - }, - }) - .set(credentials) - .end((err: Error, res: DummyResponse) => { - if (err) { - return reject(err); - } - resolve(res.body.department); - }); - }); +export const NewDepartmentData = ((): Partial => ({ + enabled: true, + name: `new department ${Date.now()}`, + description: 'created from api', + showOnRegistration: true, + email: faker.internet.email(), + showOnOfflineForm: true, +}))(); + +export const createDepartment = async (departmentData: Partial = NewDepartmentData): Promise => { + const response = await request.post(api('livechat/department')).set(credentials).send({ + department: departmentData, + }).expect(200); + return response.body.department; +}; + +export const updateDepartment = async (departmentId: string, departmentData: Partial): Promise => { + const response = await request.put(api(`livechat/department/${ departmentId }`)).set(credentials).send({ + department: departmentData, + }).expect(200); + return response.body.department; +}; export const createDepartmentWithMethod = (initialAgents: { agentId: string, username: string }[] = []) => new Promise((resolve, reject) => { @@ -88,3 +88,23 @@ export const addOrRemoveAgentFromDepartment = async (departmentId: string, agent throw new Error('Failed to add or remove agent from department. Status code: ' + response.status + '\n' + response.body); } } + +export const archiveDepartment = async (departmentId: string): Promise => { + await request.post(api(`livechat/department/${ departmentId }/archive`)).set(credentials).expect(200); +} + +export const disableDepartment = async (department: ILivechatDepartment): Promise => { + department.enabled = false; + delete department._updatedAt; + const updatedDepartment = await updateDepartment(department._id, department); + expect(updatedDepartment.enabled).to.be.false; +} + +export const deleteDepartment = async (departmentId: string): Promise => { + await request.delete(api(`livechat/department/${ departmentId }`)).set(credentials).expect(200); +} + +export const getDepartmentById = async (departmentId: string): Promise => { + const response = await request.get(api(`livechat/department/${ departmentId }`)).set(credentials).expect(200); + return response.body.department; +}; diff --git a/apps/meteor/tests/data/livechat/rooms.ts b/apps/meteor/tests/data/livechat/rooms.ts index bceedec83b1c..92d4c9a6a045 100644 --- a/apps/meteor/tests/data/livechat/rooms.ts +++ b/apps/meteor/tests/data/livechat/rooms.ts @@ -106,22 +106,6 @@ export const createDepartment = (agents?: { agentId: string }[], name?: string): }); }; -export const deleteDepartment = (departmentId: string): Promise => { - return new Promise((resolve, reject) => { - request - .delete(api(`livechat/department/${departmentId}`)) - .set(credentials) - .send() - .expect(200) - .end((err: Error, res: DummyResponse) => { - if (err) { - return reject(err); - } - resolve(res.body); - }); - }); -}; - export const createAgent = (overrideUsername?: string): Promise => new Promise((resolve, reject) => { request diff --git a/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts b/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts index 052193fb55fe..1423b5885682 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/10-departments.ts @@ -12,9 +12,8 @@ import { createVisitor, createLivechatRoom, getLivechatRoomInfo, - deleteDepartment, } from '../../../data/livechat/rooms'; -import { createDepartmentWithAnOnlineAgent } from '../../../data/livechat/department'; +import { createDepartmentWithAnOnlineAgent, deleteDepartment } from '../../../data/livechat/department'; import { IS_EE } from '../../../e2e/config/constants'; import { createUser } from '../../../data/users.helper'; import { createMonitor, createUnit } from '../../../data/livechat/units'; 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 d2e41fa11bec..e03306d18df0 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,22 +1,37 @@ /* eslint-env mocha */ -import type { ILivechatAgent, ILivechatBusinessHour } from '@rocket.chat/core-typings'; -import { ILivechatAgentStatus, LivechatBusinessHourBehaviors, LivechatBusinessHourTypes } from '@rocket.chat/core-typings'; +import type { ILivechatAgent, ILivechatBusinessHour, ILivechatDepartment } from '@rocket.chat/core-typings'; +import { LivechatBusinessHourBehaviors, LivechatBusinessHourTypes, ILivechatAgentStatus } 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 { updateEESetting, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { + getDefaultBusinessHour, + removeAllCustomBusinessHours, + saveBusinessHour, + openOrCloseBusinessHour, + createCustomBusinessHour, + getCustomBusinessHourById, + getWorkHours, +} from '../../../data/livechat/businessHours'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updateSetting, updateEESetting } from '../../../data/permissions.helper'; import { IS_EE } from '../../../e2e/config/constants'; +import { + addOrRemoveAgentFromDepartment, + archiveDepartment, + createDepartmentWithAnOnlineAgent, + disableDepartment, + getDepartmentById, + deleteDepartment, +} from '../../../data/livechat/department'; +import { sleep } from '../../../../lib/utils/sleep'; import { createUser, deleteUser, getMe, login } from '../../../data/users.helper'; import { createAgent, makeAgentAvailable } from '../../../data/livechat/rooms'; -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 () { +describe('LIVECHAT - business hours', function () { this.retries(0); before((done) => getCredentials(done)); @@ -28,14 +43,15 @@ describe('[CE] LIVECHAT - business hours', function () { }); let defaultBhId: any; - describe('livechat/business-hour', () => { + describe('[CE] livechat/business-hour', () => { it('should fail when user doesnt have view-livechat-business-hours permission', async () => { - await updatePermission('view-livechat-business-hours', []); + await removePermissionFromAllRoles('view-livechat-business-hours'); const response = await request.get(api('livechat/business-hour')).set(credentials).expect(403); expect(response.body.success).to.be.false; + + await restorePermissionToRoles('view-livechat-business-hours'); }); it('should fail when business hour type is not a valid BH type', async () => { - await updatePermission('view-livechat-business-hours', ['admin', 'livechat-manager']); const response = await request.get(api('livechat/business-hour')).set(credentials).query({ type: 'invalid' }).expect(200); expect(response.body.success).to.be.true; expect(response.body.businessHour).to.be.null; @@ -87,14 +103,15 @@ describe('[CE] LIVECHAT - business hours', function () { }); }); - (IS_EE ? describe : describe.skip)('[EE] LIVECHAT - business hours', () => { + (IS_EE ? describe : describe.skip)('[EE] livechat/business-hour', () => { it('should fail if user doesnt have view-livechat-business-hours permission', async () => { - await updatePermission('view-livechat-business-hours', []); + await removePermissionFromAllRoles('view-livechat-business-hours'); const response = await request.get(api('livechat/business-hours')).set(credentials).expect(403); expect(response.body.success).to.be.false; + + await restorePermissionToRoles('view-livechat-business-hours'); }); it('should return a list of business hours', async () => { - await updatePermission('view-livechat-business-hours', ['admin', 'livechat-manager']); const response = await request.get(api('livechat/business-hours')).set(credentials).expect(200); expect(response.body.success).to.be.true; expect(response.body.businessHours).to.be.an('array').with.lengthOf.greaterThan(0); @@ -233,6 +250,429 @@ describe('[CE] LIVECHAT - business hours', function () { }); }); + // Scenario: Assume we have a BH linked to a department, and we archive the department + // Expected result: + // 1) If BH is open and only linked to that department, it should be closed + // 2) If BH is open and linked to other departments, it should remain open + // 3) Agents within the archived department should be assigned to default BH + // 3.1) We'll also need to handle the case where if an agent is assigned to "dep1" + // and "dep2" and both these depts are connected to same BH, then in this case after + // archiving "dep1", we'd still need to BH within this user's cache since he's part of + // "dep2" which is linked to BH + (IS_EE ? describe : describe.skip)('[EE] BH operations post department archiving', () => { + let defaultBusinessHour: ILivechatBusinessHour; + let customBusinessHour: ILivechatBusinessHour; + let deptLinkedToCustomBH: ILivechatDepartment; + let agentLinkedToDept: Awaited>['agent']; + + before(async () => { + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + // wait for the callbacks to be registered + await sleep(1000); + }); + + beforeEach(async () => { + // cleanup any existing business hours + await removeAllCustomBusinessHours(); + + // get default business hour + defaultBusinessHour = await getDefaultBusinessHour(); + + // close default business hour + await openOrCloseBusinessHour(defaultBusinessHour, false); + + // create custom business hour and link it to a department + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + customBusinessHour = await createCustomBusinessHour([department._id]); + agentLinkedToDept = agent; + deptLinkedToCustomBH = department; + + // open custom business hour + await openOrCloseBusinessHour(customBusinessHour, true); + }); + + it('upon archiving a department, if BH is open and only linked to that department, it should be closed', async () => { + // archive department + await archiveDepartment(deptLinkedToCustomBH._id); + + // verify if department is archived and BH link is removed + const department = await getDepartmentById(deptLinkedToCustomBH._id); + expect(department).to.be.an('object'); + expect(department).to.have.property('archived', true); + expect(department.businessHourId).to.be.undefined; + + // verify if BH is closed + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', false); + expect(latestCustomBH.departments).to.be.an('array').that.is.empty; + }); + + it('upon archiving a department, if BH is open and linked to other departments, it should remain open', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + // archive department + await archiveDepartment(deptLinkedToCustomBH._id); + + // verify if department is archived and BH link is removed + const archivedDepartment = await getDepartmentById(deptLinkedToCustomBH._id); + expect(archivedDepartment).to.be.an('object'); + expect(archivedDepartment).to.have.property('archived', true); + expect(archivedDepartment.businessHourId).to.be.undefined; + // verify if other department is not archived and BH link is not removed + const otherDepartment = await getDepartmentById(department._id); + expect(otherDepartment).to.be.an('object'); + expect(otherDepartment.businessHourId).to.be.equal(customBusinessHour._id); + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]._id).to.be.equal(department._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + it('upon archiving a department, agents within the archived department should be assigned to default BH', async () => { + await openOrCloseBusinessHour(defaultBusinessHour, true); + + // archive department + await archiveDepartment(deptLinkedToCustomBH._id); + + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials 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(defaultBusinessHour._id); + }); + + it('upon archiving a department, overlapping agents should still have BH within their cache', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + // create overlapping agent by adding previous agent to newly created department + await addOrRemoveAgentFromDepartment( + department._id, + { + agentId: agentLinkedToDept.user._id, + username: agentLinkedToDept.user.username || '', + }, + true, + ); + + // archive department + await archiveDepartment(deptLinkedToCustomBH._id); + + // verify if department is archived and BH link is removed + const archivedDepartment = await getDepartmentById(deptLinkedToCustomBH._id); + expect(archivedDepartment).to.be.an('object'); + expect(archivedDepartment).to.have.property('archived', true); + expect(archivedDepartment.businessHourId).to.be.undefined; + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]?._id).to.be.equal(department._id); + + // verify if overlapping agent still has BH within his cache + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials 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(customBusinessHour._id); + + // verify if other agent still has BH within his cache + const otherAgent: ILivechatAgent = await getMe(agent.credentials as any); + expect(otherAgent).to.be.an('object'); + expect(otherAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(otherAgent?.openBusinessHours?.[0]).to.be.equal(customBusinessHour._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + afterEach(async () => { + await deleteDepartment(deptLinkedToCustomBH._id); + await deleteUser(agentLinkedToDept.user); + }); + }); + (IS_EE ? describe : describe.skip)('[EE] BH operations post department disablement', () => { + let defaultBusinessHour: ILivechatBusinessHour; + let customBusinessHour: ILivechatBusinessHour; + let deptLinkedToCustomBH: ILivechatDepartment; + let agentLinkedToDept: Awaited>['agent']; + + before(async () => { + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + // wait for the callbacks to be registered + await sleep(1000); + }); + + beforeEach(async () => { + // cleanup any existing business hours + await removeAllCustomBusinessHours(); + + // get default business hour + defaultBusinessHour = await getDefaultBusinessHour(); + + // close default business hour + await openOrCloseBusinessHour(defaultBusinessHour, false); + + // create custom business hour and link it to a department + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + customBusinessHour = await createCustomBusinessHour([department._id]); + agentLinkedToDept = agent; + deptLinkedToCustomBH = department; + + // open custom business hour + await openOrCloseBusinessHour(customBusinessHour, true); + }); + + it('upon disabling a department, if BH is open and only linked to that department, it should be closed', async () => { + // disable department + await disableDepartment(deptLinkedToCustomBH); + + // verify if BH link is removed + const department = await getDepartmentById(deptLinkedToCustomBH._id); + expect(department).to.be.an('object'); + expect(department.businessHourId).to.be.undefined; + + // verify if BH is closed + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH.active).to.be.false; + expect(latestCustomBH.departments).to.be.an('array').that.is.empty; + }); + + it('upon disabling a department, if BH is open and linked to other departments, it should remain open', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + // disable department + await disableDepartment(deptLinkedToCustomBH); + + // verify if BH link is removed + const disabledDepartment = await getDepartmentById(deptLinkedToCustomBH._id); + expect(disabledDepartment).to.be.an('object'); + expect(disabledDepartment.businessHourId).to.be.undefined; + + // verify if other department BH link is not removed + const otherDepartment = await getDepartmentById(department._id); + expect(otherDepartment).to.be.an('object'); + expect(otherDepartment.businessHourId).to.be.equal(customBusinessHour._id); + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]._id).to.be.equal(department._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + it('upon disabling a department, agents within the disabled department should be assigned to default BH', async () => { + await openOrCloseBusinessHour(defaultBusinessHour, true); + + // disable department + await disableDepartment(deptLinkedToCustomBH); + + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials 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(defaultBusinessHour._id); + }); + + it('upon disabling a department, overlapping agents should still have BH within their cache', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + // create overlapping agent by adding previous agent to newly created department + await addOrRemoveAgentFromDepartment( + department._id, + { + agentId: agentLinkedToDept.user._id, + username: agentLinkedToDept.user.username || '', + }, + true, + ); + + // disable department + await disableDepartment(deptLinkedToCustomBH); + + // verify if BH link is removed + const disabledDepartment = await getDepartmentById(deptLinkedToCustomBH._id); + expect(disabledDepartment).to.be.an('object'); + expect(disabledDepartment.businessHourId).to.be.undefined; + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]?._id).to.be.equal(department._id); + + // verify if overlapping agent still has BH within his cache + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials 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(customBusinessHour._id); + + // verify if other agent still has BH within his cache + const otherAgent: ILivechatAgent = await getMe(agent.credentials as any); + expect(otherAgent).to.be.an('object'); + expect(otherAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(otherAgent?.openBusinessHours?.[0]).to.be.equal(customBusinessHour._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + afterEach(async () => { + await deleteDepartment(deptLinkedToCustomBH._id); + await deleteUser(agentLinkedToDept.user); + }); + }); + (IS_EE ? describe : describe.skip)('[EE] BH operations post department removal', () => { + let defaultBusinessHour: ILivechatBusinessHour; + let customBusinessHour: ILivechatBusinessHour; + let deptLinkedToCustomBH: ILivechatDepartment; + let agentLinkedToDept: Awaited>['agent']; + + before(async () => { + await updateSetting('Livechat_business_hour_type', LivechatBusinessHourBehaviors.MULTIPLE); + // wait for the callbacks to be registered + await sleep(1000); + }); + + beforeEach(async () => { + // cleanup any existing business hours + await removeAllCustomBusinessHours(); + + // get default business hour + defaultBusinessHour = await getDefaultBusinessHour(); + + // close default business hour + await openOrCloseBusinessHour(defaultBusinessHour, false); + + // create custom business hour and link it to a department + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + customBusinessHour = await createCustomBusinessHour([department._id]); + agentLinkedToDept = agent; + deptLinkedToCustomBH = department; + + // open custom business hour + await openOrCloseBusinessHour(customBusinessHour, true); + }); + + it('upon deleting a department, if BH is open and only linked to that department, it should be closed', async () => { + await deleteDepartment(deptLinkedToCustomBH._id); + + // verify if BH is closed + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH.active).to.be.false; + expect(latestCustomBH.departments).to.be.an('array').that.is.empty; + }); + + it('upon deleting a department, if BH is open and linked to other departments, it should remain open', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + await deleteDepartment(deptLinkedToCustomBH._id); + + // verify if other department BH link is not removed + const otherDepartment = await getDepartmentById(department._id); + expect(otherDepartment).to.be.an('object'); + expect(otherDepartment.businessHourId).to.be.equal(customBusinessHour._id); + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]._id).to.be.equal(department._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + it('upon deleting a department, agents within the disabled department should be assigned to default BH', async () => { + await openOrCloseBusinessHour(defaultBusinessHour, true); + + await deleteDepartment(deptLinkedToCustomBH._id); + + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials 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(defaultBusinessHour._id); + }); + + it('upon deleting a department, overlapping agents should still have BH within their cache', async () => { + // create another department and link it to the same BH + const { department, agent } = await createDepartmentWithAnOnlineAgent(); + await removeAllCustomBusinessHours(); + customBusinessHour = await createCustomBusinessHour([deptLinkedToCustomBH._id, department._id]); + + // create overlapping agent by adding previous agent to newly created department + await addOrRemoveAgentFromDepartment( + department._id, + { + agentId: agentLinkedToDept.user._id, + username: agentLinkedToDept.user.username || '', + }, + true, + ); + + await deleteDepartment(deptLinkedToCustomBH._id); + + // verify if BH is still open + const latestCustomBH = await getCustomBusinessHourById(customBusinessHour._id); + expect(latestCustomBH).to.be.an('object'); + expect(latestCustomBH).to.have.property('active', true); + expect(latestCustomBH.departments).to.be.an('array').of.length(1); + expect(latestCustomBH?.departments?.[0]?._id).to.be.equal(department._id); + + // verify if overlapping agent still has BH within his cache + const latestAgent: ILivechatAgent = await getMe(agentLinkedToDept.credentials 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(customBusinessHour._id); + + // verify if other agent still has BH within his cache + const otherAgent: ILivechatAgent = await getMe(agent.credentials as any); + expect(otherAgent).to.be.an('object'); + expect(otherAgent.openBusinessHours).to.be.an('array').of.length(1); + expect(otherAgent?.openBusinessHours?.[0]).to.be.equal(customBusinessHour._id); + + // cleanup + await deleteDepartment(department._id); + await deleteUser(agent.user); + }); + + afterEach(async () => { + await deleteUser(agentLinkedToDept.user); + }); + }); describe('BH behavior upon new agent creation/deletion', () => { let defaultBH: ILivechatBusinessHour; let agent: ILivechatAgent; diff --git a/packages/model-typings/src/models/ILivechatBusinessHoursModel.ts b/packages/model-typings/src/models/ILivechatBusinessHoursModel.ts index 6ba6e003ce42..0bbb8f381b7f 100644 --- a/packages/model-typings/src/models/ILivechatBusinessHoursModel.ts +++ b/packages/model-typings/src/models/ILivechatBusinessHoursModel.ts @@ -39,4 +39,6 @@ export interface ILivechatBusinessHoursModel extends IBaseModel; + + disableBusinessHour(businessHourId: string): Promise; } diff --git a/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts b/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts index 94c824e14f07..2d0c8c5ec69d 100644 --- a/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts +++ b/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts @@ -87,5 +87,7 @@ export interface ILivechatDepartmentAgentsModel extends IBaseModel; replaceUsernameOfAgentByUserId(userId: string, username: string): Promise; countByDepartmentId(departmentId: string): Promise; + disableAgentsByDepartmentId(departmentId: string): Promise; + enableAgentsByDepartmentId(departmentId: string): Promise; findAllAgentsConnectedToListOfDepartments(departmentIds: string[]): Promise; } diff --git a/packages/model-typings/src/models/ILivechatDepartmentModel.ts b/packages/model-typings/src/models/ILivechatDepartmentModel.ts index 800800878088..271e433f39d6 100644 --- a/packages/model-typings/src/models/ILivechatDepartmentModel.ts +++ b/packages/model-typings/src/models/ILivechatDepartmentModel.ts @@ -14,6 +14,7 @@ export interface ILivechatDepartmentModel extends IBaseModel; findByBusinessHourId(businessHourId: string, options: FindOptions): FindCursor; + countByBusinessHourIdExcludingDepartmentId(businessHourId: string, departmentId: string): Promise; findEnabledByBusinessHourId(businessHourId: string, options: FindOptions): FindCursor; @@ -59,5 +60,13 @@ export interface ILivechatDepartmentModel extends IBaseModel): Promise; findByUnitIds(unitIds: string[], options?: FindOptions): FindCursor; findActiveByUnitIds(unitIds: string[], options?: FindOptions): FindCursor; + findNotArchived(options?: FindOptions): FindCursor; + getBusinessHoursWithDepartmentStatuses(): Promise< + { + _id: string; + validDepartments: string[]; + invalidDepartments: string[]; + }[] + >; checkIfMonitorIsMonitoringDepartmentById(monitorId: string, departmentId: string): Promise; }