diff --git a/apps/meteor/app/lib/server/lib/notifyListener.ts b/apps/meteor/app/lib/server/lib/notifyListener.ts index 778fe89dbbf4a..6a28fabe5c512 100644 --- a/apps/meteor/app/lib/server/lib/notifyListener.ts +++ b/apps/meteor/app/lib/server/lib/notifyListener.ts @@ -21,9 +21,11 @@ import type { IMessage, SettingValue, MessageTypesValues, + ILivechatContact, } from '@rocket.chat/core-typings'; import { Rooms, + LivechatRooms, Permissions, Settings, PbxEvents, @@ -87,6 +89,16 @@ export const notifyOnRoomChangedByUsernamesOrUids = withDbWatcherCheck( }, ); +export const notifyOnRoomChangedByContactId = withDbWatcherCheck( + async (contactId: T['_id'], clientAction: ClientAction = 'updated'): Promise => { + const cursor = LivechatRooms.findOpenByContactId(contactId); + + void cursor.forEach((room) => { + void api.broadcast('watch.rooms', { clientAction, room }); + }); + }, +); + export const notifyOnRoomChangedByUserDM = withDbWatcherCheck( async (userId: T['u']['_id'], clientAction: ClientAction = 'updated'): Promise => { const items = Rooms.findDMsByUids([userId]); @@ -251,6 +263,20 @@ export const notifyOnLivechatInquiryChangedById = withDbWatcherCheck( }, ); +export const notifyOnLivechatInquiryChangedByVisitorIds = withDbWatcherCheck( + async ( + visitorIds: ILivechatInquiryRecord['v']['_id'][], + clientAction: Exclude = 'updated', + diff?: Partial & { queuedAt: Date; takenAt: Date }>, + ): Promise => { + const cursor = LivechatInquiry.findByVisitorIds(visitorIds); + + void cursor.forEach((inquiry) => { + void api.broadcast('watch.inquiries', { clientAction, inquiry, diff }); + }); + }, +); + export const notifyOnLivechatInquiryChangedByRoom = withDbWatcherCheck( async ( rid: ILivechatInquiryRecord['rid'], @@ -553,6 +579,19 @@ export const notifyOnSubscriptionChangedByUserIdAndRoomType = withDbWatcherCheck }, ); +export const notifyOnSubscriptionChangedByVisitorIds = withDbWatcherCheck( + async ( + visitorIds: Exclude['_id'][], + clientAction: Exclude = 'updated', + ): Promise => { + const cursor = Subscriptions.findOpenByVisitorIds(visitorIds, { projection: subscriptionFields }); + + void cursor.forEach((subscription) => { + void api.broadcast('watch.subscriptions', { clientAction, subscription }); + }); + }, +); + export const notifyOnSubscriptionChangedByNameAndRoomType = withDbWatcherCheck( async (filter: Partial>, clientAction: Exclude = 'updated'): Promise => { const cursor = Subscriptions.findByNameAndRoomType(filter, { projection: subscriptionFields }); diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts index 0c73bb3fda2bb..8ec929ae4b34b 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts @@ -1,9 +1,14 @@ import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings'; -import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; +import { LivechatContacts, LivechatInquiry, LivechatRooms, Subscriptions } from '@rocket.chat/models'; import { getAllowedCustomFields } from './getAllowedCustomFields'; import { validateContactManager } from './validateContactManager'; import { validateCustomFields } from './validateCustomFields'; +import { + notifyOnSubscriptionChangedByVisitorIds, + notifyOnRoomChangedByContactId, + notifyOnLivechatInquiryChangedByVisitorIds, +} from '../../../../lib/server/lib/notifyListener'; export type UpdateContactParams = { contactId: string; @@ -43,9 +48,19 @@ export async function updateContact(params: UpdateContactParams): Promise channel.visitor.visitorId); + if (visitorIds?.length) { + await Subscriptions.updateNameAndFnameByVisitorIds(visitorIds, name); + void notifyOnSubscriptionChangedByVisitorIds(visitorIds); + + await LivechatInquiry.updateNameByVisitorIds(visitorIds, name); + void notifyOnLivechatInquiryChangedByVisitorIds(visitorIds, 'updated', { name }); + } } return updatedContact; diff --git a/apps/meteor/server/models/raw/LivechatInquiry.ts b/apps/meteor/server/models/raw/LivechatInquiry.ts index 8d8f5172d26f8..53239d97df6ca 100644 --- a/apps/meteor/server/models/raw/LivechatInquiry.ts +++ b/apps/meteor/server/models/raw/LivechatInquiry.ts @@ -102,6 +102,7 @@ export class LivechatInquiryRaw extends BaseRaw implemen }, sparse: true, }, + { key: { 'v._id': 1 } }, ]; } @@ -456,4 +457,18 @@ export class LivechatInquiryRaw extends BaseRaw implemen async markInquiryActiveForPeriod(rid: ILivechatInquiryRecord['rid'], period: string): Promise { return this.findOneAndUpdate({ rid }, { $addToSet: { 'v.activity': period } }); } + + updateNameByVisitorIds(visitorIds: string[], name: string): Promise { + const query = { 'v._id': { $in: visitorIds } }; + + const update = { + $set: { name }, + }; + + return this.updateMany(query, update); + } + + findByVisitorIds(visitorIds: string[], options?: FindOptions): FindCursor { + return this.find({ 'v._id': { $in: visitorIds } }, options); + } } diff --git a/apps/meteor/server/models/raw/LivechatRooms.ts b/apps/meteor/server/models/raw/LivechatRooms.ts index f31f572b61ad9..25452f9127d56 100644 --- a/apps/meteor/server/models/raw/LivechatRooms.ts +++ b/apps/meteor/server/models/raw/LivechatRooms.ts @@ -80,6 +80,7 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive { key: { 'tags.0': 1, 'ts': 1 }, partialFilterExpression: { 'tags.0': { $exists: true }, 't': 'l' } }, { 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' } }, ]; } @@ -2816,4 +2817,8 @@ export class LivechatRoomsRaw extends BaseRaw implements ILive }): FindPaginated> { throw new Error('Method not implemented.'); } + + findOpenByContactId(contactId: ILivechatContact['_id'], options?: FindOptions): FindCursor { + return this.find({ open: true, contactId }, options); + } } diff --git a/apps/meteor/server/models/raw/Subscriptions.ts b/apps/meteor/server/models/raw/Subscriptions.ts index edcae8c22107e..e9e0a1704eba2 100644 --- a/apps/meteor/server/models/raw/Subscriptions.ts +++ b/apps/meteor/server/models/raw/Subscriptions.ts @@ -66,6 +66,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri { key: { 'u._id': 1, 'open': 1, 'department': 1 } }, { key: { rid: 1, ls: 1 } }, { key: { 'u._id': 1, 'autotranslate': 1 } }, + { key: { 'v._id': 1, 'open': 1 } }, ]; } @@ -341,6 +342,15 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.find(query, options || {}); } + findOpenByVisitorIds(visitorIds: string[], options?: FindOptions): FindCursor { + const query = { + 'open': true, + 'v._id': { $in: visitorIds }, + }; + + return this.find(query, options || {}); + } + findByRoomIdAndNotAlertOrOpenExcludingUserIds( { roomId, @@ -594,6 +604,19 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateMany(query, update); } + updateNameAndFnameByVisitorIds(visitorIds: string[], name: string): Promise { + const query = { 'v._id': { $in: visitorIds } }; + + const update = { + $set: { + name, + fname: name, + }, + }; + + return this.updateMany(query, update); + } + async setGroupE2EKeyAndOldRoomKeys(_id: string, key: string, oldRoomKeys?: ISubscription['oldRoomKeys']): Promise { const query = { _id }; const update = { $set: { E2EKey: key, ...(oldRoomKeys && { oldRoomKeys }) } }; diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-info.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-info.spec.ts index 76b8ac386ef78..7d06da790bcf6 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-info.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-info.spec.ts @@ -4,13 +4,14 @@ import { createFakeVisitor } from '../../mocks/data'; import { createAuxContext } from '../fixtures/createAuxContext'; import { Users } from '../fixtures/userStates'; import { OmnichannelLiveChat, HomeChannel } from '../page-objects'; -import { test } from '../utils/test'; +import { OmnichannelContacts } from '../page-objects/omnichannel-contacts-list'; +import { expect, test } from '../utils/test'; test.describe('Omnichannel contact info', () => { let poLiveChat: OmnichannelLiveChat; let newVisitor: { email: string; name: string }; - let agent: { page: Page; poHomeChannel: HomeChannel }; + let agent: { page: Page; poHomeChannel: HomeChannel; poContacts: OmnichannelContacts }; test.beforeAll(async ({ api, browser }) => { newVisitor = createFakeVisitor(); @@ -20,7 +21,7 @@ test.describe('Omnichannel contact info', () => { await api.post('/livechat/users/manager', { username: 'user1' }); const { page } = await createAuxContext(browser, Users.user1); - agent = { page, poHomeChannel: new HomeChannel(page) }; + agent = { page, poHomeChannel: new HomeChannel(page), poContacts: new OmnichannelContacts(page) }; }); test.beforeEach(async ({ page, api }) => { poLiveChat = new OmnichannelLiveChat(page, api); @@ -45,9 +46,16 @@ test.describe('Omnichannel contact info', () => { await agent.poHomeChannel.sidenav.openChat(newVisitor.name); }); - await test.step('Expect to be see contact information and edit', async () => { + await test.step('Expect to be able to see contact information and edit', async () => { await agent.poHomeChannel.content.btnContactInformation.click(); await agent.poHomeChannel.content.btnContactEdit.click(); }); + + await test.step('Expect to update room name and subscription when updating contact name', async () => { + await agent.poContacts.newContact.inputName.fill('Edited Contact Name'); + await agent.poContacts.newContact.btnSave.click(); + await expect(agent.poHomeChannel.sidenav.sidebarChannelsList.getByText('Edited Contact Name')).toBeVisible(); + await expect(agent.poHomeChannel.content.channelHeader.getByText('Edited Contact Name')).toBeVisible(); + }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts index ccb2d98ded884..daaffb1f9eb31 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -1,4 +1,5 @@ import { faker } from '@faker-js/faker'; +import type { Credentials } from '@rocket.chat/api-client'; import type { ILivechatAgent, ILivechatVisitor, @@ -18,9 +19,11 @@ import { createLivechatRoomWidget, createVisitor, deleteVisitor, + fetchInquiry, getLivechatRoomInfo, + startANewLivechatRoomAndTakeIt, } from '../../../data/livechat/rooms'; -import { removeAgent } from '../../../data/livechat/users'; +import { createAnOnlineAgent, removeAgent } from '../../../data/livechat/users'; import { removePermissionFromAllRoles, restorePermissionToRoles, updatePermission, updateSetting } from '../../../data/permissions.helper'; import { createUser, deleteUser } from '../../../data/users.helper'; import { expectInvalidParams } from '../../../data/validation.helper'; @@ -595,12 +598,16 @@ describe('LIVECHAT - contacts', () => { }); describe('Contact Rooms', () => { + let agent: { credentials: Credentials; user: IUser & { username: string } }; + before(async () => { await updatePermission('view-livechat-contact', ['admin']); + agent = await createAnOnlineAgent(); }); after(async () => { await restorePermissionToRoles('view-livechat-contact'); + await deleteUser(agent.user); }); it('should create a contact and assign it to the room', async () => { @@ -651,6 +658,53 @@ describe('LIVECHAT - contacts', () => { expect(sameRoom._id).to.be.equal(room._id); expect(sameRoom.fname).to.be.equal('New Contact Name'); }); + + it('should update room subscriptions when a contact name changes', async () => { + const response = await startANewLivechatRoomAndTakeIt({ agent: agent.credentials }); + const { room, visitor } = response; + const newName = faker.person.fullName(); + + expect(room).to.have.property('contactId').that.is.a('string'); + expect(room.fname).to.be.equal(visitor.name); + + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId: room.contactId, + name: newName, + }); + + expect(res.status).to.be.equal(200); + + const sameRoom = await createLivechatRoom(visitor.token, { rid: room._id }); + expect(sameRoom._id).to.be.equal(room._id); + expect(sameRoom.fname).to.be.equal(newName); + + const subscriptionResponse = await request + .get(api('subscriptions.getOne')) + .set(agent.credentials) + .query({ roomId: room._id }) + .expect('Content-Type', 'application/json'); + const { subscription } = subscriptionResponse.body; + expect(subscription).to.have.property('v').that.is.an('object'); + expect(subscription.v).to.have.property('_id', visitor._id); + expect(subscription).to.have.property('name', newName); + expect(subscription).to.have.property('fname', newName); + }); + + it('should update inquiry when a contact name changes', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + expect(room).to.have.property('contactId').that.is.a('string'); + expect(room.fname).to.not.be.equal('New Contact Name'); + + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId: room.contactId, + name: 'Edited Contact Name Inquiry', + }); + expect(res.status).to.be.equal(200); + + const roomInquiry = await fetchInquiry(room._id); + expect(roomInquiry).to.have.property('name', 'Edited Contact Name Inquiry'); + }); }); describe('[GET] omnichannel/contacts.get', () => { diff --git a/packages/model-typings/src/models/ILivechatInquiryModel.ts b/packages/model-typings/src/models/ILivechatInquiryModel.ts index 96a23c25d00bd..e44ff37b6ed43 100644 --- a/packages/model-typings/src/models/ILivechatInquiryModel.ts +++ b/packages/model-typings/src/models/ILivechatInquiryModel.ts @@ -48,4 +48,6 @@ export interface ILivechatInquiryModel extends IBaseModel; findIdsByVisitorToken(token: ILivechatInquiryRecord['v']['token']): FindCursor; setStatusById(inquiryId: string, status: LivechatInquiryStatus): Promise; + updateNameByVisitorIds(visitorIds: string[], name: string): Promise; + findByVisitorIds(visitorIds: string[], options?: FindOptions): FindCursor; } diff --git a/packages/model-typings/src/models/ILivechatRoomsModel.ts b/packages/model-typings/src/models/ILivechatRoomsModel.ts index efd5abb99ef77..7ba6f9e74a3b3 100644 --- a/packages/model-typings/src/models/ILivechatRoomsModel.ts +++ b/packages/model-typings/src/models/ILivechatRoomsModel.ts @@ -286,4 +286,5 @@ export interface ILivechatRoomsModel extends IBaseModel { oldContactId: ILivechatContact['_id'], contact: Partial>, ): Promise; + findOpenByContactId(contactId: ILivechatContact['_id'], options?: FindOptions): FindCursor; } diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index dbbba1b192720..e17831d282d18 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -76,6 +76,8 @@ export interface ISubscriptionsModel extends IBaseModel { findByUserIdAndTypes(userId: string, types: ISubscription['t'][], options?: FindOptions): FindCursor; + findOpenByVisitorIds(visitorIds: string[], options?: FindOptions): FindCursor; + findByRoomIdAndNotAlertOrOpenExcludingUserIds( filter: { roomId: ISubscription['rid']; @@ -114,6 +116,8 @@ export interface ISubscriptionsModel extends IBaseModel { updateNameAndFnameByRoomId(roomId: string, name: string, fname: string): Promise; + updateNameAndFnameByVisitorIds(visitorIds: string[], name: string): Promise; + setGroupE2EKey(_id: string, key: string): Promise; setGroupE2EKeyAndOldRoomKeys(_id: string, key: string, oldRoomKeys: ISubscription['oldRoomKeys']): Promise;