diff --git a/.changeset/mean-roses-shout.md b/.changeset/mean-roses-shout.md new file mode 100644 index 0000000000000..377365357fc19 --- /dev/null +++ b/.changeset/mean-roses-shout.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/model-typings": patch +"@rocket.chat/models": patch +--- + +Fixes an issue that prevented all custom fields from being saved when multiple updates were issued on a single call diff --git a/apps/meteor/app/livechat/server/api/v1/customField.ts b/apps/meteor/app/livechat/server/api/v1/customField.ts index c678824d905e1..bb14b2f80a8b7 100644 --- a/apps/meteor/app/livechat/server/api/v1/customField.ts +++ b/apps/meteor/app/livechat/server/api/v1/customField.ts @@ -35,6 +35,7 @@ API.v1.addRoute( throw new Error('invalid-token'); } + // TODO: do on one shot instead of multiple calls const fields = await Promise.all( this.bodyParams.customFields.map( async (customField: { diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index ce9dd76c30d27..de98ef1e720de 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -82,66 +82,59 @@ API.v1.addRoute( ), ); - if (customFields && Array.isArray(customFields) && customFields.length > 0) { - const errors: string[] = []; - const keys = customFields.map((field) => field.key); - - const livechatCustomFields = await LivechatCustomField.findByScope( - 'visitor', - { projection: { _id: 1, required: 1 } }, - false, - ).toArray(); - validateRequiredCustomFields(keys, livechatCustomFields); - - const matchingCustomFields = livechatCustomFields.filter((field: ILivechatCustomField) => keys.includes(field._id)); - const processedKeys = await Promise.all( - matchingCustomFields.map(async (field: ILivechatCustomField) => { - const customField = customFields.find((f) => f.key === field._id); - if (!customField) { - return; - } - - const { key, value, overwrite } = customField; - // TODO: Change this to Bulk update - if (!(await VisitorsRaw.updateLivechatDataByToken(token, key, value, overwrite))) { - errors.push(key); - } - - // TODO deduplicate this code and the one at the function setCustomFields (apps/meteor/app/livechat/server/lib/custom-fields.ts) - const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); - if (contacts.length > 0) { - await Promise.all(contacts.map((contact) => updateContactsCustomFields(contact, key, value, overwrite))); - } - - return key; - }), - ); - - if (processedKeys.length !== keys.length) { - livechatLogger.warn({ - msg: 'Some custom fields were not processed', - visitorId: visitor._id, - missingKeys: keys.filter((key) => !processedKeys.includes(key)), - }); - } - - if (errors.length > 0) { - livechatLogger.error({ - msg: 'Error updating custom fields', - visitorId: visitor._id, - errors, - }); - throw new Error('error-updating-custom-fields'); - } - - return API.v1.success({ visitor: await VisitorsRaw.findOneEnabledById(visitor._id) }); + if (!Array.isArray(customFields) || !customFields.length) { + return API.v1.success({ visitor }); } - if (!visitor) { - throw new Meteor.Error('error-saving-visitor', 'An error ocurred while saving visitor'); + const keys = customFields.map((field) => field.key); + + const livechatCustomFields = await LivechatCustomField.findByScope( + 'visitor', + { projection: { _id: 1, required: 1 } }, + false, + ).toArray(); + validateRequiredCustomFields(keys, livechatCustomFields); + + const matchingCustomFields = livechatCustomFields.filter((field: ILivechatCustomField) => keys.includes(field._id)); + const validCustomFields = customFields.filter((cf) => matchingCustomFields.find((mcf) => cf.key === mcf._id)); + if (!validCustomFields.length) { + return API.v1.success({ visitor }); + } + + const visitorCustomFieldsToUpdate = validCustomFields.reduce( + (prev, curr) => { + if (curr.overwrite) { + prev[`livechatData.${curr.key}`] = curr.value; + return prev; + } + + if (!visitor?.livechatData?.[curr.key]) { + prev[`livechatData.${curr.key}`] = curr.value; + } + + return prev; + }, + {} as Record, + ); + + if (Object.keys(visitorCustomFieldsToUpdate).length) { + await VisitorsRaw.updateAllLivechatDataByToken(visitor.token, visitorCustomFieldsToUpdate); + } + + const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); + if (contacts.length) { + await Promise.all(contacts.map((contact) => updateContactsCustomFields(contact, validCustomFields))); + } + + if (validCustomFields.length !== keys.length) { + livechatLogger.warn({ + msg: 'Some custom fields were not processed', + visitorId: visitor._id, + missingKeys: keys.filter((key) => !validCustomFields.map((v) => v.key).includes(key)), + }); } - return API.v1.success({ visitor }); + return API.v1.success({ visitor: await VisitorsRaw.findOneEnabledById(visitor._id) }); }, }, ); diff --git a/apps/meteor/app/livechat/server/lib/custom-fields.ts b/apps/meteor/app/livechat/server/lib/custom-fields.ts index 07f96b5f4910f..23f1d03bacce9 100644 --- a/apps/meteor/app/livechat/server/lib/custom-fields.ts +++ b/apps/meteor/app/livechat/server/lib/custom-fields.ts @@ -19,23 +19,33 @@ export const validateRequiredCustomFields = (customFields: string[], livechatCus } }; -export async function updateContactsCustomFields(contact: ILivechatContact, key: string, value: string, overwrite: boolean): Promise { - const shouldUpdateCustomFields = overwrite || !contact.customFields || !contact.customFields[key]; +export async function updateContactsCustomFields( + contact: ILivechatContact, + validCustomFields: { + key: string; + value: string; + overwrite: boolean; + }[], +): Promise { + const contactCustomFieldsToUpdate = validCustomFields.reduce( + (prev, curr) => { + if (curr.overwrite || !contact?.customFields?.[curr.key]) { + prev[`customFields.${curr.key}`] = curr.value; + return prev; + } + prev.conflictingFields ??= contact.conflictingFields || []; + prev.conflictingFields.push({ field: `customFields.${curr.key}`, value: curr.value }); + return prev; + }, + {} as Record, + ); - if (shouldUpdateCustomFields) { - contact.customFields ??= {}; - contact.customFields[key] = value; - } else { - contact.conflictingFields ??= []; - contact.conflictingFields.push({ field: `customFields.${key}`, value }); + if (!Object.keys(contactCustomFieldsToUpdate).length) { + return; } - await LivechatContacts.updateContactCustomFields(contact._id, { - ...(shouldUpdateCustomFields && { customFields: contact.customFields }), - ...(contact.conflictingFields && { conflictingFields: contact.conflictingFields }), - }); - - livechatLogger.debug({ msg: `Contact ${contact._id} updated with custom fields` }); + livechatLogger.debug({ msg: 'Updating custom fields for contact', contactId: contact._id, contactCustomFieldsToUpdate }); + await LivechatContacts.updateById(contact._id, { $set: contactCustomFieldsToUpdate }); } export async function setCustomFields({ @@ -73,7 +83,7 @@ export async function setCustomFields({ if (visitor) { const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); if (contacts.length > 0) { - await Promise.all(contacts.map((contact) => updateContactsCustomFields(contact, key, value, overwrite))); + await Promise.all(contacts.map((contact) => updateContactsCustomFields(contact, [{ key, value, overwrite }]))); } } } diff --git a/apps/meteor/tests/end-to-end/api/livechat/03-custom-fields.ts b/apps/meteor/tests/end-to-end/api/livechat/03-custom-fields.ts index ead1054c50f5d..823e66fe13c80 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/03-custom-fields.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/03-custom-fields.ts @@ -1,11 +1,11 @@ -import type { ILivechatVisitor } from '@rocket.chat/core-typings'; +import type { ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { after, before, describe, it } from 'mocha'; import type { Response } from 'supertest'; import { getCredentials, api, request, credentials } from '../../../data/api-data'; import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; -import { createVisitor, deleteVisitor } from '../../../data/livechat/rooms'; +import { closeOmnichannelRoom, createLivechatRoom, createVisitor, deleteVisitor } from '../../../data/livechat/rooms'; import { updatePermission, updateSetting } from '../../../data/permissions.helper'; describe('LIVECHAT - custom fields', () => { @@ -118,6 +118,52 @@ describe('LIVECHAT - custom fields', () => { }); describe('livechat/custom.fields', () => { + const customFieldName = `new_custom_field_${Date.now()}_1`; + const customFieldName2 = `new_custom_field_${Date.now()}_2`; + const customFieldName3 = `new_custom_field_${Date.now()}_3`; + let visitor: ILivechatVisitor; + let visitorRoom: IOmnichannelRoom; + + before(async () => { + await createCustomField({ + searchable: true, + field: customFieldName, + label: customFieldName, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + await createCustomField({ + searchable: true, + field: customFieldName2, + label: customFieldName2, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + await createCustomField({ + searchable: true, + field: customFieldName3, + label: customFieldName3, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + visitor = await createVisitor(); + // start a room for visitor2 + visitorRoom = await createLivechatRoom(visitor.token); + }); + after(async () => { + await Promise.all([ + deleteCustomField(customFieldName), + deleteCustomField(customFieldName2), + deleteCustomField(customFieldName3), + closeOmnichannelRoom(visitorRoom._id), + ]); + }); it('should fail when token is not on body params', async () => { await request.post(api('livechat/custom.fields')).expect(400); }); @@ -163,16 +209,6 @@ describe('LIVECHAT - custom fields', () => { }); it('should save a custom field on visitor', async () => { const visitor = await createVisitor(); - const customFieldName = `new_custom_field_${Date.now()}`; - await createCustomField({ - searchable: true, - field: customFieldName, - label: customFieldName, - defaultValue: 'test_default_address', - scope: 'visitor', - visibility: 'public', - regexp: '', - }); const { body } = await request .post(api('livechat/custom.fields')) @@ -188,6 +224,122 @@ describe('LIVECHAT - custom fields', () => { expect(body.fields).to.have.lengthOf(1); expect(body.fields[0]).to.have.property('value', 'test_address'); }); + it('should save multiple custom fields on a visitor', async () => { + const visitor = await createVisitor(); + + const { body } = await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [ + { key: customFieldName, value: 'test_address', overwrite: true }, + { key: customFieldName2, value: 'test_address2', overwrite: true }, + { key: customFieldName3, value: 'test_address3', overwrite: true }, + ], + }) + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('fields'); + expect(body.fields).to.be.an('array'); + expect(body.fields).to.have.lengthOf(3); + expect(body.fields[0]).to.have.property('value', 'test_address'); + expect(body.fields[1]).to.have.property('value', 'test_address2'); + expect(body.fields[2]).to.have.property('value', 'test_address3'); + }); + it('should save multiple custom fields on contact when visitor already has custom fields and an update with multiple fields is issued', async () => { + const { body } = await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [{ key: customFieldName, value: 'test_address', overwrite: true }], + }) + .expect(200); + + expect(body).to.have.property('success', true); + expect(body).to.have.property('fields'); + expect(body.fields).to.be.an('array'); + expect(body.fields).to.have.lengthOf(1); + expect(body.fields[0]).to.have.property('value', 'test_address'); + + await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [ + { key: customFieldName2, value: 'test_address2', overwrite: true }, + { key: customFieldName3, value: 'test_address3', overwrite: true }, + ], + }) + .expect(200); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: visitorRoom.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(customFieldName, 'test_address'); + expect(res.body.contact.customFields).to.have.property(customFieldName2, 'test_address2'); + expect(res.body.contact.customFields).to.have.property(customFieldName3, 'test_address3'); + }); + }); + it('should mark a conflict on a contact custom fields when overwrite is true and visitor already has the custom field set', async () => { + await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [{ key: customFieldName, value: 'test_address_conflict', overwrite: false }], + }) + .expect(200); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: visitorRoom.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(customFieldName, 'test_address'); + expect(res.body.contact.customFields).to.have.property(customFieldName2, 'test_address2'); + expect(res.body.contact.customFields).to.have.property(customFieldName3, 'test_address3'); + expect(res.body.contact).to.have.property('conflictingFields').that.is.an('array'); + expect(res.body.contact.conflictingFields[0]).to.deep.equal({ + field: `customFields.${customFieldName}`, + value: 'test_address_conflict', + }); + }); + }); + it('should overwrite the contact custom field when overwrite is true', async () => { + await request + .post(api('livechat/custom.fields')) + .send({ + token: visitor.token, + customFields: [{ key: customFieldName2, value: 'test_new_add', overwrite: true }], + }) + .expect(200); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: visitorRoom.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(customFieldName, 'test_address'); + expect(res.body.contact.customFields).to.have.property(customFieldName2, 'test_new_add'); + expect(res.body.contact.customFields).to.have.property(customFieldName3, 'test_address3'); + expect(res.body.contact).to.have.property('conflictingFields').that.is.an('array'); + expect(res.body.contact.conflictingFields[0]).to.deep.equal({ + field: `customFields.${customFieldName}`, + value: 'test_address_conflict', + }); + }); + }); }); describe('livechat/custom.field [with Contacts]', () => { diff --git a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts index 1bba0a170dd9d..484b7ff271355 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/09-visitors.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import type { ILivechatVisitor } from '@rocket.chat/core-typings'; +import type { ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { before, describe, it, after } from 'mocha'; import { type Response } from 'supertest'; @@ -123,6 +123,7 @@ describe('LIVECHAT - visitors', () => { expect(body2.visitor).to.have.property('phone'); expect(body2.visitor.phone[0].phoneNumber).to.equal(phone); }); + it('should update a visitor custom fields when customFields key is provided', async () => { const token = `${new Date().getTime()}-test`; const customFieldName = `new_custom_field_${Date.now()}`; @@ -294,6 +295,232 @@ describe('LIVECHAT - visitors', () => { expect(body.visitor).to.have.property('token', token); }); }); + + describe('visitor & contact custom fields', () => { + let visitor: ILivechatVisitor; + let room: IOmnichannelRoom; + const cf1 = `cf1-${Date.now()}_1`; + const cf2 = `cf2-${Date.now()}_2`; + const cf3 = `cf3-${Date.now()}_3`; + before(async () => { + await createCustomField({ + searchable: true, + field: cf1, + label: cf1, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + await createCustomField({ + searchable: true, + field: cf2, + label: cf2, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + await createCustomField({ + searchable: true, + field: cf3, + label: cf3, + defaultValue: 'test_default_address', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + }); + after(async () => { + await Promise.all([deleteCustomField(cf1), deleteCustomField(cf2), deleteCustomField(cf3)]); + }); + + it('should update custom fields on the contact', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [{ key: cf1, value: 'test', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: room.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(cf1, 'test'); + }); + await closeOmnichannelRoom(room!._id); + }); + it('should update multiple custom fields on a contact after it already has custom fields added', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [{ key: cf1, value: 'test', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + + const { body: body2 } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [ + { key: cf2, value: 'test', overwrite: true }, + { key: cf3, value: 'test', overwrite: false }, + ], + }, + }); + + expect(body2).to.have.property('success', true); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: room.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(cf1, 'test'); + expect(res.body.contact.customFields).to.have.property(cf2, 'test'); + expect(res.body.contact.customFields).to.have.property(cf3, 'test'); + }); + await closeOmnichannelRoom(room!._id); + }); + it('should overwrite a custom field value when the flag is true', async () => { + const visitor = await createVisitor(); + const room = await createLivechatRoom(visitor.token); + + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [{ key: cf1, value: 'test', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + + const { body: body2 } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [ + { key: cf1, value: 'new test', overwrite: true }, + { key: cf3, value: 'test', overwrite: false }, + ], + }, + }); + + expect(body2).to.have.property('success', true); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: room.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(cf1, 'new test'); + expect(res.body.contact.customFields).to.have.property(cf3, 'test'); + }); + await closeOmnichannelRoom(room!._id); + }); + it('should properly conflict a custom field when existing and overwrite is false', async () => { + visitor = await createVisitor(); + room = await createLivechatRoom(visitor.token); + + const { body } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [{ key: cf1, value: 'test', overwrite: true }], + }, + }); + + expect(body).to.have.property('success', true); + + const { body: body2 } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [ + { key: cf1, value: 'new test', overwrite: false }, + { key: cf2, value: 'test', overwrite: true }, + ], + }, + }); + + expect(body2).to.have.property('success', true); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: room.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(cf1, 'test'); + expect(res.body.contact.customFields).to.have.property(cf2, 'test'); + expect(res.body.contact.conflictingFields).to.be.an('array'); + expect(res.body.contact.conflictingFields[0]) + .to.be.an('object') + .that.is.deep.equal({ + field: `customFields.${cf1}`, + value: 'new test', + }); + }); + }); + it('should add more conflicts to a contact custom fields', async () => { + const { body: body2 } = await request.post(api('livechat/visitor')).send({ + visitor: { + token: visitor.token, + customFields: [ + { key: cf1, value: 'new test 2', overwrite: false }, + { key: cf2, value: 'test', overwrite: true }, + ], + }, + }); + + expect(body2).to.have.property('success', true); + + await request + .get(api(`omnichannel/contacts.get`)) + .set(credentials) + .query({ contactId: room.contactId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('contact'); + expect(res.body.contact).to.have.property('customFields'); + expect(res.body.contact.customFields).to.have.property(cf1, 'test'); + expect(res.body.contact.customFields).to.have.property(cf2, 'test'); + expect(res.body.contact.conflictingFields).to.be.an('array').with.lengthOf(2); + expect(res.body.contact.conflictingFields[0]) + .to.be.an('object') + .that.is.deep.equal({ + field: `customFields.${cf1}`, + value: 'new test', + }); + expect(res.body.contact.conflictingFields[1]) + .to.be.an('object') + .that.is.deep.equal({ + field: `customFields.${cf1}`, + value: 'new test 2', + }); + }); + await closeOmnichannelRoom(room._id); + }); + }); }); describe('livechat/visitors.info', () => { diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/custom-fields.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/custom-fields.spec.ts index 89bf138c1b723..4b6c4312146d4 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/custom-fields.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/custom-fields.spec.ts @@ -1,11 +1,10 @@ -import type { ILivechatContact } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; const modelsMock = { LivechatContacts: { - updateContactCustomFields: sinon.stub(), + updateById: sinon.stub(), }, }; @@ -15,47 +14,93 @@ const { updateContactsCustomFields } = proxyquire.noCallThru().load('../../../.. describe('[Custom Fields] updateContactsCustomFields', () => { beforeEach(() => { - modelsMock.LivechatContacts.updateContactCustomFields.reset(); + modelsMock.LivechatContacts.updateById.reset(); }); - it('should not add conflictingFields to the update data when its nullish', async () => { - const contact: Partial = { - _id: 'contactId', - customFields: { - customField: 'value', - }, - }; - - modelsMock.LivechatContacts.updateContactCustomFields.resolves({ ...contact, customFields: { customField: 'newValue' } }); - - await updateContactsCustomFields(contact, 'customField', 'newValue', true); - - expect(modelsMock.LivechatContacts.updateContactCustomFields.calledOnce).to.be.true; - expect(modelsMock.LivechatContacts.updateContactCustomFields.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContactCustomFields.getCall(0).args[1]).to.deep.equal({ - customFields: { customField: 'newValue' }, + it('should do nothing if validCustomFields param is empty', async () => { + const contact = { _id: 'contactId', customFields: {} } as any; + await updateContactsCustomFields(contact, []); + expect(modelsMock.LivechatContacts.updateById.called).to.be.false; + }); + it('should add a custom field from the validCustomFields param', async () => { + const contact = { _id: 'contactId', customFields: {} } as any; + const validCustomFields = [{ key: 'field1', value: 'value1', overwrite: true }]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { 'customFields.field1': 'value1' }, }); }); - - it('should add conflictingFields to the update data only when it is modified', async () => { - const contact: Partial = { - _id: 'contactId', - customFields: { - customField: 'value', - }, - }; - - modelsMock.LivechatContacts.updateContactCustomFields.resolves({ - ...contact, - conflictingFields: [{ field: 'customFields.customField', value: 'newValue' }], + it('should add multiple custom fields from the validCustomFields param', async () => { + const contact = { _id: 'contactId', customFields: {} } as any; + const validCustomFields = [ + { key: 'field1', value: 'value1', overwrite: true }, + { key: 'field2', value: 'value2', overwrite: true }, + ]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { 'customFields.field1': 'value1', 'customFields.field2': 'value2' }, }); - - await updateContactsCustomFields(contact, 'customField', 'newValue', false); - - expect(modelsMock.LivechatContacts.updateContactCustomFields.calledOnce).to.be.true; - expect(modelsMock.LivechatContacts.updateContactCustomFields.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContactCustomFields.getCall(0).args[1]).to.deep.equal({ - conflictingFields: [{ field: 'customFields.customField', value: 'newValue' }], + }); + it('should add custom field to conflictingFields when the contact already has the field and overwrite is false', async () => { + const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any; + const validCustomFields = [{ key: 'field1', value: 'newValue', overwrite: false }]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { conflictingFields: [{ field: 'customFields.field1', value: 'newValue' }] }, + }); + }); + it('should correctly add custom field and conflicting field from validCustomFields array', async () => { + const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any; + const validCustomFields = [ + { key: 'field1', value: 'newValue', overwrite: false }, + { key: 'field2', value: 'value2', overwrite: true }, + ]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { 'customFields.field2': 'value2', 'conflictingFields': [{ field: 'customFields.field1', value: 'newValue' }] }, + }); + }); + it('should overwrite an existing field when field is on validCustomFields array & overwrite is true', async () => { + const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any; + const validCustomFields = [ + { key: 'field1', value: 'newValue', overwrite: true }, + { key: 'field2', value: 'value2', overwrite: true }, + ]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { 'customFields.field1': 'newValue', 'customFields.field2': 'value2' }, + }); + }); + it('should update all custom fields from the validCustomFields array without issues', async () => { + const contact = { _id: 'contactId', customFields: { field1: 'existingValue' } } as any; + const validCustomFields = [ + { key: 'field1', value: 'newValue', overwrite: true }, + { key: 'field2', value: 'value2', overwrite: true }, + { key: 'field3', value: 'value3', overwrite: true }, + { key: 'field4', value: 'value4', overwrite: true }, + { key: 'field5', value: 'value5', overwrite: true }, + ]; + await updateContactsCustomFields(contact, validCustomFields); + expect(modelsMock.LivechatContacts.updateById.calledOnce).to.be.true; + expect(modelsMock.LivechatContacts.updateById.firstCall.args[0]).to.equal('contactId'); + expect(modelsMock.LivechatContacts.updateById.firstCall.args[1]).to.deep.equal({ + $set: { + 'customFields.field1': 'newValue', + 'customFields.field2': 'value2', + 'customFields.field3': 'value3', + 'customFields.field4': 'value4', + 'customFields.field5': 'value5', + }, }); }); }); diff --git a/packages/model-typings/src/models/ILivechatVisitorsModel.ts b/packages/model-typings/src/models/ILivechatVisitorsModel.ts index a29f59d39e9a6..0e39813ebce7c 100644 --- a/packages/model-typings/src/models/ILivechatVisitorsModel.ts +++ b/packages/model-typings/src/models/ILivechatVisitorsModel.ts @@ -42,6 +42,8 @@ export interface ILivechatVisitorsModel extends IBaseModel { removeContactManagerByUsername(manager: string): Promise; + updateAllLivechatDataByToken(token: string, livechatDataToUpdate: Record): Promise; + updateLivechatDataByToken(token: string, key: string, value: unknown, overwrite: boolean): Promise; findOneGuestByEmailAddress(emailAddress: string): Promise; diff --git a/packages/models/src/models/LivechatVisitors.ts b/packages/models/src/models/LivechatVisitors.ts index d30155112b578..58b379bb23be5 100644 --- a/packages/models/src/models/LivechatVisitors.ts +++ b/packages/models/src/models/LivechatVisitors.ts @@ -242,6 +242,10 @@ export class LivechatVisitorsRaw extends BaseRaw implements IL return this.findOne(query); } + updateAllLivechatDataByToken(token: string, livechatDataToUpdate: Record): Promise { + return this.updateOne({ token }, { $set: livechatDataToUpdate }); + } + async updateLivechatDataByToken( token: string, key: string,