Skip to content

Commit

Permalink
feat: add Voice Calls data to statistics (#34948)
Browse files Browse the repository at this point in the history
Co-authored-by: Marcos Spessatto Defendi <15324204+MarcosSpessatto@users.noreply.github.com>
  • Loading branch information
pierre-lehnen-rc and MarcosSpessatto authored Jan 19, 2025
1 parent c8e8518 commit 8942b00
Show file tree
Hide file tree
Showing 8 changed files with 188 additions and 3 deletions.
9 changes: 9 additions & 0 deletions .changeset/eleven-mails-sort.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/freeswitch': minor
'@rocket.chat/models': minor
'@rocket.chat/meteor': minor
---

Adds voice calls data to statistics
9 changes: 9 additions & 0 deletions apps/meteor/app/statistics/server/lib/getEEStatistics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ENTERPRISE_STATISTICS, 'modules' | 'tags' | 'seatRequests'>;
Expand Down Expand Up @@ -105,6 +107,13 @@ async function getEEStatistics(): Promise<EEOnlyStats | undefined> {
}),
);

// TeamCollab VoIP data
statsPms.push(
getVoIPStatistics().then((voip) => {
statistics.voip = voip;
}),
);

await Promise.all(statsPms).catch(log);

return statistics as EEOnlyStats;
Expand Down
95 changes: 95 additions & 0 deletions apps/meteor/app/statistics/server/lib/getVoIPStatistics.ts
Original file line number Diff line number Diff line change
@@ -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<IVoIPPeriodStats> {
const promises: Array<Promise<number | void>> = [];
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<IStats['enterprise']['voip']> {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,9 @@ export class VoipFreeSwitchService extends ServiceClassInternal implements IVoip
if (event.channelUniqueId && !call.channels.includes(event.channelUniqueId)) {
call.channels.push(event.channelUniqueId);
}
if (!call.startedAt || (event.firedAt && event.firedAt < call.startedAt)) {
call.startedAt = event.firedAt;
}

const eventType = this.getEventType(event);
fromUser.add(this.identifyCallerFromEvent(event));
Expand Down
17 changes: 17 additions & 0 deletions packages/core-typings/src/IStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/core-typings/src/voip/IFreeSwitchCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface IFreeSwitchCall extends IRocketChatRecord {
direction?: 'internal' | 'external_inbound' | 'external_outbound';
voicemail?: boolean;
duration?: number;
startedAt?: Date;
}

const knownEventTypes = [
Expand Down
5 changes: 4 additions & 1 deletion packages/model-typings/src/models/IFreeSwitchCallModel.ts
Original file line number Diff line number Diff line change
@@ -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<IFreeSwitchCall> {
registerCall(call: WithoutId<InsertionModel<IFreeSwitchCall>>): Promise<void>;
findAllByChannelUniqueIds<T extends IFreeSwitchCall>(uniqueIds: string[], options?: FindOptions<IFreeSwitchCall>): FindCursor<T>;
countCallsByDirection(direction: IFreeSwitchCall['direction'], minDate?: Date, options?: CountDocumentsOptions): Promise<number>;
sumCallsDuration(minDate?: Date, options?: AggregateOptions): Promise<number>;
countCallsBySuccessState(success: boolean, minDate?: Date, options?: CountDocumentsOptions): Promise<number>;
}
52 changes: 50 additions & 2 deletions packages/models/src/models/FreeSwitchCall.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
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<IFreeSwitchCall> implements IFreeSwitchCallModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IFreeSwitchCall>>) {
super(db, 'freeswitch_calls', trash);
}

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<InsertionModel<IFreeSwitchCall>>): Promise<void> {
Expand All @@ -25,4 +35,42 @@ export class FreeSwitchCallRaw extends BaseRaw<IFreeSwitchCall> implements IFree
options,
);
}

public countCallsByDirection(direction: IFreeSwitchCall['direction'], minDate?: Date, options?: CountDocumentsOptions): Promise<number> {
return this.col.countDocuments(
{
direction,
...(minDate && { startedAt: { $gte: minDate } }),
},
{ readPreference: readSecondaryPreferred(), ...options },
);
}

public async sumCallsDuration(minDate?: Date, options?: AggregateOptions): Promise<number> {
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<number> {
return this.col.countDocuments(
{
...(success ? { duration: { $gte: 5 } } : { $or: [{ duration: { $exists: false } }, { duration: { $lt: 5 } }] }),
...(minDate && { startedAt: { $gte: minDate } }),
},
{ readPreference: readSecondaryPreferred(), ...options },
);
}
}

0 comments on commit 8942b00

Please sign in to comment.