diff --git a/.changeset/purple-boxes-shout.md b/.changeset/purple-boxes-shout.md new file mode 100644 index 0000000000000..9e15fbd28faaf --- /dev/null +++ b/.changeset/purple-boxes-shout.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +--- + +Updates omnichannel routing so agents with `offline` status are always excluded from assignment. The `Livechat_enabled_when_agent_idle` setting now only affects agents with `away` status. diff --git a/.changeset/wet-pandas-pump.md b/.changeset/wet-pandas-pump.md new file mode 100644 index 0000000000000..0fcc3dc5a2c1f --- /dev/null +++ b/.changeset/wet-pandas-pump.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/omni-core': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixed an issue where the Omnichannel routing system ignored the `Livechat_accept_chats_with_no_agents` setting. The agent availability queries have been updated to properly evaluate this setting, ensuring offline agents are correctly included in the routing pool when allowed. diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index ab0faf0b2c3b4..c73bbda1d3580 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -226,6 +226,7 @@ export class AppLivechatBridge extends LivechatBridge { const livechatVisitor = await registerGuest(registerData, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), }); if (!livechatVisitor) { @@ -255,6 +256,7 @@ export class AppLivechatBridge extends LivechatBridge { const livechatVisitor = await registerGuest(registerData, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), }); return this.orch.getConverters()?.get('visitors').convertVisitor(livechatVisitor); diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index e0fdf587e60e3..533409d342ac2 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -73,7 +73,8 @@ const defineVisitor = async (smsNumber: string, targetDepartment?: string) => { data.department = targetDepartment; } - const livechatVisitor = await registerGuest(data, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); + const livechatVisitor = await registerGuest(data, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents')}); if (!livechatVisitor) { throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor'); diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 3e90c1df1efe5..8d5d99352c355 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -272,7 +272,10 @@ API.v1.addRoute( guest.connectionData = normalizeHttpHeaderData(this.request.headers); } - visitor = await registerGuest(guest, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); + visitor = await registerGuest(guest, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }); if (!visitor) { throw new Error('error-livechat-visitor-registration'); } diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index 706260f80afba..ec4c93d4ff5cf 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -59,7 +59,10 @@ API.v1.addRoute( connectionData: normalizeHttpHeaderData(this.request.headers), }; - const visitor = await registerGuest(guest, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); + const visitor = await registerGuest(guest, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }); if (!visitor) { throw new Meteor.Error('error-livechat-visitor-registration', 'Error registering visitor', { method: 'livechat/visitor', diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 7d0f81d02b2e7..dbb44c17f74ce 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -496,7 +496,12 @@ export const forwardRoomToAgent = async (room: IOmnichannelRoom, transferData: T if (!agentId) { throw new Error('error-invalid-agent'); } - const user = await Users.findOneOnlineAgentById(agentId, settings.get('Livechat_enabled_when_agent_idle')); + const user = await Users.findOneOnlineAgentById( + agentId, + settings.get('Livechat_enabled_when_agent_idle'), + {}, + settings.get('Livechat_accept_chats_with_no_agents'), + ); if (!user) { logger.debug({ msg: 'Agent is offline. Cannot forward', @@ -657,7 +662,12 @@ export const forwardRoomToDepartment = async (room: IOmnichannelRoom, guest: ILi departmentId, agentId, }); - const user = await Users.findOneOnlineAgentById(agentId, settings.get('Livechat_enabled_when_agent_idle')); + const user = await Users.findOneOnlineAgentById( + agentId, + settings.get('Livechat_enabled_when_agent_idle'), + {}, + settings.get('Livechat_accept_chats_with_no_agents'), + ); if (!user) { throw new Error('error-user-is-offline'); } diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index c012a8a7d8a0b..858db7279981e 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -443,7 +443,12 @@ export class QueueManager { let defaultAgent: SelectedAgent | undefined; const isAgentAvailable = (username: string) => - Users.findOneOnlineAgentByUserList(username, { projection: { _id: 1 } }, settings.get('Livechat_enabled_when_agent_idle')); + Users.findOneOnlineAgentByUserList( + username, + { projection: { _id: 1 } }, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); if (servedBy?.username && (await isAgentAvailable(servedBy.username))) { defaultAgent = { agentId: servedBy._id, username: servedBy.username }; diff --git a/apps/meteor/app/livechat/server/lib/RoutingManager.ts b/apps/meteor/app/livechat/server/lib/RoutingManager.ts index 8c6fb51c08b2d..581ecfe1f6d94 100644 --- a/apps/meteor/app/livechat/server/lib/RoutingManager.ts +++ b/apps/meteor/app/livechat/server/lib/RoutingManager.ts @@ -111,7 +111,12 @@ export const RoutingManager: Routing = { if ( !agent || (agent.username && - !(await Users.findOneOnlineAgentByUserList(agent.username, {}, settings.get('Livechat_enabled_when_agent_idle'))) && + !(await Users.findOneOnlineAgentByUserList( + agent.username, + {}, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + )) && !(await allowAgentSkipQueue(agent))) ) { logger.debug({ msg: 'Agent offline or invalid. Using routing method to get next agent', inquiryId: inquiry._id }); diff --git a/apps/meteor/app/livechat/server/lib/departmentsLib.ts b/apps/meteor/app/livechat/server/lib/departmentsLib.ts index 3612ba48415a6..3b063672781c9 100644 --- a/apps/meteor/app/livechat/server/lib/departmentsLib.ts +++ b/apps/meteor/app/livechat/server/lib/departmentsLib.ts @@ -295,6 +295,7 @@ export async function checkOnlineForDepartment(departmentId: string) { depUsers.map((agent) => agent.username), { projection: { _id: 1 } }, settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), ); return !!onlineForDep; @@ -304,5 +305,9 @@ export async function getOnlineForDepartment(departmentId: string) { const agents = await LivechatDepartmentAgents.findByDepartmentId(departmentId, { projection: { username: 1 } }).toArray(); const usernames = agents.map(({ username }) => username); - return Users.findOnlineUserFromList([...new Set(usernames)], settings.get('Livechat_enabled_when_agent_idle')); + return Users.findOnlineUserFromList( + [...new Set(usernames)], + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } diff --git a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts index 25feaa8de1e11..1accf6347e3e8 100644 --- a/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts +++ b/apps/meteor/app/livechat/server/lib/routing/AutoSelection.ts @@ -35,10 +35,16 @@ class AutoSelection implements IRoutingMethod { settings.get('Livechat_enabled_when_agent_idle'), ignoreAgentId, extraQuery, + settings.get('Livechat_accept_chats_with_no_agents'), ); } - return Users.getNextAgent(ignoreAgentId, extraQuery, settings.get('Livechat_enabled_when_agent_idle')); + return Users.getNextAgent( + ignoreAgentId, + extraQuery, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } } diff --git a/apps/meteor/app/livechat/server/lib/routing/External.ts b/apps/meteor/app/livechat/server/lib/routing/External.ts index b5aaad05472e1..98222c318c10b 100644 --- a/apps/meteor/app/livechat/server/lib/routing/External.ts +++ b/apps/meteor/app/livechat/server/lib/routing/External.ts @@ -61,6 +61,7 @@ class ExternalQueue implements IRoutingMethod { result.username, {}, settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), ); if (!agent?.username) { diff --git a/apps/meteor/app/livechat/server/lib/service-status.ts b/apps/meteor/app/livechat/server/lib/service-status.ts index 471ab72558dda..94d1e06aeb64b 100644 --- a/apps/meteor/app/livechat/server/lib/service-status.ts +++ b/apps/meteor/app/livechat/server/lib/service-status.ts @@ -8,13 +8,21 @@ import { settings } from '../../../settings/server'; export async function getOnlineAgents(department?: string, agent?: SelectedAgent | null): Promise | undefined> { if (agent?.agentId) { - return Users.findOnlineAgents(agent.agentId, settings.get('Livechat_enabled_when_agent_idle')); + return Users.findOnlineAgents( + agent.agentId, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } if (department) { return getOnlineForDepartment(department); } - return Users.findOnlineAgents(undefined, settings.get('Livechat_enabled_when_agent_idle')); + return Users.findOnlineAgents( + undefined, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } export async function online(department?: string, skipNoAgentSetting = false, skipFallbackCheck = false): Promise { @@ -43,7 +51,11 @@ export async function online(department?: string, skipNoAgentSetting = false, sk export async function checkOnlineAgents(department?: string, agent?: { agentId: string }, skipFallbackCheck = false): Promise { if (agent?.agentId) { - return Users.checkOnlineAgents(agent.agentId, settings.get('Livechat_enabled_when_agent_idle')); + return Users.checkOnlineAgents( + agent.agentId, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } if (department) { @@ -62,7 +74,11 @@ export async function checkOnlineAgents(department?: string, agent?: { agentId: return checkOnlineAgents(dep?.fallbackForwardDepartment); } - return Users.checkOnlineAgents(undefined, settings.get('Livechat_enabled_when_agent_idle')); + return Users.checkOnlineAgents( + undefined, + settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), + ); } async function countBotAgents(department?: string) { diff --git a/apps/meteor/app/livechat/server/lib/takeInquiry.ts b/apps/meteor/app/livechat/server/lib/takeInquiry.ts index 3bea195ceaca2..7d169dac2040b 100644 --- a/apps/meteor/app/livechat/server/lib/takeInquiry.ts +++ b/apps/meteor/app/livechat/server/lib/takeInquiry.ts @@ -26,12 +26,18 @@ export const takeInquiry = async ( }); } - const user = await Users.findOneOnlineAgentById(userId, settings.get('Livechat_enabled_when_agent_idle')); + const user = await Users.findOneOnlineAgentById( + userId, + settings.get('Livechat_enabled_when_agent_idle'), + {}, + settings.get('Livechat_accept_chats_with_no_agents'), + ); if (!user) { throw new Meteor.Error('error-agent-status-service-offline', 'Agent status is offline or Omnichannel service is not active', { method: 'livechat:takeInquiry', ...(process.env.TEST_MODE && { Livechat_enabled_when_agent_idle: settings.get('Livechat_enabled_when_agent_idle'), + Livechat_accept_chats_with_no_agents: settings.get('Livechat_accept_chats_with_no_agents'), user: await Users.findOneById(userId), }), }); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts index f7ed6e38282c7..cae3703a9d967 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/handleNextAgentPreferredEvents.ts @@ -23,9 +23,14 @@ const getDefaultAgent = async ({ username, id }: { username?: string; id?: strin } if (id) { - const agent = await Users.findOneOnlineAgentById(id, settings.get('Livechat_enabled_when_agent_idle'), { - projection: { _id: 1, username: 1 }, - }); + const agent = await Users.findOneOnlineAgentById( + id, + settings.get('Livechat_enabled_when_agent_idle'), + { + projection: { _id: 1, username: 1 }, + }, + settings.get('Livechat_accept_chats_with_no_agents'), + ); if (agent) { return normalizeDefaultAgent(agent); } @@ -43,6 +48,7 @@ const getDefaultAgent = async ({ username, id }: { username?: string; id?: strin username || [], { projection: { _id: 1, username: 1 } }, settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), ), ); }; @@ -131,6 +137,7 @@ checkDefaultAgentOnNewRoom.patch(async (_next, defaultAgent, { visitorId, source usernameByRoom, { projection: { _id: 1, username: 1 } }, settings.get('Livechat_enabled_when_agent_idle'), + settings.get('Livechat_accept_chats_with_no_agents'), ), ); return lastRoomAgent; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts index 53f7093aaf430..3974f96067dab 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadBalancing.ts @@ -32,15 +32,18 @@ class LoadBalancing { async getNextAgent(department?: string, ignoreAgentId?: string) { const enabledWhenIdle = settings.get('Livechat_enabled_when_agent_idle'); + const enabledWhenOffline = settings.get('Livechat_accept_chats_with_no_agents'); + const extraQuery = await getChatLimitsQuery(department); - const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle); - logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle }); + const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle, enabledWhenOffline); + logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle, enabledWhenOffline }); const nextAgent = await Users.getNextLeastBusyAgent( department, ignoreAgentId, enabledWhenIdle, unavailableUsers.map((u) => u.username), + enabledWhenOffline, ); if (!nextAgent) { return; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts index 0f731973c68ed..3e4fab5f94da5 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/routing/LoadRotation.ts @@ -31,16 +31,18 @@ class LoadRotation { public async getNextAgent(department?: string, ignoreAgentId?: string): Promise { const enabledWhenIdle = settings.get('Livechat_enabled_when_agent_idle'); + const enabledWhenOffline = settings.get('Livechat_accept_chats_with_no_agents'); const extraQuery = await getChatLimitsQuery(department); - const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle); - logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle }); + const unavailableUsers = await Users.getUnavailableAgents(department, extraQuery, enabledWhenIdle, enabledWhenOffline); + logger.debug({ msg: 'Ignoring unavailable agents from assignment', unavailableUsers, department, enabledWhenIdle, enabledWhenOffline }); const nextAgent = await Users.getLastAvailableAgentRouted( department, ignoreAgentId, enabledWhenIdle, unavailableUsers.map((user) => user.username), + enabledWhenOffline, ); if (!nextAgent?.username) { return; diff --git a/apps/meteor/ee/server/models/raw/Users.ts b/apps/meteor/ee/server/models/raw/Users.ts index 0912a3c80b03d..3804a1ea3c06f 100644 --- a/apps/meteor/ee/server/models/raw/Users.ts +++ b/apps/meteor/ee/server/models/raw/Users.ts @@ -10,6 +10,7 @@ declare module '@rocket.chat/model-typings' { departmentId: string, customFilter: Filter, enabledWhenIdle?: boolean, + enabledWhenOffline?: boolean, ): Promise[]>; } } @@ -23,6 +24,7 @@ export class UsersEE extends UsersRaw { departmentId: string, customFilter: Filter, enabledWhenIdle = false, + enabledWhenOffline = false, ): Promise[]> { // if department is provided, remove the agents that are not from the selected department const departmentFilter = departmentId @@ -53,7 +55,7 @@ export class UsersEE extends UsersRaw { .aggregate( [ { - $match: queryStatusAgentOnline({}, enabledWhenIdle), + $match: queryStatusAgentOnline({}, enabledWhenIdle, enabledWhenOffline), }, ...departmentFilter, { diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index c6b41fa66356a..57ca854038960 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -48,7 +48,10 @@ async function getGuestByEmail(email: string, name: string, department = ''): Pr email, department, }, - { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }, + { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + shouldConsiderOfflineAgent: settings.get('Livechat_accept_chats_with_no_agents'), + }, ); if (!livechatVisitor) { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts index 1689b26bcd606..97b089f882c46 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-assign-room-tags.spec.ts @@ -53,16 +53,25 @@ test.describe('OC - Tags Visibility', () => { }); }); - test.beforeAll('Create conversations', async ({ api }) => { - conversations = await Promise.all([ - createConversation(api, { visitorName: visitorA.name, agentId: 'user1', departmentId: departmentA.data._id }), - createConversation(api, { visitorName: visitorB.name, agentId: 'user1', departmentId: departmentB.data._id }), - ]); - }); - - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page, api }) => { poOmnichannel = new HomeOmnichannel(page); await page.goto('/'); + await poOmnichannel.waitForHome(); + + if (conversations.length === 0) { + conversations = await Promise.all([ + createConversation(api, { + visitorName: visitorA.name, + agentId: 'user1', + departmentId: departmentA.data._id, + }), + createConversation(api, { + visitorName: visitorB.name, + agentId: 'user1', + departmentId: departmentB.data._id, + }), + ]); + } }); test.afterAll(async ({ api }) => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts index ff9e1ff384e74..2c537eae01aca 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-custom-field-usage.spec.ts @@ -34,7 +34,7 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => test.beforeAll('Set up agent, manager and custom fields', async ({ api }) => { [agent, manager] = await Promise.all([createAgent(api, 'user1'), createManager(api, 'user1')]); - [roomCustomField, visitorCustomField, conversation] = await Promise.all([ + [roomCustomField, visitorCustomField] = await Promise.all([ createCustomField(api, { field: roomCustomFieldLabel, label: roomCustomFieldName, @@ -45,12 +45,19 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => label: visitorCustomFieldName, scope: 'visitor', }), - createConversation(api, { - visitorName: visitor.name, - agentId: 'user1', - visitorToken, - }), ]); + }); + + test.beforeEach(async ({ page, api }) => { + poHomeChannel = new HomeOmnichannel(page); + await page.goto('/'); + await poHomeChannel.waitForHome(); + + conversation = await createConversation(api, { + visitorName: visitor.name, + agentId: 'user1', + visitorToken, + }); await setVisitorCustomFieldValue(api, { token: visitorToken, @@ -59,12 +66,6 @@ test.describe.serial('OC - Custom fields usage, scope : room and visitor', () => }); }); - test.beforeEach(async ({ page }) => { - poHomeChannel = new HomeOmnichannel(page); - await page.goto('/'); - await poHomeChannel.waitForHome(); - }); - test.afterAll('Remove agent, manager, custom fields and conversation', async () => { await Promise.all([agent.delete(), manager.delete(), roomCustomField.delete(), visitorCustomField.delete(), conversation.delete()]); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts new file mode 100644 index 0000000000000..3f83ac989480e --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Routing to Idle Agents', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let poHomeOmnichannel: HomeOmnichannel; + let poLivechat: OmnichannelLiveChat; + + let livechatPage: Page; + + let agent: Awaited>; + let testDepartment: Awaited>; + let visitor: { name: string; email: string }; + + const routingMethods = ['Auto_Selection', 'Load_Balancing', 'Load_Rotation']; + + test.beforeAll(async ({ api }) => { + [agent, testDepartment] = await Promise.all([ + createAgent(api, 'user1'), + createDepartment(api, { name: 'Idle Routing Dept' }), + setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).toBeOK(), + ]); + + await Promise.all([addAgentToDepartment(api, { department: testDepartment.data, agentId: 'user1' })]); + }); + + test.beforeEach(async ({ page, browser, api }) => { + visitor = createFakeVisitor(); + poHomeOmnichannel = new HomeOmnichannel(page); + await page.goto('/'); + await page.locator('#main-content').waitFor(); + + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + poLivechat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.afterEach(async ({ api }) => { + if (livechatPage) { + await livechatPage.context().close(); + } + + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + testDepartment.delete(), + agent.delete(), + setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), + setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).toBeOK(), + ]); + }); + + routingMethods.forEach((routingMethod) => { + test.describe(`Routing method: ${routingMethod}`, () => { + test(`should not route to idle agents`, async ({ api }) => { + await test.step(`Setup routing method to ${routingMethod} and ignore idle agents`, async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false); + }); + + await test.step('Visitor tries to initiate a conversation with the away agent', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('Hello from visitor'); + + // Force Agent to become away by idle timeout + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + await poHomeOmnichannel.page.reload(); + await expect(poHomeOmnichannel.navbar.getUserStatusBadge('away')).toBeVisible(); + + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Verify visitor is not taken by agent', async () => { + await expect( + poLivechat.alertMessage('Error starting a new conversation: Sorry, no online agents [no-agent-online]'), + ).toBeVisible(); + }); + }); + + test(`should route to agents even if they are idle when setting is enabled`, async ({ api }) => { + await test.step(`Setup routing method to ${routingMethod} and allow idle agents`, async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', routingMethod); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', true); + }); + + await test.step('Force agent to become away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + await poHomeOmnichannel.page.reload(); + await expect(poHomeOmnichannel.navbar.getUserStatusBadge('away')).toBeVisible(); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test message'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Verify chat is served to an agent', async () => { + await expect(poLivechat.headerTitle).toHaveText(agent.data.username); + }); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts index 2822a9c691321..336a0cbc355aa 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat.spec.ts @@ -265,8 +265,10 @@ test.describe('OC - Livechat - Close chat using widget', () => { let poLiveChat: OmnichannelLiveChat; let agent: Awaited>; - test.beforeAll(async ({ api }) => { + test.beforeAll(async ({ api, browser }) => { agent = await createAgent(api, 'user1'); + + await createAuxContext(browser, Users.user1, '/', true); }); test.beforeEach(async ({ page, api }) => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts new file mode 100644 index 0000000000000..f7540a9cc09b9 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-rooms-forward.spec.ts @@ -0,0 +1,271 @@ +import type { Page } from '@playwright/test'; +import type { IRoom } from '@rocket.chat/core-typings'; + +import { createFakeVisitor } from '../../mocks/data'; +import { IS_EE } from '../config/constants'; +import { createAuxContext } from '../fixtures/createAuxContext'; +import { Users } from '../fixtures/userStates'; +import { HomeOmnichannel } from '../page-objects'; +import { OmnichannelLiveChat } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; +import { createAgent } from '../utils/omnichannel/agents'; +import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; +import { createManager } from '../utils/omnichannel/managers'; +import { test, expect } from '../utils/test'; + +test.use({ storageState: Users.user1.state }); + +test.describe('OC - Forwarding to away agents (EE)', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let poHomeOmnichannelOnlineAgent: HomeOmnichannel; + let poHomeOmnichannelAwayAgent: HomeOmnichannel; + let poLivechat: OmnichannelLiveChat; + + let livechatPage: Page; + let omnichannelPage: Page; + + let manager: Awaited>; + let onlineAgent: Awaited>; + let awayAgent: Awaited>; + let initialDepartment: Awaited>; + let forwardToOfflineDepartment: Awaited>; + let visitor: { name: string; email: string }; + + test.beforeAll(async ({ api }) => { + [manager, onlineAgent, awayAgent, initialDepartment, forwardToOfflineDepartment] = await Promise.all([ + createManager(api, 'user1'), + createAgent(api, 'user1'), + createAgent(api, 'user2'), + createDepartment(api, { name: 'Initial Dept' }), + createDepartment(api, { name: 'Forward Dept', allowReceiveForwardOffline: true }), + setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).toBeOK(), + ]); + + await Promise.all([ + addAgentToDepartment(api, { department: initialDepartment.data, agentId: 'user1' }), + addAgentToDepartment(api, { department: forwardToOfflineDepartment.data, agentId: 'user2' }), + ]); + }); + + test.beforeEach(async ({ page, browser, api }) => { + visitor = createFakeVisitor(); + poHomeOmnichannelOnlineAgent = new HomeOmnichannel(page); + await page.goto('/'); + await page.locator('#main-content').waitFor(); + + ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); + poLivechat = new OmnichannelLiveChat(livechatPage, api); + }); + + test.afterEach(async ({ api }) => { + if (livechatPage) { + await livechatPage.context().close(); + } + if (omnichannelPage) { + await omnichannelPage.context().close(); + } + + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 300); + }); + + test.afterAll(async ({ api }) => { + await Promise.all([ + initialDepartment.delete(), + forwardToOfflineDepartment.delete(), + manager.delete(), + onlineAgent.delete(), + awayAgent.delete(), + setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'), + setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false), + expect(await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).toBeOK(), + ]); + }); + + test('when manager forward to offline (agent away, accept when agent idle off) department the inquiry should be set to the queue', async ({ + api, + browser, + }) => { + await test.step('Setup routing settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Manual_Selection'); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', false); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 agent away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); + }); + + await test.step('Manager forwards chat', async () => { + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check inquiry status via API is queued', async () => { + const roomInfoResp = await api.get(`/livechat/rooms`); + const roomBody = await roomInfoResp.json(); + const roomId = roomBody.rooms.find((room: IRoom) => room.fname === visitor.name)._id; + + const inquiryResp = await api.get(`/livechat/inquiries.getOne?roomId=${roomId}`); + const inquiryBody = await inquiryResp.json(); + + expect(inquiryBody.inquiry.status).toBe('queued'); + expect(inquiryBody.inquiry.department).toBe(forwardToOfflineDepartment.data._id); + }); + }); + + test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is true, chat should end in departments queue', async ({ + api, + browser, + }) => { + await test.step('Setup routing settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 agent away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); + }); + + await test.step('Manager enables queue and forwards chat', async () => { + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await setSettingValueById(api, 'Livechat_waiting_queue', true); + + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check inquiry status via API is queued', async () => { + const roomInfoResp = await api.get(`/livechat/rooms`); + const roomBody = await roomInfoResp.json(); + const roomId = roomBody.rooms.find((room: IRoom) => room.fname === visitor.name)._id; + + const inquiryResp = await api.get(`/livechat/inquiries.getOne?roomId=${roomId}`); + const inquiryBody = await inquiryResp.json(); + + expect(inquiryBody.inquiry.status).toBe('queued'); + expect(inquiryBody.inquiry.department).toBe(forwardToOfflineDepartment.data._id); + }); + + await test.step('Disable waiting queue', async () => { + await setSettingValueById(api, 'Livechat_waiting_queue', false); + }); + }); + + test('when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is false, transfer should fail', async ({ + api, + browser, + }) => { + await test.step('Setup routing and department settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'); + await api.put(`/livechat/department/${forwardToOfflineDepartment.data._id}`, { + department: { ...forwardToOfflineDepartment.data, allowReceiveForwardOffline: false }, + }); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 agent away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); + }); + + await test.step('Manager attempts to forward and sees error', async () => { + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await setSettingValueById(api, 'Livechat_waiting_queue', true); + + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.page.locator('role=alert')).toContainText( + 'No agents are available for service on this department.', + ); + }); + + await test.step('Restore department setting', async () => { + await api.put(`/livechat/department/${forwardToOfflineDepartment.data._id}`, { + department: { ...forwardToOfflineDepartment.data, allowReceiveForwardOffline: true }, + }); + }); + + await test.step('Disable waiting queue', async () => { + await setSettingValueById(api, 'Livechat_waiting_queue', false); + }); + }); + + test('when manager forward to online (agent away, accept when agent idle on) department the inquiry should not be set to the queue', async ({ + api, + browser, + }) => { + await test.step('Setup routing settings', async () => { + await setSettingValueById(api, 'Livechat_Routing_Method', 'Auto_Selection'); + await setSettingValueById(api, 'Livechat_enabled_when_agent_idle', true); + }); + + await test.step('Visitor initiates chat', async () => { + await poLivechat.page.reload(); + await poLivechat.openAnyLiveChat(); + await poLivechat.sendMessage(visitor, false); + await poLivechat.onlineAgentMessage.fill('test'); + await poLivechat.btnSendMessageToOnlineAgent.click(); + }); + + await test.step('Set user2 agent away by idle timeout', async () => { + await setSettingValueById(api, 'Accounts_Default_User_Preferences_idleTimeLimit', 1); + ({ page: omnichannelPage } = await createAuxContext(browser, Users.user2, '/', false)); + poHomeOmnichannelAwayAgent = new HomeOmnichannel(omnichannelPage); + await expect(poHomeOmnichannelAwayAgent.navbar.getUserStatusBadge('away')).toBeVisible(); + }); + + await test.step('Manager forwards chat successfully', async () => { + await poHomeOmnichannelOnlineAgent.sidebar.getSidebarItemByName(visitor.name).click(); + await poHomeOmnichannelOnlineAgent.quickActionsRoomToolbar.forwardChat(); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.selectDepartment('Forward Dept'); + await poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward.click(); + await expect(poHomeOmnichannelOnlineAgent.content.forwardChatModal.btnForward).not.toBeVisible(); + }); + + await test.step('Check room routing via API serves to away agent', async () => { + const roomInfoResp = await api.get(`/livechat/rooms`); + const roomBody = await roomInfoResp.json(); + const room = roomBody.rooms.find((room: IRoom) => room.fname === visitor.name); + + expect(room.servedBy._id).toBe(awayAgent.data._id); + expect(room.departmentId).toBe(forwardToOfflineDepartment.data._id); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts index 7a3ad3a8c0e7c..5a6a653645298 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/navbar.ts @@ -268,4 +268,8 @@ export class Navbar { const newStatus = await this.btnSwitchOmnichannelStatus.getAttribute('title'); expect(newStatus).toBe(status === 'offline' ? StatusTitleMap.offline : StatusTitleMap.online); } + + getUserStatusBadge(status: 'online' | 'away' | 'busy' | 'offline'): Locator { + return this.btnUserMenu.locator(`svg[class*="${status}"]`); + } } diff --git a/apps/meteor/tests/e2e/utils/omnichannel/departments.ts b/apps/meteor/tests/e2e/utils/omnichannel/departments.ts index fb7d8cd14ec32..e268ff8a52ae9 100644 --- a/apps/meteor/tests/e2e/utils/omnichannel/departments.ts +++ b/apps/meteor/tests/e2e/utils/omnichannel/departments.ts @@ -18,6 +18,7 @@ type CreateDepartmentParams = { departmentsAllowedToForward?: string[]; fallbackForwardDepartment?: string; maxNumberSimultaneousChat?: number; + allowReceiveForwardOffline?: boolean; }; export const createDepartment = async ( @@ -37,6 +38,7 @@ export const createDepartment = async ( departmentsAllowedToForward = [], fallbackForwardDepartment = '', maxNumberSimultaneousChat, + allowReceiveForwardOffline, }: CreateDepartmentParams = {}, ) => { const response = await api.post('/livechat/department', { @@ -55,6 +57,7 @@ export const createDepartment = async ( departmentsAllowedToForward, fallbackForwardDepartment, maxNumberSimultaneousChat, + allowReceiveForwardOffline, }, }); diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index d606feac77a87..848e47c5546f7 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -25,7 +25,6 @@ import { createCustomField, deleteCustomField } from '../../../data/livechat/cus import type { OnlineAgent } from '../../../data/livechat/department'; import { createDepartmentWith2OnlineAgents, - createDepartmentWithAnAwayAgent, createDepartmentWithAnOfflineAgent, createDepartmentWithAnOnlineAgent, deleteDepartment, @@ -1541,57 +1540,6 @@ describe('LIVECHAT - rooms', () => { }, ); - (IS_EE ? it : it.skip)( - 'when manager forward to offline (agent away, accept when agent idle off) department the inquiry should be set to the queue', - async () => { - await updateSetting('Livechat_Routing_Method', 'Manual_Selection'); - await updateSetting('Livechat_enabled_when_agent_idle', false); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: true, - }); - - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - await request - .get(api(`livechat/queue`)) - .set(credentials) - .query({ - count: 1, - }) - .expect('Content-Type', 'application/json') - .expect(200) - .expect((res: Response) => { - expect(res.body).to.have.property('success', true); - expect(res.body.queue).to.be.an('array'); - expect(res.body.queue[0].chats).not.to.undefined; - expect(res.body).to.have.property('offset'); - expect(res.body).to.have.property('total'); - expect(res.body).to.have.property('count'); - }); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - ]); - }, - ); - (IS_EE ? it : it.skip)( 'when manager forwards a chat that hasnt been assigned to a user to another department with no online agents, chat should end ready in department (not queued)', async () => { @@ -1670,82 +1618,6 @@ describe('LIVECHAT - rooms', () => { }, ); - (IS_EE ? it : it.skip)( - 'when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is true, chat should end in departments queue', - async () => { - await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: true }); - - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - // Waiting queue enabled after assignement but before transfer, otherwise, chat will fall on previous test case - await updateSetting('Livechat_waiting_queue', true); - await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - const inquiry = await fetchInquiry(newRoom._id); - - expect(inquiry.status).to.equal('queued'); - expect(inquiry.department).to.equal(forwardToOfflineDepartment._id); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - updateSetting('Livechat_waiting_queue', false), - ]); - }, - ); - - (IS_EE ? it : it.skip)( - 'when manager forward to a department while waiting_queue is active and allowReceiveForwardOffline is false, transfer should fail', - async () => { - await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment } = await createDepartmentWithAnAwayAgent({ allowReceiveForwardOffline: false }); - - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - // Waiting queue enabled after assignement but before transfer, otherwise, chat will fall on previous test case - await updateSetting('Livechat_waiting_queue', true); - const res = await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - expect(res.status).to.equal(400); - expect(res.body).to.have.property('error', 'error-no-agents-available-for-service-on-department'); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - updateSetting('Livechat_waiting_queue', false), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - ]); - }, - ); - (IS_EE ? it : it.skip)( 'when manager forward to a department while waiting_queue is disabled and allowReceiveForwardOffline is false, but department is online, transfer should succeed', async () => { @@ -1785,46 +1657,6 @@ describe('LIVECHAT - rooms', () => { }, ); - (IS_EE ? it : it.skip)( - 'when manager forward to online (agent away, accept when agent idle on) department the inquiry should not be set to the queue', - async () => { - await updateSetting('Livechat_Routing_Method', 'Auto_Selection'); - await updateSetting('Livechat_enabled_when_agent_idle', true); - const { department: initialDepartment } = await createDepartmentWithAnOnlineAgent(); - const { department: forwardToOfflineDepartment, agent } = await createDepartmentWithAnAwayAgent({ - allowReceiveForwardOffline: true, - }); - - const newVisitor = await createVisitor(initialDepartment._id); - const newRoom = await createLivechatRoom(newVisitor.token); - - const manager = await createUser(); - const managerCredentials = await login(manager.username, password); - await createManager(manager.username); - - await request.post(api('livechat/room.forward')).set(managerCredentials).send({ - roomId: newRoom._id, - departmentId: forwardToOfflineDepartment._id, - clientAction: true, - comment: 'test comment', - }); - - const roomInfo = await getLivechatRoomInfo(newRoom._id); - - expect(roomInfo.servedBy).to.have.property('_id', agent.user._id); - expect(roomInfo.departmentId).to.be.equal(forwardToOfflineDepartment._id); - - await Promise.all([ - deleteDepartment(initialDepartment._id), - deleteDepartment(forwardToOfflineDepartment._id), - closeOmnichannelRoom(newRoom._id), - deleteVisitor(newVisitor.token), - deleteUser(manager), - updateSetting('Livechat_enabled_when_agent_idle', false), - ]); - }, - ); - (IS_EE ? it : it.skip)( 'when manager forward to a department while waiting_queue is enabled, but department is online, transfer should succeed but it should end queued on target', async () => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts index 3bc9c8d9f218a..bb2e386a03f46 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/24-routing.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import type { Credentials } from '@rocket.chat/api-client'; import { UserStatus } from '@rocket.chat/core-typings'; -import type { ILivechatDepartment, IUser } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, IUser, ILivechatAgent } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { after, before, describe, it } from 'mocha'; @@ -16,9 +16,10 @@ import { makeAgentUnavailable, switchLivechatStatus, } from '../../../data/livechat/rooms'; -import { updateSetting } from '../../../data/permissions.helper'; +import { getAgent } from '../../../data/livechat/users'; +import { getSettingValueById, updateSetting } from '../../../data/permissions.helper'; import { password } from '../../../data/user'; -import { createUser, deleteUser, login, setUserActiveStatus, setUserAway, setUserStatus } from '../../../data/users.helper'; +import { createUser, deleteUser, login, setUserActiveStatus, setUserStatus } from '../../../data/users.helper'; import { IS_EE } from '../../../e2e/config/constants'; (IS_EE ? describe : describe.skip)('Omnichannel - Routing', () => { @@ -347,39 +348,6 @@ import { IS_EE } from '../../../e2e/config/constants'; const roomInfo = await getLivechatRoomInfo(room._id); expect(roomInfo.servedBy).to.be.undefined; }); - it('should ignore offline users when Livechat_enabled_when_agent_idle is false', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', false); - await setUserStatus(testUser.credentials, UserStatus.OFFLINE); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); - it('should not route to an idle user', async () => { - await setUserStatus(testUser.credentials, UserStatus.AWAY); - await setUserAway(testUser.credentials); - await setUserStatus(testUser3.credentials, UserStatus.AWAY); - await setUserAway(testUser3.credentials); - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); - it('should route to an idle user', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', true); - - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); - - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.an('object'); - }); it('should route to another available agent if contact manager is unavailable and Omnichannel_contact_manager_routing is enabled', async () => { await makeAgentAvailable(testUser.credentials); const visitor = await createVisitor(testDepartment._id, faker.person.fullName(), visitorEmail); @@ -428,6 +396,52 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.an('object').that.has.property('_id').that.is.not.equal(testUser3.user._id); }); }); + describe('Auto_Selection - Livechat_accept_chats_with_no_agents', async () => { + let initialSettingValue: any; + let testUserInitialState: ILivechatAgent; + let testUser3InitialState: ILivechatAgent; + + before(async () => { + testUserInitialState = await getAgent(testUser.user._id); + testUser3InitialState = await getAgent(testUser3.user._id); + initialSettingValue = await getSettingValueById('Livechat_accept_chats_with_no_agents'); + }); + + after(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', initialSettingValue); + await switchLivechatStatus(testUserInitialState.statusLivechat, testUser.credentials); + await switchLivechatStatus(testUser3InitialState.statusLivechat, testUser3.credentials); + await setUserStatus(testUser3.credentials, testUser3InitialState.status); + }); + describe('Livechat_accept_chats_with_no_agents is false', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', false); + await switchLivechatStatus('not-available', testUser.credentials); + await switchLivechatStatus('available', testUser3.credentials); + await setUserStatus(testUser3.credentials, UserStatus.OFFLINE); + }); + + it('should fail to start a conversation if there is an available but offline agent and Livechat_accept_chats_with_no_agents is false', async () => { + const visitor = await createVisitor(testDepartment._id); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + }); + }); + describe('Livechat_accept_chats_with_no_agents is true', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await switchLivechatStatus('available', testUser3.credentials); + await setUserStatus(testUser3.credentials, UserStatus.OFFLINE); + }); + it('should accept a conversation and route to an offline agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + const roomInfo = await getLivechatRoomInfo(room._id); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser3.user._id); + }); + }); + }); }); describe('Load Balancing', () => { before(async () => { @@ -509,32 +523,52 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.an('object'); expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); }); - it('should not route to an idle user', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', false); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - await setUserAway(testUser.credentials); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - await setUserAway(testUser2.credentials); - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); + describe('Load_Balancing - Livechat_accept_chats_with_no_agents', async () => { + let initialSettingValue: any; + let testUserInitialState: ILivechatAgent; + let testUser2InitialState: ILivechatAgent; - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); + before(async () => { + testUserInitialState = await getAgent(testUser.user._id); + testUser2InitialState = await getAgent(testUser2.user._id); + initialSettingValue = await getSettingValueById('Livechat_accept_chats_with_no_agents'); + }); - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); - it('should route to agents even if theyre idle when setting is enabled', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', true); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); + after(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', initialSettingValue); + await switchLivechatStatus(testUserInitialState.statusLivechat, testUser.credentials); + await switchLivechatStatus(testUser2InitialState.statusLivechat, testUser2.credentials); + await setUserStatus(testUser2.credentials, testUser2InitialState.status); + }); + describe('Livechat_accept_chats_with_no_agents is false', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', false); + await switchLivechatStatus('not-available', testUser.credentials); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); + it('should fail to start a conversation if there is an available but offline agent and Livechat_accept_chats_with_no_agents is false', async () => { + const visitor = await createVisitor(testDepartment._id); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + }); + }); + describe('Livechat_accept_chats_with_no_agents is true', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); + it('should accept a conversation and route to an offline agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + const roomInfo = await getLivechatRoomInfo(room._id); - const roomInfo = await getLivechatRoomInfo(room._id); - // Not checking who, just checking it's served - expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); + }); + }); }); }); describe('Load Rotation', () => { @@ -617,32 +651,52 @@ import { IS_EE } from '../../../e2e/config/constants'; expect(roomInfo.servedBy).to.be.an('object'); expect(roomInfo.servedBy?._id).to.be.equal(testUser.user._id); }); - it('should not route to an idle user', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', false); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - await setUserAway(testUser.credentials); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); - await setUserAway(testUser2.credentials); - // Agent is available but should be ignored - await switchLivechatStatus('available', testUser.credentials); + describe('Load_Rotation - Livechat_accept_chats_with_no_agents', async () => { + let initialSettingValue: any; + let testUserInitialState: ILivechatAgent; + let testUser2InitialState: ILivechatAgent; - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); + before(async () => { + testUserInitialState = await getAgent(testUser.user._id); + testUser2InitialState = await getAgent(testUser2.user._id); + initialSettingValue = await getSettingValueById('Livechat_accept_chats_with_no_agents'); + }); - const roomInfo = await getLivechatRoomInfo(room._id); - expect(roomInfo.servedBy).to.be.undefined; - }); - it('should route to agents even if theyre idle when setting is enabled', async () => { - await updateSetting('Livechat_enabled_when_agent_idle', true); - await setUserStatus(testUser.credentials, UserStatus.AWAY); - await setUserStatus(testUser2.credentials, UserStatus.AWAY); + after(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', initialSettingValue); + await switchLivechatStatus(testUserInitialState.statusLivechat, testUser.credentials); + await switchLivechatStatus(testUser2InitialState.statusLivechat, testUser2.credentials); + await setUserStatus(testUser2.credentials, testUser2InitialState.status); + }); + describe('Livechat_accept_chats_with_no_agents is false', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', false); + await switchLivechatStatus('not-available', testUser.credentials); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); - const visitor = await createVisitor(testDepartment._id); - const room = await createLivechatRoom(visitor.token); + it('should fail to start a conversation if there is an available but offline agent and Livechat_accept_chats_with_no_agents is false', async () => { + const visitor = await createVisitor(testDepartment._id); + const { body } = await request.get(api('livechat/room')).query({ token: visitor.token }).expect(400); + expect(body.error).to.be.equal('Sorry, no online agents [no-agent-online]'); + }); + }); + describe('Livechat_accept_chats_with_no_agents is true', () => { + before(async () => { + await updateSetting('Livechat_accept_chats_with_no_agents', true); + await switchLivechatStatus('available', testUser2.credentials); + await setUserStatus(testUser2.credentials, UserStatus.OFFLINE); + }); + it('should accept a conversation and route to an offline agent', async () => { + const visitor = await createVisitor(testDepartment._id); + const room = await createLivechatRoom(visitor.token); + const roomInfo = await getLivechatRoomInfo(room._id); - const roomInfo = await getLivechatRoomInfo(room._id); - // Not checking who, just checking it's served - expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy).to.be.an('object'); + expect(roomInfo.servedBy?._id).to.be.equal(testUser2.user._id); + }); + }); }); }); }); diff --git a/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts b/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts index 651a14847b39a..69c1d7ce63f4f 100644 --- a/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts +++ b/packages/model-typings/src/models/ILivechatDepartmentAgentsModel.ts @@ -58,6 +58,7 @@ export interface ILivechatDepartmentAgentsModel extends IBaseModel, + acceptChatsWithNoAgents?: boolean, ): Promise | null | undefined>; getBotsForDepartment(departmentId: string): Promise>; countBotsForDepartment(departmentId: string): Promise; diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index 383db7efaf337..c16bb7285d62c 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -110,12 +110,14 @@ export interface IUsersModel extends IBaseModel { ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, ignoreUsernames?: string[], + acceptChatsWithNoAgents?: boolean, ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date; count: number }>; getLastAvailableAgentRouted( department?: string, ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, ignoreUsernames?: string[], + acceptChatsWithNoAgents?: boolean, ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date }>; setLastRoutingTime(userId: IUser['_id']): Promise | null>; @@ -250,17 +252,20 @@ export interface IUsersModel extends IBaseModel { findOnlineUserFromList( userList: string | string[], isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, ): FindCursor; countOnlineUserFromList(userList: string | string[], isLivechatEnabledWhenAgentIdle?: boolean): Promise; getUnavailableAgents( departmentId?: string, extraQuery?: Filter, isLivechatEnabledWhenIdle?: boolean, + acceptChatsWithNoAgents?: boolean, ): Promise[]>; findOneOnlineAgentByUserList( userList: string[] | string, options?: FindOptions, isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, ): Promise; findBotAgents(usernameList?: string | string[]): FindCursor; @@ -281,14 +286,19 @@ export interface IUsersModel extends IBaseModel { loginTokenObject: AtLeast; }): Promise; findPersonalAccessTokenByTokenNameAndUserId({ userId, tokenName }: { userId: IUser['_id']; tokenName: string }): Promise; - checkOnlineAgents(agentId?: string, isLivechatEnabledWhenIdle?: boolean): Promise; - findOnlineAgents(agentId?: IUser['_id'], isLivechatEnabledWhenIdle?: boolean): FindCursor; + checkOnlineAgents(agentId?: string, isLivechatEnabledWhenIdle?: boolean, acceptChatsWithNoAgents?: boolean): Promise; + findOnlineAgents( + agentId?: IUser['_id'], + isLivechatEnabledWhenIdle?: boolean, + acceptChatsWithNoAgents?: boolean, + ): FindCursor; countOnlineAgents(agentId: string): Promise; findOneBotAgent(): Promise; findOneOnlineAgentById( agentId: string, isLivechatEnabledWhenAgentIdle?: boolean, options?: FindOptions, + acceptChatsWithNoAgents?: boolean, ): Promise; findAgents(): FindCursor; countAgents(): Promise; @@ -296,6 +306,7 @@ export interface IUsersModel extends IBaseModel { ignoreAgentId?: string, extraQuery?: Filter, enabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, ): Promise<{ agentId: string; username?: string } | null>; getNextBotAgent(ignoreAgentId?: string): Promise<{ agentId: string; username?: string } | null>; setLivechatStatus(userId: string, status: ILivechatAgentStatus): Promise; diff --git a/packages/models/src/helpers/omnichannel/agentStatus.ts b/packages/models/src/helpers/omnichannel/agentStatus.ts index 020751a29d35a..4c50e68721b20 100644 --- a/packages/models/src/helpers/omnichannel/agentStatus.ts +++ b/packages/models/src/helpers/omnichannel/agentStatus.ts @@ -2,24 +2,32 @@ import { UserStatus } from '@rocket.chat/core-typings'; import type { IUser } from '@rocket.chat/core-typings'; import type { Filter } from 'mongodb'; -export const queryStatusAgentOnline = (extraFilters = {}, isLivechatEnabledWhenAgentIdle?: boolean): Filter => ({ +/** + * Builds the query to find online and available agents for livechat auto-assignment. + * * According to product rules, the primary conditions for auto-assignment are: + * - User must have the 'livechat-agent' role. + * - Livechat service status (`statusLivechat`) must be 'available'. + * - User's global status must NOT be 'offline' Exceptions: Bots are exempt from this rule, and this check is skipped entirely if the `acceptChatsWithNoAgents` setting is enabled (allowing offline human agents to be assigned). + * - If the "Accept new omnichannel requests when the agent is idle" (aka `Livechat_enabled_when_agent_idle`) setting is OFF, + * then the statusConnection must NOT be 'away'. + */ +export const queryStatusAgentOnline = ( + extraFilters = {}, + isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, +): Filter => ({ statusLivechat: 'available', roles: 'livechat-agent', // ignore deactivated users active: true, - ...(!isLivechatEnabledWhenAgentIdle && { + ...(!acceptChatsWithNoAgents && { $or: [ + { roles: 'bot' }, { status: { $exists: true, $ne: UserStatus.OFFLINE, }, - roles: { - $ne: 'bot', - }, - }, - { - roles: 'bot', }, ], }), @@ -29,8 +37,12 @@ export const queryStatusAgentOnline = (extraFilters = {}, isLivechatEnabledWhenA }), }); -export const queryAvailableAgentsForSelection = (extraFilters = {}, isLivechatEnabledWhenAgentIdle?: boolean): Filter => ({ - ...queryStatusAgentOnline(extraFilters, isLivechatEnabledWhenAgentIdle), +export const queryAvailableAgentsForSelection = ( + extraFilters = {}, + isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, +): Filter => ({ + ...queryStatusAgentOnline(extraFilters, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents), $and: [ { $or: [{ agentLocked: { $exists: false } }, { agentLockedAt: { $lt: new Date(Date.now() - 5000) } }], diff --git a/packages/models/src/models/LivechatDepartmentAgents.ts b/packages/models/src/models/LivechatDepartmentAgents.ts index c9c29a74568d1..a0e15e6f1df3f 100644 --- a/packages/models/src/models/LivechatDepartmentAgents.ts +++ b/packages/models/src/models/LivechatDepartmentAgents.ts @@ -178,6 +178,7 @@ export class LivechatDepartmentAgentsRaw extends BaseRaw, + acceptChatsWithNoAgents?: boolean, ): Promise | null | undefined> { const agents = await this.findByDepartmentId(departmentId).toArray(); @@ -188,14 +189,15 @@ export class LivechatDepartmentAgentsRaw extends BaseRaw agent.username), isLivechatEnabledWhenAgentIdle, + acceptChatsWithNoAgents, ).toArray(); const onlineUsernames = onlineUsers.map((user) => user.username).filter(isStringValue); // get fully booked agents, to ignore them from the query - const currentUnavailableAgents = (await Users.getUnavailableAgents(departmentId, extraQuery, isLivechatEnabledWhenAgentIdle)).map( - (u) => u.username, - ); + const currentUnavailableAgents = ( + await Users.getUnavailableAgents(departmentId, extraQuery, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents) + ).map((u) => u.username); const query: Filter = { departmentId, diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index 95cc31246380a..1316ff9f440c2 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -556,10 +556,12 @@ export class UsersRaw extends BaseRaw> implements IU ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, ignoreUsernames?: string[], + acceptChatsWithNoAgents?: boolean, ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date; count: number; departments?: any[] }> { const match = queryAvailableAgentsForSelection( { ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }), ...(ignoreUsernames?.length && { username: { $nin: ignoreUsernames } }) }, isEnabledWhenAgentIdle, + acceptChatsWithNoAgents, ); const departmentFilter = department @@ -637,10 +639,12 @@ export class UsersRaw extends BaseRaw> implements IU ignoreAgentId?: string, isEnabledWhenAgentIdle?: boolean, ignoreUsernames?: string[], + acceptChatsWithNoAgents?: boolean, ): Promise<{ agentId: string; username?: string; lastRoutingTime?: Date; departments?: any[] }> { const match = queryAvailableAgentsForSelection( { ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }), ...(ignoreUsernames?.length && { username: { $nin: ignoreUsernames } }) }, isEnabledWhenAgentIdle, + acceptChatsWithNoAgents, ); const departmentFilter = department ? [ @@ -1616,13 +1620,17 @@ export class UsersRaw extends BaseRaw> implements IU }); } - findOnlineUserFromList(userList: string | string[], isLivechatEnabledWhenAgentIdle?: boolean) { + findOnlineUserFromList( + userList: string | string[], + isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, + ) { // TODO: Create class Agent const username = { $in: ([] as string[]).concat(userList), }; - const query = queryStatusAgentOnline({ username }, isLivechatEnabledWhenAgentIdle); + const query = queryStatusAgentOnline({ username }, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents); return this.find(query); } @@ -1638,13 +1646,18 @@ export class UsersRaw extends BaseRaw> implements IU return this.countDocuments(query); } - findOneOnlineAgentByUserList(userList: string | string[], options?: FindOptions, isLivechatEnabledWhenAgentIdle?: boolean) { + findOneOnlineAgentByUserList( + userList: string | string[], + options?: FindOptions, + isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, + ) { // TODO:: Create class Agent const username = { $in: ([] as string[]).concat(userList), }; - const query = queryStatusAgentOnline({ username }, isLivechatEnabledWhenAgentIdle); + const query = queryStatusAgentOnline({ username }, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents); return this.findOne(query, options); } @@ -1653,6 +1666,7 @@ export class UsersRaw extends BaseRaw> implements IU _departmentId?: string, _extraQuery?: Filter, _isLivechatEnabledWhenAgentIdle?: boolean, + _acceptChatsWithNoAgent?: boolean, ): Promise[]> { return []; } @@ -1861,16 +1875,20 @@ export class UsersRaw extends BaseRaw> implements IU return this.findOne(query); } - async checkOnlineAgents(agentId: IUser['_id'], isLivechatEnabledWhenAgentIdle?: boolean) { + async checkOnlineAgents(agentId: IUser['_id'], isLivechatEnabledWhenAgentIdle?: boolean, acceptChatsWithNoAgents?: boolean) { // TODO:: Create class Agent - const query = queryStatusAgentOnline(agentId && { _id: agentId }, isLivechatEnabledWhenAgentIdle); + const query = queryStatusAgentOnline(agentId && { _id: agentId }, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents); return !!(await this.findOne(query)); } - findOnlineAgents(agentId?: IUser['_id'], isLivechatEnabledWhenAgentIdle?: boolean) { + findOnlineAgents( + agentId?: IUser['_id'], + isLivechatEnabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, + ) { // TODO:: Create class Agent - const query = queryStatusAgentOnline(agentId && { _id: agentId }, isLivechatEnabledWhenAgentIdle); + const query = queryStatusAgentOnline(agentId && { _id: agentId }, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents); return this.find(query); } @@ -1897,9 +1915,10 @@ export class UsersRaw extends BaseRaw> implements IU _id: IUser['_id'], isLivechatEnabledWhenAgentIdle?: boolean, options?: FindOptions, + acceptChatsWithNoAgents?: boolean, ) { // TODO: Create class Agent - const query = queryStatusAgentOnline({ _id }, isLivechatEnabledWhenAgentIdle); + const query = queryStatusAgentOnline({ _id }, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents); return this.findOne(query, options); } @@ -1923,17 +1942,24 @@ export class UsersRaw extends BaseRaw> implements IU } // 2 - async getNextAgent(ignoreAgentId?: string, extraQuery?: Filter, enabledWhenAgentIdle?: boolean) { + async getNextAgent( + ignoreAgentId?: string, + extraQuery?: Filter, + enabledWhenAgentIdle?: boolean, + acceptChatsWithNoAgents?: boolean, + ) { // TODO: Create class Agent // fetch all unavailable agents, and exclude them from the selection - const unavailableAgents = (await this.getUnavailableAgents(undefined, extraQuery, enabledWhenAgentIdle)).map((u) => u.username); + const unavailableAgents = (await this.getUnavailableAgents(undefined, extraQuery, enabledWhenAgentIdle, acceptChatsWithNoAgents)).map( + (u) => u.username, + ); const extraFilters = { ...(ignoreAgentId && { _id: { $ne: ignoreAgentId } }), // limit query to remove booked agents username: { $nin: unavailableAgents }, }; - const query = queryAvailableAgentsForSelection(extraFilters, enabledWhenAgentIdle); + const query = queryAvailableAgentsForSelection(extraFilters, enabledWhenAgentIdle, acceptChatsWithNoAgents); const sort: Record = { livechatCount: 1, diff --git a/packages/omni-core/src/visitor/create.spec.ts b/packages/omni-core/src/visitor/create.spec.ts index 2dee062386877..3089abbc1980c 100644 --- a/packages/omni-core/src/visitor/create.spec.ts +++ b/packages/omni-core/src/visitor/create.spec.ts @@ -69,7 +69,9 @@ describe('registerGuest', () => { it('should throw error when token is not provided', async () => { const guestData = {}; - await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-token'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false })).rejects.toThrow( + 'error-invalid-token', + ); }); it('should throw error when token is empty string', async () => { @@ -77,7 +79,9 @@ describe('registerGuest', () => { token: '', }; - await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-token'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false })).rejects.toThrow( + 'error-invalid-token', + ); }); }); @@ -106,11 +110,16 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(mockValidateEmail).toHaveBeenCalledWith('test@example.com'); expect(findContactByEmailAndContactManagerSpy).toHaveBeenCalledWith('test@example.com'); - expect(findOneOnlineAgentByIdSpy).toHaveBeenCalledWith(agentId, false, { projection: { _id: 1, username: 1, name: 1, emails: 1 } }); + expect(findOneOnlineAgentByIdSpy).toHaveBeenCalledWith( + agentId, + false, + { projection: { _id: 1, username: 1, name: 1, emails: 1 } }, + false, + ); // Verify the data passed to updateOneByIdOrToken expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( @@ -147,7 +156,7 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); // Verify contact manager is not included in the data expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( @@ -175,7 +184,7 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(mockValidateEmail).toHaveBeenCalledWith('test@example.com'); expect(findContactByEmailAndContactManagerSpy).toHaveBeenCalledWith('test@example.com'); @@ -207,7 +216,7 @@ describe('registerGuest', () => { department, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(findOneByIdOrNameSpy).toHaveBeenCalledWith(department, { projection: { _id: 1 } }); @@ -236,7 +245,9 @@ describe('registerGuest', () => { department, }; - await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-department'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false })).rejects.toThrow( + 'error-invalid-department', + ); // Verify updateOneByIdOrToken is not called when department validation fails expect(updateOneByIdOrTokenSpy).not.toHaveBeenCalled(); @@ -257,7 +268,7 @@ describe('registerGuest', () => { department, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); // Department validation should be skipped expect(findOneByIdOrNameSpy).not.toHaveBeenCalled(); @@ -289,7 +300,7 @@ describe('registerGuest', () => { name: 'Updated Name', }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(getVisitorByTokenSpy).toHaveBeenCalledWith(token, { projection: { _id: 1 } }); @@ -322,7 +333,7 @@ describe('registerGuest', () => { phone: { number: phoneNumber }, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(findOneVisitorByPhoneSpy).toHaveBeenCalledWith(phoneNumber); @@ -355,7 +366,7 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(findOneGuestByEmailAddressSpy).toHaveBeenCalledWith(email); @@ -387,7 +398,7 @@ describe('registerGuest', () => { username, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); // Verify new visitor data is created with provided values expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( @@ -415,7 +426,7 @@ describe('registerGuest', () => { token, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(getNextVisitorUsernameSpy).toHaveBeenCalled(); @@ -445,7 +456,7 @@ describe('registerGuest', () => { username: providedUsername, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(getNextVisitorUsernameSpy).not.toHaveBeenCalled(); @@ -476,7 +487,7 @@ describe('registerGuest', () => { phone: { number: phoneNumber }, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -504,7 +515,7 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -529,7 +540,7 @@ describe('registerGuest', () => { token, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -555,7 +566,7 @@ describe('registerGuest', () => { status, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -590,7 +601,7 @@ describe('registerGuest', () => { connectionData, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -624,7 +635,7 @@ describe('registerGuest', () => { connectionData, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -654,7 +665,7 @@ describe('registerGuest', () => { connectionData, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -684,7 +695,7 @@ describe('registerGuest', () => { token, }; - const result = await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + const result = await registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false }); expect(result).toBeNull(); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( @@ -710,12 +721,14 @@ describe('registerGuest', () => { email, }; - await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('Invalid email'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false, shouldConsiderOfflineAgent: false })).rejects.toThrow( + 'Invalid email', + ); }); }); describe('shouldConsiderIdleAgent parameter', () => { - it('should pass shouldConsiderIdleAgent to findOneOnlineAgentById', async () => { + it('should pass shouldConsiderIdleAgent and shouldConsiderOfflineAgent to findOneOnlineAgentById', async () => { const token = 'test-token'; const email = 'test@example.com'; const agentId = 'agent-123'; @@ -743,12 +756,13 @@ describe('registerGuest', () => { email, }; - await registerGuest(guestData, { shouldConsiderIdleAgent: true }); + await registerGuest(guestData, { shouldConsiderIdleAgent: true, shouldConsiderOfflineAgent: true }); expect(findOneOnlineAgentByIdSpy).toHaveBeenCalledWith( agentId, true, // shouldConsiderIdleAgent should be true { projection: { _id: 1, username: 1, name: 1, emails: 1 } }, + true, // shouldConsiderOfflineAgent should be true ); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts index e4df7d70f305e..83b0dec398c1c 100644 --- a/packages/omni-core/src/visitor/create.ts +++ b/packages/omni-core/src/visitor/create.ts @@ -17,7 +17,7 @@ type RegisterGuestType = Partial => { if (!token) { throw Error('error-invalid-token'); @@ -40,9 +40,14 @@ export const registerGuest = makeFunction( const contact = await LivechatContacts.findContactByEmailAndContactManager(visitorEmail); if (contact?.contactManager) { - const agent = await Users.findOneOnlineAgentById(contact.contactManager, shouldConsiderIdleAgent, { - projection: { _id: 1, username: 1, name: 1, emails: 1 }, - }); + const agent = await Users.findOneOnlineAgentById( + contact.contactManager, + shouldConsiderIdleAgent, + { + projection: { _id: 1, username: 1, name: 1, emails: 1 }, + }, + shouldConsiderOfflineAgent, + ); if (agent?.username && agent.name && agent.emails) { visitorDataToUpdate.contactManager = { _id: agent._id,