From 6fe3dfbd914518738c8af31ea0b40700dfc7059e Mon Sep 17 00:00:00 2001 From: Pierre Date: Mon, 21 Oct 2024 18:55:30 -0300 Subject: [PATCH] chore: split Contacts.ts file and its tests into separate files for every function --- .../server/lib/maybeMigrateLivechatRoom.ts | 2 +- .../meteor/app/apps/server/bridges/contact.ts | 3 +- .../classes/converters/ContactConverter.ts | 4 +- .../app/livechat/server/api/lib/visitors.ts | 3 +- .../app/livechat/server/api/v1/contact.ts | 9 +- .../app/livechat/server/lib/Contacts.ts | 611 ------------------ apps/meteor/app/livechat/server/lib/Helper.ts | 3 +- .../app/livechat/server/lib/LivechatTyped.ts | 4 +- .../app/livechat/server/lib/QueueManager.ts | 2 +- .../lib/{ => contacts}/ContactMerger.ts | 2 +- .../server/lib/contacts/addContactEmail.ts | 26 + .../server/lib/contacts/createContact.ts | 47 ++ .../lib/contacts/createContactFromVisitor.ts | 19 + .../lib/contacts/getAllowedCustomFields.ts | 12 + .../server/lib/contacts/getContact.ts | 34 + .../server/lib/contacts/getContactHistory.ts | 65 ++ .../lib/contacts/getContactIdByVisitorId.ts | 10 + .../contacts/getContactManagerIdByUsername.ts | 12 + .../server/lib/contacts/getContacts.ts | 30 + .../lib/contacts/getVisitorNewestSource.ts | 14 + .../lib/contacts/mapVisitorToContact.ts | 24 + .../server/lib/contacts/mergeContacts.ts | 4 + .../migrateVisitorIfMissingContact.ts | 25 + .../lib/contacts/migrateVisitorToContactId.ts | 38 ++ .../server/lib/contacts/registerContact.ts | 133 ++++ .../server/lib/contacts/registerGuestData.ts | 39 ++ .../contacts/shouldTriggerVerificationApp.ts | 6 + .../server/lib/contacts/updateContact.ts | 45 ++ .../lib/contacts/validateContactManager.ts | 9 + .../lib/contacts/validateCustomFields.ts | 49 ++ .../lib/contacts/verifyContactChannel.ts | 12 + .../livechat/server/methods/takeInquiry.ts | 3 +- .../meteor/ee/server/patches/mergeContacts.ts | 4 +- .../patches/shouldTriggerVerificationApp.ts | 2 +- .../ee/server/patches/verifyContactChannel.ts | 3 +- .../server/lib/mergeContacts.spec.ts | 4 +- .../server/lib/verifyContactChannel.spec.ts | 3 +- .../app/livechat/server/lib/Contacts.spec.ts | 229 ------- .../livechat/server/lib/getContact.spec.ts | 103 +++ .../server/lib/mapVisitorToContact.spec.ts | 100 +++ .../lib/migrateVisitorToContactId.spec.ts | 90 +++ .../server/lib/registerContact.spec.ts | 138 ++++ .../livechat/server/lib/updateContact.spec.ts | 48 ++ .../server/lib/validateContactManager.spec.ts | 34 + .../server/lib/validateCustomFields.spec.ts | 43 ++ yarn.lock | 2 - 46 files changed, 1241 insertions(+), 861 deletions(-) delete mode 100644 apps/meteor/app/livechat/server/lib/Contacts.ts rename apps/meteor/app/livechat/server/lib/{ => contacts}/ContactMerger.ts (99%) create mode 100644 apps/meteor/app/livechat/server/lib/contacts/addContactEmail.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/createContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/createContactFromVisitor.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getAllowedCustomFields.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getContactHistory.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getContactIdByVisitorId.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getContactManagerIdByUsername.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getContacts.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/getVisitorNewestSource.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/mergeContacts.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/migrateVisitorIfMissingContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/migrateVisitorToContactId.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/registerContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/registerGuestData.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/shouldTriggerVerificationApp.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/updateContact.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/validateContactManager.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.ts create mode 100644 apps/meteor/app/livechat/server/lib/contacts/verifyContactChannel.ts delete mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/getContact.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/mapVisitorToContact.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/migrateVisitorToContactId.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/registerContact.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/updateContact.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/validateContactManager.spec.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/validateCustomFields.spec.ts diff --git a/apps/meteor/app/api/server/lib/maybeMigrateLivechatRoom.ts b/apps/meteor/app/api/server/lib/maybeMigrateLivechatRoom.ts index ca38189991ed..797500941e12 100644 --- a/apps/meteor/app/api/server/lib/maybeMigrateLivechatRoom.ts +++ b/apps/meteor/app/api/server/lib/maybeMigrateLivechatRoom.ts @@ -2,7 +2,7 @@ import { isOmnichannelRoom, type IRoom } from '@rocket.chat/core-typings'; import { Rooms } from '@rocket.chat/models'; import type { FindOptions } from 'mongodb'; -import { migrateVisitorIfMissingContact } from '../../../livechat/server/lib/Contacts'; +import { migrateVisitorIfMissingContact } from '../../../livechat/server/lib/contacts/migrateVisitorIfMissingContact'; export async function maybeMigrateLivechatRoom(room: IRoom | null, options: FindOptions = {}): Promise { if (!room || !isOmnichannelRoom(room)) { diff --git a/apps/meteor/app/apps/server/bridges/contact.ts b/apps/meteor/app/apps/server/bridges/contact.ts index 275149935e59..10f8a6d00632 100644 --- a/apps/meteor/app/apps/server/bridges/contact.ts +++ b/apps/meteor/app/apps/server/bridges/contact.ts @@ -4,7 +4,8 @@ import { ContactBridge } from '@rocket.chat/apps-engine/server/bridges'; import type { IVisitor } from '@rocket.chat/core-typings'; import { LivechatContacts } from '@rocket.chat/models'; -import { addContactEmail, verifyContactChannel } from '../../../livechat/server/lib/Contacts'; +import { addContactEmail } from '../../../livechat/server/lib/contacts/addContactEmail'; +import { verifyContactChannel } from '../../../livechat/server/lib/contacts/verifyContactChannel'; export class AppContactBridge extends ContactBridge { constructor(private readonly orch: IAppServerOrchestrator) { diff --git a/apps/meteor/app/importer/server/classes/converters/ContactConverter.ts b/apps/meteor/app/importer/server/classes/converters/ContactConverter.ts index 306c0f77e2c7..d6eb31a8cb44 100644 --- a/apps/meteor/app/importer/server/classes/converters/ContactConverter.ts +++ b/apps/meteor/app/importer/server/classes/converters/ContactConverter.ts @@ -1,7 +1,9 @@ import type { IImportContact, IImportContactRecord } from '@rocket.chat/core-typings'; import { LivechatVisitors } from '@rocket.chat/models'; -import { createContact, getAllowedCustomFields, validateCustomFields } from '../../../../livechat/server/lib/Contacts'; +import { createContact } from '../../../../livechat/server/lib/contacts/createContact'; +import { getAllowedCustomFields } from '../../../../livechat/server/lib/contacts/getAllowedCustomFields'; +import { validateCustomFields } from '../../../../livechat/server/lib/contacts/validateCustomFields'; import { RecordConverter } from './RecordConverter'; export class ContactConverter extends RecordConverter { diff --git a/apps/meteor/app/livechat/server/api/lib/visitors.ts b/apps/meteor/app/livechat/server/api/lib/visitors.ts index f5b1a1993f93..7fe8e8ab23f9 100644 --- a/apps/meteor/app/livechat/server/api/lib/visitors.ts +++ b/apps/meteor/app/livechat/server/api/lib/visitors.ts @@ -4,7 +4,8 @@ import type { FindOptions } from 'mongodb'; import { callbacks } from '../../../../../lib/callbacks'; import { canAccessRoomAsync } from '../../../../authorization/server/functions/canAccessRoom'; -import { migrateVisitorToContactId, getContactIdByVisitorId } from '../../lib/Contacts'; +import { getContactIdByVisitorId } from '../../lib/contacts/getContactIdByVisitorId'; +import { migrateVisitorToContactId } from '../../lib/contacts/migrateVisitorToContactId'; export async function findVisitorInfo({ visitorId }: { visitorId: IVisitor['_id'] }) { const visitor = await LivechatVisitors.findOneEnabledById(visitorId); diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index a384d4ca9158..0c4d12905c04 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -13,7 +13,12 @@ import { Meteor } from 'meteor/meteor'; import { API } from '../../../../api/server'; import { getPaginationItems } from '../../../../api/server/helpers/getPaginationItems'; -import { getContactHistory, Contacts, createContact, getContact, updateContact, getContacts } from '../../lib/Contacts'; +import { createContact } from '../../lib/contacts/createContact'; +import { getContact } from '../../lib/contacts/getContact'; +import { getContactHistory } from '../../lib/contacts/getContactHistory'; +import { getContacts } from '../../lib/contacts/getContacts'; +import { registerContact } from '../../lib/contacts/registerContact'; +import { updateContact } from '../../lib/contacts/updateContact'; API.v1.addRoute( 'omnichannel/contact', @@ -36,7 +41,7 @@ API.v1.addRoute( }), }); - const contact = await Contacts.registerContact(this.bodyParams); + const contact = await registerContact(this.bodyParams); return API.v1.success({ contact }); }, diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts deleted file mode 100644 index da394e7fa6be..000000000000 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ /dev/null @@ -1,611 +0,0 @@ -import type { - IOmnichannelSource, - AtLeast, - ILivechatContact, - ILivechatContactChannel, - ILivechatCustomField, - ILivechatVisitor, - IOmnichannelRoom, - IUser, -} from '@rocket.chat/core-typings'; -import { - LivechatVisitors, - Users, - LivechatRooms, - LivechatCustomField, - LivechatInquiry, - Rooms, - Subscriptions, - LivechatContacts, -} from '@rocket.chat/models'; -import { makeFunction } from '@rocket.chat/patch-injection'; -import type { PaginatedResult, VisitorSearchChatsResult } from '@rocket.chat/rest-typings'; -import { wrapExceptions } from '@rocket.chat/tools'; -import { check } from 'meteor/check'; -import { Meteor } from 'meteor/meteor'; -import type { MatchKeysAndValues, OnlyFieldsOfType, FindOptions, Sort } from 'mongodb'; - -import { callbacks } from '../../../../lib/callbacks'; -import { trim } from '../../../../lib/utils/stringUtils'; -import { - notifyOnRoomChangedById, - notifyOnSubscriptionChangedByRoomId, - notifyOnLivechatInquiryChangedByRoom, -} from '../../../lib/server/lib/notifyListener'; -import { i18n } from '../../../utils/lib/i18n'; -import type { FieldAndValue } from './ContactMerger'; -import { ContactMerger } from './ContactMerger'; -import { validateEmail } from './Helper'; -import type { RegisterGuestType } from './LivechatTyped'; -import { Livechat } from './LivechatTyped'; - -type RegisterContactProps = { - _id?: string; - token: string; - name: string; - username?: string; - email?: string; - phone?: string; - customFields?: Record; - contactManager?: { - username: string; - }; -}; - -type CreateContactParams = { - name: string; - emails?: string[]; - phones?: string[]; - unknown: boolean; - customFields?: Record; - contactManager?: string; - channels?: ILivechatContactChannel[]; - importIds?: string[]; -}; - -type VerifyContactChannelParams = { - contactId: string; - field: string; - value: string; - visitorId: string; - roomId: string; -}; - -type UpdateContactParams = { - contactId: string; - name?: string; - emails?: string[]; - phones?: string[]; - customFields?: Record; - contactManager?: string; - channels?: ILivechatContactChannel[]; - wipeConflicts?: boolean; -}; - -type GetContactsParams = { - searchText?: string; - count: number; - offset: number; - sort: Sort; -}; - -type GetContactHistoryParams = { - contactId: string; - source?: string; - count: number; - offset: number; - sort: Sort; -}; - -export const Contacts = { - async registerContact({ - token, - name, - email = '', - phone, - username, - customFields = {}, - contactManager, - }: RegisterContactProps): Promise { - check(token, String); - - const visitorEmail = email.trim().toLowerCase(); - - if (contactManager?.username) { - // verify if the user exists with this username and has a livechat-agent role - const user = await Users.findOneByUsername(contactManager.username, { projection: { roles: 1 } }); - if (!user) { - throw new Meteor.Error('error-contact-manager-not-found', `No user found with username ${contactManager.username}`); - } - if (!user.roles || !Array.isArray(user.roles) || !user.roles.includes('livechat-agent')) { - throw new Meteor.Error('error-invalid-contact-manager', 'The contact manager must have the role "livechat-agent"'); - } - } - - let contactId; - - const user = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); - - if (user) { - contactId = user._id; - } else { - if (!username) { - username = await LivechatVisitors.getNextVisitorUsername(); - } - - let existingUser = null; - - if (visitorEmail !== '' && (existingUser = await LivechatVisitors.findOneGuestByEmailAddress(visitorEmail))) { - contactId = existingUser._id; - } else { - const userData = { - username, - ts: new Date(), - token, - }; - - contactId = (await LivechatVisitors.insertOne(userData)).insertedId; - } - } - - const allowedCF = await getAllowedCustomFields(); - const livechatData: Record = validateCustomFields(allowedCF, customFields, { ignoreAdditionalFields: true }); - - const fieldsToRemove = { - // if field is explicitely set to empty string, remove - ...(phone === '' && { phone: 1 }), - ...(visitorEmail === '' && { visitorEmails: 1 }), - ...(!contactManager?.username && { contactManager: 1 }), - }; - - const updateUser: { $set: MatchKeysAndValues; $unset?: OnlyFieldsOfType } = { - $set: { - token, - name, - livechatData, - // if phone has some value, set - ...(phone && { phone: [{ phoneNumber: phone }] }), - ...(visitorEmail && { visitorEmails: [{ address: visitorEmail }] }), - ...(contactManager?.username && { contactManager: { username: contactManager.username } }), - }, - ...(Object.keys(fieldsToRemove).length && { $unset: fieldsToRemove }), - }; - - await LivechatVisitors.updateOne({ _id: contactId }, updateUser); - - const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); - const rooms: IOmnichannelRoom[] = await LivechatRooms.findByVisitorId(contactId, {}, extraQuery).toArray(); - - if (rooms?.length) { - for await (const room of rooms) { - const { _id: rid } = room; - - const responses = await Promise.all([ - Rooms.setFnameById(rid, name), - LivechatInquiry.setNameByRoomId(rid, name), - Subscriptions.updateDisplayNameByRoomId(rid, name), - ]); - - if (responses[0]?.modifiedCount) { - void notifyOnRoomChangedById(rid); - } - - if (responses[1]?.modifiedCount) { - void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); - } - - if (responses[2]?.modifiedCount) { - void notifyOnSubscriptionChangedByRoomId(rid); - } - } - } - - return contactId; - }, - - async registerGuestData( - { name, phone, email, username }: Pick, - visitor: AtLeast, - ): Promise { - // If a visitor was updated who already had a contact, load up that contact and update that information as well - const contact = await LivechatContacts.findOneByVisitorId(visitor._id); - if (!contact) { - return; - } - - 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; - } - - await ContactMerger.mergeFieldsIntoContact(fields, contact, contact.unknown ? 'overwrite' : 'conflict'); - }, -}; - -export async function getContactManagerIdByUsername(username?: IUser['username']): Promise { - if (!username) { - return; - } - - const user = await Users.findOneByUsername>(username, { projection: { _id: 1 } }); - - return user?._id; -} - -export async function getContactIdByVisitorId(visitorId: ILivechatVisitor['_id']): Promise { - const contact = await LivechatContacts.findOneByVisitorId>(visitorId, { projection: { _id: 1 } }); - if (!contact) { - return null; - } - return contact._id; -} - -export async function migrateVisitorIfMissingContact( - visitorId: ILivechatVisitor['_id'], - source: IOmnichannelSource, -): Promise { - Livechat.logger.debug(`Detecting visitor's contact ID`); - // Check if there is any contact already linking to this visitorId - const contactId = await getContactIdByVisitorId(visitorId); - if (contactId) { - return contactId; - } - - const visitor = await LivechatVisitors.findOneById(visitorId); - if (!visitor) { - throw new Error('Failed to migrate visitor data into Contact information: visitor not found.'); - } - - return migrateVisitorToContactId(visitor, source); -} - -export async function findContactMatchingVisitor( - visitor: AtLeast, -): Promise { - // Search for any contact that is not yet associated with any visitor and that have the same email or phone number as this visitor. - return LivechatContacts.findContactMatchingVisitor(visitor); -} - -async function getVisitorNewestSource(visitor: ILivechatVisitor): Promise { - const room = await LivechatRooms.findNewestByVisitorIdOrToken>(visitor._id, visitor.token, { - projection: { source: 1 }, - }); - - if (!room) { - return null; - } - - return room.source; -} - -/** - This function assumes you already ensured that the visitor is not yet linked to any contact -**/ -export async function migrateVisitorToContactId( - visitor: ILivechatVisitor, - source?: IOmnichannelSource, - useVisitorId = false, -): Promise { - // If we haven't received any source and the visitor doesn't have any room yet, then there's no need to migrate it - const visitorSource = source || (await getVisitorNewestSource(visitor)); - if (!visitorSource) { - return null; - } - - const existingContact = await findContactMatchingVisitor(visitor); - if (!existingContact) { - Livechat.logger.debug(`Creating a new contact for existing visitor ${visitor._id}`); - return createContactFromVisitor(visitor, visitorSource, useVisitorId); - } - - // There is already an existing contact with no linked visitors and matching this visitor's phone or email, so let's use it - Livechat.logger.debug(`Adding channel to existing contact ${existingContact._id}`); - await ContactMerger.mergeVisitorIntoContact(visitor, existingContact); - - // Update all existing rooms of that visitor to add the contactId to them - await LivechatRooms.setContactIdByVisitorIdOrToken(existingContact._id, visitor._id, visitor.token); - - return existingContact._id; -} - -export async function getContact(contactId: ILivechatContact['_id']): Promise { - const contact = await LivechatContacts.findOneById(contactId); - if (contact) { - return contact; - } - - // If the contact was not found, search for a visitor with the same ID - const visitor = await LivechatVisitors.findOneById(contactId); - // If there's also no visitor with that ID, then there's nothing for us to get - if (!visitor) { - return null; - } - - // ContactId is actually the ID of a visitor, so let's get the contact that is linked to this visitor - const linkedContact = await LivechatContacts.findOneByVisitorId(contactId); - if (linkedContact) { - return linkedContact; - } - - // If this is the ID of a visitor and there is no contact linking to it yet, then migrate it into a contact - const newContactId = await migrateVisitorToContactId(visitor, undefined, true); - // If no contact was created by the migration, this visitor doesn't need a contact yet, so let's return null - if (!newContactId) { - return null; - } - - // Finally, let's return the data of the migrated contact - return LivechatContacts.findOneById(newContactId); -} - -export async function mapVisitorToContact(visitor: ILivechatVisitor, source: IOmnichannelSource): Promise { - return { - name: visitor.name || visitor.username, - emails: visitor.visitorEmails?.map(({ address }) => address), - phones: visitor.phone?.map(({ phoneNumber }) => phoneNumber), - unknown: true, - channels: [ - { - name: source.label || source.type.toString(), - visitorId: visitor._id, - blocked: false, - verified: false, - details: source, - }, - ], - customFields: visitor.livechatData, - contactManager: await getContactManagerIdByUsername(visitor.contactManager?.username), - }; -} - -export async function createContactFromVisitor( - visitor: ILivechatVisitor, - source: IOmnichannelSource, - useVisitorId = false, -): Promise { - const contactData = await mapVisitorToContact(visitor, source); - - const contactId = await createContact(contactData, useVisitorId ? visitor._id : undefined); - - await LivechatRooms.setContactIdByVisitorIdOrToken(contactId, visitor._id, visitor.token); - - return contactId; -} - -export async function createContact(params: CreateContactParams, upsertId?: ILivechatContact['_id']): Promise { - const { name, emails, phones, customFields: receivedCustomFields = {}, contactManager, channels, unknown, importIds } = params; - - if (contactManager) { - await validateContactManager(contactManager); - } - - const allowedCustomFields = await getAllowedCustomFields(); - const customFields = validateCustomFields(allowedCustomFields, receivedCustomFields); - - const updateData = { - name, - emails: emails?.map((address) => ({ address })), - phones: phones?.map((phoneNumber) => ({ phoneNumber })), - contactManager, - channels, - customFields, - unknown, - ...(importIds?.length ? { importIds } : {}), - } as const; - - // Use upsert when doing auto-migration so that if there's multiple requests processing at the same time, they won't interfere with each other - if (upsertId) { - await LivechatContacts.upsertContact(upsertId, updateData); - return upsertId; - } - - return LivechatContacts.insertContact(updateData); -} - -export async function updateContact(params: UpdateContactParams): Promise { - const { contactId, name, emails, phones, customFields: receivedCustomFields, contactManager, channels, wipeConflicts } = params; - - const contact = await LivechatContacts.findOneById>(contactId, { projection: { _id: 1 } }); - - if (!contact) { - throw new Error('error-contact-not-found'); - } - - if (contactManager) { - await validateContactManager(contactManager); - } - - const customFields = receivedCustomFields && validateCustomFields(await getAllowedCustomFields(), receivedCustomFields); - - const updatedContact = await LivechatContacts.updateContact(contactId, { - name, - emails: emails?.map((address) => ({ address })), - phones: phones?.map((phoneNumber) => ({ phoneNumber })), - contactManager, - channels, - customFields, - ...(wipeConflicts && { conflictingFields: [], hasConflict: false }), - }); - - return updatedContact; -} - -/** - * Adds a new email into the contact's email list, if the email is already in the list it does not add anything - * and simply return the data, since the email was aready registered :P - * - * @param contactId the id of the contact that will be updated - * @param email the email that will be added to the contact - * @returns the updated contact - */ -export async function addContactEmail(contactId: ILivechatContact['_id'], email: string): Promise { - const contact = await LivechatContacts.findOneById(contactId); - if (!contact) { - throw new Error('error-contact-not-found'); - } - - const emails = contact.emails?.map(({ address }) => address) || []; - if (!emails.includes(email)) { - return LivechatContacts.updateContact(contactId, { - emails: [...emails.map((e) => ({ address: e })), { address: email }], - }); - } - - return contact; -} - -export async function getContacts(params: GetContactsParams): Promise> { - const { searchText, count, offset, sort } = params; - - const { cursor, totalCount } = LivechatContacts.findPaginatedContacts(searchText, { - limit: count, - skip: offset, - sort: sort ?? { name: 1 }, - }); - - const [contacts, total] = await Promise.all([cursor.toArray(), totalCount]); - - return { - contacts, - count, - offset, - total, - }; -} - -export async function getContactHistory( - params: GetContactHistoryParams, -): Promise> { - const { contactId, source, count, offset, sort } = params; - - const contact = await LivechatContacts.findOneById>(contactId, { projection: { channels: 1 } }); - - if (!contact) { - throw new Error('error-contact-not-found'); - } - - const visitorsIds = new Set(contact.channels?.map((channel: ILivechatContactChannel) => channel.visitorId)); - - if (!visitorsIds?.size) { - return { history: [], count: 0, offset, total: 0 }; - } - - const options: FindOptions = { - sort: sort || { ts: -1 }, - skip: offset, - limit: count, - projection: { - fname: 1, - ts: 1, - v: 1, - msgs: 1, - servedBy: 1, - closedAt: 1, - closedBy: 1, - closer: 1, - tags: 1, - source: 1, - lastMessage: 1, - verified: 1, - }, - }; - - const { totalCount, cursor } = LivechatRooms.findPaginatedRoomsByVisitorsIdsAndSource({ - visitorsIds: Array.from(visitorsIds), - source, - options, - }); - - const [total, history] = await Promise.all([totalCount, cursor.toArray()]); - - return { - history, - count: history.length, - offset, - total, - }; -} - -export async function getAllowedCustomFields(): Promise[]> { - return LivechatCustomField.findByScope( - 'visitor', - { - projection: { _id: 1, label: 1, regexp: 1, required: 1 }, - }, - false, - ).toArray(); -} - -export function validateCustomFields( - allowedCustomFields: AtLeast[], - customFields: Record, - options?: { ignoreAdditionalFields?: boolean }, -): Record { - const validValues: Record = {}; - - for (const cf of allowedCustomFields) { - if (!customFields.hasOwnProperty(cf._id)) { - if (cf.required) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - continue; - } - const cfValue: string = trim(customFields[cf._id]); - - if (!cfValue || typeof cfValue !== 'string') { - if (cf.required) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - continue; - } - - if (cf.regexp) { - const regex = new RegExp(cf.regexp); - if (!regex.test(cfValue)) { - throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); - } - } - - validValues[cf._id] = cfValue; - } - - if (!options?.ignoreAdditionalFields) { - const allowedCustomFieldIds = new Set(allowedCustomFields.map((cf) => cf._id)); - for (const key in customFields) { - if (!allowedCustomFieldIds.has(key)) { - throw new Error(i18n.t('error-custom-field-not-allowed', { key })); - } - } - } - - return validValues; -} - -export async function validateContactManager(contactManagerUserId: string) { - const contactManagerUser = await Users.findOneAgentById>(contactManagerUserId, { projection: { _id: 1 } }); - if (!contactManagerUser) { - throw new Error('error-contact-manager-not-found'); - } -} - -export const verifyContactChannel = makeFunction(async (_params: VerifyContactChannelParams): Promise => null); - -export const mergeContacts = makeFunction(async (_contactId: string, _visitorId: string): Promise => null); - -export const shouldTriggerVerificationApp = makeFunction( - async (_contactId: ILivechatContact['_id'], _source: IOmnichannelSource): Promise => false, -); diff --git a/apps/meteor/app/livechat/server/lib/Helper.ts b/apps/meteor/app/livechat/server/lib/Helper.ts index 9cf393613801..672665e0aec1 100644 --- a/apps/meteor/app/livechat/server/lib/Helper.ts +++ b/apps/meteor/app/livechat/server/lib/Helper.ts @@ -45,10 +45,11 @@ import { notifyOnSubscriptionChanged, } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; -import { getContactIdByVisitorId, migrateVisitorIfMissingContact } from './Contacts'; import { Livechat as LivechatTyped } from './LivechatTyped'; import { queueInquiry, saveQueueInquiry } from './QueueManager'; import { RoutingManager } from './RoutingManager'; +import { getContactIdByVisitorId } from './contacts/getContactIdByVisitorId'; +import { migrateVisitorIfMissingContact } from './contacts/migrateVisitorIfMissingContact'; const logger = new Logger('LivechatHelper'); export const allowAgentSkipQueue = (agent: SelectedAgent) => { diff --git a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts index e8ad55a8de13..c824a8fb6ed6 100644 --- a/apps/meteor/app/livechat/server/lib/LivechatTyped.ts +++ b/apps/meteor/app/livechat/server/lib/LivechatTyped.ts @@ -73,11 +73,11 @@ import * as Mailer from '../../../mailer/server/api'; import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; import { businessHourManager } from '../business-hour'; -import { Contacts } from './Contacts'; import { parseAgentCustomFields, updateDepartmentAgents, normalizeTransferredByData } from './Helper'; import { QueueManager } from './QueueManager'; import { RoutingManager } from './RoutingManager'; import { Visitors } from './Visitors'; +import { registerGuestData } from './contacts/registerGuestData'; import { isDepartmentCreationAvailable } from './isDepartmentCreationAvailable'; import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes'; import { parseTranscriptRequest } from './parseTranscriptRequest'; @@ -608,7 +608,7 @@ class LivechatClass { const result = await Visitors.registerGuest(newData); if (result) { - await Contacts.registerGuestData(newData, result); + await registerGuestData(newData, result); } return result; diff --git a/apps/meteor/app/livechat/server/lib/QueueManager.ts b/apps/meteor/app/livechat/server/lib/QueueManager.ts index 3814f20927a6..acb85c7c1407 100644 --- a/apps/meteor/app/livechat/server/lib/QueueManager.ts +++ b/apps/meteor/app/livechat/server/lib/QueueManager.ts @@ -25,10 +25,10 @@ import { } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; import { i18n } from '../../../utils/lib/i18n'; -import { shouldTriggerVerificationApp } from './Contacts'; import { createLivechatRoom, createLivechatInquiry, allowAgentSkipQueue } from './Helper'; import { Livechat } from './LivechatTyped'; import { RoutingManager } from './RoutingManager'; +import { shouldTriggerVerificationApp } from './contacts/shouldTriggerVerificationApp'; import { getInquirySortMechanismSetting } from './settings'; const logger = new Logger('QueueManager'); diff --git a/apps/meteor/app/livechat/server/lib/ContactMerger.ts b/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts similarity index 99% rename from apps/meteor/app/livechat/server/lib/ContactMerger.ts rename to apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts index 9347c8d0409d..b76e420216cc 100644 --- a/apps/meteor/app/livechat/server/lib/ContactMerger.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/ContactMerger.ts @@ -10,7 +10,7 @@ import type { import { LivechatContacts } from '@rocket.chat/models'; import type { UpdateFilter } from 'mongodb'; -import { getContactManagerIdByUsername } from './Contacts'; +import { getContactManagerIdByUsername } from './getContactManagerIdByUsername'; type ManagerValue = { id: string } | { username: string }; type ContactFields = { diff --git a/apps/meteor/app/livechat/server/lib/contacts/addContactEmail.ts b/apps/meteor/app/livechat/server/lib/contacts/addContactEmail.ts new file mode 100644 index 000000000000..8ce4f38577e0 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/addContactEmail.ts @@ -0,0 +1,26 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +/** + * Adds a new email into the contact's email list, if the email is already in the list it does not add anything + * and simply return the data, since the email was aready registered :P + * + * @param contactId the id of the contact that will be updated + * @param email the email that will be added to the contact + * @returns the updated contact + */ +export async function addContactEmail(contactId: ILivechatContact['_id'], email: string): Promise { + const contact = await LivechatContacts.findOneById(contactId); + if (!contact) { + throw new Error('error-contact-not-found'); + } + + const emails = contact.emails?.map(({ address }) => address) || []; + if (!emails.includes(email)) { + return LivechatContacts.updateContact(contactId, { + emails: [...emails.map((e) => ({ address: e })), { address: email }], + }); + } + + return contact; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/createContact.ts b/apps/meteor/app/livechat/server/lib/contacts/createContact.ts new file mode 100644 index 000000000000..7e0009ffd6ed --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/createContact.ts @@ -0,0 +1,47 @@ +import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { validateContactManager } from './validateContactManager'; +import { validateCustomFields } from './validateCustomFields'; + +export type CreateContactParams = { + name: string; + emails?: string[]; + phones?: string[]; + unknown: boolean; + customFields?: Record; + contactManager?: string; + channels?: ILivechatContactChannel[]; + importIds?: string[]; +}; + +export async function createContact(params: CreateContactParams, upsertId?: ILivechatContact['_id']): Promise { + const { name, emails, phones, customFields: receivedCustomFields = {}, contactManager, channels, unknown, importIds } = params; + + if (contactManager) { + await validateContactManager(contactManager); + } + + const allowedCustomFields = await getAllowedCustomFields(); + const customFields = validateCustomFields(allowedCustomFields, receivedCustomFields); + + const updateData = { + name, + emails: emails?.map((address) => ({ address })), + phones: phones?.map((phoneNumber) => ({ phoneNumber })), + contactManager, + channels, + customFields, + unknown, + ...(importIds?.length ? { importIds } : {}), + } as const; + + // Use upsert when doing auto-migration so that if there's multiple requests processing at the same time, they won't interfere with each other + if (upsertId) { + await LivechatContacts.upsertContact(upsertId, updateData); + return upsertId; + } + + return LivechatContacts.insertContact(updateData); +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/createContactFromVisitor.ts b/apps/meteor/app/livechat/server/lib/contacts/createContactFromVisitor.ts new file mode 100644 index 000000000000..1516f839cb86 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/createContactFromVisitor.ts @@ -0,0 +1,19 @@ +import type { ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; + +import { createContact } from './createContact'; +import { mapVisitorToContact } from './mapVisitorToContact'; + +export async function createContactFromVisitor( + visitor: ILivechatVisitor, + source: IOmnichannelSource, + useVisitorId = false, +): Promise { + const contactData = await mapVisitorToContact(visitor, source); + + const contactId = await createContact(contactData, useVisitorId ? visitor._id : undefined); + + await LivechatRooms.setContactIdByVisitorIdOrToken(contactId, visitor._id, visitor.token); + + return contactId; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getAllowedCustomFields.ts b/apps/meteor/app/livechat/server/lib/contacts/getAllowedCustomFields.ts new file mode 100644 index 000000000000..d71f902c1122 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getAllowedCustomFields.ts @@ -0,0 +1,12 @@ +import type { ILivechatCustomField } from '@rocket.chat/core-typings'; +import { LivechatCustomField } from '@rocket.chat/models'; + +export async function getAllowedCustomFields(): Promise[]> { + return LivechatCustomField.findByScope( + 'visitor', + { + projection: { _id: 1, label: 1, regexp: 1, required: 1 }, + }, + false, + ).toArray(); +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getContact.ts b/apps/meteor/app/livechat/server/lib/contacts/getContact.ts new file mode 100644 index 000000000000..33f0c8366e37 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getContact.ts @@ -0,0 +1,34 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatVisitors } from '@rocket.chat/models'; + +import { migrateVisitorToContactId } from './migrateVisitorToContactId'; + +export async function getContact(contactId: ILivechatContact['_id']): Promise { + const contact = await LivechatContacts.findOneById(contactId); + if (contact) { + return contact; + } + + // If the contact was not found, search for a visitor with the same ID + const visitor = await LivechatVisitors.findOneById(contactId); + // If there's also no visitor with that ID, then there's nothing for us to get + if (!visitor) { + return null; + } + + // ContactId is actually the ID of a visitor, so let's get the contact that is linked to this visitor + const linkedContact = await LivechatContacts.findOneByVisitorId(contactId); + if (linkedContact) { + return linkedContact; + } + + // If this is the ID of a visitor and there is no contact linking to it yet, then migrate it into a contact + const newContactId = await migrateVisitorToContactId(visitor, undefined, true); + // If no contact was created by the migration, this visitor doesn't need a contact yet, so let's return null + if (!newContactId) { + return null; + } + + // Finally, let's return the data of the migrated contact + return LivechatContacts.findOneById(newContactId); +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getContactHistory.ts b/apps/meteor/app/livechat/server/lib/contacts/getContactHistory.ts new file mode 100644 index 000000000000..1f378df9d430 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getContactHistory.ts @@ -0,0 +1,65 @@ +import type { ILivechatContact, ILivechatContactChannel, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; +import type { PaginatedResult, VisitorSearchChatsResult } from '@rocket.chat/rest-typings'; +import type { FindOptions, Sort } from 'mongodb'; + +export type GetContactHistoryParams = { + contactId: string; + source?: string; + count: number; + offset: number; + sort: Sort; +}; + +export async function getContactHistory( + params: GetContactHistoryParams, +): Promise> { + const { contactId, source, count, offset, sort } = params; + + const contact = await LivechatContacts.findOneById>(contactId, { projection: { channels: 1 } }); + + if (!contact) { + throw new Error('error-contact-not-found'); + } + + const visitorsIds = new Set(contact.channels?.map((channel: ILivechatContactChannel) => channel.visitorId)); + + if (!visitorsIds?.size) { + return { history: [], count: 0, offset, total: 0 }; + } + + const options: FindOptions = { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: { + fname: 1, + ts: 1, + v: 1, + msgs: 1, + servedBy: 1, + closedAt: 1, + closedBy: 1, + closer: 1, + tags: 1, + source: 1, + lastMessage: 1, + verified: 1, + }, + }; + + const { totalCount, cursor } = LivechatRooms.findPaginatedRoomsByVisitorsIdsAndSource({ + visitorsIds: Array.from(visitorsIds), + source, + options, + }); + + const [total, history] = await Promise.all([totalCount, cursor.toArray()]); + + return { + history, + count: history.length, + offset, + total, + }; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getContactIdByVisitorId.ts b/apps/meteor/app/livechat/server/lib/contacts/getContactIdByVisitorId.ts new file mode 100644 index 000000000000..e4cffaff645e --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getContactIdByVisitorId.ts @@ -0,0 +1,10 @@ +import type { ILivechatContact, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +export async function getContactIdByVisitorId(visitorId: ILivechatVisitor['_id']): Promise { + const contact = await LivechatContacts.findOneByVisitorId>(visitorId, { projection: { _id: 1 } }); + if (!contact) { + return null; + } + return contact._id; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getContactManagerIdByUsername.ts b/apps/meteor/app/livechat/server/lib/contacts/getContactManagerIdByUsername.ts new file mode 100644 index 000000000000..57c845ec570f --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getContactManagerIdByUsername.ts @@ -0,0 +1,12 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +export async function getContactManagerIdByUsername(username?: IUser['username']): Promise { + if (!username) { + return; + } + + const user = await Users.findOneByUsername>(username, { projection: { _id: 1 } }); + + return user?._id; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getContacts.ts b/apps/meteor/app/livechat/server/lib/contacts/getContacts.ts new file mode 100644 index 000000000000..b12bd6734b31 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getContacts.ts @@ -0,0 +1,30 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; +import type { Sort } from 'mongodb'; + +export type GetContactsParams = { + searchText?: string; + count: number; + offset: number; + sort: Sort; +}; + +export async function getContacts(params: GetContactsParams): Promise> { + const { searchText, count, offset, sort } = params; + + const { cursor, totalCount } = LivechatContacts.findPaginatedContacts(searchText, { + limit: count, + skip: offset, + sort: sort ?? { name: 1 }, + }); + + const [contacts, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + contacts, + count, + offset, + total, + }; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/getVisitorNewestSource.ts b/apps/meteor/app/livechat/server/lib/contacts/getVisitorNewestSource.ts new file mode 100644 index 000000000000..fab2525df729 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/getVisitorNewestSource.ts @@ -0,0 +1,14 @@ +import type { ILivechatVisitor, IOmnichannelSource, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatRooms } from '@rocket.chat/models'; + +export async function getVisitorNewestSource(visitor: ILivechatVisitor): Promise { + const room = await LivechatRooms.findNewestByVisitorIdOrToken>(visitor._id, visitor.token, { + projection: { source: 1 }, + }); + + if (!room) { + return null; + } + + return room.source; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.ts b/apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.ts new file mode 100644 index 000000000000..13552d55c34d --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/mapVisitorToContact.ts @@ -0,0 +1,24 @@ +import type { ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings'; + +import type { CreateContactParams } from './createContact'; +import { getContactManagerIdByUsername } from './getContactManagerIdByUsername'; + +export async function mapVisitorToContact(visitor: ILivechatVisitor, source: IOmnichannelSource): Promise { + return { + name: visitor.name || visitor.username, + emails: visitor.visitorEmails?.map(({ address }) => address), + phones: visitor.phone?.map(({ phoneNumber }) => phoneNumber), + unknown: true, + channels: [ + { + name: source.label || source.type.toString(), + visitorId: visitor._id, + blocked: false, + verified: false, + details: source, + }, + ], + customFields: visitor.livechatData, + contactManager: await getContactManagerIdByUsername(visitor.contactManager?.username), + }; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/mergeContacts.ts b/apps/meteor/app/livechat/server/lib/contacts/mergeContacts.ts new file mode 100644 index 000000000000..c327e6a38c87 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/mergeContacts.ts @@ -0,0 +1,4 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const mergeContacts = makeFunction(async (_contactId: string, _visitorId: string): Promise => null); diff --git a/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorIfMissingContact.ts b/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorIfMissingContact.ts new file mode 100644 index 000000000000..2264fd96c622 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorIfMissingContact.ts @@ -0,0 +1,25 @@ +import type { ILivechatVisitor, IOmnichannelSource, ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatVisitors } from '@rocket.chat/models'; + +import { Livechat } from '../LivechatTyped'; +import { getContactIdByVisitorId } from './getContactIdByVisitorId'; +import { migrateVisitorToContactId } from './migrateVisitorToContactId'; + +export async function migrateVisitorIfMissingContact( + visitorId: ILivechatVisitor['_id'], + source: IOmnichannelSource, +): Promise { + Livechat.logger.debug(`Detecting visitor's contact ID`); + // Check if there is any contact already linking to this visitorId + const contactId = await getContactIdByVisitorId(visitorId); + if (contactId) { + return contactId; + } + + const visitor = await LivechatVisitors.findOneById(visitorId); + if (!visitor) { + throw new Error('Failed to migrate visitor data into Contact information: visitor not found.'); + } + + return migrateVisitorToContactId(visitor, source); +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorToContactId.ts b/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorToContactId.ts new file mode 100644 index 000000000000..8c8e9cb27585 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/migrateVisitorToContactId.ts @@ -0,0 +1,38 @@ +import type { ILivechatVisitor, IOmnichannelSource, ILivechatContact } from '@rocket.chat/core-typings'; +import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; + +import { Livechat } from '../LivechatTyped'; +import { ContactMerger } from './ContactMerger'; +import { createContactFromVisitor } from './createContactFromVisitor'; +import { getVisitorNewestSource } from './getVisitorNewestSource'; + +/** + This function assumes you already ensured that the visitor is not yet linked to any contact +**/ +export async function migrateVisitorToContactId( + visitor: ILivechatVisitor, + source?: IOmnichannelSource, + useVisitorId = false, +): Promise { + // If we haven't received any source and the visitor doesn't have any room yet, then there's no need to migrate it + const visitorSource = source || (await getVisitorNewestSource(visitor)); + if (!visitorSource) { + return null; + } + + // Search for any contact that is not yet associated with any visitor and that have the same email or phone number as this visitor. + const existingContact = await LivechatContacts.findContactMatchingVisitor(visitor); + if (!existingContact) { + Livechat.logger.debug(`Creating a new contact for existing visitor ${visitor._id}`); + return createContactFromVisitor(visitor, visitorSource, useVisitorId); + } + + // There is already an existing contact with no linked visitors and matching this visitor's phone or email, so let's use it + Livechat.logger.debug(`Adding channel to existing contact ${existingContact._id}`); + await ContactMerger.mergeVisitorIntoContact(visitor, existingContact); + + // Update all existing rooms of that visitor to add the contactId to them + await LivechatRooms.setContactIdByVisitorIdOrToken(existingContact._id, visitor._id, visitor.token); + + return existingContact._id; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/registerContact.ts b/apps/meteor/app/livechat/server/lib/contacts/registerContact.ts new file mode 100644 index 000000000000..22425e4714c2 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/registerContact.ts @@ -0,0 +1,133 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import type { ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import { LivechatVisitors, Users, LivechatRooms, LivechatInquiry, Rooms, Subscriptions } from '@rocket.chat/models'; +import type { MatchKeysAndValues, OnlyFieldsOfType } from 'mongodb'; + +import { callbacks } from '../../../../../lib/callbacks'; +import { + notifyOnRoomChangedById, + notifyOnSubscriptionChangedByRoomId, + notifyOnLivechatInquiryChangedByRoom, +} from '../../../../lib/server/lib/notifyListener'; +import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { validateCustomFields } from './validateCustomFields'; + +type RegisterContactProps = { + _id?: string; + token: string; + name: string; + username?: string; + email?: string; + phone?: string; + customFields?: Record; + contactManager?: { + username: string; + }; +}; + +export async function registerContact({ + token, + name, + email = '', + phone, + username, + customFields = {}, + contactManager, +}: RegisterContactProps): Promise { + if (!token || typeof token !== 'string') { + throw new MeteorError('error-invalid-contact-data', 'Invalid visitor token'); + } + + const visitorEmail = email.trim().toLowerCase(); + + if (contactManager?.username) { + // verify if the user exists with this username and has a livechat-agent role + const manager = await Users.findOneByUsername(contactManager.username, { projection: { roles: 1 } }); + if (!manager) { + throw new MeteorError('error-contact-manager-not-found', `No user found with username ${contactManager.username}`); + } + if (!manager.roles || !Array.isArray(manager.roles) || !manager.roles.includes('livechat-agent')) { + throw new MeteorError('error-invalid-contact-manager', 'The contact manager must have the role "livechat-agent"'); + } + } + + let visitorId; + + const user = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); + + if (user) { + visitorId = user._id; + } else { + if (!username) { + username = await LivechatVisitors.getNextVisitorUsername(); + } + + let existingUser = null; + + if (visitorEmail !== '' && (existingUser = await LivechatVisitors.findOneGuestByEmailAddress(visitorEmail))) { + visitorId = existingUser._id; + } else { + const userData = { + username, + ts: new Date(), + token, + }; + + visitorId = (await LivechatVisitors.insertOne(userData)).insertedId; + } + } + + const allowedCF = await getAllowedCustomFields(); + const livechatData: Record = validateCustomFields(allowedCF, customFields, { ignoreAdditionalFields: true }); + + const fieldsToRemove = { + // if field is explicitely set to empty string, remove + ...(phone === '' && { phone: 1 }), + ...(visitorEmail === '' && { visitorEmails: 1 }), + ...(!contactManager?.username && { contactManager: 1 }), + }; + + const updateUser: { $set: MatchKeysAndValues; $unset?: OnlyFieldsOfType } = { + $set: { + token, + name, + livechatData, + // if phone has some value, set + ...(phone && { phone: [{ phoneNumber: phone }] }), + ...(visitorEmail && { visitorEmails: [{ address: visitorEmail }] }), + ...(contactManager?.username && { contactManager: { username: contactManager.username } }), + }, + ...(Object.keys(fieldsToRemove).length && { $unset: fieldsToRemove }), + }; + + await LivechatVisitors.updateOne({ _id: visitorId }, updateUser); + + const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}); + const rooms: IOmnichannelRoom[] = await LivechatRooms.findByVisitorId(visitorId, {}, extraQuery).toArray(); + + if (rooms?.length) { + for await (const room of rooms) { + const { _id: rid } = room; + + const responses = await Promise.all([ + Rooms.setFnameById(rid, name), + LivechatInquiry.setNameByRoomId(rid, name), + Subscriptions.updateDisplayNameByRoomId(rid, name), + ]); + + if (responses[0]?.modifiedCount) { + void notifyOnRoomChangedById(rid); + } + + if (responses[1]?.modifiedCount) { + void notifyOnLivechatInquiryChangedByRoom(rid, 'updated', { name }); + } + + if (responses[2]?.modifiedCount) { + void notifyOnSubscriptionChangedByRoomId(rid); + } + } + } + + return visitorId; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/registerGuestData.ts b/apps/meteor/app/livechat/server/lib/contacts/registerGuestData.ts new file mode 100644 index 000000000000..2186c976671f --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/registerGuestData.ts @@ -0,0 +1,39 @@ +import type { AtLeast, ILivechatVisitor } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; +import { wrapExceptions } from '@rocket.chat/tools'; + +import { validateEmail } from '../Helper'; +import type { RegisterGuestType } from '../LivechatTyped'; +import { ContactMerger, type FieldAndValue } from './ContactMerger'; + +export async function registerGuestData( + { name, phone, email, username }: Pick, + visitor: AtLeast, +): Promise { + // If a visitor was updated who already had a contact, load up that contact and update that information as well + const contact = await LivechatContacts.findOneByVisitorId(visitor._id); + if (!contact) { + return; + } + + 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; + } + + await ContactMerger.mergeFieldsIntoContact(fields, contact, contact.unknown ? 'overwrite' : 'conflict'); +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/shouldTriggerVerificationApp.ts b/apps/meteor/app/livechat/server/lib/contacts/shouldTriggerVerificationApp.ts new file mode 100644 index 000000000000..bfb9f242ef37 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/shouldTriggerVerificationApp.ts @@ -0,0 +1,6 @@ +import type { ILivechatContact, IOmnichannelSource } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const shouldTriggerVerificationApp = makeFunction( + async (_contactId: ILivechatContact['_id'], _source: IOmnichannelSource): Promise => false, +); diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts new file mode 100644 index 000000000000..6582ec1bd075 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts @@ -0,0 +1,45 @@ +import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +import { getAllowedCustomFields } from './getAllowedCustomFields'; +import { validateContactManager } from './validateContactManager'; +import { validateCustomFields } from './validateCustomFields'; + +export type UpdateContactParams = { + contactId: string; + name?: string; + emails?: string[]; + phones?: string[]; + customFields?: Record; + contactManager?: string; + channels?: ILivechatContactChannel[]; + wipeConflicts?: boolean; +}; + +export async function updateContact(params: UpdateContactParams): Promise { + const { contactId, name, emails, phones, customFields: receivedCustomFields, contactManager, channels, wipeConflicts } = params; + + const contact = await LivechatContacts.findOneById>(contactId, { projection: { _id: 1 } }); + + if (!contact) { + throw new Error('error-contact-not-found'); + } + + if (contactManager) { + await validateContactManager(contactManager); + } + + const customFields = receivedCustomFields && validateCustomFields(await getAllowedCustomFields(), receivedCustomFields); + + const updatedContact = await LivechatContacts.updateContact(contactId, { + name, + emails: emails?.map((address) => ({ address })), + phones: phones?.map((phoneNumber) => ({ phoneNumber })), + contactManager, + channels, + customFields, + ...(wipeConflicts && { conflictingFields: [], hasConflict: false }), + }); + + return updatedContact; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/validateContactManager.ts b/apps/meteor/app/livechat/server/lib/contacts/validateContactManager.ts new file mode 100644 index 000000000000..cea2c0fe0c37 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/validateContactManager.ts @@ -0,0 +1,9 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +export async function validateContactManager(contactManagerUserId: string) { + const contactManagerUser = await Users.findOneAgentById>(contactManagerUserId, { projection: { _id: 1 } }); + if (!contactManagerUser) { + throw new Error('error-contact-manager-not-found'); + } +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.ts b/apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.ts new file mode 100644 index 000000000000..e389d1b34ac9 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/validateCustomFields.ts @@ -0,0 +1,49 @@ +import type { AtLeast, ILivechatCustomField } from '@rocket.chat/core-typings'; + +import { trim } from '../../../../../lib/utils/stringUtils'; +import { i18n } from '../../../../utils/lib/i18n'; + +export function validateCustomFields( + allowedCustomFields: AtLeast[], + customFields: Record, + options?: { ignoreAdditionalFields?: boolean }, +): Record { + const validValues: Record = {}; + + for (const cf of allowedCustomFields) { + if (!customFields.hasOwnProperty(cf._id)) { + if (cf.required) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + continue; + } + const cfValue: string = trim(customFields[cf._id]); + + if (!cfValue || typeof cfValue !== 'string') { + if (cf.required) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + continue; + } + + if (cf.regexp) { + const regex = new RegExp(cf.regexp); + if (!regex.test(cfValue)) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + } + + validValues[cf._id] = cfValue; + } + + if (!options?.ignoreAdditionalFields) { + const allowedCustomFieldIds = new Set(allowedCustomFields.map((cf) => cf._id)); + for (const key in customFields) { + if (!allowedCustomFieldIds.has(key)) { + throw new Error(i18n.t('error-custom-field-not-allowed', { key })); + } + } + } + + return validValues; +} diff --git a/apps/meteor/app/livechat/server/lib/contacts/verifyContactChannel.ts b/apps/meteor/app/livechat/server/lib/contacts/verifyContactChannel.ts new file mode 100644 index 000000000000..77bc1e4653d2 --- /dev/null +++ b/apps/meteor/app/livechat/server/lib/contacts/verifyContactChannel.ts @@ -0,0 +1,12 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export type VerifyContactChannelParams = { + contactId: string; + field: string; + value: string; + visitorId: string; + roomId: string; +}; + +export const verifyContactChannel = makeFunction(async (_params: VerifyContactChannelParams): Promise => null); diff --git a/apps/meteor/app/livechat/server/methods/takeInquiry.ts b/apps/meteor/app/livechat/server/methods/takeInquiry.ts index 98dab4e1ed6b..6b312557c351 100644 --- a/apps/meteor/app/livechat/server/methods/takeInquiry.ts +++ b/apps/meteor/app/livechat/server/methods/takeInquiry.ts @@ -5,8 +5,9 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { settings } from '../../../settings/server'; -import { shouldTriggerVerificationApp, migrateVisitorIfMissingContact } from '../lib/Contacts'; import { RoutingManager } from '../lib/RoutingManager'; +import { migrateVisitorIfMissingContact } from '../lib/contacts/migrateVisitorIfMissingContact'; +import { shouldTriggerVerificationApp } from '../lib/contacts/shouldTriggerVerificationApp'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/ee/server/patches/mergeContacts.ts b/apps/meteor/ee/server/patches/mergeContacts.ts index 367e568eaf8e..433bf2d0723c 100644 --- a/apps/meteor/ee/server/patches/mergeContacts.ts +++ b/apps/meteor/ee/server/patches/mergeContacts.ts @@ -2,8 +2,8 @@ import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/cor import { License } from '@rocket.chat/license'; import { LivechatContacts } from '@rocket.chat/models'; -import { ContactMerger } from '../../../app/livechat/server/lib/ContactMerger'; -import { mergeContacts } from '../../../app/livechat/server/lib/Contacts'; +import { ContactMerger } from '../../../app/livechat/server/lib/contacts/ContactMerger'; +import { mergeContacts } from '../../../app/livechat/server/lib/contacts/mergeContacts'; import { logger } from '../../app/livechat-enterprise/server/lib/logger'; export const runMergeContacts = async (_next: any, contactId: string, visitorId: string): Promise => { diff --git a/apps/meteor/ee/server/patches/shouldTriggerVerificationApp.ts b/apps/meteor/ee/server/patches/shouldTriggerVerificationApp.ts index 58d7b9994338..035206baca15 100644 --- a/apps/meteor/ee/server/patches/shouldTriggerVerificationApp.ts +++ b/apps/meteor/ee/server/patches/shouldTriggerVerificationApp.ts @@ -2,7 +2,7 @@ import type { IOmnichannelSource, ILivechatContact } from '@rocket.chat/core-typ import { License } from '@rocket.chat/license'; import { LivechatContacts } from '@rocket.chat/models'; -import { shouldTriggerVerificationApp } from '../../../app/livechat/server/lib/Contacts'; +import { shouldTriggerVerificationApp } from '../../../app/livechat/server/lib/contacts/shouldTriggerVerificationApp'; import { settings } from '../../../app/settings/server'; const runShouldTriggerVerificationApp = async ( diff --git a/apps/meteor/ee/server/patches/verifyContactChannel.ts b/apps/meteor/ee/server/patches/verifyContactChannel.ts index e70330632520..2447bf6251b4 100644 --- a/apps/meteor/ee/server/patches/verifyContactChannel.ts +++ b/apps/meteor/ee/server/patches/verifyContactChannel.ts @@ -2,8 +2,9 @@ import type { ILivechatContact } from '@rocket.chat/core-typings'; import { License } from '@rocket.chat/license'; import { LivechatContacts, LivechatInquiry, LivechatRooms } from '@rocket.chat/models'; -import { verifyContactChannel, mergeContacts } from '../../../app/livechat/server/lib/Contacts'; import { saveQueueInquiry } from '../../../app/livechat/server/lib/QueueManager'; +import { mergeContacts } from '../../../app/livechat/server/lib/contacts/mergeContacts'; +import { verifyContactChannel } from '../../../app/livechat/server/lib/contacts/verifyContactChannel'; export const runVerifyContactChannel = async ( _next: any, diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts index aee20cd6c340..2eaa70d33303 100644 --- a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts @@ -16,8 +16,8 @@ const contactMergerStub = { }; const { runMergeContacts } = proxyquire.noCallThru().load('../../../../../../server/patches/mergeContacts', { - '../../../app/livechat/server/lib/Contacts': { mergeContacts: { patch: sinon.stub() } }, - '../../../app/livechat/server/lib/ContactMerger': { ContactMerger: contactMergerStub }, + '../../../app/livechat/server/lib/contacts/mergeContacts': { mergeContacts: { patch: sinon.stub() } }, + '../../../app/livechat/server/lib/contacts/ContactMerger': { ContactMerger: contactMergerStub }, '../../app/livechat-enterprise/server/lib/logger': { logger: { info: sinon.stub() } }, '@rocket.chat/models': modelsMock, }); diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/verifyContactChannel.spec.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/verifyContactChannel.spec.ts index 58a01f265405..1ce8e32809e0 100644 --- a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/verifyContactChannel.spec.ts +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/verifyContactChannel.spec.ts @@ -19,7 +19,8 @@ const mergeContactsStub = sinon.stub(); const saveQueueInquiryStub = sinon.stub(); const { runVerifyContactChannel } = proxyquire.noCallThru().load('../../../../../../server/patches/verifyContactChannel', { - '../../../app/livechat/server/lib/Contacts': { mergeContacts: mergeContactsStub, verifyContactChannel: { patch: sinon.stub() } }, + '../../../app/livechat/server/lib/contacts/mergeContacts': { mergeContacts: mergeContactsStub }, + '../../../app/livechat/server/lib/contacts/verifyContactChannel': { verifyContactChannel: { patch: sinon.stub() } }, '../../../app/livechat/server/lib/QueueManager': { saveQueueInquiry: saveQueueInquiryStub }, '@rocket.chat/models': modelsMock, }); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts deleted file mode 100644 index f485f59850b0..000000000000 --- a/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { ILivechatContact } from '@rocket.chat/core-typings'; -import { expect } from 'chai'; -import proxyquire from 'proxyquire'; -import sinon from 'sinon'; - -const modelsMock = { - Users: { - findOneAgentById: sinon.stub(), - findOneByUsername: sinon.stub(), - }, - LivechatContacts: { - findOneById: sinon.stub(), - insertOne: sinon.stub(), - upsertContact: sinon.stub(), - updateContact: sinon.stub(), - findContactMatchingVisitor: sinon.stub(), - findOneByVisitorId: sinon.stub(), - }, - LivechatRooms: { - findNewestByVisitorIdOrToken: sinon.stub(), - setContactIdByVisitorIdOrToken: sinon.stub(), - }, - LivechatVisitors: { - findOneById: sinon.stub(), - updateById: sinon.stub(), - updateOne: sinon.stub(), - }, - LivechatCustomField: { - findByScope: sinon.stub(), - }, -}; -const { validateCustomFields, validateContactManager, updateContact, getContact } = proxyquire - .noCallThru() - .load('../../../../../../app/livechat/server/lib/Contacts', { - 'meteor/check': sinon.stub(), - 'meteor/meteor': sinon.stub(), - '@rocket.chat/models': modelsMock, - '@rocket.chat/tools': { wrapExceptions: sinon.stub() }, - './Helper': { validateEmail: sinon.stub() }, - './LivechatTyped': { - Livechat: { - logger: { - debug: sinon.stub(), - }, - }, - }, - }); - -describe('[OC] Contacts', () => { - describe('validateCustomFields', () => { - const mockCustomFields = [{ _id: 'cf1', label: 'Custom Field 1', regexp: '^[0-9]+$', required: true }]; - - it('should validate custom fields correctly', () => { - expect(() => validateCustomFields(mockCustomFields, { cf1: '123' })).to.not.throw(); - }); - - it('should throw an error if a required custom field is missing', () => { - expect(() => validateCustomFields(mockCustomFields, {})).to.throw(); - }); - - it('should NOT throw an error when a non-required custom field is missing', () => { - const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; - const customFields = {}; - - expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); - }); - - it('should throw an error if a custom field value does not match the regexp', () => { - expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' })).to.throw(); - }); - - it('should handle an empty customFields input without throwing an error', () => { - const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; - const customFields = {}; - - expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); - }); - - it('should throw an error if a extra custom field is passed', () => { - const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; - const customFields = { field2: 'value' }; - - expect(() => validateCustomFields(allowedCustomFields, customFields)).to.throw(); - }); - }); - - describe('validateContactManager', () => { - beforeEach(() => { - modelsMock.Users.findOneAgentById.reset(); - }); - - it('should throw an error if the user does not exist', async () => { - modelsMock.Users.findOneAgentById.resolves(undefined); - await expect(validateContactManager('any_id')).to.be.rejectedWith('error-contact-manager-not-found'); - }); - - it('should not throw an error if the user has the "livechat-agent" role', async () => { - const user = { _id: 'userId' }; - modelsMock.Users.findOneAgentById.resolves(user); - - await expect(validateContactManager('userId')).to.not.be.rejected; - expect(modelsMock.Users.findOneAgentById.getCall(0).firstArg).to.be.equal('userId'); - }); - }); - - describe('updateContact', () => { - beforeEach(() => { - modelsMock.LivechatContacts.findOneById.reset(); - modelsMock.LivechatContacts.updateContact.reset(); - }); - - it('should throw an error if the contact does not exist', async () => { - modelsMock.LivechatContacts.findOneById.resolves(undefined); - await expect(updateContact('any_id')).to.be.rejectedWith('error-contact-not-found'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; - }); - - it('should update the contact with correct params', async () => { - modelsMock.LivechatContacts.findOneById.resolves({ _id: 'contactId' }); - modelsMock.LivechatContacts.updateContact.resolves({ _id: 'contactId', name: 'John Doe' } as any); - - const updatedContact = await updateContact({ contactId: 'contactId', name: 'John Doe' }); - - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); - expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'John Doe' }); - expect(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' }); - }); - }); - - describe('getContact', () => { - beforeEach(() => { - modelsMock.LivechatContacts.findOneById.reset(); - modelsMock.LivechatContacts.upsertContact.reset(); - modelsMock.LivechatContacts.insertOne.reset(); - modelsMock.LivechatVisitors.findOneById.reset(); - modelsMock.LivechatVisitors.updateById.reset(); - modelsMock.Users.findOneByUsername.reset(); - }); - - describe('contact not found', () => { - it('should search for visitor when the contact is not found', async () => { - modelsMock.LivechatContacts.findOneById.resolves(undefined); - modelsMock.LivechatVisitors.findOneById.resolves(undefined); - expect(await getContact('any_id')).to.be.null; - - expect(modelsMock.LivechatContacts.upsertContact.getCall(0)).to.be.null; - expect(modelsMock.LivechatVisitors.updateById.getCall(0)).to.be.null; - expect(modelsMock.LivechatVisitors.findOneById.getCall(0).args[0]).to.be.equal('any_id'); - }); - - it('should create a contact if there is a visitor with that id', async () => { - let createdContact: ILivechatContact | null = null; - modelsMock.LivechatContacts.findOneById.callsFake(() => createdContact); - modelsMock.Users.findOneByUsername.resolves({ _id: 'manager_id' }); - modelsMock.LivechatCustomField.findByScope.returns({ toArray: () => [] }); - modelsMock.LivechatVisitors.findOneById.resolves({ - _id: 'any_id', - contactManager: { username: 'username' }, - name: 'VisitorName', - username: 'VisitorUsername', - visitorEmails: [{ address: 'email@domain.com' }, { address: 'email2@domain.com' }], - phone: [{ phoneNumber: '1' }, { phoneNumber: '2' }], - }); - - modelsMock.LivechatContacts.upsertContact.callsFake((contactId, data) => { - createdContact = { - ...data, - _id: contactId, - }; - }); - modelsMock.LivechatContacts.insertOne.callsFake((data) => { - createdContact = { - ...data, - _id: 'random_id', - }; - return { - insertedId: 'random_id', - }; - }); - modelsMock.LivechatRooms.findNewestByVisitorIdOrToken.resolves({ - _id: 'room_id', - visitorId: 'any_id', - source: { - type: 'widget', - }, - }); - - expect(await getContact('any_id')).to.be.deep.equal({ - _id: 'any_id', - name: 'VisitorName', - emails: [{ address: 'email@domain.com' }, { address: 'email2@domain.com' }], - phones: [{ phoneNumber: '1' }, { phoneNumber: '2' }], - contactManager: 'manager_id', - unknown: true, - channels: [ - { - name: 'widget', - visitorId: 'any_id', - blocked: false, - verified: false, - details: { type: 'widget' }, - }, - ], - customFields: {}, - }); - - expect(modelsMock.LivechatContacts.findOneById.getCall(0).args[0]).to.be.equal('any_id'); - expect(modelsMock.LivechatContacts.findOneById.getCall(0).returnValue).to.be.equal(null); - expect(modelsMock.LivechatContacts.findOneById.getCall(1).args[0]).to.be.equal('any_id'); - expect(modelsMock.LivechatContacts.findOneById.getCall(1).returnValue).to.be.equal(createdContact); - - expect(modelsMock.LivechatContacts.insertOne.getCall(0)).to.be.null; - expect(modelsMock.Users.findOneByUsername.getCall(0).args[0]).to.be.equal('username'); - }); - }); - - describe('contact found', () => { - it('should not search for visitor data.', async () => { - modelsMock.LivechatContacts.findOneById.resolves({ _id: 'any_id' }); - - expect(await getContact('any_id')).to.be.deep.equal({ _id: 'any_id' }); - - expect(modelsMock.LivechatVisitors.findOneById.getCall(0)).to.be.null; - expect(modelsMock.LivechatContacts.insertOne.getCall(0)).to.be.null; - expect(modelsMock.LivechatContacts.upsertContact.getCall(0)).to.be.null; - }); - }); - }); -}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/getContact.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/getContact.spec.ts new file mode 100644 index 000000000000..dee3c0bf678f --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/getContact.spec.ts @@ -0,0 +1,103 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findOneById: sinon.stub(), + findOneByVisitorId: sinon.stub(), + }, + LivechatVisitors: { + findOneById: sinon.stub(), + }, +}; + +const migrateVisitorToContactId = sinon.stub(); + +const { getContact } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/contacts/getContact', { + './migrateVisitorToContactId': { + migrateVisitorToContactId, + }, + '@rocket.chat/models': modelsMock, +}); + +describe('getContact', () => { + beforeEach(() => { + modelsMock.LivechatContacts.findOneById.reset(); + modelsMock.LivechatContacts.findOneByVisitorId.reset(); + modelsMock.LivechatVisitors.findOneById.reset(); + migrateVisitorToContactId.reset(); + }); + + describe('using contactId', () => { + it('should return the contact data without searching for visitor.', async () => { + const contact = { _id: 'any_id' }; + modelsMock.LivechatContacts.findOneById.resolves(contact); + + expect(await getContact('any_id')).to.be.deep.equal(contact); + + expect(modelsMock.LivechatVisitors.findOneById.getCall(0)).to.be.null; + expect(migrateVisitorToContactId.getCall(0)).to.be.null; + }); + }); + + describe('using visitorId', () => { + it('should return null if neither a contact nor visitor match the ID', async () => { + modelsMock.LivechatContacts.findOneById.resolves(undefined); + modelsMock.LivechatVisitors.findOneById.resolves(undefined); + expect(await getContact('any_id')).to.be.null; + + expect(modelsMock.LivechatVisitors.findOneById.getCall(0).args[0]).to.be.equal('any_id'); + expect(migrateVisitorToContactId.getCall(0)).to.be.null; + }); + + it('should return an existing contact already associated with that visitor', async () => { + modelsMock.LivechatContacts.findOneById.resolves(undefined); + modelsMock.LivechatVisitors.findOneById.resolves({ + _id: 'visitor1', + }); + + const contact = { _id: 'contact1' }; + modelsMock.LivechatContacts.findOneByVisitorId.resolves(contact); + + expect(await getContact('any_id')).to.be.deep.equal(contact); + + expect(migrateVisitorToContactId.getCall(0)).to.be.null; + }); + + it('should attempt to migrate the visitor into a new contact if there is no existing contact', async () => { + modelsMock.LivechatContacts.findOneById.resolves(undefined); + const visitor = { _id: 'visitor1' }; + modelsMock.LivechatVisitors.findOneById.resolves(visitor); + modelsMock.LivechatContacts.findOneByVisitorId.resolves(undefined); + + expect(await getContact('any_id')).to.be.null; + expect(migrateVisitorToContactId.getCall(0)).to.not.be.null; + expect(migrateVisitorToContactId.getCall(0).args[0]).to.be.deep.equal(visitor); + }); + + it('should load data for the created contact when migration happens successfully', async () => { + modelsMock.LivechatContacts.findOneById.callsFake((id) => { + if (id === 'contact1') { + return { + _id: 'new_id', + }; + } + return undefined; + }); + const visitor = { _id: 'visitor1' }; + modelsMock.LivechatVisitors.findOneById.resolves(visitor); + modelsMock.LivechatContacts.findOneByVisitorId.resolves(undefined); + + migrateVisitorToContactId.resolves('contact1'); + + expect(await getContact('any_id')).to.be.deep.equal({ _id: 'new_id' }); + expect(migrateVisitorToContactId.getCall(0)).to.not.be.null; + expect(migrateVisitorToContactId.getCall(0).args[0]).to.be.deep.equal(visitor); + + expect(modelsMock.LivechatContacts.findOneById.getCall(0)).to.not.be.null; + expect(modelsMock.LivechatContacts.findOneById.getCall(1)).to.not.be.null; + expect(modelsMock.LivechatContacts.findOneById.getCall(1).args[0]).to.be.equal('contact1'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/mapVisitorToContact.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/mapVisitorToContact.spec.ts new file mode 100644 index 000000000000..707eac8d442e --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/mapVisitorToContact.spec.ts @@ -0,0 +1,100 @@ +import { OmnichannelSourceType, type ILivechatVisitor, type IOmnichannelSource } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import type { CreateContactParams } from '../../../../../../app/livechat/server/lib/contacts/createContact'; + +const getContactManagerIdByUsername = sinon.stub(); + +const { mapVisitorToContact } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/contacts/mapVisitorToContact', { + './getContactManagerIdByUsername': { + getContactManagerIdByUsername, + }, +}); + +const dataMap: [Partial, IOmnichannelSource, CreateContactParams][] = [ + [ + { + _id: 'visitor1', + username: 'Username', + name: 'Name', + visitorEmails: [{ address: 'email1@domain.com' }, { address: 'email2@domain.com' }], + phone: [{ phoneNumber: '10' }, { phoneNumber: '20' }], + contactManager: { + username: 'user1', + }, + }, + { + type: OmnichannelSourceType.WIDGET, + }, + { + name: 'Name', + emails: ['email1@domain.com', 'email2@domain.com'], + phones: ['10', '20'], + unknown: true, + channels: [ + { + name: 'widget', + visitorId: 'visitor1', + blocked: false, + verified: false, + details: { + type: OmnichannelSourceType.WIDGET, + }, + }, + ], + customFields: undefined, + contactManager: 'manager1', + }, + ], + + [ + { + _id: 'visitor1', + username: 'Username', + }, + { + type: OmnichannelSourceType.SMS, + }, + { + name: 'Username', + emails: undefined, + phones: undefined, + unknown: true, + channels: [ + { + name: 'sms', + visitorId: 'visitor1', + blocked: false, + verified: false, + details: { + type: OmnichannelSourceType.SMS, + }, + }, + ], + customFields: undefined, + contactManager: undefined, + }, + ], +]; + +describe('mapVisitorToContact', () => { + beforeEach(() => { + getContactManagerIdByUsername.reset(); + getContactManagerIdByUsername.callsFake((username) => { + if (username === 'user1') { + return 'manager1'; + } + + return undefined; + }); + }); + + const index = 0; + for (const [visitor, source, contact] of dataMap) { + it(`should map an ILivechatVisitor + IOmnichannelSource to an ILivechatContact [${index}]`, async () => { + expect(await mapVisitorToContact(visitor, source)).to.be.deep.equal(contact); + }); + } +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/migrateVisitorToContactId.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/migrateVisitorToContactId.spec.ts new file mode 100644 index 000000000000..5676a21f04d9 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/migrateVisitorToContactId.spec.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findContactMatchingVisitor: sinon.stub(), + }, + LivechatRooms: { + setContactIdByVisitorIdOrToken: sinon.stub(), + }, +}; + +const createContactFromVisitor = sinon.stub(); +const getVisitorNewestSource = sinon.stub(); +const mergeVisitorIntoContact = sinon.stub(); + +const { migrateVisitorToContactId } = proxyquire + .noCallThru() + .load('../../../../../../app/livechat/server/lib/contacts/migrateVisitorToContactId', { + './createContactFromVisitor': { + createContactFromVisitor, + }, + './getVisitorNewestSource': { + getVisitorNewestSource, + }, + './ContactMerger': { + ContactMerger: { + mergeVisitorIntoContact, + }, + }, + '@rocket.chat/models': modelsMock, + '../LivechatTyped': { + Livechat: { + logger: { + debug: sinon.stub(), + }, + }, + }, + }); + +describe('migrateVisitorToContactId', () => { + beforeEach(() => { + modelsMock.LivechatContacts.findContactMatchingVisitor.reset(); + modelsMock.LivechatRooms.setContactIdByVisitorIdOrToken.reset(); + createContactFromVisitor.reset(); + getVisitorNewestSource.reset(); + mergeVisitorIntoContact.reset(); + }); + + it('should not create a contact if there is no source for the visitor', async () => { + expect(await migrateVisitorToContactId({ _id: 'visitor1' })).to.be.null; + expect(getVisitorNewestSource.getCall(0)).to.not.be.null; + }); + + it('should attempt to create a new contact if there is no free existing contact matching the visitor data', async () => { + modelsMock.LivechatContacts.findContactMatchingVisitor.resolves(undefined); + createContactFromVisitor.resolves('contactCreated'); + + expect(await migrateVisitorToContactId({ _id: 'visitor1' }, { type: 'other' })).to.be.equal('contactCreated'); + expect(getVisitorNewestSource.getCall(0)).to.be.null; + }); + + it('should load the source from existing visitor rooms if none is provided', async () => { + modelsMock.LivechatContacts.findContactMatchingVisitor.resolves(undefined); + const source = { type: 'sms' }; + getVisitorNewestSource.resolves(source); + createContactFromVisitor.resolves('contactCreated'); + + const visitor = { _id: 'visitor1' }; + + expect(await migrateVisitorToContactId(visitor)).to.be.equal('contactCreated'); + expect(getVisitorNewestSource.getCall(0)).to.not.be.null; + expect(createContactFromVisitor.getCall(0).args[0]).to.be.deep.equal(visitor); + expect(createContactFromVisitor.getCall(0).args[1]).to.be.deep.equal(source); + }); + + it('should not attempt to create a new contact if one is found for the visitor', async () => { + const visitor = { _id: 'visitor1' }; + const contact = { _id: 'contact1' }; + const source = { type: 'sms' }; + modelsMock.LivechatContacts.findContactMatchingVisitor.resolves(contact); + createContactFromVisitor.resolves('contactCreated'); + + expect(await migrateVisitorToContactId(visitor, source)).to.be.equal('contact1'); + expect(mergeVisitorIntoContact.getCall(0)).to.not.be.null; + expect(mergeVisitorIntoContact.getCall(0).args[0]).to.be.deep.equal(visitor); + expect(mergeVisitorIntoContact.getCall(0).args[1]).to.be.deep.equal(contact); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/registerContact.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/registerContact.spec.ts new file mode 100644 index 000000000000..2fd8ab7ff960 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/registerContact.spec.ts @@ -0,0 +1,138 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + 'Users': { + findOneAgentById: sinon.stub(), + findOneByUsername: sinon.stub(), + }, + 'LivechatContacts': { + findOneById: sinon.stub(), + insertOne: sinon.stub(), + upsertContact: sinon.stub(), + updateContact: sinon.stub(), + findContactMatchingVisitor: sinon.stub(), + findOneByVisitorId: sinon.stub(), + }, + 'LivechatRooms': { + findNewestByVisitorIdOrToken: sinon.stub(), + setContactIdByVisitorIdOrToken: sinon.stub(), + findByVisitorId: sinon.stub(), + }, + 'LivechatVisitors': { + findOneById: sinon.stub(), + updateById: sinon.stub(), + updateOne: sinon.stub(), + getVisitorByToken: sinon.stub(), + findOneGuestByEmailAddress: sinon.stub(), + }, + 'LivechatCustomField': { + findByScope: sinon.stub(), + }, + '@global': true, +}; + +const { registerContact } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/contacts/registerContact', { + 'meteor/meteor': sinon.stub(), + '@rocket.chat/models': modelsMock, + '@rocket.chat/tools': { wrapExceptions: sinon.stub() }, + './Helper': { validateEmail: sinon.stub() }, + './LivechatTyped': { + Livechat: { + logger: { + debug: sinon.stub(), + }, + }, + }, +}); + +describe('registerContact', () => { + beforeEach(() => { + modelsMock.Users.findOneByUsername.reset(); + modelsMock.LivechatVisitors.getVisitorByToken.reset(); + modelsMock.LivechatVisitors.updateOne.reset(); + modelsMock.LivechatVisitors.findOneGuestByEmailAddress.reset(); + modelsMock.LivechatCustomField.findByScope.reset(); + modelsMock.LivechatRooms.findByVisitorId.reset(); + }); + + it(`should throw an error if there's no token`, async () => { + modelsMock.Users.findOneByUsername.returns(undefined); + + await expect( + registerContact({ + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'unknown', + }, + }), + ).to.eventually.be.rejectedWith('error-invalid-contact-data'); + }); + + it(`should throw an error if the token is not a string`, async () => { + modelsMock.Users.findOneByUsername.returns(undefined); + + await expect( + registerContact({ + token: 15, + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'unknown', + }, + }), + ).to.eventually.be.rejectedWith('error-invalid-contact-data'); + }); + + it(`should throw an error if there's an invalid manager username`, async () => { + modelsMock.Users.findOneByUsername.returns(undefined); + + await expect( + registerContact({ + token: 'token', + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'unknown', + }, + }), + ).to.eventually.be.rejectedWith('error-contact-manager-not-found'); + }); + + it(`should throw an error if the manager username does not belong to a livechat agent`, async () => { + modelsMock.Users.findOneByUsername.returns({ roles: ['user'] }); + + await expect( + registerContact({ + token: 'token', + email: 'test@test.com', + username: 'username', + name: 'Name', + contactManager: { + username: 'username', + }, + }), + ).to.eventually.be.rejectedWith('error-invalid-contact-manager'); + }); + + it('should register a contact when passing valid data', async () => { + modelsMock.LivechatVisitors.getVisitorByToken.returns({ _id: 'visitor1' }); + modelsMock.LivechatCustomField.findByScope.returns({ toArray: () => [] }); + modelsMock.LivechatRooms.findByVisitorId.returns({ toArray: () => [] }); + modelsMock.LivechatVisitors.updateOne.returns(undefined); + + await expect( + registerContact({ + token: 'token', + email: 'test@test.com', + username: 'username', + name: 'Name', + }), + ).to.eventually.be.equal('visitor1'); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/updateContact.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/updateContact.spec.ts new file mode 100644 index 000000000000..b15d5d10dbe9 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/updateContact.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + LivechatContacts: { + findOneById: sinon.stub(), + updateContact: sinon.stub(), + }, +}; + +const { updateContact } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/contacts/updateContact', { + './getAllowedCustomFields': { + getAllowedCustomFields: sinon.stub(), + }, + './validateContactManager': { + validateContactManager: sinon.stub(), + }, + './validateCustomFields': { + validateCustomFields: sinon.stub(), + }, + + '@rocket.chat/models': modelsMock, +}); + +describe('updateContact', () => { + beforeEach(() => { + modelsMock.LivechatContacts.findOneById.reset(); + modelsMock.LivechatContacts.updateContact.reset(); + }); + + it('should throw an error if the contact does not exist', async () => { + modelsMock.LivechatContacts.findOneById.resolves(undefined); + await expect(updateContact('any_id')).to.be.rejectedWith('error-contact-not-found'); + expect(modelsMock.LivechatContacts.updateContact.getCall(0)).to.be.null; + }); + + it('should update the contact with correct params', async () => { + modelsMock.LivechatContacts.findOneById.resolves({ _id: 'contactId' }); + modelsMock.LivechatContacts.updateContact.resolves({ _id: 'contactId', name: 'John Doe' } as any); + + const updatedContact = await updateContact({ contactId: 'contactId', name: 'John Doe' }); + + expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[0]).to.be.equal('contactId'); + expect(modelsMock.LivechatContacts.updateContact.getCall(0).args[1]).to.be.deep.contain({ name: 'John Doe' }); + expect(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' }); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/validateContactManager.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/validateContactManager.spec.ts new file mode 100644 index 000000000000..91ffb862556c --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/validateContactManager.spec.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const modelsMock = { + Users: { + findOneAgentById: sinon.stub(), + }, +}; + +const { validateContactManager } = proxyquire + .noCallThru() + .load('../../../../../../app/livechat/server/lib/contacts/validateContactManager', { + '@rocket.chat/models': modelsMock, + }); + +describe('validateContactManager', () => { + beforeEach(() => { + modelsMock.Users.findOneAgentById.reset(); + }); + + it('should throw an error if the user does not exist', async () => { + modelsMock.Users.findOneAgentById.resolves(undefined); + await expect(validateContactManager('any_id')).to.be.rejectedWith('error-contact-manager-not-found'); + }); + + it('should not throw an error if the user has the "livechat-agent" role', async () => { + const user = { _id: 'userId' }; + modelsMock.Users.findOneAgentById.resolves(user); + + await expect(validateContactManager('userId')).to.not.be.rejected; + expect(modelsMock.Users.findOneAgentById.getCall(0).firstArg).to.be.equal('userId'); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/validateCustomFields.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/validateCustomFields.spec.ts new file mode 100644 index 000000000000..37e31744ab51 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/validateCustomFields.spec.ts @@ -0,0 +1,43 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; + +const { validateCustomFields } = proxyquire + .noCallThru() + .load('../../../../../../app/livechat/server/lib/contacts/validateCustomFields', {}); + +describe('validateCustomFields', () => { + const mockCustomFields = [{ _id: 'cf1', label: 'Custom Field 1', regexp: '^[0-9]+$', required: true }]; + + it('should validate custom fields correctly', () => { + expect(() => validateCustomFields(mockCustomFields, { cf1: '123' })).to.not.throw(); + }); + + it('should throw an error if a required custom field is missing', () => { + expect(() => validateCustomFields(mockCustomFields, {})).to.throw(); + }); + + it('should NOT throw an error when a non-required custom field is missing', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = {}; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); + }); + + it('should throw an error if a custom field value does not match the regexp', () => { + expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' })).to.throw(); + }); + + it('should handle an empty customFields input without throwing an error', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = {}; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); + }); + + it('should throw an error if a extra custom field is passed', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = { field2: 'value' }; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).to.throw(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 3fb389c255ae..1836871ef9e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8133,8 +8133,6 @@ __metadata: typescript: "npm:~5.1.6" uglify-es: "npm:^3.3.10" uuid: "npm:~8.3.2" - peerDependencies: - "@rocket.chat/ui-kit": "workspace:^" languageName: unknown linkType: soft