diff --git a/.changeset/eleven-mails-sort.md b/.changeset/eleven-mails-sort.md new file mode 100644 index 000000000000..daa491dd8d33 --- /dev/null +++ b/.changeset/eleven-mails-sort.md @@ -0,0 +1,9 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/freeswitch': minor +'@rocket.chat/models': minor +'@rocket.chat/meteor': minor +--- + +Added voice calls data to statistics diff --git a/apps/meteor/app/statistics/server/lib/getEEStatistics.ts b/apps/meteor/app/statistics/server/lib/getEEStatistics.ts index ffb5a939c2fd..6511feeabb77 100644 --- a/apps/meteor/app/statistics/server/lib/getEEStatistics.ts +++ b/apps/meteor/app/statistics/server/lib/getEEStatistics.ts @@ -5,6 +5,8 @@ import type { IStats } from '@rocket.chat/core-typings'; import { License } from '@rocket.chat/license'; import { CannedResponse, OmnichannelServiceLevelAgreements, LivechatRooms, LivechatTag, LivechatUnit, Users } from '@rocket.chat/models'; +import { getVoIPStatistics } from './getVoIPStatistics'; + type ENTERPRISE_STATISTICS = IStats['enterprise']; type GenericStats = Pick; @@ -105,6 +107,13 @@ async function getEEStatistics(): Promise { }), ); + // TeamCollab VoIP data + statsPms.push( + getVoIPStatistics().then((voip) => { + statistics.voip = voip; + }), + ); + await Promise.all(statsPms).catch(log); return statistics as EEOnlyStats; diff --git a/apps/meteor/app/statistics/server/lib/getVoIPStatistics.ts b/apps/meteor/app/statistics/server/lib/getVoIPStatistics.ts new file mode 100644 index 000000000000..765f23992aa7 --- /dev/null +++ b/apps/meteor/app/statistics/server/lib/getVoIPStatistics.ts @@ -0,0 +1,95 @@ +import { log } from 'console'; + +import type { IStats, IVoIPPeriodStats } from '@rocket.chat/core-typings'; +import { FreeSwitchCall } from '@rocket.chat/models'; +import { MongoInternals } from 'meteor/mongo'; + +import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred'; + +const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo; + +const getMinDate = (days?: number): Date | undefined => { + if (!days) { + return; + } + + const date = new Date(); + date.setDate(date.getDate() - days); + + return date; +}; + +async function getVoIPStatisticsForPeriod(days?: number): Promise { + const promises: Array> = []; + const options = { + readPreference: readSecondaryPreferred(db), + }; + + const minDate = getMinDate(days); + + const statistics: IVoIPPeriodStats = {}; + + promises.push( + FreeSwitchCall.countCallsByDirection('internal', minDate, options).then((count) => { + statistics.internalCalls = count; + }), + ); + promises.push( + FreeSwitchCall.countCallsByDirection('external_inbound', minDate, options).then((count) => { + statistics.externalInboundCalls = count; + }), + ); + promises.push( + FreeSwitchCall.countCallsByDirection('external_outbound', minDate, options).then((count) => { + statistics.externalOutboundCalls = count; + }), + ); + + promises.push( + FreeSwitchCall.sumCallsDuration(minDate, options).then((callsDuration) => { + statistics.callsDuration = callsDuration; + }), + ); + + promises.push( + FreeSwitchCall.countCallsBySuccessState(true, minDate, options).then((count) => { + statistics.successfulCalls = count; + }), + ); + + promises.push( + FreeSwitchCall.countCallsBySuccessState(false, minDate, options).then((count) => { + statistics.failedCalls = count; + }), + ); + + await Promise.allSettled(promises).catch(log); + + statistics.externalCalls = (statistics.externalInboundCalls || 0) + (statistics.externalOutboundCalls || 0); + statistics.calls = (statistics.successfulCalls || 0) + (statistics.failedCalls || 0); + + return statistics; +} + +export async function getVoIPStatistics(): Promise { + const statistics: IStats['enterprise']['voip'] = {}; + + const promises = [ + getVoIPStatisticsForPeriod().then((total) => { + statistics.total = total; + }), + getVoIPStatisticsForPeriod(30).then((month) => { + statistics.lastMonth = month; + }), + getVoIPStatisticsForPeriod(7).then((week) => { + statistics.lastWeek = week; + }), + getVoIPStatisticsForPeriod(1).then((day) => { + statistics.lastDay = day; + }), + ]; + + await Promise.allSettled(promises).catch(log); + + return statistics; +} diff --git a/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts b/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts index 41010f41ad55..5e2dcb399f9e 100644 --- a/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts +++ b/apps/meteor/ee/server/local-services/voip-freeswitch/service.ts @@ -378,6 +378,7 @@ export class VoipFreeSwitchService extends ServiceClassInternal implements IVoip UUID: callUUID, channels: [], events: [], + startedAt: new Date(), }; // Sort events by both sequence and timestamp, but only when they are present @@ -404,6 +405,9 @@ export class VoipFreeSwitchService extends ServiceClassInternal implements IVoip if (event.channelUniqueId && !call.channels.includes(event.channelUniqueId)) { call.channels.push(event.channelUniqueId); } + if (event.firedAt && event.firedAt < call.startedAt) { + call.startedAt = event.firedAt; + } const eventType = this.getEventType(event); fromUser.add(this.identifyCallerFromEvent(event)); diff --git a/packages/core-typings/src/IStats.ts b/packages/core-typings/src/IStats.ts index df179989de95..4ff216f1c9ac 100644 --- a/packages/core-typings/src/IStats.ts +++ b/packages/core-typings/src/IStats.ts @@ -6,6 +6,17 @@ import type { ISettingStatisticsObject } from './ISetting'; import type { ITeamStats } from './ITeam'; import type { MACStats } from './omnichannel'; +export interface IVoIPPeriodStats { + calls?: number; + externalInboundCalls?: number; + externalOutboundCalls?: number; + internalCalls?: number; + externalCalls?: number; + successfulCalls?: number; + failedCalls?: number; + callsDuration?: number; +} + export interface IStats { _id: string; wizard: { @@ -169,6 +180,12 @@ export interface IStats { omnichannelRoomsWithSlas?: number; omnichannelRoomsWithPriorities?: number; livechatMonitors?: number; + voip?: { + total?: IVoIPPeriodStats; + lastMonth?: IVoIPPeriodStats; + lastWeek?: IVoIPPeriodStats; + lastDay?: IVoIPPeriodStats; + }; }; createdAt: Date | string; totalOTR: number; diff --git a/packages/core-typings/src/voip/IFreeSwitchCall.ts b/packages/core-typings/src/voip/IFreeSwitchCall.ts index b0f8043f78fc..6771e967bac5 100644 --- a/packages/core-typings/src/voip/IFreeSwitchCall.ts +++ b/packages/core-typings/src/voip/IFreeSwitchCall.ts @@ -12,6 +12,7 @@ export interface IFreeSwitchCall extends IRocketChatRecord { direction?: 'internal' | 'external_inbound' | 'external_outbound'; voicemail?: boolean; duration?: number; + startedAt?: Date; } const knownEventTypes = [ diff --git a/packages/model-typings/src/models/IFreeSwitchCallModel.ts b/packages/model-typings/src/models/IFreeSwitchCallModel.ts index ef5b35860420..a224cb5ceef9 100644 --- a/packages/model-typings/src/models/IFreeSwitchCallModel.ts +++ b/packages/model-typings/src/models/IFreeSwitchCallModel.ts @@ -1,9 +1,12 @@ import type { IFreeSwitchCall } from '@rocket.chat/core-typings'; -import type { FindCursor, FindOptions, WithoutId } from 'mongodb'; +import type { AggregateOptions, CountDocumentsOptions, FindCursor, FindOptions, WithoutId } from 'mongodb'; import type { IBaseModel, InsertionModel } from './IBaseModel'; export interface IFreeSwitchCallModel extends IBaseModel { registerCall(call: WithoutId>): Promise; findAllByChannelUniqueIds(uniqueIds: string[], options?: FindOptions): FindCursor; + countCallsByDirection(direction: IFreeSwitchCall['direction'], minDate?: Date, options?: CountDocumentsOptions): Promise; + sumCallsDuration(minDate?: Date, options?: AggregateOptions): Promise; + countCallsBySuccessState(success: boolean, minDate?: Date, options?: CountDocumentsOptions): Promise; } diff --git a/packages/models/src/models/FreeSwitchCall.ts b/packages/models/src/models/FreeSwitchCall.ts index 2be10e0d29d3..f16986d01408 100644 --- a/packages/models/src/models/FreeSwitchCall.ts +++ b/packages/models/src/models/FreeSwitchCall.ts @@ -1,8 +1,18 @@ import type { IFreeSwitchCall, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { IFreeSwitchCallModel, InsertionModel } from '@rocket.chat/model-typings'; -import type { Collection, Db, FindCursor, FindOptions, IndexDescription, WithoutId } from 'mongodb'; +import type { + AggregateOptions, + Collection, + CountDocumentsOptions, + Db, + FindCursor, + FindOptions, + IndexDescription, + WithoutId, +} from 'mongodb'; import { BaseRaw } from './BaseRaw'; +import { readSecondaryPreferred } from '../readSecondaryPreferred'; export class FreeSwitchCallRaw extends BaseRaw implements IFreeSwitchCallModel { constructor(db: Db, trash?: Collection>) { @@ -10,7 +20,7 @@ export class FreeSwitchCallRaw extends BaseRaw implements IFree } protected modelIndexes(): IndexDescription[] { - return [{ key: { UUID: 1 } }, { key: { channels: 1 } }]; + return [{ key: { UUID: 1 } }, { key: { channels: 1 } }, { key: { direction: 1, startedAt: 1 } }]; } public async registerCall(call: WithoutId>): Promise { @@ -25,4 +35,42 @@ export class FreeSwitchCallRaw extends BaseRaw implements IFree options, ); } + + public countCallsByDirection(direction: IFreeSwitchCall['direction'], minDate?: Date, options?: CountDocumentsOptions): Promise { + return this.col.countDocuments( + { + direction, + ...(minDate && { startedAt: { $gte: minDate } }), + }, + { readPreference: readSecondaryPreferred(), ...options }, + ); + } + + public async sumCallsDuration(minDate?: Date, options?: AggregateOptions): Promise { + return this.col + .aggregate( + [ + ...(minDate ? [{ $match: { startedAt: { $gte: minDate } } }] : []), + { + $group: { + _id: '1', + calls: { $sum: '$duration' }, + }, + }, + ], + { readPreference: readSecondaryPreferred(), ...options }, + ) + .toArray() + .then(([{ calls }]) => calls); + } + + public countCallsBySuccessState(success: boolean, minDate?: Date, options?: CountDocumentsOptions): Promise { + return this.col.countDocuments( + { + ...(success ? { duration: { $gte: 5 } } : { $or: [{ duration: { $exists: false } }, { duration: { $lt: 5 } }] }), + ...(minDate && { startedAt: { $gte: minDate } }), + }, + { readPreference: readSecondaryPreferred(), ...options }, + ); + } }