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