snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,336 @@
import type {
ILivechatContact,
ILivechatVisitor,
ILivechatContactChannel,
ILivechatContactConflictingField,
IUser,
DeepWritable,
IOmnichannelSource,
} from '@rocket.chat/core-typings';
import { LivechatContacts } from '@rocket.chat/models';
import type { ClientSession, UpdateFilter } from 'mongodb';
import { getContactManagerIdByUsername } from './getContactManagerIdByUsername';
import { isSameChannel } from '../../../../app/livechat/lib/isSameChannel';
type ManagerValue = { id: string } | { username: string };
type ContactFields = {
email: string;
phone: string;
name: string;
username: string;
manager: ManagerValue;
channel: ILivechatContactChannel;
activity: string[];
};
type CustomFieldAndValue = { type: `customFields.${string}`; value: string };
export type FieldAndValue =
| { type: keyof Omit<ContactFields, 'manager' | 'channel'>; value: string }
| { type: 'manager'; value: ManagerValue }
| { type: 'channel'; value: ILivechatContactChannel }
| { type: 'activity'; value: string[] }
| CustomFieldAndValue;
type ConflictHandlingMode = 'conflict' | 'overwrite' | 'ignore';
type MergeFieldsIntoContactParams = {
fields: FieldAndValue[];
contact: ILivechatContact;
conflictHandlingMode?: ConflictHandlingMode;
session?: ClientSession;
};
export class ContactMerger {
private managerList = new Map<Required<IUser>['username'], IUser['_id'] | undefined>();
private getManagerId(manager: ManagerValue): IUser['_id'] | undefined {
if ('id' in manager) {
return manager.id;
}
return this.managerList.get(manager.username);
}
private isSameManager(manager1: ManagerValue, manager2: ManagerValue): boolean {
if ('id' in manager1 && 'id' in manager2) {
return manager1.id === manager2.id;
}
if ('username' in manager1 && 'username' in manager2) {
return manager1.username === manager2.username;
}
const id1 = this.getManagerId(manager1);
const id2 = this.getManagerId(manager2);
if (!id1 || !id2) {
return false;
}
return id1 === id2;
}
private isSameField(field1: FieldAndValue, field2: FieldAndValue): boolean {
if (field1.type === 'manager' && field2.type === 'manager') {
return this.isSameManager(field1.value, field2.value);
}
if (field1.type === 'channel' && field2.type === 'channel') {
return isSameChannel(field1.value.visitor, field2.value.visitor);
}
if (field1.type !== field2.type) {
return false;
}
if (field1.value === field2.value) {
return true;
}
return false;
}
private async loadDataForFields(session: ClientSession | undefined, ...fieldLists: FieldAndValue[][]): Promise<void> {
for (const fieldList of fieldLists) {
for (const field of fieldList) {
if (field.type !== 'manager' || 'id' in field.value) {
continue;
}
if (!field.value.username) {
continue;
}
if (this.managerList.has(field.value.username)) {
continue;
}
const id = await getContactManagerIdByUsername(field.value.username, session);
this.managerList.set(field.value.username, id);
}
}
}
static async createWithFields(session: ClientSession | undefined, ...fieldLists: FieldAndValue[][]): Promise<ContactMerger> {
const merger = new ContactMerger();
await merger.loadDataForFields(session, ...fieldLists);
return merger;
}
static getAllFieldsFromContact(contact: ILivechatContact): FieldAndValue[] {
const { customFields = {}, name, contactManager, activity } = contact;
const fields = new Set<FieldAndValue>();
contact.emails?.forEach(({ address: value }) => fields.add({ type: 'email', value }));
contact.phones?.forEach(({ phoneNumber: value }) => fields.add({ type: 'phone', value }));
contact.channels.forEach((value) => fields.add({ type: 'channel', value }));
if (name) {
fields.add({ type: 'name', value: name });
}
if (contactManager) {
fields.add({ type: 'manager', value: { id: contactManager } });
}
if (activity) {
fields.add({ type: 'activity', value: activity });
}
Object.keys(customFields).forEach((key) =>
fields.add({ type: `customFields.${key}`, value: customFields[key] } as CustomFieldAndValue),
);
// If the contact already has conflicts, load their values as well
if (contact.conflictingFields) {
for (const conflict of contact.conflictingFields) {
fields.add({ type: conflict.field, value: conflict.value } as FieldAndValue);
}
}
return [...fields];
}
static async getAllFieldsFromVisitor(visitor: ILivechatVisitor, source?: IOmnichannelSource): Promise<FieldAndValue[]> {
const { livechatData: customFields = {}, contactManager, name, username } = visitor;
const fields = new Set<FieldAndValue>();
visitor.visitorEmails?.forEach(({ address: value }) => fields.add({ type: 'email', value }));
visitor.phone?.forEach(({ phoneNumber: value }) => fields.add({ type: 'phone', value }));
if (name) {
fields.add({ type: 'name', value: name });
}
if (username) {
fields.add({ type: 'username', value: username });
}
if (contactManager?.username) {
fields.add({ type: 'manager', value: { username: contactManager?.username } });
}
Object.keys(customFields).forEach((key) =>
fields.add({ type: `customFields.${key}`, value: customFields[key] } as CustomFieldAndValue),
);
if (source) {
fields.add({
type: 'channel',
value: {
name: source.label || source.type.toString(),
visitor: {
visitorId: visitor._id,
source: {
type: source.type,
id: source.id,
},
},
blocked: false,
verified: false,
details: source,
},
});
}
return [...fields];
}
static getFieldValuesByType<T extends keyof ContactFields>(fields: FieldAndValue[], type: T): ContactFields[T][] {
return fields.filter((field) => field.type === type).map(({ value }) => value) as ContactFields[T][];
}
static async mergeFieldsIntoContact({
fields,
contact,
conflictHandlingMode = 'conflict',
session,
}: MergeFieldsIntoContactParams): Promise<void> {
const existingFields = ContactMerger.getAllFieldsFromContact(contact);
const overwriteData = conflictHandlingMode === 'overwrite';
const merger = await ContactMerger.createWithFields(session, fields, existingFields);
const newFields = fields.filter((field) => {
// If the field already exists with the same value, ignore it
if (existingFields.some((existingField) => merger.isSameField(existingField, field))) {
return false;
}
// If the field is an username and the contact already has a name, ignore it as well
if (field.type === 'username' && existingFields.some(({ type }) => type === 'name')) {
return false;
}
return true;
});
const newPhones = ContactMerger.getFieldValuesByType(newFields, 'phone');
const newEmails = ContactMerger.getFieldValuesByType(newFields, 'email');
const newChannels = ContactMerger.getFieldValuesByType(newFields, 'channel');
const newActivities = ContactMerger.getFieldValuesByType(newFields, 'activity');
const newNamesOnly = ContactMerger.getFieldValuesByType(newFields, 'name');
const newCustomFields = newFields.filter(({ type }) => type.startsWith('customFields.')) as CustomFieldAndValue[];
// Usernames are ignored unless the contact has no other name
const newUsernames = !contact.name && !newNamesOnly.length ? ContactMerger.getFieldValuesByType(newFields, 'username') : [];
const dataToSet: DeepWritable<UpdateFilter<ILivechatContact>['$set']> = {};
// Names, Managers and Custom Fields need are set as conflicting fields if the contact already has them
const newNames = [...newNamesOnly, ...newUsernames];
const newManagers = ContactMerger.getFieldValuesByType(newFields, 'manager')
.map((manager) => {
if ('id' in manager) {
return manager.id;
}
return merger.getManagerId(manager);
})
.filter((id) => Boolean(id));
if (newNames.length && (!contact.name || overwriteData)) {
const firstName = newNames.shift();
if (firstName) {
dataToSet.name = firstName;
}
}
if (newManagers.length && (!contact.contactManager || overwriteData)) {
const firstManager = newManagers.shift();
if (firstManager) {
dataToSet.contactManager = firstManager;
}
}
if (newActivities.length) {
const newActivity = newActivities.shift();
if (newActivity) {
const distinctActivities = new Set([...newActivity, ...(contact.activity || [])]);
const latestActivities = Array.from(distinctActivities).sort().slice(-12);
dataToSet.activity = latestActivities;
}
}
const customFieldsPerName = new Map<string, CustomFieldAndValue[]>();
for (const customField of newCustomFields) {
if (!customFieldsPerName.has(customField.type)) {
customFieldsPerName.set(customField.type, []);
}
customFieldsPerName.get(customField.type)?.push(customField);
}
for (const [key, customFields] of customFieldsPerName) {
const fieldName = key.replace('customFields.', '');
// If the contact does not have this custom field yet, save the first value directly to the contact instead of as a conflict
if (!contact.customFields?.[fieldName] || overwriteData) {
const first = customFields.shift();
if (first) {
dataToSet[key] = first.value;
}
}
}
const allConflicts: ILivechatContactConflictingField[] =
conflictHandlingMode !== 'conflict'
? []
: [
...newNames.map((name): ILivechatContactConflictingField => ({ field: 'name', value: name })),
...newManagers.map((manager): ILivechatContactConflictingField => ({ field: 'manager', value: manager as string })),
];
// Phones, Emails and Channels are simply added to the contact's existing list
const dataToAdd: UpdateFilter<ILivechatContact>['$addToSet'] = {
...(newPhones.length ? { phones: { $each: newPhones.map((phoneNumber) => ({ phoneNumber })) } } : {}),
...(newEmails.length ? { emails: { $each: newEmails.map((address) => ({ address })) } } : {}),
...(newChannels.length ? { channels: { $each: newChannels } } : {}),
...(allConflicts.length ? { conflictingFields: { $each: allConflicts } } : {}),
};
if (newChannels.length) {
dataToSet.preRegistration = false;
}
const updateData: UpdateFilter<ILivechatContact> = {
...(Object.keys(dataToSet).length ? { $set: dataToSet } : {}),
...(Object.keys(dataToAdd).length ? { $addToSet: dataToAdd } : {}),
};
if (Object.keys(updateData).length) {
await LivechatContacts.updateById(contact._id, updateData, { session });
}
}
public static async mergeVisitorIntoContact(
visitor: ILivechatVisitor,
contact: ILivechatContact,
source?: IOmnichannelSource,
): Promise<void> {
const fields = await ContactMerger.getAllFieldsFromVisitor(visitor, source);
await ContactMerger.mergeFieldsIntoContact({
fields,
contact,
conflictHandlingMode: contact.unknown ? 'overwrite' : 'conflict',
});
}
}

View File

@@ -0,0 +1,20 @@
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<ILivechatContact> {
const contact = await LivechatContacts.addEmail(contactId, email);
if (!contact) {
throw new Error('error-contact-not-found');
}
return contact;
}

View File

@@ -0,0 +1,52 @@
import type { ILivechatContactChannel, IVisitorLastChat } 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<string, string | unknown>;
lastChat?: IVisitorLastChat;
contactManager?: string;
channels?: ILivechatContactChannel[];
importIds?: string[];
shouldValidateCustomFields?: boolean;
};
export async function createContact({
name,
emails,
phones,
customFields: receivedCustomFields = {},
lastChat,
contactManager,
channels = [],
unknown,
importIds,
shouldValidateCustomFields = true,
}: CreateContactParams): Promise<string> {
if (contactManager) {
await validateContactManager(contactManager);
}
const customFields = shouldValidateCustomFields
? validateCustomFields(await getAllowedCustomFields(), receivedCustomFields)
: receivedCustomFields;
return LivechatContacts.insertContact({
name,
...(emails && { emails: emails?.map((address) => ({ address })) }),
...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }),
...(contactManager && { contactManager }),
...(channels && { channels }),
...(customFields && { customFields }),
...(lastChat && { lastChat }),
unknown,
...(importIds?.length && { importIds }),
});
}

View File

@@ -0,0 +1,24 @@
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): Promise<string> {
const contactData = await mapVisitorToContact(visitor, source);
const contactId = await createContact(contactData);
await LivechatRooms.setContactByVisitorAssociation(
{
visitorId: visitor._id,
source: { type: source.type, ...(source.id ? { id: source.id } : {}) },
},
{
_id: contactId,
name: contactData.name,
},
);
return contactId;
}

View File

@@ -0,0 +1,23 @@
import type { ILivechatContact } from '@rocket.chat/core-typings';
import { LivechatContacts, LivechatRooms } from '@rocket.chat/models';
import { settings } from '../../../settings';
import { removeGuest } from '../guests';
export async function disableContactById(contactId: string): Promise<void> {
const contact = await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, '_id' | 'channels'>>(contactId);
if (!contact) {
throw new Error('error-contact-not-found');
}
// Checking if the contact has any open channel/room before removing its data.
const contactOpenRooms = await LivechatRooms.checkContactOpenRooms(contactId);
if (contactOpenRooms && !settings.get<boolean>('Livechat_Allow_collect_and_store_HTTP_header_informations')) {
throw new Error('error-contact-has-open-rooms');
}
// Cleaning contact/visitor data;
await Promise.all(contact.channels.map((channel) => removeGuest({ _id: channel.visitor.visitorId })));
await LivechatContacts.disableByContactId(contactId);
}

View File

@@ -0,0 +1,12 @@
import type { ILivechatCustomField } from '@rocket.chat/core-typings';
import { LivechatCustomField } from '@rocket.chat/models';
export async function getAllowedCustomFields(): Promise<Pick<ILivechatCustomField, '_id' | 'label' | 'regexp' | 'required'>[]> {
return LivechatCustomField.findByScope(
'visitor',
{
projection: { _id: 1, label: 1, regexp: 1, required: 1 },
},
false,
).toArray();
}

View File

@@ -0,0 +1,26 @@
import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings';
import { LivechatContacts } from '@rocket.chat/models';
export async function getContactChannelsGrouped(contactId: string): Promise<ILivechatContactChannel[]> {
const contact = await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, 'channels'>>(contactId, { projection: { channels: 1 } });
if (!contact?.channels) {
return [];
}
const groupedChannels = new Map<string, ILivechatContactChannel>();
contact.channels.forEach((channel: ILivechatContactChannel) => {
const existingChannel = groupedChannels.get(channel.name);
if (!existingChannel) {
return groupedChannels.set(channel.name, channel);
}
if ((channel.lastChat?.ts?.valueOf() || 0) > (existingChannel?.lastChat?.ts?.valueOf() || 0)) {
groupedChannels.set(channel.name, channel);
}
});
return [...groupedChannels.values()];
}

View File

@@ -0,0 +1,76 @@
import type { ILivechatContact, IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { FindPaginated } from '@rocket.chat/model-typings';
import { LivechatContacts, LivechatRooms } from '@rocket.chat/models';
import { makeFunction } from '@rocket.chat/patch-injection';
import type { PaginatedResult, VisitorSearchChatsResult } from '@rocket.chat/rest-typings';
import type { FindOptions, Sort, FindCursor } from 'mongodb';
export type GetContactHistoryParams = {
contactId: string;
source?: string;
count: number;
offset: number;
sort: Sort;
};
export const fetchContactHistory = makeFunction(
async ({
contactId,
options,
}: {
contactId: string;
options?: FindOptions<IOmnichannelRoom>;
extraParams?: Record<string, any>;
}): Promise<FindPaginated<FindCursor<IOmnichannelRoom>>> =>
LivechatRooms.findClosedRoomsByContactPaginated({
contactId,
options,
}),
);
export const getContactHistory = makeFunction(
async (params: GetContactHistoryParams): Promise<PaginatedResult<{ history: VisitorSearchChatsResult[] }>> => {
const { contactId, count, offset, sort } = params;
const contact = await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, '_id'>>(contactId, { projection: { _id: 1 } });
if (!contact) {
throw new Error('error-contact-not-found');
}
const options: FindOptions<IOmnichannelRoom> = {
sort: sort || { closedAt: -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 } = await fetchContactHistory({
contactId: contact._id,
options,
extraParams: params,
});
const [total, history] = await Promise.all([totalCount, cursor.toArray()]);
return {
history,
count: history.length,
offset,
total,
};
},
);

View File

@@ -0,0 +1,8 @@
import type { ILivechatContact, ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings';
import { LivechatContacts } from '@rocket.chat/models';
export async function getContactIdByVisitor(visitor: ILivechatContactVisitorAssociation): Promise<ILivechatContact['_id'] | undefined> {
const contact = await LivechatContacts.findOneByVisitor<Pick<ILivechatContact, '_id'>>(visitor, { projection: { _id: 1 } });
return contact?._id;
}

View File

@@ -0,0 +1,12 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import type { ClientSession } from 'mongodb';
export async function getContactManagerIdByUsername(
username: Required<IUser>['username'],
session?: ClientSession,
): Promise<IUser['_id'] | undefined> {
const user = await Users.findOneByUsername<Pick<IUser, '_id'>>(username, { projection: { _id: 1 }, session });
return user?._id;
}

View File

@@ -0,0 +1,51 @@
import type { IUser } from '@rocket.chat/core-typings';
import { isNotUndefined } from '@rocket.chat/core-typings';
import { LivechatContacts, Users } from '@rocket.chat/models';
import type { PaginatedResult, ILivechatContactWithManagerData } from '@rocket.chat/rest-typings';
import type { FindCursor, Sort } from 'mongodb';
export type GetContactsParams = {
searchText?: string;
count: number;
offset: number;
sort: Sort;
unknown?: boolean;
};
export async function getContacts(params: GetContactsParams): Promise<PaginatedResult<{ contacts: ILivechatContactWithManagerData[] }>> {
const { searchText, count, offset, sort, unknown } = params;
const { cursor, totalCount } = LivechatContacts.findPaginatedContacts(
{ searchText, unknown },
{
limit: count,
skip: offset,
sort: sort ?? { name: 1 },
},
);
const [rawContacts, total] = await Promise.all([cursor.toArray(), totalCount]);
const managerIds = [...new Set(rawContacts.map(({ contactManager }) => contactManager))].filter(isNotUndefined);
const managersCursor: FindCursor<[string, Pick<IUser, '_id' | 'name' | 'username'>]> = Users.findByIds(managerIds, {
projection: { name: 1, username: 1 },
}).map((manager) => [manager._id, manager]);
const managersData = await managersCursor.toArray();
const mappedManagers = Object.fromEntries(managersData);
const contacts: ILivechatContactWithManagerData[] = rawContacts.map((contact) => {
const { contactManager, ...data } = contact;
return {
...data,
...(contactManager ? { contactManager: mappedManagers[contactManager] } : {}),
};
});
return {
contacts,
count,
offset,
total,
};
}

View File

@@ -0,0 +1,12 @@
import type { ILivechatContact, ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';
export const isAgentAvailableToTakeContactInquiry = makeFunction(
async (
_visitorId: ILivechatVisitor['_id'],
_source: IOmnichannelSource,
_contactId: ILivechatContact['_id'],
): Promise<{ error: string; value: false } | { value: true }> => ({
value: true,
}),
);

View File

@@ -0,0 +1,110 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
const { isVerifiedChannelInSource } = proxyquire.noCallThru().load('./isVerifiedChannelInSource', {});
describe('isVerifiedChannelInSource', () => {
it('should return false if channel is not verified', () => {
const channel = {
verified: false,
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false;
});
it('should return false if channel visitorId is different from visitorId', () => {
const channel = {
verified: true,
visitor: {
visitorId: 'visitor2',
},
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false;
});
it('should return false if channel visitor source type is different from source type', () => {
const channel = {
verified: true,
visitor: {
visitorId: 'visitor1',
source: {
type: 'web',
},
},
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false;
});
it('should return false if channel visitor source id is different from source id', () => {
const channel = {
verified: true,
visitor: {
visitorId: 'visitor1',
source: {
type: 'widget',
id: 'source1',
},
},
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
id: 'source2',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false;
});
it('should return false if source id is not defined and channel visitor source id is defined', () => {
const channel = {
verified: true,
visitor: {
visitorId: 'visitor1',
source: {
type: 'widget',
id: 'source1',
},
},
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.false;
});
it('should return true if all conditions are met', () => {
const channel = {
verified: true,
visitor: {
visitorId: 'visitor1',
source: {
type: 'widget',
id: 'source1',
},
},
};
const visitorId = 'visitor1';
const source = {
type: 'widget',
id: 'source1',
};
expect(isVerifiedChannelInSource(channel, visitorId, source)).to.be.true;
});
});

View File

@@ -0,0 +1,25 @@
import type { ILivechatContactChannel, ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings';
export const isVerifiedChannelInSource = (
channel: ILivechatContactChannel,
visitorId: ILivechatVisitor['_id'],
source: IOmnichannelSource,
) => {
if (!channel.verified) {
return false;
}
if (channel.visitor.visitorId !== visitorId) {
return false;
}
if (channel.visitor.source.type !== source.type) {
return false;
}
if ((source.id || channel.visitor.source.id) && channel.visitor.source.id !== source.id) {
return false;
}
return true;
};

View File

@@ -0,0 +1,214 @@
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 './createContact';
const getContactManagerIdByUsername = sinon.stub();
const getAllowedCustomFields = sinon.stub();
const { mapVisitorToContact } = proxyquire.noCallThru().load('./mapVisitorToContact', {
'./getContactManagerIdByUsername': {
getContactManagerIdByUsername,
},
'./getAllowedCustomFields': { getAllowedCustomFields },
});
const testDate = new Date();
const dataMap: [Partial<ILivechatVisitor>, 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',
},
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
{
type: OmnichannelSourceType.WIDGET,
},
{
name: 'Name',
emails: ['email1@domain.com', 'email2@domain.com'],
phones: ['10', '20'],
unknown: true,
channels: [
{
name: 'widget',
visitor: {
visitorId: 'visitor1',
source: {
type: OmnichannelSourceType.WIDGET,
},
},
blocked: false,
verified: false,
details: {
type: OmnichannelSourceType.WIDGET,
},
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
],
lastChat: { _id: 'afdsfdasf', ts: testDate },
customFields: undefined,
shouldValidateCustomFields: false,
contactManager: 'manager1',
},
],
[
{
_id: 'visitor1',
username: 'Username',
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
{
type: OmnichannelSourceType.SMS,
},
{
name: 'Username',
emails: undefined,
phones: undefined,
unknown: true,
channels: [
{
name: 'sms',
visitor: {
visitorId: 'visitor1',
source: {
type: OmnichannelSourceType.SMS,
},
},
blocked: false,
verified: false,
details: {
type: OmnichannelSourceType.SMS,
},
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
],
customFields: undefined,
shouldValidateCustomFields: false,
contactManager: undefined,
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
],
[
{
_id: 'visitor1',
username: 'Username',
activity: ['2024-11'],
lastChat: {
_id: 'last-chat-id',
ts: testDate,
},
},
{
type: OmnichannelSourceType.WIDGET,
},
{
name: 'Username',
emails: undefined,
phones: undefined,
unknown: false,
channels: [
{
name: 'widget',
visitor: {
visitorId: 'visitor1',
source: {
type: OmnichannelSourceType.WIDGET,
},
},
blocked: false,
verified: false,
details: {
type: OmnichannelSourceType.WIDGET,
},
lastChat: {
_id: 'last-chat-id',
ts: testDate,
},
},
],
customFields: undefined,
shouldValidateCustomFields: false,
lastChat: {
_id: 'last-chat-id',
ts: testDate,
},
contactManager: undefined,
},
],
[
{
_id: 'visitor1',
username: 'Username',
livechatData: {
customFieldId: 'customFieldValue',
invalidCustomFieldId: 'invalidCustomFieldValue',
},
activity: [],
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
{
type: OmnichannelSourceType.WIDGET,
},
{
name: 'Username',
emails: undefined,
phones: undefined,
unknown: true,
channels: [
{
name: 'widget',
visitor: {
visitorId: 'visitor1',
source: {
type: OmnichannelSourceType.WIDGET,
},
},
blocked: false,
verified: false,
details: {
type: OmnichannelSourceType.WIDGET,
},
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
],
customFields: {
customFieldId: 'customFieldValue',
},
shouldValidateCustomFields: false,
contactManager: undefined,
lastChat: { _id: 'afdsfdasf', ts: testDate },
},
],
];
describe('mapVisitorToContact', () => {
beforeEach(() => {
getContactManagerIdByUsername.reset();
getContactManagerIdByUsername.callsFake((username) => {
if (username === 'user1') {
return 'manager1';
}
return undefined;
});
getAllowedCustomFields.resolves([{ _id: 'customFieldId', label: 'custom-field-label' }]);
});
dataMap.forEach(([visitor, source, contact], index) => {
it(`should map an ILivechatVisitor + IOmnichannelSource to an ILivechatContact [${index}]`, async () => {
expect(await mapVisitorToContact(visitor, source)).to.be.deep.equal(contact);
});
});
});

View File

@@ -0,0 +1,40 @@
import type { ILivechatVisitor, IOmnichannelSource } from '@rocket.chat/core-typings';
import type { CreateContactParams } from './createContact';
import { getAllowedCustomFields } from './getAllowedCustomFields';
import { getContactManagerIdByUsername } from './getContactManagerIdByUsername';
import { validateCustomFields } from './validateCustomFields';
export async function mapVisitorToContact(visitor: ILivechatVisitor, source: IOmnichannelSource): Promise<CreateContactParams> {
return {
name: visitor.name || visitor.username,
emails: visitor.visitorEmails?.map(({ address }) => address),
phones: visitor.phone?.map(({ phoneNumber }) => phoneNumber),
unknown: !visitor.activity || visitor.activity.length === 0,
channels: [
{
name: source.label || source.type.toString(),
visitor: {
visitorId: visitor._id,
source: {
type: source.type,
...(source.id ? { id: source.id } : {}),
},
},
blocked: false,
verified: false,
details: source,
lastChat: visitor.lastChat,
},
],
customFields:
visitor.livechatData &&
validateCustomFields(await getAllowedCustomFields(), visitor.livechatData, {
ignoreAdditionalFields: true,
ignoreValidationErrors: true,
}),
shouldValidateCustomFields: false,
lastChat: visitor.lastChat,
contactManager: visitor.contactManager?.username && (await getContactManagerIdByUsername(visitor.contactManager.username)),
};
}

View File

@@ -0,0 +1,8 @@
import type { ILivechatContact, ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';
import type { ClientSession } from 'mongodb';
export const mergeContacts = makeFunction(
async (_contactId: string, _visitor: ILivechatContactVisitorAssociation, _session?: ClientSession): Promise<ILivechatContact | null> =>
null,
);

View File

@@ -0,0 +1,25 @@
import type { ILivechatVisitor, IOmnichannelSource, ILivechatContact } from '@rocket.chat/core-typings';
import { LivechatVisitors } from '@rocket.chat/models';
import { livechatContactsLogger as logger } from '../logger';
import { getContactIdByVisitor } from './getContactIdByVisitor';
import { migrateVisitorToContactId } from './migrateVisitorToContactId';
export async function migrateVisitorIfMissingContact(
visitorId: ILivechatVisitor['_id'],
source: IOmnichannelSource,
): Promise<ILivechatContact['_id'] | null> {
logger.debug({ msg: 'Detecting visitor contact ID' });
// Check if there is any contact already linking to this visitorId and source
const contactId = await getContactIdByVisitor({ visitorId, source });
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, requireRoom: false });
}

View File

@@ -0,0 +1,69 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatContacts: {
findContactMatchingVisitor: sinon.stub(),
},
LivechatRooms: {
setContactByVisitorAssociation: sinon.stub(),
findNewestByContactVisitorAssociation: sinon.stub(),
},
};
const createContactFromVisitor = sinon.stub();
const mergeVisitorIntoContact = sinon.stub();
const { migrateVisitorToContactId } = proxyquire.noCallThru().load('./migrateVisitorToContactId', {
'./createContactFromVisitor': {
createContactFromVisitor,
},
'./ContactMerger': {
ContactMerger: {
mergeVisitorIntoContact,
},
},
'@rocket.chat/models': modelsMock,
'../logger': {
livechatContactsLogger: {
debug: sinon.stub(),
},
},
});
describe('migrateVisitorToContactId', () => {
beforeEach(() => {
modelsMock.LivechatContacts.findContactMatchingVisitor.reset();
modelsMock.LivechatRooms.setContactByVisitorAssociation.reset();
modelsMock.LivechatRooms.findNewestByContactVisitorAssociation.reset();
createContactFromVisitor.reset();
mergeVisitorIntoContact.reset();
});
it('should not create a contact if there is no source for the visitor', async () => {
expect(await migrateVisitorToContactId({ visitor: { _id: 'visitor1' } })).to.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);
const visitor = { _id: 'visitor1' };
const source = { type: 'other' };
modelsMock.LivechatRooms.findNewestByContactVisitorAssociation.resolves({ _id: 'room1', v: { _id: visitor._id }, source });
createContactFromVisitor.resolves('contactCreated');
expect(await migrateVisitorToContactId({ visitor: { _id: 'visitor1' }, source })).to.be.equal('contactCreated');
});
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.LivechatRooms.findNewestByContactVisitorAssociation.resolves({ _id: 'room1', v: { _id: visitor._id }, source });
modelsMock.LivechatContacts.findContactMatchingVisitor.resolves(contact);
createContactFromVisitor.resolves('contactCreated');
expect(await migrateVisitorToContactId({ visitor, source })).to.be.equal('contact1');
expect(mergeVisitorIntoContact.calledOnceWith(visitor, contact, source)).to.be.true;
});
});

View File

@@ -0,0 +1,55 @@
import type { ILivechatVisitor, IOmnichannelSource, ILivechatContact, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatContacts, LivechatRooms } from '@rocket.chat/models';
import { livechatContactsLogger as logger } from '../logger';
import { ContactMerger } from './ContactMerger';
import { createContactFromVisitor } from './createContactFromVisitor';
/**
This function assumes you already ensured that the visitor is not yet linked to any contact
**/
export async function migrateVisitorToContactId({
visitor,
source,
requireRoom = true,
}: {
visitor: ILivechatVisitor;
source: IOmnichannelSource;
requireRoom?: boolean;
}): Promise<ILivechatContact['_id'] | null> {
if (requireRoom) {
// Do not migrate the visitor with this source if they have no rooms matching it
const anyRoom = await LivechatRooms.findNewestByContactVisitorAssociation<Pick<IOmnichannelRoom, '_id'>>(
{ visitorId: visitor._id, source },
{
projection: { _id: 1 },
},
);
if (!anyRoom) {
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) {
logger.debug({ msg: 'Creating a new contact for existing visitor', visitorId: visitor._id });
return createContactFromVisitor(visitor, source);
}
// There is already an existing contact with no linked visitors and matching this visitor's phone or email, so let's use it
logger.debug({ msg: 'Adding channel to existing contact', contactId: existingContact._id });
await ContactMerger.mergeVisitorIntoContact(visitor, existingContact, source);
// Update all existing rooms matching the visitor id and source to set the contactId to them
await LivechatRooms.setContactByVisitorAssociation(
{
visitorId: visitor._id,
source,
},
existingContact,
);
return existingContact._id;
}

View File

@@ -0,0 +1,14 @@
import type { ILivechatContact } from '@rocket.chat/core-typings';
import { LivechatContacts } from '@rocket.chat/models';
export const patchContact = async (
contactId: ILivechatContact['_id'],
data: { set?: Partial<ILivechatContact>; unset?: Partial<Record<keyof ILivechatContact, '' | 1>> },
): Promise<ILivechatContact | null> => {
const { set = {}, unset = {} } = data;
if (Object.keys(set).length === 0 && Object.keys(unset).length === 0) {
return LivechatContacts.findOneEnabledById(contactId);
}
return LivechatContacts.patchContact(contactId, data);
};

View File

@@ -0,0 +1,129 @@
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(),
},
'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('./registerContact', {
'meteor/meteor': sinon.stub(),
'@rocket.chat/models': modelsMock,
'@rocket.chat/tools': { wrapExceptions: 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');
});
});

View File

@@ -0,0 +1,120 @@
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 { getAllowedCustomFields } from './getAllowedCustomFields';
import { validateCustomFields } from './validateCustomFields';
import { callbacks } from '../../callbacks';
import { notifyOnRoomChangedById, notifyOnSubscriptionChangedByRoomId, notifyOnLivechatInquiryChangedByRoom } from '../../notifyListener';
type RegisterContactProps = {
_id?: string;
token: string;
name: string;
username?: string;
email?: string;
phone?: string;
customFields?: Record<string, unknown | string>;
contactManager?: {
username: string;
};
};
export async function registerContact(
{ token, name, email = '', phone, username, customFields = {}, contactManager }: RegisterContactProps,
userId: string,
): Promise<string> {
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"');
}
}
const existingUserByToken = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } });
let visitorId = existingUserByToken?._id;
if (!existingUserByToken) {
if (!username) {
username = await LivechatVisitors.getNextVisitorUsername();
}
const existingUserByEmail = await LivechatVisitors.findOneGuestByEmailAddress(visitorEmail);
visitorId = existingUserByEmail?._id;
if (!existingUserByEmail) {
const userData = {
username,
ts: new Date(),
token,
};
visitorId = (await LivechatVisitors.insertOne(userData)).insertedId;
}
}
const allowedCF = await getAllowedCustomFields();
const livechatData: Record<string, string> = 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<ILivechatVisitor>; $unset?: OnlyFieldsOfType<ILivechatVisitor> } = {
$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', {}, { userId });
const rooms: IOmnichannelRoom[] = await LivechatRooms.findByVisitorId(visitorId, {}, extraQuery).toArray();
if (rooms?.length) {
for (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 as string;
}

View File

@@ -0,0 +1,278 @@
import type { ILivechatContact } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatContacts: {
findOneEnabledById: sinon.stub(),
patchContact: sinon.stub(),
},
Settings: {
incrementValueById: sinon.stub(),
},
};
const validateContactManagerMock = sinon.stub();
const { patchContact } = proxyquire.noCallThru().load('./patchContact.ts', {
'@rocket.chat/models': modelsMock,
});
const { resolveContactConflicts } = proxyquire.noCallThru().load('./resolveContactConflicts', {
'@rocket.chat/models': modelsMock,
'./validateContactManager': {
validateContactManager: validateContactManagerMock,
},
'./patchContact': {
patchContact,
},
});
describe('resolveContactConflicts', () => {
beforeEach(() => {
modelsMock.LivechatContacts.findOneEnabledById.reset();
modelsMock.Settings.incrementValueById.reset();
modelsMock.LivechatContacts.patchContact.reset();
validateContactManagerMock.reset();
});
it('should update the contact with the resolved custom field', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
customFields: { customField: 'newValue' },
conflictingFields: [{ field: 'customFields.customField', value: 'oldValue' }],
});
modelsMock.Settings.incrementValueById.resolves(1);
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
customFields: { customField: 'newestValue' },
conflictingFields: [],
} as Partial<ILivechatContact>);
await resolveContactConflicts({ contactId: 'contactId', customFields: { customField: 'newestValue' } });
expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({
set: {
customFields: { customField: 'newestValue' },
conflictingFields: [],
},
});
});
it('should update the contact with the resolved name', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Old Name',
customFields: { customField: 'newValue' },
conflictingFields: [{ field: 'name', value: 'Old Name' }],
});
modelsMock.Settings.incrementValueById.resolves(1);
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'New Name',
customFields: { customField: 'newValue' },
conflictingFields: [],
} as Partial<ILivechatContact>);
await resolveContactConflicts({ contactId: 'contactId', name: 'New Name' });
expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({
set: {
name: 'New Name',
conflictingFields: [],
},
});
});
it('should update the contact with the resolved contact manager', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
contactManager: 'contactManagerId',
customFields: { customField: 'value' },
conflictingFields: [{ field: 'manager', value: 'oldManagerId' }],
});
validateContactManagerMock.resolves();
modelsMock.Settings.incrementValueById.resolves(1);
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'Name',
contactManager: 'newContactManagerId',
customFields: { customField: 'value' },
conflictingFields: [],
} as Partial<ILivechatContact>);
await resolveContactConflicts({ contactId: 'contactId', contactManager: 'newContactManagerId' });
expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId');
expect(validateContactManagerMock.getCall(0).args[0]).to.be.equal('newContactManagerId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({
set: {
contactManager: 'newContactManagerId',
conflictingFields: [],
},
});
});
it('should wipe all conflicts if wipeConflicts = true', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
customFields: { customField: 'newValue' },
conflictingFields: [
{ field: 'name', value: 'NameTest' },
{ field: 'customFields.customField', value: 'value' },
],
});
modelsMock.Settings.incrementValueById.resolves({ _id: 'Resolved_Conflicts_Count', value: 2 });
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'New Name',
customFields: { customField: 'newValue' },
conflictingFields: [],
} as Partial<ILivechatContact>);
const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: true });
expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.Settings.incrementValueById.getCall(0).args[0]).to.be.equal('Resolved_Conflicts_Count');
expect(modelsMock.Settings.incrementValueById.getCall(0).args[1]).to.be.equal(2);
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({
set: {
name: 'New Name',
conflictingFields: [],
},
});
expect(result).to.be.deep.equal({
_id: 'contactId',
name: 'New Name',
customFields: { customField: 'newValue' },
conflictingFields: [],
});
});
it('should only resolve specified conflicts when wipeConflicts = false', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
customFields: { customField: 'newValue' },
conflictingFields: [
{ field: 'name', value: 'NameTest' },
{ field: 'customFields.customField', value: 'value' },
],
});
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'New Name',
customFields: { customField: 'newValue' },
conflictingFields: [{ field: 'customFields.customField', value: 'value' }],
} as Partial<ILivechatContact>);
const result = await resolveContactConflicts({ contactId: 'contactId', name: 'New Name', wipeConflicts: false });
expect(modelsMock.LivechatContacts.findOneEnabledById.getCall(0).args[0]).to.be.equal('contactId');
// When wipeConflicts is false, incrementValueById should NOT be called
expect(modelsMock.Settings.incrementValueById.called).to.be.false;
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({
set: {
name: 'New Name',
conflictingFields: [{ field: 'customFields.customField', value: 'value' }],
},
});
expect(result).to.be.deep.equal({
_id: 'contactId',
name: 'New Name',
customFields: { customField: 'newValue' },
conflictingFields: [{ field: 'customFields.customField', value: 'value' }],
});
});
it('should throw an error if the contact does not exist', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves(undefined);
await expect(resolveContactConflicts({ contactId: 'id', customFields: { customField: 'newValue' } })).to.be.rejectedWith(
'error-contact-not-found',
);
expect(modelsMock.LivechatContacts.patchContact.called).to.be.false;
});
it('should throw an error if the contact has no conflicting fields', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
contactManager: 'contactManagerId',
customFields: { customField: 'value' },
conflictingFields: [],
});
await expect(resolveContactConflicts({ contactId: 'id', customFields: { customField: 'newValue' } })).to.be.rejectedWith(
'error-contact-has-no-conflicts',
);
expect(modelsMock.LivechatContacts.patchContact.called).to.be.false;
});
it('should unset contactManager when explicitly set to empty string', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
contactManager: 'oldManagerId',
customFields: { customField: 'value' },
conflictingFields: [{ field: 'manager', value: 'oldManagerId' }],
});
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'Name',
customFields: { customField: 'value' },
conflictingFields: [],
} as Partial<ILivechatContact>);
await resolveContactConflicts({ contactId: 'contactId', contactManager: '' });
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.deep.include({
set: {
conflictingFields: [],
},
unset: { contactManager: '' },
});
expect(validateContactManagerMock.called).to.be.false;
});
it('should unset contactManager when explicitly set to undefined', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({
_id: 'contactId',
name: 'Name',
contactManager: 'oldManagerId',
customFields: { customField: 'value' },
conflictingFields: [{ field: 'manager', value: 'oldManagerId' }],
});
modelsMock.LivechatContacts.patchContact.resolves({
_id: 'contactId',
name: 'Name',
customFields: { customField: 'value' },
conflictingFields: [],
} as Partial<ILivechatContact>);
await resolveContactConflicts({ contactId: 'contactId', contactManager: undefined });
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.deep.include({
set: {
conflictingFields: [],
},
unset: { contactManager: '' },
});
expect(validateContactManagerMock.called).to.be.false;
});
});

View File

@@ -0,0 +1,77 @@
import type { ILivechatContact, ILivechatContactConflictingField } from '@rocket.chat/core-typings';
import { LivechatContacts, Settings } from '@rocket.chat/models';
import { patchContact } from './patchContact';
import { validateContactManager } from './validateContactManager';
import { notifyOnSettingChanged } from '../../notifyListener';
export type ResolveContactConflictsParams = {
contactId: string;
name?: string;
customFields?: Record<string, unknown>;
contactManager?: string;
wipeConflicts?: boolean;
};
export async function resolveContactConflicts(params: ResolveContactConflictsParams): Promise<ILivechatContact> {
const { contactId, name, customFields, contactManager, wipeConflicts } = params;
const contact = await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, '_id' | 'customFields' | 'conflictingFields'>>(
contactId,
{
projection: { _id: 1, customFields: 1, conflictingFields: 1 },
},
);
if (!contact) {
throw new Error('error-contact-not-found');
}
if (!contact.conflictingFields?.length) {
throw new Error('error-contact-has-no-conflicts');
}
if (contactManager) {
await validateContactManager(contactManager);
}
let updatedConflictingFieldsArr: ILivechatContactConflictingField[] = [];
if (wipeConflicts) {
const value = await Settings.incrementValueById('Resolved_Conflicts_Count', contact.conflictingFields.length, {
returnDocument: 'after',
});
if (value) {
void notifyOnSettingChanged(value);
}
} else {
const fieldsToRemove = new Set<string>(
[
name && 'name',
'contactManager' in params && 'manager',
...(customFields ? Object.keys(customFields).map((key) => `customFields.${key}`) : []),
].filter((field): field is string => !!field),
);
updatedConflictingFieldsArr = contact.conflictingFields.filter(
(conflictingField: ILivechatContactConflictingField) => !fieldsToRemove.has(conflictingField.field),
) as ILivechatContactConflictingField[];
}
const set = {
...(name && { name }),
...(contactManager && { contactManager }),
...(customFields && { customFields: { ...contact.customFields, ...customFields } }),
conflictingFields: updatedConflictingFieldsArr,
};
const unset: Partial<Record<keyof ILivechatContact, '' | 1>> =
'contactManager' in params && !contactManager ? { contactManager: '' } : {};
const updatedContact = await patchContact(contactId, { set, unset });
if (!updatedContact) {
throw new Error('error-contact-not-found');
}
return updatedContact;
}

View File

@@ -0,0 +1,81 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
LivechatContacts: {
findOneEnabledById: sinon.stub(),
patchContact: sinon.stub(),
},
LivechatRooms: {
updateContactDataByContactId: sinon.stub(),
},
};
const { patchContact } = proxyquire.noCallThru().load('./patchContact.ts', {
'@rocket.chat/models': modelsMock,
});
const { updateContact } = proxyquire.noCallThru().load('./updateContact', {
'./getAllowedCustomFields': {
getAllowedCustomFields: sinon.stub().resolves([]),
},
'./validateContactManager': {
validateContactManager: sinon.stub(),
},
'./validateCustomFields': {
validateCustomFields: sinon.stub(),
},
'@rocket.chat/models': modelsMock,
'./patchContact': {
patchContact,
},
});
describe('updateContact', () => {
beforeEach(() => {
modelsMock.LivechatContacts.findOneEnabledById.reset();
modelsMock.LivechatContacts.patchContact.reset();
modelsMock.LivechatRooms.updateContactDataByContactId.reset();
});
it('should throw an error if the contact does not exist', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves(undefined);
await expect(updateContact('any_id')).to.be.rejectedWith('error-contact-not-found');
expect(modelsMock.LivechatContacts.patchContact.getCall(0)).to.be.null;
});
it('should update the contact with correct params', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId' });
modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId', name: 'John Doe' } as any);
const updatedContact = await updateContact({ contactId: 'contactId', name: 'John Doe' });
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ set: { name: 'John Doe' } });
expect(updatedContact).to.be.deep.equal({ _id: 'contactId', name: 'John Doe' });
});
it('should be able to clear the contact manager when passing an empty string for contactManager', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', contactManager: 'manager' });
modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId' } as any);
const updatedContact = await updateContact({ contactId: 'contactId', contactManager: '' });
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ unset: { contactManager: '' } });
expect(updatedContact).to.be.deep.equal({ _id: 'contactId' });
});
it('should be able to clear the contact manager when passing undefined for contactManager', async () => {
modelsMock.LivechatContacts.findOneEnabledById.resolves({ _id: 'contactId', contactManager: 'manager' });
modelsMock.LivechatContacts.patchContact.resolves({ _id: 'contactId' } as any);
const updatedContact = await updateContact({ contactId: 'contactId', contactManager: undefined });
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[0]).to.be.equal('contactId');
expect(modelsMock.LivechatContacts.patchContact.getCall(0).args[1]).to.be.deep.contain({ unset: { contactManager: '' } });
expect(updatedContact).to.be.deep.equal({ _id: 'contactId' });
});
});

View File

@@ -0,0 +1,109 @@
import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings';
import { LivechatContacts, LivechatInquiry, LivechatRooms, Settings, Subscriptions } from '@rocket.chat/models';
import { getAllowedCustomFields } from './getAllowedCustomFields';
import { patchContact } from './patchContact';
import { validateContactManager } from './validateContactManager';
import { validateCustomFields } from './validateCustomFields';
import {
notifyOnSubscriptionChangedByVisitorIds,
notifyOnRoomChangedByContactId,
notifyOnLivechatInquiryChangedByVisitorIds,
notifyOnSettingChanged,
} from '../../notifyListener';
export type UpdateContactParams = {
contactId: string;
name?: string;
emails?: string[];
phones?: string[];
customFields?: Record<string, unknown>;
contactManager?: string;
channels?: ILivechatContactChannel[];
wipeConflicts?: boolean;
};
export async function updateContact(params: UpdateContactParams): Promise<ILivechatContact> {
const { contactId, name, emails, phones, customFields: receivedCustomFields, contactManager, channels, wipeConflicts } = params;
const contact = await LivechatContacts.findOneEnabledById<Pick<ILivechatContact, '_id' | 'name' | 'customFields' | 'conflictingFields'>>(
contactId,
{
projection: { _id: 1, name: 1, customFields: 1, conflictingFields: 1 },
},
);
if (!contact) {
throw new Error('error-contact-not-found');
}
if (contactManager) {
await validateContactManager(contactManager);
}
if (wipeConflicts && contact.conflictingFields?.length) {
const value = await Settings.incrementValueById('Resolved_Conflicts_Count', contact.conflictingFields.length, {
returnDocument: 'after',
});
if (value) {
void notifyOnSettingChanged(value);
}
}
const workspaceAllowedCustomFields = await getAllowedCustomFields();
const workspaceAllowedCustomFieldsIds = workspaceAllowedCustomFields.map((customField) => customField._id);
const currentCustomFieldsIds = Object.keys(contact.customFields || {});
const notRegisteredCustomFields = currentCustomFieldsIds
.filter((customFieldId) => !workspaceAllowedCustomFieldsIds.includes(customFieldId))
.map((customFieldId) => ({ _id: customFieldId }));
const customFieldsToUpdate =
receivedCustomFields &&
validateCustomFields(workspaceAllowedCustomFields, receivedCustomFields, {
ignoreAdditionalFields: !!notRegisteredCustomFields.length,
});
if (receivedCustomFields && customFieldsToUpdate && notRegisteredCustomFields.length) {
const allowedCustomFields = [...workspaceAllowedCustomFields, ...notRegisteredCustomFields];
validateCustomFields(allowedCustomFields, receivedCustomFields);
notRegisteredCustomFields.forEach((notRegisteredCustomField) => {
customFieldsToUpdate[notRegisteredCustomField._id] = contact.customFields?.[notRegisteredCustomField._id] as string;
});
}
const set = {
...(name !== undefined && { name }),
...(emails && { emails: emails?.map((address) => ({ address })) }),
...(phones && { phones: phones?.map((phoneNumber) => ({ phoneNumber })) }),
...(contactManager && { contactManager }),
...(channels && { channels }),
...(customFieldsToUpdate && { customFields: customFieldsToUpdate }),
...(wipeConflicts && { conflictingFields: [] }),
};
const unset: Partial<Record<keyof ILivechatContact, '' | 1>> =
'contactManager' in params && !contactManager ? { contactManager: '' } : {};
const updatedContact = await patchContact(contactId, { set, unset });
if (!updatedContact) {
throw new Error('error-contact-not-found');
}
// If the contact name changed, update the name of its existing rooms and subscriptions
if (name !== undefined && name !== contact.name) {
await LivechatRooms.updateContactDataByContactId(contactId, { name });
void notifyOnRoomChangedByContactId(contactId);
const visitorIds = updatedContact.channels?.map((channel) => channel.visitor.visitorId);
if (visitorIds?.length) {
await Subscriptions.updateNameAndFnameByVisitorIds(visitorIds, name);
void notifyOnSubscriptionChangedByVisitorIds(visitorIds);
await LivechatInquiry.updateNameByVisitorIds(visitorIds, name);
void notifyOnLivechatInquiryChangedByVisitorIds(visitorIds, 'updated', { name });
}
}
return updatedContact;
}

View File

@@ -0,0 +1,32 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
const modelsMock = {
Users: {
findOneAgentById: sinon.stub(),
},
};
const { validateContactManager } = proxyquire.noCallThru().load('./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');
});
});

View File

@@ -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<Pick<IUser, '_id'>>(contactManagerUserId, { projection: { _id: 1 } });
if (!contactManagerUser) {
throw new Error('error-contact-manager-not-found');
}
}

View File

@@ -0,0 +1,62 @@
import { expect } from 'chai';
import proxyquire from 'proxyquire';
const { validateCustomFields } = proxyquire.noCallThru().load('./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 if a required custom field is missing, but the ignoreValidationErrors option is provided', () => {
expect(() => validateCustomFields(mockCustomFields, {}, { ignoreValidationErrors: true }))
.not.to.throw()
.and.to.equal({});
});
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 not throw an error if a custom field value does not match the regexp, but the ignoreValidationErrors option is provided', () => {
expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' }, { ignoreValidationErrors: true }))
.not.to.throw()
.and.to.equal({});
});
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();
});
it('should not throw an error if a extra custom field is passed, but the ignoreValidationErrors option is provided', () => {
const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }];
const customFields = { field2: 'value' };
expect(() => validateCustomFields(allowedCustomFields, customFields, { ignoreAdditionalFields: true }))
.not.to.throw()
.and.to.equal({});
});
});

View File

@@ -0,0 +1,56 @@
import type { AtLeast, ILivechatCustomField } from '@rocket.chat/core-typings';
import { i18n } from '../../../../app/utils/lib/i18n';
import { trim } from '../../../../lib/utils/stringUtils';
export function validateCustomFields(
allowedCustomFields: AtLeast<ILivechatCustomField, '_id'>[],
customFields: Record<string, string | unknown>,
{
ignoreAdditionalFields = false,
ignoreValidationErrors = false,
}: { ignoreAdditionalFields?: boolean; ignoreValidationErrors?: boolean } = {},
): Record<string, string> {
const validValues: Record<string, string> = {};
for (const cf of allowedCustomFields) {
if (!customFields.hasOwnProperty(cf._id)) {
if (cf.required && !ignoreValidationErrors) {
throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label || cf._id }));
}
continue;
}
const cfValue: string = trim(customFields[cf._id]);
if (!cfValue || typeof cfValue !== 'string') {
if (cf.required && !ignoreValidationErrors) {
throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label || cf._id }));
}
continue;
}
if (cf.regexp) {
const regex = new RegExp(cf.regexp);
if (!regex.test(cfValue)) {
if (ignoreValidationErrors) {
continue;
}
throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label || cf._id }));
}
}
validValues[cf._id] = cfValue;
}
if (!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;
}

View File

@@ -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<ILivechatContact | null> => null);