diff --git a/.changeset/thick-ties-hunt.md b/.changeset/thick-ties-hunt.md new file mode 100644 index 0000000000000..8f62ddd459a58 --- /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` and `omnichannel/contacts.conflicts` where the contact manager field could not be cleared. 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.spec.ts b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts index 4b527016359bb..d4eda66d0f744 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': { + 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/resolveContactConflicts.ts b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts index f6d03757531d0..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'; @@ -46,7 +47,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), ); @@ -56,12 +57,20 @@ export async function resolveContactConflicts(params: ResolveContactConflictsPar ) as ILivechatContactConflictingField[]; } - const dataToUpdate = { + const set = { ...(name && { name }), ...(contactManager && { contactManager }), ...(customFields && { customFields: { ...contact.customFields, ...customFields } }), conflictingFields: updatedConflictingFieldsArr, }; - return LivechatContacts.updateContact(contactId, dataToUpdate); + 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'); + } + + return updatedContact; } 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..3d2bcb7cc5dbc 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,54 @@ const { updateContact } = proxyquire.noCallThru().load('./updateContact', { }, '@rocket.chat/models': modelsMock, + + './patchContact': { + 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 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: '' }); + + 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' }); + }); }); diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts index a7389420d4af0..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,15 +72,22 @@ export async function updateContact(params: UpdateContactParams): Promise ({ 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'); + } // If the contact name changed, update the name of its existing rooms and subscriptions if (name !== undefined && name !== contact.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 43ba8f5b67a04..d1d24782d7a59 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')) @@ -779,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 78c2b3963d43d..3d0f1f7e33fb8 100644 --- a/packages/models/src/models/LivechatContacts.ts +++ b/packages/models/src/models/LivechatContacts.ts @@ -114,13 +114,32 @@ export class LivechatContactsRaw extends BaseRaw implements IL return result.insertedId; } - async updateContact(contactId: string, data: Partial, options?: FindOneAndUpdateOptions): Promise { - const updatedValue = await this.findOneAndUpdate( + 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 $unset = unset.reduce((acc, key) => ({ ...acc, [key]: '' }), {}); + + return this.findOneAndUpdate( { _id: contactId, enabled: { $ne: false } }, - { $set: { ...data, unknown: false, ...(data.channels && { preRegistration: !data.channels.length }) } }, + { + $set, + ...(unset.length > 0 && { $unset }), + }, { returnDocument: 'after', ...options }, ); - return updatedValue as ILivechatContact; } updateById(contactId: string, update: UpdateFilter, options?: UpdateOptions): Promise {