Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/dull-horses-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/ddp-client': patch
'@rocket.chat/meteor': patch
---

Fixes livechat inquiries not routing to the manager queue when manual routing is enabled.
41 changes: 30 additions & 11 deletions apps/meteor/app/livechat/client/lib/stream/queueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ const events = {
removed: (inquiry: ILivechatInquiryRecord) => removeInquiry(inquiry),
};

type InquiryEventType = keyof typeof events;
type InquiryEventArgs = { type: InquiryEventType } & Omit<ILivechatInquiryRecord, 'type'>;

const processInquiryEvent = async (args: unknown): Promise<void> => {
if (!args || typeof args !== 'object' || !('type' in args)) {
return;
}

const { type, ...inquiry } = args as InquiryEventArgs;
if (type in events) {
await events[type](inquiry as ILivechatInquiryRecord);
}
};

const invalidateRoomQueries = async (rid: string) => {
await queryClient.invalidateQueries({ queryKey: ['rooms', { reference: rid, type: 'l' }] });
queryClient.removeQueries({ queryKey: ['rooms', rid] });
Expand All @@ -53,11 +67,7 @@ const removeListenerOfDepartment = (departmentId: ILivechatDepartment['_id']) =>
const appendListenerToDepartment = (departmentId: ILivechatDepartment['_id']) => {
departments.add(departmentId);
sdk.stream('livechat-inquiry-queue-observer', [`department/${departmentId}`], async (args) => {
if (!('type' in args)) {
return;
}
const { type, ...inquiry } = args;
await events[args.type](inquiry);
await processInquiryEvent(args);
});
return () => removeListenerOfDepartment(departmentId);
};
Expand All @@ -78,15 +88,22 @@ const removeGlobalListener = () => sdk.stop('livechat-inquiry-queue-observer', '

const addGlobalListener = () => {
sdk.stream('livechat-inquiry-queue-observer', ['public'], async (args) => {
if (!('type' in args)) {
return;
}
const { type, ...inquiry } = args;
await events[args.type](inquiry);
await processInquiryEvent(args);
});
return removeGlobalListener;
};

const removeAgentListener = (userId: IOmnichannelAgent['_id']) => {
sdk.stop('livechat-inquiry-queue-observer', `agent/${userId}`);
};

const addAgentListener = (userId: IOmnichannelAgent['_id']) => {
sdk.stream('livechat-inquiry-queue-observer', [`agent/${userId}`], async (args) => {
await processInquiryEvent(args);
});
return () => removeAgentListener(userId);
};

const subscribe = async (userId: IOmnichannelAgent['_id']) => {
const config = await callWithErrorHandling('livechat:getRoutingConfig');
if (config?.autoAssignAgent) {
Expand All @@ -95,7 +112,8 @@ const subscribe = async (userId: IOmnichannelAgent['_id']) => {

const agentDepartments = (await getAgentsDepartments(userId)).map((department) => department.departmentId);

// Register to all depts + public queue always to match the inquiry list returned by backend
// Register to agent-specific queue, all depts + public queue to match the inquiry list returned by backend
const cleanAgentListener = addAgentListener(userId);
const cleanDepartmentListeners = addListenerForeachDepartment(agentDepartments);
const globalCleanup = addGlobalListener();

Expand All @@ -108,6 +126,7 @@ const subscribe = async (userId: IOmnichannelAgent['_id']) => {
return () => {
LivechatInquiry.remove({});
removeGlobalListener();
cleanAgentListener?.();
cleanDepartmentListeners?.();
globalCleanup?.();
departments.clear();
Expand Down
14 changes: 10 additions & 4 deletions apps/meteor/app/livechat/server/api/lib/inquiries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,18 @@ export async function findInquiries({
// V in Enum only works for numeric enums
...(status && Object.values(LivechatInquiryStatus).includes(status) && { status }),
$or: [
// Cases where this user is the default agent
{
$and: [{ defaultAgent: { $exists: true } }, { 'defaultAgent.agentId': userId }],
'defaultAgent': { $exists: true },
'defaultAgent.agentId': userId,
},
// Cases with no default agent assigned yet, AND either:
// - belongs to one of user's departments, or
// - has no department (public queue)
{
defaultAgent: { $exists: false },
$or: [...(department ? [{ department }] : []), { department: { $exists: false } }],
},
{ ...(department && { department }) },
// Add _always_ the "public queue" to returned list of inquiries, even if agent already has departments
{ department: { $exists: false } },
],
};

Expand Down
10 changes: 9 additions & 1 deletion apps/meteor/app/livechat/server/lib/QueueManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ export class QueueManager {
return LivechatInquiryStatus.READY;
}

if (settings.get('Livechat_Routing_Method') === 'Manual_Selection' && agent) {
return LivechatInquiryStatus.QUEUED;
}

if (!agent) {
return LivechatInquiryStatus.QUEUED;
}
Expand All @@ -167,8 +171,12 @@ export class QueueManager {

if (inquiry.status === LivechatInquiryStatus.QUEUED) {
await callbacks.run('livechat.afterInquiryQueued', inquiry);
await callbacks.run('livechat.chatQueued', room);

void callbacks.run('livechat.chatQueued', room);
if (defaultAgent) {
logger.debug(`Setting default agent for inquiry ${inquiry._id} to ${defaultAgent.username}`);
await LivechatInquiry.setDefaultAgentById(inquiry._id, defaultAgent);
}

return this.dispatchInquiryQueued(inquiry, room, defaultAgent);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,19 @@ const getDefaultAgent = async ({ username, id }: { username?: string; id?: strin
}

if (id) {
return normalizeDefaultAgent(await Users.findOneOnlineAgentById(id, undefined, { projection: { _id: 1, username: 1 } }));
const agent = await Users.findOneOnlineAgentById(id, undefined, { projection: { _id: 1, username: 1 } });
if (agent) {
return normalizeDefaultAgent(agent);
}

const offlineAgent = await Users.findOneAgentById(id, { projection: { _id: 1, username: 1 } });
if (offlineAgent && settings.get('Livechat_accept_chats_with_no_agents')) {
return normalizeDefaultAgent(offlineAgent);
}

return undefined;
}

return normalizeDefaultAgent(await Users.findOneOnlineAgentByUserList(username || [], { projection: { _id: 1, username: 1 } }));
};

Expand Down
58 changes: 29 additions & 29 deletions apps/meteor/server/modules/listeners/listeners.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,49 +223,49 @@ export class ListenersModule {

service.onEvent('watch.inquiries', async ({ clientAction, inquiry, diff }): Promise<void> => {
const type = minimongoChangeMap[clientAction] as 'added' | 'changed' | 'removed';
if (clientAction === 'removed') {
notifications.streamLivechatQueueData.emitWithoutBroadcast(inquiry._id, {
_id: inquiry._id,
clientAction,
});

if (inquiry.department) {
return notifications.streamLivechatQueueData.emitWithoutBroadcast(`department/${inquiry.department}`, { type, ...inquiry });
const isOnlyQueueMetadataUpdate = (diff: Record<string, unknown> | undefined): boolean => {
if (!diff) {
return false;
}

return notifications.streamLivechatQueueData.emitWithoutBroadcast('public', {
type,
...inquiry,
});
}

// Don't do notifications for updating inquiries when the only thing changing is the queue metadata
if (
clientAction === 'updated' &&
diff?.hasOwnProperty('lockedAt') &&
diff?.hasOwnProperty('locked') &&
diff?.hasOwnProperty('_updatedAt') &&
Object.keys(diff).length === 3
) {
return;
}
const queueMetadataKeys = ['lockedAt', 'locked', '_updatedAt'];
return Object.keys(diff).length === queueMetadataKeys.length && queueMetadataKeys.every((key) => diff.hasOwnProperty(key));
};

// Always notify the specific inquiry channel
notifications.streamLivechatQueueData.emitWithoutBroadcast(inquiry._id, {
...inquiry,
_id: inquiry._id,
...(clientAction !== 'removed' && { ...inquiry }),
clientAction,
});

if (!inquiry.department) {
return notifications.streamLivechatQueueData.emitWithoutBroadcast('public', {
// Skip further notifications if it's just a queue metadata update
if (clientAction === 'updated' && isOnlyQueueMetadataUpdate(diff)) {
return;
}

// Notify the defaultAgent if exists
if (inquiry.defaultAgent?.agentId) {
notifications.streamLivechatQueueData.emitWithoutBroadcast(`agent/${inquiry.defaultAgent.agentId}`, {
type,
...inquiry,
});
}

notifications.streamLivechatQueueData.emitWithoutBroadcast(`department/${inquiry.department}`, { type, ...inquiry });
// Prioritize department-specific channel over public
if (inquiry.department) {
notifications.streamLivechatQueueData.emitWithoutBroadcast(`department/${inquiry.department}`, {
type,
...inquiry,
});
}

if (clientAction === 'updated' && !diff?.department) {
notifications.streamLivechatQueueData.emitWithoutBroadcast('public', { type, ...inquiry });
if (!inquiry.department && !inquiry.defaultAgent?.agentId) {
notifications.streamLivechatQueueData.emitWithoutBroadcast('public', {
type,
...inquiry,
});
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,124 @@ test.describe('OC - Livechat - Queue Management', () => {
});
});
});

test.describe('OC - Contact Manager Routing', () => {
test.skip(!IS_EE, 'Enterprise Only');

let poHomeOmnichannel: HomeOmnichannel;
let poLiveChat: OmnichannelLiveChat;

// User2 will be the contact manager
let poHomeOmnichannelUser2: HomeOmnichannel;

const visitorWithManager = createFakeVisitor();
const contactId = `contact-${Date.now()}`;

test.beforeAll(async ({ api, browser }) => {
await api.post('/livechat/users/agent', { username: 'user2' });
await api.post('/settings/Livechat_Routing_Method', { value: 'Manual_Selection' });
await api.post('/omnichannel/contact', {
_id: contactId,
name: visitorWithManager.name,
email: visitorWithManager.email,
contactManager: {
username: 'user2',
},
});

const { page: omniPage } = await createAuxContext(browser, Users.user1, '/', true);
poHomeOmnichannel = new HomeOmnichannel(omniPage);

const { page: omniPageUser2 } = await createAuxContext(browser, Users.user2, '/', true);
poHomeOmnichannelUser2 = new HomeOmnichannel(omniPageUser2);
});

test.beforeEach(async ({ browser, api }) => {
const context = await browser.newContext();
const page = await context.newPage();

poLiveChat = new OmnichannelLiveChat(page, api);
await poLiveChat.page.goto('/livechat');
});

test.afterAll(async ({ api }) => {
await Promise.all([
api.post('/settings/Livechat_Routing_Method', { value: 'Auto_Selection' }),
api.delete('/livechat/users/agent/user1'),
api.delete('/livechat/users/agent/user2'),
api.delete(`/omnichannel/contact/${contactId}`),
]);

await poHomeOmnichannel.page.close();
await poHomeOmnichannelUser2.page.close();
});

test.afterEach(async () => {
await poLiveChat.closeChat();
await poLiveChat.page.close();
});

test('should route inquiry only to the contact manager', async () => {
await test.step('visitor with contact manager starts a chat', async () => {
await poLiveChat.openAnyLiveChatAndSendMessage({
liveChatUser: visitorWithManager,
message: 'I need assistance',
isOffline: false,
});
});

await test.step('verify non-manager agent does not see the inquiry', async () => {
const nonManagerQueuedChat = poHomeOmnichannel.sidenav.getQueuedChat(visitorWithManager.name);
await expect(nonManagerQueuedChat).toHaveCount(0);
});

await test.step('verify the contact manager agent sees the inquiry', async () => {
const managerQueuedChat = poHomeOmnichannelUser2.sidenav.getQueuedChat(visitorWithManager.name);
await expect(managerQueuedChat).toBeVisible();
});

await test.step('contact manager can take the chat', async () => {
await poHomeOmnichannelUser2.sidenav.getQueuedChat(visitorWithManager.name).click();
await expect(poHomeOmnichannelUser2.content.btnTakeChat).toBeVisible();
await poHomeOmnichannelUser2.content.btnTakeChat.click();
await expect(poHomeOmnichannelUser2.content.lastSystemMessageBody).toHaveText('joined the channel');
});
});

test('inquiry should persist only in contact manager queue after page refresh', async () => {
const anotherVisitorWithManager = createFakeVisitor();

await test.step('visitor with contact manager starts a chat', async () => {
await poLiveChat.openAnyLiveChatAndSendMessage({
liveChatUser: anotherVisitorWithManager,
message: 'I need help after refresh test',
isOffline: false,
});
});

await test.step('refresh both agent pages', async () => {
await poHomeOmnichannel.page.reload();
await poHomeOmnichannel.page.waitForLoadState('networkidle');

await poHomeOmnichannelUser2.page.reload();
await poHomeOmnichannelUser2.page.waitForLoadState('networkidle');
});

await test.step('verify non-manager agent still does not see the inquiry after refresh', async () => {
const nonManagerQueuedChat = poHomeOmnichannel.sidenav.getQueuedChat(anotherVisitorWithManager.name);
await expect(nonManagerQueuedChat).toHaveCount(0);
});

await test.step('verify the contact manager still sees the inquiry after refresh', async () => {
const managerQueuedChat = poHomeOmnichannelUser2.sidenav.getQueuedChat(anotherVisitorWithManager.name);
await expect(managerQueuedChat).toBeVisible();
});

await test.step('contact manager can take the chat after refresh', async () => {
await poHomeOmnichannelUser2.sidenav.getQueuedChat(anotherVisitorWithManager.name).click();
await expect(poHomeOmnichannelUser2.content.btnTakeChat).toBeVisible();
await poHomeOmnichannelUser2.content.btnTakeChat.click();
await expect(poHomeOmnichannelUser2.content.lastSystemMessageBody).toHaveText('joined the channel');
});
});
});
8 changes: 8 additions & 0 deletions packages/ddp-client/src/types/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,14 @@ export interface StreamerEvents {
} & ILivechatInquiryRecord,
];
},
{
key: `agent/${string}`;
args: [
{
type: 'added' | 'removed' | 'changed';
} & ILivechatInquiryRecord,
];
},
{
key: `${string}`;
args: [
Expand Down
Loading