diff --git a/.changeset/sixty-spoons-own.md b/.changeset/sixty-spoons-own.md new file mode 100644 index 000000000000..0b717c3965ef --- /dev/null +++ b/.changeset/sixty-spoons-own.md @@ -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 diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index 6efe99e14d0e..d9ae4133e49e 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -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', diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 57c1d117f1b0..91b18a6b21af 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -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 }); + }, + }, +); diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts index c20b5dbdb661..4f4a33ee61b2 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ b/apps/meteor/app/livechat/server/lib/Contacts.ts @@ -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; + 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 { + const { name, emails, phones, customFields = {}, contactManager, channels, unknown } = params; + + if (contactManager) { + const contactManagerUser = await Users.findOneAgentById>(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 { + return LivechatCustomField.findByScope( + 'visitor', + { + projection: { _id: 1, label: 1, regexp: 1, required: 1 }, + }, + false, + ).toArray(); +} + +export function validateCustomFields(allowedCustomFields: ILivechatCustomField[], customFields: Record) { + 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 })); + } + } + } +} diff --git a/apps/meteor/server/models/LivechatContacts.ts b/apps/meteor/server/models/LivechatContacts.ts new file mode 100644 index 000000000000..d341ae87b021 --- /dev/null +++ b/apps/meteor/server/models/LivechatContacts.ts @@ -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)); diff --git a/apps/meteor/server/models/raw/LivechatContacts.ts b/apps/meteor/server/models/raw/LivechatContacts.ts new file mode 100644 index 000000000000..1f5f29a3cc78 --- /dev/null +++ b/apps/meteor/server/models/raw/LivechatContacts.ts @@ -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 implements ILivechatContactsModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'livechat_contact', trash); + } +} diff --git a/apps/meteor/server/models/startup.ts b/apps/meteor/server/models/startup.ts index 3d6dc6066689..eaca155674f5 100644 --- a/apps/meteor/server/models/startup.ts +++ b/apps/meteor/server/models/startup.ts @@ -22,6 +22,7 @@ import './Integrations'; import './Invites'; import './LivechatAgentActivity'; import './LivechatBusinessHours'; +import './LivechatContacts'; import './LivechatCustomField'; import './LivechatDepartment'; import './LivechatDepartmentAgents'; diff --git a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts new file mode 100644 index 000000000000..21eced5ee7e9 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -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'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts new file mode 100644 index 000000000000..9ff2019ffca5 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts @@ -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(); + }); + }); +}); diff --git a/packages/core-typings/src/ILivechatContact.ts b/packages/core-typings/src/ILivechatContact.ts new file mode 100644 index 000000000000..149dab2b88b1 --- /dev/null +++ b/packages/core-typings/src/ILivechatContact.ts @@ -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; + channels?: ILivechatContactChannel[]; +} diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index c04ffa998d77..5d2e2935a466 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -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'; diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 61ad5d1f5c55..83def2bd19b2 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -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'; diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts new file mode 100644 index 000000000000..bcf48a837400 --- /dev/null +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -0,0 +1,5 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; + +import type { IBaseModel } from './IBaseModel'; + +export type ILivechatContactsModel = IBaseModel; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 655a94923feb..eb357ed293ef 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -20,6 +20,7 @@ import type { IImportDataModel, ILivechatAgentActivityModel, ILivechatBusinessHoursModel, + ILivechatContactsModel, ILivechatCustomFieldModel, ILivechatDepartmentAgentsModel, ILivechatDepartmentModel, @@ -117,6 +118,7 @@ export const Integrations = proxify('IIntegrationsModel'); export const Invites = proxify('IInvitesModel'); export const LivechatAgentActivity = proxify('ILivechatAgentActivityModel'); export const LivechatBusinessHours = proxify('ILivechatBusinessHoursModel'); +export const LivechatContacts = proxify('ILivechatContactsModel'); export const LivechatCustomField = proxify('ILivechatCustomFieldModel'); export const LivechatDepartmentAgents = proxify('ILivechatDepartmentAgentsModel'); export const LivechatDepartment = proxify('ILivechatDepartmentModel'); diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index b8519bf8fe02..c15e94030de3 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -1211,6 +1211,49 @@ const POSTOmnichannelContactSchema = { export const isPOSTOmnichannelContactProps = ajv.compile(POSTOmnichannelContactSchema); +type POSTOmnichannelContactsProps = { + name: string; + emails: string[]; + phones: string[]; + customFields?: Record; + 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(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 }; };