diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js index b57571205fa4..ae5004a84500 100644 --- a/app/livechat/server/lib/Livechat.js +++ b/app/livechat/server/lib/Livechat.js @@ -117,8 +117,9 @@ export const Livechat = { } if (room == null) { + const defaultAgent = callbacks.run('livechat.checkDefaultAgentOnNewRoom', agent, guest); // if no department selected verify if there is at least one active and pick the first - if (!agent && !guest.department) { + if (!defaultAgent && !guest.department) { const department = this.getRequiredDepartment(); if (department) { @@ -127,7 +128,7 @@ export const Livechat = { } // delegate room creation to QueueManager - room = await QueueManager.requestRoom({ guest, message, roomInfo, agent, extraData }); + room = await QueueManager.requestRoom({ guest, message, roomInfo, agent: defaultAgent, extraData }); newRoom = true; } diff --git a/app/livechat/server/lib/RoutingManager.js b/app/livechat/server/lib/RoutingManager.js index f654415af4a8..5558d1be5d0e 100644 --- a/app/livechat/server/lib/RoutingManager.js +++ b/app/livechat/server/lib/RoutingManager.js @@ -145,7 +145,7 @@ export const RoutingManager = { LivechatInquiry.takeInquiry(_id); const inq = this.assignAgent(inquiry, agent); - callbacks.run('livechat.afterTakeInquiry', inq); + callbacks.runAsync('livechat.afterTakeInquiry', inq, agent); return LivechatRooms.findOneById(rid); }, diff --git a/app/models/server/models/LivechatInquiry.js b/app/models/server/models/LivechatInquiry.js index 954c7083ab6a..9dc6251a17cb 100644 --- a/app/models/server/models/LivechatInquiry.js +++ b/app/models/server/models/LivechatInquiry.js @@ -45,6 +45,7 @@ export class LivechatInquiry extends Base { _id: inquiryId, }, { $set: { status: 'taken' }, + $unset: { defaultAgent: 1 }, }); } @@ -62,11 +63,14 @@ export class LivechatInquiry extends Base { /* * mark inquiry as queued */ - queueInquiry(inquiryId) { + queueInquiry(inquiryId, defaultAgent) { return this.update({ _id: inquiryId, }, { - $set: { status: 'queued' }, + $set: { + status: 'queued', + ...defaultAgent && { defaultAgent }, + }, }); } @@ -180,6 +184,14 @@ export class LivechatInquiry extends Base { return collectionObj.aggregate(aggregate).toArray(); } + removeDefaultAgentById(inquiryId) { + return this.update({ + _id: inquiryId, + }, { + $unset: { defaultAgent: 1 }, + }); + } + /* * remove the inquiry by roomId */ diff --git a/app/models/server/models/LivechatRooms.js b/app/models/server/models/LivechatRooms.js index 25e2e51c6be2..49823c11f896 100644 --- a/app/models/server/models/LivechatRooms.js +++ b/app/models/server/models/LivechatRooms.js @@ -16,6 +16,8 @@ export class LivechatRooms extends Base { this.tryEnsureIndex({ 'metrics.serviceTimeDuration': 1 }, { sparse: true }); this.tryEnsureIndex({ 'metrics.visitorInactivity': 1 }, { sparse: true }); this.tryEnsureIndex({ 'omnichannel.predictedVisitorAbandonmentAt': 1 }, { sparse: true }); + this.tryEnsureIndex({ closedAt: 1 }, { sparse: true }); + this.tryEnsureIndex({ servedBy: 1 }, { sparse: true }); } findLivechat(filter = {}, offset = 0, limit = 20) { @@ -164,6 +166,18 @@ export class LivechatRooms extends Base { return this.findOne(query, options); } + findOneLastServedAndClosedByVisitorToken(visitorToken, options = {}) { + const query = { + t: 'l', + 'v.token': visitorToken, + closedAt: { $exists: true }, + servedBy: { $exists: true }, + }; + + options.sort = { closedAt: -1 }; + return this.findOne(query, options); + } + findOneByVisitorToken(visitorToken, fields) { const options = {}; diff --git a/app/models/server/models/LivechatVisitors.js b/app/models/server/models/LivechatVisitors.js index 803ecf2dc56f..571d950b0fee 100644 --- a/app/models/server/models/LivechatVisitors.js +++ b/app/models/server/models/LivechatVisitors.js @@ -78,6 +78,20 @@ export class LivechatVisitors extends Base { return this.update(query, update); } + updateLastAgentByToken(token, lastAgent) { + const query = { + token, + }; + + const update = { + $set: { + lastAgent, + }, + }; + + return this.update(query, update); + } + /** * Find a visitor by their phone number * @return {object} User from db diff --git a/app/models/server/models/Rooms.js b/app/models/server/models/Rooms.js index 0f038458b700..6e5007cabf90 100644 --- a/app/models/server/models/Rooms.js +++ b/app/models/server/models/Rooms.js @@ -20,9 +20,6 @@ export class Rooms extends Base { // discussions this.tryEnsureIndex({ prid: 1 }, { sparse: true }); this.tryEnsureIndex({ fname: 1 }, { sparse: true }); - // Livechat - statistics - this.tryEnsureIndex({ closedAt: 1 }, { sparse: true }); - // field used for DMs only this.tryEnsureIndex({ uids: 1 }, { sparse: true }); } diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index c9515a3b03e7..5f9374fac4a6 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -124,10 +124,10 @@ export class Users extends Base { return this.findOne(query); } - findOneOnlineAgentByUsername(username) { + findOneOnlineAgentByUsername(username, options) { const query = queryStatusAgentOnline({ username }); - return this.findOne(query); + return this.findOne(query, options); } findOneOnlineAgentById(_id) { diff --git a/ee/app/livechat-enterprise/server/hooks/beforeRoutingChat.js b/ee/app/livechat-enterprise/server/hooks/beforeRoutingChat.js index dd5cefce1bfa..4a9701648b91 100644 --- a/ee/app/livechat-enterprise/server/hooks/beforeRoutingChat.js +++ b/ee/app/livechat-enterprise/server/hooks/beforeRoutingChat.js @@ -3,7 +3,7 @@ import { settings } from '../../../../../app/settings'; import { LivechatInquiry } from '../../../../../app/models/server'; import { dispatchInquiryPosition, checkWaitingQueue } from '../lib/Helper'; -callbacks.add('livechat.beforeRouteChat', async (inquiry) => { +callbacks.add('livechat.beforeRouteChat', async (inquiry, agent) => { if (!settings.get('Livechat_waiting_queue')) { return inquiry; } @@ -18,7 +18,7 @@ callbacks.add('livechat.beforeRouteChat', async (inquiry) => { return inquiry; } - LivechatInquiry.queueInquiry(_id); + LivechatInquiry.queueInquiry(_id, agent); const [inq] = await LivechatInquiry.getCurrentSortedQueueAsync({ _id }); if (inq) { diff --git a/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.js b/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.js index 4cbdd304f043..513f07d8a3e9 100644 --- a/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.js +++ b/ee/app/livechat-enterprise/server/hooks/checkAgentBeforeTakeInquiry.js @@ -34,9 +34,12 @@ callbacks.add('livechat.checkAgentBeforeTakeInquiry', async (agent, inquiry) => const { queueInfo: { chats = 0 } = {} } = user; if (maxNumberSimultaneousChat <= chats) { + callbacks.run('livechat.onMaxNumberSimultaneousChatsReached', inquiry, agent); + if (!RoutingManager.getConfig().autoAssignAgent) { throw new Meteor.Error('error-max-number-simultaneous-chats-reached', 'Not allowed'); } + return null; } return agent; diff --git a/ee/app/livechat-enterprise/server/hooks/handleLastChattedAgentPreferredEvents.js b/ee/app/livechat-enterprise/server/hooks/handleLastChattedAgentPreferredEvents.js new file mode 100644 index 000000000000..9bdc2bc8de66 --- /dev/null +++ b/ee/app/livechat-enterprise/server/hooks/handleLastChattedAgentPreferredEvents.js @@ -0,0 +1,96 @@ +import { callbacks } from '../../../../../app/callbacks/server'; +import { RoutingManager } from '../../../../../app/livechat/server/lib/RoutingManager'; +import { settings } from '../../../../../app/settings/server'; +import { LivechatRooms, LivechatInquiry, LivechatVisitors, Users } from '../../../../../app/models/server'; +import { checkWaitingQueue } from '../lib/Helper'; + +const normalizeDefaultAgent = (agent) => { + if (!agent) { + return; + } + + const { _id: agentId, username } = agent; + return { agentId, username }; +}; + +const checkDefaultAgentOnNewRoom = (defaultAgent, defaultGuest) => { + if (defaultAgent || !defaultGuest) { + return defaultAgent; + } + + if (!RoutingManager.getConfig().autoAssignAgent) { + return defaultAgent; + } + + const { _id: guestId } = defaultGuest; + const guest = LivechatVisitors.findOneById(guestId, { fields: { lastAgent: 1, token: 1 } }); + const { lastAgent: { username: usernameByVisitor } = {}, token } = guest; + + const lastGuestAgent = usernameByVisitor && normalizeDefaultAgent(Users.findOneOnlineAgentByUsername(usernameByVisitor, { fields: { _id: 1, username: 1 } })); + if (lastGuestAgent) { + return lastGuestAgent; + } + + const room = LivechatRooms.findOneLastServedAndClosedByVisitorToken(token, { fields: { servedBy: 1 } }); + if (!room || !room.servedBy) { + return defaultAgent; + } + + const { servedBy: { username: usernameByRoom } } = room; + const lastRoomAgent = normalizeDefaultAgent(Users.findOneOnlineAgentByUsername(usernameByRoom, { fields: { _id: 1, username: 1 } })); + return lastRoomAgent || defaultAgent; +}; + +const onMaxNumberSimultaneousChatsReached = (inquiry, agent) => { + if (!inquiry || !inquiry.defaultAgent) { + return inquiry; + } + + if (!RoutingManager.getConfig().autoAssignAgent) { + return inquiry; + } + + const { _id, defaultAgent, department } = inquiry; + + LivechatInquiry.removeDefaultAgentById(_id); + + const { _id: defaultAgentId } = defaultAgent; + const { agentId } = agent; + + if (defaultAgentId === agentId) { + checkWaitingQueue(department); + } + + return LivechatInquiry.findOneById(_id); +}; + +const afterTakeInquiry = (inquiry, agent) => { + if (!inquiry || !agent) { + return inquiry; + } + + if (!RoutingManager.getConfig().autoAssignAgent) { + return inquiry; + } + + const { v: { token } = {} } = inquiry; + if (!token) { + return inquiry; + } + + LivechatVisitors.updateLastAgentByToken(token, { ...agent, ts: new Date() }); + + return inquiry; +}; +settings.get('Livechat_last_chatted_agent_routing', function(key, value) { + if (!value) { + callbacks.remove('livechat.checkDefaultAgentOnNewRoom', 'livechat-check-default-agent-new-room'); + callbacks.remove('livechat.onMaxNumberSimultaneousChatsReached', 'livechat-on-max-number-simultaneous-chats-reached'); + callbacks.remove('livechat.afterTakeInquiry', 'livechat-save-default-agent-after-take-inquiry'); + return; + } + + callbacks.add('livechat.checkDefaultAgentOnNewRoom', checkDefaultAgentOnNewRoom, callbacks.priority.MEDIUM, 'livechat-check-default-agent-new-room'); + callbacks.add('livechat.onMaxNumberSimultaneousChatsReached', onMaxNumberSimultaneousChatsReached, callbacks.priority.MEDIUM, 'livechat-on-max-number-simultaneous-chats-reached'); + callbacks.add('livechat.afterTakeInquiry', afterTakeInquiry, callbacks.priority.MEDIUM, 'livechat-save-default-agent-after-take-inquiry'); +}); diff --git a/ee/app/livechat-enterprise/server/index.js b/ee/app/livechat-enterprise/server/index.js index 63f36630a18c..f18d76223f40 100644 --- a/ee/app/livechat-enterprise/server/index.js +++ b/ee/app/livechat-enterprise/server/index.js @@ -27,6 +27,7 @@ import './hooks/beforeNewInquiry'; import './hooks/beforeNewRoom'; import './hooks/beforeRoutingChat'; import './hooks/checkAgentBeforeTakeInquiry'; +import './hooks/handleLastChattedAgentPreferredEvents'; import './hooks/onCheckRoomParamsApi'; import './hooks/onLoadConfigApi'; import './hooks/onSetUserStatusLivechat'; diff --git a/ee/app/livechat-enterprise/server/lib/Helper.js b/ee/app/livechat-enterprise/server/lib/Helper.js index d3c525e85066..dd3318c8e678 100644 --- a/ee/app/livechat-enterprise/server/lib/Helper.js +++ b/ee/app/livechat-enterprise/server/lib/Helper.js @@ -100,7 +100,8 @@ const processWaitingQueue = async (department) => { return; } - const room = await RoutingManager.delegateInquiry(inquiry); + const { defaultAgent } = inquiry; + const room = await RoutingManager.delegateInquiry(inquiry, defaultAgent); const propagateAgentDelegated = Meteor.bindEnvironment((rid, agentId) => { dispatchAgentDelegated(rid, agentId); diff --git a/ee/app/livechat-enterprise/server/settings.js b/ee/app/livechat-enterprise/server/settings.js index 8d91cb92f6c2..118fe5967a54 100644 --- a/ee/app/livechat-enterprise/server/settings.js +++ b/ee/app/livechat-enterprise/server/settings.js @@ -51,5 +51,12 @@ export const createSettings = () => { enableQuery: { _id: 'Livechat_auto_close_abandoned_rooms', value: true }, }); + settings.add('Livechat_last_chatted_agent_routing', false, { + type: 'boolean', + group: 'Omnichannel', + section: 'Routing', + enableQuery: { _id: 'Livechat_Routing_Method', value: { $ne: 'Manual_Selection' } }, + }); + Settings.addOptionValueById('Livechat_Routing_Method', { key: 'Load_Balancing', i18nLabel: 'Load_Balancing' }); }; diff --git a/ee/i18n/en.i18n.json b/ee/i18n/en.i18n.json index 09f587ee2297..0c44d3332954 100644 --- a/ee/i18n/en.i18n.json +++ b/ee/i18n/en.i18n.json @@ -34,6 +34,8 @@ "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_last_chatted_agent_routing": "Last-Chatted Agent Preferred", + "Livechat_last_chatted_agent_routing_Description": "The Last-Chatted Agent setting allocates chats to the agent who previously interacted with the same visitor if the agent is available when the chat starts.", "Livechat_Monitors": "Monitors", "Livechat_monitors": "Livechat monitors", "Max_number_of_chats_per_agent": "Max. number of simultaneous chats", diff --git a/ee/i18n/pt-BR.i18n.json b/ee/i18n/pt-BR.i18n.json index 892daf29eed0..e7ebff498eae 100644 --- a/ee/i18n/pt-BR.i18n.json +++ b/ee/i18n/pt-BR.i18n.json @@ -26,6 +26,8 @@ "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_last_chatted_agent_routing": "Agente preferido pela última conversa", + "Livechat_last_chatted_agent_routing_Description": "Agente preferido pela última conversa aloca bate-papos para o agente que interagiu anteriormente com o mesmo visitante, caso o agente esteja disponível quando o bate-papo for iniciado.", "Livechat_Monitors": "Monitores", "Livechat_monitors": "Monitores de Livechat", "Max_number_of_chats_per_agent": "Número máximo de atendimentos simultâneos",