Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for Omnichannel Contacts on the Importer system #33422

Merged
9 changes: 9 additions & 0 deletions .changeset/happy-clouds-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@rocket.chat/model-typings': minor
'@rocket.chat/core-typings': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Added option to import omnichannel contacts from CSV files
27 changes: 22 additions & 5 deletions apps/meteor/app/importer-csv/server/CsvImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import type { ConverterOptions } from '../../importer/server/classes/ImportDataC
import type { ImporterProgress } from '../../importer/server/classes/ImporterProgress';
import type { ImporterInfo } from '../../importer/server/definitions/ImporterInfo';
import { notifyOnSettingChanged } from '../../lib/server/lib/notifyListener';
import { isSingleContactEnabled } from '../../livechat/server/lib/Contacts';
import { addParsedContacts } from './addParsedContacts';

export class CsvImporter extends Importer {
private csvParser: (csv: string) => string[];
private csvParser: (csv: string) => string[][];

constructor(info: ImporterInfo, importRecord: IImport, converterOptions: ConverterOptions = {}) {
super(info, importRecord, converterOptions);
Expand Down Expand Up @@ -46,6 +48,7 @@ export class CsvImporter extends Importer {
let messagesCount = 0;
let usersCount = 0;
let channelsCount = 0;
let contactsCount = 0;
const dmRooms = new Set<string>();
const roomIds = new Map<string, string>();
const usedUsernames = new Set<string>();
Expand Down Expand Up @@ -140,6 +143,20 @@ export class CsvImporter extends Importer {
continue;
}

// Parse the contacts
if (entry.entryName.toLowerCase() === 'contacts.csv') {
if (isSingleContactEnabled()) {
await super.updateProgress(ProgressStep.PREPARING_CONTACTS);
const parsedContacts = this.csvParser(entry.getData().toString());

contactsCount = await addParsedContacts.call(this.converter, parsedContacts);

await super.updateRecord({ 'count.contacts': contactsCount });
}
increaseProgressCount();
continue;
}

// Parse the messages
if (entry.entryName.indexOf('/') > -1) {
if (this.progress.step !== ProgressStep.PREPARING_MESSAGES) {
Expand Down Expand Up @@ -258,12 +275,12 @@ export class CsvImporter extends Importer {
}
}

await super.addCountToTotal(messagesCount + usersCount + channelsCount);
await super.addCountToTotal(messagesCount + usersCount + channelsCount + contactsCount);
ImporterWebsocket.progressUpdated({ rate: 100 });

// Ensure we have at least a single user, channel, or message
if (usersCount === 0 && channelsCount === 0 && messagesCount === 0) {
this.logger.error('No users, channels, or messages found in the import file.');
// Ensure we have at least a single record of any kind
if (usersCount === 0 && channelsCount === 0 && messagesCount === 0 && contactsCount === 0) {
this.logger.error('No valid record found in the import file.');
await super.updateProgress(ProgressStep.ERROR);
}

Expand Down
36 changes: 36 additions & 0 deletions apps/meteor/app/importer-csv/server/addParsedContacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Random } from '@rocket.chat/random';

import type { ImportDataConverter } from '../../importer/server/classes/ImportDataConverter';

export async function addParsedContacts(this: ImportDataConverter, parsedContacts: string[][]): Promise<number> {
MarcosSpessatto marked this conversation as resolved.
Show resolved Hide resolved
const columnNames = parsedContacts.shift();
let addedContacts = 0;

for await (const parsedData of parsedContacts) {
const contactData = parsedData.reduce((acc, value, index) => {
const columnName = columnNames && index < columnNames.length ? columnNames[index] : `column${index}`;
return {
...acc,
[columnName]: value,
};
}, {} as Record<string, string>);

if (!contactData.emails && !contactData.phones && !contactData.name) {
continue;
}

const { emails = '', phones = '', name = '', manager: contactManager = undefined, ...customFields } = contactData;

await this.addContact({
importIds: [Random.id()],
emails: emails.split(';'),
phones: phones.split(';'),
name,
contactManager,
customFields,
});
addedContacts++;
}

return addedContacts;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import fs from 'node:fs';

import type { IImport } from '@rocket.chat/core-typings';
import { parse } from 'csv-parse/lib/sync';

import { addParsedContacts } from '../../importer-csv/server/addParsedContacts';
import { Importer, ProgressStep, ImporterWebsocket } from '../../importer/server';
import type { ConverterOptions } from '../../importer/server/classes/ImportDataConverter';
import type { ImporterProgress } from '../../importer/server/classes/ImporterProgress';
import type { ImporterInfo } from '../../importer/server/definitions/ImporterInfo';

export class ContactImporter extends Importer {
private csvParser: (csv: string) => string[][];

constructor(info: ImporterInfo, importRecord: IImport, converterOptions: ConverterOptions = {}) {
super(info, importRecord, converterOptions);

this.csvParser = parse;
}

async prepareUsingLocalFile(fullFilePath: string): Promise<ImporterProgress> {
this.logger.debug('start preparing import operation');
await this.converter.clearImportData();

ImporterWebsocket.progressUpdated({ rate: 0 });

await super.updateProgress(ProgressStep.PREPARING_CONTACTS);
// Reading the whole file at once for compatibility with the code written for the other importers
// We can change this to a stream once we improve the rest of the importer classes
const fileContents = fs.readFileSync(fullFilePath, { encoding: 'utf8' });
if (!fileContents || typeof fileContents !== 'string') {
throw new Error('Failed to load file contents.');
}

const parsedContacts = this.csvParser(fileContents);
const contactsCount = await addParsedContacts.call(this.converter, parsedContacts);

if (contactsCount === 0) {
this.logger.error('No contacts found in the import file.');
await super.updateProgress(ProgressStep.ERROR);
} else {
await super.updateRecord({ 'count.contacts': contactsCount, 'count.total': contactsCount });
ImporterWebsocket.progressUpdated({ rate: 100 });
}

return super.getProgress();
}
}
11 changes: 11 additions & 0 deletions apps/meteor/app/importer-omnichannel-contacts/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Importers } from '../../importer/server';
import { isSingleContactEnabled } from '../../livechat/server/lib/Contacts';
import { ContactImporter } from './ContactImporter';

if (isSingleContactEnabled()) {
Importers.add({
key: 'omnichannel_contact',
name: 'omnichannel_contacts_importer',
importer: ContactImporter,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class PendingAvatarImporter extends Importer {
await this.updateRecord({ 'count.messages': fileCount, 'messagesstatus': null });
await this.addCountToTotal(fileCount);

const fileData = new Selection(this.info.name, [], [], fileCount);
const fileData = new Selection<false>(this.info.name, [], [], fileCount, []);
await this.updateRecord({ fileData });

await super.updateProgress(ProgressStep.IMPORTING_FILES);
Expand All @@ -31,7 +31,7 @@ export class PendingAvatarImporter extends Importer {
return fileCount;
}

async startImport(importSelection: Selection): Promise<ImporterProgress> {
async startImport(importSelection: Selection<false>): Promise<ImporterProgress> {
const pendingFileUserList = Users.findAllUsersWithPendingAvatar();
try {
for await (const user of pendingFileUserList) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class PendingFileImporter extends Importer {
await this.updateRecord({ 'count.messages': fileCount, 'messagesstatus': null });
await this.addCountToTotal(fileCount);

const fileData = new Selection(this.info.name, [], [], fileCount);
const fileData = new Selection<false>(this.info.name, [], [], fileCount, []);
await this.updateRecord({ fileData });

await super.updateProgress(ProgressStep.IMPORTING_FILES);
Expand All @@ -41,7 +41,7 @@ export class PendingFileImporter extends Importer {
return fileCount;
}

async startImport(importSelection: Selection): Promise<ImporterProgress> {
async startImport(importSelection: Selection<false>): Promise<ImporterProgress> {
const downloadedFileIds: string[] = [];
const maxFileCount = 10;
const maxFileSize = 1024 * 1024 * 500;
Expand Down
4 changes: 4 additions & 0 deletions apps/meteor/app/importer/lib/ImporterProgressStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ export const ProgressStep = Object.freeze({
PREPARING_USERS: 'importer_preparing_users',
PREPARING_CHANNELS: 'importer_preparing_channels',
PREPARING_MESSAGES: 'importer_preparing_messages',
PREPARING_CONTACTS: 'importer_preparing_contacts',

USER_SELECTION: 'importer_user_selection',

IMPORTING_STARTED: 'importer_importing_started',
IMPORTING_USERS: 'importer_importing_users',
IMPORTING_CHANNELS: 'importer_importing_channels',
IMPORTING_MESSAGES: 'importer_importing_messages',
IMPORTING_CONTACTS: 'importer_importing_contacts',
IMPORTING_FILES: 'importer_importing_files',
FINISHING: 'importer_finishing',

Expand All @@ -35,13 +37,15 @@ export const ImportPreparingStartedStates: IImportProgress['step'][] = [
ProgressStep.PREPARING_USERS,
ProgressStep.PREPARING_CHANNELS,
ProgressStep.PREPARING_MESSAGES,
ProgressStep.PREPARING_CONTACTS,
];

export const ImportingStartedStates: IImportProgress['step'][] = [
ProgressStep.IMPORTING_STARTED,
ProgressStep.IMPORTING_USERS,
ProgressStep.IMPORTING_CHANNELS,
ProgressStep.IMPORTING_MESSAGES,
ProgressStep.IMPORTING_CONTACTS,
ProgressStep.IMPORTING_FILES,
ProgressStep.FINISHING,
];
Expand Down
11 changes: 10 additions & 1 deletion apps/meteor/app/importer/server/classes/ImportDataConverter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IImportRecord, IImportUser, IImportMessage, IImportChannel } from '@rocket.chat/core-typings';
import type { IImportRecord, IImportUser, IImportMessage, IImportChannel, IImportContact } from '@rocket.chat/core-typings';
import type { Logger } from '@rocket.chat/logger';
import { ImportData } from '@rocket.chat/models';
import { pick } from '@rocket.chat/tools';
Expand Down Expand Up @@ -90,6 +90,10 @@ export class ImportDataConverter {
this._messageConverter = new MessageConverter(messageOptions, logger, this._cache);
}

async addContact(_data: IImportContact): Promise<void> {
// #ToDo
}

async addUser(data: IImportUser): Promise<void> {
return this._userConverter.addObject(data);
}
Expand All @@ -104,6 +108,10 @@ export class ImportDataConverter {
});
}

async convertContacts(_callbacks: IConversionCallbacks): Promise<void> {
// #ToDo
}

async convertUsers(callbacks: IConversionCallbacks): Promise<void> {
return this._userConverter.convertData(callbacks);
}
Expand All @@ -118,6 +126,7 @@ export class ImportDataConverter {

async convertData(startedByUserId: string, callbacks: IConversionCallbacks = {}): Promise<void> {
await this.convertUsers(callbacks);
await this.convertContacts(callbacks);
await this.convertChannels(startedByUserId, callbacks);
await this.convertMessages(callbacks);

Expand Down
38 changes: 29 additions & 9 deletions apps/meteor/app/importer/server/classes/Importer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { api } from '@rocket.chat/core-services';
import type { IImport, IImportRecord, IImportChannel, IImportUser, IImportProgress } from '@rocket.chat/core-typings';
import type { IImport, IImportRecord, IImportChannel, IImportUser, IImportProgress, IImportContact } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Settings, ImportData, Imports } from '@rocket.chat/models';
import AdmZip from 'adm-zip';
Expand Down Expand Up @@ -94,7 +94,7 @@ export class Importer {
* @param {Selection} importSelection The selection data.
* @returns {ImporterProgress} The progress record of the import.
*/
async startImport(importSelection: Selection, startedByUserId: string): Promise<ImporterProgress> {
async startImport(importSelection: Selection<false>, startedByUserId: string): Promise<ImporterProgress> {
if (!(importSelection instanceof Selection)) {
throw new Error(`Invalid Selection data provided to the ${this.info.name} importer.`);
} else if (importSelection.users === undefined) {
Expand Down Expand Up @@ -156,6 +156,19 @@ export class Importer {

return false;
}

case 'contact': {
const contactData = data as IImportContact;

const id = contactData.importIds[0];
for (const contact of importSelection.contacts) {
if (contact.id === id) {
return contact.do_import;
}
}

return false;
}
}

return false;
Expand Down Expand Up @@ -202,6 +215,9 @@ export class Importer {
await callbacks.run('beforeUserImport', { userCount: usersToImport.length });
await this.converter.convertUsers({ beforeImportFn, afterImportFn, onErrorFn, afterBatchFn });

await this.updateProgress(ProgressStep.IMPORTING_CONTACTS);
await this.converter.convertContacts({ beforeImportFn, afterImportFn, onErrorFn });

await this.updateProgress(ProgressStep.IMPORTING_CHANNELS);
await this.converter.convertChannels(startedByUserId, { beforeImportFn, afterImportFn, onErrorFn });

Expand Down Expand Up @@ -324,14 +340,10 @@ export class Importer {
}

async maybeUpdateRecord() {
// Only update the database every 500 messages (or 50 for users/channels)
// Only update the database every 500 messages (or 50 for other records)
// Or the completed is greater than or equal to the total amount
const count = this.progress.count.completed + this.progress.count.error;
const range = ([ProgressStep.IMPORTING_USERS, ProgressStep.IMPORTING_CHANNELS] as IImportProgress['step'][]).includes(
this.progress.step,
)
? 50
: 500;
const range = this.progress.step === ProgressStep.IMPORTING_MESSAGES ? 500 : 50;

if (count % range === 0 || count >= this.progress.count.total || count - this._lastProgressReportTotal > range) {
this._lastProgressReportTotal = this.progress.count.completed + this.progress.count.error;
Expand Down Expand Up @@ -379,6 +391,7 @@ export class Importer {

const users = await ImportData.getAllUsersForSelection();
const channels = await ImportData.getAllChannelsForSelection();
const contacts = await ImportData.getAllContactsForSelection();
const hasDM = await ImportData.checkIfDirectMessagesExists();

const selectionUsers = users.map(
Expand All @@ -388,13 +401,20 @@ export class Importer {
const selectionChannels = channels.map(
(c) => new SelectionChannel(c.data.importIds[0], c.data.name, Boolean(c.data.archived), true, c.data.t === 'p', c.data.t === 'd'),
);
const selectionContacts = contacts.map((c) => ({
id: c.data.importIds[0],
name: c.data.name || '',
emails: c.data.emails || [],
phones: c.data.phones || [],
do_import: true,
}));
const selectionMessages = await ImportData.countMessages();

if (hasDM) {
selectionChannels.push(new SelectionChannel('__directMessages__', t('Direct_Messages'), false, true, true, true));
}

const results = new Selection(this.info.name, selectionUsers, selectionChannels, selectionMessages);
const results = new Selection(this.info.name, selectionUsers, selectionChannels, selectionMessages, selectionContacts);

return results;
}
Expand Down
Loading
Loading