diff --git a/.changeset/fluffy-turtles-admire.md b/.changeset/fluffy-turtles-admire.md new file mode 100644 index 0000000000000..3d21fa3ed7910 --- /dev/null +++ b/.changeset/fluffy-turtles-admire.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixes race condition causing duplicate open livechat rooms per visitor token. diff --git a/apps/meteor/app/api/server/v1/emoji-custom.ts b/apps/meteor/app/api/server/v1/emoji-custom.ts index 0cc6bb53720d5..043da3b0c5396 100644 --- a/apps/meteor/app/api/server/v1/emoji-custom.ts +++ b/apps/meteor/app/api/server/v1/emoji-custom.ts @@ -1,7 +1,7 @@ import { Media } from '@rocket.chat/core-services'; import type { IEmojiCustom } from '@rocket.chat/core-typings'; import { EmojiCustom } from '@rocket.chat/models'; -import { ajv, isEmojiCustomList } from '@rocket.chat/rest-typings'; +import { ajv, isEmojiCustomList, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Meteor } from 'meteor/meteor'; @@ -30,11 +30,43 @@ function validateDateParam(paramName: string, paramValue: string | undefined): D return date; } -API.v1.addRoute( - 'emoji-custom.list', - { authRequired: true, validateParams: isEmojiCustomList }, - { - async get() { +const emojiListResponseSchema = ajv.compile({ + type: 'object', + properties: { + emojis: { + type: 'object', + properties: { + update: { type: 'array', items: { type: 'object' } }, + remove: { type: 'array', items: { type: 'object' } }, + }, + required: ['update', 'remove'], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['emojis', 'success'], + additionalProperties: false, +}); + +const emojiDeleteBodySchema = ajv.compile({ + type: 'object', + properties: { emojiId: { type: 'string' } }, + required: ['emojiId'], + additionalProperties: false, +}); + +const emojiCustomCreateEndpoints = API.v1 + .get( + 'emoji-custom.list', + { + authRequired: true, + query: isEmojiCustomList, + response: { + 200: emojiListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { query } = await this.parseJsonQuery(); const { updatedSince, _updatedAt, _id } = this.queryParams; @@ -71,14 +103,28 @@ API.v1.addRoute( }, }); }, - }, -); - -API.v1.addRoute( - 'emoji-custom.all', - { authRequired: true }, - { - async get() { + ) + .get( + 'emoji-custom.all', + { + authRequired: true, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + emojis: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['emojis', 'total', 'count', 'offset', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { offset, count } = await getPaginationItems(this.queryParams); const { sort, query } = await this.parseJsonQuery(); const { name } = this.queryParams; @@ -101,84 +147,92 @@ API.v1.addRoute( }), ); }, - }, -); - -const emojiCustomCreateEndpoints = API.v1.post( - 'emoji-custom.create', - { - authRequired: true, - response: { - 400: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [false] }, - stack: { type: 'string' }, - error: { type: 'string' }, - errorType: { type: 'string' }, - details: { type: 'string' }, - }, - required: ['success'], - additionalProperties: false, - }), - 200: ajv.compile({ - type: 'object', - properties: { - success: { - type: 'boolean', - enum: [true], + ) + .post( + 'emoji-custom.create', + { + authRequired: true, + response: { + 400: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [false] }, + stack: { type: 'string' }, + error: { type: 'string' }, + errorType: { type: 'string' }, + details: { type: 'string' }, }, - }, - required: ['success'], - additionalProperties: false, - }), - }, - }, - async function action() { - const emoji = await getUploadFormData( - { - request: this.request, - }, - { - field: 'emoji', - sizeLimit: settings.get('FileUpload_MaxFileSize'), + required: ['success'], + additionalProperties: false, + }), + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), }, - ); - - const { fields, fileBuffer, mimetype } = emoji; + }, + async function action() { + const emoji = await getUploadFormData( + { + request: this.request, + }, + { + field: 'emoji', + sizeLimit: settings.get('FileUpload_MaxFileSize'), + }, + ); - const isUploadable = await Media.isImage(fileBuffer); - if (!isUploadable) { - throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image"); - } + const { fields, fileBuffer, mimetype } = emoji; - const [, extension] = mimetype.split('/'); - fields.extension = extension; + const isUploadable = await Media.isImage(fileBuffer); + if (!isUploadable) { + throw new Meteor.Error('emoji-is-not-image', "Emoji file provided cannot be uploaded since it's not an image"); + } - try { - const emojiData = await insertOrUpdateEmoji(this.userId, { - ...fields, - newFile: true, - aliases: fields.aliases || '', - name: fields.name, - extension: fields.extension, - }); + const [, extension] = mimetype.split('/'); + fields.extension = extension; - await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData); - } catch (err) { - SystemLogger.error({ err }); - return API.v1.failure(); - } + try { + const emojiData = await insertOrUpdateEmoji(this.userId, { + ...fields, + newFile: true, + aliases: fields.aliases || '', + name: fields.name, + extension: fields.extension, + }); - return API.v1.success(); - }, -); + await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData); + } catch (err) { + SystemLogger.error({ err }); + return API.v1.failure(); + } -API.v1.addRoute( - 'emoji-custom.update', - { authRequired: true }, - { - async post() { + return API.v1.success(); + }, + ) + .post( + 'emoji-custom.update', + { + authRequired: true, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const emoji = await getUploadFormData( { request: this.request, @@ -229,14 +283,24 @@ API.v1.addRoute( } return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'emoji-custom.delete', - { authRequired: true }, - { - async post() { + ) + .post( + 'emoji-custom.delete', + { + authRequired: true, + body: emojiDeleteBodySchema, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { emojiId } = this.bodyParams; if (!emojiId) { return API.v1.failure('The "emojiId" params is required!'); @@ -246,8 +310,7 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); + ); type EmojiCustomCreateEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/api/server/v1/invites.ts b/apps/meteor/app/api/server/v1/invites.ts index 637da81892c03..1c4b6ed4433ee 100644 --- a/apps/meteor/app/api/server/v1/invites.ts +++ b/apps/meteor/app/api/server/v1/invites.ts @@ -5,6 +5,7 @@ import { isUseInviteTokenProps, isValidateInviteTokenProps, isSendInvitationEmailParams, + validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; import { findOrCreateInvite } from '../../../invites/server/functions/findOrCreateInvite'; @@ -170,43 +171,89 @@ const invites = API.v1 return API.v1.success((await findOrCreateInvite(this.userId, { rid, days, maxUses })) as IInvite); }, - ); - -API.v1.addRoute( - 'removeInvite/:_id', - { authRequired: true }, - { - async delete() { + ) + .delete( + 'removeInvite/:_id', + { + authRequired: true, + response: { + 200: ajv.compile({ + type: 'boolean', + enum: [true], + }), + 400: validateBadRequestErrorResponse, + 401: ajv.compile({ + type: 'object', + properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, + required: ['success', 'error'], + additionalProperties: false, + }), + }, + }, + async function action() { const { _id } = this.urlParams; return API.v1.success(await removeInvite(this.userId, { _id })); }, - }, -); - -API.v1.addRoute( - 'useInviteToken', - { - authRequired: true, - validateParams: isUseInviteTokenProps, - }, - { - async post() { + ) + .post( + 'useInviteToken', + { + authRequired: true, + body: isUseInviteTokenProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + room: { + type: 'object', + properties: { + rid: { type: 'string' }, + prid: { type: 'string', nullable: true }, + fname: { type: 'string', nullable: true }, + name: { type: 'string', nullable: true }, + t: { type: 'string' }, + }, + required: ['rid', 't'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: ajv.compile({ + type: 'object', + properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, + required: ['success', 'error'], + additionalProperties: false, + }), + }, + }, + async function action() { const { token } = this.bodyParams; - // eslint-disable-next-line react-hooks/rules-of-hooks return API.v1.success(await useInviteToken(this.userId, token)); }, - }, -); - -API.v1.addRoute( - 'validateInviteToken', - { - authRequired: false, - validateParams: isValidateInviteTokenProps, - }, - { - async post() { + ) + .post( + 'validateInviteToken', + { + authRequired: false, + body: isValidateInviteTokenProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + valid: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['valid', 'success'], + additionalProperties: false, + }), + }, + }, + async function action() { const { token } = this.bodyParams; try { return API.v1.success({ valid: Boolean(await validateInviteToken(token)) }); @@ -214,26 +261,42 @@ API.v1.addRoute( return API.v1.success({ valid: false }); } }, - }, -); - -API.v1.addRoute( - 'sendInvitationEmail', - { - authRequired: true, - validateParams: isSendInvitationEmailParams, - }, - { - async post() { + ) + .post( + 'sendInvitationEmail', + { + authRequired: true, + body: isSendInvitationEmailParams, + response: { + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean' } }, + required: ['success'], + additionalProperties: false, + }), + 400: ajv.compile({ + type: 'object', + properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, + required: ['success', 'error'], + additionalProperties: false, + }), + 401: ajv.compile({ + type: 'object', + properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, + required: ['success', 'error'], + additionalProperties: false, + }), + }, + }, + async function action() { const { emails } = this.bodyParams; try { return API.v1.success({ success: Boolean(await sendInvitationEmail(this.userId, emails)) }); - } catch (e: any) { - return API.v1.failure({ error: e.message }); + } catch (e: unknown) { + return API.v1.failure({ error: e instanceof Error ? e.message : String(e) }); } }, - }, -); + ); type InvitesEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index b6e406bbfc969..375107c016cd6 100644 --- a/apps/meteor/app/api/server/v1/subscriptions.ts +++ b/apps/meteor/app/api/server/v1/subscriptions.ts @@ -1,9 +1,12 @@ import { Rooms, Subscriptions } from '@rocket.chat/models'; import { + ajv, isSubscriptionsGetProps, isSubscriptionsGetOneProps, isSubscriptionsReadProps, isSubscriptionsUnreadProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; @@ -12,56 +15,86 @@ import { getSubscriptions } from '../../../../server/publications/subscription'; import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages'; import { API } from '../api'; -API.v1.addRoute( +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: true, +}); + +API.v1.get( 'subscriptions.get', { authRequired: true, - validateParams: isSubscriptionsGetProps, + query: isSubscriptionsGetProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + update: { type: 'array', items: { type: 'object' } }, + remove: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, + }), + 401: validateUnauthorizedErrorResponse, + }, }, - { - async get() { - const { updatedSince } = this.queryParams; - - let updatedSinceDate: Date | undefined; - if (updatedSince) { - if (isNaN(Date.parse(updatedSince as string))) { - throw new Meteor.Error('error-roomId-param-invalid', 'The "lastUpdate" query parameter must be a valid date.'); - } - updatedSinceDate = new Date(updatedSince as string); + async function action() { + const { updatedSince } = this.queryParams; + + let updatedSinceDate: Date | undefined; + if (updatedSince) { + const updatedSinceStr = String(updatedSince); + if (isNaN(Date.parse(updatedSinceStr))) { + throw new Meteor.Error('error-roomId-param-invalid', 'The "lastUpdate" query parameter must be a valid date.'); } + updatedSinceDate = new Date(updatedSinceStr); + } - const result = await getSubscriptions(this.userId, updatedSinceDate); + const result = await getSubscriptions(this.userId, updatedSinceDate); - return API.v1.success( - Array.isArray(result) - ? { - update: result, - remove: [], - } - : result, - ); - }, + return API.v1.success( + Array.isArray(result) + ? { + update: result, + remove: [], + } + : result, + ); }, ); -API.v1.addRoute( +API.v1.get( 'subscriptions.getOne', { authRequired: true, - validateParams: isSubscriptionsGetOneProps, + query: isSubscriptionsGetOneProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + subscription: { type: 'object', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['subscription', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async get() { - const { roomId } = this.queryParams; + async function action() { + const { roomId } = this.queryParams; - if (!roomId) { - return API.v1.failure("The 'roomId' param is required"); - } + if (!roomId) { + return API.v1.failure("The 'roomId' param is required"); + } - return API.v1.success({ - subscription: await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId), - }); - }, + return API.v1.success({ + subscription: await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId), + }); }, ); @@ -74,44 +107,50 @@ API.v1.addRoute( - rid: The rid of the room to be marked as read. - roomId: Alternative for rid. */ -API.v1.addRoute( +API.v1.post( 'subscriptions.read', { authRequired: true, - validateParams: isSubscriptionsReadProps, + body: isSubscriptionsReadProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { readThreads = false } = this.bodyParams; - const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId; + async function action() { + const { readThreads = false } = this.bodyParams; + const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId; - const room = await Rooms.findOneById(roomId); - if (!room) { - throw new Error('error-invalid-subscription'); - } + const room = await Rooms.findOneById(roomId); + if (!room) { + throw new Error('error-invalid-subscription'); + } - await readMessages(room, this.userId, readThreads); + await readMessages(room, this.userId, readThreads); - return API.v1.success(); - }, + return API.v1.success({}); }, ); -API.v1.addRoute( +API.v1.post( 'subscriptions.unread', { authRequired: true, - validateParams: isSubscriptionsUnreadProps, - }, - { - async post() { - await unreadMessages( - this.userId, - 'firstUnreadMessage' in this.bodyParams ? this.bodyParams.firstUnreadMessage : undefined, - 'roomId' in this.bodyParams ? this.bodyParams.roomId : undefined, - ); - - return API.v1.success(); + body: isSubscriptionsUnreadProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + await unreadMessages( + this.userId, + 'firstUnreadMessage' in this.bodyParams ? this.bodyParams.firstUnreadMessage : undefined, + 'roomId' in this.bodyParams ? this.bodyParams.roomId : undefined, + ); + + return API.v1.success({}); + }, ); diff --git a/apps/meteor/app/cloud/server/index.ts b/apps/meteor/app/cloud/server/index.ts index 7bc7696d5b0dc..78485ccab1fa7 100644 --- a/apps/meteor/app/cloud/server/index.ts +++ b/apps/meteor/app/cloud/server/index.ts @@ -22,7 +22,7 @@ Meteor.startup(async () => { throw new Error("Couldn't register with token. Please make sure token is valid or hasn't already been used"); } - console.log('Successfully registered with token provided by REG_TOKEN!'); + SystemLogger.info('Successfully registered with token provided by REG_TOKEN!'); } catch (err: any) { SystemLogger.error({ msg: 'An error occurred registering with token.', err }); } diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 719996542d057..7d0f81d02b2e7 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -145,6 +145,9 @@ export const prepareLivechatRoom = async ( priorityWeight: LivechatPriorityWeight.NOT_SPECIFIED, estimatedWaitingTimeQueue: DEFAULT_SLA_CONFIG.ESTIMATED_WAITING_TIME_QUEUE, ...extraRoomInfo, + // marker field for unique index - only new rooms have this field (see #39087) + // allows index creation to succeed even if old duplicates exist + _enforceSingleRoom: true, } as InsertionModel; }; diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-agents.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-agents.spec.ts index 26ee32afd7215..6eaf7f731a251 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-agents.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-agents.spec.ts @@ -2,6 +2,7 @@ import { createFakeDepartment } from '../../mocks/data'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; import { OmnichannelAgents } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; import { createDepartment } from '../utils/omnichannel/departments'; import { test, expect } from '../utils/test'; @@ -24,12 +25,18 @@ test.describe.serial('OC - Manage Agents', () => { await poOmnichannelAgents.sidebar.linkAgents.click(); }); + test.beforeAll(async ({ api }) => { + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200); + }); + + test.afterAll(async ({ api }) => { + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200); + }); + // Ensure that there is no leftover data even if test fails test.afterEach(async ({ api }) => { await api.delete('/livechat/users/agent/user1'); - await api.post('/settings/Omnichannel_enable_department_removal', { value: true }).then((res) => expect(res.status()).toBe(200)); await department.delete(); - await api.post('/settings/Omnichannel_enable_department_removal', { value: false }).then((res) => expect(res.status()).toBe(200)); }); test('OC - Manage Agents - Add, search and remove using table', async ({ page }) => { 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 c3ceeae55d853..1689b26bcd606 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 @@ -2,6 +2,7 @@ import { createFakeVisitor } from '../../mocks/data'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; import { HomeOmnichannel } from '../page-objects'; +import { setSettingValueById } from '../utils'; import { createAgent } from '../utils/omnichannel/agents'; import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; import { createConversation } from '../utils/omnichannel/rooms'; @@ -27,6 +28,7 @@ test.describe('OC - Tags Visibility', () => { let sharedTag: Awaited>; test.beforeAll('Create departments', async ({ api }) => { + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200); departmentA = await createDepartment(api, { name: 'Department A' }); departmentB = await createDepartment(api, { name: 'Department B' }); }); @@ -63,17 +65,18 @@ test.describe('OC - Tags Visibility', () => { await page.goto('/'); }); - test.afterAll(async () => { + test.afterAll(async ({ api }) => { await Promise.all(conversations.map((conversation) => conversation.delete())); await Promise.all([tagA, tagB, globalTag, sharedTag].map((tag) => tag.delete())); await agent.delete(); await departmentA.delete(); await departmentB.delete(); + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200); }); test('Verify agent should see correct tags based on department association', async () => { await test.step('Agent opens room', async () => { - await poOmnichannel.sidebar.getSidebarItemByName(visitorA.name).click(); + await poOmnichannel.navbar.openChat(visitorA.name); }); await test.step('should not be able to see tags field', async () => { @@ -117,7 +120,7 @@ test.describe('OC - Tags Visibility', () => { test('Verify tags visibility for agent associated with multiple departments', async () => { await test.step('Open room info', async () => { - await poOmnichannel.sidebar.getSidebarItemByName(visitorB.name).click(); + await poOmnichannel.navbar.openChat(visitorB.name); await poOmnichannel.roomInfo.btnEdit.click(); await expect(poOmnichannel.editRoomInfo.root).toBeVisible(); await poOmnichannel.editRoomInfo.inputTags.click(); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-business-hours.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-business-hours.spec.ts index 40a064256c401..334e7f1370959 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-business-hours.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-business-hours.spec.ts @@ -3,6 +3,7 @@ import type { Page } from '@playwright/test'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; import { OmnichannelBusinessHours } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; import { createAgent } from '../utils/omnichannel/agents'; import { createBusinessHour } from '../utils/omnichannel/businessHours'; import { createDepartment } from '../utils/omnichannel/departments'; @@ -25,6 +26,7 @@ test.describe('OC - Business Hours', () => { department = await createDepartment(api); department2 = await createDepartment(api); agent = await createAgent(api, 'user2'); + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200); await api.post('/settings/Livechat_enable_business_hours', { value: true }).then((res) => expect(res.status()).toBe(200)); await api.post('/settings/Livechat_business_hour_type', { value: 'Multiple' }).then((res) => expect(res.status()).toBe(200)); }); @@ -33,6 +35,7 @@ test.describe('OC - Business Hours', () => { await department.delete(); await department2.delete(); await agent.delete(); + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', false)).status()).toBe(200); await api.post('/settings/Livechat_enable_business_hours', { value: false }).then((res) => expect(res.status()).toBe(200)); await api.post('/settings/Livechat_business_hour_type', { value: 'Single' }).then((res) => expect(res.status()).toBe(200)); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts index 22c661897207f..3ffad2c3f31d7 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts @@ -4,6 +4,7 @@ import { IS_EE } from '../config/constants'; import { createAuxContext } from '../fixtures/createAuxContext'; import { Users } from '../fixtures/userStates'; import { HomeOmnichannel } from '../page-objects'; +import { setSettingValueById } from '../utils'; import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; import { createManager } from '../utils/omnichannel/managers'; @@ -94,6 +95,7 @@ test.describe('OC - Chat transfers [Monitor role]', () => { departments: [{ departmentId: departmentB._id }], }), ]); + expect((await setSettingValueById(api, 'Omnichannel_enable_department_removal', true)).status()).toBe(200); }); // Create sessions @@ -116,7 +118,7 @@ test.describe('OC - Chat transfers [Monitor role]', () => { await Promise.all(sessions.map(({ page }) => page.close())); }); - test.afterAll(async () => { + test.afterAll(async ({ api }) => { await Promise.all([ ...conversations.map((conversation) => conversation.delete()), ...monitors.map((monitor) => monitor.delete()), @@ -124,6 +126,7 @@ test.describe('OC - Chat transfers [Monitor role]', () => { ...units.map((unit) => unit.delete()), ...departments.map((department) => department.delete()), ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); }); test(`OC - Chat transfers [Monitor role] - Transfer to department with no online agents should fail`, async ({ api }) => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-chats.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-chats.spec.ts index 873fc625f8686..45fa43371450b 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-chats.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-chats.spec.ts @@ -5,6 +5,7 @@ import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; import { HomeOmnichannel } from '../page-objects'; import { OmnichannelContactCenterChats } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; import { createConversation, updateRoom } from '../utils/omnichannel/rooms'; @@ -108,6 +109,7 @@ test.describe('OC - Contact Center Chats [Auto Selection]', async () => { tags: [tagB.data.name], }), ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); }); test.beforeEach(async ({ page }: { page: Page }) => { @@ -132,6 +134,7 @@ test.describe('OC - Contact Center Chats [Auto Selection]', async () => { api.post('/settings/Livechat_allow_manual_on_hold', { value: false }), api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: true }), ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); }); // Change conversation A to on hold and close conversation B diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-filters.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-filters.spec.ts index bbe5a52572ab6..06a7388c21e16 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-filters.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-center-filters.spec.ts @@ -3,6 +3,7 @@ import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; import { Navbar } from '../page-objects/fragments'; import { OmnichannelContactCenterChats } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; import { addAgentToDepartment, createDepartment } from '../utils/omnichannel/departments'; import { createConversation, updateRoom } from '../utils/omnichannel/rooms'; @@ -128,6 +129,7 @@ test.describe('OC - Contact Center', async () => { tags: [tagB.data.name], }), ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); }); test.afterAll(async ({ api }) => { @@ -146,6 +148,7 @@ test.describe('OC - Contact Center', async () => { api.post('/settings/Livechat_allow_manual_on_hold', { value: false }), api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: true }), ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); }); // Change conversation A to on hold and close conversation B diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-manager-role.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-manager-role.spec.ts index 27eea4e9f2ce1..e7a110d1e6881 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-manager-role.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-manager-role.spec.ts @@ -5,6 +5,7 @@ import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; import { HomeOmnichannel } from '../page-objects'; import { OmnichannelAgents, OmnichannelManager, OmnichannelMonitors } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; import { createDepartment } from '../utils/omnichannel/departments'; import { createManager } from '../utils/omnichannel/managers'; @@ -79,6 +80,7 @@ test.describe('OC - Manager Role', () => { agentId: `user2`, }), ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); }); // Delete all created data @@ -92,6 +94,7 @@ test.describe('OC - Manager Role', () => { api.post('/settings/Livechat_allow_manual_on_hold', { value: false }), api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: true }), ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); }); test.beforeEach(async ({ page }: { page: Page }) => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-department.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-department.spec.ts index 109198cfe5bfa..45e8f911d59fa 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-department.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-department.spec.ts @@ -4,6 +4,7 @@ import type { Page } from '@playwright/test'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; import { OmnichannelDepartments } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; import { createAgent } from '../utils/omnichannel/agents'; import { createDepartment } from '../utils/omnichannel/departments'; import { createMonitor } from '../utils/omnichannel/monitors'; @@ -63,16 +64,18 @@ test.describe.serial('OC - Monitor Role', () => { departments: [{ departmentId: departmentA._id }], }), ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); }); // Delete all created data - test.afterAll(async () => { + test.afterAll(async ({ api }) => { await Promise.all([ ...agents.map((agent) => agent.delete()), ...departments.map((department) => department.delete()), ...units.map((unit) => unit.delete()), monitor.delete(), ]); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); }); test.beforeEach(async ({ page }: { page: Page }) => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-role.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-role.spec.ts index 4cde8f2e0a35d..b5530df20ad24 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-role.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-role.spec.ts @@ -4,6 +4,7 @@ import type { Page } from '@playwright/test'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; import { HomeOmnichannel } from '../page-objects'; +import { setSettingValueById } from '../utils'; import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; import { createDepartment } from '../utils/omnichannel/departments'; import { createMonitor } from '../utils/omnichannel/monitors'; @@ -44,7 +45,7 @@ test.describe('OC - Monitor Role', () => { const responses = await Promise.all([ api.post('/settings/Livechat_allow_manual_on_hold', { value: true }), api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: false }), - api.post('/settings/Omnichannel_enable_department_removal', { value: true }), + setSettingValueById(api, 'Omnichannel_enable_department_removal', true), // This is required now we're sending a chat into a department with no agents and no default agent api.post('/settings/Livechat_accept_chats_with_no_agents', { value: true }), ]); @@ -124,7 +125,7 @@ test.describe('OC - Monitor Role', () => { // Reset setting api.post('/settings/Livechat_allow_manual_on_hold', { value: false }), api.post('/settings/Livechat_allow_manual_on_hold_upon_agent_engagement_only', { value: true }), - api.post('/settings/Omnichannel_enable_department_removal', { value: false }), + setSettingValueById(api, 'Omnichannel_enable_department_removal', false), api.post('/settings/Livechat_accept_chats_with_no_agents', { value: false }), ]); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-tags.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-tags.spec.ts index d17aab773cef1..9275166c4eaa6 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-tags.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-tags.spec.ts @@ -4,6 +4,7 @@ import type { Page } from '@playwright/test'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; import { OmnichannelTags } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; import { createAgent } from '../utils/omnichannel/agents'; import { createDepartment } from '../utils/omnichannel/departments'; import { createTag } from '../utils/omnichannel/tags'; @@ -29,10 +30,15 @@ test.describe('OC - Manage Tags', () => { agent = await createAgent(api, 'user2'); }); - test.afterAll(async () => { + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); + }); + + test.afterAll(async ({ api }) => { await department.delete(); await department2.delete(); await agent.delete(); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); }); test.beforeEach(async ({ page }: { page: Page }) => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts index 0af2c31bc108e..f94c63addec63 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts @@ -4,6 +4,7 @@ import type { Page } from '@playwright/test'; import { IS_EE } from '../config/constants'; import { Users } from '../fixtures/userStates'; import { OmnichannelUnits } from '../page-objects/omnichannel'; +import { setSettingValueById } from '../utils'; import { createAgent } from '../utils/omnichannel/agents'; import { createDepartment } from '../utils/omnichannel/departments'; import { createMonitor } from '../utils/omnichannel/monitors'; @@ -40,12 +41,17 @@ test.describe('OC - Manage Units', () => { monitor2 = await createMonitor(api, 'user3'); }); - test.afterAll(async () => { + test.beforeAll(async ({ api }) => { + await setSettingValueById(api, 'Omnichannel_enable_department_removal', true); + }); + + test.afterAll(async ({ api }) => { await department.delete(); await department2.delete(); await monitor.delete(); await monitor2.delete(); await agent.delete(); + await setSettingValueById(api, 'Omnichannel_enable_department_removal', false); }); test.beforeEach(async ({ page }: { page: Page }) => { diff --git a/apps/meteor/tests/end-to-end/api/emoji-custom.ts b/apps/meteor/tests/end-to-end/api/emoji-custom.ts index b256fd4b54551..9e8291e689f2c 100644 --- a/apps/meteor/tests/end-to-end/api/emoji-custom.ts +++ b/apps/meteor/tests/end-to-end/api/emoji-custom.ts @@ -448,7 +448,7 @@ describe('[EmojiCustom]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal('The "emojiId" params is required!'); + expect(res.body.error).to.be.equal("must have required property 'emojiId'"); }) .end(done); }); diff --git a/apps/meteor/tests/end-to-end/api/subscriptions.ts b/apps/meteor/tests/end-to-end/api/subscriptions.ts index a03179569615a..7379e59606aab 100644 --- a/apps/meteor/tests/end-to-end/api/subscriptions.ts +++ b/apps/meteor/tests/end-to-end/api/subscriptions.ts @@ -59,7 +59,7 @@ describe('[Subscriptions]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', "must have required property 'roomId' [invalid-params]"); + expect(res.body).to.have.property('error', "must have required property 'roomId'"); }) .end(done); }); diff --git a/packages/models/src/models/LivechatRooms.ts b/packages/models/src/models/LivechatRooms.ts index d388e7a39fd6e..1e027dc15ed16 100644 --- a/packages/models/src/models/LivechatRooms.ts +++ b/packages/models/src/models/LivechatRooms.ts @@ -77,6 +77,15 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive { key: { servedBy: 1, ts: 1 }, partialFilterExpression: { servedBy: { $exists: true }, t: 'l' } }, { key: { 'v.activity': 1, 'ts': 1 }, partialFilterExpression: { 'v.activity': { $exists: true }, 't': 'l' } }, { key: { contactId: 1 }, partialFilterExpression: { contactId: { $exists: true }, t: 'l' } }, + { + key: { 'v.token': 1 }, + unique: true, + partialFilterExpression: { + t: 'l', + open: true, + _enforceSingleRoom: true, + }, + }, ]; }