diff --git a/.changeset/odd-hounds-develop.md b/.changeset/odd-hounds-develop.md new file mode 100644 index 0000000000000..e7bbcf5737fb3 --- /dev/null +++ b/.changeset/odd-hounds-develop.md @@ -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. diff --git a/apps/meteor/app/api/server/v1/stats.ts b/apps/meteor/app/api/server/v1/stats.ts index 27cea2c310574..af357695890a7 100644 --- a/apps/meteor/app/api/server/v1/stats.ts +++ b/apps/meteor/app/api/server/v1/stats.ts @@ -1,15 +1,94 @@ +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 StatisticsProps = { refresh?: 'true' | 'false' }; + +const StatisticsSchema = { + type: 'object', + properties: { + refresh: { + enum: ['true', 'false'], + default: 'false', + }, + }, + required: [], + additionalProperties: false, +}; + +const isStatisticsProps = ajv.compile(StatisticsSchema); + +type StatisticsListProps = { + offset: number; + count?: number; +}; + +const StatisticsListSchema = { + type: 'object', + properties: { + offset: { + type: 'number', + default: 0, + minimum: 0, + }, + count: { + type: 'number', + default: 100, + minimum: 1, + }, + }, + required: [], + additionalProperties: false, +}; + +const isStatisticsListProps = ajv.compile(StatisticsListSchema); + +type OTREnded = { rid: string }; + +type SlashCommand = { command: string }; + +type SettingsCounter = { settingsId: string }; +type Param = { + eventName: TelemetryEvents; + timestamp?: number; +} & (OTREnded | SlashCommand | SettingsCounter); + +type TelemetryPayload = { + params: Param[]; +}; + +const statisticsEndpoints = API.v1 + .get( + 'statistics', + { + authRequired: true, + query: isStatisticsProps, + response: { + 200: ajv.compile({ + 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, @@ -17,14 +96,34 @@ API.v1.addRoute( }), ); }, - }, -); - -API.v1.addRoute( - 'statistics.list', - { authRequired: true }, - { - async get() { + ) + .get( + 'statistics.list', + { + authRequired: true, + query: isStatisticsListProps, + response: { + 200: ajv.compile<{ statistics: IStats[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + statistics: { + type: 'array', + items: { $ref: '#/components/schemas/IStats' }, + minItems: 0, + }, + count: { type: 'integer', minimum: 1 }, + offset: { type: 'integer', minimum: 0, default: 0 }, + total: { type: 'integer', minimum: 1 }, + success: { type: 'boolean', enum: [true] }, + }, + additionalProperties: false, + required: ['statistics', 'count', 'offset', 'total', 'success'], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { offset, count } = await getPaginationItems(this.queryParams); const { sort, fields, query } = await this.parseJsonQuery(); @@ -41,14 +140,86 @@ API.v1.addRoute( }), ); }, - }, -); - -API.v1.addRoute( - 'statistics.telemetry', - { authRequired: true }, - { - post() { + ) + .post( + 'statistics.telemetry', + { + authRequired: true, + body: ajv.compile({ + 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: ajv.compile<{ + error?: string; + errorType?: string; + stack?: string; + details?: string; + }>({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [false] }, + stack: { type: 'string' }, + error: { type: 'string' }, + errorType: { type: 'string' }, + details: { type: 'string' }, + }, + required: ['success'], + additionalProperties: false, + }), + 401: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [false], + }, + status: { type: 'string' }, + message: { type: 'string' }, + error: { type: 'string' }, + errorType: { type: 'string' }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { const events = this.bodyParams; events?.params?.forEach((event) => { @@ -58,5 +229,11 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); + ); + +export type StatisticsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends StatisticsEndpoints {} +} diff --git a/apps/meteor/app/statistics/server/lib/getAppsStatistics.ts b/apps/meteor/app/statistics/server/lib/getAppsStatistics.ts index 90e05d1923563..ce03a66080c9e 100644 --- a/apps/meteor/app/statistics/server/lib/getAppsStatistics.ts +++ b/apps/meteor/app/statistics/server/lib/getAppsStatistics.ts @@ -8,22 +8,22 @@ import { Info } from '../../../utils/rocketchat.info'; type AppsStatistics = { engineVersion: string; - totalInstalled: number | false; - totalActive: number | false; - totalFailed: number | false; - totalPrivateApps: number | false; - totalPrivateAppsEnabled: number | false; + totalInstalled: number; + totalActive: number; + totalFailed: number; + totalPrivateApps: number; + totalPrivateAppsEnabled: number; }; async function _getAppsStatistics(): Promise { if (!Apps.self?.isInitialized()) { return { engineVersion: Info.marketplaceApiVersion, - totalInstalled: false, - totalActive: false, - totalFailed: false, - totalPrivateApps: false, - totalPrivateAppsEnabled: false, + totalInstalled: 0, + totalActive: 0, + totalFailed: 0, + totalPrivateApps: 0, + totalPrivateAppsEnabled: 0, }; } @@ -58,7 +58,6 @@ async function _getAppsStatistics(): Promise { } }), ); - return { engineVersion: Info.marketplaceApiVersion, totalInstalled, @@ -71,11 +70,11 @@ async function _getAppsStatistics(): Promise { SystemLogger.error({ msg: 'Exception while getting Apps statistics', err }); return { engineVersion: Info.marketplaceApiVersion, - totalInstalled: false, - totalActive: false, - totalFailed: false, - totalPrivateApps: false, - totalPrivateAppsEnabled: false, + totalInstalled: 0, + totalActive: 0, + totalFailed: 0, + totalPrivateApps: 0, + totalPrivateAppsEnabled: 0, }; } } diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index 0fadf701d4657..21db8866ecacf 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -221,9 +221,10 @@ export const statistics = { }), ); + // TODO: the routingAlgorithm is duplicated in L202 & L227 // Type of routing algorithm used on omnichannel statistics.routingAlgorithm = settings.get('Livechat_Routing_Method'); - + // TODO: the onHoldEnabled is duplicated in L205 & L230 // is on-hold active statistics.onHoldEnabled = settings.get('Livechat_allow_manual_on_hold'); diff --git a/packages/core-services/src/types/ITelemetryEvent.ts b/packages/core-services/src/types/ITelemetryEvent.ts index d81ee16000535..9b8e598ba343a 100644 --- a/packages/core-services/src/types/ITelemetryEvent.ts +++ b/packages/core-services/src/types/ITelemetryEvent.ts @@ -2,7 +2,6 @@ type updateCounterDataType = { settingsId: string }; type slashCommandsDataType = { command: string }; type otrDataType = { rid: string }; -// TODO this is duplicated from /packages/rest-typings/src/v1/statistics.ts export type TelemetryMap = { otrStats: otrDataType; slashCommandsStats: slashCommandsDataType; updateCounter: updateCounterDataType }; export type TelemetryEvents = keyof TelemetryMap; diff --git a/packages/core-typings/src/Ajv.ts b/packages/core-typings/src/Ajv.ts index 8d828e041b1bd..0d5167afb6809 100644 --- a/packages/core-typings/src/Ajv.ts +++ b/packages/core-typings/src/Ajv.ts @@ -4,7 +4,9 @@ import type { ICustomSound } from './ICustomSound'; import type { IInvite } from './IInvite'; import type { IMessage } from './IMessage'; import type { IOAuthApps } from './IOAuthApps'; +import type { IStats } from './IStats'; import type { IPermission } from './IPermission'; import type { ISubscription } from './ISubscription'; -export const schemas = typia.json.schemas<[ISubscription | IInvite | ICustomSound | IMessage | IOAuthApps | IPermission], '3.0'>(); +export const schemas = typia.json.schemas<[ISubscription | IInvite | ICustomSound | IMessage | IOAuthApps | IPermission | IStats], '3.0'>(); + diff --git a/packages/core-typings/src/IStats.ts b/packages/core-typings/src/IStats.ts index a940be16fd374..45e5261477cc6 100644 --- a/packages/core-typings/src/IStats.ts +++ b/packages/core-typings/src/IStats.ts @@ -67,14 +67,14 @@ export interface IStats { totalDirectMessages: number; totalDiscussionsMessages: number; totalLivechatMessages: number; - totalLivechatRoomsWithPriority: number; + totalLivechatRoomsWithPriority?: number; totalLivechatRoomsWithDepartment: number; totalTriggers: number; totalMessages: number; federatedServers: number; federatedUsers: number; lastLogin: string; - lastMessageSentAt: Date | undefined; + lastMessageSentAt?: Date; lastSeenSubscription: string; os: { type: string; @@ -101,11 +101,12 @@ export interface IStats { uploadsTotalSize: number; fileStoreType: string; migration: { - _id?: string; - locked: boolean; + _id: string; version: number; - buildAt?: string | Date; - lockedAt?: string | Date; + locked: boolean; + hash?: string; + buildAt?: string; + lockedAt?: string; }; instanceCount: number; oplogEnabled: boolean; @@ -123,7 +124,10 @@ export interface IStats { routingAlgorithm: string; onHoldEnabled: boolean; emailInboxes: number; - BusinessHours: { [key: string]: number | string }; + BusinessHours: { + total: number; + strategy: string; + }; lastChattedAgentPreferred: boolean; assignNewConversationsToContactManager: boolean; visitorAbandonment: string; @@ -134,7 +138,7 @@ export interface IStats { voipSuccessfulCalls: number; voipErrorCalls: number; voipOnHoldCalls: number; - federationOverviewData: { + federationOverviewData?: { numberOfEvents: number; numberOfFederatedUsers: number; numberOfServers: number; @@ -152,9 +156,11 @@ export interface IStats { uniqueOSOfLastMonth: OSSessionAggregationResult; apps: { engineVersion: string; - totalInstalled: number | false; - totalActive: number | false; - totalFailed: number | false; + totalInstalled: number; + totalActive: number; + totalFailed: number; + totalPrivateApps: number; + totalPrivateAppsEnabled: number; }; services: Record; importer: Record; @@ -188,7 +194,7 @@ export interface IStats { lastDay?: IVoIPPeriodStats; }; }; - createdAt: Date | string; + createdAt: Date; totalOTR: number; totalOTRRooms: number; slashCommandsJitsi: number; @@ -214,7 +220,7 @@ export interface IStats { onLogoutCustomScriptChanged: boolean; loggedOutCustomScriptChanged: boolean; loggedInCustomScriptChanged: boolean; - roomsInsideTeams: number; + roomsInsideTeams?: number; showHomeButton: boolean; totalEncryptedMessages: number; totalLinkInvitationUses: number; diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 6097656e9e65a..263ce96d263b0 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -39,7 +39,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'; @@ -72,7 +71,6 @@ export interface Endpoints UsersEndpoints, AppsEndpoints, OmnichannelEndpoints, - StatisticsEndpoints, LicensesEndpoints, MiscEndpoints, PresenceEndpoints, diff --git a/packages/rest-typings/src/v1/statistics.ts b/packages/rest-typings/src/v1/statistics.ts deleted file mode 100644 index 9af4954d8d867..0000000000000 --- a/packages/rest-typings/src/v1/statistics.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { IStats } from '@rocket.chat/core-typings'; -import Ajv from 'ajv'; - -import type { PaginatedRequest } from '../helpers/PaginatedRequest'; - -type OTREnded = { rid: string }; - -type SlashCommand = { command: string }; - -type SettingsCounter = { settingsId: string }; - -export type TelemetryMap = { otrStats: OTREnded; slashCommandsStats: SlashCommand; updateCounter: SettingsCounter }; - -export type TelemetryEvents = keyof TelemetryMap; - -type Param = { - eventName: TelemetryEvents; - timestamp?: number; -} & (OTREnded | SlashCommand | SettingsCounter); - -type TelemetryPayload = { - params: Param[]; -}; - -const ajv = new Ajv({ - coerceTypes: true, -}); - -type StatisticsProps = { refresh?: 'true' | 'false' }; - -const StatisticsSchema = { - type: 'object', - properties: { - refresh: { - type: 'string', - nullable: true, - }, - }, - required: [], - additionalProperties: false, -}; - -export const isStatisticsProps = ajv.compile(StatisticsSchema); - -type StatisticsListProps = PaginatedRequest<{ fields?: string }>; - -const StatisticsListSchema = { - type: 'object', - properties: { - fields: { - type: 'string', - nullable: true, - }, - count: { - type: 'number', - nullable: true, - }, - offset: { - type: 'number', - nullable: true, - }, - sort: { - type: 'string', - nullable: true, - }, - query: { - type: 'string', - nullable: true, - }, - }, - required: [], - additionalProperties: false, -}; - -export const isStatisticsListProps = ajv.compile(StatisticsListSchema); - -export type StatisticsEndpoints = { - '/v1/statistics': { - GET: (params: StatisticsProps) => IStats; - }; - '/v1/statistics.list': { - GET: (params: StatisticsListProps) => { - statistics: IStats[]; - count: number; - offset: number; - total: number; - }; - }; - '/v1/statistics.telemetry': { - POST: (params: TelemetryPayload) => any; - }; -};