From 8f71f7832efea54a23618596228bec8ecadd3221 Mon Sep 17 00:00:00 2001 From: Rafael Tapia <rafael.tapia@rocket.chat> Date: Mon, 14 Oct 2024 09:04:13 -0300 Subject: [PATCH] feat: add contact channels (#33308) --- .../app/apps/server/bridges/livechat.ts | 1 + .../app/apps/server/converters/visitors.js | 2 + .../app/livechat/imports/server/rest/sms.ts | 6 +- .../meteor/app/livechat/server/api/v1/room.ts | 4 +- .../app/livechat/server/lib/Contacts.ts | 30 ++++++++++ .../app/livechat/server/lib/LivechatTyped.ts | 57 ++++++++++++++++++- .../server/models/raw/LivechatContacts.ts | 6 +- .../tests/end-to-end/api/livechat/contacts.ts | 48 ++++++++++++++++ .../src/definition/livechat/ILivechatRoom.ts | 2 + packages/core-typings/src/ILivechatContact.ts | 6 ++ packages/core-typings/src/IRoom.ts | 3 + .../src/models/ILivechatContactsModel.ts | 3 +- 12 files changed, 161 insertions(+), 7 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 4f4794591e0..821d1fdd60d 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -118,6 +118,7 @@ export class AppLivechatBridge extends LivechatBridge { sidebarIcon: source.sidebarIcon, defaultIcon: source.defaultIcon, label: source.label, + destination: source.destination, }), }, }, diff --git a/apps/meteor/app/apps/server/converters/visitors.js b/apps/meteor/app/apps/server/converters/visitors.js index c8fb0b7c4a2..32864e3e900 100644 --- a/apps/meteor/app/apps/server/converters/visitors.js +++ b/apps/meteor/app/apps/server/converters/visitors.js @@ -36,6 +36,7 @@ export class AppVisitorsConverter { visitorEmails: 'visitorEmails', livechatData: 'livechatData', status: 'status', + contactId: 'contactId', }; return transformMappedData(visitor, map); @@ -54,6 +55,7 @@ export class AppVisitorsConverter { phone: visitor.phone, livechatData: visitor.livechatData, status: visitor.status || 'online', + contactId: visitor.contactId, ...(visitor.visitorEmails && { visitorEmails: visitor.visitorEmails }), ...(visitor.department && { department: visitor.department }), }; diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index 6b2411cf8e3..2fe3ce40eed 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -121,13 +121,17 @@ API.v1.addRoute('livechat/sms-incoming/:service', { return API.v1.success(SMSService.error(new Error('Invalid visitor'))); } - const roomInfo = { + const roomInfo: { + source?: IOmnichannelRoom['source']; + [key: string]: unknown; + } = { sms: { from: sms.to, }, source: { type: OmnichannelSourceType.SMS, alias: service, + destination: sms.to, }, }; diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 7aacfacb447..9bda8b443ea 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -76,7 +76,9 @@ API.v1.addRoute( const roomInfo = { source: { - type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API, + ...(isWidget(this.request.headers) + ? { type: OmnichannelSourceType.WIDGET, destination: this.request.headers.host } + : { type: OmnichannelSourceType.API }), }, }; diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts index 166f370bc63..c26fbcc7b08 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ b/apps/meteor/app/livechat/server/lib/Contacts.ts @@ -7,6 +7,7 @@ import type { IOmnichannelRoom, IUser, } from '@rocket.chat/core-typings'; +import type { InsertionModel } from '@rocket.chat/model-typings'; import { LivechatVisitors, Users, @@ -183,6 +184,35 @@ export function isSingleContactEnabled(): boolean { return process.env.TEST_MODE?.toUpperCase() === 'TRUE'; } +export async function createContactFromVisitor(visitor: ILivechatVisitor): Promise<string> { + if (visitor.contactId) { + throw new Error('error-contact-already-exists'); + } + + const contactData: InsertionModel<ILivechatContact> = { + name: visitor.name || visitor.username, + emails: visitor.visitorEmails?.map(({ address }) => address), + phones: visitor.phone?.map(({ phoneNumber }) => phoneNumber), + unknown: true, + channels: [], + customFields: visitor.livechatData, + createdAt: new Date(), + }; + + if (visitor.contactManager) { + const contactManagerId = await Users.findOneByUsername<Pick<IUser, '_id'>>(visitor.contactManager.username, { projection: { _id: 1 } }); + if (contactManagerId) { + contactData.contactManager = contactManagerId._id; + } + } + + const { insertedId: contactId } = await LivechatContacts.insertOne(contactData); + + await LivechatVisitors.updateOne({ _id: visitor._id }, { $set: { contactId } }); + + return contactId; +} + export async function createContact(params: CreateContactParams): Promise<string> { const { name, emails, phones, customFields: receivedCustomFields = {}, contactManager, channels, unknown } = params; diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index 44ee46f0441..e521ac98fe7 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -20,10 +20,11 @@ import type { IOmnichannelAgent, ILivechatDepartmentAgents, LivechatDepartmentDTO, - OmnichannelSourceType, ILivechatInquiryRecord, + ILivechatContact, + ILivechatContactChannel, } from '@rocket.chat/core-typings'; -import { ILivechatAgentStatus, UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; +import { OmnichannelSourceType, ILivechatAgentStatus, UserStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger, type MainLogger } from '@rocket.chat/logger'; import { LivechatDepartment, @@ -37,6 +38,7 @@ import { ReadReceipts, Rooms, LivechatCustomField, + LivechatContacts, } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Match, check } from 'meteor/check'; @@ -71,7 +73,7 @@ import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; -import { createContact, isSingleContactEnabled } from './Contacts'; +import { createContact, createContactFromVisitor, isSingleContactEnabled } from './Contacts'; import { parseAgentCustomFields, updateDepartmentAgents, validateEmail, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; @@ -459,6 +461,55 @@ class LivechatClass { extraData, }); + if (isSingleContactEnabled()) { + let { contactId } = visitor; + + if (!contactId) { + const visitorContact = await LivechatVisitors.findOne< + Pick<ILivechatVisitor, 'name' | 'contactManager' | 'livechatData' | 'phone' | 'visitorEmails' | 'username' | 'contactId'> + >(visitor._id, { + projection: { + name: 1, + contactManager: 1, + livechatData: 1, + phone: 1, + visitorEmails: 1, + username: 1, + contactId: 1, + }, + }); + + contactId = visitorContact?.contactId; + } + + if (!contactId) { + // ensure that old visitors have a contact + contactId = await createContactFromVisitor(visitor); + } + + const contact = await LivechatContacts.findOneById<Pick<ILivechatContact, '_id' | 'channels'>>(contactId, { + projection: { _id: 1, channels: 1 }, + }); + + if (contact) { + const channel = contact.channels?.find( + (channel: ILivechatContactChannel) => channel.name === roomInfo.source?.type && channel.visitorId === visitor._id, + ); + + if (!channel) { + Livechat.logger.debug(`Adding channel for contact ${contact._id}`); + + await LivechatContacts.addChannel(contact._id, { + name: roomInfo.source?.label || roomInfo.source?.type.toString() || OmnichannelSourceType.OTHER, + visitorId: visitor._id, + blocked: false, + verified: false, + details: roomInfo.source, + }); + } + } + } + Livechat.logger.debug(`Room obtained for visitor ${visitor._id} -> ${room._id}`); await Messages.setRoomIdByToken(visitor.token, room._id); diff --git a/apps/meteor/server/models/raw/LivechatContacts.ts b/apps/meteor/server/models/raw/LivechatContacts.ts index a2697bdd810..3daea28e326 100644 --- a/apps/meteor/server/models/raw/LivechatContacts.ts +++ b/apps/meteor/server/models/raw/LivechatContacts.ts @@ -1,4 +1,4 @@ -import type { ILivechatContact, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { ILivechatContact, ILivechatContactChannel, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { FindPaginated, ILivechatContactsModel } from '@rocket.chat/model-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { Collection, Db, RootFilterOperators, Filter, FindOptions, FindCursor, IndexDescription, UpdateResult } from 'mongodb'; @@ -61,6 +61,10 @@ export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements IL ); } + async addChannel(contactId: string, channel: ILivechatContactChannel): Promise<void> { + await this.updateOne({ _id: contactId }, { $push: { channels: channel } }); + } + updateLastChatById(contactId: string, lastChat: ILivechatContact['lastChat']): Promise<UpdateResult> { return this.updateOne({ _id: contactId }, { $set: { lastChat } }); } 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 5ea29db03f5..0c97b85a511 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -761,4 +761,52 @@ describe('LIVECHAT - contacts', () => { }); }); }); + + describe('Contact Channels', () => { + let visitor: ILivechatVisitor; + + beforeEach(async () => { + visitor = await createVisitor(); + }); + + afterEach(async () => { + await deleteVisitor(visitor.token); + }); + + it('should add a channel to a contact when creating a new room', async () => { + await request.get(api('livechat/room')).query({ token: visitor.token }); + + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: visitor.contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact.channels).to.be.an('array'); + expect(res.body.contact.channels.length).to.be.equal(1); + expect(res.body.contact.channels[0].name).to.be.equal('api'); + expect(res.body.contact.channels[0].verified).to.be.false; + expect(res.body.contact.channels[0].blocked).to.be.false; + expect(res.body.contact.channels[0].visitorId).to.be.equal(visitor._id); + }); + + it('should not add a channel if visitor already has one with same type', async () => { + const roomResult = await request.get(api('livechat/room')).query({ token: visitor.token }); + + const res = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: visitor.contactId }); + + expect(res.status).to.be.equal(200); + expect(res.body).to.have.property('success', true); + expect(res.body.contact.channels).to.be.an('array'); + expect(res.body.contact.channels.length).to.be.equal(1); + + await closeOmnichannelRoom(roomResult.body.room._id); + await request.get(api('livechat/room')).query({ token: visitor.token }); + + const secondResponse = await request.get(api(`omnichannel/contacts.get`)).set(credentials).query({ contactId: visitor.contactId }); + + expect(secondResponse.status).to.be.equal(200); + expect(secondResponse.body).to.have.property('success', true); + expect(secondResponse.body.contact.channels).to.be.an('array'); + expect(secondResponse.body.contact.channels.length).to.be.equal(1); + }); + }); }); diff --git a/packages/apps-engine/src/definition/livechat/ILivechatRoom.ts b/packages/apps-engine/src/definition/livechat/ILivechatRoom.ts index e3f55142331..bebbcb54f05 100644 --- a/packages/apps-engine/src/definition/livechat/ILivechatRoom.ts +++ b/packages/apps-engine/src/definition/livechat/ILivechatRoom.ts @@ -21,6 +21,8 @@ interface IOmnichannelSourceApp { label?: string; sidebarIcon?: string; defaultIcon?: string; + // The destination of the message (e.g widget host, email address, whatsapp number, etc) + destination?: string; } type OmnichannelSource = | { diff --git a/packages/core-typings/src/ILivechatContact.ts b/packages/core-typings/src/ILivechatContact.ts index d434493c3b9..66f0eeeed82 100644 --- a/packages/core-typings/src/ILivechatContact.ts +++ b/packages/core-typings/src/ILivechatContact.ts @@ -1,9 +1,15 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; +import type { IOmnichannelSource } from './IRoom'; export interface ILivechatContactChannel { name: string; verified: boolean; visitorId: string; + blocked: boolean; + field?: string; + value?: string; + verifiedAt?: Date; + details?: IOmnichannelSource; } export interface ILivechatContactConflictingField { diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 8ef3fa83855..cba7fbede92 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -180,6 +180,8 @@ export interface IOmnichannelSource { sidebarIcon?: string; // The default sidebar icon defaultIcon?: string; + // The destination of the message (e.g widget host, email address, whatsapp number, etc) + destination?: string; } export interface IOmnichannelSourceFromApp extends IOmnichannelSource { @@ -189,6 +191,7 @@ export interface IOmnichannelSourceFromApp extends IOmnichannelSource { sidebarIcon?: string; defaultIcon?: string; alias?: string; + destination?: string; } export interface IOmnichannelGenericRoom extends Omit<IRoom, 'default' | 'featured' | 'broadcast'> { diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts index 8412adf11e9..b57cc0cde49 100644 --- a/packages/model-typings/src/models/ILivechatContactsModel.ts +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -1,10 +1,11 @@ -import type { ILivechatContact } from '@rocket.chat/core-typings'; +import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings'; import type { FindCursor, FindOptions, UpdateResult } from 'mongodb'; import type { FindPaginated, IBaseModel } from './IBaseModel'; export interface ILivechatContactsModel extends IBaseModel<ILivechatContact> { updateContact(contactId: string, data: Partial<ILivechatContact>): Promise<ILivechatContact>; + addChannel(contactId: string, channel: ILivechatContactChannel): Promise<void>; findPaginatedContacts(searchText?: string, options?: FindOptions): FindPaginated<FindCursor<ILivechatContact>>; updateLastChatById(contactId: string, lastChat: ILivechatContact['lastChat']): Promise<UpdateResult>; } -- GitLab