diff --git a/.changeset/wise-jokes-deliver.md b/.changeset/wise-jokes-deliver.md new file mode 100644 index 0000000000000..6a36bbe448c90 --- /dev/null +++ b/.changeset/wise-jokes-deliver.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/rest-typings": patch +--- + +Adds new endpoint to handle contact's conflicting data diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 4ce64bf2c6242..09218d13e28e6 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -7,6 +7,7 @@ import { isGETOmnichannelContactsChannelsProps, isGETOmnichannelContactsSearchProps, isGETOmnichannelContactsCheckExistenceProps, + isPOSTOmnichannelContactsConflictsProps, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { removeEmpty } from '@rocket.chat/tools'; @@ -20,6 +21,7 @@ import { getContactChannelsGrouped } from '../../lib/contacts/getContactChannels import { getContactHistory } from '../../lib/contacts/getContactHistory'; import { getContacts } from '../../lib/contacts/getContacts'; import { registerContact } from '../../lib/contacts/registerContact'; +import { resolveContactConflicts } from '../../lib/contacts/resolveContactConflicts'; import { updateContact } from '../../lib/contacts/updateContact'; API.v1.addRoute( @@ -129,6 +131,18 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'omnichannel/contacts.conflicts', + { authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTOmnichannelContactsConflictsProps }, + { + async post() { + const result = await resolveContactConflicts(removeEmpty(this.bodyParams)); + + return API.v1.success({ result }); + }, + }, +); + API.v1.addRoute( 'omnichannel/contacts.get', { authRequired: true, permissionsRequired: ['view-livechat-contact'], validateParams: isGETOmnichannelContactsProps }, diff --git a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts new file mode 100644 index 0000000000000..245d42801f7b3 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.spec.ts @@ -0,0 +1,239 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findOneById: sinon.stub(), + updateContact: sinon.stub(), + }, + Settings: { + incrementValueById: sinon.stub(), + }, +}; + +const validateContactManagerMock = sinon.stub(); + +const { resolveContactConflicts } = proxyquire.noCallThru().load('./resolveContactConflicts', { + '@rocket.chat/models': modelsMock, + './validateContactManager': { + validateContactManager: validateContactManagerMock, + }, +}); + +describe('resolveContactConflicts', () => { + beforeEach(() => { + modelsMock.LivechatContacts.findOneById.reset(); + modelsMock.Settings.incrementValueById.reset(); + modelsMock.LivechatContacts.updateContact.reset(); + }); + + it('should update the contact with the resolved custom field', async () => { + modelsMock.LivechatContacts.findOneById.resolves({ + _id: 'contactId', + customFields: { customField: 'newValue' }, + conflictingFields: [{ field: 'customFields.customField', value: 'oldValue' }], + }); + modelsMock.Settings.incrementValueById.resolves(1); + modelsMock.LivechatContacts.updateContact.resolves({ + _id: 'contactId', + customField: { customField: 'newValue' }, + conflictingFields: [{ field: 'customFields.customField', value: 'oldValue' }], + } as Partial); + + const result = await resolveContactConflicts({ contactId: 'contactId', customField: { customField: 'newValue' } }); + + expect(modelsMock.LivechatContacts.findOneById.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); + + 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: 'newValue' } }); + expect(result).to.be.deep.equal({ + _id: 'contactId', + customField: { customField: 'newValue' }, + conflictingFields: [], + }); + }); + + it('should update the contact with the resolved name', async () => { + modelsMock.LivechatContacts.findOneById.resolves({ + _id: 'contactId', + name: 'Old Name', + customFields: { customField: 'newValue' }, + conflictingFields: [{ field: 'name', value: 'Old Name' }], + }); + modelsMock.Settings.incrementValueById.resolves(1); + modelsMock.LivechatContacts.updateContact.resolves({ + _id: 'contactId', + name: 'New Name', + customField: { customField: 'newValue' }, + conflictingFields: [], + } as Partial); + + const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name' }); + + expect(modelsMock.LivechatContacts.findOneById.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); + + 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', + name: 'New Name', + customField: { customField: 'newValue' }, + conflictingFields: [], + }); + }); + + it('should update the contact with the resolved contact manager', async () => { + modelsMock.LivechatContacts.findOneById.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'contactManagerId', + customFields: { customField: 'value' }, + conflictingFields: [{ field: 'manager', value: 'newContactManagerId' }], + }); + modelsMock.Settings.incrementValueById.resolves(1); + modelsMock.LivechatContacts.updateContact.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'newContactManagerId', + customField: { customField: 'value' }, + conflictingFields: [], + } as Partial); + + const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name' }); + + expect(modelsMock.LivechatContacts.findOneById.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); + + expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ contactManager: 'newContactManagerId' }); + expect(result).to.be.deep.equal({ + _id: 'contactId', + name: 'New Name', + customField: { customField: 'newValue' }, + conflictingFields: [], + }); + }); + + it('should wipe conflicts if wipeConflicts = true', async () => { + it('should update the contact with the resolved name', async () => { + modelsMock.LivechatContacts.findOneById.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); + + const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: true }); + + expect(modelsMock.LivechatContacts.findOneById.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.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', + name: 'New Name', + customField: { customField: 'newValue' }, + conflictingFields: [], + }); + }); + }); + + it('should wipe conflicts if wipeConflicts = true', async () => { + it('should update the contact with the resolved name', async () => { + modelsMock.LivechatContacts.findOneById.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); + + const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: false }); + + expect(modelsMock.LivechatContacts.findOneById.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); + + 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', + name: 'New Name', + customField: { customField: 'newValue' }, + conflictingFields: [{ field: 'customFields.customField', value: 'value' }], + }); + }); + }); + + it('should throw an error if the contact does not exist', async () => { + modelsMock.LivechatContacts.findOneById.resolves(undefined); + await expect(resolveContactConflicts({ contactId: 'id', customField: { customField: 'newValue' } })).to.be.rejectedWith( + 'error-contact-not-found', + ); + expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + }); + + it('should throw an error if the contact has no conflicting fields', async () => { + modelsMock.LivechatContacts.findOneById.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'contactManagerId', + customFields: { customField: 'value' }, + conflictingFields: [], + }); + await expect(resolveContactConflicts({ contactId: 'id', customField: { customField: 'newValue' } })).to.be.rejectedWith( + 'error-contact-has-no-conflicts', + ); + expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + }); + + it('should throw an error if the contact manager is invalid', async () => { + modelsMock.LivechatContacts.findOneById.resolves({ + _id: 'contactId', + name: 'Name', + contactManager: 'contactManagerId', + customFields: { customField: 'value' }, + conflictingFields: [{ field: 'manager', value: 'newContactManagerId' }], + }); + await expect(resolveContactConflicts({ contactId: 'id', contactManager: 'invalid' })).to.be.rejectedWith( + 'error-contact-manager-not-found', + ); + + expect(validateContactManagerMock.getCall(0).args[0]).to.be.equal('invalid'); + + expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + }); +}); diff --git a/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts new file mode 100644 index 0000000000000..332db8e29cab7 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/resolveContactConflicts.ts @@ -0,0 +1,64 @@ +import type { ILivechatContact, ILivechatContactConflictingField } from '@rocket.chat/core-typings'; +import { LivechatContacts, Settings } from '@rocket.chat/models'; + +import { validateContactManager } from './validateContactManager'; +import { notifyOnSettingChanged } from '../../../../lib/server/lib/notifyListener'; + +export type ResolveContactConflictsParams = { + contactId: string; + name?: string; + customFields?: Record; + contactManager?: string; + wipeConflicts?: boolean; +}; + +export async function resolveContactConflicts(params: ResolveContactConflictsParams): Promise { + const { contactId, name, customFields, contactManager, wipeConflicts } = params; + + const contact = await LivechatContacts.findOneById>(contactId, { + projection: { _id: 1, customFields: 1, conflictingFields: 1 }, + }); + + if (!contact) { + throw new Error('error-contact-not-found'); + } + + if (!contact.conflictingFields?.length) { + throw new Error('error-contact-has-no-conflicts'); + } + + if (contactManager) { + await validateContactManager(contactManager); + } + + let updatedConflictingFieldsArr: ILivechatContactConflictingField[] = []; + if (wipeConflicts) { + const value = await Settings.incrementValueById('Resolved_Conflicts_Count', contact.conflictingFields.length, { + returnDocument: 'after', + }); + if (value) { + void notifyOnSettingChanged(value); + } + } else { + const fieldsToRemove = new Set( + [ + name && 'name', + contactManager && 'manager', + ...(customFields ? Object.keys(customFields).map((key) => `customFields.${key}`) : []), + ].filter((field): field is string => !!field), + ); + + updatedConflictingFieldsArr = contact.conflictingFields.filter( + (conflictingField: ILivechatContactConflictingField) => !fieldsToRemove.has(conflictingField.field), + ) as ILivechatContactConflictingField[]; + } + + const dataToUpdate = { + ...(name && { name }), + ...(contactManager && { contactManager }), + ...(customFields && { customFields: { ...contact.customFields, ...customFields } }), + conflictingFields: updatedConflictingFieldsArr, + }; + + return LivechatContacts.updateContact(contactId, dataToUpdate); +} diff --git a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ReviewContactModal.tsx b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ReviewContactModal.tsx index 7ca34aa3c4dc7..fa21dc2ace5c0 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ReviewContactModal.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ReviewContactModal.tsx @@ -11,7 +11,7 @@ import GenericModal from '../../../../components/GenericModal'; import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule'; import { ContactManagerInput } from '../../additionalForms'; import { useCustomFieldsMetadata } from '../../directory/hooks/useCustomFieldsMetadata'; -import { useEditContact } from '../hooks/useEditContact'; +import { useReviewContact } from '../hooks/useReviewContact'; type ReviewContactModalProps = { contact: Serialized; @@ -41,7 +41,7 @@ const ReviewContactModal = ({ contact, onCancel }: ReviewContactModalProps) => { enabled: canViewCustomFields, }); - const editContact = useEditContact(['getContactById']); + const editContact = useReviewContact(['getContactById']); const handleConflicts = async ({ name, contactManager, ...customFields }: HandleConflictsPayload) => { const payload = { diff --git a/apps/meteor/client/views/omnichannel/contactInfo/hooks/useReviewContact.ts b/apps/meteor/client/views/omnichannel/contactInfo/hooks/useReviewContact.ts new file mode 100644 index 0000000000000..3027d9f3eeb9a --- /dev/null +++ b/apps/meteor/client/views/omnichannel/contactInfo/hooks/useReviewContact.ts @@ -0,0 +1,26 @@ +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import type { QueryKey } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { useContactRoute } from '../../hooks/useContactRoute'; + +export const useReviewContact = (invalidateQueries?: QueryKey) => { + const { t } = useTranslation(); + const updateContact = useEndpoint('POST', '/v1/omnichannel/contacts.conflicts'); + const dispatchToastMessage = useToastMessageDispatch(); + const queryClient = useQueryClient(); + const handleNavigate = useContactRoute(); + + return useMutation({ + mutationFn: updateContact, + onSuccess: async ({ contact }) => { + handleNavigate({ context: 'details', id: contact?._id }); + dispatchToastMessage({ type: 'success', message: t('Contact_has_been_updated') }); + await queryClient.invalidateQueries({ queryKey: invalidateQueries }); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }); +}; diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-conflict-review.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-conflict-review.spec.ts index b808d28f7aca4..3d6ac8ca2d0f0 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-conflict-review.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-contact-conflict-review.spec.ts @@ -83,7 +83,7 @@ test.describe.serial('OC - Contact Review', () => { await poHomeChannel.content.contactReviewModal.getFieldByName(customFieldName).click(); await poHomeChannel.content.contactReviewModal.findOption('custom-field-value-2').click(); - const responseListener = page.waitForResponse('**/api/v1/omnichannel/contacts.update'); + const responseListener = page.waitForResponse('**/api/v1/omnichannel/contacts.conflicts'); await poHomeChannel.content.contactReviewModal.btnSave.click(); const response = await responseListener; await expect(response.status()).toBe(200); 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 e19f5490b8573..a78a07426960a 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -708,6 +708,79 @@ describe('LIVECHAT - contacts', () => { }); }); + describe('[POST] omnichannel/contacts.conflicts', () => { + let token: string; + let contactId: string; + + before(async () => { + const visitor = await createVisitor(); + token = visitor.token; + + const { body } = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: visitor.name, + emails: [visitor.visitorEmails?.[0].address], + phones: [visitor.phone?.[0].phoneNumber], + }); + contactId = body.contactId; + + await request.get(api('livechat/room')).query({ token: visitor.token }); + + await createCustomField({ + field: 'cf1', + label: 'Custom Field 1', + scope: 'visitor', + visibility: 'public', + type: 'input', + required: true, + regexp: '^[0-9]+$', + searchable: true, + public: true, + }); + }); + + after(async () => { + await restorePermissionToRoles('update-livechat-contact'); + await deleteCustomField('cf1'); + }); + + it('should resolve the contact custom field conflict', async () => { + await request + .post(api('livechat/custom.field')) + .send({ token, key: 'cf1', value: '123', overwrite: true }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(api('livechat/custom.field')) + .send({ token, key: 'cf1', value: '456', overwrite: false }) + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + }); + + const res = await request + .post(api('omnichannel/contacts.conflicts')) + .set(credentials) + .send({ + contactId, + customFields: { + cf1: '123', + }, + }); + 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.have.property('customFields'); + expect(res.body.result.customFields).to.have.property('cf1', '123'); + }); + }); + describe('Contact Rooms', () => { let agent: { credentials: Credentials; user: IUser & { username: string } }; diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 0d7302b5fc0a6..1992e860bc037 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -1384,6 +1384,44 @@ const POSTUpdateOmnichannelContactsSchema = { export const isPOSTUpdateOmnichannelContactsProps = ajv.compile(POSTUpdateOmnichannelContactsSchema); +type POSTOmnichannelContactsConflictsProps = { + contactId: string; + name?: string; + contactManager?: string; + customFields?: Record; + wipeConflicts?: boolean; +}; + +const POSTOmnichannelContactsConflictsSchema = { + type: 'object', + properties: { + contactId: { + type: 'string', + }, + name: { + type: 'string', + nullable: true, + }, + contactManager: { + type: 'string', + nullable: true, + }, + customFields: { + type: 'object', + }, + wipeConflicts: { + type: 'boolean', + nullable: true, + }, + }, + required: ['contactId'], + additionalProperties: false, +}; + +export const isPOSTOmnichannelContactsConflictsProps = ajv.compile( + POSTOmnichannelContactsConflictsSchema, +); + type GETOmnichannelContactsProps = { contactId?: string }; export const ContactVisitorAssociationSchema = { @@ -3965,6 +4003,9 @@ export type OmnichannelEndpoints = { '/v1/omnichannel/contacts.update': { POST: (params: POSTUpdateOmnichannelContactsProps) => { contact: ILivechatContact }; }; + '/v1/omnichannel/contacts.conflicts': { + POST: (params: POSTOmnichannelContactsConflictsProps) => { contact: ILivechatContact }; + }; '/v1/omnichannel/contacts.get': { GET: (params: GETOmnichannelContactsProps) => { contact: ILivechatContact | null }; };