Files
upstream-mirror 30810eecd7
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 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

337 lines
11 KiB
TypeScript

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',
});
}
}