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
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:
336
apps/meteor/server/lib/omnichannel/contacts/ContactMerger.ts
Normal file
336
apps/meteor/server/lib/omnichannel/contacts/ContactMerger.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
52
apps/meteor/server/lib/omnichannel/contacts/createContact.ts
Normal file
52
apps/meteor/server/lib/omnichannel/contacts/createContact.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
51
apps/meteor/server/lib/omnichannel/contacts/getContacts.ts
Normal file
51
apps/meteor/server/lib/omnichannel/contacts/getContacts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
14
apps/meteor/server/lib/omnichannel/contacts/patchContact.ts
Normal file
14
apps/meteor/server/lib/omnichannel/contacts/patchContact.ts
Normal 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);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
120
apps/meteor/server/lib/omnichannel/contacts/registerContact.ts
Normal file
120
apps/meteor/server/lib/omnichannel/contacts/registerContact.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
109
apps/meteor/server/lib/omnichannel/contacts/updateContact.ts
Normal file
109
apps/meteor/server/lib/omnichannel/contacts/updateContact.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user