diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 6c1152e678b3d..0347d8e77f2fc 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,13 +207,14 @@ export class AppLivechatBridge extends LivechatBridge { name: visitor.name, token: visitor.token, email: '', - connectionData: undefined, id: visitor.id, ...(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'); @@ -231,13 +232,14 @@ export class AppLivechatBridge extends LivechatBridge { name: visitor.name, token: visitor.token, email: '', - connectionData: undefined, id: visitor.id, ...(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 d8546be970f4c..b271194c1f05c 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'; @@ -76,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 67c9a7e397c58..228ada8f464d1 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,12 @@ 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..2c17181fc3feb 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'; @@ -59,7 +60,7 @@ API.v1.addRoute( 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/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/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index 1df1245655bab..d6035c7ab03fb 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 } 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, options) => { + const visitor = await originalFn(newData, options); + 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/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index d7a6ce004fc62..f6cff68c3efea 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'; @@ -41,12 +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, - }); + 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/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 2dbe7bd594772..9dbd412107b4f 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", @@ -15,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" }, @@ -23,6 +25,9 @@ "files": [ "/dist" ], + "volta": { + "extends": "../../package.json" + }, "dependencies": { "@rocket.chat/models": "workspace:^", "@rocket.chat/patch-injection": "workspace:^" 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.spec.ts b/packages/omni-core/src/visitor/create.spec.ts new file mode 100644 index 0000000000000..2dee062386877 --- /dev/null +++ b/packages/omni-core/src/visitor/create.spec.ts @@ -0,0 +1,768 @@ +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 } 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 = {}; + + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-token'); + }); + + it('should throw error when token is empty string', async () => { + const guestData = { + token: '', + }; + + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).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 = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + // 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 = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + department, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + 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(); + }); + + 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 = { + token, + department, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + // 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 = { + token, + name: 'Updated Name', + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + phone: { number: phoneNumber }, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + id, + token, + username, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + // 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 = { + token, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + username: providedUsername, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + phone: { number: phoneNumber }, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + status, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + connectionData, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + connectionData, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + connectionData, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + }; + + const result = await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + 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 = { + token, + email, + }; + + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).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 = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: true }); + + 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' }, + ); + }); + }); +}); diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts new file mode 100644 index 0000000000000..0fc212a4b3ddc --- /dev/null +++ b/packages/omni-core/src/visitor/create.ts @@ -0,0 +1,116 @@ +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'); + +type RegisterGuestType = Partial> & { + id?: string; + connectionData?: any; + email?: string; + phone?: { number: string }; +}; + +export const registerGuest = makeFunction( + async ( + { id, token, name, phone, email, department, username, connectionData, status = UserStatus.ONLINE }: RegisterGuestType, + { shouldConsiderIdleAgent }: { shouldConsiderIdleAgent: boolean }, + ): 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 (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; + } + + return upsertedLivechatVisitor; + }, +); 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" 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: