[NEW][Enterprise] Omnichannel On-Hold Queue (#20945)
* Add settings for on-hold feature * Add On-Hold Section within Sidebar * On hold room UI * OnHold - Automatic * Add Manual on-hold button UI * OnHold - manual - Start manual On-Hold timer after agent reply - Stop manual On-Hold timer on visitor message - Show manual On-Hold Option when timer expires * Handle manual On-Hold event * Add permissions to manual on-hold feature * [New] Auto-Close On hold chats * Routing chat when an on-hold get is resumed * Apply suggestions from code review * Apply suggestions from code review * Add migration * Add new endpoint - livechat/placeChatOnHold * Move Resume Button login within livechatReadOnly file * Remove timeout on Manual On-Hold feature - From now on, the On-Hold option will appear within visitor Info panel, provided the agent has sent the last message * Apply suggestions from code review * Move resume On-Hold chat logic inside Queue Manager * Apply suggestions from code review Co-authored-by: Renato Becker <renato.augusto.becker@gmail.com> * Apply suggestions from code review * Use takeInquiry() to resume On-Hold chats * Fix failing test case * Minor improvements. * [Regression] Omnichannel On Hold feature - handle following impacted events - Returning chat to the queue - Forwarding chats - Closing chats * Fix failing test cases * Revert "[Regression] Omnichannel On hold Queue" * Prevent on hold chat from being returned or forwarded (cherry picked from commit 2edcb8234224ebad2b479282da3f9be6a5626db6) * Move checks to Livechat methods * Add releaseOnHoldChat() method * Fix callback returning promise. Co-authored-by: Renato Becker <renato.augusto.becker@gmail.com>pull/20512/merge
parent
6acb8ed371
commit
297dc068ce
@ -0,0 +1,40 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { API } from '../../../../../app/api/server'; |
||||
import { hasPermission } from '../../../../../app/authorization'; |
||||
import { Subscriptions, LivechatRooms } from '../../../../../app/models/server'; |
||||
import { LivechatEnterprise } from '../lib/LivechatEnterprise'; |
||||
|
||||
|
||||
API.v1.addRoute('livechat/room.onHold', { authRequired: true }, { |
||||
post() { |
||||
const { roomId } = this.bodyParams; |
||||
if (!roomId || roomId.trim() === '') { |
||||
return API.v1.failure('Invalid room Id'); |
||||
} |
||||
|
||||
if (!this.userId || !hasPermission(this.userId, 'on-hold-livechat-room')) { |
||||
return API.v1.failure('Not authorized'); |
||||
} |
||||
|
||||
const room = LivechatRooms.findOneById(roomId); |
||||
if (!room || room.t !== 'l') { |
||||
return API.v1.failure('Invalid room Id'); |
||||
} |
||||
|
||||
if (room.onHold) { |
||||
return API.v1.failure('Room is already On-Hold'); |
||||
} |
||||
|
||||
const user = Meteor.user(); |
||||
|
||||
const subscription = Subscriptions.findOneByRoomIdAndUserId(roomId, user._id, { _id: 1 }); |
||||
if (!subscription && !hasPermission(this.userId, 'on-hold-others-livechat-room')) { |
||||
return API.v1.failure('Not authorized'); |
||||
} |
||||
|
||||
LivechatEnterprise.placeRoomOnHold(room); |
||||
|
||||
return API.v1.success(); |
||||
}, |
||||
}); |
@ -0,0 +1,36 @@ |
||||
import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; |
||||
|
||||
import { callbacks } from '../../../../../app/callbacks/server'; |
||||
import { settings } from '../../../../../app/settings/server'; |
||||
import { AutoCloseOnHoldScheduler } from '../lib/AutoCloseOnHoldScheduler'; |
||||
|
||||
|
||||
const DEFAULT_CLOSED_MESSAGE = TAPi18n.__('Closed_automatically'); |
||||
|
||||
let autoCloseOnHoldChatTimeout = 0; |
||||
let customCloseMessage = DEFAULT_CLOSED_MESSAGE; |
||||
|
||||
const handleAfterOnHold = async (room: any = {}): Promise<any> => { |
||||
const { _id: rid } = room; |
||||
if (!rid) { |
||||
return; |
||||
} |
||||
|
||||
if (!autoCloseOnHoldChatTimeout || autoCloseOnHoldChatTimeout <= 0) { |
||||
return; |
||||
} |
||||
|
||||
await AutoCloseOnHoldScheduler.scheduleRoom(room._id, autoCloseOnHoldChatTimeout, customCloseMessage); |
||||
}; |
||||
|
||||
settings.get('Livechat_auto_close_on_hold_chats_timeout', (_, value) => { |
||||
autoCloseOnHoldChatTimeout = value as number; |
||||
if (!value || value <= 0) { |
||||
callbacks.remove('livechat:afterOnHold', 'livechat-auto-close-on-hold'); |
||||
} |
||||
callbacks.add('livechat:afterOnHold', handleAfterOnHold, callbacks.priority.HIGH, 'livechat-auto-close-on-hold'); |
||||
}); |
||||
|
||||
settings.get('Livechat_auto_close_on_hold_chats_custom_message', (_, value) => { |
||||
customCloseMessage = value as string || DEFAULT_CLOSED_MESSAGE; |
||||
}); |
@ -0,0 +1,12 @@ |
||||
import { callbacks } from '../../../../../app/callbacks/server'; |
||||
import { LivechatEnterprise } from '../lib/LivechatEnterprise'; |
||||
|
||||
const handleAfterOnHoldChatResumed = async (room: any): Promise<void> => { |
||||
if (!room || !room._id || !room.onHold) { |
||||
return; |
||||
} |
||||
|
||||
LivechatEnterprise.releaseOnHoldChat(room); |
||||
}; |
||||
|
||||
callbacks.add('livechat:afterOnHoldChatResumed', handleAfterOnHoldChatResumed, callbacks.priority.HIGH, 'livechat-after-on-hold-chat-resumed'); |
@ -0,0 +1,25 @@ |
||||
import { callbacks } from '../../../../../app/callbacks/server'; |
||||
import { LivechatInquiry, Subscriptions, LivechatRooms } from '../../../../../app/models/server'; |
||||
import { queueInquiry } from '../../../../../app/livechat/server/lib/QueueManager'; |
||||
|
||||
const handleOnAgentAssignmentFailed = async ({ inquiry, room }: { inquiry: any; room: any }): Promise<any> => { |
||||
if (!inquiry || !room || !room.onHold) { |
||||
return; |
||||
} |
||||
|
||||
const { _id: roomId, servedBy } = room; |
||||
|
||||
const { _id: inquiryId } = inquiry; |
||||
LivechatInquiry.readyInquiry(inquiryId); |
||||
LivechatInquiry.removeDefaultAgentById(inquiryId); |
||||
LivechatRooms.removeAgentByRoomId(roomId); |
||||
if (servedBy?._id) { |
||||
Subscriptions.removeByRoomIdAndUserId(roomId, servedBy._id); |
||||
} |
||||
|
||||
const newInquiry = LivechatInquiry.findOneById(inquiryId); |
||||
|
||||
await queueInquiry(room, newInquiry); |
||||
}; |
||||
|
||||
callbacks.add('livechat.onAgentAssignmentFailed', handleOnAgentAssignmentFailed, callbacks.priority.HIGH, 'livechat-agent-assignment-failed'); |
@ -0,0 +1,36 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { callbacks } from '../../../../../app/callbacks/server'; |
||||
import { LivechatRooms } from '../../../../../app/models/server'; |
||||
|
||||
const handleAfterSaveMessage = (message, { _id: rid }) => { |
||||
// skips this callback if the message was edited
|
||||
if (message.editedAt) { |
||||
return message; |
||||
} |
||||
|
||||
// if the message has a type means it is a special message (like the closing comment), so skips
|
||||
if (message.t) { |
||||
return message; |
||||
} |
||||
|
||||
// Need to read the room every time, the room object is not updated
|
||||
const room = LivechatRooms.findOneById(rid, { t: 1, v: 1, onHold: 1 }); |
||||
if (!room) { |
||||
return message; |
||||
} |
||||
|
||||
// message valid only if it is a livechat room
|
||||
if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.v && room.v.token)) { |
||||
return message; |
||||
} |
||||
|
||||
// if a visitor sends a message in room which is On Hold
|
||||
if (message.token && room.onHold) { |
||||
Meteor.call('livechat:resumeOnHold', rid, { clientAction: false }); |
||||
} |
||||
|
||||
return message; |
||||
}; |
||||
|
||||
callbacks.add('afterSaveMessage', handleAfterSaveMessage, callbacks.priority.HIGH, 'livechat-resume-on-hold'); |
@ -0,0 +1,67 @@ |
||||
import Agenda from 'agenda'; |
||||
import { MongoInternals } from 'meteor/mongo'; |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import moment from 'moment'; |
||||
|
||||
import { Livechat } from '../../../../../app/livechat/server'; |
||||
import { LivechatRooms, Users } from '../../../../../app/models/server'; |
||||
|
||||
const schedulerUser = Users.findOneById('rocket.cat'); |
||||
const SCHEDULER_NAME = 'omnichannel_auto_close_on_hold_scheduler'; |
||||
|
||||
class AutoCloseOnHoldSchedulerClass { |
||||
scheduler: Agenda; |
||||
|
||||
running: boolean; |
||||
|
||||
public init(): void { |
||||
if (this.running) { |
||||
return; |
||||
} |
||||
|
||||
this.scheduler = new Agenda({ |
||||
mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(), |
||||
db: { collection: SCHEDULER_NAME }, |
||||
defaultConcurrency: 1, |
||||
}); |
||||
|
||||
this.scheduler.start(); |
||||
this.running = true; |
||||
} |
||||
|
||||
public async scheduleRoom(roomId: string, timeout: number, comment: string): Promise<void> { |
||||
await this.unscheduleRoom(roomId); |
||||
|
||||
const jobName = `${ SCHEDULER_NAME }-${ roomId }`; |
||||
const when = moment(new Date()).add(timeout, 's').toDate(); |
||||
|
||||
this.scheduler.define(jobName, this.executeJob.bind(this)); |
||||
await this.scheduler.schedule(when, jobName, { roomId, comment }); |
||||
} |
||||
|
||||
|
||||
public async unscheduleRoom(roomId: string): Promise<void> { |
||||
const jobName = `${ SCHEDULER_NAME }-${ roomId }`; |
||||
await this.scheduler.cancel({ name: jobName }); |
||||
} |
||||
|
||||
private async executeJob({ attrs: { data } }: any = {}): Promise<void> { |
||||
const { roomId, comment } = data; |
||||
|
||||
const payload = { |
||||
user: schedulerUser, |
||||
room: LivechatRooms.findOneById(roomId), |
||||
comment, |
||||
options: {}, |
||||
visitor: undefined, |
||||
}; |
||||
|
||||
Livechat.closeRoom(payload); |
||||
} |
||||
} |
||||
|
||||
export const AutoCloseOnHoldScheduler = new AutoCloseOnHoldSchedulerClass(); |
||||
|
||||
Meteor.startup(() => { |
||||
AutoCloseOnHoldScheduler.init(); |
||||
}); |
@ -0,0 +1,30 @@ |
||||
import { Meteor } from 'meteor/meteor'; |
||||
|
||||
import { LivechatRooms, LivechatInquiry } from '../../../../../app/models/server'; |
||||
import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager'; |
||||
import { callbacks } from '../../../../../app/callbacks/server'; |
||||
|
||||
Meteor.methods({ |
||||
async 'livechat:resumeOnHold'(roomId, options = { clientAction: false }) { |
||||
const room = await LivechatRooms.findOneById(roomId); |
||||
if (!room || room.t !== 'l') { |
||||
throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'livechat:resumeOnHold' }); |
||||
} |
||||
|
||||
if (!room.onHold) { |
||||
throw new Meteor.Error('room-closed', 'Room is not OnHold', { method: 'livechat:resumeOnHold' }); |
||||
} |
||||
|
||||
const { servedBy: { _id: agentId, username } } = room; |
||||
|
||||
const inquiry = LivechatInquiry.findOneByRoomId(roomId, {}); |
||||
if (!inquiry) { |
||||
throw new Meteor.Error('inquiry-not-found', 'Error! No inquiry found for this room', { method: 'livechat:resumeOnHold' }); |
||||
} |
||||
|
||||
await RoutingManager.takeInquiry(inquiry, { agentId, username }, options); |
||||
|
||||
const updatedRoom = LivechatRooms.findOneById(roomId); |
||||
updatedRoom && Meteor.defer(() => callbacks.run('livechat:afterOnHoldChatResumed', updatedRoom)); |
||||
}, |
||||
}); |
@ -0,0 +1,34 @@ |
||||
import { Migrations } from '../../../app/migrations/server'; |
||||
import { Settings } from '../../../app/models/server'; |
||||
|
||||
Migrations.add({ |
||||
version: 219, |
||||
up() { |
||||
const SettingIds = { |
||||
old: 'Livechat_auto_close_abandoned_rooms', |
||||
new: 'Livechat_abandoned_rooms_action', |
||||
}; |
||||
|
||||
|
||||
const oldSetting = Settings.findOne({ _id: SettingIds.old }); |
||||
if (!oldSetting) { |
||||
return; |
||||
} |
||||
|
||||
const oldValue = oldSetting.value; |
||||
|
||||
const newValue = oldValue && oldValue === true ? 'close' : 'none'; |
||||
|
||||
Settings.update({ |
||||
_id: SettingIds.new, |
||||
}, { |
||||
$set: { |
||||
value: newValue, |
||||
}, |
||||
}); |
||||
|
||||
Settings.remove({ |
||||
_id: SettingIds.old, |
||||
}); |
||||
}, |
||||
}); |
Loading…
Reference in new issue