From 8d84323cc2d34fcb5fa6fafb746251321c45bd7f Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Tue, 20 Jan 2026 01:04:44 +0530 Subject: [PATCH 01/13] fix: api cannot clear contact manager field Signed-off-by: Abhinav Kumar --- .../livechat/server/lib/contacts/updateContact.ts | 2 +- .../meteor/tests/end-to-end/api/livechat/contacts.ts | 12 ++++++++++++ packages/models/src/models/LivechatContacts.ts | 11 ++++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts index a7389420d4af0..2bb014fd95092 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts @@ -75,7 +75,7 @@ export async function updateContact(params: UpdateContactParams): Promise ({ address })) }), ...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }), - ...(contactManager && { contactManager }), + ...('contactManager' in params && { contactManager }), // A contactManager of empty string or undefined will be unset in the model method ...(channels && { channels }), ...(customFieldsToUpdate && { customFields: customFieldsToUpdate }), ...(wipeConflicts && { conflictingFields: [] }), 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 43ba8f5b67a04..1062387f26cbf 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -508,6 +508,18 @@ describe('LIVECHAT - contacts', () => { expect(res.body.contact.contactManager).to.be.equal(livechatAgent._id); }); + it('should be able to clear the contact manager', async () => { + const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({ + contactId, + contactManager: '', + }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact._id).to.be.equal(contactId); + expect(res.body.contact).to.not.have.property('contactManager'); + }); + it('should return an error if contact does not exist', async () => { const res = await request .post(api('omnichannel/contacts.update')) diff --git a/packages/models/src/models/LivechatContacts.ts b/packages/models/src/models/LivechatContacts.ts index 78c2b3963d43d..b35a7fd8491c6 100644 --- a/packages/models/src/models/LivechatContacts.ts +++ b/packages/models/src/models/LivechatContacts.ts @@ -115,9 +115,18 @@ export class LivechatContactsRaw extends BaseRaw implements IL } async updateContact(contactId: string, data: Partial, options?: FindOneAndUpdateOptions): Promise { + const $set = { ...data, unknown: false, ...(data.channels && { preRegistration: !data.channels.length }) }; + const $unset: Partial> = {}; + + if ('contactManager' in data && !data.contactManager) { + // Here we want to unset the contactManager field if it's provided as an empty string or falsy value + delete $set.contactManager; + $unset.contactManager = ''; + } + const updatedValue = await this.findOneAndUpdate( { _id: contactId, enabled: { $ne: false } }, - { $set: { ...data, unknown: false, ...(data.channels && { preRegistration: !data.channels.length }) } }, + { $set, $unset }, { returnDocument: 'after', ...options }, ); return updatedValue as ILivechatContact; From 28e043eb0c468e500d1f36e29958d31a846fcae4 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Tue, 20 Jan 2026 01:08:50 +0530 Subject: [PATCH 02/13] added changeset Signed-off-by: Abhinav Kumar --- .changeset/thick-ties-hunt.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/thick-ties-hunt.md diff --git a/.changeset/thick-ties-hunt.md b/.changeset/thick-ties-hunt.md new file mode 100644 index 0000000000000..1df3bced1ed5e --- /dev/null +++ b/.changeset/thick-ties-hunt.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixes endpoint `omnichannel/contacts.update` where the contact manager field could not be cleared. From 89e8a709de3561d3bbc242cafba84c030dceaf2c Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 22 Jan 2026 00:08:25 +0530 Subject: [PATCH 03/13] fix: contactManager could not be cleared Signed-off-by: Abhinav Kumar --- .../lib/contacts/resolveContactConflicts.ts | 11 ++++- .../server/lib/contacts/updateContact.ts | 23 ++++++---- .../tests/end-to-end/api/livechat/contacts.ts | 43 +++++++++++++++++++ .../src/models/ILivechatContactsModel.ts | 9 +++- .../models/src/models/LivechatContacts.ts | 34 +++++++++------ 5 files changed, 98 insertions(+), 22 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts index f6d03757531d0..b613c6a32792a 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts @@ -63,5 +63,14 @@ export async function resolveContactConflicts(params: ResolveContactConflictsPar conflictingFields: updatedConflictingFieldsArr, }; - return LivechatContacts.updateContact(contactId, dataToUpdate); + const updatedContact = await LivechatContacts.patchContact(contactId, { + set: dataToUpdate, + unset: 'contactManager' in params && !contactManager ? ['contactManager'] : undefined, + }); + + if (!updatedContact) { + throw new Error('error-contact-not-found'); + } + + return updatedContact; } diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts index 2bb014fd95092..b486af9aedf5c 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts @@ -71,16 +71,23 @@ export async function updateContact(params: UpdateContactParams): Promise ({ address })) }), - ...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }), - ...('contactManager' in params && { contactManager }), // A contactManager of empty string or undefined will be unset in the model method - ...(channels && { channels }), - ...(customFieldsToUpdate && { customFields: customFieldsToUpdate }), - ...(wipeConflicts && { conflictingFields: [] }), + const updatedContact = await LivechatContacts.patchContact(contactId, { + set: { + name, + ...(emails && { emails: emails?.map((address) => ({ address })) }), + ...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }), + ...(contactManager && { contactManager }), + ...(channels && { channels }), + ...(customFieldsToUpdate && { customFields: customFieldsToUpdate }), + ...(wipeConflicts && { conflictingFields: [] }), + }, + unset: 'contactManager' in params && !contactManager ? ['contactManager'] : undefined, }); + if (!updatedContact) { + throw new Error('error-contact-not-found'); + } + // If the contact name changed, update the name of its existing rooms and subscriptions if (name !== undefined && name !== contact.name) { await LivechatRooms.updateContactDataByContactId(contactId, { name }); 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 1062387f26cbf..d1d24782d7a59 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -791,6 +791,49 @@ describe('LIVECHAT - contacts', () => { expect(res.body.result).to.have.property('customFields'); expect(res.body.result.customFields).to.have.property('cf1', '123'); }); + + it('should be able to clear the contact manager when resolving conflicts', async () => { + await request + .post(api('omnichannel/contacts.update')) + .set(credentials) + .send({ + contactId, + contactManager: livechatAgent._id, + }) + .expect(200); + + // Create a conflict + await request.post(api('livechat/custom.field')).send({ token, key: 'cf1', value: '111', overwrite: true }).expect(200); + + await request.post(api('livechat/custom.field')).send({ token, key: 'cf1', value: '222', overwrite: false }).expect(200); + + // Verify the contact has a contact manager and conflicts + const contactBefore = await request.get(api('omnichannel/contacts.get')).set(credentials).query({ contactId }).expect(200); + expect(contactBefore.body.contact).to.have.property('contactManager', livechatAgent._id); + expect(contactBefore.body.contact).to.have.property('conflictingFields'); + expect(contactBefore.body.contact.conflictingFields).to.have.lengthOf.greaterThan(0); + + const res = await request + .post(api('omnichannel/contacts.conflicts')) + .set(credentials) + .send({ + contactId, + contactManager: '', + customFields: { + cf1: '111', + }, + }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('result'); + expect(res.body.result).to.not.have.property('contactManager'); + expect(res.body.result).to.have.property('customFields'); + expect(res.body.result.customFields).to.have.property('cf1', '111'); + + const contactAfter = await request.get(api('omnichannel/contacts.get')).set(credentials).query({ contactId }).expect(200); + expect(contactAfter.body.contact).to.not.have.property('contactManager'); + }); }); describe('Contact Rooms', () => { diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts index 5d6069c03a2d6..2da3bbd514702 100644 --- a/packages/model-typings/src/models/ILivechatContactsModel.ts +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -23,7 +23,14 @@ export interface ILivechatContactsModel extends IBaseModel { insertContact( data: InsertionModel> & { createdAt?: ILivechatContact['createdAt'] }, ): Promise; - updateContact(contactId: string, data: Partial, options?: FindOneAndUpdateOptions): Promise; + patchContact( + contactId: string, + data: { + set?: Partial; + unset?: Array; + }, + options?: FindOneAndUpdateOptions, + ): Promise; updateById(contactId: string, update: UpdateFilter, options?: UpdateOptions): Promise; updateContactCustomFields(contactId: string, data: Partial, options?: UpdateOptions): Promise; addChannel(contactId: string, channel: ILivechatContactChannel): Promise; diff --git a/packages/models/src/models/LivechatContacts.ts b/packages/models/src/models/LivechatContacts.ts index b35a7fd8491c6..3d0f1f7e33fb8 100644 --- a/packages/models/src/models/LivechatContacts.ts +++ b/packages/models/src/models/LivechatContacts.ts @@ -114,22 +114,32 @@ export class LivechatContactsRaw extends BaseRaw implements IL return result.insertedId; } - async updateContact(contactId: string, data: Partial, options?: FindOneAndUpdateOptions): Promise { - const $set = { ...data, unknown: false, ...(data.channels && { preRegistration: !data.channels.length }) }; - const $unset: Partial> = {}; - - if ('contactManager' in data && !data.contactManager) { - // Here we want to unset the contactManager field if it's provided as an empty string or falsy value - delete $set.contactManager; - $unset.contactManager = ''; - } + async patchContact( + contactId: string, + changes: { + set?: Partial; + unset?: Array; + }, + options?: FindOneAndUpdateOptions, + ) { + const { set = {}, unset = [] } = changes; + + const $set = { + ...set, + unknown: false, + ...(set.channels && { preRegistration: !set.channels.length }), + }; - const updatedValue = await this.findOneAndUpdate( + const $unset = unset.reduce((acc, key) => ({ ...acc, [key]: '' }), {}); + + return this.findOneAndUpdate( { _id: contactId, enabled: { $ne: false } }, - { $set, $unset }, + { + $set, + ...(unset.length > 0 && { $unset }), + }, { returnDocument: 'after', ...options }, ); - return updatedValue as ILivechatContact; } updateById(contactId: string, update: UpdateFilter, options?: UpdateOptions): Promise { From 1cfc17cb6a8448374fd4728487dbc232084b35e1 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 22 Jan 2026 00:10:17 +0530 Subject: [PATCH 04/13] Update thick-ties-hunt.md --- .changeset/thick-ties-hunt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/thick-ties-hunt.md b/.changeset/thick-ties-hunt.md index 1df3bced1ed5e..8f62ddd459a58 100644 --- a/.changeset/thick-ties-hunt.md +++ b/.changeset/thick-ties-hunt.md @@ -3,4 +3,4 @@ '@rocket.chat/meteor': patch --- -Fixes endpoint `omnichannel/contacts.update` where the contact manager field could not be cleared. +Fixes endpoint `omnichannel/contacts.update` and `omnichannel/contacts.conflicts` where the contact manager field could not be cleared. From 96b64fa171ee81b5b57baa89601b7249b943fe49 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 22 Jan 2026 00:54:34 +0530 Subject: [PATCH 05/13] fixes Signed-off-by: Abhinav Kumar --- .../app/livechat/server/lib/contacts/resolveContactConflicts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts index b613c6a32792a..5d7ebc85fd21f 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts @@ -46,7 +46,7 @@ export async function resolveContactConflicts(params: ResolveContactConflictsPar const fieldsToRemove = new Set( [ name && 'name', - contactManager && 'manager', + 'contactManager' in params && 'manager', ...(customFields ? Object.keys(customFields).map((key) => `customFields.${key}`) : []), ].filter((field): field is string => !!field), ); From 14c87b6170976db577f4f0a4b7d8c70c9b2eddae Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 22 Jan 2026 00:55:23 +0530 Subject: [PATCH 06/13] fixes Signed-off-by: Abhinav Kumar --- apps/meteor/app/livechat/server/lib/contacts/updateContact.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts index b486af9aedf5c..c89dcd63a62fd 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts @@ -73,7 +73,7 @@ export async function updateContact(params: UpdateContactParams): Promise ({ address })) }), ...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }), ...(contactManager && { contactManager }), From e863058b14282777a031cef0831b6f481d0fdc6e Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 22 Jan 2026 22:54:46 +0530 Subject: [PATCH 07/13] fixes Signed-off-by: Abhinav Kumar --- packages/models/src/models/LivechatContacts.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/models/src/models/LivechatContacts.ts b/packages/models/src/models/LivechatContacts.ts index 3d0f1f7e33fb8..86a86268de1fe 100644 --- a/packages/models/src/models/LivechatContacts.ts +++ b/packages/models/src/models/LivechatContacts.ts @@ -124,6 +124,10 @@ export class LivechatContactsRaw extends BaseRaw implements IL ) { const { set = {}, unset = [] } = changes; + if (Object.keys(set).length === 0 && unset.length === 0) { + return this.findOneById(contactId); + } + const $set = { ...set, unknown: false, From 1d777246fffdf7d79ebaad83444d0dee087caeca Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 22 Jan 2026 22:55:04 +0530 Subject: [PATCH 08/13] fixes Signed-off-by: Abhinav Kumar --- packages/models/src/models/LivechatContacts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/models/src/models/LivechatContacts.ts b/packages/models/src/models/LivechatContacts.ts index 86a86268de1fe..2e6cfccc61c67 100644 --- a/packages/models/src/models/LivechatContacts.ts +++ b/packages/models/src/models/LivechatContacts.ts @@ -125,7 +125,7 @@ export class LivechatContactsRaw extends BaseRaw implements IL const { set = {}, unset = [] } = changes; if (Object.keys(set).length === 0 && unset.length === 0) { - return this.findOneById(contactId); + return this.findOne({ _id: contactId, enabled: { $ne: false } }); } const $set = { From 59044f6b1a72890683ac3b8209c834e2751a2842 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Thu, 22 Jan 2026 22:56:09 +0530 Subject: [PATCH 09/13] Apply suggestion from @cubic-dev-ai[bot] Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- apps/meteor/app/livechat/server/lib/contacts/updateContact.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts index c89dcd63a62fd..cd651e345439c 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts @@ -73,7 +73,7 @@ export async function updateContact(params: UpdateContactParams): Promise ({ address })) }), ...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }), ...(contactManager && { contactManager }), From 7d3ae691e36e499dac94ef144d6c0d56990f1ddc Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Wed, 28 Jan 2026 16:05:54 +0530 Subject: [PATCH 10/13] requested changes Signed-off-by: Abhinav Kumar --- .../server/lib/contacts/patchContact.ts | 14 +++++++++++ .../lib/contacts/resolveContactConflicts.ts | 10 ++++---- .../server/lib/contacts/updateContact.ts | 25 ++++++++++--------- .../models/src/models/LivechatContacts.ts | 4 --- 4 files changed, 32 insertions(+), 21 deletions(-) create mode 100644 apps/meteor/app/livechat/server/lib/contacts/patchContact.ts diff --git a/apps/meteor/app/livechat/server/lib/contacts/patchContact.ts b/apps/meteor/app/livechat/server/lib/contacts/patchContact.ts new file mode 100644 index 0000000000000..0b1f545e3fb59 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/patchContact.ts @@ -0,0 +1,14 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +export const patchContact = async ( + contactId: ILivechatContact['_id'], + data: { set?: Partial; unset?: Array }, +): Promise => { + const { set = {}, unset = [] } = data; + + if (Object.keys(set).length === 0 && unset.length === 0) { + return LivechatContacts.findOneEnabledById(contactId); + } + return LivechatContacts.patchContact(contactId, data); +}; diff --git a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts index 5d7ebc85fd21f..a20599aee2501 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts @@ -1,6 +1,7 @@ import type { ILivechatContact, ILivechatContactConflictingField } from '@rocket.chat/core-typings'; import { LivechatContacts, Settings } from '@rocket.chat/models'; +import { patchContact } from './patchContact'; import { validateContactManager } from './validateContactManager'; import { notifyOnSettingChanged } from '../../../../lib/server/lib/notifyListener'; @@ -56,17 +57,16 @@ export async function resolveContactConflicts(params: ResolveContactConflictsPar ) as ILivechatContactConflictingField[]; } - const dataToUpdate = { + const set = { ...(name && { name }), ...(contactManager && { contactManager }), ...(customFields && { customFields: { ...contact.customFields, ...customFields } }), conflictingFields: updatedConflictingFieldsArr, }; - const updatedContact = await LivechatContacts.patchContact(contactId, { - set: dataToUpdate, - unset: 'contactManager' in params && !contactManager ? ['contactManager'] : undefined, - }); + const unset: (keyof ILivechatContact)[] = 'contactManager' in params && !contactManager ? ['contactManager'] : []; + + const updatedContact = await patchContact(contactId, { set, unset }); if (!updatedContact) { throw new Error('error-contact-not-found'); diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts index cd651e345439c..bd110e6b02453 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts @@ -2,6 +2,7 @@ import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/cor import { LivechatContacts, LivechatInquiry, LivechatRooms, Settings, Subscriptions } from '@rocket.chat/models'; import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { patchContact } from './patchContact'; import { validateContactManager } from './validateContactManager'; import { validateCustomFields } from './validateCustomFields'; import { @@ -71,18 +72,18 @@ export async function updateContact(params: UpdateContactParams): Promise ({ address })) }), - ...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }), - ...(contactManager && { contactManager }), - ...(channels && { channels }), - ...(customFieldsToUpdate && { customFields: customFieldsToUpdate }), - ...(wipeConflicts && { conflictingFields: [] }), - }, - unset: 'contactManager' in params && !contactManager ? ['contactManager'] : undefined, - }); + const set = { + ...(name !== undefined && { name }), + ...(emails && { emails: emails?.map((address) => ({ address })) }), + ...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }), + ...(contactManager && { contactManager }), + ...(channels && { channels }), + ...(customFieldsToUpdate && { customFields: customFieldsToUpdate }), + ...(wipeConflicts && { conflictingFields: [] }), + }; + const unset: (keyof ILivechatContact)[] = 'contactManager' in params && !contactManager ? ['contactManager'] : []; + + const updatedContact = await patchContact(contactId, { set, unset }); if (!updatedContact) { throw new Error('error-contact-not-found'); diff --git a/packages/models/src/models/LivechatContacts.ts b/packages/models/src/models/LivechatContacts.ts index 2e6cfccc61c67..3d0f1f7e33fb8 100644 --- a/packages/models/src/models/LivechatContacts.ts +++ b/packages/models/src/models/LivechatContacts.ts @@ -124,10 +124,6 @@ export class LivechatContactsRaw extends BaseRaw implements IL ) { const { set = {}, unset = [] } = changes; - if (Object.keys(set).length === 0 && unset.length === 0) { - return this.findOne({ _id: contactId, enabled: { $ne: false } }); - } - const $set = { ...set, unknown: false, From 2a05f70b9f6912d69a26adb1ee7936f704ceea3d Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Wed, 28 Jan 2026 18:06:15 +0530 Subject: [PATCH 11/13] added unit tests Signed-off-by: Abhinav Kumar --- .../contacts/resolveContactConflicts.spec.ts | 239 ++++++++++++------ .../server/lib/contacts/updateContact.spec.ts | 31 ++- 2 files changed, 193 insertions(+), 77 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts index 4b527016359bb..05b36e163ac21 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts @@ -6,7 +6,7 @@ import sinon from 'sinon'; const modelsMock = { LivechatContacts: { findOneEnabledById: sinon.stub(), - updateContact: sinon.stub(), + patchContact: sinon.stub(), }, Settings: { incrementValueById: sinon.stub(), @@ -15,18 +15,26 @@ const modelsMock = { const validateContactManagerMock = sinon.stub(); +const { patchContact } = proxyquire.noCallThru().load('./patchContact.ts', { + '@rocket.chat/models': modelsMock, +}); + const { resolveContactConflicts } = proxyquire.noCallThru().load('./resolveContactConflicts', { '@rocket.chat/models': modelsMock, './validateContactManager': { validateContactManager: validateContactManagerMock, }, + './patchContact.ts': { + patchContact, + }, }); describe('resolveContactConflicts', () => { beforeEach(() => { modelsMock.LivechatContacts.findOneEnabledById.reset(); modelsMock.Settings.incrementValueById.reset(); - modelsMock.LivechatContacts.updateContact.reset(); + modelsMock.LivechatContacts.patchContact.reset(); + validateContactManagerMock.reset(); }); it('should update the contact with the resolved custom field', async () => { @@ -36,14 +44,22 @@ describe('resolveContactConflicts', () => { conflictingFields: [{ field: 'customFields.customField', value: 'oldValue' }], }); modelsMock.Settings.incrementValueById.resolves(1); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + customFields: { customField: 'newestValue' }, + conflictingFields: [], + } as Partial); await resolveContactConflicts({ contactId: 'contactId', customFields: { customField: 'newestValue' } }); expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ - customFields: { customField: 'newestValue' }, + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { + customFields: { customField: 'newestValue' }, + conflictingFields: [], + }, }); }); @@ -55,13 +71,24 @@ describe('resolveContactConflicts', () => { conflictingFields: [{ field: 'name', value: 'Old Name' }], }); modelsMock.Settings.incrementValueById.resolves(1); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [], + } as Partial); await resolveContactConflicts({ contactId: 'contactId', name: 'New Name' }); expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'New Name' }); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { + name: 'New Name', + conflictingFields: [], + }, + }); }); it('should update the contact with the resolved contact manager', async () => { @@ -72,97 +99,115 @@ describe('resolveContactConflicts', () => { customFields: { customField: 'value' }, conflictingFields: [{ field: 'manager', value: 'oldManagerId' }], }); + validateContactManagerMock.resolves(); + modelsMock.Settings.incrementValueById.resolves(1); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'newContactManagerId', + customFields: { customField: 'value' }, + conflictingFields: [], + } as Partial); - await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', customFields: { manager: 'newContactManagerId' } }); + await resolveContactConflicts({ contactId: 'contactId', contactManager: 'newContactManagerId' }); expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); + expect(validateContactManagerMock.getCall(0).args[0]).to.be.equal('newContactManagerId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ - customFields: { customField: 'value', manager: 'newContactManagerId' }, + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { + contactManager: 'newContactManagerId', + conflictingFields: [], + }, }); }); - it('should wipe conflicts if wipeConflicts = true', async () => { - it('should update the contact with the resolved name', async () => { - modelsMock.LivechatContacts.findOneEnabledById.resolves({ - _id: 'contactId', - name: 'Name', - customFields: { customField: 'newValue' }, - conflictingFields: [ - { field: 'name', value: 'NameTest' }, - { field: 'customFields.customField', value: 'value' }, - ], - }); - modelsMock.Settings.incrementValueById.resolves(2); - modelsMock.LivechatContacts.updateContact.resolves({ - _id: 'contactId', - name: 'New Name', - customField: { customField: 'newValue' }, - conflictingFields: [], - } as Partial); + it('should wipe all conflicts if wipeConflicts = true', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'newValue' }, + conflictingFields: [ + { field: 'name', value: 'NameTest' }, + { field: 'customFields.customField', value: 'value' }, + ], + }); + modelsMock.Settings.incrementValueById.resolves({ _id: 'Resolved_Conflicts_Count', value: 2 }); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [], + } as Partial); - const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: true }); + const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: true }); - expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Livechat_conflicting_fields_counter'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(2); + expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Resolved_Conflicts_Count'); + expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(2); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'New Name' }); - expect(result).to.be.deep.equal({ - _id: 'contactId', + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { name: 'New Name', - customField: { customField: 'newValue' }, conflictingFields: [], - }); + }, + }); + expect(result).to.be.deep.equal({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [], }); }); - it('should wipe conflicts if wipeConflicts = true', async () => { - it('should update the contact with the resolved name', async () => { - modelsMock.LivechatContacts.findOneEnabledById.resolves({ - _id: 'contactId', - name: 'Name', - customFields: { customField: 'newValue' }, - conflictingFields: [ - { field: 'name', value: 'NameTest' }, - { field: 'customFields.customField', value: 'value' }, - ], - }); - modelsMock.Settings.incrementValueById.resolves(2); - modelsMock.LivechatContacts.updateContact.resolves({ - _id: 'contactId', - name: 'New Name', - customField: { customField: 'newValue' }, - conflictingFields: [], - } as Partial); + it('should only resolve specified conflicts when wipeConflicts = false', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'newValue' }, + conflictingFields: [ + { field: 'name', value: 'NameTest' }, + { field: 'customFields.customField', value: 'value' }, + ], + }); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [{ field: 'customFields.customField', value: 'value' }], + } as Partial); - const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: false }); + const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: false }); - expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Livechat_conflicting_fields_counter'); - expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(1); + // When wipeConflicts is false, incrementValueById should NOT be called + expect(modelsMock.Settings.incrementValueById.called).to.be.false; - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'New Name' }); - expect(result).to.be.deep.equal({ - _id: 'contactId', + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ + set: { name: 'New Name', - customField: { customField: 'newValue' }, conflictingFields: [{ field: 'customFields.customField', value: 'value' }], - }); + }, + }); + expect(result).to.be.deep.equal({ + _id: 'contactId', + name: 'New Name', + customFields: { customField: 'newValue' }, + conflictingFields: [{ field: 'customFields.customField', value: 'value' }], }); }); it('should throw an error if the contact does not exist', async () => { modelsMock.LivechatContacts.findOneEnabledById.resolves(undefined); - await expect(resolveContactConflicts({ contactId: 'id', customField: { customField: 'newValue' } })).to.be.rejectedWith( + await expect(resolveContactConflicts({ contactId: 'id', customFields: { customField: 'newValue' } })).to.be.rejectedWith( 'error-contact-not-found', ); - expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + expect(modelsMock.LivechatContacts.patchContact.called).to.be.false; }); it('should throw an error if the contact has no conflicting fields', async () => { @@ -173,9 +218,61 @@ describe('resolveContactConflicts', () => { customFields: { customField: 'value' }, conflictingFields: [], }); - await expect(resolveContactConflicts({ contactId: 'id', customField: { customField: 'newValue' } })).to.be.rejectedWith( + await expect(resolveContactConflicts({ contactId: 'id', customFields: { customField: 'newValue' } })).to.be.rejectedWith( 'error-contact-has-no-conflicts', ); - expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + expect(modelsMock.LivechatContacts.patchContact.called).to.be.false; + }); + + it('should unset contactManager when explicitly set to empty string', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'oldManagerId', + customFields: { customField: 'value' }, + conflictingFields: [{ field: 'manager', value: 'oldManagerId' }], + }); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'value' }, + conflictingFields: [], + } as Partial); + + await resolveContactConflicts({ contactId: 'contactId', contactManager: '' }); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.deep.include({ + set: { + conflictingFields: [], + }, + unset: ['contactManager'], + }); + expect(validateContactManagerMock.called).to.be.false; + }); + + it('should unset contactManager when explicitly set to undefined', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'oldManagerId', + customFields: { customField: 'value' }, + conflictingFields: [{ field: 'manager', value: 'oldManagerId' }], + }); + modelsMock.LivechatContacts.patchContact.resolves({ + _id: 'contactId', + name: 'Name', + customFields: { customField: 'value' }, + conflictingFields: [], + } as Partial); + + await resolveContactConflicts({ contactId: 'contactId', contactManager: undefined }); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.deep.include({ + set: { + conflictingFields: [], + }, + unset: ['contactManager'], + }); + expect(validateContactManagerMock.called).to.be.false; }); }); diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts index fafc98fd355e1..8b660cd0fb5c6 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts @@ -5,13 +5,17 @@ import sinon from 'sinon'; const modelsMock = { LivechatContacts: { findOneEnabledById: sinon.stub(), - updateContact: sinon.stub(), + patchContact: sinon.stub(), }, LivechatRooms: { updateContactDataByContactId: sinon.stub(), }, }; +const { patchContact } = proxyquire.noCallThru().load('./patchContact.ts', { + '@rocket.chat/models': modelsMock, +}); + const { updateContact } = proxyquire.noCallThru().load('./updateContact', { './getAllowedCustomFields': { getAllowedCustomFields: sinon.stub().resolves([]), @@ -24,29 +28,44 @@ const { updateContact } = proxyquire.noCallThru().load('./updateContact', { }, '@rocket.chat/models': modelsMock, + + './patchContact.ts': { + patchContact, + }, }); describe('updateContact', () => { beforeEach(() => { modelsMock.LivechatContacts.findOneEnabledById.reset(); - modelsMock.LivechatContacts.updateContact.reset(); + modelsMock.LivechatContacts.patchContact.reset(); modelsMock.LivechatRooms.updateContactDataByContactId.reset(); }); it('should throw an error if the contact does not exist', async () => { modelsMock.LivechatContacts.findOneEnabledById.resolves(undefined); await expect(updateContact('any_id')).to.be.rejectedWith('error-contact-not-found'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + expect(modelsMock.LivechatContacts.patchContact.getCall(0)).to.be.null; }); it('should update the contact with correct params', async () => { modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId' }); - modelsMock.LivechatContacts.updateContact.resolves({ _id: 'contactId', name: 'John Doe' } as any); + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId', name: 'John Doe' } as any); const updatedContact = await updateContact({ contactId: 'contactId', name: 'John Doe' }); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'John Doe' }); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ set: { name: 'John Doe' } }); expect(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' }); }); + + it('should be able to clear the contact manager', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', contactManager: 'manager' }); + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId' } as any); + + const updatedContact = await updateContact({ contactId: 'contactId', contactManager: null }); + + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ unset: ['contactManager'] }); + expect(updatedContact).to.be.deep.equal({ _id: 'contactId' }); + }); }); From 8a7f77186c12c317b60629e420553aecb32ab378 Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Wed, 28 Jan 2026 22:07:26 +0530 Subject: [PATCH 12/13] minor changes Signed-off-by: Abhinav Kumar --- .../server/lib/contacts/resolveContactConflicts.spec.ts | 2 +- .../app/livechat/server/lib/contacts/updateContact.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts index 05b36e163ac21..d4eda66d0f744 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts @@ -24,7 +24,7 @@ const { resolveContactConflicts } = proxyquire.noCallThru().load('./resolveConta './validateContactManager': { validateContactManager: validateContactManagerMock, }, - './patchContact.ts': { + './patchContact': { patchContact, }, }); diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts index 8b660cd0fb5c6..df43a75c47991 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts @@ -29,7 +29,7 @@ const { updateContact } = proxyquire.noCallThru().load('./updateContact', { '@rocket.chat/models': modelsMock, - './patchContact.ts': { + './patchContact': { patchContact, }, }); From acd1f78ee4c9c9853b927530a2b1f810a60040ba Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Wed, 28 Jan 2026 22:10:03 +0530 Subject: [PATCH 13/13] minor changes in tests Signed-off-by: Abhinav Kumar --- .../server/lib/contacts/updateContact.spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts index df43a75c47991..3d2bcb7cc5dbc 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.spec.ts @@ -58,14 +58,24 @@ describe('updateContact', () => { expect(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' }); }); - it('should be able to clear the contact manager', async () => { + it('should be able to clear the contact manager when passing an empty string for contactManager', async () => { modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', contactManager: 'manager' }); modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId' } as any); - const updatedContact = await updateContact({ contactId: 'contactId', contactManager: null }); + const updatedContact = await updateContact({ contactId: 'contactId', contactManager: '' }); expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ unset: ['contactManager'] }); expect(updatedContact).to.be.deep.equal({ _id: 'contactId' }); }); + + it('should be able to clear the contact manager when passing undefined for contactManager', async () => { + modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', contactManager: 'manager' }); + modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId' } as any); + + const updatedContact = await updateContact({ contactId: 'contactId', contactManager: undefined }); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ unset: ['contactManager'] }); + expect(updatedContact).to.be.deep.equal({ _id: 'contactId' }); + }); });