diff --git a/app/apps/server/bridges/livechat.js b/app/apps/server/bridges/livechat.js index 001d87fab741f..edd3aabf00be7 100644 --- a/app/apps/server/bridges/livechat.js +++ b/app/apps/server/bridges/livechat.js @@ -123,7 +123,7 @@ export class AppLivechatBridge { return Livechat.transfer( this.orch.getConverters().get('rooms').convertAppRoom(currentRoom), this.orch.getConverters().get('visitors').convertAppVisitor(visitor), - { userId: targetAgent.id, departmentId } + { userId: targetAgent.id, departmentId }, ); } diff --git a/app/livechat/lib/messageTypes.js b/app/livechat/lib/messageTypes.js index 381c6d7b3b7e8..0188443baa8e4 100644 --- a/app/livechat/lib/messageTypes.js +++ b/app/livechat/lib/messageTypes.js @@ -22,6 +22,34 @@ MessageTypes.registerType({ }, }); +MessageTypes.registerType({ + id: 'livechat_transfer_history', + system: true, + message: 'New_chat_transfer', + data(message) { + if (!message.transferData) { + return; + } + const from = message.transferData.transferredBy && (message.transferData.transferredBy.name || message.transferData.transferredBy.username); + const transferTypes = { + agent: () => TAPi18n.__('Livechat_transfer_to_agent', { + from, + to: message.transferData.transferredTo && (message.transferData.transferredTo.name || message.transferData.transferredTo.username), + }), + department: () => TAPi18n.__('Livechat_transfer_to_department', { + from, + to: message.transferData.nextDepartment && message.transferData.nextDepartment.name, + }), + queue: () => TAPi18n.__('Livechat_transfer_return_to_the_queue', { + from, + }), + }; + return { + transfer: transferTypes[message.transferData.scope](), + }; + }, +}); + MessageTypes.registerType({ id: 'livechat_video_call', system: true, diff --git a/app/livechat/server/api/v1/room.js b/app/livechat/server/api/v1/room.js index 3ebce3ec482fa..91ab0fe4300d9 100644 --- a/app/livechat/server/api/v1/room.js +++ b/app/livechat/server/api/v1/room.js @@ -8,6 +8,7 @@ import { Messages, LivechatRooms } from '../../../../models'; import { API } from '../../../../api'; import { findGuest, findRoom, getRoom, settings, findAgent } from '../lib/livechat'; import { Livechat } from '../../lib/Livechat'; +import { normalizeTransferredByData } from '../../lib/Helper'; API.v1.addRoute('livechat/room', { get() { @@ -103,7 +104,10 @@ API.v1.addRoute('livechat/room.transfer', { // update visited page history to not expire Messages.keepHistoryForToken(token); - if (!Promise.await(Livechat.transfer(room, guest, { roomId: rid, departmentId: department }))) { + const { _id, username, name } = guest; + const transferredBy = normalizeTransferredByData({ _id, username, name, userType: 'visitor' }, room); + + if (!Promise.await(Livechat.transfer(room, guest, { roomId: rid, departmentId: department, transferredBy }))) { return API.v1.failure(); } diff --git a/app/livechat/server/config.js b/app/livechat/server/config.js index 6de0719a1aa45..f4998c7d87acf 100644 --- a/app/livechat/server/config.js +++ b/app/livechat/server/config.js @@ -140,32 +140,6 @@ Meteor.startup(function() { i18nLabel: 'Livechat_room_count', }); - settings.add('Livechat_agent_leave_action', 'none', { - type: 'select', - group: 'Livechat', - values: [ - { key: 'none', i18nLabel: 'None' }, - { key: 'forward', i18nLabel: 'Forward' }, - { key: 'close', i18nLabel: 'Close' }, - ], - i18nLabel: 'How_to_handle_open_sessions_when_agent_goes_offline', - }); - - settings.add('Livechat_agent_leave_action_timeout', 60, { - type: 'int', - group: 'Livechat', - enableQuery: { _id: 'Livechat_agent_leave_action', value: { $ne: 'none' } }, - i18nLabel: 'How_long_to_wait_after_agent_goes_offline', - i18nDescription: 'Time_in_seconds', - }); - - settings.add('Livechat_agent_leave_comment', '', { - type: 'string', - group: 'Livechat', - enableQuery: { _id: 'Livechat_agent_leave_action', value: 'close' }, - i18nLabel: 'Comment_to_leave_on_closing_session', - }); - settings.add('Livechat_enabled_when_agent_idle', true, { type: 'boolean', group: 'Livechat', @@ -475,4 +449,41 @@ Meteor.startup(function() { i18nDescription: 'Data_processing_consent_text_description', enableQuery: { _id: 'Livechat_force_accept_data_processing_consent', value: true }, }); + + settings.add('Livechat_agent_leave_action', 'none', { + type: 'select', + group: 'Livechat', + section: 'Sessions', + values: [ + { key: 'none', i18nLabel: 'None' }, + { key: 'forward', i18nLabel: 'Forward' }, + { key: 'close', i18nLabel: 'Close' }, + ], + i18nLabel: 'How_to_handle_open_sessions_when_agent_goes_offline', + }); + + settings.add('Livechat_agent_leave_action_timeout', 60, { + type: 'int', + group: 'Livechat', + section: 'Sessions', + enableQuery: { _id: 'Livechat_agent_leave_action', value: { $ne: 'none' } }, + i18nLabel: 'How_long_to_wait_after_agent_goes_offline', + i18nDescription: 'Time_in_seconds', + }); + + settings.add('Livechat_agent_leave_comment', '', { + type: 'string', + group: 'Livechat', + section: 'Sessions', + enableQuery: { _id: 'Livechat_agent_leave_action', value: 'close' }, + i18nLabel: 'Comment_to_leave_on_closing_session', + }); + + settings.add('Livechat_visitor_inactivity_timeout', 3600, { + type: 'int', + group: 'Livechat', + section: 'Sessions', + i18nLabel: 'How_long_to_wait_to_consider_visitor_abandonment', + i18nDescription: 'Time_in_seconds', + }); }); diff --git a/app/livechat/server/hooks/processRoomAbandonment.js b/app/livechat/server/hooks/processRoomAbandonment.js new file mode 100644 index 0000000000000..342547090a354 --- /dev/null +++ b/app/livechat/server/hooks/processRoomAbandonment.js @@ -0,0 +1,58 @@ +import moment from 'moment'; + +import { settings } from '../../../settings'; +import { callbacks } from '../../../callbacks'; +import { LivechatRooms, Messages, LivechatOfficeHour } from '../../../models'; + +const getSecondsWhenOfficeHoursIsDisabled = (room, agentLastMessage) => moment(new Date(room.closedAt)).diff(moment(new Date(agentLastMessage.ts)), 'seconds'); +const getOfficeHoursDictionary = () => LivechatOfficeHour.find().fetch().reduce((acc, day) => { + acc[day.day] = { + start: day.start, + finish: day.finish, + open: day.open, + }; + return acc; +}, {}); +const getSecondsSinceLastAgentResponse = (room, agentLastMessage) => { + if (!settings.get('Livechat_enable_office_hours')) { + return getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage); + } + + let totalSeconds = 0; + const officeDays = getOfficeHoursDictionary(); + const endOfConversation = moment(new Date(room.closedAt)); + const startOfInactivity = moment(new Date(agentLastMessage.ts)); + const daysOfInactivity = endOfConversation.clone().startOf('day').diff(startOfInactivity.clone().startOf('day'), 'days'); + const inactivityDay = moment(new Date(agentLastMessage.ts)); + + for (let index = 0; index <= daysOfInactivity; index++) { + const today = inactivityDay.clone().format('dddd'); + const officeDay = officeDays[today]; + const startTodaysOfficeHour = moment(officeDay.start, 'HH:mm').add(index, 'days'); + const endTodaysOfficeHour = moment(officeDay.finish, 'HH:mm').add(index, 'days'); + if (officeDays[today].open) { + const firstDayOfInactivity = startOfInactivity.clone().format('D') === inactivityDay.clone().format('D'); + const lastDayOfInactivity = endOfConversation.clone().format('D') === inactivityDay.clone().format('D'); + + if (!firstDayOfInactivity && !lastDayOfInactivity) { + totalSeconds += endTodaysOfficeHour.clone().diff(startTodaysOfficeHour, 'seconds'); + } else { + const end = endOfConversation.isBefore(endTodaysOfficeHour) ? endOfConversation : endTodaysOfficeHour; + const start = firstDayOfInactivity ? inactivityDay : startTodaysOfficeHour; + totalSeconds += end.clone().diff(start, 'seconds'); + } + } + inactivityDay.add(1, 'days'); + } + return totalSeconds; +}; + +callbacks.add('livechat.closeRoom', (room) => { + const closedByAgent = room.closer !== 'visitor'; + const wasTheLastMessageSentByAgent = room.lastMessage && !room.lastMessage.token; + if (closedByAgent && wasTheLastMessageSentByAgent) { + const agentLastMessage = Messages.findAgentLastMessageByVisitorLastMessageTs(room._id, room.v.lastMessageTs).fetch()[0]; + const secondsSinceLastAgentResponse = getSecondsSinceLastAgentResponse(room, agentLastMessage); + LivechatRooms.setVisitorInactivityInSecondsByRoomId(room._id, secondsSinceLastAgentResponse); + } +}, callbacks.priority.HIGH, 'process-room-abandonment'); diff --git a/app/livechat/server/hooks/saveLastVisitorMessageTs.js b/app/livechat/server/hooks/saveLastVisitorMessageTs.js new file mode 100644 index 0000000000000..bd6fdff8cb870 --- /dev/null +++ b/app/livechat/server/hooks/saveLastVisitorMessageTs.js @@ -0,0 +1,12 @@ +import { callbacks } from '../../../callbacks'; +import { LivechatRooms } from '../../../models'; + +callbacks.add('afterSaveMessage', function(message, room) { + if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.v && room.v.token)) { + return message; + } + if (message.token) { + LivechatRooms.setVisitorLastMessageTimestampByRoomId(room._id, message.ts); + } + return message; +}, callbacks.priority.HIGH, 'save-last-visitor-message-timestamp'); diff --git a/app/livechat/server/index.js b/app/livechat/server/index.js index b6603ac15c244..52e30d9701eae 100644 --- a/app/livechat/server/index.js +++ b/app/livechat/server/index.js @@ -15,6 +15,8 @@ import './hooks/RDStation'; import './hooks/saveAnalyticsData'; import './hooks/sendToCRM'; import './hooks/sendToFacebook'; +import './hooks/processRoomAbandonment'; +import './hooks/saveLastVisitorMessageTs'; import './methods/addAgent'; import './methods/addManager'; import './methods/changeLivechatStatus'; diff --git a/app/livechat/server/lib/Helper.js b/app/livechat/server/lib/Helper.js index 55fb40c56813c..dc39444449be2 100644 --- a/app/livechat/server/lib/Helper.js +++ b/app/livechat/server/lib/Helper.js @@ -257,3 +257,18 @@ export const forwardRoomToDepartment = async (room, guest, departmentId) => { return true; }; + +export const normalizeTransferredByData = (transferredBy, room) => { + if (!transferredBy || !room) { + throw new Error('You must provide "transferredBy" and "room" params to "getTransferredByData"'); + } + const { servedBy: { _id: agentId } = {} } = room; + const { _id, username, name, userType: transferType } = transferredBy; + const type = transferType || (_id === agentId ? 'agent' : 'user'); + return { + _id, + username, + name, + type, + }; +}; diff --git a/app/livechat/server/lib/Livechat.js b/app/livechat/server/lib/Livechat.js index 437631bda5e85..9e4c494ad0fc6 100644 --- a/app/livechat/server/lib/Livechat.js +++ b/app/livechat/server/lib/Livechat.js @@ -36,6 +36,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 } from './Helper'; export const Livechat = { Analytics, @@ -430,7 +431,10 @@ export const Livechat = { forwardOpenChats(userId) { LivechatRooms.findOpenByAgent(userId).forEach((room) => { const guest = LivechatVisitors.findOneById(room.v._id); - this.transfer(room, guest, { departmentId: guest.department }); + const user = Users.findOneById(userId); + const { _id, username, name } = user; + const transferredBy = normalizeTransferredByData({ _id, username, name }, room); + this.transfer(room, guest, { roomId: room._id, transferredBy, departmentId: guest.department }); }); }, @@ -461,8 +465,38 @@ export const Livechat = { } }, + saveTransferHistory(room, transferData) { + const { departmentId: previousDepartment } = room; + const { department: nextDepartment, transferredBy, transferredTo, scope } = transferData; + check(transferredBy, Match.ObjectIncluding({ + _id: String, + username: String, + name: String, + type: String, + })); + const user = Users.findOneByUsername(transferredBy.username); + const transfer = { + transferData: { + transferredBy, + ts: new Date(), + scope: scope || (nextDepartment ? 'department' : 'agent'), + ...previousDepartment && { previousDepartment }, + ...nextDepartment && { nextDepartment }, + ...transferredTo && { transferredTo }, + }, + }; + return Messages.createTransferHistoryWithRoomIdMessageAndUser(room._id, '', user, transfer); + }, + async transfer(room, guest, transferData) { - return RoutingManager.transferRoom(room, guest, transferData); + const result = await RoutingManager.transferRoom(room, guest, transferData); + if (!result) { + return false; + } + if (transferData.departmentId) { + transferData.department = LivechatDepartment.findOneById(transferData.departmentId, { fields: { name: 1 } }); + } + return this.saveTransferHistory(room, transferData); }, returnRoomAsInquiry(rid, departmentId) { @@ -489,7 +523,9 @@ export const Livechat = { if (!inquiry) { return false; } - + const transferredBy = normalizeTransferredByData(user, room); + const transferData = { roomId: rid, scope: 'queue', departmentId, transferredBy }; + this.saveTransferHistory(room, transferData); return RoutingManager.unassignAgent(inquiry, departmentId); }, @@ -866,6 +902,7 @@ export const Livechat = { }, notifyAgentStatusChanged(userId, status) { + callbacks.runAsync('livechat.agentStatusChanged', { userId, status }); if (!settings.get('Livechat_show_agent_info')) { return; } diff --git a/app/livechat/server/lib/analytics/departments.js b/app/livechat/server/lib/analytics/departments.js index 1dbf78449dde7..75ad306b09d58 100644 --- a/app/livechat/server/lib/analytics/departments.js +++ b/app/livechat/server/lib/analytics/departments.js @@ -4,105 +4,112 @@ const findAllRoomsAsync = async ({ start, end, answered, + departmentId, options = {}, }) => { if (!start || !end) { throw new Error('"start" and "end" must be provided'); } return { - departments: await LivechatDepartment.findAllRooms({ start, answered, end, options }), - total: (await LivechatDepartment.findAllRooms({ start, answered, end })).length, + departments: await LivechatDepartment.findAllRooms({ start, answered, end, departmentId, options }), + total: (await LivechatDepartment.findAllRooms({ start, answered, end, departmentId })).length, }; }; const findAllAverageServiceTimeAsync = async ({ start, end, + departmentId, options = {}, }) => { if (!start || !end) { throw new Error('"start" and "end" must be provided'); } return { - departments: await LivechatDepartment.findAllAverageServiceTime({ start, end, options }), - total: (await LivechatDepartment.findAllAverageServiceTime({ start, end })).length, + departments: await LivechatDepartment.findAllAverageServiceTime({ start, end, departmentId, options }), + total: (await LivechatDepartment.findAllAverageServiceTime({ start, end, departmentId })).length, }; }; const findAllServiceTimeAsync = async ({ start, end, + departmentId, options = {}, }) => { if (!start || !end) { throw new Error('"start" and "end" must be provided'); } return { - departments: await LivechatDepartment.findAllServiceTime({ start, end, options }), - total: (await LivechatDepartment.findAllServiceTime({ start, end })).length, + departments: await LivechatDepartment.findAllServiceTime({ start, end, departmentId, options }), + total: (await LivechatDepartment.findAllServiceTime({ start, end, departmentId })).length, }; }; const findAllAverageWaitingTimeAsync = async ({ start, end, + departmentId, options = {}, }) => { if (!start || !end) { throw new Error('"start" and "end" must be provided'); } return { - departments: await LivechatDepartment.findAllAverageWaitingTime({ start, end, options }), - total: (await LivechatDepartment.findAllAverageWaitingTime({ start, end })).length, + departments: await LivechatDepartment.findAllAverageWaitingTime({ start, end, departmentId, options }), + total: (await LivechatDepartment.findAllAverageWaitingTime({ start, end, departmentId })).length, }; }; -const findAllNumberOfTransferedRoomsAsync = async ({ +const findAllNumberOfTransferredRoomsAsync = async ({ start, end, + departmentId, options = {}, }) => { if (!start || !end) { throw new Error('"start" and "end" must be provided'); } return { - departments: await LivechatDepartment.findAllNumberOfTransferedRooms({ start, end, options }), - total: (await LivechatDepartment.findAllNumberOfTransferedRooms({ start, end })).length, + departments: await LivechatDepartment.findAllNumberOfTransferredRooms({ start, end, departmentId, options }), + total: (await LivechatDepartment.findAllNumberOfTransferredRooms({ start, end, departmentId })).length, }; }; const findAllNumberOfAbandonedRoomsAsync = async ({ start, end, + departmentId, options = {}, }) => { if (!start || !end) { throw new Error('"start" and "end" must be provided'); } return { - departments: await LivechatDepartment.findAllNumberOfAbandonedRooms({ start, end, options }), - total: (await LivechatDepartment.findAllNumberOfAbandonedRooms({ start, end })).length, + departments: await LivechatDepartment.findAllNumberOfAbandonedRooms({ start, end, departmentId, options }), + total: (await LivechatDepartment.findAllNumberOfAbandonedRooms({ start, end, departmentId })).length, }; }; const findPercentageOfAbandonedRoomsAsync = async ({ start, end, + departmentId, options = {}, }) => { if (!start || !end) { throw new Error('"start" and "end" must be provided'); } return { - departments: await LivechatDepartment.findPercentageOfAbandonedRooms({ start, end, options }), - total: (await LivechatDepartment.findPercentageOfAbandonedRooms({ start, end })).length, + departments: await LivechatDepartment.findPercentageOfAbandonedRooms({ start, end, departmentId, options }), + total: (await LivechatDepartment.findPercentageOfAbandonedRooms({ start, end, departmentId })).length, }; }; -export const findAllAverageServiceTime = ({ start, end, options }) => Promise.await(findAllAverageServiceTimeAsync({ start, end, options })); -export const findAllRooms = ({ start, end, answered, options }) => Promise.await(findAllRoomsAsync({ start, end, answered, options })); -export const findAllServiceTime = ({ start, end, options }) => Promise.await(findAllServiceTimeAsync({ start, end, options })); -export const findAllAverageWaitingTime = ({ start, end, options }) => Promise.await(findAllAverageWaitingTimeAsync({ start, end, options })); -export const findAllNumberOfTransferedRooms = ({ start, end, options }) => Promise.await(findAllNumberOfTransferedRoomsAsync({ start, end, options })); -export const findAllNumberOfAbandonedRooms = ({ start, end, options }) => Promise.await(findAllNumberOfAbandonedRoomsAsync({ start, end, options })); -export const findPercentageOfAbandonedRooms = ({ start, end, options }) => Promise.await(findPercentageOfAbandonedRoomsAsync({ start, end, options })); +export const findAllAverageServiceTime = ({ start, end, departmentId, options }) => Promise.await(findAllAverageServiceTimeAsync({ start, end, departmentId, options })); +export const findAllRooms = ({ start, end, answered, departmentId, options }) => Promise.await(findAllRoomsAsync({ start, end, answered, departmentId, options })); +export const findAllServiceTime = ({ start, end, departmentId, options }) => Promise.await(findAllServiceTimeAsync({ start, end, departmentId, options })); +export const findAllAverageWaitingTime = ({ start, end, departmentId, options }) => Promise.await(findAllAverageWaitingTimeAsync({ start, end, departmentId, options })); +export const findAllNumberOfTransferredRooms = ({ start, end, departmentId, options }) => Promise.await(findAllNumberOfTransferredRoomsAsync({ start, end, departmentId, options })); +export const findAllNumberOfAbandonedRooms = ({ start, end, departmentId, options }) => Promise.await(findAllNumberOfAbandonedRoomsAsync({ start, end, departmentId, options })); +export const findPercentageOfAbandonedRooms = ({ start, end, departmentId, options }) => Promise.await(findPercentageOfAbandonedRoomsAsync({ start, end, departmentId, options })); diff --git a/app/livechat/server/methods/setDepartmentForVisitor.js b/app/livechat/server/methods/setDepartmentForVisitor.js index e3eaa25529d9a..9d1f7abaccd95 100644 --- a/app/livechat/server/methods/setDepartmentForVisitor.js +++ b/app/livechat/server/methods/setDepartmentForVisitor.js @@ -3,6 +3,7 @@ import { check } from 'meteor/check'; import { LivechatRooms, Messages, LivechatVisitors } from '../../../models'; import { Livechat } from '../lib/Livechat'; +import { normalizeTransferredByData } from '../lib/Helper'; Meteor.methods({ 'livechat:setDepartmentForVisitor'({ roomId, visitorToken, departmentId } = {}) { @@ -23,6 +24,7 @@ Meteor.methods({ const transferData = { roomId, departmentId, + transferredBy: normalizeTransferredByData(visitor, room), }; return Livechat.transfer(room, visitor, transferData); diff --git a/app/livechat/server/methods/transfer.js b/app/livechat/server/methods/transfer.js index 352cc149065a5..88a60e991afe2 100644 --- a/app/livechat/server/methods/transfer.js +++ b/app/livechat/server/methods/transfer.js @@ -2,8 +2,9 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import { hasPermission } from '../../../authorization'; -import { LivechatRooms, Subscriptions, LivechatVisitors } from '../../../models'; +import { LivechatRooms, Subscriptions, LivechatVisitors, Users } from '../../../models'; import { Livechat } from '../lib/Livechat'; +import { normalizeTransferredByData } from '../lib/Helper'; Meteor.methods({ 'livechat:transfer'(transferData) { @@ -32,6 +33,11 @@ Meteor.methods({ } const guest = LivechatVisitors.findOneById(room.v && room.v._id); + transferData.transferredBy = normalizeTransferredByData(Meteor.user() || {}, room); + if (transferData.userId) { + const userToTransfer = Users.findOneById(transferData.userId); + transferData.transferredTo = { _id: userToTransfer._id, username: userToTransfer.username, name: userToTransfer.name }; + } return Livechat.transfer(room, guest, transferData); }, diff --git a/app/livechat/server/startup.js b/app/livechat/server/startup.js index 9fdaf13f02166..8a27e52c24c76 100644 --- a/app/livechat/server/startup.js +++ b/app/livechat/server/startup.js @@ -10,6 +10,7 @@ import { LivechatInquiry } from '../lib/LivechatInquiry'; import { LivechatDepartment, LivechatDepartmentAgents } from '../../models/server'; import { RoutingManager } from './lib/RoutingManager'; import { createLivechatQueueView } from './lib/Helper'; +import { LivechatAgentActivityMonitor } from './statistics/LivechatAgentActivityMonitor'; Meteor.startup(() => { roomTypes.setRoomFind('l', (_id) => LivechatRooms.findOneById(_id)); @@ -69,4 +70,6 @@ Meteor.startup(() => { }, callbacks.priority.LOW, 'cant-leave-room'); createLivechatQueueView(); + + new LivechatAgentActivityMonitor().start(); }); diff --git a/app/livechat/server/statistics/LivechatAgentActivityMonitor.js b/app/livechat/server/statistics/LivechatAgentActivityMonitor.js new file mode 100644 index 0000000000000..6221aebdfc4e5 --- /dev/null +++ b/app/livechat/server/statistics/LivechatAgentActivityMonitor.js @@ -0,0 +1,126 @@ +import moment from 'moment'; +import { Meteor } from 'meteor/meteor'; +import { SyncedCron } from 'meteor/littledata:synced-cron'; + +import { callbacks } from '../../../callbacks/server'; +import { LivechatAgentActivity, Sessions, Users } from '../../../models/server'; + +const formatDate = (dateTime = new Date()) => ({ + date: parseInt(moment(dateTime).format('YYYYMMDD')), +}); + +export class LivechatAgentActivityMonitor { + constructor() { + this._started = false; + this._handleMeteorConnection = this._handleMeteorConnection.bind(this); + this._handleAgentStatusChanged = this._handleAgentStatusChanged.bind(this); + this._handleUserStatusLivechatChanged = this._handleUserStatusLivechatChanged.bind(this); + } + + start() { + this._setupListeners(); + } + + isRunning() { + return this._started; + } + + _setupListeners() { + if (this.isRunning()) { + return; + } + this._startMonitoring(); + Meteor.onConnection(this._handleMeteorConnection); + callbacks.add('livechat.agentStatusChanged', this._handleAgentStatusChanged); + callbacks.add('livechat.setUserStatusLivechat', this._handleUserStatusLivechatChanged); + this.started = true; + } + + _startMonitoring() { + SyncedCron.add({ + name: 'Livechat Agent Activity Monitor', + schedule: (parser) => parser.cron('0 0 * * *'), + job: () => { + this._updateActiveSessions(); + }, + }); + SyncedCron.start(); + } + + _updateActiveSessions() { + const openLivechatAgentSessions = LivechatAgentActivity.findOpenSessions().fetch(); + if (!openLivechatAgentSessions.length) { + return; + } + const today = moment(new Date()); + const yesterday = today.clone().subtract(1, 'days'); + const stoppedAt = new Date(yesterday.year(), yesterday.month(), yesterday.date(), 23, 59, 59); + const startedAt = new Date(today.year(), today.month(), today.date()); + for (const session of openLivechatAgentSessions) { + const data = { ...formatDate(yesterday), agentId: session.agentId }; + const availableTime = moment(stoppedAt).diff(moment(new Date(session.lastStartedAt)), 'seconds'); + LivechatAgentActivity.updateLastStoppedAt({ ...data, availableTime, lastStoppedAt: stoppedAt }); + LivechatAgentActivity.updateServiceHistory({ ...data, serviceHistory: { startedAt: session.lastStartedAt, stoppedAt } }); + this._createOrUpdateSession(session.agentId, startedAt); + } + } + + _handleMeteorConnection(connection) { + const session = Sessions.findOne({ sessionId: connection.id }); + if (!session) { + return; + } + const user = Users.findOneById(session.userId); + if (user && user.status !== 'offline' && user.statusLivechat === 'available') { + this._createOrUpdateSession(user._id); + } + connection.onClose(() => { + if (session) { + this._updateSessionWhenAgentStop(session.userId); + } + }); + } + + _handleAgentStatusChanged({ userId, status }) { + const user = Users.findOneById(userId); + if (!user || user.statusLivechat !== 'available') { + return; + } + + if (status !== 'offline') { + this._createOrUpdateSession(userId); + } else { + this._updateSessionWhenAgentStop(userId); + } + } + + _handleUserStatusLivechatChanged({ userId, status }) { + const user = Users.findOneById(userId); + if (user && user.status === 'offline') { + return; + } + + if (status === 'available') { + this._createOrUpdateSession(userId); + } + if (status === 'not-available') { + this._updateSessionWhenAgentStop(userId); + } + } + + _createOrUpdateSession(userId, lastStartedAt) { + const data = { ...formatDate(lastStartedAt), agentId: userId, lastStartedAt }; + LivechatAgentActivity.createOrUpdate(data); + } + + _updateSessionWhenAgentStop(userId) { + const data = { ...formatDate(), agentId: userId }; + const livechatSession = LivechatAgentActivity.findOne(data); + if (livechatSession) { + const stoppedAt = new Date(); + const availableTime = moment(stoppedAt).diff(moment(new Date(livechatSession.lastStartedAt)), 'seconds'); + LivechatAgentActivity.updateLastStoppedAt({ ...data, availableTime, lastStoppedAt: stoppedAt }); + LivechatAgentActivity.updateServiceHistory({ ...data, serviceHistory: { startedAt: livechatSession.lastStartedAt, stoppedAt } }); + } + } +} diff --git a/app/models/server/index.js b/app/models/server/index.js index 81b288f34d534..dc92464edf6cd 100644 --- a/app/models/server/index.js +++ b/app/models/server/index.js @@ -32,6 +32,7 @@ import LivechatPageVisited from './models/LivechatPageVisited'; import LivechatRooms from './models/LivechatRooms'; import LivechatTrigger from './models/LivechatTrigger'; import LivechatVisitors from './models/LivechatVisitors'; +import LivechatAgentActivity from './models/LivechatAgentActivity'; import ReadReceipts from './models/ReadReceipts'; export { AppsLogsModel } from './models/apps-logs-model'; @@ -77,5 +78,6 @@ export { LivechatRooms, LivechatTrigger, LivechatVisitors, + LivechatAgentActivity, ReadReceipts, }; diff --git a/app/models/server/models/LivechatAgentActivity.js b/app/models/server/models/LivechatAgentActivity.js new file mode 100644 index 0000000000000..48368f22c3de5 --- /dev/null +++ b/app/models/server/models/LivechatAgentActivity.js @@ -0,0 +1,69 @@ +import { Base } from './_Base'; + +export class LivechatAgentActivity extends Base { + constructor(...args) { + super(...args); + + this.tryEnsureIndex({ date: 1 }); + this.tryEnsureIndex({ agentId: 1, date: 1 }, { unique: true }); + this.tryEnsureIndex({ agentId: 1 }); + } + + createOrUpdate(data = {}) { + const { date, agentId, lastStartedAt } = data; + + if (!date || !agentId) { + return; + } + + return this.upsert({ agentId, date }, { + $unset: { + lastStoppedAt: 1, + }, + $set: { + lastStartedAt: lastStartedAt || new Date(), + }, + $setOnInsert: { + date, + agentId, + }, + }); + } + + updateLastStoppedAt({ agentId, date, lastStoppedAt, availableTime }) { + const query = { + agentId, + date, + }; + const update = { + $inc: { availableTime }, + $set: { + lastStoppedAt, + }, + }; + return this.update(query, update); + } + + updateServiceHistory({ agentId, date, serviceHistory }) { + const query = { + agentId, + date, + }; + const update = { + $addToSet: { + serviceHistory, + }, + }; + return this.update(query, update); + } + + findOpenSessions() { + const query = { + lastStoppedAt: { $exists: false }, + }; + + return this.find(query); + } +} + +export default new LivechatAgentActivity('livechat_agent_activity'); diff --git a/app/models/server/models/LivechatRooms.js b/app/models/server/models/LivechatRooms.js index 94deba0930841..66912966b97bd 100644 --- a/app/models/server/models/LivechatRooms.js +++ b/app/models/server/models/LivechatRooms.js @@ -449,6 +449,32 @@ export class LivechatRooms extends Base { return this.remove(query); } + + setVisitorLastMessageTimestampByRoomId(roomId, lastMessageTs) { + const query = { + _id: roomId, + }; + const update = { + $set: { + 'v.lastMessageTs': lastMessageTs, + }, + }; + + return this.update(query, update); + } + + setVisitorInactivityInSecondsByRoomId(roomId, visitorInactivity) { + const query = { + _id: roomId, + }; + const update = { + $set: { + 'metrics.visitorInactivity': visitorInactivity, + }, + }; + + return this.update(query, update); + } } export default new LivechatRooms(Rooms.model, true); diff --git a/app/models/server/models/Messages.js b/app/models/server/models/Messages.js index 882bfa63ad685..6f955447a5dfd 100644 --- a/app/models/server/models/Messages.js +++ b/app/models/server/models/Messages.js @@ -770,6 +770,33 @@ export class Messages extends Base { return record; } + createTransferHistoryWithRoomIdMessageAndUser(roomId, message, user, extraData) { + const type = 'livechat_transfer_history'; + const room = Rooms.findOneById(roomId, { fields: { sysMes: 1 } }); + if ((room != null ? room.sysMes : undefined) === false) { + return; + } + const record = { + t: type, + rid: roomId, + ts: new Date(), + msg: message, + u: { + _id: user._id, + username: user.username, + }, + groupable: false, + }; + + if (settings.get('Message_Read_Receipt_Enabled')) { + record.unread = true; + } + Object.assign(record, extraData); + + record._id = this.insertOrUpsert(record); + return record; + } + createUserJoinWithRoomIdAndUser(roomId, user, extraData) { const message = user.username; return this.createWithTypeRoomIdMessageAndUser('uj', roomId, message, user, extraData); @@ -1109,6 +1136,15 @@ export class Messages extends Base { findThreadsByRoomId(rid, skip, limit) { return this.find({ rid, tcount: { $exists: true } }, { sort: { tlm: -1 }, skip, limit }); } + + findAgentLastMessageByVisitorLastMessageTs(roomId, visitorLastMessageTs) { + const query = { + rid: roomId, + ts: { $gt: visitorLastMessageTs }, + }; + + return this.find(query, { sort: { ts: 1 } }); + } } export default new Messages(); diff --git a/app/models/server/raw/LivechatDepartment.js b/app/models/server/raw/LivechatDepartment.js index cfb66a6789fcb..01168f278777c 100644 --- a/app/models/server/raw/LivechatDepartment.js +++ b/app/models/server/raw/LivechatDepartment.js @@ -2,7 +2,7 @@ import { BaseRaw } from './BaseRaw'; import { getValue } from '../../../settings/server/raw'; export class LivechatDepartmentRaw extends BaseRaw { - findAllRooms({ start, end, answered, options = {} }) { + findAllRooms({ start, end, answered, departmentId, options = {} }) { const roomsFilter = [ { $gte: ['$$room.ts', new Date(start)] }, { $lte: ['$$room.ts', new Date(end)] }, @@ -36,7 +36,15 @@ export class LivechatDepartmentRaw extends BaseRaw { }, }, }; + const match = { + $match: { + _id: departmentId, + }, + }; const params = [lookup, project]; + if (departmentId) { + params.unshift(match); + } if (options.offset) { params.push({ $skip: options.offset }); } @@ -49,7 +57,7 @@ export class LivechatDepartmentRaw extends BaseRaw { return this.col.aggregate(params).toArray(); } - findAllAverageServiceTime({ start, end, options = {} }) { + findAllAverageServiceTime({ start, end, departmentId, options = {} }) { const roomsFilter = [ { $gte: ['$$room.ts', new Date(start)] }, { $lte: ['$$room.ts', new Date(end)] }, @@ -92,7 +100,15 @@ export class LivechatDepartmentRaw extends BaseRaw { averageServiceTimeInSeconds: { $ceil: { $cond: [{ $eq: ['$chats', 0] }, 0, { $divide: ['$chatsDuration', '$chats'] }] } }, }, }]; + const match = { + $match: { + _id: departmentId, + }, + }; const params = [lookup, ...projects]; + if (departmentId) { + params.unshift(match); + } if (options.offset) { params.push({ $skip: options.offset }); } @@ -105,7 +121,7 @@ export class LivechatDepartmentRaw extends BaseRaw { return this.col.aggregate(params).toArray(); } - findAllServiceTime({ start, end, options = {} }) { + findAllServiceTime({ start, end, departmentId, options = {} }) { const roomsFilter = [ { $gte: ['$$room.ts', new Date(start)] }, { $lte: ['$$room.ts', new Date(end)] }, @@ -142,7 +158,15 @@ export class LivechatDepartmentRaw extends BaseRaw { chatsDuration: { $ceil: { $sum: '$rooms.metrics.chatDuration' } }, }, }]; + const match = { + $match: { + _id: departmentId, + }, + }; const params = [lookup, ...projects]; + if (departmentId) { + params.unshift(match); + } if (options.offset) { params.push({ $skip: options.offset }); } @@ -155,7 +179,7 @@ export class LivechatDepartmentRaw extends BaseRaw { return this.col.aggregate(params).toArray(); } - findAllAverageWaitingTime({ start, end, options = {} }) { + findAllAverageWaitingTime({ start, end, departmentId, options = {} }) { const roomsFilter = [ { $gte: ['$$room.ts', new Date(start)] }, { $lte: ['$$room.ts', new Date(end)] }, @@ -198,7 +222,15 @@ export class LivechatDepartmentRaw extends BaseRaw { averageWaitingTimeInSeconds: { $ceil: { $cond: [{ $eq: ['$chats', 0] }, 0, { $divide: ['$chatsFirstResponses', '$chats'] }] } }, }, }]; + const match = { + $match: { + _id: departmentId, + }, + }; const params = [lookup, ...projects]; + if (departmentId) { + params.unshift(match); + } if (options.offset) { params.push({ $skip: options.offset }); } @@ -211,12 +243,13 @@ export class LivechatDepartmentRaw extends BaseRaw { return this.col.aggregate(params).toArray(); } - findAllNumberOfTransferedRooms({ start, end, options = {} }) { - const roomsFilter = [ - { $gte: ['$$room.ts', new Date(start)] }, - { $lte: ['$$room.ts', new Date(end)] }, + findAllNumberOfTransferredRooms({ start, end, departmentId, options = {} }) { + const messageFilter = [ + { $gte: ['$$message.ts', new Date(start)] }, + { $lte: ['$$message.ts', new Date(end)] }, + { $eq: ['$$message.t', 'livechat_transfer_history'] }, ]; - const lookup = { + const roomsLookup = { $lookup: { from: 'rocketchat_room', localField: '_id', @@ -224,15 +257,29 @@ export class LivechatDepartmentRaw extends BaseRaw { as: 'rooms', }, }; + const messagesLookup = { + $lookup: { + from: 'rocketchat_message', + localField: 'rooms._id', + foreignField: 'rid', + as: 'messages', + }, + }; const projectRooms = { $project: { department: '$$ROOT', - rooms: { + rooms: 1, + }, + }; + const projectMessages = { + $project: { + department: '$department', + messages: { $filter: { - input: '$rooms', - as: 'room', + input: '$messages', + as: 'message', cond: { - $and: roomsFilter, + $and: messageFilter, }, }, }, @@ -241,7 +288,7 @@ export class LivechatDepartmentRaw extends BaseRaw { const projectTransfersSize = { $project: { department: '$department', - transfers: { $size: { $ifNull: ['$rooms.transferHistory', []] } }, + transfers: { $size: { $ifNull: ['$messages', []] } }, }, }; const group = { @@ -252,7 +299,7 @@ export class LivechatDepartmentRaw extends BaseRaw { description: '$department.description', enabled: '$department.enabled', }, - numberOfTransferedRooms: { $sum: '$transfers' }, + numberOfTransferredRooms: { $sum: '$transfers' }, }, }; const presentationProject = { @@ -261,7 +308,7 @@ export class LivechatDepartmentRaw extends BaseRaw { name: '$_id.name', description: '$_id.description', enabled: '$_id.enabled', - numberOfTransferedRooms: 1, + numberOfTransferredRooms: 1, }, }; const unwind = { @@ -270,7 +317,15 @@ export class LivechatDepartmentRaw extends BaseRaw { preserveNullAndEmptyArrays: true, }, }; - const params = [lookup, projectRooms, unwind, projectTransfersSize, group, presentationProject]; + const match = { + $match: { + _id: departmentId, + }, + }; + const params = [roomsLookup, projectRooms, unwind, messagesLookup, projectMessages, projectTransfersSize, group, presentationProject]; + if (departmentId) { + params.unshift(match); + } if (options.offset) { params.push({ $skip: options.offset }); } @@ -283,7 +338,7 @@ export class LivechatDepartmentRaw extends BaseRaw { return this.col.aggregate(params).toArray(); } - async findAllNumberOfAbandonedRooms({ start, end, options = {} }) { + async findAllNumberOfAbandonedRooms({ start, end, departmentId, options = {} }) { const roomsFilter = [ { $gte: ['$$room.ts', new Date(start)] }, { $lte: ['$$room.ts', new Date(end)] }, @@ -319,7 +374,15 @@ export class LivechatDepartmentRaw extends BaseRaw { abandonedRooms: { $size: '$rooms' }, }, }]; + const match = { + $match: { + _id: departmentId, + }, + }; const params = [lookup, ...projects]; + if (departmentId) { + params.unshift(match); + } if (options.offset) { params.push({ $skip: options.offset }); } @@ -332,7 +395,7 @@ export class LivechatDepartmentRaw extends BaseRaw { return this.col.aggregate(params).toArray(); } - findPercentageOfAbandonedRooms({ start, end, options = {} }) { + findPercentageOfAbandonedRooms({ start, end, departmentId, options = {} }) { const roomsFilter = [ { $gte: ['$$room.ts', new Date(start)] }, { $lte: ['$$room.ts', new Date(end)] }, @@ -403,7 +466,15 @@ export class LivechatDepartmentRaw extends BaseRaw { }, }, }; + const match = { + $match: { + _id: departmentId, + }, + }; const params = [lookup, projectRooms, unwind, group, presentationProject]; + if (departmentId) { + params.unshift(match); + } if (options.offset) { params.push({ $skip: options.offset }); } diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index b4aaa21e97824..d66abf045806b 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1584,6 +1584,7 @@ "How_friendly_was_the_chat_agent": "How friendly was the chat agent?", "How_knowledgeable_was_the_chat_agent": "How knowledgeable was the chat agent?", "How_long_to_wait_after_agent_goes_offline": "How Long to Wait After Agent Goes Offline", + "How_long_to_wait_to_consider_visitor_abandonment": "How Long to Wait to Consider Visitor Abandonment?", "How_responsive_was_the_chat_agent": "How responsive was the chat agent?", "How_satisfied_were_you_with_this_chat": "How satisfied were you with this chat?", "How_to_handle_open_sessions_when_agent_goes_offline": "How to Handle Open Sessions When Agent Goes Offline", @@ -1997,6 +1998,9 @@ "Livechat_title": "Livechat Title", "Livechat_title_color": "Livechat Title Background Color", "Livechat_transcript_sent": "Livechat transcript sent", + "Livechat_transfer_to_agent": "__from__ transferred the chat to __to__", + "Livechat_transfer_to_department": "__from__ transferred the chat to the department __to__", + "Livechat_transfer_return_to_the_queue": "__from__ returned the chat to the queue", "Livechat_Users": "Livechat Users", "LiveStream & Broadcasting": "LiveStream & Broadcasting", "Livestream_close": "Close Livestream", @@ -2257,6 +2261,7 @@ "Name_Placeholder": "Please enter your name...", "Navigation_History": "Navigation History", "New_Application": "New Application", + "New_chat_transfer": "New Chat Transfer: __transfer__", "New_Custom_Field": "New Custom Field", "New_Department": "New Department", "New_discussion": "New discussion", diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index c0209e3be6adf..721d0e4d5c52f 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -1499,6 +1499,7 @@ "How_friendly_was_the_chat_agent": "Quão amigável foi o agente de bate-papo?", "How_knowledgeable_was_the_chat_agent": "O agente bate-papo possuía conhecimentos suficientes?", "How_long_to_wait_after_agent_goes_offline": "Quanto tempo esperar após agente ficar offline", + "How_long_to_wait_to_consider_visitor_abandonment": "Quanto tempo esperar para considerar um abandono do visitante?", "How_responsive_was_the_chat_agent": "Quão responsivo foi o agente de bate-papo?", "How_satisfied_were_you_with_this_chat": "Ficou satisfeito com este bate-papo?", "How_to_handle_open_sessions_when_agent_goes_offline": "O que fazer com sessões abertas quando agente ficar offline", @@ -1868,6 +1869,9 @@ "Livechat_title": "Título Livechat", "Livechat_title_color": "Cor de fundo do título do Livechat", "Livechat_transcript_sent": "Transcrição de Livechat enviada", + "Livechat_transfer_to_agent": "__from__ transferiu a conversa para __to__", + "Livechat_transfer_to_department": "__from__ transferiu a conversa para o departamento __to__", + "Livechat_transfer_return_to_the_queue": "__from__ retornou a conversa para a fila", "Livechat_Users": "Usuários Livechat", "LiveStream & Broadcasting": "LiveStream e Broadcasting", "Livestream_close": "Fechar Livestream", @@ -2105,6 +2109,7 @@ "Name_Placeholder": "Por favor, insira seu nome...", "Navigation_History": "Histórico de navegação", "New_Application": "Nova aplicação", + "New_chat_transfer": "Nova Transferência de conversa: __transfer__", "New_Custom_Field": "Novo Campo Personalizado", "New_Department": "Novo Departamento", "New_discussion": "Nova discussão", diff --git a/server/startup/migrations/index.js b/server/startup/migrations/index.js index bef05a91fb079..ca39ce54a2c90 100644 --- a/server/startup/migrations/index.js +++ b/server/startup/migrations/index.js @@ -164,4 +164,5 @@ import './v163'; import './v164'; import './v165'; import './v166'; +import './v167'; import './xrun'; diff --git a/server/startup/migrations/v167.js b/server/startup/migrations/v167.js new file mode 100644 index 0000000000000..c09a75c75ebad --- /dev/null +++ b/server/startup/migrations/v167.js @@ -0,0 +1,11 @@ +import { Migrations } from '../../../app/migrations'; +import { Settings } from '../../../app/models'; + +Migrations.add({ + version: 167, + up() { + Settings.update({ _id: 'Livechat_agent_leave_action' }, { $set: { section: 'Sessions' } }); + Settings.update({ _id: 'Livechat_agent_leave_action_timeout' }, { $set: { section: 'Sessions' } }); + Settings.update({ _id: 'Livechat_agent_leave_comment' }, { $set: { section: 'Sessions' } }); + }, +});