Skip to content
Closed
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/odd-hounds-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fake-scope/fake-pkg": patch
---

Add OpenAPI support for the Rocket.Chat Statistics API endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation.
146 changes: 123 additions & 23 deletions apps/meteor/app/api/server/v1/stats.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,134 @@
import type { TelemetryEvents } from '@rocket.chat/core-services';
import type { IStats } from '@rocket.chat/core-typings';
import { ajv, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } 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(
'statistics',
{ authRequired: true },
{
async get() {
const { refresh = 'false' } = this.queryParams;
type SlashCommand = { command: string };

type SettingsCounter = { settingsId: string };

type Param = {
eventName: TelemetryEvents;
timestamp?: number;
} & (SlashCommand | SettingsCounter);

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

type StatisticsProps = { refresh?: 'true' | 'false' };

const StatisticsSchema = {
type: 'object',
properties: {
refresh: {
type: 'string',
nullable: true,
},
},
required: [],
additionalProperties: false,
};

const isStatisticsProps = ajv.compile<StatisticsProps>(StatisticsSchema);

const statisticsEndpoints = API.v1
.get(
'statistics',
{
authRequired: true,
query: isStatisticsProps,
response: {
200: ajv.compile<IStats>({
allOf: [
{ $ref: '#/components/schemas/IStats' },
{
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
},
required: ['success'],
},
],
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const { refresh = 'false' } = this.queryParams;
return API.v1.success(
await getLastStatistics({
userId: this.userId,
refresh: refresh === 'true',
}),
);
},
},
);
)
.post(
'statistics.telemetry',
{
authRequired: true,
body: ajv.compile<TelemetryPayload>({
oneOf: [
{
type: 'object',
properties: {
eventName: { const: 'otrStats' },
rid: { type: 'string' },
},
required: ['eventName', 'rid'],
additionalProperties: false,
},
{
type: 'object',
properties: {
eventName: { const: 'slashCommandsStats' },
command: { type: 'string' },
},
required: ['eventName', 'command'],
additionalProperties: false,
},
{
type: 'object',
properties: {
eventName: { const: 'updateCounter' },
settingsId: { type: 'string' },
},
required: ['eventName', 'settingsId'],
additionalProperties: false,
},
],
}),
response: {
200: ajv.compile({
type: 'object',
properties: {
success: { type: 'boolean' },
},
required: ['success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
async function action() {
const events = this.bodyParams;

events?.params?.forEach((event) => {
const { eventName, ...params } = event;
void telemetryEvent.call(eventName, params);
});

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

API.v1.addRoute(
'statistics.list',
Expand All @@ -44,19 +154,9 @@ API.v1.addRoute(
},
);

API.v1.addRoute(
'statistics.telemetry',
{ authRequired: true },
{
post() {
const events = this.bodyParams;
export type StatisticsEndpoints = ExtractRoutesFromAPI<typeof statisticsEndpoints>;

events?.params?.forEach((event) => {
const { eventName, ...params } = event;
void telemetryEvent.call(eventName, params);
});

return API.v1.success();
},
},
);
declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends StatisticsEndpoints {}
}
29 changes: 28 additions & 1 deletion apps/meteor/app/statistics/server/functions/getLastStatistics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { Statistics } from '@rocket.chat/models';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { statistics } from '../lib/statistics';

const formatDate = (date: any): string | undefined => {
if (!date) return undefined;
return (date instanceof Date ? date : new Date(date)).toString();
};

export async function getLastStatistics({ userId, refresh }: { userId: IUser['_id']; refresh?: boolean }) {
if (!(await hasPermissionAsync(userId, 'view-statistics'))) {
throw new Error('error-not-allowed');
Expand All @@ -12,5 +17,27 @@ export async function getLastStatistics({ userId, refresh }: { userId: IUser['_i
if (refresh) {
return statistics.save();
}
return Statistics.findLast();

const stats = await Statistics.findLast();

if (!stats) {
return stats;
}

// Ensure dates are formatted consistently as strings (same as when refresh=true)
// This prevents JSON schema validation errors where Date | string types cause ambiguity
if (stats.migration) {
stats.migration = {
...stats.migration,
buildAt: formatDate(stats.migration.buildAt),
lockedAt: formatDate(stats.migration.lockedAt),
};
}

// Format createdAt if it's a Date object
if (stats.createdAt instanceof Date) {
stats.createdAt = stats.createdAt.toString();
}

return stats;
Comment on lines +20 to +42
Copy link
Copy Markdown
Contributor Author

@ahmed-n-abdeltwab ahmed-n-abdeltwab Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The date props coming from the database are causing AJV validation errors because they aren't formatted as strings. As a work around fix, I’ve added a check in this function to ensure the dates passed to the controller are properly formatted. no edit or change was made in the logic

}
Loading
Loading