diff --git a/app/authorization/server/startup.js b/app/authorization/server/startup.js index 7d5efbdca1679..759c363bb3d50 100644 --- a/app/authorization/server/startup.js +++ b/app/authorization/server/startup.js @@ -96,6 +96,8 @@ Meteor.startup(function() { { _id: 'view-livechat-rooms', roles: ['livechat-manager', 'admin'] }, { _id: 'close-livechat-room', roles: ['livechat-agent', 'livechat-manager', 'admin'] }, { _id: 'close-others-livechat-room', roles: ['livechat-manager', 'admin'] }, + { _id: 'on-hold-livechat-room', roles: ['livechat-agent', 'livechat-manager', 'admin'] }, + { _id: 'on-hold-others-livechat-room', roles: ['livechat-manager', 'admin'] }, { _id: 'save-others-livechat-room-info', roles: ['livechat-manager'] }, { _id: 'remove-closed-livechat-rooms', roles: ['livechat-manager', 'admin'] }, { _id: 'view-livechat-analytics', roles: ['livechat-manager', 'admin'] }, diff --git a/app/livechat/client/views/app/livechatReadOnly.html b/app/livechat/client/views/app/livechatReadOnly.html index 88c84a34435c6..4eea70906b1ef 100644 --- a/app/livechat/client/views/app/livechatReadOnly.html +++ b/app/livechat/client/views/app/livechatReadOnly.html @@ -4,13 +4,20 @@ {{#if isPreparing}} {{> loading}} {{else}} - {{#if inquiryOpen}} + {{#if isOnHold}}
- {{{_ "you_are_in_preview_mode_of_incoming_livechat"}}} - + {{{_ "chat_on_hold_due_to_inactivity"}}} +
{{else}} - {{_ "room_is_read_only"}} + {{#if inquiryOpen}} +
+ {{{_ "you_are_in_preview_mode_of_incoming_livechat"}}} + +
+ {{else}} + {{_ "room_is_read_only"}} + {{/if}} {{/if}} {{/if}} {{/if}} diff --git a/app/livechat/client/views/app/livechatReadOnly.js b/app/livechat/client/views/app/livechatReadOnly.js index ef516a7ac8854..fcfaed1111298 100644 --- a/app/livechat/client/views/app/livechatReadOnly.js +++ b/app/livechat/client/views/app/livechatReadOnly.js @@ -22,12 +22,16 @@ Template.livechatReadOnly.helpers({ showPreview() { const config = Template.instance().routingConfig.get(); - return config.previewRoom; + return config.previewRoom || Template.currentData().onHold; }, isPreparing() { return Template.instance().preparing.get(); }, + + isOnHold() { + return Template.currentData().onHold; + }, }); Template.livechatReadOnly.events({ @@ -37,9 +41,18 @@ Template.livechatReadOnly.events({ const inquiry = instance.inquiry.get(); const { _id } = inquiry; - await call('livechat:takeInquiry', _id); + await call('livechat:takeInquiry', _id, { clientAction: true }); instance.loadInquiry(inquiry.rid); }, + + async 'click .js-resume-it'(event, instance) { + event.preventDefault(); + event.stopPropagation(); + + const room = instance.room.get(); + + await call('livechat:resumeOnHold', room._id, { clientAction: true }); + }, }); Template.livechatReadOnly.onCreated(function() { diff --git a/app/livechat/client/views/app/tabbar/visitorInfo.html b/app/livechat/client/views/app/tabbar/visitorInfo.html index c46ed0b1ec51d..233bd77ccd360 100644 --- a/app/livechat/client/views/app/tabbar/visitorInfo.html +++ b/app/livechat/client/views/app/tabbar/visitorInfo.html @@ -89,6 +89,10 @@

{{_ "Transcript_Request"}}

{{#if canSendTranscript}} {{/if}} + + {{#if canPlaceChatOnHold}} + + {{/if}} {{/if}} diff --git a/app/livechat/client/views/app/tabbar/visitorInfo.js b/app/livechat/client/views/app/tabbar/visitorInfo.js index f061207a35af6..56de7d9275c0b 100644 --- a/app/livechat/client/views/app/tabbar/visitorInfo.js +++ b/app/livechat/client/views/app/tabbar/visitorInfo.js @@ -206,6 +206,11 @@ Template.visitorInfo.helpers({ return !room.email && hasPermission('send-omnichannel-chat-transcript'); }, + canPlaceChatOnHold() { + const room = Template.instance().room.get(); + return room.open && !room.onHold && room.servedBy && room.lastMessage && !room.lastMessage?.token && settings.get('Livechat_allow_manual_on_hold'); + }, + roomClosedDateTime() { const { closedAt } = this; return DateFormat.formatDateAndTime(closedAt); @@ -324,6 +329,31 @@ Template.visitorInfo.events({ instance.action.set('transcript'); }, + + 'click .on-hold'(event) { + event.preventDefault(); + + modal.open({ + title: t('Would_you_like_to_place_chat_on_hold'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#3085d6', + cancelButtonColor: '#d33', + confirmButtonText: t('Yes'), + }, + async () => { + const { success } = await APIClient.v1.post('livechat/room.onHold', { roomId: this.rid }); + if (success) { + modal.open({ + title: t('Chat_On_Hold'), + text: t('Chat_On_Hold_Successfully'), + type: 'success', + timer: 1500, + showConfirmButton: false, + }); + } + }); + }, }); Template.visitorInfo.onCreated(function() { diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js index 31a7edcb361dd..c27b7839769d7 100644 --- a/app/livechat/server/lib/Livechat.js +++ b/app/livechat/server/lib/Livechat.js @@ -589,6 +589,10 @@ export const Livechat = { }, async transfer(room, guest, transferData) { + if (room.onHold) { + throw new Meteor.Error('error-room-onHold', 'Room On Hold', { method: 'livechat:transfer' }); + } + if (transferData.departmentId) { transferData.department = LivechatDepartment.findOneById(transferData.departmentId, { fields: { name: 1 } }); } @@ -606,6 +610,10 @@ export const Livechat = { throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:returnRoomAsInquiry' }); } + if (room.onHold) { + throw new Meteor.Error('error-room-onHold', 'Room On Hold', { method: 'livechat:returnRoomAsInquiry' }); + } + if (!room.servedBy) { return false; } diff --git a/app/livechat/server/lib/QueueManager.js b/app/livechat/server/lib/QueueManager.js index febd9cbf59ba5..37a5805f12d81 100644 --- a/app/livechat/server/lib/QueueManager.js +++ b/app/livechat/server/lib/QueueManager.js @@ -7,7 +7,7 @@ import { callbacks } from '../../../callbacks/server'; import { RoutingManager } from './RoutingManager'; -const queueInquiry = async (room, inquiry, defaultAgent) => { +export const queueInquiry = async (room, inquiry, defaultAgent) => { const inquiryAgent = RoutingManager.delegateAgent(defaultAgent, inquiry); await callbacks.run('livechat.beforeRouteChat', inquiry, inquiryAgent); inquiry = LivechatInquiry.findOneById(inquiry._id); diff --git a/app/livechat/server/lib/RoutingManager.js b/app/livechat/server/lib/RoutingManager.js index 49aec01b625a1..9f147539d4637 100644 --- a/app/livechat/server/lib/RoutingManager.js +++ b/app/livechat/server/lib/RoutingManager.js @@ -13,7 +13,7 @@ import { allowAgentSkipQueue, } from './Helper'; import { callbacks } from '../../../callbacks/server'; -import { LivechatRooms, Rooms, Messages, Users, LivechatInquiry } from '../../../models/server'; +import { LivechatRooms, Rooms, Messages, Users, LivechatInquiry, Subscriptions } from '../../../models/server'; import { Apps, AppEvents } from '../../../apps/server'; export const RoutingManager = { @@ -110,7 +110,7 @@ export const RoutingManager = { return true; }, - async takeInquiry(inquiry, agent) { + async takeInquiry(inquiry, agent, options = { clientAction: false }) { check(agent, Match.ObjectIncluding({ agentId: String, username: String, @@ -128,15 +128,20 @@ export const RoutingManager = { return room; } - if (room.servedBy && room.servedBy._id === agent.agentId) { + if (room.servedBy && room.servedBy._id === agent.agentId && !room.onHold) { return room; } - agent = await callbacks.run('livechat.checkAgentBeforeTakeInquiry', agent, inquiry); + agent = await callbacks.run('livechat.checkAgentBeforeTakeInquiry', { agent, inquiry, options }); if (!agent) { + await callbacks.run('livechat.onAgentAssignmentFailed', { inquiry, room, options }); return null; } + if (room.onHold) { + Subscriptions.removeByRoomIdAndUserId(room._id, agent.agentId); + } + LivechatInquiry.takeInquiry(_id); const inq = this.assignAgent(inquiry, agent); diff --git a/app/livechat/server/methods/takeInquiry.js b/app/livechat/server/methods/takeInquiry.js index abb006593ef8f..b367467d01348 100644 --- a/app/livechat/server/methods/takeInquiry.js +++ b/app/livechat/server/methods/takeInquiry.js @@ -6,7 +6,7 @@ import { RoutingManager } from '../lib/RoutingManager'; import { userCanTakeInquiry } from '../lib/Helper'; Meteor.methods({ - 'livechat:takeInquiry'(inquiryId) { + 'livechat:takeInquiry'(inquiryId, options) { if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'view-l-room')) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:takeInquiry' }); } @@ -27,6 +27,6 @@ Meteor.methods({ username: user.username, }; - return RoutingManager.takeInquiry(inquiry, agent); + return RoutingManager.takeInquiry(inquiry, agent, options); }, }); diff --git a/app/models/server/models/LivechatInquiry.js b/app/models/server/models/LivechatInquiry.js index 8d2aef5ec4f5c..7b79c53226182 100644 --- a/app/models/server/models/LivechatInquiry.js +++ b/app/models/server/models/LivechatInquiry.js @@ -73,6 +73,19 @@ export class LivechatInquiry extends Base { }); } + /* + * mark inquiry as ready + */ + readyInquiry(inquiryId) { + return this.update({ + _id: inquiryId, + }, { + $set: { + status: 'ready', + }, + }); + } + changeDepartmentIdByRoomId(rid, department) { const query = { rid, diff --git a/app/models/server/models/Subscriptions.js b/app/models/server/models/Subscriptions.js index 049e6a05cda9d..fcd24f02c1dcb 100644 --- a/app/models/server/models/Subscriptions.js +++ b/app/models/server/models/Subscriptions.js @@ -1330,6 +1330,22 @@ export class Subscriptions extends Base { return this.update(query, update, { multi: true }); } + + setOnHold(roomId) { + return this.update( + { rid: roomId }, + { $set: { onHold: true } }, + { multi: true }, + ); + } + + unsetOnHold(roomId) { + return this.update( + { rid: roomId }, + { $unset: { onHold: 1 } }, + { multi: true }, + ); + } } export default new Subscriptions('subscription', true); diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index c89ecd9068230..b3d798557faaa 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -191,7 +191,7 @@ export class UsersRaw extends BaseRaw { const aggregate = [ { $match: { _id: userId, status: { $exists: true, $ne: 'offline' }, statusLivechat: 'available', roles: 'livechat-agent' } }, { $lookup: { from: 'rocketchat_subscription', localField: '_id', foreignField: 'u._id', as: 'subs' } }, - { $project: { agentId: '$_id', username: 1, lastAssignTime: 1, lastRoutingTime: 1, 'queueInfo.chats': { $size: { $filter: { input: '$subs', as: 'sub', cond: { $eq: ['$$sub.t', 'l'] } } } } } }, + { $project: { agentId: '$_id', username: 1, lastAssignTime: 1, lastRoutingTime: 1, 'queueInfo.chats': { $size: { $filter: { input: '$subs', as: 'sub', cond: { $and: [{ $eq: ['$$sub.t', 'l'] }, { $eq: ['$$sub.open', true] }, { $ne: ['$$sub.onHold', true] }] } } } } } }, { $sort: { 'queueInfo.chats': 1, lastAssignTime: 1, lastRoutingTime: 1, username: 1 } }, ]; diff --git a/app/theme/client/imports/components/message-box.css b/app/theme/client/imports/components/message-box.css index e596c69fcc65e..676c0fd33813d 100644 --- a/app/theme/client/imports/components/message-box.css +++ b/app/theme/client/imports/components/message-box.css @@ -270,6 +270,10 @@ margin: 0 0.5rem; } + &__resume-it-button { + margin: 0 0.5rem; + } + &__cannot-send { display: flex; justify-content: space-between; diff --git a/app/ui-message/client/messageBox/messageBox.html b/app/ui-message/client/messageBox/messageBox.html index 12def35badffa..a5fb8327baace 100644 --- a/app/ui-message/client/messageBox/messageBox.html +++ b/app/ui-message/client/messageBox/messageBox.html @@ -84,7 +84,7 @@ {{#if isBlockedOrBlocker}} {{_ "room_is_blocked"}} {{else}} - {{> messageBoxReadOnly rid=rid isSubscribed=isSubscribed}} + {{> messageBoxReadOnly rid=rid isSubscribed=isSubscribed onHold=onHold }} {{/if}} {{/if}} diff --git a/app/ui-message/client/messageBox/messageBox.js b/app/ui-message/client/messageBox/messageBox.js index 46856eeecea10..7fcf12f371a90 100644 --- a/app/ui-message/client/messageBox/messageBox.js +++ b/app/ui-message/client/messageBox/messageBox.js @@ -120,7 +120,6 @@ Template.messageBox.onRendered(function() { } $input.on('dataChange', () => { const messages = $input.data('reply') || []; - console.log('dataChange', messages); this.replyMessageData.set(messages); }); } @@ -214,6 +213,10 @@ Template.messageBox.helpers({ return false; } + if (subscription?.onHold) { + return false; + } + const isReadOnly = roomTypes.readOnly(rid, Users.findOne({ _id: Meteor.userId() }, { fields: { username: 1 } })); const isArchived = roomTypes.archived(rid) || (subscription && subscription.t === 'd' && subscription.archived); @@ -256,6 +259,10 @@ Template.messageBox.helpers({ isBlockedOrBlocker() { return Template.instance().state.get('isBlockedOrBlocker'); }, + onHold() { + const { rid, subscription } = Template.currentData(); + return rid && !!subscription?.onHold; + }, isSubscribed() { const { subscription } = Template.currentData(); return !!subscription; diff --git a/client/sidebar/hooks/useRoomList.ts b/client/sidebar/hooks/useRoomList.ts index 96087832288c7..2efe703a46a94 100644 --- a/client/sidebar/hooks/useRoomList.ts +++ b/client/sidebar/hooks/useRoomList.ts @@ -33,6 +33,7 @@ export const useRoomList = (): Array => { const direct = new Set(); const discussion = new Set(); const conversation = new Set(); + const onHold = new Set(); rooms.forEach((room) => { if (sidebarShowUnread && (room.alert || room.unread) && !room.hideUnreadStatus) { @@ -55,6 +56,10 @@ export const useRoomList = (): Array => { _private.add(room); } + if (room.t === 'l' && room.onHold) { + return showOmnichannel && onHold.add(room); + } + if (room.t === 'l') { return showOmnichannel && omnichannel.add(room); } @@ -66,11 +71,13 @@ export const useRoomList = (): Array => { conversation.add(room); }); + const groups = new Map(); - showOmnichannel && inquiries.enabled && groups.set('Omnichannel', []); - showOmnichannel && !inquiries.enabled && groups.set('Omnichannel', omnichannel); + showOmnichannel && (inquiries.enabled || onHold.size) && groups.set('Omnichannel', []); + showOmnichannel && !inquiries.enabled && !onHold.size && groups.set('Omnichannel', omnichannel); showOmnichannel && inquiries.enabled && inquiries.queue.length && groups.set('Incoming_Livechats', inquiries.queue); - showOmnichannel && inquiries.enabled && omnichannel.size && groups.set('Open_Livechats', omnichannel); + showOmnichannel && (inquiries.enabled || onHold.size) && omnichannel.size && groups.set('Open_Livechats', omnichannel); + showOmnichannel && onHold.size && groups.set('On_Hold_Chats', onHold); sidebarShowUnread && unread.size && groups.set('Unread', unread); favoritesEnabled && favorite.size && groups.set('Favorites', favorite); showDiscussion && discussion.size && groups.set('Discussions', discussion); diff --git a/definition/IRoom.ts b/definition/IRoom.ts index ae83e23360bdc..a6504dedc7d6c 100644 --- a/definition/IRoom.ts +++ b/definition/IRoom.ts @@ -38,6 +38,8 @@ export interface IRoom extends IRocketChatRecord { balance: number; }[]; }; + + onHold?: boolean; } export interface IDirectMessageRoom extends Omit { diff --git a/definition/ISubscription.ts b/definition/ISubscription.ts index 268475735fae9..69b5a154d89d8 100644 --- a/definition/ISubscription.ts +++ b/definition/ISubscription.ts @@ -31,6 +31,8 @@ export interface ISubscription extends IRocketChatRecord { prid?: RoomID; roles?: string[]; + + onHold?: boolean; } export interface ISubscriptionDirectMessage extends Omit { diff --git a/ee/app/livechat-enterprise/server/api/index.js b/ee/app/livechat-enterprise/server/api/index.js index da303b6e498ab..1906206a69b1b 100644 --- a/ee/app/livechat-enterprise/server/api/index.js +++ b/ee/app/livechat-enterprise/server/api/index.js @@ -6,3 +6,4 @@ import './priorities'; import './tags'; import './units'; import './business-hours'; +import './rooms'; diff --git a/ee/app/livechat-enterprise/server/api/rooms.js b/ee/app/livechat-enterprise/server/api/rooms.js new file mode 100644 index 0000000000000..4cec68f48ddb4 --- /dev/null +++ b/ee/app/livechat-enterprise/server/api/rooms.js @@ -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(); + }, +}); diff --git a/ee/app/livechat-enterprise/server/hooks/afterOnHold.ts b/ee/app/livechat-enterprise/server/hooks/afterOnHold.ts new file mode 100644 index 0000000000000..6a7a4063f28bc --- /dev/null +++ b/ee/app/livechat-enterprise/server/hooks/afterOnHold.ts @@ -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 => { + 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; +}); diff --git a/ee/app/livechat-enterprise/server/hooks/afterOnHoldChatResumed.ts b/ee/app/livechat-enterprise/server/hooks/afterOnHoldChatResumed.ts new file mode 100644 index 0000000000000..82875932ccbfa --- /dev/null +++ b/ee/app/livechat-enterprise/server/hooks/afterOnHoldChatResumed.ts @@ -0,0 +1,12 @@ +import { callbacks } from '../../../../../app/callbacks/server'; +import { LivechatEnterprise } from '../lib/LivechatEnterprise'; + +const handleAfterOnHoldChatResumed = async (room: any): Promise => { + if (!room || !room._id || !room.onHold) { + return; + } + + LivechatEnterprise.releaseOnHoldChat(room); +}; + +callbacks.add('livechat:afterOnHoldChatResumed', handleAfterOnHoldChatResumed, callbacks.priority.HIGH, 'livechat-after-on-hold-chat-resumed'); diff --git a/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.js b/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.js index 3dcb33c08d1de..09a192c357a27 100644 --- a/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.js +++ b/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.js @@ -4,10 +4,9 @@ import { callbacks } from '../../../../../app/callbacks'; import { Users } from '../../../../../app/models/server/raw'; import { settings } from '../../../../../app/settings'; import { getMaxNumberSimultaneousChat } from '../lib/Helper'; -import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager'; import { allowAgentSkipQueue } from '../../../../../app/livechat/server/lib/Helper'; -callbacks.add('livechat.checkAgentBeforeTakeInquiry', async (agent, inquiry) => { +callbacks.add('livechat.checkAgentBeforeTakeInquiry', async ({ agent, inquiry, options }) => { if (!settings.get('Livechat_waiting_queue')) { return agent; } @@ -36,8 +35,7 @@ callbacks.add('livechat.checkAgentBeforeTakeInquiry', async (agent, inquiry) => const { queueInfo: { chats = 0 } = {} } = user; if (maxNumberSimultaneousChat <= chats) { callbacks.run('livechat.onMaxNumberSimultaneousChatsReached', inquiry); - - if (!RoutingManager.getConfig().autoAssignAgent) { + if (options.clientAction) { throw new Meteor.Error('error-max-number-simultaneous-chats-reached', 'Not allowed'); } diff --git a/ee/app/livechat-enterprise/server/hooks/index.js b/ee/app/livechat-enterprise/server/hooks/index.js index 5489b3b065884..99824636a152c 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 './onCloseLivechat'; import './onSaveVisitorInfo'; import './onBusinessHourStart'; +import './onAgentAssignmentFailed'; +import './afterOnHoldChatResumed'; diff --git a/ee/app/livechat-enterprise/server/hooks/onAgentAssignmentFailed.ts b/ee/app/livechat-enterprise/server/hooks/onAgentAssignmentFailed.ts new file mode 100644 index 0000000000000..b8592fff228d7 --- /dev/null +++ b/ee/app/livechat-enterprise/server/hooks/onAgentAssignmentFailed.ts @@ -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 => { + 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'); diff --git a/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.js b/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.js index c180849ed9759..e8a802b8181b2 100644 --- a/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.js +++ b/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.js @@ -2,8 +2,11 @@ import { callbacks } from '../../../../../app/callbacks'; import { settings } from '../../../../../app/settings'; import { dispatchWaitingQueueStatus } from '../lib/Helper'; import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager'; +import { LivechatEnterprise } from '../lib/LivechatEnterprise'; const onCloseLivechat = (room) => { + Promise.await(LivechatEnterprise.releaseOnHoldChat(room)); + if (!settings.get('Livechat_waiting_queue')) { return room; } diff --git a/ee/app/livechat-enterprise/server/hooks/resumeOnHold.js b/ee/app/livechat-enterprise/server/hooks/resumeOnHold.js new file mode 100644 index 0000000000000..750662e3a5cfe --- /dev/null +++ b/ee/app/livechat-enterprise/server/hooks/resumeOnHold.js @@ -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'); diff --git a/ee/app/livechat-enterprise/server/hooks/setPredictedVisitorAbandonmentTime.js b/ee/app/livechat-enterprise/server/hooks/setPredictedVisitorAbandonmentTime.js index a5b6f9cde7bb8..14f20fe556404 100644 --- a/ee/app/livechat-enterprise/server/hooks/setPredictedVisitorAbandonmentTime.js +++ b/ee/app/livechat-enterprise/server/hooks/setPredictedVisitorAbandonmentTime.js @@ -3,7 +3,7 @@ import { settings } from '../../../../../app/settings/server'; import { setPredictedVisitorAbandonmentTime } from '../lib/Helper'; callbacks.add('afterSaveMessage', function(message, room) { - if (!settings.get('Livechat_auto_close_abandoned_rooms') || settings.get('Livechat_visitor_inactivity_timeout') <= 0) { + if (!settings.get('Livechat_abandoned_rooms_action') || settings.get('Livechat_abandoned_rooms_action') === 'none' || settings.get('Livechat_visitor_inactivity_timeout') <= 0) { return message; } // skips this callback if the message was edited diff --git a/ee/app/livechat-enterprise/server/index.js b/ee/app/livechat-enterprise/server/index.js index 0f6006affbed4..3763b924c2195 100644 --- a/ee/app/livechat-enterprise/server/index.js +++ b/ee/app/livechat-enterprise/server/index.js @@ -11,6 +11,7 @@ import './methods/saveUnit'; import './methods/savePriority'; import './methods/removePriority'; import './methods/removeBusinessHour'; +import './methods/resumeOnHold'; import LivechatUnit from '../../models/server/models/LivechatUnit'; import LivechatTag from '../../models/server/models/LivechatTag'; import LivechatUnitMonitors from '../../models/server/models/LivechatUnitMonitors'; @@ -26,7 +27,10 @@ import './hooks/onLoadConfigApi'; import './hooks/onCloseLivechat'; import './hooks/onSaveVisitorInfo'; import './hooks/scheduleAutoTransfer'; +import './hooks/resumeOnHold'; +import './hooks/afterOnHold'; import './lib/routing/LoadBalancing'; +import './lib/AutoCloseOnHoldScheduler'; import { onLicense } from '../../license/server'; import './business-hour'; diff --git a/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts b/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts new file mode 100644 index 0000000000000..9042f96b8e17c --- /dev/null +++ b/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts @@ -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 { + 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 { + const jobName = `${ SCHEDULER_NAME }-${ roomId }`; + await this.scheduler.cancel({ name: jobName }); + } + + private async executeJob({ attrs: { data } }: any = {}): Promise { + 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(); +}); diff --git a/ee/app/livechat-enterprise/server/lib/Helper.js b/ee/app/livechat-enterprise/server/lib/Helper.js index b3f8f675f32da..ed11b351334b4 100644 --- a/ee/app/livechat-enterprise/server/lib/Helper.js +++ b/ee/app/livechat-enterprise/server/lib/Helper.js @@ -124,7 +124,7 @@ export const processWaitingQueue = async (department) => { }; export const setPredictedVisitorAbandonmentTime = (room) => { - if (!room.v || !room.v.lastMessageTs || !settings.get('Livechat_auto_close_abandoned_rooms')) { + if (!room.v || !room.v.lastMessageTs || !settings.get('Livechat_abandoned_rooms_action') || settings.get('Livechat_abandoned_rooms_action') === 'none') { return; } @@ -144,10 +144,10 @@ export const setPredictedVisitorAbandonmentTime = (room) => { }; export const updatePredictedVisitorAbandonment = () => { - if (settings.get('Livechat_auto_close_abandoned_rooms')) { - LivechatRooms.findLivechat({ open: true }).forEach((room) => setPredictedVisitorAbandonmentTime(room)); - } else { + if (!settings.get('Livechat_abandoned_rooms_action') || (settings.get('Livechat_abandoned_rooms_action') === 'none')) { LivechatRooms.unsetPredictedVisitorAbandonment(); + } else { + LivechatRooms.findLivechat({ open: true }).forEach((room) => setPredictedVisitorAbandonmentTime(room)); } }; diff --git a/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.js b/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.js index 77608e6c53066..6ce8b448ce5f4 100644 --- a/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.js +++ b/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.js @@ -5,11 +5,14 @@ import { Users } from '../../../../../app/models'; import { LivechatInquiry, OmnichannelQueue } from '../../../../../app/models/server/raw'; import LivechatUnit from '../../../models/server/models/LivechatUnit'; import LivechatTag from '../../../models/server/models/LivechatTag'; +import { LivechatRooms, Subscriptions } from '../../../../../app/models/server'; import LivechatPriority from '../../../models/server/models/LivechatPriority'; import { addUserRoles, removeUserFromRoles } from '../../../../../app/authorization/server'; import { processWaitingQueue, removePriorityFromRooms, updateInquiryQueuePriority, updatePriorityInquiries, updateRoomPriorityHistory } from './Helper'; import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager'; import { settings } from '../../../../../app/settings/server'; +import { callbacks } from '../../../../../app/callbacks'; +import { AutoCloseOnHoldScheduler } from './AutoCloseOnHoldScheduler'; export const LivechatEnterprise = { addMonitor(username) { @@ -163,6 +166,32 @@ export const LivechatEnterprise = { updateInquiryQueuePriority(roomId, priority); updateRoomPriorityHistory(roomId, user, priority); }, + + placeRoomOnHold(room) { + const { _id: roomId, onHold } = room; + if (!roomId || onHold) { + return false; + } + LivechatRooms.setOnHold(roomId); + Subscriptions.setOnHold(roomId); + + Meteor.defer(() => { + callbacks.run('livechat:afterOnHold', room); + }); + + return true; + }, + + async releaseOnHoldChat(room) { + const { _id: roomId, onHold } = room; + if (!roomId || !onHold) { + return; + } + + await AutoCloseOnHoldScheduler.unscheduleRoom(roomId); + LivechatRooms.unsetAllOnHoldFieldsByRoomId(roomId); + Subscriptions.unsetOnHold(roomId); + }, }; const RACE_TIMEOUT = 1000; diff --git a/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.js b/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.js index 11879836e1829..3f32e99126cdc 100644 --- a/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.js +++ b/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.js @@ -4,6 +4,7 @@ import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; import { settings } from '../../../../../app/settings/server'; import { LivechatRooms, LivechatDepartment, Users } from '../../../../../app/models/server'; import { Livechat } from '../../../../../app/livechat/server/lib/Livechat'; +import { LivechatEnterprise } from './LivechatEnterprise'; export class VisitorInactivityMonitor { constructor() { @@ -77,11 +78,27 @@ export class VisitorInactivityMonitor { }); } + placeRoomOnHold(room) { + LivechatEnterprise.placeRoomOnHold(room) && LivechatRooms.unsetPredictedVisitorAbandonmentByRoomId(room._id); + } + handleAbandonedRooms() { - if (!settings.get('Livechat_auto_close_abandoned_rooms')) { + const action = settings.get('Livechat_abandoned_rooms_action'); + if (!action || action === 'none') { return; } - LivechatRooms.findAbandonedOpenRooms(new Date()).forEach((room) => this.closeRooms(room)); + LivechatRooms.findAbandonedOpenRooms(new Date()).forEach((room) => { + switch (action) { + case 'close': { + this.closeRooms(room); + break; + } + case 'on-hold': { + this.placeRoomOnHold(room); + break; + } + } + }); this._initializeMessageCache(); } } diff --git a/ee/app/livechat-enterprise/server/methods/resumeOnHold.ts b/ee/app/livechat-enterprise/server/methods/resumeOnHold.ts new file mode 100644 index 0000000000000..77523c52e04cb --- /dev/null +++ b/ee/app/livechat-enterprise/server/methods/resumeOnHold.ts @@ -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)); + }, +}); diff --git a/ee/app/livechat-enterprise/server/permissions.js b/ee/app/livechat-enterprise/server/permissions.js index ba09adda5ecb4..2e934be9cea44 100644 --- a/ee/app/livechat-enterprise/server/permissions.js +++ b/ee/app/livechat-enterprise/server/permissions.js @@ -21,6 +21,8 @@ export const createPermissions = () => { 'view-livechat-rooms', 'close-livechat-room', 'close-others-livechat-room', + 'on-hold-livechat-room', + 'on-hold-others-livechat-room', 'save-others-livechat-room-info', 'remove-closed-livechat-rooms', 'view-livechat-analytics', diff --git a/ee/app/livechat-enterprise/server/settings.js b/ee/app/livechat-enterprise/server/settings.js index 89c15ea1890f4..4963cc256981a 100644 --- a/ee/app/livechat-enterprise/server/settings.js +++ b/ee/app/livechat-enterprise/server/settings.js @@ -53,24 +53,30 @@ export const createSettings = () => { ], }); - settings.add('Livechat_auto_close_abandoned_rooms', false, { - type: 'boolean', + settings.add('Livechat_abandoned_rooms_action', 'none', { + type: 'select', group: 'Omnichannel', section: 'Sessions', - i18nLabel: 'Enable_omnichannel_auto_close_abandoned_rooms', + values: [ + { key: 'none', i18nLabel: 'Do_Nothing' }, + { key: 'close', i18nLabel: 'Livechat_close_chat' }, + { key: 'on-hold', i18nLabel: 'Livechat_onHold_Chat' }, + ], enterprise: true, - invalidValue: false, + public: true, + invalidValue: 'none', modules: [ 'livechat-enterprise', ], }); + settings.add('Livechat_abandoned_rooms_closed_custom_message', '', { type: 'string', group: 'Omnichannel', section: 'Sessions', i18nLabel: 'Livechat_abandoned_rooms_closed_custom_message', - enableQuery: { _id: 'Livechat_auto_close_abandoned_rooms', value: true }, + enableQuery: { _id: 'Livechat_abandoned_rooms_action', value: 'close' }, enterprise: true, invalidValue: '', modules: [ @@ -89,18 +95,6 @@ export const createSettings = () => { ], }); - settings.add('Livechat_auto_transfer_chat_timeout', 0, { - type: 'int', - group: 'Omnichannel', - section: 'Sessions', - i18nDescription: 'Livechat_auto_transfer_chat_timeout_description', - enterprise: true, - invalidValue: 0, - modules: [ - 'livechat-enterprise', - ], - }); - settings.addGroup('Omnichannel', function() { this.section('Business_Hours', function() { this.add('Livechat_business_hour_type', 'Single', { @@ -134,5 +128,52 @@ export const createSettings = () => { ], }); + settings.add('Livechat_auto_close_on_hold_chats_timeout', 3600, { + type: 'int', + group: 'Omnichannel', + section: 'Sessions', + enterprise: true, + invalidValue: 0, + modules: [ + 'livechat-enterprise', + ], + }); + + settings.add('Livechat_auto_close_on_hold_chats_custom_message', '', { + type: 'string', + group: 'Omnichannel', + section: 'Sessions', + enableQuery: { _id: 'Livechat_auto_close_on_hold_chats_timeout', value: { $gte: 1 } }, + enterprise: true, + invalidValue: '', + modules: [ + 'livechat-enterprise', + ], + }); + + settings.add('Livechat_allow_manual_on_hold', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'Sessions', + enterprise: true, + invalidValue: false, + public: true, + modules: [ + 'livechat-enterprise', + ], + }); + + settings.add('Livechat_auto_transfer_chat_timeout', 0, { + type: 'int', + group: 'Omnichannel', + section: 'Sessions', + i18nDescription: 'Livechat_auto_transfer_chat_timeout_description', + enterprise: true, + invalidValue: 0, + modules: [ + 'livechat-enterprise', + ], + }); + 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 52e9640fff91b..71724f59268e3 100644 --- a/ee/app/livechat-enterprise/server/startup.js +++ b/ee/app/livechat-enterprise/server/startup.js @@ -16,9 +16,9 @@ const businessHours = { }; Meteor.startup(async function() { - settings.onload('Livechat_auto_close_abandoned_rooms', function(_, value) { + settings.onload('Livechat_abandoned_rooms_action', function(_, value) { updatePredictedVisitorAbandonment(); - if (!value) { + if (!value || value === 'none') { return visitorActivityMonitor.stop(); } visitorActivityMonitor.start(); diff --git a/ee/app/models/server/models/LivechatRooms.js b/ee/app/models/server/models/LivechatRooms.js index 2dc41df46cbd3..69144374584e0 100644 --- a/ee/app/models/server/models/LivechatRooms.js +++ b/ee/app/models/server/models/LivechatRooms.js @@ -46,6 +46,20 @@ LivechatRooms.prototype.findAbandonedOpenRooms = function(date) { }); }; +LivechatRooms.prototype.setOnHold = function(roomId) { + return this.update( + { _id: roomId }, + { $set: { onHold: true } }, + ); +}; + +LivechatRooms.prototype.unsetOnHold = function(roomId) { + return this.update( + { _id: roomId }, + { $unset: { onHold: 1 } }, + ); +}; + LivechatRooms.prototype.unsetPredictedVisitorAbandonment = function() { return this.update({ open: true, @@ -57,6 +71,25 @@ LivechatRooms.prototype.unsetPredictedVisitorAbandonment = function() { }); }; +LivechatRooms.prototype.unsetPredictedVisitorAbandonmentByRoomId = function(roomId) { + return this.update({ + _id: roomId, + }, { + $unset: { 'omnichannel.predictedVisitorAbandonmentAt': 1 }, + }); +}; + +LivechatRooms.prototype.unsetAllOnHoldFieldsByRoomId = function(roomId) { + return this.update({ + _id: roomId, + }, { + $unset: { + 'omnichannel.predictedVisitorAbandonmentAt': 1, + onHold: 1, + }, + }); +}; + LivechatRooms.prototype.unsetPriorityById = function(priorityId) { return this.update({ open: true, diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 034a84489cf7f..bdb4544869d4c 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -724,8 +724,12 @@ "Chat_closed_successfully": "Chat closed successfully", "Chat_History": "Chat History", "Chat_Now": "Chat Now", + "chat_on_hold_due_to_inactivity": "This chat is on-hold due to inactivity", + "Chat_On_Hold": "Chat On-Hold", + "Chat_On_Hold_Successfully": "This chat was successfully placed On-Hold", "Chat_queued": "Chat Queued", "Chat_removed": "Chat Removed", + "Chat_resumed": "Chat Resumed", "Chat_start": "Chat Start", "Chat_started": "Chat started", "Chat_taken": "Chat Taken", @@ -1372,6 +1376,7 @@ "Displays_action_text": "Displays action text", "Do_not_display_unread_counter": "Do not display any counter of this channel", "Do_not_provide_this_code_to_anyone": "Do not provide this code to anyone.", + "Do_Nothing": "Do Nothing", "Do_you_want_to_accept": "Do you want to accept?", "Do_you_want_to_change_to_s_question": "Do you want to change to %s?", "Document_Domain": "Document Domain", @@ -1644,6 +1649,7 @@ "error-role-in-use": "Cannot delete role because it's in use", "error-role-name-required": "Role name is required", "error-room-is-not-closed": "Room is not closed", + "error-room-onHold": "Error! Room is On Hold", "error-selected-agent-room-agent-are-same": "The selected agent and the room agent are the same", "error-starring-message": "Message could not be stared", "error-tags-must-be-assigned-before-closing-chat": "Tag(s) must be assigned before closing the chat", @@ -2401,15 +2407,23 @@ "List_of_Direct_Messages": "List of Direct Messages", "Omnichannel": "Omnichannel", "Livechat": "Livechat", + "Livechat_abandoned_rooms_action": "How to handle Visitor Abandonment", "Livechat_abandoned_rooms_closed_custom_message": "Custom message when room is automatically closed by visitor inactivity", "Livechat_agents": "Omnichannel agents", "Livechat_Agents": "Agents", + "Livechat_allow_manual_on_hold": "Allow agents to manually place chat On Hold", + "Livechat_allow_manual_on_hold_Description": "If enabled, the agent will get a new option to place a chat On Hold, provided the agent has sent the last message", "Livechat_AllowedDomainsList": "Livechat Allowed Domains", "Livechat_Appearance": "Livechat Appearance", + "Livechat_auto_close_on_hold_chats_custom_message": "Custom message for closed chats in On Hold queue", + "Livechat_auto_close_on_hold_chats_custom_message_Description": "Custom Message to be sent when a room in On-Hold queue gets automatically closed by the system", + "Livechat_auto_close_on_hold_chats_timeout": "How long to wait before closing a chat in On Hold Queue ?", + "Livechat_auto_close_on_hold_chats_timeout_Description": "Define how long the chat will remain in the On Hold queue until it's automatically closed by the system. Time in seconds", "Livechat_auto_transfer_chat_timeout": "Timeout (in seconds) for automatic transfer of unanswered chats to another agent", - "Livechat_auto_transfer_chat_timeout_description": "This event takes place only when the chat has just started. After the first transfering for inactivity, the room is no longer monitored.", + "Livechat_auto_transfer_chat_timeout_Description": "This event takes place only when the chat has just started. After the first transfering for inactivity, the room is no longer monitored.", "Livechat_business_hour_type": "Business Hour Type (Single or Multiple)", "Livechat_chat_transcript_sent": "Chat transcript sent: __transcript__", + "Livechat_close_chat": "Close chat", "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_Dashboard": "Omnichannel Dashboard", @@ -2434,6 +2448,7 @@ "Livechat_offline": "Omnichannel offline", "Livechat_offline_message_sent": "Livechat offline message sent", "Livechat_OfflineMessageToChannel_enabled": "Send Livechat offline messages to a channel", + "Livechat_onHold_Chat": "Place chat On-Hold", "Livechat_online": "Omnichannel on-line", "Livechat_Queue": "Omnichannel Queue", "Livechat_registration_form": "Registration Form", @@ -2933,6 +2948,7 @@ "Omnichannel_External_Frame_Encryption_JWK_Description": "If provided it will encrypt the user's token with the provided key and the external system will need to decrypt the data to access the token", "Omnichannel_External_Frame_URL": "External frame URL", "On": "On", + "On_Hold_Chats": "On Hold", "online": "online", "Online": "Online", "Only_authorized_users_can_write_new_messages": "Only authorized users can write new messages", @@ -4233,6 +4249,7 @@ "Without_priority": "Without priority", "Worldwide": "Worldwide", "Would_you_like_to_return_the_inquiry": "Would you like to return the inquiry?", + "Would_you_like_to_place_chat_on_hold": "Would you like to place this chat On-Hold?", "Yes": "Yes", "Yes_archive_it": "Yes, archive it!", "Yes_clear_all": "Yes, clear all!", diff --git a/server/modules/watchers/publishFields.ts b/server/modules/watchers/publishFields.ts index 63f1cd8183858..4b05d89372e7d 100644 --- a/server/modules/watchers/publishFields.ts +++ b/server/modules/watchers/publishFields.ts @@ -37,6 +37,7 @@ export const subscriptionFields = { tunreadGroup: 1, tunreadUser: 1, v: 1, + onHold: 1, }; export const roomFields = { diff --git a/server/startup/migrations/index.js b/server/startup/migrations/index.js index e4e5470f4846d..b36ce20ef6b98 100644 --- a/server/startup/migrations/index.js +++ b/server/startup/migrations/index.js @@ -215,4 +215,5 @@ import './v215'; import './v216'; import './v217'; import './v218'; +import './v219'; import './xrun'; diff --git a/server/startup/migrations/v219.js b/server/startup/migrations/v219.js new file mode 100644 index 0000000000000..b155c086d2552 --- /dev/null +++ b/server/startup/migrations/v219.js @@ -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, + }); + }, +});