feat: create contact endpoint (#32693)

pull/33092/head^2
Rafael Tapia 1 year ago committed by GitHub
parent 27f924967d
commit 927710d778
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 9
      .changeset/sixty-spoons-own.md
  2. 4
      apps/meteor/app/authorization/server/constant/permissions.ts
  3. 23
      apps/meteor/app/livechat/server/api/v1/contact.ts
  4. 85
      apps/meteor/app/livechat/server/lib/Contacts.ts
  5. 6
      apps/meteor/server/models/LivechatContacts.ts
  6. 11
      apps/meteor/server/models/raw/LivechatContacts.ts
  7. 1
      apps/meteor/server/models/startup.ts
  8. 299
      apps/meteor/tests/end-to-end/api/livechat/contacts.ts
  9. 40
      apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts
  10. 25
      packages/core-typings/src/ILivechatContact.ts
  11. 1
      packages/core-typings/src/index.ts
  12. 1
      packages/model-typings/src/index.ts
  13. 5
      packages/model-typings/src/models/ILivechatContactsModel.ts
  14. 2
      packages/models/src/index.ts
  15. 47
      packages/rest-typings/src/v1/omnichannel.ts

@ -0,0 +1,9 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/core-typings": minor
"@rocket.chat/model-typings": minor
"@rocket.chat/models": minor
"@rocket.chat/rest-typings": minor
---
Introduced "create contacts" endpoint to omnichannel

@ -93,6 +93,10 @@ export const permissions = [
_id: 'view-l-room',
roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'],
},
{
_id: 'create-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,14 +1,18 @@
import { LivechatCustomField, LivechatVisitors } from '@rocket.chat/models';
import { isPOSTOmnichannelContactsProps } 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 } from '../../lib/Contacts';
import { Contacts, createContact } from '../../lib/Contacts';
API.v1.addRoute(
'omnichannel/contact',
{ authRequired: true, permissionsRequired: ['view-l-room'] },
{
authRequired: true,
permissionsRequired: ['view-l-room'],
},
{
async post() {
check(this.bodyParams, {
@ -82,3 +86,18 @@ API.v1.addRoute(
},
},
);
API.v1.addRoute(
'omnichannel/contacts',
{ authRequired: true, permissionsRequired: ['create-livechat-contact'], validateParams: isPOSTOmnichannelContactsProps },
{
async post() {
if (!process.env.TEST_MODE) {
throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode');
}
const contactId = await createContact({ ...this.bodyParams, unknown: false });
return API.v1.success({ contactId });
},
},
);

@ -1,5 +1,14 @@
import type { ILivechatCustomField, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatVisitors, Users, LivechatRooms, LivechatCustomField, LivechatInquiry, Rooms, Subscriptions } from '@rocket.chat/models';
import type { ILivechatContactChannel, ILivechatCustomField, ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings';
import {
LivechatVisitors,
Users,
LivechatRooms,
LivechatCustomField,
LivechatInquiry,
Rooms,
Subscriptions,
LivechatContacts,
} from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import type { MatchKeysAndValues, OnlyFieldsOfType } from 'mongodb';
@ -26,6 +35,16 @@ type RegisterContactProps = {
};
};
type CreateContactParams = {
name: string;
emails: string[];
phones: string[];
unknown: boolean;
customFields?: Record<string, string | unknown>;
contactManager?: string;
channels?: ILivechatContactChannel[];
};
export const Contacts = {
async registerContact({
token,
@ -165,3 +184,65 @@ export const Contacts = {
return contactId;
},
};
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');
}
}
const allowedCustomFields = await getAllowedCustomFields();
validateCustomFields(allowedCustomFields, customFields);
const { insertedId } = await LivechatContacts.insertOne({
name,
emails,
phones,
contactManager,
channels,
customFields,
unknown,
});
return insertedId;
}
async function getAllowedCustomFields(): Promise<ILivechatCustomField[]> {
return LivechatCustomField.findByScope(
'visitor',
{
projection: { _id: 1, label: 1, regexp: 1, required: 1 },
},
false,
).toArray();
}
export function validateCustomFields(allowedCustomFields: ILivechatCustomField[], customFields: Record<string, string | unknown>) {
for (const cf of allowedCustomFields) {
if (!customFields.hasOwnProperty(cf._id)) {
if (cf.required) {
throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label }));
}
continue;
}
const cfValue: string = trim(customFields[cf._id]);
if (!cfValue || typeof cfValue !== 'string') {
if (cf.required) {
throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label }));
}
continue;
}
if (cf.regexp) {
const regex = new RegExp(cf.regexp);
if (!regex.test(cfValue)) {
throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label }));
}
}
}
}

@ -0,0 +1,6 @@
import { registerModel } from '@rocket.chat/models';
import { db } from '../database/utils';
import { LivechatContactsRaw } from './raw/LivechatContacts';
registerModel('ILivechatContactsModel', new LivechatContactsRaw(db));

@ -0,0 +1,11 @@
import type { ILivechatContact, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ILivechatContactsModel } from '@rocket.chat/model-typings';
import type { Collection, Db } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements ILivechatContactsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ILivechatContact>>) {
super(db, 'livechat_contact', trash);
}
}

@ -22,6 +22,7 @@ import './Integrations';
import './Invites';
import './LivechatAgentActivity';
import './LivechatBusinessHours';
import './LivechatContacts';
import './LivechatCustomField';
import './LivechatDepartment';
import './LivechatDepartmentAgents';

@ -0,0 +1,299 @@
import { faker } from '@faker-js/faker';
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 { removePermissionFromAllRoles, restorePermissionToRoles, updatePermission, updateSetting } from '../../../data/permissions.helper';
import { createUser, deleteUser } from '../../../data/users.helper';
describe('LIVECHAT - contacts', () => {
before((done) => getCredentials(done));
before(async () => {
await updateSetting('Livechat_enabled', true);
await updatePermission('create-livechat-contact', ['admin']);
});
after(async () => {
await restorePermissionToRoles('create-livechat-contact');
await updateSetting('Livechat_enabled', true);
});
describe('[POST] omnichannel/contacts', () => {
it('should be able to create a new contact', async () => {
const res = await request
.post(api('omnichannel/contacts'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
});
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('contactId');
expect(res.body.contactId).to.be.an('string');
});
it("should return an error if user doesn't have 'create-livechat-contact' permission", async () => {
await removePermissionFromAllRoles('create-livechat-contact');
const res = await request
.post(api('omnichannel/contacts'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
});
expect(res.body).to.have.property('success', false);
expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]');
await restorePermissionToRoles('create-livechat-contact');
});
it('should return an error if contact manager not exists', async () => {
const res = await request
.post(api('omnichannel/contacts'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
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');
});
it('should return an error if contact manager is not a livechat-agent', async () => {
const normalUser = await createUser();
const res = await request
.post(api('omnichannel/contacts'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
contactManager: normalUser._id,
});
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');
await deleteUser(normalUser);
});
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)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
contactManager: livechatAgent._id,
});
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', () => {
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'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
customFields: {
cf1: '123',
},
});
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('contactId');
expect(res.body.contactId).to.be.an('string');
});
it('should return an error for missing required custom field', async () => {
const res = await request
.post(api('omnichannel/contacts'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
customFields: {},
});
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 for invalid custom field value', async () => {
const res = await request
.post(api('omnichannel/contacts'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
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');
});
});
describe('Fields Validation', () => {
it('should return an error if name is missing', async () => {
const res = await request
.post(api('omnichannel/contacts'))
.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 'name' [invalid-params]");
expect(res.body.errorType).to.be.equal('invalid-params');
});
it('should return an error if emails is missing', async () => {
const res = await request
.post(api('omnichannel/contacts'))
.set(credentials)
.send({
name: faker.person.fullName(),
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 'emails' [invalid-params]");
expect(res.body.errorType).to.be.equal('invalid-params');
});
it('should return an error if phones is missing', async () => {
const res = await request
.post(api('omnichannel/contacts'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
});
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 'phones' [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'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: 'invalid',
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 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'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [{ invalid: true }],
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 be string [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'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
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'))
.set(credentials)
.send({
name: faker.person.fullName(),
emails: [faker.internet.email().toLowerCase()],
phones: [faker.phone.number()],
additional: 'invalid',
});
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');
});
});
});
});

@ -0,0 +1,40 @@
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(),
});
describe('[OC] Contacts', () => {
describe('validateCustomFields', () => {
const mockCustomFields = [{ _id: 'cf1', label: 'Custom Field 1', regexp: '^[0-9]+$', required: true }];
it('should validate custom fields correctly', () => {
expect(() => validateCustomFields(mockCustomFields, { cf1: '123' })).to.not.throw();
});
it('should throw an error if a required custom field is missing', () => {
expect(() => validateCustomFields(mockCustomFields, {})).to.throw();
});
it('should NOT throw an error when a non-required custom field is missing', () => {
const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }];
const customFields = {};
expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw();
});
it('should throw an error if a custom field value does not match the regexp', () => {
expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' })).to.throw();
});
it('should handle an empty customFields input without throwing an error', () => {
const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }];
const customFields = {};
expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw();
});
});
});

@ -0,0 +1,25 @@
import type { IRocketChatRecord } from './IRocketChatRecord';
export interface ILivechatContactChannel {
name: string;
verified: boolean;
visitorId: string;
}
export interface ILivechatContactConflictingField {
field: string;
oldValue: string;
newValue: string;
}
export interface ILivechatContact extends IRocketChatRecord {
name: string;
phones: string[];
emails: string[];
contactManager?: string;
unknown?: boolean;
hasConflict?: boolean;
conflictingFields?: ILivechatContactConflictingField[];
customFields?: Record<string, string | unknown>;
channels?: ILivechatContactChannel[];
}

@ -96,6 +96,7 @@ export * from './ILivechatCustomField';
export * from './IOmnichannel';
export * from './ILivechatAgentActivity';
export * from './ILivechatBusinessHour';
export * from './ILivechatContact';
export * from './ILivechatVisitor';
export * from './ILivechatDepartmentAgents';
export * from './ILivechatAgent';

@ -21,6 +21,7 @@ export * from './models/IInvitesModel';
export * from './models/IImportDataModel';
export * from './models/ILivechatAgentActivityModel';
export * from './models/ILivechatBusinessHoursModel';
export * from './models/ILivechatContactsModel';
export * from './models/ILivechatCustomFieldModel';
export * from './models/ILivechatDepartmentAgentsModel';
export * from './models/ILivechatDepartmentModel';

@ -0,0 +1,5 @@
import type { ILivechatContact } from '@rocket.chat/core-typings';
import type { IBaseModel } from './IBaseModel';
export type ILivechatContactsModel = IBaseModel<ILivechatContact>;

@ -20,6 +20,7 @@ import type {
IImportDataModel,
ILivechatAgentActivityModel,
ILivechatBusinessHoursModel,
ILivechatContactsModel,
ILivechatCustomFieldModel,
ILivechatDepartmentAgentsModel,
ILivechatDepartmentModel,
@ -117,6 +118,7 @@ export const Integrations = proxify<IIntegrationsModel>('IIntegrationsModel');
export const Invites = proxify<IInvitesModel>('IInvitesModel');
export const LivechatAgentActivity = proxify<ILivechatAgentActivityModel>('ILivechatAgentActivityModel');
export const LivechatBusinessHours = proxify<ILivechatBusinessHoursModel>('ILivechatBusinessHoursModel');
export const LivechatContacts = proxify<ILivechatContactsModel>('ILivechatContactsModel');
export const LivechatCustomField = proxify<ILivechatCustomFieldModel>('ILivechatCustomFieldModel');
export const LivechatDepartmentAgents = proxify<ILivechatDepartmentAgentsModel>('ILivechatDepartmentAgentsModel');
export const LivechatDepartment = proxify<ILivechatDepartmentModel>('ILivechatDepartmentModel');

@ -1211,6 +1211,49 @@ const POSTOmnichannelContactSchema = {
export const isPOSTOmnichannelContactProps = ajv.compile<POSTOmnichannelContactProps>(POSTOmnichannelContactSchema);
type POSTOmnichannelContactsProps = {
name: string;
emails: string[];
phones: string[];
customFields?: Record<string, unknown>;
contactManager?: string;
};
const POSTOmnichannelContactsSchema = {
type: 'object',
properties: {
name: {
type: 'string',
},
emails: {
type: 'array',
items: {
type: 'string',
},
uniqueItems: true,
},
phones: {
type: 'array',
items: {
type: 'string',
},
uniqueItems: true,
},
customFields: {
type: 'object',
nullable: true,
},
contactManager: {
type: 'string',
nullable: true,
},
},
required: ['name', 'emails', 'phones'],
additionalProperties: false,
};
export const isPOSTOmnichannelContactsProps = ajv.compile<POSTOmnichannelContactsProps>(POSTOmnichannelContactsSchema);
type GETOmnichannelContactProps = { contactId: string };
const GETOmnichannelContactSchema = {
@ -3649,6 +3692,10 @@ export type OmnichannelEndpoints = {
GET: (params: GETOmnichannelContactProps) => { contact: ILivechatVisitor | null };
};
'/v1/omnichannel/contacts': {
POST: (params: POSTOmnichannelContactsProps) => { contactId: string };
};
'/v1/omnichannel/contact.search': {
GET: (params: GETOmnichannelContactSearchProps) => { contact: ILivechatVisitor | null };
};

Loading…
Cancel
Save