feat: update contact endpoint (#32729)

pull/32923/head^2
Rafael Tapia 1 year ago committed by GitHub
parent 304bc6d5ad
commit 9eaefdc892
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/heavy-snails-help.md
  2. 4
      apps/meteor/app/authorization/server/constant/permissions.ts
  3. 19
      apps/meteor/app/livechat/server/api/v1/contact.ts
  4. 61
      apps/meteor/app/livechat/server/lib/Contacts.ts
  5. 9
      apps/meteor/server/models/raw/LivechatContacts.ts
  6. 285
      apps/meteor/tests/end-to-end/api/livechat/contacts.ts
  7. 70
      apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts
  8. 3
      packages/i18n/src/locales/en.i18n.json
  9. 3
      packages/i18n/src/locales/pt-BR.i18n.json
  10. 4
      packages/model-typings/src/models/ILivechatContactsModel.ts
  11. 53
      packages/rest-typings/src/v1/omnichannel.ts

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/rest-typings": minor
---
Implemented "omnichannel/contacts.update" endpoint to update contacts

@ -97,6 +97,10 @@ export const permissions = [
_id: 'create-livechat-contact',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{
_id: 'update-livechat-contact',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{ _id: 'view-livechat-manager', roles: ['livechat-manager', 'livechat-monitor', 'admin'] },
{
_id: 'view-omnichannel-contact-center',

@ -1,11 +1,11 @@
import { LivechatCustomField, LivechatVisitors } from '@rocket.chat/models';
import { isPOSTOmnichannelContactsProps } from '@rocket.chat/rest-typings';
import { isPOSTOmnichannelContactsProps, isPOSTUpdateOmnichannelContactsProps } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { API } from '../../../../api/server';
import { Contacts, createContact } from '../../lib/Contacts';
import { Contacts, createContact, updateContact } from '../../lib/Contacts';
API.v1.addRoute(
'omnichannel/contact',
@ -101,3 +101,18 @@ API.v1.addRoute(
},
},
);
API.v1.addRoute(
'omnichannel/contacts.update',
{ authRequired: true, permissionsRequired: ['update-livechat-contact'], validateParams: isPOSTUpdateOmnichannelContactsProps },
{
async post() {
if (!process.env.TEST_MODE) {
throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode');
}
const contact = await updateContact({ ...this.bodyParams });
return API.v1.success({ contact });
},
},
);

@ -1,4 +1,11 @@
import type { ILivechatContactChannel, ILivechatCustomField, ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings';
import type {
ILivechatContact,
ILivechatContactChannel,
ILivechatCustomField,
ILivechatVisitor,
IOmnichannelRoom,
IUser,
} from '@rocket.chat/core-typings';
import {
LivechatVisitors,
Users,
@ -45,6 +52,16 @@ type CreateContactParams = {
channels?: ILivechatContactChannel[];
};
type UpdateContactParams = {
contactId: string;
name?: string;
emails?: string[];
phones?: string[];
customFields?: Record<string, unknown>;
contactManager?: string;
channels?: ILivechatContactChannel[];
};
export const Contacts = {
async registerContact({
token,
@ -189,10 +206,7 @@ export async function createContact(params: CreateContactParams): Promise<string
const { name, emails, phones, customFields = {}, contactManager, channels, unknown } = params;
if (contactManager) {
const contactManagerUser = await Users.findOneAgentById<Pick<IUser, 'roles'>>(contactManager, { projection: { roles: 1 } });
if (!contactManagerUser) {
throw new Error('error-contact-manager-not-found');
}
await validateContactManager(contactManager);
}
const allowedCustomFields = await getAllowedCustomFields();
@ -211,6 +225,29 @@ export async function createContact(params: CreateContactParams): Promise<string
return insertedId;
}
export async function updateContact(params: UpdateContactParams): Promise<ILivechatContact> {
const { contactId, name, emails, phones, customFields, contactManager, channels } = params;
const contact = await LivechatContacts.findOneById<Pick<ILivechatContact, '_id'>>(contactId, { projection: { _id: 1 } });
if (!contact) {
throw new Error('error-contact-not-found');
}
if (contactManager) {
await validateContactManager(contactManager);
}
if (customFields) {
const allowedCustomFields = await getAllowedCustomFields();
validateCustomFields(allowedCustomFields, customFields);
}
const updatedContact = await LivechatContacts.updateContact(contactId, { name, emails, phones, contactManager, channels, customFields });
return updatedContact;
}
async function getAllowedCustomFields(): Promise<ILivechatCustomField[]> {
return LivechatCustomField.findByScope(
'visitor',
@ -245,4 +282,18 @@ export function validateCustomFields(allowedCustomFields: ILivechatCustomField[]
}
}
}
const allowedCustomFieldIds = new Set(allowedCustomFields.map((cf) => cf._id));
for (const key in customFields) {
if (!allowedCustomFieldIds.has(key)) {
throw new Error(i18n.t('error-custom-field-not-allowed', { key }));
}
}
}
export async function validateContactManager(contactManagerUserId: string) {
const contactManagerUser = await Users.findOneAgentById<Pick<IUser, '_id'>>(contactManagerUserId, { projection: { _id: 1 } });
if (!contactManagerUser) {
throw new Error('error-contact-manager-not-found');
}
}

@ -8,4 +8,13 @@ export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements IL
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ILivechatContact>>) {
super(db, 'livechat_contact', trash);
}
async updateContact(contactId: string, data: Partial<ILivechatContact>): Promise<ILivechatContact> {
const updatedValue = await this.findOneAndUpdate(
{ _id: contactId },
{ $set: { ...data, unknown: false } },
{ returnDocument: 'after' },
);
return updatedValue.value as ILivechatContact;
}
}

@ -1,27 +1,42 @@
import { faker } from '@faker-js/faker';
import type { ILivechatAgent, IUser } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { before, after, describe, it } from 'mocha';
import { getCredentials, api, request, credentials } from '../../../data/api-data';
import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields';
import { createAgent } from '../../../data/livechat/rooms';
import { removeAgent } from '../../../data/livechat/users';
import { removePermissionFromAllRoles, restorePermissionToRoles, updatePermission, updateSetting } from '../../../data/permissions.helper';
import { createUser, deleteUser } from '../../../data/users.helper';
describe('LIVECHAT - contacts', () => {
let agentUser: IUser;
let livechatAgent: ILivechatAgent;
before((done) => getCredentials(done));
before(async () => {
await updateSetting('Livechat_enabled', true);
await updatePermission('create-livechat-contact', ['admin']);
agentUser = await createUser();
livechatAgent = await createAgent(agentUser.username);
});
after(async () => {
await removeAgent(livechatAgent._id);
await deleteUser(agentUser);
await restorePermissionToRoles('create-livechat-contact');
await updateSetting('Livechat_enabled', true);
});
describe('[POST] omnichannel/contacts', () => {
before(async () => {
await updatePermission('create-livechat-contact', ['admin']);
});
after(async () => {
await restorePermissionToRoles('create-livechat-contact');
});
it('should be able to create a new contact', async () => {
const res = await request
.post(api('omnichannel/contacts'))
@ -92,9 +107,6 @@ describe('LIVECHAT - contacts', () => {
});
it('should be able to create a new contact with a contact manager', async () => {
const user = await createUser();
const livechatAgent = await createAgent(user.username);
const res = await request
.post(api('omnichannel/contacts'))
.set(credentials)
@ -108,8 +120,6 @@ describe('LIVECHAT - contacts', () => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('contactId');
expect(res.body.contactId).to.be.an('string');
await deleteUser(user);
});
describe('Custom Fields', () => {
@ -296,4 +306,267 @@ describe('LIVECHAT - contacts', () => {
});
});
});
describe('[POST] omnichannel/contacts.update', () => {
let contactId: string;
before(async () => {
const { body } = await request
.post(api('omnichannel/contacts'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
});
contactId = body.contactId;
});
after(async () => {
await restorePermissionToRoles('update-livechat-contact');
});
it('should be able to update a contact', async () => {
const name = faker.person.fullName();
const emails = [faker.internet.email().toLowerCase()];
const phones = [faker.phone.number()];
const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({
contactId,
name,
emails,
phones,
});
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.name).to.be.equal(name);
expect(res.body.contact.emails).to.be.deep.equal(emails);
expect(res.body.contact.phones).to.be.deep.equal(phones);
});
it('should set the unknown field to false when updating a contact', async () => {
const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({
contactId,
name: faker.person.fullName(),
});
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.unknown).to.be.equal(false);
});
it('should be able to update the contact manager', async () => {
const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({
contactId,
contactManager: livechatAgent._id,
});
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.contactManager).to.be.equal(livechatAgent._id);
});
it('should return an error if contact does not exist', async () => {
const res = await request
.post(api('omnichannel/contacts.update'))
.set(credentials)
.send({
contactId: 'invalid',
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
});
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.equal('error-contact-not-found');
});
it('should return an error if contact manager not exists', async () => {
const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({
contactId,
contactManager: 'invalid',
});
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.equal('error-contact-manager-not-found');
});
describe('Permissions', () => {
before(async () => {
await removePermissionFromAllRoles('update-livechat-contact');
});
after(async () => {
await restorePermissionToRoles('update-livechat-contact');
});
it("should return an error if user doesn't have 'update-livechat-contact' permission", async () => {
const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({
contactId,
});
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]');
});
});
describe('Custom Fields', () => {
before(async () => {
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 deleteCustomField('cf1');
});
it('should validate custom fields correctly', async () => {
const res = await request
.post(api('omnichannel/contacts.update'))
.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.contact._id).to.be.equal(contactId);
});
it('should return an error for invalid custom field value', async () => {
const res = await request
.post(api('omnichannel/contacts.update'))
.set(credentials)
.send({
contactId,
customFields: {
cf1: 'invalid',
},
});
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.equal('Invalid value for Custom Field 1 field');
});
it('should return an error if additional custom fields are provided', async () => {
const res = await request
.post(api('omnichannel/contacts.update'))
.set(credentials)
.send({
contactId,
customFields: {
cf1: '123',
cf2: 'invalid',
},
});
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.equal('Custom field cf2 is not allowed');
});
});
describe('Fields Validation', () => {
it('should return an error if contactId is missing', async () => {
const res = await request
.post(api('omnichannel/contacts.update'))
.set(credentials)
.send({
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
});
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.equal("must have required property 'contactId' [invalid-params]");
expect(res.body.errorType).to.be.equal('invalid-params');
});
it('should return an error if emails is not an array', async () => {
const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({
contactId,
emails: 'invalid',
});
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.equal('must be array [invalid-params]');
expect(res.body.errorType).to.be.equal('invalid-params');
});
it('should return an error if emails is not an array of strings', async () => {
const res = await request
.post(api('omnichannel/contacts.update'))
.set(credentials)
.send({
contactId,
emails: [{ invalid: true }],
});
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.equal('must be string [invalid-params]');
expect(res.body.errorType).to.be.equal('invalid-params');
});
it('should return an error if phones is not an array', async () => {
const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({
contactId,
phones: 'invalid',
});
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.equal('must be array [invalid-params]');
expect(res.body.errorType).to.be.equal('invalid-params');
});
it('should return an error if phones is not an array of strings', async () => {
const res = await request
.post(api('omnichannel/contacts.update'))
.set(credentials)
.send({
contactId,
phones: [{ invalid: true }],
});
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.equal('must be string [invalid-params]');
expect(res.body.errorType).to.be.equal('invalid-params');
});
it('should return an error if additional fields are provided', async () => {
const res = await request.post(api('omnichannel/contacts.update')).set(credentials).send({
contactId,
unknown: true,
});
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
expect(res.body.error).to.be.equal('must NOT have additional properties [invalid-params]');
expect(res.body.errorType).to.be.equal('invalid-params');
});
});
});
});

@ -2,10 +2,22 @@ import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const { validateCustomFields } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/Contacts', {
'meteor/check': sinon.stub(),
'meteor/meteor': sinon.stub(),
});
const modelsMock = {
Users: {
findOneAgentById: sinon.stub(),
},
LivechatContacts: {
findOneById: sinon.stub(),
updateContact: sinon.stub(),
},
};
const { validateCustomFields, validateContactManager, updateContact } = proxyquire
.noCallThru()
.load('../../../../../../app/livechat/server/lib/Contacts', {
'meteor/check': sinon.stub(),
'meteor/meteor': sinon.stub(),
'@rocket.chat/models': modelsMock,
});
describe('[OC] Contacts', () => {
describe('validateCustomFields', () => {
@ -36,5 +48,55 @@ describe('[OC] Contacts', () => {
expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw();
});
it('should throw an error if a extra custom field is passed', () => {
const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }];
const customFields = { field2: 'value' };
expect(() => validateCustomFields(allowedCustomFields, customFields)).to.throw();
});
});
describe('validateContactManager', () => {
beforeEach(() => {
modelsMock.Users.findOneAgentById.reset();
});
it('should throw an error if the user does not exist', async () => {
modelsMock.Users.findOneAgentById.resolves(undefined);
await expect(validateContactManager('any_id')).to.be.rejectedWith('error-contact-manager-not-found');
});
it('should not throw an error if the user has the "livechat-agent" role', async () => {
const user = { _id: 'userId' };
modelsMock.Users.findOneAgentById.resolves(user);
await expect(validateContactManager('userId')).to.not.be.rejected;
expect(modelsMock.Users.findOneAgentById.getCall(0).firstArg).to.be.equal('userId');
});
});
describe('updateContact', () => {
beforeEach(() => {
modelsMock.LivechatContacts.findOneById.reset();
modelsMock.LivechatContacts.updateContact.reset();
});
it('should throw an error if the contact does not exist', async () => {
modelsMock.LivechatContacts.findOneById.resolves(undefined);
await expect(updateContact('any_id')).to.be.rejectedWith('error-contact-not-found');
expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null;
});
it('should update the contact with correct params', async () => {
modelsMock.LivechatContacts.findOneById.resolves({ _id: 'contactId' });
modelsMock.LivechatContacts.updateContact.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(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' });
});
});
});

@ -2082,6 +2082,7 @@
"error-invalid-custom-field": "Invalid custom field",
"error-invalid-custom-field-name": "Invalid custom field name. Use only letters, numbers, hyphens and underscores.",
"error-invalid-custom-field-value": "Invalid value for {{field}} field",
"error-custom-field-not-allowed": "Custom field {{key}} is not allowed",
"error-invalid-date": "Invalid date provided.",
"error-invalid-dates": "From date cannot be after To date",
"error-invalid-description": "Invalid description",
@ -5842,6 +5843,8 @@
"view-joined-room": "View Joined Room",
"view-joined-room_description": "Permission to view the currently joined channels",
"view-l-room": "View Omnichannel Rooms",
"create-livechat-contact": "Create Omnichannel contacts",
"update-livechat-contact": "Update Omnichannel contacts",
"view-l-room_description": "Permission to view Omnichannel rooms",
"view-livechat-analytics": "View Omnichannel Analytics",
"onboarding.page.awaitingConfirmation.subtitle": "We have sent you an email to {{emailAddress}} with a confirmation link. Please verify that the security code below matches the one in the email.",

@ -1736,6 +1736,7 @@
"error-invalid-custom-field": "Campo personalizado inválido",
"error-invalid-custom-field-name": "Nome inválido para o campo personalizado. Use apenas letras, números, hífens e travessão.",
"error-invalid-custom-field-value": "Valor inválido para o campo {{field}}",
"error-custom-field-not-allowed": "O campo personalizado {{key}} não é permitido",
"error-invalid-date": "Data fornecida inválida",
"error-invalid-description": "Descrição inválida",
"error-invalid-domain": "Domínio inválido",
@ -4678,6 +4679,8 @@
"view-joined-room": "Ver sala incorporada",
"view-joined-room_description": "Permissão para ver os canais atualmente associados",
"view-l-room": "Ver salas de omnichannel",
"create-livechat-contact": "Criar contatos do omnichannel",
"update-livechat-contact": "Atualizar contatos do omnichannel",
"view-l-room_description": "Permissão para ver salas de omnichannel",
"view-livechat-analytics": "Ver a análise do omnichannel",
"onboarding.page.awaitingConfirmation.subtitle": "Enviamos um e-mail para {{emailAddress}} com um link de confirmação. Verifique se o código de segurança abaixo coincide com o do e-mail.",

@ -2,4 +2,6 @@ import type { ILivechatContact } from '@rocket.chat/core-typings';
import type { IBaseModel } from './IBaseModel';
export type ILivechatContactsModel = IBaseModel<ILivechatContact>;
export interface ILivechatContactsModel extends IBaseModel<ILivechatContact> {
updateContact(contactId: string, data: Partial<ILivechatContact>): Promise<ILivechatContact>;
}

@ -27,6 +27,7 @@ import type {
ReportWithUnmatchingElements,
SMSProviderResponse,
ILivechatTriggerActionResponse,
ILivechatContact,
} from '@rocket.chat/core-typings';
import { ILivechatAgentStatus } from '@rocket.chat/core-typings';
import Ajv from 'ajv';
@ -1254,6 +1255,55 @@ const POSTOmnichannelContactsSchema = {
export const isPOSTOmnichannelContactsProps = ajv.compile<POSTOmnichannelContactsProps>(POSTOmnichannelContactsSchema);
type POSTUpdateOmnichannelContactsProps = {
contactId: string;
name?: string;
emails?: string[];
phones?: string[];
customFields?: Record<string, unknown>;
contactManager?: string;
};
const POSTUpdateOmnichannelContactsSchema = {
type: 'object',
properties: {
contactId: {
type: 'string',
},
name: {
type: 'string',
},
emails: {
type: 'array',
items: {
type: 'string',
},
uniqueItems: true,
nullable: true,
},
phones: {
type: 'array',
items: {
type: 'string',
},
uniqueItems: true,
nullable: true,
},
customFields: {
type: 'object',
nullable: true,
},
contactManager: {
type: 'string',
nullable: true,
},
},
required: ['contactId'],
additionalProperties: false,
};
export const isPOSTUpdateOmnichannelContactsProps = ajv.compile<POSTUpdateOmnichannelContactsProps>(POSTUpdateOmnichannelContactsSchema);
type GETOmnichannelContactProps = { contactId: string };
const GETOmnichannelContactSchema = {
@ -3695,6 +3745,9 @@ export type OmnichannelEndpoints = {
'/v1/omnichannel/contacts': {
POST: (params: POSTOmnichannelContactsProps) => { contactId: string };
};
'/v1/omnichannel/contacts.update': {
POST: (params: POSTUpdateOmnichannelContactsProps) => { contact: ILivechatContact };
};
'/v1/omnichannel/contact.search': {
GET: (params: GETOmnichannelContactSearchProps) => { contact: ILivechatVisitor | null };

Loading…
Cancel
Save