diff --git a/app/livechat/client/route.js b/app/livechat/client/route.js index e320ebc82308..b2e488bad93a 100644 --- a/app/livechat/client/route.js +++ b/app/livechat/client/route.js @@ -7,7 +7,7 @@ export const livechatManagerRoutes = FlowRouter.group({ name: 'livechat-manager', }); -const load = () => import('./views/admin'); +export const load = () => import('./views/admin'); AccountBox.addRoute({ name: 'livechat-dashboard', diff --git a/app/livechat/client/views/app/business-hours/BusinessHours.ts b/app/livechat/client/views/app/business-hours/BusinessHours.ts index b5befb2d8ee2..8d5fe0401e5f 100644 --- a/app/livechat/client/views/app/business-hours/BusinessHours.ts +++ b/app/livechat/client/views/app/business-hours/BusinessHours.ts @@ -1,28 +1,33 @@ -import { Meteor } from 'meteor/meteor'; - import { IBusinessHour } from './IBusinessHour'; import { SingleBusinessHour } from './Single'; -import { callbacks } from '../../../../../callbacks/client'; +import { ILivechatBusinessHour } from '../../../../../../definition/ILivechatBusinessHour'; class BusinessHoursManager { private businessHour: IBusinessHour; - onStartBusinessHourManager(businessHour: IBusinessHour): void { - this.registerBusinessHour(businessHour); + constructor(businessHour: IBusinessHour) { + this.setBusinessHourManager(businessHour); + } + + setBusinessHourManager(businessHour: IBusinessHour): void { + this.registerBusinessHourMethod(businessHour); } - registerBusinessHour(businessHour: IBusinessHour): void { + registerBusinessHourMethod(businessHour: IBusinessHour): void { this.businessHour = businessHour; } getTemplate(): string { return this.businessHour.getView(); } -} -export const businessHourManager = new BusinessHoursManager(); + shouldShowCustomTemplate(businessHourData: ILivechatBusinessHour): boolean { + return this.businessHour.shouldShowCustomTemplate(businessHourData); + } + + shouldShowBackButton(): boolean { + return this.businessHour.shouldShowBackButton(); + } +} -Meteor.startup(() => { - const { BusinessHourClass } = callbacks.run('on-business-hour-start', { BusinessHourClass: SingleBusinessHour }); - businessHourManager.onStartBusinessHourManager(new BusinessHourClass() as IBusinessHour); -}); +export const businessHourManager = new BusinessHoursManager(new SingleBusinessHour() as IBusinessHour); diff --git a/app/livechat/client/views/app/business-hours/IBusinessHour.ts b/app/livechat/client/views/app/business-hours/IBusinessHour.ts index e29ff2400240..f5a1c97dab10 100644 --- a/app/livechat/client/views/app/business-hours/IBusinessHour.ts +++ b/app/livechat/client/views/app/business-hours/IBusinessHour.ts @@ -1,3 +1,7 @@ +import { ILivechatBusinessHour } from '../../../../../../definition/ILivechatBusinessHour'; + export interface IBusinessHour { getView(): string; + shouldShowCustomTemplate(businessHourData: ILivechatBusinessHour): boolean; + shouldShowBackButton(): boolean; } diff --git a/app/livechat/client/views/app/business-hours/Single.ts b/app/livechat/client/views/app/business-hours/Single.ts index 085ab3f043e8..4dc41b05cb95 100644 --- a/app/livechat/client/views/app/business-hours/Single.ts +++ b/app/livechat/client/views/app/business-hours/Single.ts @@ -4,4 +4,12 @@ export class SingleBusinessHour implements IBusinessHour { getView(): string { return 'livechatBusinessHoursForm'; } + + shouldShowCustomTemplate(): boolean { + return false; + } + + shouldShowBackButton(): boolean { + return false; + } } diff --git a/app/livechat/client/views/app/business-hours/livechatBusinessHoursForm.html b/app/livechat/client/views/app/business-hours/livechatBusinessHoursForm.html index c8901f703699..a212fed851e5 100644 --- a/app/livechat/client/views/app/business-hours/livechatBusinessHoursForm.html +++ b/app/livechat/client/views/app/business-hours/livechatBusinessHoursForm.html @@ -2,15 +2,20 @@ {{#requiresPermission 'view-livechat-business-hours'}}
+ {{#if customFieldsTemplate}} + {{> Template.dynamic template=customFieldsTemplate data=data }} + {{/if}} +
{{_ "Open_days_of_the_week"}} {{#each day in days}} {{#if open day}} - + {{else}} - + {{/if}} {{/each}}
@@ -29,14 +34,16 @@

{{name day}}

- +
- +
@@ -46,8 +53,9 @@

{{name day}}

-
- +
+ {{#if showBackButton}}{{/if}} +
{{/requiresPermission}} diff --git a/app/livechat/client/views/app/business-hours/livechatBusinessHoursForm.js b/app/livechat/client/views/app/business-hours/livechatBusinessHoursForm.js index b1a4627697e3..9dd2fb4cbdd4 100644 --- a/app/livechat/client/views/app/business-hours/livechatBusinessHoursForm.js +++ b/app/livechat/client/views/app/business-hours/livechatBusinessHoursForm.js @@ -2,11 +2,14 @@ import { Meteor } from 'meteor/meteor'; import { ReactiveVar } from 'meteor/reactive-var'; import { Template } from 'meteor/templating'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; +import { FlowRouter } from 'meteor/kadira:flow-router'; import toastr from 'toastr'; import moment from 'moment'; import { t, handleError, APIClient } from '../../../../../utils/client'; import './livechatBusinessHoursForm.html'; +import { getCustomFormTemplate } from '../customTemplates/register'; +import { businessHourManager } from './BusinessHours'; Template.livechatBusinessHoursForm.helpers({ days() { @@ -33,6 +36,18 @@ Template.livechatBusinessHoursForm.helpers({ open(day) { return Template.instance().dayVars[day.day].open.get(); }, + customFieldsTemplate() { + if (!businessHourManager.shouldShowCustomTemplate(Template.instance().businessHour.get())) { + return; + } + return getCustomFormTemplate('livechatBusinessHoursForm'); + }, + showBackButton() { + return businessHourManager.shouldShowBackButton(); + }, + data() { + return Template.instance().businessHour; + }, }); const splitDayAndPeriod = (value) => value.split('_'); @@ -69,6 +84,12 @@ Template.livechatBusinessHoursForm.events({ instance[e.currentTarget.name].set(value); } }, + + 'click button.back'(e/* , instance*/) { + e.preventDefault(); + FlowRouter.go('livechat-business-hours'); + }, + 'submit .rocket-form'(e, instance) { e.preventDefault(); @@ -87,14 +108,23 @@ Template.livechatBusinessHoursForm.events({ }); } } - Meteor.call('livechat:saveBusinessHour', { + + const businessHourData = { ...instance.businessHour.get(), workHours: days, - }, function(err /* ,result*/) { + }; + + instance.$('.customFormField').each((i, el) => { + const elField = instance.$(el); + const name = elField.attr('name'); + businessHourData[name] = elField.val(); + }); + Meteor.call('livechat:saveBusinessHour', businessHourData, function(err /* ,result*/) { if (err) { return handleError(err); } toastr.success(t('Business_hours_updated')); + FlowRouter.go('livechat-business-hours'); }); }, }); @@ -112,7 +142,6 @@ const createDefaultBusinessHour = () => { }; }; - Template.livechatBusinessHoursForm.onCreated(async function() { this.dayVars = createDefaultBusinessHour().workHours.reduce((acc, day) => { acc[day.day] = { @@ -124,21 +153,28 @@ Template.livechatBusinessHoursForm.onCreated(async function() { }, {}); this.businessHour = new ReactiveVar({}); - const { businessHour } = await APIClient.v1.get('livechat/business-hour'); this.businessHour.set({ ...createDefaultBusinessHour(), }); - if (businessHour) { - this.businessHour.set(businessHour); - businessHour.workHours.forEach((d) => { - if (businessHour.timezone.name) { - this.dayVars[d.day].start.set(moment.utc(d.start.utc.time, 'HH:mm').tz(businessHour.timezone.name).format('HH:mm')); - this.dayVars[d.day].finish.set(moment.utc(d.finish.utc.time, 'HH:mm').tz(businessHour.timezone.name).format('HH:mm')); - } else { - this.dayVars[d.day].start.set(moment.utc(d.start.utc.time, 'HH:mm').local().format('HH:mm')); - this.dayVars[d.day].finish.set(moment.utc(d.finish.utc.time, 'HH:mm').local().format('HH:mm')); - } - this.dayVars[d.day].open.set(d.open); - }); - } + this.autorun(async () => { + const id = FlowRouter.getParam('_id'); + let url = 'livechat/business-hour'; + if (id) { + url += `?_id=${ id }`; + } + const { businessHour } = await APIClient.v1.get(url); + if (businessHour) { + this.businessHour.set(businessHour); + businessHour.workHours.forEach((d) => { + if (businessHour.timezone.name) { + this.dayVars[d.day].start.set(moment.utc(d.start.utc.time, 'HH:mm').tz(businessHour.timezone.name).format('HH:mm')); + this.dayVars[d.day].finish.set(moment.utc(d.finish.utc.time, 'HH:mm').tz(businessHour.timezone.name).format('HH:mm')); + } else { + this.dayVars[d.day].start.set(moment.utc(d.start.utc.time, 'HH:mm').local().format('HH:mm')); + this.dayVars[d.day].finish.set(moment.utc(d.finish.utc.time, 'HH:mm').local().format('HH:mm')); + } + this.dayVars[d.day].open.set(d.open); + }); + } + }); }); diff --git a/app/livechat/client/views/app/business-hours/livechatMainBusinessHours.html b/app/livechat/client/views/app/business-hours/livechatMainBusinessHours.html index 55ed27c70fe4..91a112cf915f 100644 --- a/app/livechat/client/views/app/business-hours/livechatMainBusinessHours.html +++ b/app/livechat/client/views/app/business-hours/livechatMainBusinessHours.html @@ -1,7 +1,5 @@ diff --git a/app/livechat/client/views/app/customTemplates/register.js b/app/livechat/client/views/app/customTemplates/register.js index 6fcddbeb5303..9d724f06f107 100644 --- a/app/livechat/client/views/app/customTemplates/register.js +++ b/app/livechat/client/views/app/customTemplates/register.js @@ -2,4 +2,6 @@ export const customFormTemplate = new Map(); export const addCustomFormTemplate = (form, customTemplateName) => customFormTemplate.set(form, customTemplateName); +export const removeCustomTemplate = (form) => customFormTemplate.delete(form); + export const getCustomFormTemplate = (form) => customFormTemplate.get(form); diff --git a/app/livechat/server/business-hour/AbstractBusinessHour.ts b/app/livechat/server/business-hour/AbstractBusinessHour.ts index d42575c68122..9cb250f88750 100644 --- a/app/livechat/server/business-hour/AbstractBusinessHour.ts +++ b/app/livechat/server/business-hour/AbstractBusinessHour.ts @@ -10,12 +10,17 @@ import { LivechatBusinessHours, Users } from '../../../models/server/raw'; export interface IBusinessHour { saveBusinessHour(businessHourData: ILivechatBusinessHour): Promise; allowAgentChangeServiceStatus(agentId: string): Promise; - getBusinessHour(id: string): Promise; + getBusinessHour(id: string): Promise; findHoursToCreateJobs(): Promise; openBusinessHoursByDayHour(day: string, hour: string): Promise; closeBusinessHoursByDayAndHour(day: string, hour: string): Promise; removeBusinessHoursFromUsers(): Promise; + removeBusinessHourById(id: string): Promise; + removeBusinessHourFromUsers(departmentId: string, businessHourId: string): Promise; openBusinessHoursIfNeeded(): Promise; + removeBusinessHourFromUsersByIds(userIds: string[], businessHourId: string): Promise; + addBusinessHourToUsersByIds(userIds: string[], businessHourId: string): Promise; + setDefaultToUsersIfNeeded(userIds: string[]): Promise; } export abstract class AbstractBusinessHour { @@ -36,10 +41,10 @@ export abstract class AbstractBusinessHour { await this.UsersRepository.updateLivechatStatusBasedOnBusinessHours(); } - protected async getBusinessHoursThatMustBeOpened(day: string, currentTime: any, activeBusinessHours: ILivechatBusinessHour[]): Promise[]> { + protected async getBusinessHoursThatMustBeOpened(currentTime: any, activeBusinessHours: ILivechatBusinessHour[]): Promise[]> { return activeBusinessHours .filter((businessHour) => businessHour.workHours - .filter((hour) => hour.start.cron.dayOfWeek === day) + .filter((hour) => hour.open) .some((hour) => { const localTimeStart = moment(`${ hour.start.cron.dayOfWeek }:${ hour.start.cron.time }`, 'dddd:HH:mm'); const localTimeFinish = moment(`${ hour.finish.cron.dayOfWeek }:${ hour.finish.cron.time }`, 'dddd:HH:mm'); @@ -50,4 +55,40 @@ export abstract class AbstractBusinessHour { type: businessHour.type, })); } + + protected convertWorkHoursWithServerTimezone(businessHourData: ILivechatBusinessHour): ILivechatBusinessHour { + businessHourData.workHours.forEach((hour: any) => { + hour.start = { + time: hour.start, + utc: { + dayOfWeek: this.formatDayOfTheWeekFromUTC(`${ hour.day }:${ hour.start }`, 'dddd'), + time: this.formatDayOfTheWeekFromUTC(`${ hour.day }:${ hour.start }`, 'HH:mm'), + }, + cron: { + dayOfWeek: this.formatDayOfTheWeekFromServerTimezone(`${ hour.day }:${ hour.start }`, 'dddd'), + time: this.formatDayOfTheWeekFromServerTimezone(`${ hour.day }:${ hour.start }`, ' HH:mm'), + }, + }; + hour.finish = { + time: hour.finish, + utc: { + dayOfWeek: this.formatDayOfTheWeekFromUTC(`${ hour.day }:${ hour.finish }`, 'dddd'), + time: this.formatDayOfTheWeekFromUTC(`${ hour.day }:${ hour.finish }`, 'HH:mm'), + }, + cron: { + dayOfWeek: this.formatDayOfTheWeekFromServerTimezone(`${ hour.day }:${ hour.finish }`, 'dddd'), + time: this.formatDayOfTheWeekFromServerTimezone(`${ hour.day }:${ hour.finish }`, 'HH:mm'), + }, + }; + }); + return businessHourData; + } + + protected formatDayOfTheWeekFromServerTimezone(hour: string, format: string): string { + return moment(hour, 'dddd:HH:mm').format(format); + } + + protected formatDayOfTheWeekFromUTC(hour: string, format: string): string { + return moment(hour, 'dddd:HH:mm').utc().format(format); + } } diff --git a/app/livechat/server/business-hour/BusinessHourManager.ts b/app/livechat/server/business-hour/BusinessHourManager.ts index 0224f05c3645..fe8ef5f8e657 100644 --- a/app/livechat/server/business-hour/BusinessHourManager.ts +++ b/app/livechat/server/business-hour/BusinessHourManager.ts @@ -4,6 +4,7 @@ import { ILivechatBusinessHour } from '../../../../definition/ILivechatBusinessH import { ICronJobs } from '../../../utils/server/lib/cron/Cronjobs'; import { IBusinessHour } from './AbstractBusinessHour'; import { settings } from '../../../settings/server'; +import { ILivechatDepartment } from '../../../../definition/ILivechatDepartment'; const cronJobDayDict: Record = { Sunday: 0, @@ -36,16 +37,25 @@ export class BusinessHourManager { this.businessHour = businessHour; } + async dispatchOnStartTasks(): Promise { + await this.createCronJobsForWorkHours(); + await this.openBusinessHoursIfNeeded(); + } + + async dispatchOnCloseTasks(): Promise { + await this.removeBusinessHoursFromAgents(); + await this.removeCronJobs(); + } + async saveBusinessHour(businessHourData: ILivechatBusinessHour): Promise { await this.businessHour.saveBusinessHour(businessHourData); if (!settings.get('Livechat_enable_business_hours')) { return; } - await this.createCronJobsForWorkHours(); - await this.openBusinessHoursIfNeeded(); + await this.dispatchOnStartTasks(); } - async getBusinessHour(id?: string): Promise { + async getBusinessHour(id?: string): Promise { return this.businessHour.getBusinessHour(id as string); } @@ -56,11 +66,48 @@ export class BusinessHourManager { return this.businessHour.allowAgentChangeServiceStatus(agentId); } - removeCronJobs(): void { + async removeBusinessHourIdFromUsers(department: ILivechatDepartment): Promise { + return this.businessHour.removeBusinessHourFromUsers(department._id, department.businessHourId as string); + } + + async removeBusinessHourById(id: string): Promise { + await this.businessHour.removeBusinessHourById(id); + if (!settings.get('Livechat_enable_business_hours')) { + return; + } + await this.createCronJobsForWorkHours(); + await this.openBusinessHoursIfNeeded(); + } + + async removeBusinessHourFromUsersByIds(userIds: string[], businessHourId: string): Promise { + if (!settings.get('Livechat_enable_business_hours')) { + return; + } + + await this.businessHour.removeBusinessHourFromUsersByIds(userIds, businessHourId); + } + + async setDefaultToUsersIfNeeded(userIds: string[]): Promise { + if (!settings.get('Livechat_enable_business_hours')) { + return; + } + + await this.businessHour.setDefaultToUsersIfNeeded(userIds); + } + + async addBusinessHourToUsersByIds(userIds: string[], businessHourId: string): Promise { + if (!settings.get('Livechat_enable_business_hours')) { + return; + } + + await this.businessHour.addBusinessHourToUsersByIds(userIds, businessHourId); + } + + private removeCronJobs(): void { this.cronJobsCache.forEach((jobName) => this.cronJobs.remove(jobName)); } - async createCronJobsForWorkHours(): Promise { + private async createCronJobsForWorkHours(): Promise { this.removeCronJobs(); this.clearCronJobsCache(); const workHours = await this.businessHour.findHoursToCreateJobs(); @@ -83,11 +130,11 @@ export class BusinessHourManager { }); } - async removeBusinessHoursFromAgents(): Promise { + private async removeBusinessHoursFromAgents(): Promise { return this.businessHour.removeBusinessHoursFromUsers(); } - async openBusinessHoursIfNeeded(): Promise { + private async openBusinessHoursIfNeeded(): Promise { return this.businessHour.openBusinessHoursIfNeeded(); } diff --git a/app/livechat/server/business-hour/Single.ts b/app/livechat/server/business-hour/Single.ts index 0b7b21117062..164a18849d70 100644 --- a/app/livechat/server/business-hour/Single.ts +++ b/app/livechat/server/business-hour/Single.ts @@ -8,33 +8,10 @@ export class SingleBusinessHour extends AbstractBusinessHour implements IBusines if (!businessHourData._id) { return; } - businessHourData.workHours.forEach((hour: any) => { - hour.start = { - time: hour.start, - utc: { - dayOfWeek: moment(`${ hour.day }:${ hour.start }`, 'dddd:HH:mm').utc().format('dddd'), - time: moment(`${ hour.day }:${ hour.start }`, 'dddd:HH:mm').utc().format('HH:mm'), - }, - cron: { - dayOfWeek: moment(`${ hour.day }:${ hour.start }`, 'dddd:HH:mm').format('dddd'), - time: moment(`${ hour.day }:${ hour.start }`, 'dddd:HH:mm').format('HH:mm'), - }, - }; - hour.finish = { - time: hour.finish, - utc: { - dayOfWeek: moment(`${ hour.day }:${ hour.finish }`, 'dddd:HH:mm').utc().format('dddd'), - time: moment(`${ hour.day }:${ hour.finish }`, 'dddd:HH:mm').utc().format('HH:mm'), - }, - cron: { - dayOfWeek: moment(`${ hour.day }:${ hour.finish }`, 'dddd:HH:mm').format('dddd'), - time: moment(`${ hour.day }:${ hour.finish }`, 'dddd:HH:mm').format('HH:mm'), - }, - }; - }); + businessHourData = this.convertWorkHoursWithServerTimezone(businessHourData); businessHourData.timezone = { name: '', - utc: moment().utcOffset() / 60, + utc: String(moment().utcOffset() / 60), }; await this.BusinessHourRepository.updateOne(businessHourData._id, businessHourData); } @@ -65,8 +42,28 @@ export class SingleBusinessHour extends AbstractBusinessHour implements IBusines type: 1, }, }); - const businessHoursToOpenIds = (await this.getBusinessHoursThatMustBeOpened(day, currentTime, activeBusinessHours)).map((businessHour) => businessHour._id); + const businessHoursToOpenIds = (await this.getBusinessHoursThatMustBeOpened(currentTime, activeBusinessHours)).map((businessHour) => businessHour._id); await this.UsersRepository.openAgentsBusinessHours(businessHoursToOpenIds); await this.UsersRepository.updateLivechatStatusBasedOnBusinessHours(); } + + removeBusinessHourFromUsers(): Promise { + return Promise.resolve(); + } + + removeBusinessHourById(): Promise { + return Promise.resolve(); + } + + removeBusinessHourFromUsersByIds(): Promise { + return Promise.resolve(); + } + + addBusinessHourToUsersByIds(): Promise { + return Promise.resolve(); + } + + setDefaultToUsersIfNeeded(): Promise { + return Promise.resolve(); + } } diff --git a/app/livechat/server/lib/Helper.js b/app/livechat/server/lib/Helper.js index 8a7c9ada3a25..431bf8546f17 100644 --- a/app/livechat/server/lib/Helper.js +++ b/app/livechat/server/lib/Helper.js @@ -1,8 +1,9 @@ +import _ from 'underscore'; import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import { MongoInternals } from 'meteor/mongo'; -import { Messages, LivechatRooms, Rooms, Subscriptions, Users, LivechatInquiry } from '../../../models/server'; +import { Messages, LivechatRooms, Rooms, Subscriptions, Users, LivechatInquiry, LivechatDepartmentAgents } from '../../../models/server'; import { Livechat } from './Livechat'; import { RoutingManager } from './RoutingManager'; import { callbacks } from '../../../callbacks/server'; @@ -358,3 +359,41 @@ export const userCanTakeInquiry = (user) => { // TODO: hasRole when the user has already been fetched from DB return (status !== 'offline' && statusLivechat === 'available') || roles.includes('bot'); }; + +export const updateDepartmentAgents = (departmentId, agents = []) => { + check(departmentId, String); + + if (!agents && !Array.isArray(agents)) { + return true; + } + + const savedAgents = _.pluck(LivechatDepartmentAgents.findByDepartmentId(departmentId).fetch(), 'agentId'); + const agentsToSave = _.pluck(agents, 'agentId'); + + // remove other agents + const agentsRemoved = []; + _.difference(savedAgents, agentsToSave).forEach((agentId) => { + LivechatDepartmentAgents.removeByDepartmentIdAndAgentId(departmentId, agentId); + agentsRemoved.push(agentId); + }); + + if (agentsRemoved.length > 0) { + callbacks.run('livechat.removeAgentDepartment', { departmentId, agentsId: agentsRemoved }); + } + + const agentsAdded = []; + agents.forEach((agent) => { + LivechatDepartmentAgents.saveAgent({ + agentId: agent.agentId, + departmentId, + username: agent.username, + count: agent.count ? parseInt(agent.count) : 0, + order: agent.order ? parseInt(agent.order) : 0, + }); + agentsAdded.push(agent.agentId); + }); + + if (agentsAdded.length > 0) { + callbacks.run('livechat.saveAgentDepartment', { departmentId, agentsId: agentsAdded }); + } +}; diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js index edea6ff7ef89..9f01cc7812dd 100644 --- a/app/livechat/server/lib/Livechat.js +++ b/app/livechat/server/lib/Livechat.js @@ -35,7 +35,7 @@ import { sendMessage } from '../../../lib/server/functions/sendMessage'; import { updateMessage } from '../../../lib/server/functions/updateMessage'; import { deleteMessage } from '../../../lib/server/functions/deleteMessage'; import { FileUpload } from '../../../file-upload/server'; -import { normalizeTransferredByData, parseAgentCustomFields } from './Helper'; +import { normalizeTransferredByData, parseAgentCustomFields, updateDepartmentAgents } from './Helper'; import { Apps, AppEvents } from '../../../apps/server'; import { businessHourManager } from '../business-hour'; @@ -824,7 +824,8 @@ export const Livechat = { throw new Meteor.Error('error-department-not-found', 'Department not found', { method: 'livechat:saveDepartmentAgents' }); } - return LivechatDepartment.createOrUpdateDepartment(_id, department, departmentAgents); + const departmentDB = LivechatDepartment.createOrUpdateDepartment(_id, department); + return departmentDB && updateDepartmentAgents(departmentDB._id, departmentAgents); }, saveDepartment(_id, departmentData, departmentAgents) { @@ -869,7 +870,8 @@ export const Livechat = { } } - return LivechatDepartment.createOrUpdateDepartment(_id, departmentData, departmentAgents); + const departmentDB = LivechatDepartment.createOrUpdateDepartment(_id, departmentData, departmentAgents); + return departmentDB && updateDepartmentAgents(departmentDB._id, departmentAgents); }, saveAgentInfo(_id, agentData, agentDepartments) { @@ -899,6 +901,7 @@ export const Livechat = { } const ret = LivechatDepartment.removeById(_id); + LivechatDepartmentAgents.removeByDepartmentId(_id); if (ret) { Meteor.defer(() => { callbacks.run('livechat.afterRemoveDepartment', department); diff --git a/app/livechat/server/startup.js b/app/livechat/server/startup.js index e90a4f6bb3d6..772d673fff70 100644 --- a/app/livechat/server/startup.js +++ b/app/livechat/server/startup.js @@ -100,10 +100,8 @@ Meteor.startup(() => { settings.get('Livechat_enable_business_hours', async (key, value) => { if (value) { - await businessHourManager.openBusinessHoursIfNeeded(); - return businessHourManager.createCronJobsForWorkHours(); + return businessHourManager.dispatchOnStartTasks(); } - await businessHourManager.removeBusinessHoursFromAgents(); - businessHourManager.removeCronJobs(); + await businessHourManager.dispatchOnCloseTasks(); }); }); diff --git a/app/models/server/models/LivechatBusinessHours.ts b/app/models/server/models/LivechatBusinessHours.ts index 984988b16181..95caf0071048 100644 --- a/app/models/server/models/LivechatBusinessHours.ts +++ b/app/models/server/models/LivechatBusinessHours.ts @@ -42,7 +42,7 @@ const createDefaultBusinessHour = (): ILivechatBusinessHour => { })), timezone: { name: '', - utc: moment().utcOffset() / 60, + utc: String(moment().utcOffset() / 60), }, }; }; diff --git a/app/models/server/models/LivechatDepartment.js b/app/models/server/models/LivechatDepartment.js index 652ba3dd72dc..ac40ed7acdb7 100644 --- a/app/models/server/models/LivechatDepartment.js +++ b/app/models/server/models/LivechatDepartment.js @@ -46,26 +46,6 @@ export class LivechatDepartment extends Base { _id = this.insert(record); } - if (hasAgents) { - const savedAgents = _.pluck(LivechatDepartmentAgents.findByDepartmentId(_id).fetch(), 'agentId'); - const agentsToSave = _.pluck(agents, 'agentId'); - - // remove other agents - _.difference(savedAgents, agentsToSave).forEach((agentId) => { - LivechatDepartmentAgents.removeByDepartmentIdAndAgentId(_id, agentId); - }); - - agents.forEach((agent) => { - LivechatDepartmentAgents.saveAgent({ - agentId: agent.agentId, - departmentId: _id, - username: agent.username, - count: agent.count ? parseInt(agent.count) : 0, - order: agent.order ? parseInt(agent.order) : 0, - }); - }); - } - return _.extend(record, { _id }); } diff --git a/app/models/server/models/LivechatDepartmentAgents.js b/app/models/server/models/LivechatDepartmentAgents.js index f729b911f8b6..eda2c4d4b274 100644 --- a/app/models/server/models/LivechatDepartmentAgents.js +++ b/app/models/server/models/LivechatDepartmentAgents.js @@ -204,5 +204,11 @@ export class LivechatDepartmentAgents extends Base { return this.update(query, update, { multi: true }); } + + removeByDepartmentId(departmentId) { + const query = { departmentId }; + + return this.remove(query); + } } export default new LivechatDepartmentAgents(); diff --git a/app/models/server/raw/BaseRaw.js b/app/models/server/raw/BaseRaw.js index 614c196ee7ba..dc2699e01c4f 100644 --- a/app/models/server/raw/BaseRaw.js +++ b/app/models/server/raw/BaseRaw.js @@ -22,4 +22,8 @@ export class BaseRaw { update(...args) { return this.col.update(...args); } + + removeById(_id) { + return this.col.deleteOne({ _id }); + } } diff --git a/app/models/server/raw/LivechatBusinessHours.ts b/app/models/server/raw/LivechatBusinessHours.ts index 26d96576bc6f..670f7f5dd6c2 100644 --- a/app/models/server/raw/LivechatBusinessHours.ts +++ b/app/models/server/raw/LivechatBusinessHours.ts @@ -25,7 +25,20 @@ export class LivechatBusinessHoursRaw extends BaseRaw { active: true, workHours: { $elemMatch: { - 'start.cron.dayOfWeek': day, + $or: [{ 'start.cron.dayOfWeek': day, 'finish.cron.dayOfWeek': day }], + open: true, + }, + }, + }, options).toArray(); + } + + findDefaultActiveAndOpenBusinessHoursByDay(day: string, options?: any): Promise { + return this.find({ + type: LivechatBussinessHourTypes.SINGLE, + active: true, + workHours: { + $elemMatch: { + $or: [{ 'start.cron.dayOfWeek': day, 'finish.cron.dayOfWeek': day }], open: true, }, }, @@ -35,6 +48,7 @@ export class LivechatBusinessHoursRaw extends BaseRaw { async insertOne(data: Omit): Promise { return this.col.insertOne({ _id: new ObjectId().toHexString(), + ts: new Date(), ...data, }); } @@ -69,6 +83,7 @@ export class LivechatBusinessHoursRaw extends BaseRaw { findHoursToScheduleJobs(): Promise { return this.col.aggregate([ + { $match: { active: true } }, { $project: { _id: 0, workHours: 1 }, }, @@ -111,19 +126,6 @@ export class LivechatBusinessHoursRaw extends BaseRaw { return this.col.find(query, options).toArray(); } - findDefaultActiveAndOpenBusinessHoursByDay(day: string, options?: any): Promise { - return this.find({ - type: LivechatBussinessHourTypes.SINGLE, - active: true, - workHours: { - $elemMatch: { - 'start.cron.dayOfWeek': day, - open: true, - }, - }, - }, options).toArray(); - } - async findActiveBusinessHoursToClose(day: string, finish: string, type?: LivechatBussinessHourTypes, options?: any): Promise { const query: Record = { active: true, diff --git a/app/models/server/raw/LivechatDepartment.js b/app/models/server/raw/LivechatDepartment.js index 68ace2e34587..138af2568f70 100644 --- a/app/models/server/raw/LivechatDepartment.js +++ b/app/models/server/raw/LivechatDepartment.js @@ -25,4 +25,37 @@ export class LivechatDepartmentRaw extends BaseRaw { return this.find(query, options); } + + findByBusinessHourId(businessHourId, options) { + const query = { businessHourId }; + return this.find(query, options); + } + + addBusinessHourToDepartamentsByIds(ids = [], businessHourId) { + const query = { + _id: { $in: ids }, + }; + + const update = { + $set: { + businessHourId, + }, + }; + + return this.col.update(query, update, { multi: true }); + } + + removeBusinessHourFromDepartmentsByBusinessHourId(businessHourId) { + const query = { + businessHourId, + }; + + const update = { + $unset: { + businessHourId: 1, + }, + }; + + return this.col.update(query, update, { multi: true }); + } } diff --git a/app/models/server/raw/LivechatDepartmentAgents.js b/app/models/server/raw/LivechatDepartmentAgents.js index 97e68d362595..0f1285cef106 100644 --- a/app/models/server/raw/LivechatDepartmentAgents.js +++ b/app/models/server/raw/LivechatDepartmentAgents.js @@ -16,6 +16,10 @@ export class LivechatDepartmentAgentsRaw extends BaseRaw { return this.find({ agentId }); } + findByDepartmentIds(departmentIds, options) { + return this.find({ departmentId: { $in: departmentIds } }, options); + } + findActiveDepartmentsByAgentId(agentId) { const match = { $match: { agentId }, diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index ad6c96978b6d..5f5f4f8d660c 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -317,6 +317,68 @@ export class UsersRaw extends BaseRaw { return this.update(query, update, { multi: true }); } + openBusinessHourByAgentIds(agentIds = [], businessHourId) { + const query = { + _id: { $in: agentIds }, + }; + + const update = { + $set: { + statusLivechat: 'available', + }, + $addToSet: { + openBusinessHours: businessHourId, + }, + }; + + return this.update(query, update, { multi: true }); + } + + closeBusinessHourByAgentIds(agentIds = [], businessHourId) { + const query = { + _id: { $in: agentIds }, + }; + + const update = { + $pull: { + openBusinessHours: businessHourId, + }, + }; + + return this.update(query, update, { multi: true }); + } + + openBusinessHourToAgentsWithoutDepartment(agentIdsWithDepartment = [], businessHourId) { + const query = { + _id: { $nin: agentIdsWithDepartment }, + }; + + const update = { + $set: { + statusLivechat: 'available', + }, + $addToSet: { + openBusinessHours: businessHourId, + }, + }; + + return this.update(query, update, { multi: true }); + } + + closeBusinessHourToAgentsWithoutDepartment(agentIdsWithDepartment = [], businessHourId) { + const query = { + _id: { $nin: agentIdsWithDepartment }, + }; + + const update = { + $pull: { + openBusinessHours: businessHourId, + }, + }; + + return this.update(query, update, { multi: true }); + } + closeAgentsBusinessHours(businessHourIds) { const query = { roles: 'livechat-agent', @@ -331,10 +393,11 @@ export class UsersRaw extends BaseRaw { return this.update(query, update, { multi: true }); } - updateLivechatStatusBasedOnBusinessHours() { + updateLivechatStatusBasedOnBusinessHours(userIds = []) { const query = { $or: [{ openBusinessHours: { $exists: false } }, { openBusinessHours: { $size: 0 } }], roles: 'livechat-agent', + ...Array.isArray(userIds) && userIds.length > 0 && { _id: { $in: userIds } }, }; const update = { diff --git a/definition/ILivechatBusinessHour.ts b/definition/ILivechatBusinessHour.ts index 293ab4880762..bae2b38c69df 100644 --- a/definition/ILivechatBusinessHour.ts +++ b/definition/ILivechatBusinessHour.ts @@ -1,5 +1,8 @@ +import { ILivechatDepartment } from './ILivechatDepartment'; + export enum LivechatBussinessHourTypes { SINGLE = 'single', + MULTIPLE = 'multiple', } interface IBusinessHourTime { @@ -17,7 +20,7 @@ export interface IBusinessHourWorkHour { export interface IBusinessHourTimezone { name: string; - utc: number; + utc: string; } export interface ILivechatBusinessHour { @@ -29,4 +32,5 @@ export interface ILivechatBusinessHour { ts: Date; workHours: IBusinessHourWorkHour[]; _updatedAt?: Date; + departments?: ILivechatDepartment[]; } diff --git a/definition/ILivechatDepartment.ts b/definition/ILivechatDepartment.ts new file mode 100644 index 000000000000..22c352007e8b --- /dev/null +++ b/definition/ILivechatDepartment.ts @@ -0,0 +1,15 @@ +export interface ILivechatDepartment { + _id: string; + name: string; + enabled: boolean; + description: string; + showOnRegistration: boolean; + showOnOfflineForm: boolean; + requestTagBeforeClosingChat: boolean; + email: string; + chatClosingTags: string[]; + offlineMessageChannelName: string; + numAgents: number; + _updatedAt?: Date; + businessHourId?: string; +} diff --git a/ee/app/livechat-enterprise/client/index.js b/ee/app/livechat-enterprise/client/index.js index 4ce8c3ffebf0..1d535f53f072 100644 --- a/ee/app/livechat-enterprise/client/index.js +++ b/ee/app/livechat-enterprise/client/index.js @@ -8,6 +8,8 @@ import './views/livechatTags'; import './views/livechatTagForm'; import './views/livechatPriorities'; import './views/livechatPriorityForm'; +import './views/business-hours/livechatBusinessHours'; +import './startup'; hasLicense('livechat-enterprise').then((enabled) => { if (!enabled) { @@ -16,4 +18,5 @@ hasLicense('livechat-enterprise').then((enabled) => { require('./views/app/registerCustomTemplates'); require('./views/livechatSideNavItems'); + require('./views/business-hours/Multiple'); }); diff --git a/ee/app/livechat-enterprise/client/route.js b/ee/app/livechat-enterprise/client/route.js index c6d8477a8a22..7f2ea05197d8 100644 --- a/ee/app/livechat-enterprise/client/route.js +++ b/ee/app/livechat-enterprise/client/route.js @@ -1,5 +1,5 @@ import { AccountBox } from '../../../../app/ui-utils'; -import { livechatManagerRoutes } from '../../../../app/livechat/client/route'; +import { livechatManagerRoutes, load } from '../../../../app/livechat/client/route'; AccountBox.addRoute({ name: 'livechat-monitors', @@ -80,3 +80,19 @@ AccountBox.addRoute({ i18nPageTitle: 'New_Priority', pageTemplate: 'livechatPriorityForm', }, livechatManagerRoutes); + +AccountBox.addRoute({ + name: 'livechat-business-hour-edit', + path: '/business-hours/:_id/edit', + sideNav: 'livechatFlex', + i18nPageTitle: 'Edit_Business_Hour', + pageTemplate: 'livechatBusinessHoursForm', +}, livechatManagerRoutes, load); + +AccountBox.addRoute({ + name: 'livechat-business-hour-new', + path: '/business-hours/new', + sideNav: 'livechatFlex', + i18nPageTitle: 'New_Business_Hour', + pageTemplate: 'livechatBusinessHoursForm', +}, livechatManagerRoutes, load); diff --git a/ee/app/livechat-enterprise/client/startup.ts b/ee/app/livechat-enterprise/client/startup.ts new file mode 100644 index 000000000000..a494801a8260 --- /dev/null +++ b/ee/app/livechat-enterprise/client/startup.ts @@ -0,0 +1,35 @@ +import { Meteor } from 'meteor/meteor'; + +import { MultipleBusinessHours } from './views/business-hours/Multiple'; +import { SingleBusinessHour } from '../../../../app/livechat/client/views/app/business-hours/Single'; +import { settings } from '../../../../app/settings/client'; +import { businessHourManager } from '../../../../app/livechat/client/views/app/business-hours/BusinessHours'; +import { IBusinessHour } from '../../../../app/livechat/client/views/app/business-hours/IBusinessHour'; +import { + addCustomFormTemplate, + removeCustomTemplate, +} from '../../../../app/livechat/client/views/app/customTemplates/register'; +import { LivechatBussinessHourTypes } from '../../../../definition/ILivechatBusinessHour'; + +const businessHours: Record = { + Multiple: new MultipleBusinessHours(), + Single: new SingleBusinessHour(), +}; + +Meteor.startup(function() { + settings.onload('Livechat_business_hour_type', (_, value) => { + removeCustomTemplate('livechatBusinessHoursForm'); + + switch (String(value).toLowerCase()) { + case LivechatBussinessHourTypes.SINGLE: + businessHourManager.setBusinessHourManager(new SingleBusinessHour() as IBusinessHour); + break; + case LivechatBussinessHourTypes.MULTIPLE: + businessHourManager.setBusinessHourManager(new MultipleBusinessHours() as IBusinessHour); + addCustomFormTemplate('livechatBusinessHoursForm', 'businessHoursCustomFieldsForm'); + break; + } + + businessHourManager.registerBusinessHourMethod(businessHours[value as string]); + }); +}); diff --git a/ee/app/livechat-enterprise/client/views/app/customTemplates/businessHoursCustomFieldsForm.html b/ee/app/livechat-enterprise/client/views/app/customTemplates/businessHoursCustomFieldsForm.html new file mode 100644 index 000000000000..e9b3a8ba2ba2 --- /dev/null +++ b/ee/app/livechat-enterprise/client/views/app/customTemplates/businessHoursCustomFieldsForm.html @@ -0,0 +1,58 @@ + diff --git a/ee/app/livechat-enterprise/client/views/app/customTemplates/businessHoursCustomFieldsForm.js b/ee/app/livechat-enterprise/client/views/app/customTemplates/businessHoursCustomFieldsForm.js new file mode 100644 index 000000000000..8a5b94c39c07 --- /dev/null +++ b/ee/app/livechat-enterprise/client/views/app/customTemplates/businessHoursCustomFieldsForm.js @@ -0,0 +1,103 @@ +import moment from 'moment'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; + +import './businessHoursCustomFieldsForm.html'; + +Template.businessHoursCustomFieldsForm.helpers({ + timezones() { + return moment.tz.names().map((name) => ({ + key: name, + i18nLabel: name, + })); + }, + data() { + return Template.instance().businessHour.get(); + }, + showBusinessHourActiveFormTrueChecked() { + if (Template.instance().businessHour.get().active) { + return 'checked'; + } + }, + showBusinessHourActiveFormFalseChecked() { + if (!Template.instance().businessHour.get().active) { + return 'checked'; + } + }, + active() { + return Template.instance().active.get(); + }, + selectedOption(val) { + const { timezone } = Template.instance().businessHour.get(); + if (!timezone) { + return; + } + return timezone.name === val; + }, + departmentModifier() { + return (filter, text = '') => { + const f = filter.get(); + return `${ f.length === 0 ? text : text.replace(new RegExp(filter.get(), 'i'), (part) => `${ part }`) }`; + }; + }, + onClickTagDepartment() { + return Template.instance().onClickTagDepartment; + }, + selectedDepartments() { + return Template.instance().selectedDepartments.get(); + }, + selectedDepartmentsIds() { + return Template.instance().selectedDepartments.get().map((dept) => dept._id); + }, + onSelectDepartments() { + return Template.instance().onSelectDepartments; + }, + exceptionsDepartments() { + const businessHour = Template.instance().businessHour.get(); + const exceptions = [...Template.instance().selectedDepartments.get().map((dept) => dept._id)]; + if (!businessHour || !businessHour.departments) { + return exceptions; + } + return exceptions.concat(...businessHour.departments.map((dept) => dept._id)); + }, + departmentsConditions() { + return { businessHourId: { $exists: false } }; + }, +}); + +Template.businessHoursCustomFieldsForm.events({ + 'change .js-active-business-hour': (event, instance) => { + instance.active.set(event.target.value === '1'); + }, +}); + +Template.businessHoursCustomFieldsForm.onCreated(function() { + this.active = new ReactiveVar(false); + this.businessHour = new ReactiveVar({ + active: false, + }); + this.selectedDepartments = new ReactiveVar([]); + + this.autorun(() => { + // To make this template reactive we expect a ReactiveVar through the data property, + // because the parent form may not be rerender, only the dynamic template data + this.businessHour.set(this.data.get()); + this.active.set(this.businessHour.get().active); + const { departments } = this.businessHour.get(); + if (departments?.length) { + this.selectedDepartments.set(departments.map((dept) => ({ + _id: dept._id, + text: dept.name, + }))); + } + }); + + this.onSelectDepartments = ({ item: department }) => { + department.text = department.name; + this.selectedDepartments.set(this.selectedDepartments.get().concat(department)); + }; + + this.onClickTagDepartment = (department) => { + this.selectedDepartments.set(this.selectedDepartments.get().filter((dept) => dept._id !== department._id)); + }; +}); diff --git a/ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.html b/ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.html index 264078cc53d5..969666e2867d 100644 --- a/ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.html +++ b/ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.html @@ -1,56 +1,67 @@ \ No newline at end of file + rows="4">{{department.waitingQueueMessage}} +
+ +
+ {{> livechatAutocompleteUser + onClickTag=onClickTagDepartment + list=selectedDepartments + onSelect=onSelectDepartments + collection='CachedDepartmentList' + endpoint='livechat/department.autocomplete' + field='name' + sort='name' + label="List_of_departments_for_forward" + placeholder="Enter_a_department_name" + name="department" + icon="queue" + noMatchTemplate="userSearchEmpty" + templateItem="popupList_item_channel" + template="roomSearch" + noMatchTemplate="roomSearchEmpty" + modifier=departmentModifier + showLabel=true + exceptions=exceptionsDepartments + }} + +
+ {{{_ "List_of_departments_for_forward_description"}}} +
+ {{#with businessHour}} +
+ +
+ +
+
+ {{/with}} +
+ diff --git a/ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.js b/ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.js index a1851d16cea3..1af3c036cda6 100644 --- a/ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.js +++ b/ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.js @@ -30,9 +30,13 @@ Template.livechatDepartmentCustomFieldsForm.helpers({ const department = Template.instance().department.get(); return [department && department._id, ...Template.instance().selectedDepartments.get().map((dept) => dept._id)]; }, + businessHour() { + return Template.instance().businessHour.get(); + }, }); Template.livechatDepartmentCustomFieldsForm.onCreated(function() { + this.businessHour = new ReactiveVar({}); this.selectedDepartments = new ReactiveVar([]); const { id: _id, department: contextDepartment } = this.data; @@ -51,7 +55,14 @@ Template.livechatDepartmentCustomFieldsForm.onCreated(function() { const { department } = await APIClient.v1.get(`livechat/department/${ _id }`); if (department.departmentsAllowedToForward) { const { departments } = await APIClient.v1.get(`livechat/department.listByIds?${ mountArrayQueryParameters('ids', department.departmentsAllowedToForward) }&fields=${ JSON.stringify({ fields: { name: 1 } }) }`); - this.selectedDepartments.set(departments.map((dept) => ({ _id: dept._id, text: dept.name }))); + this.selectedDepartments.set(departments.map((dept) => ({ + _id: dept._id, + text: dept.name, + }))); + } + if (department.businessHourId) { + const { businessHour } = await APIClient.v1.get(`livechat/business-hour?_id=${ department.businessHourId }`); + this.businessHour.set(businessHour); } this.department.set(department); }); diff --git a/ee/app/livechat-enterprise/client/views/app/registerCustomTemplates.js b/ee/app/livechat-enterprise/client/views/app/registerCustomTemplates.js index 6a655f1b1ae4..188ced2d77c9 100644 --- a/ee/app/livechat-enterprise/client/views/app/registerCustomTemplates.js +++ b/ee/app/livechat-enterprise/client/views/app/registerCustomTemplates.js @@ -5,6 +5,7 @@ import './customTemplates/livechatAgentEditCustomFieldsForm'; import './customTemplates/livechatAgentInfoCustomFieldsForm'; import './customTemplates/visitorEditCustomFieldsForm'; import './customTemplates/visitorInfoCustomForm'; +import './customTemplates/businessHoursCustomFieldsForm'; addCustomFormTemplate('livechatAgentEditForm', 'livechatAgentEditCustomFieldsForm'); addCustomFormTemplate('livechatAgentInfoForm', 'livechatAgentInfoCustomFieldsForm'); diff --git a/ee/app/livechat-enterprise/client/views/business-hours/Multiple.ts b/ee/app/livechat-enterprise/client/views/business-hours/Multiple.ts new file mode 100644 index 000000000000..15c383c98bd5 --- /dev/null +++ b/ee/app/livechat-enterprise/client/views/business-hours/Multiple.ts @@ -0,0 +1,16 @@ +import { IBusinessHour } from '../../../../../../app/livechat/client/views/app/business-hours/IBusinessHour'; +import { ILivechatBusinessHour, LivechatBussinessHourTypes } from '../../../../../../definition/ILivechatBusinessHour'; + +export class MultipleBusinessHours implements IBusinessHour { + getView(): string { + return 'livechatBusinessHours'; + } + + shouldShowCustomTemplate(businessHourData: ILivechatBusinessHour): boolean { + return !businessHourData._id || businessHourData.type !== LivechatBussinessHourTypes.SINGLE; + } + + shouldShowBackButton(): boolean { + return true; + } +} diff --git a/ee/app/livechat-enterprise/client/views/business-hours/livechatBusinessHours.html b/ee/app/livechat-enterprise/client/views/business-hours/livechatBusinessHours.html new file mode 100644 index 000000000000..14a838475287 --- /dev/null +++ b/ee/app/livechat-enterprise/client/views/business-hours/livechatBusinessHours.html @@ -0,0 +1,86 @@ + diff --git a/ee/app/livechat-enterprise/client/views/business-hours/livechatBusinessHours.js b/ee/app/livechat-enterprise/client/views/business-hours/livechatBusinessHours.js new file mode 100644 index 000000000000..0c8a995d30b1 --- /dev/null +++ b/ee/app/livechat-enterprise/client/views/business-hours/livechatBusinessHours.js @@ -0,0 +1,133 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { ReactiveDict } from 'meteor/reactive-dict'; + +import { hasLicense } from '../../../../license/client'; +import './livechatBusinessHours.html'; +import { modal } from '../../../../../../app/ui-utils/client'; +import { APIClient, handleError, t } from '../../../../../../app/utils'; +import { LivechatBussinessHourTypes } from '../../../../../../definition/ILivechatBusinessHour'; + +const licenseEnabled = new ReactiveVar(false); + +hasLicense('livechat-enterprise').then((enabled) => { + licenseEnabled.set(enabled); +}); + +Template.livechatBusinessHours.helpers({ + hasLicense() { + return licenseEnabled.get(); + }, + businessHours() { + return Template.instance().businessHours.get(); + }, + isLoading() { + return Template.instance().state.get('loading'); + }, + isReady() { + const instance = Template.instance(); + return instance.ready && instance.ready.get(); + }, + isDefault() { + return this.type === LivechatBussinessHourTypes.SINGLE; + }, + openDays() { + return this + .workHours + .filter((hour) => hour.open) + .map((hour) => hour.day) + .map((day) => day?.slice(0, 3)) + .join(', '); + }, + onTableScroll() { + const instance = Template.instance(); + return function(currentTarget) { + if ( + currentTarget.offsetHeight + currentTarget.scrollTop + >= currentTarget.scrollHeight - 100 + ) { + return instance.limit.set(instance.limit.get() + 50); + } + }; + }, +}); + +const DEBOUNCE_TIME_FOR_SEARCH_IN_MS = 300; + +Template.livechatBusinessHours.events({ + 'click .remove-business-hour'(e, instance) { + e.preventDefault(); + e.stopPropagation(); + + modal.open({ + title: t('Are_you_sure'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes'), + cancelButtonText: t('Cancel'), + closeOnConfirm: false, + html: false, + }, () => { + Meteor.call('livechat:removeBusinessHour', this._id, (error/* , result*/) => { + if (error) { + return handleError(error); + } + instance.businessHours.set(instance.businessHours.curValue.filter((businessHour) => businessHour._id !== this._id)); + modal.open({ + title: t('Removed'), + text: t('Business_Hour_Removed'), + type: 'success', + timer: 1000, + showConfirmButton: false, + }); + }); + }); + }, + + 'click .business-hour-info'(e/* , instance*/) { + e.preventDefault(); + FlowRouter.go('livechat-business-hour-edit', { _id: this._id }); + }, + + 'keydown #business-hour-filter'(e) { + if (e.which === 13) { + e.stopPropagation(); + e.preventDefault(); + } + }, + 'keyup #business-hour-filter': _.debounce((e, t) => { + e.stopPropagation(); + e.preventDefault(); + t.filter.set(e.currentTarget.value); + }, DEBOUNCE_TIME_FOR_SEARCH_IN_MS), +}); + + +Template.livechatBusinessHours.onCreated(function() { + const instance = this; + this.limit = new ReactiveVar(50); + this.filter = new ReactiveVar(''); + this.state = new ReactiveDict({ + loading: false, + }); + this.ready = new ReactiveVar(true); + this.businessHours = new ReactiveVar([]); + + this.autorun(async function() { + const limit = instance.limit.get(); + const filter = instance.filter.get(); + let baseUrl = `livechat/business-hours.list?count=${ limit }`; + + if (filter) { + baseUrl += `&name=${ encodeURIComponent(filter) }`; + } + + const { businessHours } = await APIClient.v1.get(baseUrl); + instance.businessHours.set(businessHours); + instance.ready.set(true); + }); +}); diff --git a/ee/app/livechat-enterprise/server/api/business-hours.ts b/ee/app/livechat-enterprise/server/api/business-hours.ts new file mode 100644 index 000000000000..2f5fa314e6cc --- /dev/null +++ b/ee/app/livechat-enterprise/server/api/business-hours.ts @@ -0,0 +1,19 @@ +import { API } from '../../../../../app/api/server'; +import { findBusinessHours } from '../business-hour/lib/business-hour'; + +API.v1.addRoute('livechat/business-hours.list', { authRequired: true }, { + get() { + const { offset, count } = this.getPaginationItems(); + const { sort } = this.parseJsonQuery(); + const { name } = this.queryParams; + + return API.v1.success(Promise.await(findBusinessHours( + this.userId, + { + offset, + count, + sort, + }, + name))); + }, +}); diff --git a/ee/app/livechat-enterprise/server/api/index.js b/ee/app/livechat-enterprise/server/api/index.js index 54152c09bb0a..da303b6e498a 100644 --- a/ee/app/livechat-enterprise/server/api/index.js +++ b/ee/app/livechat-enterprise/server/api/index.js @@ -5,3 +5,4 @@ import './monitors'; import './priorities'; import './tags'; import './units'; +import './business-hours'; diff --git a/ee/app/livechat-enterprise/server/api/lib/definition.ts b/ee/app/livechat-enterprise/server/api/lib/definition.ts new file mode 100644 index 000000000000..063d80b45135 --- /dev/null +++ b/ee/app/livechat-enterprise/server/api/lib/definition.ts @@ -0,0 +1,11 @@ +export interface IPagination { + offset: number; + count: number; + sort: Record; +} + +export interface IPaginatedResponse { + count: number; + offset: number; + total: number; +} diff --git a/ee/app/livechat-enterprise/server/business-hour/Multiple.ts b/ee/app/livechat-enterprise/server/business-hour/Multiple.ts new file mode 100644 index 000000000000..e2818de0d250 --- /dev/null +++ b/ee/app/livechat-enterprise/server/business-hour/Multiple.ts @@ -0,0 +1,228 @@ +import moment from 'moment'; + +import { + AbstractBusinessHour, + IBusinessHour, +} from '../../../../../app/livechat/server/business-hour/AbstractBusinessHour'; +import { ILivechatBusinessHour, LivechatBussinessHourTypes } from '../../../../../definition/ILivechatBusinessHour'; +import { LivechatDepartment } from '../../../../../app/models/server/raw'; +import { LivechatDepartmentRaw } from '../../../../../app/models/server/raw/LivechatDepartment'; +import LivechatDepartmentAgents, { LivechatDepartmentAgentsRaw } from '../../../models/server/raw/LivechatDepartmentAgents'; + +interface IBusinessHoursExtraProperties extends ILivechatBusinessHour { + timezoneName: string; + departmentsToApplyBusinessHour: string; +} + +export class MultipleBusinessHours extends AbstractBusinessHour implements IBusinessHour { + private DepartmentsRepository: LivechatDepartmentRaw = LivechatDepartment; + + private DepartmentsAgentsRepository: LivechatDepartmentAgentsRaw = LivechatDepartmentAgents; + + async closeBusinessHoursByDayAndHour(day: string, hour: string): Promise { + const businessHours = await this.BusinessHourRepository.findActiveBusinessHoursToClose(day, hour, undefined, { + fields: { + _id: 1, + type: 1, + }, + }); + for (const businessHour of businessHours) { + this.closeBusinessHour(businessHour); + } + } + + async getBusinessHour(id: string): Promise { + if (!id) { + return; + } + const businessHour: ILivechatBusinessHour = await this.BusinessHourRepository.findOneById(id); + businessHour.departments = await this.DepartmentsRepository.findByBusinessHourId(businessHour._id, { fields: { name: 1 } }).toArray(); + return businessHour; + } + + async openBusinessHoursByDayHour(day: string, hour: string): Promise { + const businessHours = await this.BusinessHourRepository.findActiveBusinessHoursToOpen(day, hour, undefined, { + fields: { + _id: 1, + type: 1, + }, + }); + for (const businessHour of businessHours) { + this.openBusinessHour(businessHour); + } + } + + async saveBusinessHour(businessHourData: IBusinessHoursExtraProperties): Promise { + businessHourData.timezone = { + name: businessHourData.timezoneName, + utc: this.getUTCFromTimezone(businessHourData.timezoneName), + }; + if (businessHourData.timezone.name) { + businessHourData = this.convertWorkHoursWithSpecificTimezone(businessHourData); + } else { + businessHourData = this.convertWorkHoursWithServerTimezone(businessHourData) as IBusinessHoursExtraProperties; + } + businessHourData.active = Boolean(businessHourData.active); + businessHourData.type = businessHourData.type || LivechatBussinessHourTypes.MULTIPLE; + const departments = businessHourData.departmentsToApplyBusinessHour?.split(','); + delete businessHourData.timezoneName; + delete businessHourData.departmentsToApplyBusinessHour; + delete businessHourData.departments; + if (businessHourData._id) { + await this.BusinessHourRepository.updateOne(businessHourData._id, businessHourData); + return this.updateDepartmentBusinessHour(businessHourData._id, departments); + } + const { insertedId } = await this.BusinessHourRepository.insertOne(businessHourData); + return this.updateDepartmentBusinessHour(insertedId, departments); + } + + async removeBusinessHourById(id: string): Promise { + const businessHour = await this.BusinessHourRepository.findOneById(id); + if (!businessHour || businessHour.type !== LivechatBussinessHourTypes.MULTIPLE) { + return; + } + this.BusinessHourRepository.removeById(id); + this.DepartmentsRepository.removeBusinessHourFromDepartmentsByBusinessHourId(id); + } + + async openBusinessHoursIfNeeded(): Promise { + await this.removeBusinessHoursFromUsers(); + const currentTime = moment.utc(moment().utc().format('dddd:HH:mm'), 'dddd:HH:mm'); + const day = currentTime.format('dddd'); + const activeBusinessHours = await this.BusinessHourRepository.findActiveAndOpenBusinessHoursByDay(day, { + fields: { + workHours: 1, + timezone: 1, + type: 1, + }, + }); + const businessHoursToOpenIds = await this.getBusinessHoursThatMustBeOpened(currentTime, activeBusinessHours); + for (const businessHour of businessHoursToOpenIds) { + this.openBusinessHour(businessHour); + } + } + + async removeBusinessHourFromUsers(departmentId: string, businessHourId: string): Promise { + const agentIds = (await this.DepartmentsAgentsRepository.findByDepartmentIds([departmentId], { fields: { agentId: 1 } }).toArray()).map((dept: any) => dept.agentId); + await this.UsersRepository.closeBusinessHourByAgentIds(agentIds, businessHourId); + return this.UsersRepository.updateLivechatStatusBasedOnBusinessHours(); + } + + async removeBusinessHourFromUsersByIds(userIds: string[], businessHourId: string): Promise { + if (!userIds?.length) { + return; + } + + await this.UsersRepository.closeBusinessHourByAgentIds(userIds, businessHourId); + return this.UsersRepository.updateLivechatStatusBasedOnBusinessHours(userIds); + } + + async addBusinessHourToUsersByIds(userIds: string[], businessHourId: string): Promise { + if (!userIds?.length) { + return; + } + + await this.UsersRepository.openBusinessHourByAgentIds(userIds, businessHourId); + return this.UsersRepository.updateLivechatStatusBasedOnBusinessHours(userIds); + } + + async setDefaultToUsersIfNeeded(userIds: string[]): Promise { + if (!userIds?.length) { + return; + } + const currentTime = moment(moment().format('dddd:HH:mm'), 'dddd:HH:mm'); + const day = currentTime.format('dddd'); + const [businessHour] = await this.BusinessHourRepository.findDefaultActiveAndOpenBusinessHoursByDay(day); + if (!businessHour) { + return; + } + for (const userId of userIds) { + if (!(await this.DepartmentsAgentsRepository.findDepartmentsWithBusinessHourByAgentId(userId)).length) { // eslint-disable-line no-await-in-loop + await this.UsersRepository.openBusinessHourByAgentIds([userId], businessHour._id); // eslint-disable-line no-await-in-loop + } + } + await this.UsersRepository.updateLivechatStatusBasedOnBusinessHours(); + } + + private async updateDepartmentBusinessHour(businessHourId: string, departments: string[]): Promise { + if (businessHourId) { + await this.DepartmentsRepository.removeBusinessHourFromDepartmentsByBusinessHourId(businessHourId); + } + if (!departments?.length) { + return; + } + return this.DepartmentsRepository.addBusinessHourToDepartamentsByIds(departments, businessHourId); + } + + private convertWorkHoursWithSpecificTimezone(businessHourData: IBusinessHoursExtraProperties): IBusinessHoursExtraProperties { + businessHourData.workHours.forEach((hour: any) => { + const startUtc = moment.tz(`${ hour.day }:${ hour.start }`, 'dddd:HH:mm', businessHourData.timezoneName).utc(); + const finishUtc = moment.tz(`${ hour.day }:${ hour.finish }`, 'dddd:HH:mm', businessHourData.timezoneName).utc(); + hour.start = { + time: hour.start, + utc: { + dayOfWeek: startUtc.clone().format('dddd'), + time: startUtc.clone().format('HH:mm'), + }, + cron: { + dayOfWeek: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(startUtc, 'dddd'), + time: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(startUtc, 'HH:mm'), + }, + }; + hour.finish = { + time: hour.finish, + utc: { + dayOfWeek: finishUtc.clone().format('dddd'), + time: finishUtc.clone().format('HH:mm'), + }, + cron: { + dayOfWeek: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(finishUtc, 'dddd'), + time: this.formatDayOfTheWeekFromServerTimezoneAndUtcHour(finishUtc, 'HH:mm'), + }, + }; + }); + return businessHourData; + } + + private formatDayOfTheWeekFromServerTimezoneAndUtcHour(utc: any, format: string): string { + return moment(utc.format('dddd:HH:mm'), 'dddd:HH:mm').add(moment().utcOffset() / 60, 'hours').format(format); + } + + private async openBusinessHour(businessHour: Record): Promise { + if (businessHour.type === LivechatBussinessHourTypes.MULTIPLE) { + const agentIds = await this.getAgentIdsFromBusinessHour(businessHour); + return this.UsersRepository.openBusinessHourByAgentIds(agentIds, businessHour._id); + } + const agentIdsWithDepartment = await this.getAgentIdsWithDepartment(); + return this.UsersRepository.openBusinessHourToAgentsWithoutDepartment(agentIdsWithDepartment, businessHour._id); + } + + private async getAgentIdsFromBusinessHour(businessHour: Record): Promise { + const departmentIds = (await this.DepartmentsRepository.findByBusinessHourId(businessHour._id, { fields: { _id: 1 } }).toArray()).map((dept: any) => dept._id); + const agentIds = (await this.DepartmentsAgentsRepository.findByDepartmentIds(departmentIds, { fields: { agentId: 1 } }).toArray()).map((dept: any) => dept.agentId); + return agentIds; + } + + private async getAgentIdsWithDepartment(): Promise { + const agentIdsWithDepartment = (await this.DepartmentsAgentsRepository.find({}, { fields: { agentId: 1 } }).toArray()).map((dept: any) => dept.agentId); + return agentIdsWithDepartment; + } + + private async closeBusinessHour(businessHour: Record): Promise { + if (businessHour.type === LivechatBussinessHourTypes.MULTIPLE) { + const agentIds = await this.getAgentIdsFromBusinessHour(businessHour); + await this.UsersRepository.closeBusinessHourByAgentIds(agentIds, businessHour._id); + return this.UsersRepository.updateLivechatStatusBasedOnBusinessHours(); + } + const agentIdsWithDepartment = await this.getAgentIdsWithDepartment(); + await this.UsersRepository.closeBusinessHourToAgentsWithoutDepartment(agentIdsWithDepartment, businessHour._id); + return this.UsersRepository.updateLivechatStatusBasedOnBusinessHours(); + } + + private getUTCFromTimezone(timezone: string): string { + if (!timezone) { + return String(moment().utcOffset() / 60); + } + return moment.tz(timezone).format('Z'); + } +} diff --git a/ee/app/livechat-enterprise/server/business-hour/lib/business-hour.ts b/ee/app/livechat-enterprise/server/business-hour/lib/business-hour.ts new file mode 100644 index 000000000000..4ae955b70fcb --- /dev/null +++ b/ee/app/livechat-enterprise/server/business-hour/lib/business-hour.ts @@ -0,0 +1,37 @@ +import s from 'underscore.string'; + +import { hasPermissionAsync } from '../../../../../../app/authorization/server/functions/hasPermission'; +import { LivechatBusinessHours } from '../../../../../../app/models/server/raw'; +import { IPaginatedResponse, IPagination } from '../../api/lib/definition'; +import { ILivechatBusinessHour } from '../../../../../../definition/ILivechatBusinessHour'; + +interface IResponse extends IPaginatedResponse { + businessHours: ILivechatBusinessHour[]; +} + +export async function findBusinessHours(userId: string, { offset, count, sort }: IPagination, name?: string): Promise { + if (!await hasPermissionAsync(userId, 'view-livechat-business-hours')) { + throw new Error('error-not-authorized'); + } + const query = {}; + if (name) { + const filterReg = new RegExp(s.escapeRegExp(name), 'i'); + Object.assign(query, { name: filterReg }); + } + const cursor = LivechatBusinessHours.find(query, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }); + + const total = await cursor.count(); + + const businessHours = await cursor.toArray(); + + return { + businessHours, + count: businessHours.length, + offset, + total, + }; +} diff --git a/ee/app/livechat-enterprise/server/hooks/afterRemoveDepartment.js b/ee/app/livechat-enterprise/server/hooks/afterRemoveDepartment.js index 4a101a0787fb..29b34907a72c 100644 --- a/ee/app/livechat-enterprise/server/hooks/afterRemoveDepartment.js +++ b/ee/app/livechat-enterprise/server/hooks/afterRemoveDepartment.js @@ -1,10 +1,16 @@ import { callbacks } from '../../../../../app/callbacks'; import { LivechatDepartment } from '../../../../../app/models/server'; +import { businessHourManager } from '../../../../../app/livechat/server/business-hour'; callbacks.add('livechat.afterRemoveDepartment', (department) => { if (!department) { return department; } LivechatDepartment.removeDepartmentFromForwardListById(department._id); + const deletedDepartment = LivechatDepartment.trashFindOneById(department._id); + if (!deletedDepartment.businessHourId) { + return department; + } + Promise.await(businessHourManager.removeBusinessHourIdFromUsers(deletedDepartment)); return department; }, callbacks.priority.HIGH, 'livechat-after-remove-department'); diff --git a/ee/app/livechat-enterprise/server/hooks/index.js b/ee/app/livechat-enterprise/server/hooks/index.js index 4877aa007de0..23eebee8c3c6 100644 --- a/ee/app/livechat-enterprise/server/hooks/index.js +++ b/ee/app/livechat-enterprise/server/hooks/index.js @@ -15,3 +15,5 @@ import './onLoadConfigApi'; import './onSetUserStatusLivechat'; import './onCloseLivechat'; import './onSaveVisitorInfo'; +import './onBusinessHourStart'; +import './onChangeAgentDepartment'; diff --git a/ee/app/livechat-enterprise/server/hooks/onBusinessHourStart.ts b/ee/app/livechat-enterprise/server/hooks/onBusinessHourStart.ts new file mode 100644 index 000000000000..b46f148d1540 --- /dev/null +++ b/ee/app/livechat-enterprise/server/hooks/onBusinessHourStart.ts @@ -0,0 +1,14 @@ +import { callbacks } from '../../../../../app/callbacks/server'; +import { MultipleBusinessHours } from '../business-hour/Multiple'; +import { settings } from '../../../../../app/settings/server'; + +callbacks.add('on-business-hour-start', (options: any = {}) => { + const { BusinessHourClass } = options; + if (!BusinessHourClass) { + return options; + } + if (settings.get('Livechat_business_hour_type') === 'Single') { + return options; + } + return { BusinessHourClass: MultipleBusinessHours }; +}, callbacks.priority.HIGH, 'livechat-on-business-hour-start'); diff --git a/ee/app/livechat-enterprise/server/hooks/onChangeAgentDepartment.ts b/ee/app/livechat-enterprise/server/hooks/onChangeAgentDepartment.ts new file mode 100644 index 000000000000..ddb1154349dd --- /dev/null +++ b/ee/app/livechat-enterprise/server/hooks/onChangeAgentDepartment.ts @@ -0,0 +1,27 @@ +import { callbacks } from '../../../../../app/callbacks/server'; +import { businessHourManager } from '../../../../../app/livechat/server/business-hour'; +import { LivechatDepartment } from '../../../../../app/models/server'; + +callbacks.add('livechat.removeAgentDepartment', async (options: any = {}) => { + const { departmentId, agentsId } = options; + const department = LivechatDepartment.findOneById(departmentId, { fields: { businessHourId: 1 } }); + if (!department || !department.businessHourId) { + return options; + } + + await businessHourManager.removeBusinessHourFromUsersByIds(agentsId, department.businessHourId); + await businessHourManager.setDefaultToUsersIfNeeded(agentsId); + return options; +}, callbacks.priority.HIGH, 'livechat-on-remove-agent-department'); + +callbacks.add('livechat.saveAgentDepartment', async (options: any = {}) => { + const { departmentId, agentsId } = options; + const department = LivechatDepartment.findOneById(departmentId, { fields: { businessHourId: 1 } }); + if (!department || !department.businessHourId) { + return options; + } + + await businessHourManager.addBusinessHourToUsersByIds(agentsId, department.businessHourId); + + return options; +}, callbacks.priority.HIGH, 'livechat-on-save-agent-department'); diff --git a/ee/app/livechat-enterprise/server/index.js b/ee/app/livechat-enterprise/server/index.js index 890418ef567c..3aa2319d672e 100644 --- a/ee/app/livechat-enterprise/server/index.js +++ b/ee/app/livechat-enterprise/server/index.js @@ -10,6 +10,7 @@ import './methods/removeUnit'; import './methods/saveUnit'; import './methods/savePriority'; import './methods/removePriority'; +import './methods/removeBusinessHour'; import LivechatUnit from '../../models/server/models/LivechatUnit'; import LivechatTag from '../../models/server/models/LivechatTag'; import LivechatUnitMonitors from '../../models/server/models/LivechatUnitMonitors'; diff --git a/ee/app/livechat-enterprise/server/methods/removeBusinessHour.ts b/ee/app/livechat-enterprise/server/methods/removeBusinessHour.ts new file mode 100644 index 000000000000..960211f1cf5b --- /dev/null +++ b/ee/app/livechat-enterprise/server/methods/removeBusinessHour.ts @@ -0,0 +1,14 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../../../app/authorization/server'; +import { businessHourManager } from '../../../../../app/livechat/server/business-hour'; + +Meteor.methods({ + 'livechat:removeBusinessHour'(id: string) { + if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-livechat-business-hours')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:removeBusinessHour' }); + } + + return Promise.await(businessHourManager.removeBusinessHourById(id)); + }, +}); diff --git a/ee/app/livechat-enterprise/server/settings.js b/ee/app/livechat-enterprise/server/settings.js index 118fe5967a54..e2e92660ca2e 100644 --- a/ee/app/livechat-enterprise/server/settings.js +++ b/ee/app/livechat-enterprise/server/settings.js @@ -58,5 +58,23 @@ export const createSettings = () => { enableQuery: { _id: 'Livechat_Routing_Method', value: { $ne: 'Manual_Selection' } }, }); + settings.addGroup('Omnichannel', function() { + this.section('Business_Hours', function() { + this.add('Livechat_business_hour_type', 'Single', { + type: 'select', + values: [{ + key: 'Single', + i18nLabel: 'Single', + }, { + key: 'Multiple', + i18nLabel: 'Multiple', + }], + public: true, + i18nLabel: 'Livechat_business_hour_type', + }); + }); + }); + + Settings.addOptionValueById('Livechat_Routing_Method', { key: 'Load_Balancing', i18nLabel: 'Load_Balancing' }); }; diff --git a/ee/app/livechat-enterprise/server/startup.js b/ee/app/livechat-enterprise/server/startup.js index 91d956ad449c..e852f250c989 100644 --- a/ee/app/livechat-enterprise/server/startup.js +++ b/ee/app/livechat-enterprise/server/startup.js @@ -4,9 +4,15 @@ import { settings } from '../../../../app/settings'; import { checkWaitingQueue, updatePredictedVisitorAbandonment } from './lib/Helper'; import { VisitorInactivityMonitor } from './lib/VisitorInactivityMonitor'; import './lib/query.helper'; +import { MultipleBusinessHours } from './business-hour/Multiple'; +import { SingleBusinessHour } from '../../../../app/livechat/server/business-hour/Single'; +import { businessHourManager } from '../../../../app/livechat/server/business-hour'; const visitorActivityMonitor = new VisitorInactivityMonitor(); - +const businessHours = { + Multiple: new MultipleBusinessHours(), + Single: new SingleBusinessHour(), +}; Meteor.startup(function() { settings.onload('Livechat_maximum_chats_per_agent', function(/* key, value */) { checkWaitingQueue(); @@ -21,4 +27,8 @@ Meteor.startup(function() { settings.onload('Livechat_visitor_inactivity_timeout', function() { updatePredictedVisitorAbandonment(); }); + settings.onload('Livechat_business_hour_type', (_, value) => { + businessHourManager.registerBusinessHourMethod(businessHours[value]); + businessHourManager.dispatchOnStartTasks(); + }); }); diff --git a/ee/app/models/server/raw/LivechatDepartmentAgents.ts b/ee/app/models/server/raw/LivechatDepartmentAgents.ts new file mode 100644 index 000000000000..0e4ae010f35a --- /dev/null +++ b/ee/app/models/server/raw/LivechatDepartmentAgents.ts @@ -0,0 +1,29 @@ +import { LivechatDepartmentAgentsRaw as Raw } from '../../../../../app/models/server/raw/LivechatDepartmentAgents'; +import { LivechatDepartmentAgents } from '../../../../../app/models/server'; + +export class LivechatDepartmentAgentsRaw extends Raw { + findDepartmentsWithBusinessHourByAgentId(agentId: string): Promise> { + const match = { + $match: { agentId }, + }; + const lookup = { + $lookup: { + from: 'rocketchat_livechat_department', + localField: 'departmentId', + foreignField: '_id', + as: 'departments', + }, + }; + const unwind = { + $unwind: { + path: '$departments', + preserveNullAndEmptyArrays: true, + }, + }; + const withBusinessHourId = { $match: { 'departments.businessHourId': { $exists: true } } }; + const project = { $project: { departments: 0 } }; + return this.col.aggregate([match, lookup, unwind, withBusinessHourId, project]).toArray(); + } +} + +export default new LivechatDepartmentAgentsRaw(LivechatDepartmentAgents.model.rawCollection()); diff --git a/ee/i18n/en.i18n.json b/ee/i18n/en.i18n.json index 695954324418..3365ac9bdb82 100644 --- a/ee/i18n/en.i18n.json +++ b/ee/i18n/en.i18n.json @@ -7,11 +7,14 @@ "error-max-number-simultaneous-chats-reached": "The maximum number of simultaneous chats per agent has been reached.", "Add_monitor": "Add monitor", "Available_departments": "Available Departments", + "Business_Hour": "Business Hour", + "Business_Hour_Removed": "Business Hour Removed", "Canned Responses": "Canned Responses", "Canned_Response_Removed": "Canned Response Removed", "Canned_Responses_Enable": "Enable Canned Responses", "Closed_automatically": "Closed automatically by the system", "Default_value": "Default value", + "Edit_Business_Hour": "Edit Business Hour", "Edit_Tag": "Edit Tag", "Edit_Unit": "Edit Unit", "Edit_Priority": "Edit Priority", @@ -38,9 +41,11 @@ "LDAP_Sync_User_Active_State_Both": "Enable and Disable Users", "LDAP_Validate_Roles_For_Each_Login": "Validate mapping for each login", "LDAP_Validate_Roles_For_Each_Login_Description": "If the validation should occurs for each login (Be careful with this setting because it will overwrite the user roles in each login, otherwise this will be validated only at the moment of user creation).", + "List_of_departments_to_apply_this_business_hour": "List of departments to apply this business hour", "List_of_departments_for_forward": "List of departments allowed for forwarding (Optional)", "List_of_departments_for_forward_description": "Allow to set a restricted list of departments that can receive chats from this department", "Livechat_abandoned_rooms_closed_custom_message": "Custom message when room is automatically closed by visitor inactivity", + "Livechat_business_hour_type": "Business Hour Type (Single or Multiple)", "Livechat_custom_fields_options_placeholder": "Comma-separated list used to select a pre-configured value. Spaces between elements are not accepted.", "Livechat_custom_fields_public_description": "Public custom fields will be displayed in external applications, such as Livechat, etc.", "Livechat_last_chatted_agent_routing": "Last-Chatted Agent Preferred", @@ -54,6 +59,7 @@ "Message_auditing_log": "Message auditing log", "Monitor_removed": "Monitor removed", "Monitors": "Monitors", + "New_Business_Hour": "New Business Hour", "New_Canned_Response": "New Canned Response", "New_chat_priority": "Priority Changed: __user__ changed the priority to __priority__", "New_Tag": "New Tag", @@ -64,6 +70,7 @@ "Number_of_most_recent_chats_estimate_wait_time": "Number of recent chats to calculate estimate wait time", "Number_of_most_recent_chats_estimate_wait_time_description": "This number defines the number of last served rooms that will be used to calculate queue wait times.", "Others": "Others", + "Open_Days": "Open days", "Please_select_visibility": "Please select a visibility", "Priorities": "Priorities", "Priority": "Priority", @@ -89,4 +96,4 @@ "Waiting_queue_message": "Waiting queue message", "Waiting_queue_message_description": "Message that will be displayed to the visitors when they get in the queue", "Without_priority": "Without priority" -} \ No newline at end of file +} diff --git a/ee/i18n/pt-BR.i18n.json b/ee/i18n/pt-BR.i18n.json index 2d772e4be909..53399f1f0198 100644 --- a/ee/i18n/pt-BR.i18n.json +++ b/ee/i18n/pt-BR.i18n.json @@ -4,8 +4,11 @@ "error-max-number-simultaneous-chats-reached": "O número máximo de bate-papos simultâneos por agente foi atingido.", "Add_monitor": "Adicionar Monitor", "Available_departments": "Departamentos disponíveis", + "Business_Hour": "Horário de expediente", + "Business_Hour_Removed": "Horário de expediente removido", "Closed_automatically": "Fechado automaticamente pelo sistema", "Default_value": "Default value", + "Edit_Business_Hour": "Editar horário de expediente", "Edit_Tag": "Editar Tag", "Edit_Unit": "Editar Unidade", "Edit_Priority": "Editar Prioridade", @@ -24,9 +27,11 @@ "LDAP_Roles_To_Rocket_Chat_Roles_Description": "Mapeamento dos papéis que deve ser em formato de objeto onde a chave do objeto precisa ser o nome do papel LDAP e o valor deve ser um array de papéis Rocket.Chat. Exemplo: { 'ldapRole': ['rcRole', 'anotherRCRole'] }", "LDAP_Validate_Roles_For_Each_Login": "Validar o mapeamento em cada login", "LDAP_Validate_Roles_For_Each_Login_Description": " Se a validação deve ser feita a cada login(Tenha cuidado com essa configuração, pois ela vai sobrescrever os papéis de usuário a cada login, caso esteja desabilitado, a validação será feita apenas no momento da criação do usuário).", + "List_of_departments_to_apply_this_business_hour": "Lista de departamentos para aplicar esse horário de expediente", "List_of_departments_for_forward": "Lista de departamentos permitidos para o encaminhamento(Opcional).", "List_of_departments_for_forward_description": "Permite definir uma lista restrita de departamentos que podem receber conversas desse departamento.", "Livechat_abandoned_rooms_closed_custom_message": "Mensagem customizada para usar quando a sala for automaticamente fechada por abandono do visitante", + "Livechat_business_hour_type": "Tipo de Horário de expediente (Único ou Múltiplo)", "Livechat_custom_fields_options_placeholder": "Lista separada por vírgula usada para selecionar um valor pré-configurado. Espaços entre elementos não são aceitos.", "Livechat_custom_fields_public_description": "Os custom fields públicos serão exibidos nos aplicativos externos, como o Livechat etc.", "Livechat_last_chatted_agent_routing": "Agente preferido pela última conversa", @@ -39,6 +44,7 @@ "Message_auditing_log": "Log de auditoria", "Monitor_removed": "Monitor removido", "Monitors": "Monitores", + "New_Business_Hour": "Novo horário de expediente", "New_chat_priority": "Prioridade da sala alterada: __user__ alterou a prioridade para __priority__", "New_Tag": "Nova Tag", "New_Unit": "Nova Unidade", @@ -47,6 +53,7 @@ "Number_of_most_recent_chats_estimate_wait_time": "Número de chats recentes para cálculo de tempo na fila", "Number_of_most_recent_chats_estimate_wait_time_description": "Este numero define a quantidade de últimas salas atendidas que serão usadas para calculo de estimativa de tempo de espera da fila.", "Others": "Outros", + "Open_Days": "Dias abertos", "Please_select_visibility": "Por favor selecione o tipo de visibilidade", "Priorities": "Prioridades", "Priority": "Prioridade", @@ -71,4 +78,4 @@ "Waiting_queue_message": "Mensagem de fila de espera", "Waiting_queue_message_description": "Mensagem que será exibida aos visitantes quando eles entrarem na fila de espera", "Without_priority": "Sem prioridade" -} \ No newline at end of file +} diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 02388d744a46..796ad3437c51 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -301,7 +301,7 @@ "Allow_Invalid_SelfSigned_Certs_Description": "Permitir certificado SSL inválidos e auto-assinados para validação de link e previews.", "Allow_switching_departments": "Permitir que Visitantes Mudem de Departamento", "Allow_Marketing_Emails": "Permitir emails de marketing", - "Allow_Online_Agents_Outside_Business_Hours": "Permitir agentes online fora do horário comercial", + "Allow_Online_Agents_Outside_Business_Hours": "Permitir agentes online fora do horário de expediente", "Allow_Online_Agents_Outside_Office_Hours": "Permitir agentes online fora do horário de expediente", "Almost_done": "Quase pronto", "Alphabetical": "Alfabética", diff --git a/server/startup/migrations/index.js b/server/startup/migrations/index.js index 459213fe92cb..8511de4c36c7 100644 --- a/server/startup/migrations/index.js +++ b/server/startup/migrations/index.js @@ -192,4 +192,5 @@ import './v192'; import './v193'; import './v194'; import './v195'; +import './v196'; import './xrun'; diff --git a/server/startup/migrations/v196.js b/server/startup/migrations/v196.js new file mode 100644 index 000000000000..804b1f3762f4 --- /dev/null +++ b/server/startup/migrations/v196.js @@ -0,0 +1,30 @@ +import { Migrations } from '../../../app/migrations/server'; +import { LivechatDepartmentAgents } from '../../../app/models/server'; + +const removeOrphanDepartmentAgents = async () => { + const orphanAgentIds = (await LivechatDepartmentAgents.model.rawCollection().aggregate([ + { + $lookup: { + from: 'rocketchat_livechat_department', + localField: 'departmentId', + foreignField: '_id', + as: 'departments', + }, + }, + { + $unwind: { + path: '$departments', + preserveNullAndEmptyArrays: true, + }, + }, + { $match: { departments: { $exists: false } } }, + ]).toArray()).map((dept) => dept._id); + LivechatDepartmentAgents.remove({ _id: { $in: orphanAgentIds } }); +}; + +Migrations.add({ + version: 196, + up() { + Promise.await(removeOrphanDepartmentAgents()); + }, +});