From 0cfda2143c2ff7f64529f0844926fe67b36828c0 Mon Sep 17 00:00:00 2001 From: Harshal Sewatkar Date: Mon, 2 Mar 2026 07:40:27 +0000 Subject: [PATCH 1/2] chore: migrate user.info endpoint to new OpenAPI pattern with AJV validation --- .changeset/users-info-openapi.md | 5 ++ apps/meteor/app/api/server/v1/users.ts | 120 ++++++++++++++++--------- 2 files changed, 83 insertions(+), 42 deletions(-) create mode 100644 .changeset/users-info-openapi.md diff --git a/.changeset/users-info-openapi.md b/.changeset/users-info-openapi.md new file mode 100644 index 0000000000000..bf393cef0d465 --- /dev/null +++ b/.changeset/users-info-openapi.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +chore: migrate users.info endpoint to new OpenAPI pattern with AJV validation diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index d9e37b1f7b8b2..160a7d1338e6c 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -5,7 +5,6 @@ import { isUserCreateParamsPOST, isUserSetActiveStatusParamsPOST, isUserDeactivateIdleParamsPOST, - isUsersInfoParamsGetProps, isUsersListStatusProps, isUsersSendWelcomeEmailProps, isUserRegisterParamsPOST, @@ -426,52 +425,89 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +const usersInfoEndpoint = API.v1.get( 'users.info', - { authRequired: true, validateParams: isUsersInfoParamsGetProps }, { - async get() { - const searchTerms: [string, 'id' | 'username' | 'importId'] | false = - ('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) || - ('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) || - ('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']); - - if (!searchTerms) { - return API.v1.failure('Invalid search query.'); - } + authRequired: true, + query: ajv.compile< + | { userId: string; username?: never; importId?: never; includeUserRooms?: string } + | { username: string; userId?: never; importId?: never; includeUserRooms?: string } + | { importId: string; userId?: never; username?: never; includeUserRooms?: string } + >({ + anyOf: [ + { + type: 'object', + properties: { + userId: { type: 'string' }, + includeUserRooms: { type: 'string' }, + }, + required: ['userId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + username: { type: 'string' }, + includeUserRooms: { type: 'string' }, + }, + required: ['username'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + importId: { type: 'string' }, + includeUserRooms: { type: 'string' }, + }, + required: ['importId'], + additionalProperties: false, + }, + ], + }), + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ user: IUser; success: true }>({ + type: 'object', + properties: { + user: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const searchTerms: [string, 'id' | 'username' | 'importId'] | false = + ('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) || + ('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) || + ('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']); + + if (!searchTerms) { + return API.v1.failure('Invalid search query.'); + } - const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms); + const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms); - if (!user) { - return API.v1.failure('User not found.'); - } - const myself = user._id === this.userId; - if (this.queryParams.includeUserRooms === 'true' && (myself || (await hasPermissionAsync(this.userId, 'view-other-user-channels')))) { - return API.v1.success({ - user: { - ...user, - rooms: await Subscriptions.findByUserId(user._id, { - projection: { - rid: 1, - name: 1, - t: 1, - roles: 1, - unread: 1, - federated: 1, - }, - sort: { - t: 1, - name: 1, - }, - }).toArray(), - }, - }); - } + if (!user) { + return API.v1.failure('User not found.'); + } + const myself = user._id === this.userId; + if (this.queryParams.includeUserRooms === 'true' && (myself || (await hasPermissionAsync(this.userId, 'view-other-user-channels')))) { return API.v1.success({ - user, + user: { + ...user, + rooms: await Subscriptions.findByUserId(user._id, { + projection: { rid: 1, name: 1, t: 1, roles: 1, unread: 1, federated: 1 }, + sort: { t: 1, name: 1 }, + }).toArray(), + }, }); - }, + } + + return API.v1.success({ user }); }, ); @@ -1555,8 +1591,8 @@ settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { }); type UsersEndpoints = ExtractRoutesFromAPI; - +type UsersInfoEndpoint = ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface - interface Endpoints extends UsersEndpoints {} + interface Endpoints extends UsersEndpoints, UsersInfoEndpoint {} } From f1b6cddb2659725267ea9893908a9aa71d284ebc Mon Sep 17 00:00:00 2001 From: Harshal Sewatkar Date: Mon, 2 Mar 2026 09:13:04 +0000 Subject: [PATCH 2/2] fix: Refactoring user.info --- apps/meteor/app/api/server/v1/users.ts | 248 +++++++++++------- packages/core-typings/src/Ajv.ts | 3 + packages/rest-typings/src/v1/users.ts | 34 +-- .../src/v1/users/UsersInfoParamsGet.ts | 64 ----- 4 files changed, 170 insertions(+), 179 deletions(-) delete mode 100644 packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 160a7d1338e6c..ef88875f73f24 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -1,5 +1,5 @@ import { MeteorError, Team, api, Calendar } from '@rocket.chat/core-services'; -import type { IExportOperation, ILoginToken, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings'; +import type { IExportOperation, ILoginToken, ISubscription, IPersonalAccessToken, IUser, UserStatus } from '@rocket.chat/core-typings'; import { Users, Subscriptions, Sessions } from '@rocket.chat/models'; import { isUserCreateParamsPOST, @@ -425,92 +425,6 @@ API.v1.addRoute( }, ); -const usersInfoEndpoint = API.v1.get( - 'users.info', - { - authRequired: true, - query: ajv.compile< - | { userId: string; username?: never; importId?: never; includeUserRooms?: string } - | { username: string; userId?: never; importId?: never; includeUserRooms?: string } - | { importId: string; userId?: never; username?: never; includeUserRooms?: string } - >({ - anyOf: [ - { - type: 'object', - properties: { - userId: { type: 'string' }, - includeUserRooms: { type: 'string' }, - }, - required: ['userId'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - username: { type: 'string' }, - includeUserRooms: { type: 'string' }, - }, - required: ['username'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - importId: { type: 'string' }, - includeUserRooms: { type: 'string' }, - }, - required: ['importId'], - additionalProperties: false, - }, - ], - }), - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile<{ user: IUser; success: true }>({ - type: 'object', - properties: { - user: { type: 'object' }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['user', 'success'], - additionalProperties: false, - }), - }, - }, - async function action() { - const searchTerms: [string, 'id' | 'username' | 'importId'] | false = - ('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) || - ('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) || - ('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']); - - if (!searchTerms) { - return API.v1.failure('Invalid search query.'); - } - - const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms); - - if (!user) { - return API.v1.failure('User not found.'); - } - - const myself = user._id === this.userId; - if (this.queryParams.includeUserRooms === 'true' && (myself || (await hasPermissionAsync(this.userId, 'view-other-user-channels')))) { - return API.v1.success({ - user: { - ...user, - rooms: await Subscriptions.findByUserId(user._id, { - projection: { rid: 1, name: 1, t: 1, roles: 1, unread: 1, federated: 1 }, - sort: { t: 1, name: 1 }, - }).toArray(), - }, - }); - } - - return API.v1.success({ user }); - }, -); - API.v1.addRoute( 'users.list', { @@ -788,6 +702,69 @@ API.v1.addRoute( }, ); +type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string }) & { + fields?: string; + includeUserRooms?: string; +}; + +const UsersInfoParamsGetSchema = { + anyOf: [ + { + type: 'object', + properties: { + userId: { + type: 'string', + }, + includeUserRooms: { + type: 'string', + }, + fields: { + type: 'string', + nullable: true, + }, + }, + required: ['userId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + username: { + type: 'string', + }, + includeUserRooms: { + type: 'string', + }, + fields: { + type: 'string', + nullable: true, + }, + }, + required: ['username'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + importId: { + type: 'string', + }, + includeUserRooms: { + type: 'string', + }, + fields: { + type: 'string', + nullable: true, + }, + }, + required: ['importId'], + additionalProperties: false, + }, + ], +}; + +const isUsersInfoParamsGetProps = ajv.compile(UsersInfoParamsGetSchema); + const usersEndpoints = API.v1 .post( 'users.createToken', @@ -913,7 +890,99 @@ const usersEndpoints = API.v1 return API.v1.success({ suggestions }); }, - ); + ) + .get( + 'users.info', + { + authRequired: true, + query: isUsersInfoParamsGetProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + + 200: ajv.compile<{ + user: IUser & { rooms?: Pick & { federated?: boolean }[] }; + success: true; + }>({ + type: 'object', + properties: { + user: { + allOf: [ + { $ref: '#/components/schemas/IUser' }, + { + type: 'object', + properties: { + rooms: { + type: 'array', + items: { + type: 'object', + properties: { + rid: { type: 'string' }, + name: { type: 'string' }, + t: { type: 'string' }, + roles: { type: 'array', items: { type: 'string' } }, + unread: { type: 'number' }, + federated: { type: 'boolean' }, + }, + required: ['rid', 't', 'unread', 'name'], + additionalProperties: false, + }, + }, + }, + additionalProperties: true, + }, + ], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const searchTerms: [string, 'id' | 'username' | 'importId'] | false = + ('userId' in this.queryParams && !!this.queryParams.userId && [this.queryParams.userId, 'id']) || + ('username' in this.queryParams && !!this.queryParams.username && [this.queryParams.username, 'username']) || + ('importId' in this.queryParams && !!this.queryParams.importId && [this.queryParams.importId, 'importId']); + + if (!searchTerms) { + return API.v1.failure('Invalid search query.'); + } + + const user = await getFullUserDataByIdOrUsernameOrImportId(this.userId, ...searchTerms); + + if (!user) { + return API.v1.failure('User not found.'); + } + const myself = user._id === this.userId; + if (this.queryParams.includeUserRooms === 'true' && (myself || (await hasPermissionAsync(this.userId, 'view-other-user-channels')))) { + return API.v1.success({ + user: { + ...user, + rooms: await Subscriptions.findByUserId(user._id, { + projection: { + rid: 1, + name: 1, + t: 1, + roles: 1, + unread: 1, + federated: 1, + }, + sort: { + t: 1, + name: 1, + }, + }).toArray(), + }, + }); + } + + return API.v1.success({ + user, + }); + }, +); API.v1.addRoute( 'users.getPreferences', @@ -1591,8 +1660,7 @@ settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { }); type UsersEndpoints = ExtractRoutesFromAPI; -type UsersInfoEndpoint = ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface - interface Endpoints extends UsersEndpoints, UsersInfoEndpoint {} + interface Endpoints extends UsersEndpoints {} } diff --git a/packages/core-typings/src/Ajv.ts b/packages/core-typings/src/Ajv.ts index eb91852edb6de..2501e8cfcaeec 100644 --- a/packages/core-typings/src/Ajv.ts +++ b/packages/core-typings/src/Ajv.ts @@ -11,12 +11,15 @@ import type { ISubscription } from './ISubscription'; import type { SlashCommand } from './SlashCommands'; import type { IMediaCall } from './mediaCalls/IMediaCall'; +import type { IUser } from './IUser'; + export const schemas = typia.json.schemas< [ ISubscription | IInvite | ICustomSound | IMessage | IOAuthApps | IPermission | IMediaCall, CallHistoryItem, ICustomUserStatus, SlashCommand, + IUser, ], '3.0' >(); diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 565620d31ba5e..76ff349b8b687 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -9,7 +9,6 @@ import type { UserLogoutParamsPOST } from './users/UserLogoutParamsPOST'; import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST'; import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST'; import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET'; -import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet'; import type { UsersListStatusParamsGET } from './users/UsersListStatusParamsGET'; import type { UsersListTeamsParamsGET } from './users/UsersListTeamsParamsGET'; import type { UsersSendConfirmationEmailParamsPOST } from './users/UsersSendConfirmationEmailParamsPOST'; @@ -18,26 +17,11 @@ import type { UsersSetPreferencesParamsPOST } from './users/UsersSetPreferencePa import type { UsersUpdateOwnBasicInfoParamsPOST } from './users/UsersUpdateOwnBasicInfoParamsPOST'; import type { UsersUpdateParamsPOST } from './users/UsersUpdateParamsPOST'; -type UsersInfo = { userId?: IUser['_id']; username?: IUser['username'] }; - -const UsersInfoSchema = { - type: 'object', - properties: { - userId: { - type: 'string', - nullable: true, - }, - username: { - type: 'string', - nullable: true, - }, - }, - required: [], - additionalProperties: false, +type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string }) & { + fields?: string; + includeUserRooms?: string; }; -export const isUsersInfoProps = ajv.compile(UsersInfoSchema); - type Users2faSendEmailCode = { emailOrUsername: string }; const Users2faSendEmailCodeSchema = { @@ -271,6 +255,12 @@ export type UsersEndpoints = { }; }; + '/v1/users.info': { + GET: (params: UsersInfoParamsGet) => { + user: IUser & { rooms?: Pick[] }; + }; + }; + '/v1/users.create': { POST: (params: UserCreateParamsPOST) => { user: IUser; @@ -327,11 +317,6 @@ export type UsersEndpoints = { }; }; - '/v1/users.info': { - GET: (params: UsersInfoParamsGet) => { - user: IUser & { rooms?: Pick[] }; - }; - }; '/v1/users.register': { POST: (params: UserRegisterParamsPOST) => { @@ -373,7 +358,6 @@ export type UsersEndpoints = { export * from './users/UserCreateParamsPOST'; export * from './users/UserSetActiveStatusParamsPOST'; export * from './users/UserDeactivateIdleParamsPOST'; -export * from './users/UsersInfoParamsGet'; export * from './users/UsersListStatusParamsGET'; export * from './users/UsersSendWelcomeEmailParamsPOST'; export * from './users/UserRegisterParamsPOST'; diff --git a/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts b/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts deleted file mode 100644 index 47d56a3cc2e19..0000000000000 --- a/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ajv } from '../Ajv'; - -export type UsersInfoParamsGet = ({ userId: string } | { username: string } | { importId: string }) & { - fields?: string; - includeUserRooms?: string; -}; - -const UsersInfoParamsGetSchema = { - anyOf: [ - { - type: 'object', - properties: { - userId: { - type: 'string', - }, - includeUserRooms: { - type: 'string', - }, - fields: { - type: 'string', - nullable: true, - }, - }, - required: ['userId'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - username: { - type: 'string', - }, - includeUserRooms: { - type: 'string', - }, - fields: { - type: 'string', - nullable: true, - }, - }, - required: ['username'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - importId: { - type: 'string', - }, - includeUserRooms: { - type: 'string', - }, - fields: { - type: 'string', - nullable: true, - }, - }, - required: ['importId'], - additionalProperties: false, - }, - ], -}; - -export const isUsersInfoParamsGetProps = ajv.compile(UsersInfoParamsGetSchema);