Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/refactor-statistics-api-chained-pattern.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Migrated statistics API endpoints (`statistics`, `statistics.list`, `statistics.telemetry`) from legacy `API.v1.addRoute` to the new chained `API.v1.get()`/`API.v1.post()` pattern with OpenAPI-compatible AJV response schemas, query/body validation, and `declare module` augmentation for type-safe route registration.
200 changes: 162 additions & 38 deletions apps/meteor/app/api/server/v1/stats.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,186 @@
import type { IStats } from '@rocket.chat/core-typings';
import {
ajv,
isStatisticsProps,
isStatisticsListProps,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
} from '@rocket.chat/rest-typings';

import { getStatistics, getLastStatistics } from '../../../statistics/server';
import telemetryEvent from '../../../statistics/server/lib/telemetryEvents';
import type { ExtractRoutesFromAPI } from '../ApiClass';
import { API } from '../api';
import { getPaginationItems } from '../helpers/getPaginationItems';

API.v1.addRoute(
const statisticsEndpoints = API.v1.get(
'statistics',
{ authRequired: true },
{
async get() {
const { refresh = 'false' } = this.queryParams;

return API.v1.success(
await getLastStatistics({
userId: this.userId,
refresh: refresh === 'true',
}),
);
authRequired: true,
query: isStatisticsProps,
response: {
200: ajv.compile<IStats>({
type: 'object',
additionalProperties: true,
properties: {
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['success'],
}),
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { refresh = 'false' } = this.queryParams;

return API.v1.success(
await getLastStatistics({
userId: this.userId,
refresh: refresh === 'true',
}),
);
},
);

API.v1.addRoute(
const statisticsListEndpoints = API.v1.get(
'statistics.list',
{ authRequired: true },
{
async get() {
const { offset, count } = await getPaginationItems(this.queryParams);
const { sort, fields, query } = await this.parseJsonQuery();

return API.v1.success(
await getStatistics({
userId: this.userId,
query,
pagination: {
offset,
count,
sort,
fields,
authRequired: true,
query: isStatisticsListProps,
response: {
200: ajv.compile<{
statistics: IStats[];
count: number;
offset: number;
total: number;
}>({
type: 'object',
additionalProperties: false,
properties: {
statistics: {
type: 'array',
items: { type: 'object', additionalProperties: true },
},
count: {
type: 'number',
description: 'The number of statistics items returned in this response.',
},
offset: {
type: 'number',
description: 'The number of statistics items that were skipped in this response.',
},
}),
);
total: {
type: 'number',
description: 'The total number of statistics items that match the query.',
},
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['statistics', 'count', 'offset', 'total', 'success'],
}),
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
},
},
async function action() {
const { offset, count } = await getPaginationItems(this.queryParams as Record<string, string | number | null | undefined>);
const { sort, fields, query } = await this.parseJsonQuery();

return API.v1.success(
await getStatistics({
userId: this.userId,
query,
pagination: {
offset,
count,
sort,
fields,
},
}),
);
},
);

API.v1.addRoute(
type TelemetryParam = {
eventName: string;
timestamp?: number;
[key: string]: unknown;
};

type TelemetryPayload = {
params: TelemetryParam[];
};

const isTelemetryPayload = ajv.compile<TelemetryPayload>({
type: 'object',
properties: {
params: {
type: 'array',
items: {
type: 'object',
properties: {
eventName: { type: 'string' },
timestamp: { type: 'number', nullable: true },
},
required: ['eventName'],
additionalProperties: true,
},
},
},
required: ['params'],
additionalProperties: false,
});

const statisticsTelemetryEndpoints = API.v1.post(
'statistics.telemetry',
{ authRequired: true },
{
post() {
const events = this.bodyParams;
authRequired: true,
validateParams: isTelemetryPayload,
body: isTelemetryPayload,
response: {
200: ajv.compile<Record<string, never>>({
type: 'object',
properties: {
success: {
type: 'boolean',
description: 'Indicates if the request was successful.',
},
},
required: ['success'],
additionalProperties: false,
}),
401: validateUnauthorizedErrorResponse,
},
},
function action() {
const events = this.bodyParams;

events?.params?.forEach((event) => {
const { eventName, ...params } = event;
void telemetryEvent.call(eventName, params);
});
events?.params?.forEach((event: TelemetryParam) => {
const { eventName, ...params } = event;
void telemetryEvent.call(eventName as Parameters<typeof telemetryEvent.call>[0], params as Parameters<typeof telemetryEvent.call>[1]);
});

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

type StatisticsGetEndpoints = ExtractRoutesFromAPI<typeof statisticsEndpoints>;

type StatisticsListGetEndpoints = ExtractRoutesFromAPI<typeof statisticsListEndpoints>;

type StatisticsTelemetryPostEndpoints = ExtractRoutesFromAPI<typeof statisticsTelemetryEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends StatisticsGetEndpoints {}
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends StatisticsListGetEndpoints {}
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends StatisticsTelemetryPostEndpoints {}
}
3 changes: 1 addition & 2 deletions packages/rest-typings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ import type { RolesEndpoints } from './v1/roles';
import type { RoomsEndpoints } from './v1/rooms';
import type { ServerEventsEndpoints } from './v1/server-events';
import type { SettingsEndpoints } from './v1/settings';
import type { StatisticsEndpoints } from './v1/statistics';
import type { SubscriptionsEndpoints } from './v1/subscriptionsEndpoints';
import type { TeamsEndpoints } from './v1/teams';
import type { UsersEndpoints } from './v1/users';
Expand Down Expand Up @@ -69,7 +68,6 @@ export interface Endpoints
UsersEndpoints,
AppsEndpoints,
OmnichannelEndpoints,
StatisticsEndpoints,
LicensesEndpoints,
MiscEndpoints,
PresenceEndpoints,
Expand Down Expand Up @@ -255,6 +253,7 @@ export * from './v1/chat';
export * from './v1/auth';
export * from './v1/cloud';
export * from './v1/banners';
export * from './v1/statistics';
export * from './default';

// Export the ajv instance for use in other packages
Expand Down
Loading