diff --git a/.changeset/perfect-ties-tell.md b/.changeset/perfect-ties-tell.md new file mode 100644 index 000000000000..e129fa0937c8 --- /dev/null +++ b/.changeset/perfect-ties-tell.md @@ -0,0 +1,21 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/model-typings": minor +--- + +Adds statistics related to the new **Contact Identification** feature: +- `totalContacts`: Total number of contacts; +- `totalUnknownContacts`: Total number of unknown contacts; +- `totalMergedContacts`: Total number of merged contacts; +- `totalConflicts`: Total number of merge conflicts; +- `totalResolvedConflicts`: Total number of resolved conflicts; +- `totalBlockedContacts`: Total number of blocked contacts; +- `totalPartiallyBlockedContacts`: Total number of partially blocked contacts; +- `totalFullyBlockedContacts`: Total number of fully blocked contacts; +- `totalVerifiedContacts`: Total number of verified contacts; +- `avgChannelsPerContact`: Average number of channels per contact; +- `totalContactsWithoutChannels`: Number of contacts without channels; +- `totalImportedContacts`: Total number of imported contacts; +- `totalUpsellViews`: Total number of "Advanced Contact Management" Upsell CTA views; +- `totalUpsellClicks`: Total number of "Advanced Contact Management" Upsell CTA clicks; diff --git a/apps/meteor/app/importer/server/classes/Importer.ts b/apps/meteor/app/importer/server/classes/Importer.ts index 49430c101d45..32834ed15c4a 100644 --- a/apps/meteor/app/importer/server/classes/Importer.ts +++ b/apps/meteor/app/importer/server/classes/Importer.ts @@ -18,7 +18,7 @@ import { ImportDataConverter } from './ImportDataConverter'; import type { ConverterOptions } from './ImportDataConverter'; import { ImporterProgress } from './ImporterProgress'; import { ImporterWebsocket } from './ImporterWebsocket'; -import { notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; +import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { t } from '../../../utils/lib/i18n'; import { ProgressStep, ImportPreparingStartedStates } from '../../lib/ImporterProgressStep'; import type { ImporterInfo } from '../definitions/ImporterInfo'; @@ -183,6 +183,13 @@ export class Importer { } }; + const afterContactsBatchFn = async (successCount: number) => { + const { value } = await Settings.incrementValueById('Contacts_Importer_Count', successCount, { returnDocument: 'after' }); + if (value) { + void notifyOnSettingChanged(value); + } + }; + const onErrorFn = async () => { await this.addCountCompleted(1); }; @@ -197,7 +204,7 @@ export class Importer { await this.converter.convertUsers({ beforeImportFn, afterImportFn, onErrorFn, afterBatchFn }); await this.updateProgress(ProgressStep.IMPORTING_CONTACTS); - await this.converter.convertContacts({ beforeImportFn, afterImportFn, onErrorFn }); + await this.converter.convertContacts({ beforeImportFn, afterImportFn, onErrorFn, afterBatchFn: afterContactsBatchFn }); await this.updateProgress(ProgressStep.IMPORTING_CHANNELS); await this.converter.convertChannels(startedByUserId, { beforeImportFn, afterImportFn, onErrorFn }); diff --git a/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts b/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts index 9003fe4bd416..d530b96212d7 100644 --- a/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts +++ b/apps/meteor/app/importer/server/classes/converters/RecordConverter.ts @@ -27,6 +27,8 @@ export class RecordConverter Promise } = {}): Promise { const records = await this.getDataToImport(); this.skippedCount = 0; this.failedCount = 0; + this.newCount = 0; for await (const record of records) { const { _id } = record; @@ -214,8 +218,11 @@ export class RecordConverter { diff --git a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts index 752bb62b301f..023568bd11de 100644 --- a/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts +++ b/apps/meteor/app/livechat/server/lib/contacts/updateContact.ts @@ -1,5 +1,5 @@ import type { ILivechatContact, ILivechatContactChannel } from '@rocket.chat/core-typings'; -import { LivechatContacts, LivechatInquiry, LivechatRooms, Subscriptions } from '@rocket.chat/models'; +import { LivechatContacts, LivechatInquiry, LivechatRooms, Settings, Subscriptions } from '@rocket.chat/models'; import { getAllowedCustomFields } from './getAllowedCustomFields'; import { validateContactManager } from './validateContactManager'; @@ -8,6 +8,7 @@ import { notifyOnSubscriptionChangedByVisitorIds, notifyOnRoomChangedByContactId, notifyOnLivechatInquiryChangedByVisitorIds, + notifyOnSettingChanged, } from '../../../../lib/server/lib/notifyListener'; export type UpdateContactParams = { @@ -24,9 +25,12 @@ export type UpdateContactParams = { export async function updateContact(params: UpdateContactParams): Promise { const { contactId, name, emails, phones, customFields: receivedCustomFields, contactManager, channels, wipeConflicts } = params; - const contact = await LivechatContacts.findOneById>(contactId, { - projection: { _id: 1, name: 1, customFields: 1 }, - }); + const contact = await LivechatContacts.findOneById>( + contactId, + { + projection: { _id: 1, name: 1, customFields: 1, conflictingFields: 1 }, + }, + ); if (!contact) { throw new Error('error-contact-not-found'); @@ -36,6 +40,15 @@ export async function updateContact(params: UpdateContactParams): Promise customField._id); const currentCustomFieldsIds = Object.keys(contact.customFields || {}); diff --git a/apps/meteor/app/statistics/server/lib/getContactVerificationStatistics.ts b/apps/meteor/app/statistics/server/lib/getContactVerificationStatistics.ts new file mode 100644 index 000000000000..f1072451c972 --- /dev/null +++ b/apps/meteor/app/statistics/server/lib/getContactVerificationStatistics.ts @@ -0,0 +1,41 @@ +import type { IStats } from '@rocket.chat/core-typings'; +import { LivechatContacts } from '@rocket.chat/models'; + +import { settings } from '../../../settings/server'; + +export async function getContactVerificationStatistics(): Promise { + const [ + totalContacts, + totalUnknownContacts, + [{ totalConflicts, avgChannelsPerContact } = { totalConflicts: 0, avgChannelsPerContact: 0 }], + totalBlockedContacts, + totalFullyBlockedContacts, + totalVerifiedContacts, + totalContactsWithoutChannels, + ] = await Promise.all([ + LivechatContacts.estimatedDocumentCount(), + LivechatContacts.countUnknown(), + LivechatContacts.getStatistics().toArray(), + LivechatContacts.countBlocked(), + LivechatContacts.countFullyBlocked(), + LivechatContacts.countVerified(), + LivechatContacts.countContactsWithoutChannels(), + ]); + + return { + totalContacts, + totalUnknownContacts, + totalMergedContacts: settings.get('Merged_Contacts_Count'), + totalConflicts, + totalResolvedConflicts: settings.get('Resolved_Conflicts_Count'), + totalBlockedContacts, + totalPartiallyBlockedContacts: totalBlockedContacts - totalFullyBlockedContacts, + totalFullyBlockedContacts, + totalVerifiedContacts, + avgChannelsPerContact, + totalContactsWithoutChannels, + totalImportedContacts: settings.get('Contacts_Importer_Count'), + totalUpsellViews: settings.get('Advanced_Contact_Upsell_Views_Count'), + totalUpsellClicks: settings.get('Advanced_Contact_Upsell_Clicks_Count'), + }; +} diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index 25d93a6985c3..12f24cd3bc10 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -30,6 +30,7 @@ import { MongoInternals } from 'meteor/mongo'; import moment from 'moment'; import { getAppsStatistics } from './getAppsStatistics'; +import { getContactVerificationStatistics } from './getContactVerificationStatistics'; import { getStatistics as getEnterpriseStatistics } from './getEEStatistics'; import { getImporterStatistics } from './getImporterStatistics'; import { getServicesStatistics } from './getServicesStatistics'; @@ -477,6 +478,7 @@ export const statistics = { statistics.services = await getServicesStatistics(); statistics.importer = getImporterStatistics(); statistics.videoConf = await VideoConf.getStatistics(); + statistics.contactVerification = await getContactVerificationStatistics(); // If getSettingsStatistics() returns an error, save as empty object. statsPms.push( diff --git a/apps/meteor/client/views/omnichannel/contactInfo/AdvancedContactModal.tsx b/apps/meteor/client/views/omnichannel/contactInfo/AdvancedContactModal.tsx index 884197bf0749..88f100e3e992 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/AdvancedContactModal.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/AdvancedContactModal.tsx @@ -1,5 +1,5 @@ -import { useRole } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import { useRole, useEndpoint } from '@rocket.chat/ui-contexts'; +import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { getURL } from '../../../../app/utils/client/getURL'; @@ -18,6 +18,22 @@ const AdvancedContactModal = ({ onCancel }: AdvancedContactModalProps) => { const hasLicense = useHasLicenseModule('contact-id-verification') as boolean; const { shouldShowUpsell, handleManageSubscription } = useUpsellActions(hasLicense); const openExternalLink = useExternalLink(); + const eventStats = useEndpoint('POST', '/v1/statistics.telemetry'); + + const handleUpsellClick = async () => { + eventStats({ + params: [{ eventName: 'updateCounter', settingsId: 'Advanced_Contact_Upsell_Clicks_Count' }], + }); + return handleManageSubscription(); + }; + + useEffect(() => { + if (shouldShowUpsell) { + eventStats({ + params: [{ eventName: 'updateCounter', settingsId: 'Advanced_Contact_Upsell_Views_Count' }], + }); + } + }, [eventStats, shouldShowUpsell]); return ( { onClose={onCancel} onCancel={shouldShowUpsell ? onCancel : () => openExternalLink('https://go.rocket.chat/i/omnichannel-docs')} cancelText={!shouldShowUpsell ? t('Learn_more') : undefined} - onConfirm={shouldShowUpsell ? handleManageSubscription : undefined} + onConfirm={shouldShowUpsell ? handleUpsellClick : undefined} annotation={!shouldShowUpsell && !isAdmin ? t('Ask_enable_advanced_contact_profile') : undefined} /> ); diff --git a/apps/meteor/ee/server/patches/mergeContacts.ts b/apps/meteor/ee/server/patches/mergeContacts.ts index 1f93e1731a82..30d5b03d0cfb 100644 --- a/apps/meteor/ee/server/patches/mergeContacts.ts +++ b/apps/meteor/ee/server/patches/mergeContacts.ts @@ -1,8 +1,9 @@ import type { ILivechatContact, ILivechatContactChannel, ILivechatContactVisitorAssociation } from '@rocket.chat/core-typings'; import { License } from '@rocket.chat/license'; -import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; +import { LivechatContacts, LivechatRooms, Settings } from '@rocket.chat/models'; import type { ClientSession } from 'mongodb'; +import { notifyOnSettingChanged } from '../../../app/lib/server/lib/notifyListener'; import { isSameChannel } from '../../../app/livechat/lib/isSameChannel'; import { ContactMerger } from '../../../app/livechat/server/lib/contacts/ContactMerger'; import { mergeContacts } from '../../../app/livechat/server/lib/contacts/mergeContacts'; @@ -41,10 +42,16 @@ export const runMergeContacts = async ( const similarContactIds = similarContacts.map((c) => c._id); const { deletedCount } = await LivechatContacts.deleteMany({ _id: { $in: similarContactIds } }, { session }); + + const { value } = await Settings.incrementValueById('Merged_Contacts_Count', similarContacts.length, { returnDocument: 'after' }); + if (value) { + void notifyOnSettingChanged(value); + } logger.info({ - msg: `${deletedCount} contacts have been deleted and merged`, - deletedContactIds: similarContactIds, - contactId, + msg: 'contacts have been deleted and merged with a contact', + similarContactIds, + deletedCount, + originalContactId: originalContact._id, }); logger.debug({ msg: 'Updating rooms with new contact id', contactId }); diff --git a/apps/meteor/ee/server/settings/contact-verification.ts b/apps/meteor/ee/server/settings/contact-verification.ts index 78d04b7dc1d7..b62b164a7b90 100644 --- a/apps/meteor/ee/server/settings/contact-verification.ts +++ b/apps/meteor/ee/server/settings/contact-verification.ts @@ -4,6 +4,31 @@ export const addSettings = async (): Promise => { const omnichannelEnabledQuery = { _id: 'Livechat_enabled', value: true }; return settingsRegistry.addGroup('Omnichannel', async function () { + await this.add('Merged_Contacts_Count', 0, { + type: 'int', + hidden: true, + }); + + await this.add('Resolved_Conflicts_Count', 0, { + type: 'int', + hidden: true, + }); + + await this.add('Contacts_Importer_Count', 0, { + type: 'int', + hidden: true, + }); + + await this.add('Advanced_Contact_Upsell_Views_Count', 0, { + type: 'int', + hidden: true, + }); + + await this.add('Advanced_Contact_Upsell_Clicks_Count', 0, { + type: 'int', + hidden: true, + }); + return this.with( { enterprise: true, diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts index 0fb40fa04bdd..5e4b285c7a3b 100644 --- a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/lib/mergeContacts.spec.ts @@ -11,6 +11,9 @@ const modelsMock = { LivechatRooms: { updateMergedContactIds: sinon.stub(), }, + Settings: { + incrementValueById: sinon.stub(), + }, }; const contactMergerStub = { @@ -22,6 +25,7 @@ const { runMergeContacts } = proxyquire.noCallThru().load('../../../../../../ser '../../../app/livechat/server/lib/contacts/mergeContacts': { mergeContacts: { patch: sinon.stub() } }, '../../../app/livechat/server/lib/contacts/ContactMerger': { ContactMerger: contactMergerStub }, '../../../app/livechat-enterprise/server/lib/logger': { logger: { info: sinon.stub(), debug: sinon.stub() } }, + '../../../app/lib/server/lib/notifyListener': { notifyOnSettingChanged: sinon.stub() }, '@rocket.chat/models': modelsMock, }); @@ -45,6 +49,7 @@ describe('mergeContacts', () => { modelsMock.LivechatContacts.findSimilarVerifiedContacts.reset(); modelsMock.LivechatContacts.deleteMany.reset(); modelsMock.LivechatRooms.updateMergedContactIds.reset(); + modelsMock.Settings.incrementValueById.reset(); contactMergerStub.getAllFieldsFromContact.reset(); contactMergerStub.mergeFieldsIntoContact.reset(); modelsMock.LivechatContacts.deleteMany.resolves({ deletedCount: 0 }); @@ -102,6 +107,7 @@ describe('mergeContacts', () => { modelsMock.LivechatContacts.findOneById.resolves(originalContact); modelsMock.LivechatContacts.findSimilarVerifiedContacts.resolves([similarContact]); + modelsMock.Settings.incrementValueById.resolves({ value: undefined }); await runMergeContacts(() => undefined, 'contactId', { visitorId: 'visitorId', source: { type: 'sms' } }); @@ -114,5 +120,6 @@ describe('mergeContacts', () => { expect(modelsMock.LivechatContacts.deleteMany.calledOnceWith({ _id: { $in: ['differentId'] } })).to.be.true; expect(modelsMock.LivechatRooms.updateMergedContactIds.calledOnceWith(['differentId'], 'contactId')).to.be.true; + expect(modelsMock.Settings.incrementValueById.calledOnceWith('Merged_Contacts_Count', 1)).to.be.true; }); }); diff --git a/apps/meteor/server/models/raw/LivechatContacts.ts b/apps/meteor/server/models/raw/LivechatContacts.ts index 796edb3bc059..43a5f5204e60 100644 --- a/apps/meteor/server/models/raw/LivechatContacts.ts +++ b/apps/meteor/server/models/raw/LivechatContacts.ts @@ -21,9 +21,11 @@ import type { UpdateFilter, UpdateOptions, FindOneAndUpdateOptions, + AggregationCursor, } from 'mongodb'; import { BaseRaw } from './BaseRaw'; +import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; export class LivechatContactsRaw extends BaseRaw implements ILivechatContactsModel { constructor(db: Db, trash?: Collection>) { @@ -76,6 +78,22 @@ export class LivechatContactsRaw extends BaseRaw implements IL sparse: true, unique: false, }, + { + key: { channels: 1 }, + unique: false, + }, + { + key: { 'channels.blocked': 1 }, + sparse: true, + }, + { + key: { 'channels.verified': 1 }, + sparse: true, + }, + { + key: { unknown: 1 }, + unique: false, + }, ]; } @@ -272,4 +290,49 @@ export class LivechatContactsRaw extends BaseRaw implements IL return this.countDocuments(filter); } + + countUnknown(): Promise { + return this.countDocuments({ unknown: true }, { readPreference: readSecondaryPreferred() }); + } + + countBlocked(): Promise { + return this.countDocuments({ 'channels.blocked': true }, { readPreference: readSecondaryPreferred() }); + } + + countFullyBlocked(): Promise { + return this.countDocuments( + { + 'channels.blocked': true, + 'channels': { $not: { $elemMatch: { $or: [{ blocked: false }, { blocked: { $exists: false } }] } } }, + }, + { readPreference: readSecondaryPreferred() }, + ); + } + + countVerified(): Promise { + return this.countDocuments({ 'channels.verified': true }, { readPreference: readSecondaryPreferred() }); + } + + countContactsWithoutChannels(): Promise { + return this.countDocuments({ channels: { $size: 0 } }, { readPreference: readSecondaryPreferred() }); + } + + getStatistics(): AggregationCursor<{ totalConflicts: number; avgChannelsPerContact: number }> { + return this.col.aggregate<{ totalConflicts: number; avgChannelsPerContact: number }>( + [ + { + $group: { + _id: null, + totalConflicts: { + $sum: { $size: { $cond: [{ $isArray: '$conflictingFields' }, '$conflictingFields', []] } }, + }, + avgChannelsPerContact: { + $avg: { $size: { $cond: [{ $isArray: '$channels' }, '$channels', []] } }, + }, + }, + }, + ], + { allowDiskUse: true, readPreference: readSecondaryPreferred() }, + ); + } } diff --git a/packages/core-typings/src/IStats.ts b/packages/core-typings/src/IStats.ts index 9aab9cd96f87..df179989de95 100644 --- a/packages/core-typings/src/IStats.ts +++ b/packages/core-typings/src/IStats.ts @@ -237,4 +237,20 @@ export interface IStats { webRTCEnabledForOmnichannel: boolean; omnichannelWebRTCCalls: number; statsToken?: string; + contactVerification: { + totalContacts: number; + totalUnknownContacts: number; + totalMergedContacts: number; + totalConflicts: number; + totalResolvedConflicts: number; + totalBlockedContacts: number; + totalPartiallyBlockedContacts: number; + totalFullyBlockedContacts: number; + totalVerifiedContacts: number; + avgChannelsPerContact: number; + totalContactsWithoutChannels: number; + totalImportedContacts: number; + totalUpsellViews: number; + totalUpsellClicks: number; + }; } diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts index 8501271ab5f4..00702018bc6a 100644 --- a/packages/model-typings/src/models/ILivechatContactsModel.ts +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -5,7 +5,16 @@ import type { ILivechatContactVisitorAssociation, ILivechatVisitor, } from '@rocket.chat/core-typings'; -import type { Document, FindCursor, FindOneAndUpdateOptions, FindOptions, UpdateFilter, UpdateOptions, UpdateResult } from 'mongodb'; +import type { + AggregationCursor, + Document, + FindCursor, + FindOneAndUpdateOptions, + FindOptions, + UpdateFilter, + UpdateOptions, + UpdateResult, +} from 'mongodb'; import type { Updater } from '../updater'; import type { FindPaginated, IBaseModel, InsertionModel } from './IBaseModel'; @@ -49,4 +58,10 @@ export interface ILivechatContactsModel extends IBaseModel { setVerifiedUpdateQuery(verified: boolean, contactUpdater: Updater): Updater; setFieldAndValueUpdateQuery(field: string, value: string, contactUpdater: Updater): Updater; countByContactInfo({ contactId, email, phone }: { contactId?: string; email?: string; phone?: string }): Promise; + countUnknown(): Promise; + countBlocked(): Promise; + countFullyBlocked(): Promise; + countVerified(): Promise; + countContactsWithoutChannels(): Promise; + getStatistics(): AggregationCursor<{ totalConflicts: number; avgChannelsPerContact: number }>; }