Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/wise-jokes-deliver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/rest-typings": patch
---

Adds new endpoint to handle contact's conflicting data
14 changes: 14 additions & 0 deletions apps/meteor/app/livechat/server/api/v1/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ILivechatContact>);

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<ILivechatContact>);

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<ILivechatContact>);

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<ILivechatContact>);

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<ILivechatContact>);

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;
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
contactManager?: string;
wipeConflicts?: boolean;
};

export async function resolveContactConflicts(params: ResolveContactConflictsParams): Promise<ILivechatContact> {
const { contactId, name, customFields, contactManager, wipeConflicts } = params;

const contact = await LivechatContacts.findOneById<Pick<ILivechatContact, '_id' | 'customFields' | 'conflictingFields'>>(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<string>(
[
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ILivechatContact>;
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
},
});
};
Loading
Loading