From 161c9a397d7b9a69145ea00658a7b76ccde122dd Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 15 Aug 2025 18:26:18 -0300 Subject: [PATCH 1/6] chore: move registerGuest to omni-core --- .../app/apps/server/bridges/livechat.ts | 6 +- .../app/livechat/imports/server/rest/sms.ts | 5 +- .../app/livechat/server/api/v1/message.ts | 12 +- .../app/livechat/server/api/v1/visitor.ts | 4 +- .../app/livechat/server/lib/Visitors.ts | 127 +------------ apps/meteor/app/livechat/server/lib/guests.ts | 46 +---- .../EmailInbox/EmailInbox_Incoming.ts | 3 +- packages/omni-core/src/index.ts | 1 + packages/omni-core/src/visitor/create.ts | 173 ++++++++++++++++++ 9 files changed, 196 insertions(+), 181 deletions(-) create mode 100644 packages/omni-core/src/visitor/create.ts diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 6c1152e678b3d..90f62a07bef60 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -7,13 +7,13 @@ import { LivechatBridge } from '@rocket.chat/apps-engine/server/bridges/Livechat import type { ILivechatDepartment, IOmnichannelRoom, SelectedAgent, IMessage, ILivechatVisitor } from '@rocket.chat/core-typings'; import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; import { callbacks } from '../../../../lib/callbacks'; import { deasyncPromise } from '../../../../server/deasync/deasync'; import { closeRoom } from '../../../livechat/server/lib/closeRoom'; import { setCustomFields } from '../../../livechat/server/lib/custom-fields'; import { getRoomMessages } from '../../../livechat/server/lib/getRoomMessages'; -import { registerGuest } from '../../../livechat/server/lib/guests'; import type { ILivechatMessage } from '../../../livechat/server/lib/localTypes'; import { updateMessage, sendMessage } from '../../../livechat/server/lib/messages'; import { createRoom } from '../../../livechat/server/lib/rooms'; @@ -207,8 +207,8 @@ export class AppLivechatBridge extends LivechatBridge { name: visitor.name, token: visitor.token, email: '', - connectionData: undefined, id: visitor.id, + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), ...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }), ...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }), }; @@ -231,8 +231,8 @@ export class AppLivechatBridge extends LivechatBridge { name: visitor.name, token: visitor.token, email: '', - connectionData: undefined, id: visitor.id, + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), ...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }), ...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }), }; diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index d8546be970f4c..f0faad8e34843 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -10,6 +10,7 @@ import type { import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; import { Random } from '@rocket.chat/random'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Meteor } from 'meteor/meteor'; @@ -20,7 +21,6 @@ import { FileUpload } from '../../../../file-upload/server'; import { checkUrlForSsrf } from '../../../../lib/server/functions/checkUrlForSsrf'; import { settings } from '../../../../settings/server'; import { setCustomField } from '../../../server/api/lib/customFields'; -import { registerGuest } from '../../../server/lib/guests'; import type { ILivechatMessage } from '../../../server/lib/localTypes'; import { sendMessage } from '../../../server/lib/messages'; import { createRoom } from '../../../server/lib/rooms'; @@ -59,8 +59,9 @@ const defineDepartment = async (idOrName?: string) => { const defineVisitor = async (smsNumber: string, targetDepartment?: string) => { const visitor = await LivechatVisitors.findOneVisitorByPhone(smsNumber); - let data: { token: string; department?: string } = { + let data: { token: string; department?: string; shouldConsiderIdleAgent: boolean } = { token: visitor?.token || Random.id(), + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), }; if (!visitor) { diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 67c9a7e397c58..8d62588bf1753 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -1,5 +1,6 @@ import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms, Messages } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; import { Random } from '@rocket.chat/random'; import { isPOSTLivechatMessageParams, @@ -17,7 +18,6 @@ import { isWidget } from '../../../../api/server/helpers/isWidget'; import { loadMessageHistory } from '../../../../lib/server/functions/loadMessageHistory'; import { settings } from '../../../../settings/server'; import { normalizeMessageFileUpload } from '../../../../utils/server/functions/normalizeMessageFileUpload'; -import { registerGuest } from '../../lib/guests'; import { updateMessage, deleteMessage, sendMessage } from '../../lib/messages'; import { findGuest, findRoom, normalizeHttpHeaderData } from '../lib/livechat'; @@ -267,9 +267,15 @@ API.v1.addRoute( rid = Random.id(); const guest: typeof this.bodyParams.visitor & { connectionData?: unknown } = this.bodyParams.visitor; - guest.connectionData = normalizeHttpHeaderData(this.request.headers); - const visitor = await registerGuest(guest); + if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) { + guest.connectionData = normalizeHttpHeaderData(this.request.headers); + } + + const visitor = await registerGuest({ + ...guest, + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + }); if (!visitor) { throw new Error('error-livechat-visitor-registration'); } diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index 1bc28cdd8280b..ca710f38cfbfc 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -1,5 +1,6 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { LivechatVisitors as VisitorsRaw, LivechatRooms } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -7,7 +8,7 @@ import { callbacks } from '../../../../../lib/callbacks'; import { API } from '../../../../api/server'; import { settings } from '../../../../settings/server'; import { setMultipleVisitorCustomFields } from '../../lib/custom-fields'; -import { registerGuest, notifyGuestStatusChanged, removeContactsByVisitorId } from '../../lib/guests'; +import { notifyGuestStatusChanged, removeContactsByVisitorId } from '../../lib/guests'; import { livechatLogger } from '../../lib/logger'; import { saveRoomInfo } from '../../lib/rooms'; import { updateCallStatus } from '../../lib/utils'; @@ -56,6 +57,7 @@ API.v1.addRoute( ...(username && { username }), ...(connectionData && { connectionData }), ...(phone && typeof phone === 'string' && { phone: { number: phone as string } }), + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), connectionData: normalizeHttpHeaderData(this.request.headers), }; diff --git a/apps/meteor/app/livechat/server/lib/Visitors.ts b/apps/meteor/app/livechat/server/lib/Visitors.ts index 6ff046b3fc211..4bd81498b7824 100644 --- a/apps/meteor/app/livechat/server/lib/Visitors.ts +++ b/apps/meteor/app/livechat/server/lib/Visitors.ts @@ -1,25 +1,6 @@ -import { UserStatus } from '@rocket.chat/core-typings'; -import type { ILivechatContactVisitorAssociation, IOmnichannelSource, ILivechatVisitor } from '@rocket.chat/core-typings'; -import { Logger } from '@rocket.chat/logger'; -import { LivechatContacts, LivechatDepartment, LivechatVisitors, Users } from '@rocket.chat/models'; - -import { validateEmail } from './Helper'; -import { settings } from '../../../settings/server'; - -const logger = new Logger('Livechat - Visitor'); - -export type RegisterGuestType = Partial> & { - id?: string; - connectionData?: any; - email?: string; - phone?: { number: string }; -}; +import type { ILivechatContactVisitorAssociation, IOmnichannelSource } from '@rocket.chat/core-typings'; export const Visitors = { - isValidObject(obj: unknown): obj is Record { - return typeof obj === 'object' && obj !== null; - }, - makeVisitorAssociation(visitorId: string, roomInfo: IOmnichannelSource): ILivechatContactVisitorAssociation { return { visitorId, @@ -29,110 +10,4 @@ export const Visitors = { }, }; }, - - async registerGuest({ - id, - token, - name, - phone, - email, - department, - username, - connectionData, - status = UserStatus.ONLINE, - }: RegisterGuestType): Promise { - check(token, String); - check(id, Match.Maybe(String)); - - logger.debug(`New incoming conversation: id: ${id} | token: ${token}`); - - const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } = { - token, - status, - ...(phone?.number && { phone: [{ phoneNumber: phone.number }] }), - ...(name && { name }), - }; - - if (email) { - const visitorEmail = email.trim().toLowerCase(); - validateEmail(visitorEmail); - visitorDataToUpdate.visitorEmails = [{ address: visitorEmail }]; - - const contact = await LivechatContacts.findContactByEmailAndContactManager(visitorEmail); - if (contact?.contactManager) { - const shouldConsiderIdleAgent = settings.get('Livechat_enabled_when_agent_idle'); - const agent = await Users.findOneOnlineAgentById(contact.contactManager, shouldConsiderIdleAgent, { - projection: { _id: 1, username: 1, name: 1, emails: 1 }, - }); - if (agent?.username && agent.name && agent.emails) { - visitorDataToUpdate.contactManager = { - _id: agent._id, - username: agent.username, - name: agent.name, - emails: agent.emails, - }; - logger.debug(`Assigning visitor ${token} to agent ${agent.username}`); - } - } - } - - const livechatVisitor = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); - - if (department && livechatVisitor?.department !== department) { - logger.debug(`Attempt to find a department with id/name ${department}`); - const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); - if (!dep) { - logger.debug(`Invalid department provided: ${department}`); - throw new Meteor.Error('error-invalid-department', 'The provided department is invalid'); - } - logger.debug(`Assigning visitor ${token} to department ${dep._id}`); - visitorDataToUpdate.department = dep._id; - } - - visitorDataToUpdate.token = livechatVisitor?.token || token; - - let existingUser = null; - - if (livechatVisitor) { - logger.debug('Found matching user by token'); - visitorDataToUpdate._id = livechatVisitor._id; - } else if (phone?.number && (existingUser = await LivechatVisitors.findOneVisitorByPhone(phone.number))) { - logger.debug('Found matching user by phone number'); - visitorDataToUpdate._id = existingUser._id; - // Don't change token when matching by phone number, use current visitor token - visitorDataToUpdate.token = existingUser.token; - } else if (email && (existingUser = await LivechatVisitors.findOneGuestByEmailAddress(email))) { - logger.debug('Found matching user by email'); - visitorDataToUpdate._id = existingUser._id; - } else if (!livechatVisitor) { - logger.debug(`No matches found. Attempting to create new user with token ${token}`); - - visitorDataToUpdate._id = id || undefined; - visitorDataToUpdate.username = username || (await LivechatVisitors.getNextVisitorUsername()); - visitorDataToUpdate.status = status; - visitorDataToUpdate.ts = new Date(); - - if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations') && this.isValidObject(connectionData)) { - logger.debug(`Saving connection data for visitor ${token}`); - const { httpHeaders, clientAddress } = connectionData; - if (this.isValidObject(httpHeaders)) { - visitorDataToUpdate.userAgent = httpHeaders['user-agent']; - visitorDataToUpdate.ip = httpHeaders['x-real-ip'] || httpHeaders['x-forwarded-for'] || clientAddress; - visitorDataToUpdate.host = httpHeaders?.host; - } - } - } - - const upsertedLivechatVisitor = await LivechatVisitors.updateOneByIdOrToken(visitorDataToUpdate, { - upsert: true, - returnDocument: 'after', - }); - - if (!upsertedLivechatVisitor) { - logger.debug(`No visitor found after upsert`); - return null; - } - - return upsertedLivechatVisitor; - }, }; diff --git a/apps/meteor/app/livechat/server/lib/guests.ts b/apps/meteor/app/livechat/server/lib/guests.ts index f4bee437099e5..454dc55d812e4 100644 --- a/apps/meteor/app/livechat/server/lib/guests.ts +++ b/apps/meteor/app/livechat/server/lib/guests.ts @@ -11,13 +11,9 @@ import { LivechatContacts, Users, } from '@rocket.chat/models'; -import { wrapExceptions } from '@rocket.chat/tools'; import UAParser from 'ua-parser-js'; -import { parseAgentCustomFields, validateEmail } from './Helper'; -import type { RegisterGuestType } from './Visitors'; -import { Visitors } from './Visitors'; -import { ContactMerger, type FieldAndValue } from './contacts/ContactMerger'; +import { parseAgentCustomFields } from './Helper'; import type { ICRMData } from './localTypes'; import { livechatLogger } from './logger'; import { trim } from '../../../../lib/utils/stringUtils'; @@ -102,46 +98,6 @@ export async function removeContactsByVisitorId({ _id }: { _id: string }) { } } -export async function registerGuest(newData: RegisterGuestType): Promise { - const visitor = await Visitors.registerGuest(newData); - if (!visitor) { - return null; - } - - const { name, phone, email, username } = newData; - - const validatedEmail = - email && - wrapExceptions(() => { - const trimmedEmail = email.trim().toLowerCase(); - validateEmail(trimmedEmail); - return trimmedEmail; - }).suppress(); - - const fields = [ - { type: 'name', value: name }, - { type: 'phone', value: phone?.number }, - { type: 'email', value: validatedEmail }, - { type: 'username', value: username || visitor.username }, - ].filter((field) => Boolean(field.value)) as FieldAndValue[]; - - if (!fields.length) { - return null; - } - - // If a visitor was updated who already had contacts, load up the contacts and update that information as well - const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); - for await (const contact of contacts) { - await ContactMerger.mergeFieldsIntoContact({ - fields, - contact, - conflictHandlingMode: contact.unknown ? 'overwrite' : 'conflict', - }); - } - - return visitor; -} - async function cleanGuestHistory(_id: string) { // This shouldn't be possible, but just in case if (!_id) { diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index d7a6ce004fc62..31a375ab20aae 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -7,6 +7,7 @@ import type { } from '@rocket.chat/core-typings'; import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms, Messages } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; import { Random } from '@rocket.chat/random'; import type { ParsedMail, Attachment } from 'mailparser'; import { stripHtml } from 'string-strip-html'; @@ -16,7 +17,6 @@ import { FileUpload } from '../../../app/file-upload/server'; import { notifyOnMessageChange } from '../../../app/lib/server/lib/notifyListener'; import { QueueManager } from '../../../app/livechat/server/lib/QueueManager'; import { setDepartmentForGuest } from '../../../app/livechat/server/lib/departmentsLib'; -import { registerGuest } from '../../../app/livechat/server/lib/guests'; import { sendMessage } from '../../../app/livechat/server/lib/messages'; import { settings } from '../../../app/settings/server'; import { i18n } from '../../lib/i18n'; @@ -46,6 +46,7 @@ async function getGuestByEmail(email: string, name: string, department = ''): Pr name: name || email, email, department, + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), }); if (!livechatVisitor) { diff --git a/packages/omni-core/src/index.ts b/packages/omni-core/src/index.ts index e7784bda38aca..c0a01acf69dd1 100644 --- a/packages/omni-core/src/index.ts +++ b/packages/omni-core/src/index.ts @@ -1 +1,2 @@ export * from './isDepartmentCreationAvailable'; +export * from './visitor/create'; diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts new file mode 100644 index 0000000000000..41251f1797535 --- /dev/null +++ b/packages/omni-core/src/visitor/create.ts @@ -0,0 +1,173 @@ +import { type ILivechatVisitor, UserStatus } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { LivechatContacts, LivechatDepartment, LivechatVisitors, Users } from '@rocket.chat/models'; + +const logger = new Logger('Livechat - Visitor'); + +export type RegisterGuestType = Partial> & { + id?: string; + connectionData?: any; + email?: string; + phone?: { number: string }; + shouldConsiderIdleAgent: boolean; +}; + +// TODO move to @rocket.chat/tools +export const validateEmail = (email: string, options: { style: string } = { style: 'basic' }): boolean => { + const basicEmailRegex = /^[^@]+@[^@]+$/; + const rfcEmailRegex = + /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + + switch (options.style) { + case 'rfc': + return rfcEmailRegex.test(email); + case 'basic': + default: + return basicEmailRegex.test(email); + } +}; + +// TODO move to @rocket.chat/tools +function isValidObject(obj: unknown): obj is Record { + return typeof obj === 'object' && obj !== null; +} + +export async function registerGuest({ + id, + token, + name, + phone, + email, + department, + username, + connectionData, + status = UserStatus.ONLINE, + shouldConsiderIdleAgent, +}: RegisterGuestType): Promise { + if (!token) { + throw Error('error-invalid-token'); + } + + logger.debug(`New incoming conversation: id: ${id} | token: ${token}`); + + const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } = { + token, + status, + ...(phone?.number && { phone: [{ phoneNumber: phone.number }] }), + ...(name && { name }), + }; + + if (email) { + const visitorEmail = email.trim().toLowerCase(); + validateEmail(visitorEmail); + visitorDataToUpdate.visitorEmails = [{ address: visitorEmail }]; + + const contact = await LivechatContacts.findContactByEmailAndContactManager(visitorEmail); + if (contact?.contactManager) { + const agent = await Users.findOneOnlineAgentById(contact.contactManager, shouldConsiderIdleAgent, { + projection: { _id: 1, username: 1, name: 1, emails: 1 }, + }); + if (agent?.username && agent.name && agent.emails) { + visitorDataToUpdate.contactManager = { + _id: agent._id, + username: agent.username, + name: agent.name, + emails: agent.emails, + }; + logger.debug(`Assigning visitor ${token} to agent ${agent.username}`); + } + } + } + + const livechatVisitor = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); + + if (department && livechatVisitor?.department !== department) { + logger.debug(`Attempt to find a department with id/name ${department}`); + const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); + if (!dep) { + logger.debug(`Invalid department provided: ${department}`); + // throw new Meteor.Error('error-invalid-department', 'The provided department is invalid'); + throw new Error('error-invalid-department'); + } + logger.debug(`Assigning visitor ${token} to department ${dep._id}`); + visitorDataToUpdate.department = dep._id; + } + + visitorDataToUpdate.token = livechatVisitor?.token || token; + + let existingUser = null; + + if (livechatVisitor) { + logger.debug('Found matching user by token'); + visitorDataToUpdate._id = livechatVisitor._id; + } else if (phone?.number && (existingUser = await LivechatVisitors.findOneVisitorByPhone(phone.number))) { + logger.debug('Found matching user by phone number'); + visitorDataToUpdate._id = existingUser._id; + // Don't change token when matching by phone number, use current visitor token + visitorDataToUpdate.token = existingUser.token; + } else if (email && (existingUser = await LivechatVisitors.findOneGuestByEmailAddress(email))) { + logger.debug('Found matching user by email'); + visitorDataToUpdate._id = existingUser._id; + } else if (!livechatVisitor) { + logger.debug(`No matches found. Attempting to create new user with token ${token}`); + + visitorDataToUpdate._id = id || undefined; + visitorDataToUpdate.username = username || (await LivechatVisitors.getNextVisitorUsername()); + visitorDataToUpdate.status = status; + visitorDataToUpdate.ts = new Date(); + + if (isValidObject(connectionData)) { + logger.debug(`Saving connection data for visitor ${token}`); + const { httpHeaders, clientAddress } = connectionData; + if (isValidObject(httpHeaders)) { + visitorDataToUpdate.userAgent = httpHeaders['user-agent']; + visitorDataToUpdate.ip = httpHeaders['x-real-ip'] || httpHeaders['x-forwarded-for'] || clientAddress; + visitorDataToUpdate.host = httpHeaders?.host; + } + } + } + + const upsertedLivechatVisitor = await LivechatVisitors.updateOneByIdOrToken(visitorDataToUpdate, { + upsert: true, + returnDocument: 'after', + }); + + if (!upsertedLivechatVisitor) { + logger.debug(`No visitor found after upsert`); + return null; + } + + // TODO this will be moved to a patch function to handle contact + // const { name, phone, email, username } = newData; + + // const validatedEmail = + // email && + // wrapExceptions(() => { + // const trimmedEmail = email.trim().toLowerCase(); + // validateEmail(trimmedEmail); + // return trimmedEmail; + // }).suppress(); + + // const fields = [ + // { type: 'name', value: name }, + // { type: 'phone', value: phone?.number }, + // { type: 'email', value: validatedEmail }, + // { type: 'username', value: username || visitor.username }, + // ].filter((field) => Boolean(field.value)) as FieldAndValue[]; + + // if (!fields.length) { + // return null; + // } + + // // If a visitor was updated who already had contacts, load up the contacts and update that information as well + // const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); + // for await (const contact of contacts) { + // await ContactMerger.mergeFieldsIntoContact({ + // fields, + // contact, + // conflictHandlingMode: contact.unknown ? 'overwrite' : 'conflict', + // }); + // } + + return upsertedLivechatVisitor; +} From 73012fbf58343f3020978fecb3a3e2362f487061 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 18 Aug 2025 19:00:36 -0300 Subject: [PATCH 2/6] move validaEmail function to @rocket.chat/tools --- packages/omni-core/package.json | 4 ++++ packages/tools/src/index.ts | 1 + packages/tools/src/validateEmail.ts | 13 +++++++++++++ yarn.lock | 3 ++- 4 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 packages/tools/src/validateEmail.ts diff --git a/packages/omni-core/package.json b/packages/omni-core/package.json index 2dbe7bd594772..dd50d9575e99d 100644 --- a/packages/omni-core/package.json +++ b/packages/omni-core/package.json @@ -5,6 +5,7 @@ "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/tools": "workspace:*", "@rocket.chat/tsconfig": "workspace:*", "@types/jest": "~30.0.0", "eslint": "~8.45.0", @@ -23,6 +24,9 @@ "files": [ "/dist" ], + "volta": { + "extends": "../../package.json" + }, "dependencies": { "@rocket.chat/models": "workspace:^", "@rocket.chat/patch-injection": "workspace:^" diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index ddde6d93c76ef..8734e9f4d8c1e 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -12,3 +12,4 @@ export * from './converter'; export * from './removeEmpty'; export * from './isObject'; export * from './isRecord'; +export * from './validateEmail'; diff --git a/packages/tools/src/validateEmail.ts b/packages/tools/src/validateEmail.ts new file mode 100644 index 0000000000000..fd9237f68ec05 --- /dev/null +++ b/packages/tools/src/validateEmail.ts @@ -0,0 +1,13 @@ +export const validateEmail = (email: string, options: { style: string } = { style: 'basic' }): boolean => { + const basicEmailRegex = /^[^@]+@[^@]+$/; + const rfcEmailRegex = + /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + + switch (options.style) { + case 'rfc': + return rfcEmailRegex.test(email); + case 'basic': + default: + return basicEmailRegex.test(email); + } +}; diff --git a/yarn.lock b/yarn.lock index 71129d9154855..4e3af52f4bac6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8375,6 +8375,7 @@ __metadata: "@rocket.chat/jest-presets": "workspace:~" "@rocket.chat/models": "workspace:^" "@rocket.chat/patch-injection": "workspace:^" + "@rocket.chat/tools": "workspace:*" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" eslint: "npm:~8.45.0" @@ -8890,7 +8891,7 @@ __metadata: languageName: node linkType: hard -"@rocket.chat/tools@workspace:^, @rocket.chat/tools@workspace:packages/tools, @rocket.chat/tools@workspace:~": +"@rocket.chat/tools@workspace:*, @rocket.chat/tools@workspace:^, @rocket.chat/tools@workspace:packages/tools, @rocket.chat/tools@workspace:~": version: 0.0.0-use.local resolution: "@rocket.chat/tools@workspace:packages/tools" dependencies: From 00613e65a172088c8be13b760d0a12e0fdc93db6 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 18 Aug 2025 19:00:52 -0300 Subject: [PATCH 3/6] patch registerGuest with ContactManager stuff --- apps/meteor/app/livechat/server/startup.ts | 47 +++- packages/omni-core/src/visitor/create.ts | 254 +++++++++------------ 2 files changed, 149 insertions(+), 152 deletions(-) diff --git a/apps/meteor/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index 1df1245655bab..a9ff46e140cda 100644 --- a/apps/meteor/app/livechat/server/startup.ts +++ b/apps/meteor/app/livechat/server/startup.ts @@ -1,7 +1,9 @@ import type { IUser } from '@rocket.chat/core-typings'; import { ILivechatAgentStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; -import { LivechatRooms } from '@rocket.chat/models'; +import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; +import { registerGuest, type RegisterGuestType } from '@rocket.chat/omni-core'; +import { validateEmail, wrapExceptions } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; @@ -18,9 +20,52 @@ import { hasPermissionAsync } from '../../authorization/server/functions/hasPerm import { notifyOnUserChange } from '../../lib/server/lib/notifyListener'; import { settings } from '../../settings/server'; import './roomAccessValidator.internalService'; +import { ContactMerger, type FieldAndValue } from './lib/contacts/ContactMerger'; const logger = new Logger('LivechatStartup'); +// TODO this patch is temporary because `ContactMerger` still a lot of dependencies, so it is not suitable to be moved to omni-core package +// TODO add tests covering the ContactMerger usage +registerGuest.patch(async (originalFn, newData: RegisterGuestType) => { + const visitor = await originalFn(newData); + if (!visitor) { + return null; + } + + const { name, phone, email, username } = newData; + + const validatedEmail = + email && + wrapExceptions(() => { + const trimmedEmail = email.trim().toLowerCase(); + validateEmail(trimmedEmail); + return trimmedEmail; + }).suppress(); + + const fields = [ + { type: 'name', value: name }, + { type: 'phone', value: phone?.number }, + { type: 'email', value: validatedEmail }, + { type: 'username', value: username || visitor.username }, + ].filter((field) => Boolean(field.value)) as FieldAndValue[]; + + if (!fields.length) { + return null; + } + + // If a visitor was updated who already had contacts, load up the contacts and update that information as well + const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); + for await (const contact of contacts) { + await ContactMerger.mergeFieldsIntoContact({ + fields, + contact, + conflictHandlingMode: contact.unknown ? 'overwrite' : 'conflict', + }); + } + + return visitor; +}); + Meteor.startup(async () => { roomCoordinator.setRoomFind('l', async (id) => maybeMigrateLivechatRoom(await LivechatRooms.findOneById(id))); diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts index 41251f1797535..411bf3ce12ac7 100644 --- a/packages/omni-core/src/visitor/create.ts +++ b/packages/omni-core/src/visitor/create.ts @@ -1,6 +1,8 @@ import { type ILivechatVisitor, UserStatus } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { LivechatContacts, LivechatDepartment, LivechatVisitors, Users } from '@rocket.chat/models'; +import { makeFunction } from '@rocket.chat/patch-injection'; +import { validateEmail } from '@rocket.chat/tools'; const logger = new Logger('Livechat - Visitor'); @@ -12,162 +14,112 @@ export type RegisterGuestType = Partial { - const basicEmailRegex = /^[^@]+@[^@]+$/; - const rfcEmailRegex = - /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; - - switch (options.style) { - case 'rfc': - return rfcEmailRegex.test(email); - case 'basic': - default: - return basicEmailRegex.test(email); - } -}; - -// TODO move to @rocket.chat/tools -function isValidObject(obj: unknown): obj is Record { - return typeof obj === 'object' && obj !== null; -} - -export async function registerGuest({ - id, - token, - name, - phone, - email, - department, - username, - connectionData, - status = UserStatus.ONLINE, - shouldConsiderIdleAgent, -}: RegisterGuestType): Promise { - if (!token) { - throw Error('error-invalid-token'); - } - - logger.debug(`New incoming conversation: id: ${id} | token: ${token}`); - - const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } = { +export const registerGuest = makeFunction( + async ({ + id, token, - status, - ...(phone?.number && { phone: [{ phoneNumber: phone.number }] }), - ...(name && { name }), - }; - - if (email) { - const visitorEmail = email.trim().toLowerCase(); - validateEmail(visitorEmail); - visitorDataToUpdate.visitorEmails = [{ address: visitorEmail }]; - - const contact = await LivechatContacts.findContactByEmailAndContactManager(visitorEmail); - if (contact?.contactManager) { - const agent = await Users.findOneOnlineAgentById(contact.contactManager, shouldConsiderIdleAgent, { - projection: { _id: 1, username: 1, name: 1, emails: 1 }, - }); - if (agent?.username && agent.name && agent.emails) { - visitorDataToUpdate.contactManager = { - _id: agent._id, - username: agent.username, - name: agent.name, - emails: agent.emails, - }; - logger.debug(`Assigning visitor ${token} to agent ${agent.username}`); + name, + phone, + email, + department, + username, + connectionData, + status = UserStatus.ONLINE, + shouldConsiderIdleAgent, + }: RegisterGuestType): Promise => { + if (!token) { + throw Error('error-invalid-token'); + } + + logger.debug(`New incoming conversation: id: ${id} | token: ${token}`); + + const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } = { + token, + status, + ...(phone?.number && { phone: [{ phoneNumber: phone.number }] }), + ...(name && { name }), + }; + + if (email) { + const visitorEmail = email.trim().toLowerCase(); + validateEmail(visitorEmail); + visitorDataToUpdate.visitorEmails = [{ address: visitorEmail }]; + + const contact = await LivechatContacts.findContactByEmailAndContactManager(visitorEmail); + if (contact?.contactManager) { + const agent = await Users.findOneOnlineAgentById(contact.contactManager, shouldConsiderIdleAgent, { + projection: { _id: 1, username: 1, name: 1, emails: 1 }, + }); + if (agent?.username && agent.name && agent.emails) { + visitorDataToUpdate.contactManager = { + _id: agent._id, + username: agent.username, + name: agent.name, + emails: agent.emails, + }; + logger.debug(`Assigning visitor ${token} to agent ${agent.username}`); + } } } - } - const livechatVisitor = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); + const livechatVisitor = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); - if (department && livechatVisitor?.department !== department) { - logger.debug(`Attempt to find a department with id/name ${department}`); - const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); - if (!dep) { - logger.debug(`Invalid department provided: ${department}`); - // throw new Meteor.Error('error-invalid-department', 'The provided department is invalid'); - throw new Error('error-invalid-department'); + if (department && livechatVisitor?.department !== department) { + logger.debug(`Attempt to find a department with id/name ${department}`); + const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); + if (!dep) { + logger.debug(`Invalid department provided: ${department}`); + // throw new Meteor.Error('error-invalid-department', 'The provided department is invalid'); + throw new Error('error-invalid-department'); + } + logger.debug(`Assigning visitor ${token} to department ${dep._id}`); + visitorDataToUpdate.department = dep._id; } - logger.debug(`Assigning visitor ${token} to department ${dep._id}`); - visitorDataToUpdate.department = dep._id; - } - - visitorDataToUpdate.token = livechatVisitor?.token || token; - - let existingUser = null; - - if (livechatVisitor) { - logger.debug('Found matching user by token'); - visitorDataToUpdate._id = livechatVisitor._id; - } else if (phone?.number && (existingUser = await LivechatVisitors.findOneVisitorByPhone(phone.number))) { - logger.debug('Found matching user by phone number'); - visitorDataToUpdate._id = existingUser._id; - // Don't change token when matching by phone number, use current visitor token - visitorDataToUpdate.token = existingUser.token; - } else if (email && (existingUser = await LivechatVisitors.findOneGuestByEmailAddress(email))) { - logger.debug('Found matching user by email'); - visitorDataToUpdate._id = existingUser._id; - } else if (!livechatVisitor) { - logger.debug(`No matches found. Attempting to create new user with token ${token}`); - - visitorDataToUpdate._id = id || undefined; - visitorDataToUpdate.username = username || (await LivechatVisitors.getNextVisitorUsername()); - visitorDataToUpdate.status = status; - visitorDataToUpdate.ts = new Date(); - - if (isValidObject(connectionData)) { - logger.debug(`Saving connection data for visitor ${token}`); - const { httpHeaders, clientAddress } = connectionData; - if (isValidObject(httpHeaders)) { - visitorDataToUpdate.userAgent = httpHeaders['user-agent']; - visitorDataToUpdate.ip = httpHeaders['x-real-ip'] || httpHeaders['x-forwarded-for'] || clientAddress; - visitorDataToUpdate.host = httpHeaders?.host; + + visitorDataToUpdate.token = livechatVisitor?.token || token; + + let existingUser = null; + + if (livechatVisitor) { + logger.debug('Found matching user by token'); + visitorDataToUpdate._id = livechatVisitor._id; + } else if (phone?.number && (existingUser = await LivechatVisitors.findOneVisitorByPhone(phone.number))) { + logger.debug('Found matching user by phone number'); + visitorDataToUpdate._id = existingUser._id; + // Don't change token when matching by phone number, use current visitor token + visitorDataToUpdate.token = existingUser.token; + } else if (email && (existingUser = await LivechatVisitors.findOneGuestByEmailAddress(email))) { + logger.debug('Found matching user by email'); + visitorDataToUpdate._id = existingUser._id; + } else if (!livechatVisitor) { + logger.debug(`No matches found. Attempting to create new user with token ${token}`); + + visitorDataToUpdate._id = id || undefined; + visitorDataToUpdate.username = username || (await LivechatVisitors.getNextVisitorUsername()); + visitorDataToUpdate.status = status; + visitorDataToUpdate.ts = new Date(); + + if (connectionData && typeof connectionData === 'object') { + logger.debug(`Saving connection data for visitor ${token}`); + const { httpHeaders, clientAddress } = connectionData; + if (httpHeaders && typeof httpHeaders === 'object') { + visitorDataToUpdate.userAgent = httpHeaders['user-agent']; + visitorDataToUpdate.ip = httpHeaders['x-real-ip'] || httpHeaders['x-forwarded-for'] || clientAddress; + visitorDataToUpdate.host = httpHeaders.host; + } } } - } - - const upsertedLivechatVisitor = await LivechatVisitors.updateOneByIdOrToken(visitorDataToUpdate, { - upsert: true, - returnDocument: 'after', - }); - - if (!upsertedLivechatVisitor) { - logger.debug(`No visitor found after upsert`); - return null; - } - - // TODO this will be moved to a patch function to handle contact - // const { name, phone, email, username } = newData; - - // const validatedEmail = - // email && - // wrapExceptions(() => { - // const trimmedEmail = email.trim().toLowerCase(); - // validateEmail(trimmedEmail); - // return trimmedEmail; - // }).suppress(); - - // const fields = [ - // { type: 'name', value: name }, - // { type: 'phone', value: phone?.number }, - // { type: 'email', value: validatedEmail }, - // { type: 'username', value: username || visitor.username }, - // ].filter((field) => Boolean(field.value)) as FieldAndValue[]; - - // if (!fields.length) { - // return null; - // } - - // // If a visitor was updated who already had contacts, load up the contacts and update that information as well - // const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); - // for await (const contact of contacts) { - // await ContactMerger.mergeFieldsIntoContact({ - // fields, - // contact, - // conflictHandlingMode: contact.unknown ? 'overwrite' : 'conflict', - // }); - // } - - return upsertedLivechatVisitor; -} + + const upsertedLivechatVisitor = await LivechatVisitors.updateOneByIdOrToken(visitorDataToUpdate, { + upsert: true, + returnDocument: 'after', + }); + + if (!upsertedLivechatVisitor) { + logger.debug(`No visitor found after upsert`); + return null; + } + + return upsertedLivechatVisitor; + }, +); From d17eb4f29aae7a03e1b4f71a651042b00f180948 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 20 Aug 2025 11:31:20 -0300 Subject: [PATCH 4/6] test: add tests --- packages/omni-core/src/visitor/create.spec.ts | 793 ++++++++++++++++++ 1 file changed, 793 insertions(+) create mode 100644 packages/omni-core/src/visitor/create.spec.ts diff --git a/packages/omni-core/src/visitor/create.spec.ts b/packages/omni-core/src/visitor/create.spec.ts new file mode 100644 index 0000000000000..e33562cc40af4 --- /dev/null +++ b/packages/omni-core/src/visitor/create.spec.ts @@ -0,0 +1,793 @@ +import { UserStatus } from '@rocket.chat/core-typings'; +import type { ILivechatContactsModel, ILivechatDepartmentModel, ILivechatVisitorsModel, IUsersModel } from '@rocket.chat/model-typings'; +import { registerModel } from '@rocket.chat/models'; +import { validateEmail } from '@rocket.chat/tools'; + +import { registerGuest, type RegisterGuestType } from './create'; + +// Mock the validateEmail function +jest.mock('@rocket.chat/tools', () => ({ + validateEmail: jest.fn(), +})); + +// Mock the Logger +jest.mock('@rocket.chat/logger', () => ({ + Logger: jest.fn().mockImplementation(() => ({ + debug: jest.fn(), + })), +})); + +const mockValidateEmail = validateEmail as jest.MockedFunction; + +describe('registerGuest', () => { + let updateOneByIdOrTokenSpy: jest.Mock; + let getVisitorByTokenSpy: jest.Mock; + let findOneVisitorByPhoneSpy: jest.Mock; + let findOneGuestByEmailAddressSpy: jest.Mock; + let getNextVisitorUsernameSpy: jest.Mock; + let findContactByEmailAndContactManagerSpy: jest.Mock; + let findOneOnlineAgentByIdSpy: jest.Mock; + let findOneByIdOrNameSpy: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockValidateEmail.mockImplementation(() => true); + + // Create spies that return reasonable defaults + updateOneByIdOrTokenSpy = jest.fn().mockResolvedValue({ _id: 'visitor-123', token: 'test-token' }); + getVisitorByTokenSpy = jest.fn().mockResolvedValue(null); + findOneVisitorByPhoneSpy = jest.fn().mockResolvedValue(null); + findOneGuestByEmailAddressSpy = jest.fn().mockResolvedValue(null); + getNextVisitorUsernameSpy = jest.fn().mockResolvedValue('guest-123'); + findContactByEmailAndContactManagerSpy = jest.fn().mockResolvedValue(null); + findOneOnlineAgentByIdSpy = jest.fn().mockResolvedValue(null); + findOneByIdOrNameSpy = jest.fn().mockResolvedValue(null); + + // Register the models with spy functions + registerModel('ILivechatVisitorsModel', { + getVisitorByToken: getVisitorByTokenSpy, + findOneVisitorByPhone: findOneVisitorByPhoneSpy, + findOneGuestByEmailAddress: findOneGuestByEmailAddressSpy, + getNextVisitorUsername: getNextVisitorUsernameSpy, + updateOneByIdOrToken: updateOneByIdOrTokenSpy, + } as unknown as ILivechatVisitorsModel); + + registerModel('ILivechatContactsModel', { + findContactByEmailAndContactManager: findContactByEmailAndContactManagerSpy, + } as unknown as ILivechatContactsModel); + + registerModel('IUsersModel', { + findOneOnlineAgentById: findOneOnlineAgentByIdSpy, + } as unknown as IUsersModel); + + registerModel('ILivechatDepartmentModel', { + findOneByIdOrName: findOneByIdOrNameSpy, + } as unknown as ILivechatDepartmentModel); + }); + + describe('validation', () => { + it('should throw error when token is not provided', async () => { + const guestData: RegisterGuestType = { + shouldConsiderIdleAgent: false, + }; + + await expect(registerGuest(guestData)).rejects.toThrow('error-invalid-token'); + }); + + it('should throw error when token is empty string', async () => { + const guestData: RegisterGuestType = { + token: '', + shouldConsiderIdleAgent: false, + }; + + await expect(registerGuest(guestData)).rejects.toThrow('error-invalid-token'); + }); + }); + + describe('email validation and contact manager assignment', () => { + it('should validate email and assign contact manager when available', async () => { + const email = 'test@example.com'; + const token = 'test-token'; + const agentId = 'agent-123'; + + const mockAgent = { + _id: agentId, + username: 'agent.user', + name: 'Agent User', + emails: [{ address: 'agent@example.com' }], + }; + + const mockContact = { + contactManager: agentId, + }; + + findContactByEmailAndContactManagerSpy.mockResolvedValue(mockContact); + findOneOnlineAgentByIdSpy.mockResolvedValue(mockAgent); + + const guestData: RegisterGuestType = { + token, + email, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(mockValidateEmail).toHaveBeenCalledWith('test@example.com'); + expect(findContactByEmailAndContactManagerSpy).toHaveBeenCalledWith('test@example.com'); + expect(findOneOnlineAgentByIdSpy).toHaveBeenCalledWith(agentId, false, { projection: { _id: 1, username: 1, name: 1, emails: 1 } }); + + // Verify the data passed to updateOneByIdOrToken + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status: UserStatus.ONLINE, + visitorEmails: [{ address: email }], + contactManager: { + _id: agentId, + username: 'agent.user', + name: 'Agent User', + emails: [{ address: 'agent@example.com' }], + }, + username: 'guest-123', + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should not assign contact manager when agent is not found', async () => { + const email = 'test@example.com'; + const token = 'test-token'; + + const mockContact = { + contactManager: 'agent-123', + }; + + findContactByEmailAndContactManagerSpy.mockResolvedValue(mockContact); + findOneOnlineAgentByIdSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + token, + email, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + // Verify contact manager is not included in the data + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status: UserStatus.ONLINE, + visitorEmails: [{ address: email }], + username: 'guest-123', + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + + // Ensure contactManager is not present + const callArgs = updateOneByIdOrTokenSpy.mock.calls[0][0]; + expect(callArgs.contactManager).toBeUndefined(); + }); + + it('should trim and lowercase email', async () => { + const email = ' TEST@EXAMPLE.COM '; + const token = 'test-token'; + + const guestData: RegisterGuestType = { + token, + email, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(mockValidateEmail).toHaveBeenCalledWith('test@example.com'); + expect(findContactByEmailAndContactManagerSpy).toHaveBeenCalledWith('test@example.com'); + + // Verify the trimmed and lowercase email is passed to updateOneByIdOrToken + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + visitorEmails: [{ address: 'test@example.com' }], + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); + + describe('department validation and assignment', () => { + it('should assign valid department', async () => { + const token = 'test-token'; + const department = 'sales'; + const departmentId = 'dept-123'; + + const mockDepartment = { + _id: departmentId, + }; + + findOneByIdOrNameSpy.mockResolvedValue(mockDepartment); + + const guestData: RegisterGuestType = { + token, + department, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(findOneByIdOrNameSpy).toHaveBeenCalledWith(department, { projection: { _id: 1 } }); + + // Verify the department ID is passed to updateOneByIdOrToken + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status: UserStatus.ONLINE, + department: departmentId, + username: 'guest-123', + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should throw error for invalid department', async () => { + const token = 'test-token'; + const department = 'invalid-dept'; + + findOneByIdOrNameSpy.mockResolvedValue(null); + getVisitorByTokenSpy.mockResolvedValue({ department: 'different-dept' }); + + const guestData: RegisterGuestType = { + token, + department, + shouldConsiderIdleAgent: false, + }; + + await expect(registerGuest(guestData)).rejects.toThrow('error-invalid-department'); + + // Verify updateOneByIdOrToken is not called when department validation fails + expect(updateOneByIdOrTokenSpy).not.toHaveBeenCalled(); + }); + + it('should not validate department if visitor already has the same department', async () => { + const token = 'test-token'; + const department = 'sales'; + + getVisitorByTokenSpy.mockResolvedValue({ + _id: 'visitor-123', + department, + token, + }); + + const guestData: RegisterGuestType = { + token, + department, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + // Department validation should be skipped + expect(findOneByIdOrNameSpy).not.toHaveBeenCalled(); + + // Verify existing visitor is updated without department validation + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'visitor-123', + token, + status: UserStatus.ONLINE, + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); + + describe('visitor matching and creation', () => { + it('should update existing visitor found by token', async () => { + const token = 'test-token'; + const existingVisitor = { + _id: 'visitor-123', + token, + }; + + getVisitorByTokenSpy.mockResolvedValue(existingVisitor); + + const guestData: RegisterGuestType = { + token, + name: 'Updated Name', + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(getVisitorByTokenSpy).toHaveBeenCalledWith(token, { projection: { _id: 1 } }); + + // Verify existing visitor data is used and updated + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'visitor-123', + token, + status: UserStatus.ONLINE, + name: 'Updated Name', + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should match visitor by phone number and preserve existing token', async () => { + const token = 'new-token'; + const existingToken = 'existing-token'; + const phoneNumber = '+1234567890'; + const existingVisitor = { + _id: 'visitor-123', + token: existingToken, + }; + + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(existingVisitor); + + const guestData: RegisterGuestType = { + token, + phone: { number: phoneNumber }, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(findOneVisitorByPhoneSpy).toHaveBeenCalledWith(phoneNumber); + + // Verify existing visitor's token is preserved, not the new one + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'visitor-123', + token: existingToken, // Should use existing token, not new one + status: UserStatus.ONLINE, + phone: [{ phoneNumber }], + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should match visitor by email', async () => { + const token = 'test-token'; + const email = 'test@example.com'; + const existingVisitor = { + _id: 'visitor-123', + token: 'existing-token', + }; + + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(existingVisitor); + + const guestData: RegisterGuestType = { + token, + email, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(findOneGuestByEmailAddressSpy).toHaveBeenCalledWith(email); + + // Verify existing visitor data is used + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'visitor-123', + token, + status: UserStatus.ONLINE, + visitorEmails: [{ address: email }], + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should create new visitor when no matches found', async () => { + const token = 'test-token'; + const username = 'custom-username'; + const id = 'custom-id'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + id, + token, + username, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + // Verify new visitor data is created with provided values + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _id: id, + token, + username, + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should generate username when not provided for new visitor', async () => { + const token = 'test-token'; + const generatedUsername = 'guest-123'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + token, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(getNextVisitorUsernameSpy).toHaveBeenCalled(); + + // Verify generated username is used + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + username: generatedUsername, + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should use provided username for new visitor', async () => { + const token = 'test-token'; + const providedUsername = 'custom-username'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + token, + username: providedUsername, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(getNextVisitorUsernameSpy).not.toHaveBeenCalled(); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + username: providedUsername, + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); + + describe('data formatting', () => { + it('should format phone number correctly', async () => { + const token = 'test-token'; + const phoneNumber = '+1234567890'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + token, + phone: { number: phoneNumber }, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + phone: [{ phoneNumber }], + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should format email correctly', async () => { + const token = 'test-token'; + const email = 'test@example.com'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + findContactByEmailAndContactManagerSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + token, + email, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + visitorEmails: [{ address: email }], + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should use default status when not provided', async () => { + const token = 'test-token'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + token, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should use custom status when provided', async () => { + const token = 'test-token'; + const status = UserStatus.AWAY; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + token, + status, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); + + describe('connection data handling', () => { + it('should save connection data for new visitor', async () => { + const token = 'test-token'; + const connectionData = { + httpHeaders: { + 'user-agent': 'Mozilla/5.0', + 'x-real-ip': '192.168.1.1', + 'host': 'example.com', + }, + clientAddress: '10.0.0.1', + }; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + token, + connectionData, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + userAgent: 'Mozilla/5.0', + ip: '192.168.1.1', + host: 'example.com', + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should use x-forwarded-for header when x-real-ip is not available', async () => { + const token = 'test-token'; + const connectionData = { + httpHeaders: { + 'x-forwarded-for': '203.0.113.1', + }, + clientAddress: '10.0.0.1', + }; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + token, + connectionData, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + ip: '203.0.113.1', + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should use clientAddress when no IP headers are available', async () => { + const token = 'test-token'; + const connectionData = { + httpHeaders: {}, + clientAddress: '10.0.0.1', + }; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + token, + connectionData, + shouldConsiderIdleAgent: false, + }; + + await registerGuest(guestData); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + ip: '10.0.0.1', + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); + + describe('error scenarios', () => { + it('should return null when upsert fails', async () => { + const token = 'test-token'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + // Mock upsert to return null (failure case) + updateOneByIdOrTokenSpy.mockResolvedValue(null); + + const guestData: RegisterGuestType = { + token, + shouldConsiderIdleAgent: false, + }; + + const result = await registerGuest(guestData); + + expect(result).toBeNull(); + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should throw error when email validation fails', async () => { + const token = 'test-token'; + const email = 'invalid-email'; + + mockValidateEmail.mockImplementation(() => { + throw new Error('Invalid email'); + }); + + const guestData: RegisterGuestType = { + token, + email, + shouldConsiderIdleAgent: false, + }; + + await expect(registerGuest(guestData)).rejects.toThrow('Invalid email'); + }); + }); + + describe('shouldConsiderIdleAgent parameter', () => { + it('should pass shouldConsiderIdleAgent to findOneOnlineAgentById', async () => { + const token = 'test-token'; + const email = 'test@example.com'; + const agentId = 'agent-123'; + + const mockAgent = { + _id: agentId, + username: 'agent.user', + name: 'Agent User', + emails: [{ address: 'agent@example.com' }], + }; + + const mockContact = { + contactManager: agentId, + }; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + findContactByEmailAndContactManagerSpy.mockResolvedValue(mockContact); + findOneOnlineAgentByIdSpy.mockResolvedValue(mockAgent); + + const guestData: RegisterGuestType = { + token, + email, + shouldConsiderIdleAgent: true, + }; + + await registerGuest(guestData); + + expect(findOneOnlineAgentByIdSpy).toHaveBeenCalledWith( + agentId, + true, // shouldConsiderIdleAgent should be true + { projection: { _id: 1, username: 1, name: 1, emails: 1 } }, + ); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + visitorEmails: [{ address: email }], + contactManager: expect.objectContaining({ + username: 'agent.user', + }), + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); +}); From 252957fa425f2001751d6b02eebdae8d5e2de750 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 20 Aug 2025 16:20:37 -0300 Subject: [PATCH 5/6] test: add missing testunit scripts --- ee/packages/omni-core-ee/package.json | 1 + ee/packages/omnichannel-services/package.json | 1 + packages/freeswitch/package.json | 1 + packages/omni-core/package.json | 1 + packages/storybook-config/package.json | 1 - 5 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ee/packages/omni-core-ee/package.json b/ee/packages/omni-core-ee/package.json index d3fb87b08bcc7..e93eb3e7eff9d 100644 --- a/ee/packages/omni-core-ee/package.json +++ b/ee/packages/omni-core-ee/package.json @@ -15,6 +15,7 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "test": "jest", + "testunit": "jest", "build": "rm -rf dist && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, diff --git a/ee/packages/omnichannel-services/package.json b/ee/packages/omnichannel-services/package.json index 39e81b1e64a49..04e27ce51e5cf 100644 --- a/ee/packages/omnichannel-services/package.json +++ b/ee/packages/omnichannel-services/package.json @@ -39,6 +39,7 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "test": "jest", + "testunit": "jest", "build": "rm -rf dist && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, diff --git a/packages/freeswitch/package.json b/packages/freeswitch/package.json index cdf276195a9cc..b6f89e92408ad 100644 --- a/packages/freeswitch/package.json +++ b/packages/freeswitch/package.json @@ -14,6 +14,7 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "test": "jest", + "testunit": "jest", "build": "rm -rf dist && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, diff --git a/packages/omni-core/package.json b/packages/omni-core/package.json index dd50d9575e99d..9dbd412107b4f 100644 --- a/packages/omni-core/package.json +++ b/packages/omni-core/package.json @@ -16,6 +16,7 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "test": "jest", + "testunit": "jest", "build": "rm -rf dist && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, diff --git a/packages/storybook-config/package.json b/packages/storybook-config/package.json index ad7db5f0ba2cf..fae0ee7401e62 100644 --- a/packages/storybook-config/package.json +++ b/packages/storybook-config/package.json @@ -40,7 +40,6 @@ "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", - "test": "jest", "copy-svg": "cp -r ./src/logo.svg ./dist/logo.svg", "build": "rm -rf dist && tsc && yarn run copy-svg", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" From 1de36b49b28d6f02987f0f05fd81eb9066c1e341 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 20 Aug 2025 19:33:10 -0300 Subject: [PATCH 6/6] chore: change shouldConsiderIdleAgent to an options param --- .../app/apps/server/bridges/livechat.ts | 10 +- .../app/livechat/imports/server/rest/sms.ts | 5 +- .../app/livechat/server/api/v1/message.ts | 5 +- .../app/livechat/server/api/v1/visitor.ts | 3 +- apps/meteor/app/livechat/server/startup.ts | 6 +- .../EmailInbox/EmailInbox_Incoming.ts | 16 ++- packages/omni-core/src/visitor/create.spec.ts | 123 +++++++----------- packages/omni-core/src/visitor/create.ts | 19 +-- 8 files changed, 76 insertions(+), 111 deletions(-) diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 90f62a07bef60..0347d8e77f2fc 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -208,12 +208,13 @@ export class AppLivechatBridge extends LivechatBridge { token: visitor.token, email: '', id: visitor.id, - shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), ...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }), ...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }), }; - const livechatVisitor = await registerGuest(registerData); + const livechatVisitor = await registerGuest(registerData, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + }); if (!livechatVisitor) { throw new Error('Invalid visitor, cannot create'); @@ -232,12 +233,13 @@ export class AppLivechatBridge extends LivechatBridge { token: visitor.token, email: '', id: visitor.id, - shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), ...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }), ...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }), }; - const livechatVisitor = await registerGuest(registerData); + const livechatVisitor = await registerGuest(registerData, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + }); return this.orch.getConverters()?.get('visitors').convertVisitor(livechatVisitor); } diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index f0faad8e34843..b271194c1f05c 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -59,9 +59,8 @@ const defineDepartment = async (idOrName?: string) => { const defineVisitor = async (smsNumber: string, targetDepartment?: string) => { const visitor = await LivechatVisitors.findOneVisitorByPhone(smsNumber); - let data: { token: string; department?: string; shouldConsiderIdleAgent: boolean } = { + let data: { token: string; department?: string } = { token: visitor?.token || Random.id(), - shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), }; if (!visitor) { @@ -77,7 +76,7 @@ const defineVisitor = async (smsNumber: string, targetDepartment?: string) => { data.department = targetDepartment; } - const livechatVisitor = await registerGuest(data); + const livechatVisitor = await registerGuest(data, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); if (!livechatVisitor) { throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor'); diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 8d62588bf1753..228ada8f464d1 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -272,10 +272,7 @@ API.v1.addRoute( guest.connectionData = normalizeHttpHeaderData(this.request.headers); } - const visitor = await registerGuest({ - ...guest, - shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), - }); + const visitor = await registerGuest(guest, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); if (!visitor) { throw new Error('error-livechat-visitor-registration'); } diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index ca710f38cfbfc..2c17181fc3feb 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -57,11 +57,10 @@ API.v1.addRoute( ...(username && { username }), ...(connectionData && { connectionData }), ...(phone && typeof phone === 'string' && { phone: { number: phone as string } }), - shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), connectionData: normalizeHttpHeaderData(this.request.headers), }; - const visitor = await registerGuest(guest); + const visitor = await registerGuest(guest, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); if (!visitor) { throw new Meteor.Error('error-livechat-visitor-registration', 'Error registering visitor', { method: 'livechat/visitor', diff --git a/apps/meteor/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index a9ff46e140cda..d6035c7ab03fb 100644 --- a/apps/meteor/app/livechat/server/startup.ts +++ b/apps/meteor/app/livechat/server/startup.ts @@ -2,7 +2,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { ILivechatAgentStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; -import { registerGuest, type RegisterGuestType } from '@rocket.chat/omni-core'; +import { registerGuest } from '@rocket.chat/omni-core'; import { validateEmail, wrapExceptions } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; @@ -26,8 +26,8 @@ const logger = new Logger('LivechatStartup'); // TODO this patch is temporary because `ContactMerger` still a lot of dependencies, so it is not suitable to be moved to omni-core package // TODO add tests covering the ContactMerger usage -registerGuest.patch(async (originalFn, newData: RegisterGuestType) => { - const visitor = await originalFn(newData); +registerGuest.patch(async (originalFn, newData, options) => { + const visitor = await originalFn(newData, options); if (!visitor) { return null; } diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index 31a375ab20aae..f6cff68c3efea 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -41,13 +41,15 @@ async function getGuestByEmail(email: string, name: string, department = ''): Pr return guest; } - const livechatVisitor = await registerGuest({ - token: Random.id(), - name: name || email, - email, - department, - shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), - }); + const livechatVisitor = await registerGuest( + { + token: Random.id(), + name: name || email, + email, + department, + }, + { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }, + ); if (!livechatVisitor) { throw new Error('Error getting guest'); diff --git a/packages/omni-core/src/visitor/create.spec.ts b/packages/omni-core/src/visitor/create.spec.ts index e33562cc40af4..2dee062386877 100644 --- a/packages/omni-core/src/visitor/create.spec.ts +++ b/packages/omni-core/src/visitor/create.spec.ts @@ -3,7 +3,7 @@ import type { ILivechatContactsModel, ILivechatDepartmentModel, ILivechatVisitor import { registerModel } from '@rocket.chat/models'; import { validateEmail } from '@rocket.chat/tools'; -import { registerGuest, type RegisterGuestType } from './create'; +import { registerGuest } from './create'; // Mock the validateEmail function jest.mock('@rocket.chat/tools', () => ({ @@ -67,20 +67,17 @@ describe('registerGuest', () => { describe('validation', () => { it('should throw error when token is not provided', async () => { - const guestData: RegisterGuestType = { - shouldConsiderIdleAgent: false, - }; + const guestData = {}; - await expect(registerGuest(guestData)).rejects.toThrow('error-invalid-token'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-token'); }); it('should throw error when token is empty string', async () => { - const guestData: RegisterGuestType = { + const guestData = { token: '', - shouldConsiderIdleAgent: false, }; - await expect(registerGuest(guestData)).rejects.toThrow('error-invalid-token'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-token'); }); }); @@ -104,13 +101,12 @@ describe('registerGuest', () => { findContactByEmailAndContactManagerSpy.mockResolvedValue(mockContact); findOneOnlineAgentByIdSpy.mockResolvedValue(mockAgent); - const guestData: RegisterGuestType = { + const guestData = { token, email, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(mockValidateEmail).toHaveBeenCalledWith('test@example.com'); expect(findContactByEmailAndContactManagerSpy).toHaveBeenCalledWith('test@example.com'); @@ -146,13 +142,12 @@ describe('registerGuest', () => { findContactByEmailAndContactManagerSpy.mockResolvedValue(mockContact); findOneOnlineAgentByIdSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { token, email, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); // Verify contact manager is not included in the data expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( @@ -175,13 +170,12 @@ describe('registerGuest', () => { const email = ' TEST@EXAMPLE.COM '; const token = 'test-token'; - const guestData: RegisterGuestType = { + const guestData = { token, email, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(mockValidateEmail).toHaveBeenCalledWith('test@example.com'); expect(findContactByEmailAndContactManagerSpy).toHaveBeenCalledWith('test@example.com'); @@ -208,13 +202,12 @@ describe('registerGuest', () => { findOneByIdOrNameSpy.mockResolvedValue(mockDepartment); - const guestData: RegisterGuestType = { + const guestData = { token, department, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(findOneByIdOrNameSpy).toHaveBeenCalledWith(department, { projection: { _id: 1 } }); @@ -238,13 +231,12 @@ describe('registerGuest', () => { findOneByIdOrNameSpy.mockResolvedValue(null); getVisitorByTokenSpy.mockResolvedValue({ department: 'different-dept' }); - const guestData: RegisterGuestType = { + const guestData = { token, department, - shouldConsiderIdleAgent: false, }; - await expect(registerGuest(guestData)).rejects.toThrow('error-invalid-department'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-department'); // Verify updateOneByIdOrToken is not called when department validation fails expect(updateOneByIdOrTokenSpy).not.toHaveBeenCalled(); @@ -260,13 +252,12 @@ describe('registerGuest', () => { token, }); - const guestData: RegisterGuestType = { + const guestData = { token, department, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); // Department validation should be skipped expect(findOneByIdOrNameSpy).not.toHaveBeenCalled(); @@ -293,13 +284,12 @@ describe('registerGuest', () => { getVisitorByTokenSpy.mockResolvedValue(existingVisitor); - const guestData: RegisterGuestType = { + const guestData = { token, name: 'Updated Name', - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(getVisitorByTokenSpy).toHaveBeenCalledWith(token, { projection: { _id: 1 } }); @@ -327,13 +317,12 @@ describe('registerGuest', () => { getVisitorByTokenSpy.mockResolvedValue(null); findOneVisitorByPhoneSpy.mockResolvedValue(existingVisitor); - const guestData: RegisterGuestType = { + const guestData = { token, phone: { number: phoneNumber }, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(findOneVisitorByPhoneSpy).toHaveBeenCalledWith(phoneNumber); @@ -361,13 +350,12 @@ describe('registerGuest', () => { findOneVisitorByPhoneSpy.mockResolvedValue(null); findOneGuestByEmailAddressSpy.mockResolvedValue(existingVisitor); - const guestData: RegisterGuestType = { + const guestData = { token, email, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(findOneGuestByEmailAddressSpy).toHaveBeenCalledWith(email); @@ -393,14 +381,13 @@ describe('registerGuest', () => { findOneVisitorByPhoneSpy.mockResolvedValue(null); findOneGuestByEmailAddressSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { id, token, username, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); // Verify new visitor data is created with provided values expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( @@ -424,12 +411,11 @@ describe('registerGuest', () => { findOneVisitorByPhoneSpy.mockResolvedValue(null); findOneGuestByEmailAddressSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { token, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(getNextVisitorUsernameSpy).toHaveBeenCalled(); @@ -454,13 +440,12 @@ describe('registerGuest', () => { findOneVisitorByPhoneSpy.mockResolvedValue(null); findOneGuestByEmailAddressSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { token, username: providedUsername, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(getNextVisitorUsernameSpy).not.toHaveBeenCalled(); @@ -486,13 +471,12 @@ describe('registerGuest', () => { findOneVisitorByPhoneSpy.mockResolvedValue(null); findOneGuestByEmailAddressSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { token, phone: { number: phoneNumber }, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -515,13 +499,12 @@ describe('registerGuest', () => { findOneGuestByEmailAddressSpy.mockResolvedValue(null); findContactByEmailAndContactManagerSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { token, email, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -542,12 +525,11 @@ describe('registerGuest', () => { findOneVisitorByPhoneSpy.mockResolvedValue(null); findOneGuestByEmailAddressSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { token, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -568,13 +550,12 @@ describe('registerGuest', () => { findOneVisitorByPhoneSpy.mockResolvedValue(null); findOneGuestByEmailAddressSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { token, status, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -604,13 +585,12 @@ describe('registerGuest', () => { findOneVisitorByPhoneSpy.mockResolvedValue(null); findOneGuestByEmailAddressSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { token, connectionData, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -639,13 +619,12 @@ describe('registerGuest', () => { findOneVisitorByPhoneSpy.mockResolvedValue(null); findOneGuestByEmailAddressSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { token, connectionData, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -670,13 +649,12 @@ describe('registerGuest', () => { findOneVisitorByPhoneSpy.mockResolvedValue(null); findOneGuestByEmailAddressSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { token, connectionData, - shouldConsiderIdleAgent: false, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -702,12 +680,11 @@ describe('registerGuest', () => { // Mock upsert to return null (failure case) updateOneByIdOrTokenSpy.mockResolvedValue(null); - const guestData: RegisterGuestType = { + const guestData = { token, - shouldConsiderIdleAgent: false, }; - const result = await registerGuest(guestData); + const result = await registerGuest(guestData, { shouldConsiderIdleAgent: false }); expect(result).toBeNull(); expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( @@ -728,13 +705,12 @@ describe('registerGuest', () => { throw new Error('Invalid email'); }); - const guestData: RegisterGuestType = { + const guestData = { token, email, - shouldConsiderIdleAgent: false, }; - await expect(registerGuest(guestData)).rejects.toThrow('Invalid email'); + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('Invalid email'); }); }); @@ -762,13 +738,12 @@ describe('registerGuest', () => { findContactByEmailAndContactManagerSpy.mockResolvedValue(mockContact); findOneOnlineAgentByIdSpy.mockResolvedValue(mockAgent); - const guestData: RegisterGuestType = { + const guestData = { token, email, - shouldConsiderIdleAgent: true, }; - await registerGuest(guestData); + await registerGuest(guestData, { shouldConsiderIdleAgent: true }); expect(findOneOnlineAgentByIdSpy).toHaveBeenCalledWith( agentId, diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts index 411bf3ce12ac7..0fc212a4b3ddc 100644 --- a/packages/omni-core/src/visitor/create.ts +++ b/packages/omni-core/src/visitor/create.ts @@ -6,27 +6,18 @@ import { validateEmail } from '@rocket.chat/tools'; const logger = new Logger('Livechat - Visitor'); -export type RegisterGuestType = Partial> & { +type RegisterGuestType = Partial> & { id?: string; connectionData?: any; email?: string; phone?: { number: string }; - shouldConsiderIdleAgent: boolean; }; export const registerGuest = makeFunction( - async ({ - id, - token, - name, - phone, - email, - department, - username, - connectionData, - status = UserStatus.ONLINE, - shouldConsiderIdleAgent, - }: RegisterGuestType): Promise => { + async ( + { id, token, name, phone, email, department, username, connectionData, status = UserStatus.ONLINE }: RegisterGuestType, + { shouldConsiderIdleAgent }: { shouldConsiderIdleAgent: boolean }, + ): Promise => { if (!token) { throw Error('error-invalid-token'); }