Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NEW][ENTERPRISE] Maximum waiting time for chats in Omnichannel queue #22955

Merged
merged 16 commits into from
Aug 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/livechat/client/views/app/livechatReadOnly.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Template } from 'meteor/templating';
import { ReactiveVar } from 'meteor/reactive-var';
import { FlowRouter } from 'meteor/kadira:flow-router';

import { ChatRoom } from '../../../../models';
import { ChatRoom, CachedChatRoom } from '../../../../models';
import { call } from '../../../../ui-utils/client';
import './livechatReadOnly.html';
import { APIClient } from '../../../../utils/client';
Expand Down Expand Up @@ -65,6 +65,11 @@ Template.livechatReadOnly.onCreated(function() {

this.updateInquiry = async ({ clientAction, ...inquiry }) => {
if (clientAction === 'removed' || !await call('canAccessRoom', inquiry.rid, Meteor.userId())) {
// this will force to refresh the room
// since the client wont get notified of room changes when chats are on queue (no one assigned)
// a better approach should be performed when refactoring these templates to use react
ChatRoom.remove(this.rid);
CachedChatRoom.save();
return FlowRouter.go('/home');
}

Expand Down
4 changes: 2 additions & 2 deletions app/livechat/server/lib/Helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Apps, AppEvents } from '../../../apps/server';
import notifications from '../../../notifications/server/lib/Notifications';
import { sendNotification } from '../../../lib/server';
import { sendMessage } from '../../../lib/server/functions/sendMessage';
import { queueInquiry } from './QueueManager';
import { queueInquiry, saveQueueInquiry } from './QueueManager';

export const allowAgentSkipQueue = (agent) => {
check(agent, Match.ObjectIncluding({
Expand Down Expand Up @@ -233,7 +233,7 @@ export const dispatchInquiryQueued = (inquiry, agent) => {
}

if (!agent || !allowAgentSkipQueue(agent)) {
LivechatInquiry.queueInquiry(inquiry._id);
saveQueueInquiry(inquiry);
}

// Alert only the online agents of the queued request
Expand Down
4 changes: 4 additions & 0 deletions app/livechat/server/lib/QueueManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { checkServiceStatus, createLivechatRoom, createLivechatInquiry } from '.
import { callbacks } from '../../../callbacks/server';
import { RoutingManager } from './RoutingManager';

export const saveQueueInquiry = (inquiry) => {
LivechatInquiry.queueInquiry(inquiry._id);
callbacks.run('livechat.afterInquiryQueued', inquiry);
};

export const queueInquiry = async (room, inquiry, defaultAgent) => {
const inquiryAgent = RoutingManager.delegateAgent(defaultAgent, inquiry);
Expand Down
48 changes: 47 additions & 1 deletion app/models/server/models/LivechatInquiry.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export class LivechatInquiry extends Base {
);
}

getQueuedInquiries(options) {
return this.find({ status: 'queued' }, options);
}

/*
* mark the inquiry as taken
*/
Expand All @@ -45,7 +49,7 @@ export class LivechatInquiry extends Base {
_id: inquiryId,
}, {
$set: { status: 'taken' },
$unset: { defaultAgent: 1 },
$unset: { defaultAgent: 1, estimatedInactivityCloseTimeAt: 1 },
});
}

Expand Down Expand Up @@ -228,6 +232,48 @@ export class LivechatInquiry extends Base {

this.remove(query);
}

getUnnatendedQueueItems(date) {
const query = {
status: 'queued',
estimatedInactivityCloseTimeAt: { $lte: new Date(date) },
};
return this.find(query);
}

setEstimatedInactivityCloseTime(_id, date) {
return this.update({ _id }, {
$set: {
estimatedInactivityCloseTimeAt: new Date(date),
},
});
}

unsetEstimatedInactivityCloseTime() {
return this.update({ status: 'queued' }, {
$unset: {
estimatedInactivityCloseTimeAt: 1,
},
}, { multi: true });
}

// This is a better solution, but update pipelines are not supported until version 4.2 of mongo
// leaving this here for when the time comes
/* updateEstimatedInactivityCloseTime(milisecondsToAdd) {
return this.model.rawCollection().updateMany(
{ status: 'queued' },
[{
// in case this field doesn't exists, set at the last time the item was modified (updatedAt)
$set: { estimatedInactivityCloseTimeAt: '$_updatedAt' },
}, {
$set: {
estimatedInactivityCloseTimeAt: {
$add: ['$estimatedInactivityCloseTimeAt', milisecondsToAdd],
},
},
}],
);
} */
}

export default new LivechatInquiry();
28 changes: 28 additions & 0 deletions ee/app/livechat-enterprise/server/hooks/afterInquiryQueued.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import moment from 'moment';

import { callbacks } from '../../../../../app/callbacks/server';
import { LivechatInquiry } from '../../../../../app/models/server';
import { settings } from '../../../../../app/settings/server';

let timer = 0;

const setQueueTimer = (inquiry: any): void => {
if (!inquiry?._id) {
return;
}

const newQueueTime = moment(inquiry?._updatedAt).add(timer, 'minutes');
(LivechatInquiry as any).setEstimatedInactivityCloseTime(inquiry?._id, newQueueTime);
};

settings.get('Livechat_max_queue_wait_time', (_, value) => {
timer = value as number;
});

settings.get('Livechat_max_queue_wait_time_action', (_, value) => {
if (!value || value === 'Nothing') {
callbacks.remove('livechat:afterReturnRoomAsInquiry', 'livechat-after-return-room-as-inquiry-set-queue-timer');
return;
}
callbacks.add('livechat.afterInquiryQueued', setQueueTimer, callbacks.priority.HIGH, 'livechat-inquiry-queued-set-queue-timer');
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { callbacks } from '../../../../../app/callbacks/server';
import { LivechatRooms } from '../../../../../app/models/server';
import { settings } from '../../../../../app/settings/server';

const afterReturnRoomAsInquiry = ({ room }: { room: any }): void => {
const unsetPredictedVisitorAbandonment = ({ room }: { room: any }): void => {
if (!room?._id || !room?.omnichannel?.predictedVisitorAbandonmentAt) {
return;
}
Expand All @@ -15,5 +15,5 @@ settings.get('Livechat_abandoned_rooms_action', (_, value) => {
callbacks.remove('livechat:afterReturnRoomAsInquiry', 'livechat-after-return-room-as-inquiry');
return;
}
callbacks.add('livechat:afterReturnRoomAsInquiry', afterReturnRoomAsInquiry, callbacks.priority.HIGH, 'livechat-after-return-room-as-inquiry');
callbacks.add('livechat:afterReturnRoomAsInquiry', unsetPredictedVisitorAbandonment, callbacks.priority.HIGH, 'livechat-after-return-room-as-inquiry');
});
13 changes: 13 additions & 0 deletions ee/app/livechat-enterprise/server/hooks/beforeNewInquiry.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Meteor } from 'meteor/meteor';

import { callbacks } from '../../../../../app/callbacks';
import { settings } from '../../../../../app/settings';
import LivechatPriority from '../../../models/server/models/LivechatPriority';

callbacks.add('livechat.beforeInquiry', (extraData = {}) => {
Expand All @@ -22,3 +23,15 @@ callbacks.add('livechat.beforeInquiry', (extraData = {}) => {

return Object.assign({ ...props }, { ts, queueOrder, estimatedWaitingTimeQueue, estimatedServiceTimeAt });
}, callbacks.priority.MEDIUM, 'livechat-before-new-inquiry');

callbacks.add('livechat.beforeInquiry', (extraData = {}) => {
const queueInactivityAction = settings.get('Livechat_max_queue_wait_time_action');
if (!queueInactivityAction || queueInactivityAction === 'Nothing') {
return extraData;
}

const maxQueueWaitTimeMinutes = settings.get('Livechat_max_queue_wait_time');
const estimatedInactivityCloseTimeAt = new Date(new Date().getTime() + maxQueueWaitTimeMinutes * 60000);

return Object.assign(extraData, { estimatedInactivityCloseTimeAt });
}, callbacks.priority.MEDIUM, 'livechat-before-new-inquiry-append-queue-timer');
3 changes: 2 additions & 1 deletion ee/app/livechat-enterprise/server/hooks/beforeRoutingChat.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { settings } from '../../../../../app/settings';
import { LivechatInquiry } from '../../../../../app/models/server';
import { dispatchInquiryPosition } from '../lib/Helper';
import { allowAgentSkipQueue } from '../../../../../app/livechat/server/lib/Helper';
import { saveQueueInquiry } from '../../../../../app/livechat/server/lib/QueueManager';

callbacks.add('livechat.beforeRouteChat', async (inquiry, agent) => {
if (!settings.get('Livechat_waiting_queue')) {
Expand All @@ -23,7 +24,7 @@ callbacks.add('livechat.beforeRouteChat', async (inquiry, agent) => {
return inquiry;
}

LivechatInquiry.queueInquiry(_id);
saveQueueInquiry(inquiry);

const [inq] = await LivechatInquiry.getCurrentSortedQueueAsync({ _id, department });
if (inq) {
Expand Down
1 change: 1 addition & 0 deletions ee/app/livechat-enterprise/server/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ import './afterReturnRoomAsInquiry';
import './applyDepartmentRestrictions';
import './afterForwardChatToAgent';
import './applySimultaneousChatsRestrictions';
import './afterInquiryQueued';
18 changes: 18 additions & 0 deletions ee/app/livechat-enterprise/server/lib/Helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { settings } from '../../../../../app/settings';
import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager';
import { dispatchAgentDelegated } from '../../../../../app/livechat/server/lib/Helper';
import notifications from '../../../../../app/notifications/server/lib/Notifications';
import { Logger } from '../../../../../app/logger';

const logger = new Logger('LivechatEnterpriseHelper');

export const getMaxNumberSimultaneousChat = ({ agentId, departmentId }) => {
if (departmentId) {
Expand Down Expand Up @@ -151,6 +154,21 @@ export const updatePredictedVisitorAbandonment = () => {
}
};

export const updateQueueInactivityTimeout = () => {
const queueAction = settings.get('Livechat_max_queue_wait_time_action');
renatobecker marked this conversation as resolved.
Show resolved Hide resolved
const queueTimeout = settings.get('Livechat_max_queue_wait_time');
if (!queueAction || queueAction === 'Nothing') {
logger.debug('QueueInactivityTimer: No action performed (disabled by setting)');
return LivechatInquiry.unsetEstimatedInactivityCloseTime();
}

logger.debug('QueueInactivityTimer: Updating estimated inactivity time for queued items');
LivechatInquiry.getQueuedInquiries().forEach((inq) => {
KevLehman marked this conversation as resolved.
Show resolved Hide resolved
const aggregatedDate = moment(inq._updatedAt).add(queueTimeout, 'minutes');
return LivechatInquiry.setEstimatedInactivityCloseTime(inq._id, aggregatedDate);
});
};

export const updateRoomPriorityHistory = (rid, user, priority) => {
const history = {
priorityData: {
Expand Down
101 changes: 101 additions & 0 deletions ee/app/livechat-enterprise/server/lib/QueueInactivityMonitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Agenda from 'agenda';
import { MongoInternals } from 'meteor/mongo';
import { Meteor } from 'meteor/meteor';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import moment from 'moment';

import { settings } from '../../../../../app/settings/server';
import { Logger } from '../../../../../app/logger/server';
import { LivechatRooms, Users, LivechatInquiry } from '../../../../../app/models/server';
import { Livechat } from '../../../../../app/livechat/server/lib/Livechat';
import { IUser } from '../../../../../definition/IUser';
import { IOmnichannelRoom } from '../../../../../definition/IRoom';

const SCHEDULER_NAME = 'omnichannel_queue_inactivity_monitor';

export class OmnichannelQueueInactivityMonitorClass {
scheduler: Agenda;

running: boolean;

logger: any;

_name: string;

user: IUser;

message: string;

constructor() {
this.running = false;
this._name = 'Omnichannel-Queue-Inactivity-Monitor';
this.logger = new Logger('QueueInactivityMonitor');
this.scheduler = new Agenda({
mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(),
db: { collection: SCHEDULER_NAME },
defaultConcurrency: 1,
});
this.user = Users.findOneById('rocket.cat');
const language = settings.get('Language') || 'en';
this.message = TAPi18n.__('Closed_automatically_chat_queued_too_long', { lng: language });
}

start(): void {
if (this.running) {
return;
}

Promise.await(this.scheduler.start());
this.running = true;
}

async stop(): Promise<void> {
if (!this.running) {
return;
}
await this.scheduler.cancel({ name: this._name });
this.running = false;
}

async schedule(): Promise<void> {
this.scheduler.define(this._name, Meteor.bindEnvironment(this.job.bind(this)));
await this.scheduler.every('one minute', this._name);
}

closeRooms(room: IOmnichannelRoom): void {
const comment = this.message;
Livechat.closeRoom({
comment,
room,
user: this.user,
visitor: null,
});
}

job(): void {
const action = settings.get('Livechat_max_queue_wait_time_action');
this.logger.debug(`Processing dangling queued items with action ${ action }`);
let counter = 0;
if (!action || action === 'Nothing') {
return;
}

LivechatInquiry.getUnnatendedQueueItems(moment().utc()).forEach((inquiry: any) => {
switch (action) {
case 'Close_chat': {
counter++;
this.closeRooms(LivechatRooms.findOneById(inquiry.rid));
break;
}
}
});

this.logger.debug(`Running succesful. Closed ${ counter } queued items because of inactivity`);
}
}

export const OmnichannelQueueInactivityMonitor = new OmnichannelQueueInactivityMonitorClass();

Meteor.startup(() => {
OmnichannelQueueInactivityMonitor.start();
});
Loading