Skip to content

Commit

Permalink
feat: Omnichannel Reports (#30051)
Browse files Browse the repository at this point in the history
Co-authored-by: Martin Schoeler <20868078+MartinSchoeler@users.noreply.github.com>
Co-authored-by: Kevin Aleman <11577696+KevLehman@users.noreply.github.com>
Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com>
  • Loading branch information
4 people committed Aug 29, 2023
1 parent ba18325 commit ebab8c4
Show file tree
Hide file tree
Showing 67 changed files with 2,689 additions and 45 deletions.
8 changes: 8 additions & 0 deletions .changeset/serious-geckos-drive.md
@@ -0,0 +1,8 @@
---
'@rocket.chat/core-typings': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/ui-client': minor
'@rocket.chat/meteor': minor
---

Added Reports Metrics Dashboard to Omnichannel
6 changes: 6 additions & 0 deletions apps/meteor/client/views/omnichannel/sidebarItems.ts
Expand Up @@ -13,6 +13,12 @@ export const {
i18nLabel: 'Current_Chats',
permissionGranted: (): boolean => hasPermission('view-livechat-current-chats'),
},
{
href: '/omnichannel/reports',
icon: 'file',
i18nLabel: 'Reports',
permissionGranted: (): boolean => hasPermission('view-livechat-reports'),
},
{
href: '/omnichannel/analytics',
icon: 'dashboard',
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/ee/app/livechat-enterprise/server/api/index.ts
Expand Up @@ -9,3 +9,4 @@ import './units';
import './business-hours';
import './rooms';
import './transcript';
import './reports';
@@ -0,0 +1,62 @@
import type { ReportResult, ReportWithUnmatchingElements, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import mem from 'mem';
import type { Filter } from 'mongodb';

type AggParams = { start: Date; end: Date; sort: Record<string, 1 | -1>; extraQuery: Filter<IOmnichannelRoom> };

const defaultValue = { data: [], total: 0 };
export const findAllConversationsBySource = async ({ start, end, extraQuery }: Omit<AggParams, 'sort'>): Promise<ReportResult> => {
return (await LivechatRooms.getConversationsBySource(start, end, extraQuery).toArray())[0] || defaultValue;
};

export const findAllConversationsByStatus = async ({ start, end, extraQuery }: Omit<AggParams, 'sort'>): Promise<ReportResult> => {
return (await LivechatRooms.getConversationsByStatus(start, end, extraQuery).toArray())[0] || defaultValue;
};

export const findAllConversationsByDepartment = async ({
start,
end,
sort,
extraQuery,
}: AggParams): Promise<ReportWithUnmatchingElements> => {
const [result, total] = await Promise.all([
LivechatRooms.getConversationsByDepartment(start, end, sort, extraQuery).toArray(),
LivechatRooms.getTotalConversationsWithoutDepartmentBetweenDates(start, end, extraQuery),
]);

return {
...(result?.[0] || defaultValue),
unspecified: total || 0,
};
};

export const findAllConversationsByTags = async ({ start, end, sort, extraQuery }: AggParams): Promise<ReportWithUnmatchingElements> => {
const [result, total] = await Promise.all([
LivechatRooms.getConversationsByTags(start, end, sort, extraQuery).toArray(),
LivechatRooms.getConversationsWithoutTagsBetweenDate(start, end, extraQuery),
]);

return {
...(result?.[0] || defaultValue),
unspecified: total || 0,
};
};

export const findAllConversationsByAgents = async ({ start, end, sort, extraQuery }: AggParams): Promise<ReportWithUnmatchingElements> => {
const [result, total] = await Promise.all([
LivechatRooms.getConversationsByAgents(start, end, sort, extraQuery).toArray(),
LivechatRooms.getTotalConversationsWithoutAgentsBetweenDate(start, end, extraQuery),
]);

return {
...(result?.[0] || defaultValue),
unspecified: total || 0,
};
};

export const findAllConversationsBySourceCached = mem(findAllConversationsBySource, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByStatusCached = mem(findAllConversationsByStatus, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByDepartmentCached = mem(findAllConversationsByDepartment, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByTagsCached = mem(findAllConversationsByTags, { maxAge: 60000, cacheKey: JSON.stringify });
export const findAllConversationsByAgentsCached = mem(findAllConversationsByAgents, { maxAge: 60000, cacheKey: JSON.stringify });
130 changes: 130 additions & 0 deletions apps/meteor/ee/app/livechat-enterprise/server/api/reports.ts
@@ -0,0 +1,130 @@
import { isGETDashboardConversationsByType } from '@rocket.chat/rest-typings';
import type { Moment } from 'moment';
import moment from 'moment';

import { API } from '../../../../../app/api/server';
import { restrictQuery } from '../hooks/applyRoomRestrictions';
import {
findAllConversationsBySourceCached,
findAllConversationsByStatusCached,
findAllConversationsByDepartmentCached,
findAllConversationsByTagsCached,
findAllConversationsByAgentsCached,
} from './lib/dashboards';

const checkDates = (start: Moment, end: Moment) => {
if (!start.isValid()) {
throw new Error('The "start" query parameter must be a valid date.');
}
if (!end.isValid()) {
throw new Error('The "end" query parameter must be a valid date.');
}
// Check dates are no more than 1 year apart using moment
// 1.01 === "we allow to pass year by some hours/days"
if (moment(end).startOf('day').diff(moment(start).startOf('day'), 'year', true) > 1.01) {
throw new Error('The "start" and "end" query parameters must be less than 1 year apart.');
}

if (start.isAfter(end)) {
throw new Error('The "start" query parameter must be before the "end" query parameter.');
}
};

API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-source',
{ authRequired: true, permissionsRequired: ['view-livechat-reports'], validateParams: isGETDashboardConversationsByType },
{
async get() {
const { start, end } = this.queryParams;

const startDate = moment(start);
const endDate = moment(end);

checkDates(startDate, endDate);

const extraQuery = await restrictQuery();
const result = await findAllConversationsBySourceCached({ start: startDate.toDate(), end: endDate.toDate(), extraQuery });

return API.v1.success(result);
},
},
);

API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-status',
{ authRequired: true, permissionsRequired: ['view-livechat-reports'], validateParams: isGETDashboardConversationsByType },
{
async get() {
const { start, end } = this.queryParams;

const startDate = moment(start);
const endDate = moment(end);

checkDates(startDate, endDate);
const extraQuery = await restrictQuery();
const result = await findAllConversationsByStatusCached({ start: startDate.toDate(), end: endDate.toDate(), extraQuery });

return API.v1.success(result);
},
},
);

API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-department',
{ authRequired: true, permissionsRequired: ['view-livechat-reports'], validateParams: isGETDashboardConversationsByType },
{
async get() {
const { start, end } = this.queryParams;
const { sort } = await this.parseJsonQuery();

const startDate = moment(start);
const endDate = moment(end);

checkDates(startDate, endDate);
const extraQuery = await restrictQuery();
const result = await findAllConversationsByDepartmentCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });

return API.v1.success(result);
},
},
);

API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-tags',
{ authRequired: true, permissionsRequired: ['view-livechat-reports'], validateParams: isGETDashboardConversationsByType },
{
async get() {
const { start, end } = this.queryParams;
const { sort } = await this.parseJsonQuery();

const startDate = moment(start);
const endDate = moment(end);

checkDates(startDate, endDate);
const extraQuery = await restrictQuery();
const result = await findAllConversationsByTagsCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });

return API.v1.success(result);
},
},
);

API.v1.addRoute(
'livechat/analytics/dashboards/conversations-by-agent',
{ authRequired: true, permissionsRequired: ['view-livechat-reports'], validateParams: isGETDashboardConversationsByType },
{
async get() {
const { start, end } = this.queryParams;
const { sort } = await this.parseJsonQuery();

const startDate = moment(start);
const endDate = moment(end);

checkDates(startDate, endDate);
const extraQuery = await restrictQuery();
const result = await findAllConversationsByAgentsCached({ start: startDate.toDate(), end: endDate.toDate(), sort, extraQuery });

return API.v1.success(result);
},
},
);
@@ -1,4 +1,5 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import type { FilterOperators } from 'mongodb';

import { callbacks } from '../../../../../lib/callbacks';
Expand All @@ -12,10 +13,11 @@ export const restrictQuery = async (originalQuery: FilterOperators<IOmnichannelR
if (!Array.isArray(units)) {
return query;
}
const departments = await LivechatDepartment.find({ ancestors: { $in: units } }, { projection: { _id: 1 } }).toArray();

const expressions = query.$and || [];
const condition = {
$or: [{ departmentAncestors: { $in: units } }, { departmentId: { $in: units } }],
$or: [{ departmentAncestors: { $in: units } }, { departmentId: { $in: departments.map(({ _id }) => _id) } }],
};
query.$and = [condition, ...expressions];

Expand Down
Expand Up @@ -17,6 +17,7 @@ export const omnichannelEEPermissions = [
{ _id: 'spy-voip-calls', roles: [adminRole, livechatManagerRole, livechatMonitorRole] },
{ _id: 'outbound-voip-calls', roles: [adminRole, livechatManagerRole] },
{ _id: 'request-pdf-transcript', roles: [adminRole, livechatManagerRole, livechatMonitorRole, livechatAgentRole] },
{ _id: 'view-livechat-reports', roles: [adminRole, livechatManagerRole, livechatMonitorRole] },
];

export const createPermissions = async (): Promise<void> => {
Expand Down
Expand Up @@ -4,7 +4,7 @@ import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-context
import type { ComponentProps, ReactElement } from 'react';
import React from 'react';

import { downloadCsvAs } from '../../../../../../client/lib/download';
import { downloadCsvAs } from '../../../../client/lib/download';

type RowFor<THeaders extends readonly string[]> = readonly unknown[] & {
length: THeaders['length'];
Expand Down
Expand Up @@ -14,21 +14,36 @@ const lastNDays =
end: Date;
}) =>
(utc): { start: Date; end: Date } => ({
start: utc
? moment.utc().startOf('day').subtract(n, 'days').toDate()
: moment()
.startOf('day')
.subtract(n + 1, 'days')
.toDate(),
end: utc ? moment.utc().endOf('day').subtract(1, 'days').toDate() : moment().endOf('day').toDate(),
start: utc ? moment.utc().startOf('day').subtract(n, 'days').toDate() : moment().startOf('day').subtract(n, 'days').toDate(),
end: utc ? moment.utc().endOf('day').toDate() : moment().endOf('day').toDate(),
});

const periods = [
{
key: 'today',
label: label('Today'),
range: lastNDays(0),
},
{
key: 'this week',
label: label('This_week'),
range: lastNDays(7),
},
{
key: 'last 7 days',
label: label('Last_7_days'),
range: lastNDays(7),
},
{
key: 'last 15 days',
label: label('Last_15_days'),
range: lastNDays(15),
},
{
key: 'this month',
label: label('This_month'),
range: lastNDays(30),
},
{
key: 'last 30 days',
label: label('Last_30_days'),
Expand All @@ -39,6 +54,16 @@ const periods = [
label: label('Last_90_days'),
range: lastNDays(90),
},
{
key: 'last 6 months',
label: label('Last_6_months'),
range: lastNDays(180),
},
{
key: 'last year',
label: label('Last_year'),
range: lastNDays(365),
},
] as const;

export type Period = (typeof periods)[number];
Expand All @@ -55,7 +80,7 @@ export const getPeriod = (key: (typeof periods)[number]['key']): Period => {

export const getPeriodRange = (
key: (typeof periods)[number]['key'],
utc = false,
utc = true,
): {
start: Date;
end: Date;
Expand Down
@@ -0,0 +1,26 @@
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';

import type { Period } from './periods';

export const usePeriodSelectorStorage = <TPeriod extends Period['key']>(
storageKey: string,
periods: TPeriod[],
): [
period: TPeriod,
periodSelectorProps: {
periods: TPeriod[];
value: TPeriod;
onChange: (value: TPeriod) => void;
},
] => {
const [period, setPeriod] = useLocalStorage<TPeriod>(storageKey, periods[0]);

return [
period,
{
periods,
value: period,
onChange: (value): void => setPeriod(value),
},
];
};
37 changes: 37 additions & 0 deletions apps/meteor/ee/client/omnichannel/reports/ReportsPage.tsx
@@ -0,0 +1,37 @@
import { Box } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';

import Page from '../../../../client/components/Page';
import { ResizeObserver } from './components/ResizeObserver';
import { AgentsSection, ChannelsSection, DepartmentsSection, StatusSection, TagsSection } from './sections';

const ReportsPage = () => {
const t = useTranslation();

return (
<Page background='tint'>
<Page.Header title={t('Reports')} />
<Box is='p' color='hint' fontScale='p2' mi={24}>
{t('Omnichannel_Reports_Summary')}
</Box>
<Page.ScrollableContentWithShadow alignItems='center'>
<ResizeObserver>
<Box display='flex' flexWrap='wrap' width='100rem' maxWidth='100%' m={-8}>
<StatusSection />

<ChannelsSection />

<DepartmentsSection />

<TagsSection />

<AgentsSection />
</Box>
</ResizeObserver>
</Page.ScrollableContentWithShadow>
</Page>
);
};

export default ReportsPage;

0 comments on commit ebab8c4

Please sign in to comment.