diff --git a/.changeset/breezy-rivers-agree.md b/.changeset/breezy-rivers-agree.md new file mode 100644 index 0000000000000..2b1697a07e5e3 --- /dev/null +++ b/.changeset/breezy-rivers-agree.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': patch +'@rocket.chat/meteor': patch +--- + +refactor: Migrate custom-user-status API to standardized AJV format and implement Rule 7 colocation diff --git a/apps/meteor/app/api/server/v1/custom-user-status.ts b/apps/meteor/app/api/server/v1/custom-user-status.ts index 573a8a1a123b0..169baf761a3ff 100644 --- a/apps/meteor/app/api/server/v1/custom-user-status.ts +++ b/apps/meteor/app/api/server/v1/custom-user-status.ts @@ -3,13 +3,13 @@ import { CustomUserStatus } from '@rocket.chat/models'; import { ajv, ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; -import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; - import { deleteCustomUserStatus } from '../../../user-status/server/methods/deleteCustomUserStatus'; import { insertOrUpdateUserStatus } from '../../../user-status/server/methods/insertOrUpdateUserStatus'; import type { ExtractRoutesFromAPI } from '../ApiClass'; -import { API } from '../api'; +// RULE 7: Colocate all endpoint typings and AJV validators within the handler file. +// This removes the dependency on manual definitions in the separate rest-typings package. +import { API } from '../../../../api/server'; import { getPaginationItems } from '../helpers/getPaginationItems'; type CustomUserStatusListProps = PaginatedRequest<{ name?: string; _id?: string; query?: string }>; @@ -48,6 +48,126 @@ const CustomUserStatusListSchema = { const isCustomUserStatusListProps = ajvQuery.compile(CustomUserStatusListSchema); +type CustomUserStatusCreateProps = { name: string; statusType?: string }; + +const CustomUserStatusCreatePropsSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + statusType: { + type: 'string', + }, + }, + required: ['name'], + additionalProperties: false, +}; + +const isCustomUserStatusCreateProps = ajv.compile(CustomUserStatusCreatePropsSchema); + +type CustomUserStatusDeleteProps = { customUserStatusId: string }; + +const CustomUserStatusDeletePropsSchema = { + type: 'object', + properties: { + customUserStatusId: { + type: 'string', + }, + }, + required: ['customUserStatusId'], + additionalProperties: false, +}; + +const isCustomUserStatusDeleteProps = ajv.compile(CustomUserStatusDeletePropsSchema); + +type CustomUserStatusUpdateProps = { _id: string; name?: string; statusType?: string }; + +const CustomUserStatusUpdatePropsSchema = { + type: 'object', + properties: { + _id: { + type: 'string', + }, + name: { + type: 'string', + nullable: true, + }, + statusType: { + type: 'string', + nullable: true, + }, + }, + // Only _id is strictly required for updates; other fields are optional. + required: ['_id'], + additionalProperties: false, +}; + +const isCustomUserStatusUpdateProps = ajv.compile(CustomUserStatusUpdatePropsSchema); + +/** + * @openapi + * /api/v1/custom-user-status.list: + * get: + * description: List custom user statuses + * security: + * - cookieAuth: [] + * - x-user-id: [] + * - x-auth-token: [] + * parameters: + * - in: query + * name: name + * schema: + * type: string + * description: The name of the custom user status + * - in: query + * name: _id + * schema: + * type: string + * description: The ID of the custom user status + * - in: query + * name: query + * schema: + * type: string + * description: The query to filter custom user statuses + * - in: query + * name: count + * schema: + * type: number + * description: The number of items to return + * - in: query + * name: offset + * schema: + * type: number + * description: The number of items to skip + * - in: query + * name: sort + * schema: + * type: string + * description: The sort order + * responses: + * 200: + * description: List of custom user statuses + * content: + * application/json: + * schema: + * type: object + * properties: + * statuses: + * type: array + * items: + * $ref: '#/components/schemas/ICustomUserStatus' + * count: + * type: number + * offset: + * type: number + * total: + * type: number + * success: + * type: boolean + * default: + * $ref: '#/components/schemas/ApiErrorsV1' + */ const customUserStatusEndpoints = API.v1.get( 'custom-user-status.list', { @@ -121,88 +241,237 @@ const customUserStatusEndpoints = API.v1.get( }, ); -API.v1.addRoute( +/** + * @openapi + * /api/v1/custom-user-status.create: + * post: + * description: Create a new custom user status + * security: + * - cookieAuth: [] + * - x-user-id: [] + * - x-auth-token: [] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * statusType: + * type: string + * required: + * - name + * responses: + * 200: + * description: The created custom user status + * content: + * application/json: + * schema: + * type: object + * properties: + * customUserStatus: + * $ref: '#/components/schemas/ICustomUserStatus' + * success: + * type: boolean + * default: + * $ref: '#/components/schemas/ApiErrorsV1' + */ +API.v1.post( 'custom-user-status.create', - { authRequired: true }, { - async post() { - check(this.bodyParams, { - name: String, - statusType: Match.Maybe(String), - }); + authRequired: true, + body: isCustomUserStatusCreateProps, + response: { + 200: ajv.compile<{ customUserStatus: ICustomUserStatus }>({ + type: 'object', + properties: { + customUserStatus: { + $ref: '#/components/schemas/ICustomUserStatus', + }, + success: { + type: 'boolean', + }, + }, + required: ['success', 'customUserStatus'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { name, statusType } = this.bodyParams; - const userStatusData = { - name: this.bodyParams.name, - statusType: this.bodyParams.statusType || '', - }; + const userStatusData = { + name: name || '', + statusType: statusType || '', + }; - await insertOrUpdateUserStatus(this.userId, userStatusData); + await insertOrUpdateUserStatus(this.userId, userStatusData); - const customUserStatus = await CustomUserStatus.findOneByName(userStatusData.name); - if (!customUserStatus) { - throw new Meteor.Error('error-creating-custom-user-status', 'Error creating custom user status'); - } + const customUserStatus = await CustomUserStatus.findOneByName(userStatusData.name); + if (!customUserStatus) { + throw new Meteor.Error('error-creating-custom-user-status', 'Error creating custom user status'); + } - return API.v1.success({ - customUserStatus, - }); - }, + return API.v1.success({ + customUserStatus, + }); }, ); -API.v1.addRoute( +/** + * @openapi + * /api/v1/custom-user-status.delete: + * post: + * description: Delete an existing custom user status + * security: + * - cookieAuth: [] + * - x-user-id: [] + * - x-auth-token: [] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * customUserStatusId: + * type: string + * required: + * - customUserStatusId + * responses: + * 200: + * description: Success status + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * default: + * $ref: '#/components/schemas/ApiErrorsV1' + */ +API.v1.post( 'custom-user-status.delete', - { authRequired: true }, { - async post() { - const { customUserStatusId } = this.bodyParams; - if (!customUserStatusId) { - return API.v1.failure('The "customUserStatusId" params is required!'); - } + authRequired: true, + body: isCustomUserStatusDeleteProps, + response: { + 200: ajv.compile<{ success: boolean }>({ + type: 'object', + properties: { + success: { + type: 'boolean', + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { customUserStatusId } = this.bodyParams; - await deleteCustomUserStatus(this.userId, customUserStatusId); + await deleteCustomUserStatus(this.userId, customUserStatusId); - return API.v1.success(); - }, + // Return success: true as expected by the response schema. + return API.v1.success({}); }, ); -API.v1.addRoute( +/** + * @openapi + * /api/v1/custom-user-status.update: + * post: + * description: Update an existing custom user status + * security: + * - cookieAuth: [] + * - x-user-id: [] + * - x-auth-token: [] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * _id: + * type: string + * name: + * type: string + * statusType: + * type: string + * required: + * - _id + * responses: + * 200: + * description: The updated custom user status + * content: + * application/json: + * schema: + * type: object + * properties: + * customUserStatus: + * $ref: '#/components/schemas/ICustomUserStatus' + * success: + * type: boolean + * default: + * $ref: '#/components/schemas/ApiErrorsV1' + */ +API.v1.post( 'custom-user-status.update', - { authRequired: true }, { - async post() { - check(this.bodyParams, { - _id: String, - name: String, - statusType: Match.Maybe(String), - }); + authRequired: true, + body: isCustomUserStatusUpdateProps, + response: { + 200: ajv.compile<{ customUserStatus: ICustomUserStatus }>({ + type: 'object', + properties: { + customUserStatus: { + $ref: '#/components/schemas/ICustomUserStatus', + }, + success: { + type: 'boolean', + }, + }, + required: ['success', 'customUserStatus'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { _id, name, statusType } = this.bodyParams; - const userStatusData = { - _id: this.bodyParams._id, - name: this.bodyParams.name, - statusType: this.bodyParams.statusType, - }; + const customUserStatusToUpdate = await CustomUserStatus.findOneById(_id); - const customUserStatusToUpdate = await CustomUserStatus.findOneById(userStatusData._id); + if (!customUserStatusToUpdate) { + return API.v1.failure(`No custom user status found with the id of "${_id}".`); + } - // Ensure the message exists - if (!customUserStatusToUpdate) { - return API.v1.failure(`No custom user status found with the id of "${userStatusData._id}".`); - } + const userStatusData = { + _id, + // Use nullish coalescing (??) to correctly handle explicit empty-string inputs + // which would be ignored by a logical OR (||) check. + name: name ?? customUserStatusToUpdate.name, + statusType: statusType ?? customUserStatusToUpdate.statusType, + }; - await insertOrUpdateUserStatus(this.userId, userStatusData); + await insertOrUpdateUserStatus(this.userId, userStatusData); - const customUserStatus = await CustomUserStatus.findOneById(userStatusData._id); + const customUserStatus = await CustomUserStatus.findOneById(_id); - if (!customUserStatus) { - throw new Meteor.Error('error-updating-custom-user-status', 'Error updating custom user status'); - } + if (!customUserStatus) { + throw new Meteor.Error('error-updating-custom-user-status', 'Error updating custom user status'); + } - return API.v1.success({ - customUserStatus, - }); - }, + return API.v1.success({ + customUserStatus, + }); }, ); @@ -210,5 +479,5 @@ export type CustomUserStatusEndpoints = ExtractRoutesFromAPI { - customUserStatus: ICustomUserStatus; - }; - }; - '/v1/custom-user-status.delete': { - POST: (params: { customUserStatusId: string }) => void; - }; - '/v1/custom-user-status.update': { - POST: (params: { _id: string; name?: string; statusType?: string }) => { - customUserStatus: ICustomUserStatus; - }; - }; -};