From 7a1db2a21895a82fe176636043d3a09de91da44b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 14 Apr 2026 23:58:38 -0300 Subject: [PATCH 01/14] chore: upgrade Ajv to 2020-12 and update core/rest typings Upgrade ajv from draft-07 to 2020-12 for unevaluatedProperties support. Add IMeApiUser type, update ISubscription, IIntegration, IVideoConference, IRoom types. Update rest-typings schemas and add user endpoint validators. Fix client-side type adjustments for new typings. --- .../client/lib/chats/readStateManager.ts | 4 +- apps/meteor/client/lib/userData.ts | 52 ++++++++++---- .../lib/utils/mapSubscriptionFromApi.ts | 4 +- packages/core-typings/src/Ajv.ts | 11 ++- packages/core-typings/src/IIntegration.ts | 8 +-- packages/core-typings/src/IMeApiUser.ts | 70 +++++++++++++++++++ .../MessageAttachmentDefault.ts | 6 ++ .../MessageQuoteAttachment.ts | 4 +- packages/core-typings/src/IRoom.ts | 2 + packages/core-typings/src/ISubscription.ts | 4 +- packages/core-typings/src/IVideoConference.ts | 7 +- packages/core-typings/src/index.ts | 1 + .../model-typings/src/models/IRoomsModel.ts | 12 ++-- packages/models/src/models/Rooms.ts | 12 ++-- packages/rest-typings/src/default/index.ts | 4 +- packages/rest-typings/src/index.ts | 9 +++ packages/rest-typings/src/v1/Ajv.ts | 12 ++-- packages/rest-typings/src/v1/chat.ts | 2 +- packages/rest-typings/src/v1/commands.ts | 15 +--- packages/rest-typings/src/v1/me.ts | 11 +-- .../src/v1/me/meSuccessResponse.ts | 19 +++++ packages/rest-typings/src/v1/omnichannel.ts | 2 +- packages/rest-typings/src/v1/push.ts | 13 ++-- packages/rest-typings/src/v1/rooms.ts | 52 ++++++++++---- packages/rest-typings/src/v1/statistics.ts | 21 ++++++ .../src/v1/users/UsersGetAvatarParamsGET.ts | 19 +++++ .../src/v1/users/UsersGetPresenceParamsGET.ts | 19 +++++ .../src/v1/users/UsersGetStatusParamsGET.ts | 19 +++++ .../src/v1/users/UsersPresenceParamsGET.ts | 19 +++++ .../UsersRequestDataDownloadParamsGET.ts | 17 +++++ 30 files changed, 354 insertions(+), 96 deletions(-) create mode 100644 packages/core-typings/src/IMeApiUser.ts create mode 100644 packages/rest-typings/src/v1/me/meSuccessResponse.ts create mode 100644 packages/rest-typings/src/v1/users/UsersGetAvatarParamsGET.ts create mode 100644 packages/rest-typings/src/v1/users/UsersGetPresenceParamsGET.ts create mode 100644 packages/rest-typings/src/v1/users/UsersGetStatusParamsGET.ts create mode 100644 packages/rest-typings/src/v1/users/UsersPresenceParamsGET.ts create mode 100644 packages/rest-typings/src/v1/users/UsersRequestDataDownloadParamsGET.ts diff --git a/apps/meteor/client/lib/chats/readStateManager.ts b/apps/meteor/client/lib/chats/readStateManager.ts index 4827ecdc97ba7..f464093b79476 100644 --- a/apps/meteor/client/lib/chats/readStateManager.ts +++ b/apps/meteor/client/lib/chats/readStateManager.ts @@ -76,7 +76,9 @@ export class ReadStateManager extends Emitter { const firstUnreadRecord = Messages.state.findFirst( (record) => - record.rid === this.subscription?.rid && record.ts.getTime() > this.subscription.ls.getTime() && record.u._id !== getUserId(), + record.rid === this.subscription?.rid && + record.ts.getTime() > (this.subscription.ls?.getTime() ?? 0) && + record.u._id !== getUserId(), (a, b) => a.ts.getTime() - b.ts.getTime(), ); diff --git a/apps/meteor/client/lib/userData.ts b/apps/meteor/client/lib/userData.ts index 2b768bad7a597..9ae32b29dfca5 100644 --- a/apps/meteor/client/lib/userData.ts +++ b/apps/meteor/client/lib/userData.ts @@ -64,7 +64,6 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise { switch (data.type) { case 'inserted': { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { type, id, ...user } = data; Users.state.store(user.data); break; @@ -85,13 +84,40 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise; + + const mergedEmail2fa = + email2fa && + (() => { + const { changedAt: apiChangedAt, ...email2faRest } = email2fa; + let changedAt: Date | undefined; + if (apiChangedAt != null) { + const parsed = new Date(apiChangedAt as string | number | Date); + if (!Number.isNaN(parsed.getTime())) { + changedAt = parsed; + } + } + if (changedAt == null && existingUser?.services?.email2fa?.changedAt != null) { + const prev = existingUser.services.email2fa.changedAt; + changedAt = prev instanceof Date ? prev : new Date(prev); + } + return { + ...email2faRest, + ...(changedAt != null ? { changedAt } : {}), + }; + })(); updateUser({ - ...userData, + type: existingUser?.type ?? 'user', + active: existingUser?.active ?? true, + roles: existingUser?.roles ?? [], + ...existingUser, + ...meFields, ...(rawServices && { services: { ...(services ? { ...services } : {}), @@ -101,8 +127,8 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise ({ ...token, - when: new Date('when' in token ? token.when : ''), - createdAt: ('createdAt' in token ? new Date(token.createdAt) : undefined) as Date, + when: new Date('when' in token && token.when ? token.when : 0), + createdAt: 'createdAt' in token && token.createdAt ? new Date(token.createdAt) : new Date(0), twoFactorAuthorizedUntil: token.twoFactorAuthorizedUntil ? new Date(token.twoFactorAuthorizedUntil) : undefined, })), }), @@ -118,7 +144,7 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise ({ @@ -133,13 +159,13 @@ export const synchronizeUserData = async (uid: IUser['_id']): Promise { diff --git a/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts b/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts index 358efaef0e9c6..fb8fbe581626b 100644 --- a/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts +++ b/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts @@ -12,8 +12,8 @@ export const mapSubscriptionFromApi = ({ }: Serialized): ISubscription => ({ ...subscription, ts: new Date(ts), - ls: new Date(ls), - lr: new Date(lr), + ...(ls && { ls: new Date(ls) }), + ...(lr && { lr: new Date(lr) }), _updatedAt: new Date(_updatedAt), ...(abacLastTimeChecked && { abacLastTimeChecked: new Date(abacLastTimeChecked) }), ...(oldRoomKeys && { oldRoomKeys: oldRoomKeys.map(({ ts, ...key }) => ({ ...key, ts: new Date(ts) })) }), diff --git a/packages/core-typings/src/Ajv.ts b/packages/core-typings/src/Ajv.ts index e33f94e99e2ae..b910c9aa2f9ac 100644 --- a/packages/core-typings/src/Ajv.ts +++ b/packages/core-typings/src/Ajv.ts @@ -7,13 +7,17 @@ import type { CloudConfirmationPollData, CloudRegistrationIntentData, CloudRegis import type { ICustomSound } from './ICustomSound'; import type { ICustomUserStatus } from './ICustomUserStatus'; import type { IEmailInbox } from './IEmailInbox'; +import type { IEmojiCustom } from './IEmojiCustom'; +import type { IIntegration } from './IIntegration'; +import type { IIntegrationHistory } from './IIntegrationHistory'; import type { IInvite } from './IInvite'; +import type { IMeApiUser } from './IMeApiUser'; import type { IMessage } from './IMessage'; import type { IModerationAudit, IModerationReport } from './IModerationReport'; import type { IOAuthApps } from './IOAuthApps'; import type { IPermission } from './IPermission'; import type { IRole } from './IRole'; -import type { IRoom, IDirectoryChannelResult } from './IRoom'; +import type { IRoom, IDirectoryChannelResult, IRoomAdmin } from './IRoom'; import type { ISubscription } from './ISubscription'; import type { IUser, IDirectoryUserResult } from './IUser'; import type { VideoConference, VideoConferenceInstructions } from './IVideoConference'; @@ -28,6 +32,7 @@ export const schemas = typia.json.schemas< | ISubscription | IInvite | ICustomSound + | IEmojiCustom | IMessage | IOAuthApps | IPermission @@ -37,6 +42,7 @@ export const schemas = typia.json.schemas< | ICalendarEvent | IRole | IRoom + | IRoomAdmin | IDirectoryChannelResult | IUser | IDirectoryUserResult @@ -49,6 +55,9 @@ export const schemas = typia.json.schemas< | IModerationAudit | IModerationReport | IBanner + | IIntegration + | IIntegrationHistory + | IMeApiUser ), CallHistoryItem, ICustomUserStatus, diff --git a/packages/core-typings/src/IIntegration.ts b/packages/core-typings/src/IIntegration.ts index 22fa520818757..589d4e66478fc 100644 --- a/packages/core-typings/src/IIntegration.ts +++ b/packages/core-typings/src/IIntegration.ts @@ -11,9 +11,9 @@ export interface IIncomingIntegration extends IRocketChatRecord { username: string; channel: string[]; - token: string; + token?: string; scriptEnabled: boolean; - script: string; + script?: string; scriptCompiled?: string; scriptError?: Pick; @@ -50,10 +50,10 @@ export interface IOutgoingIntegration extends IRocketChatRecord { urls?: string[]; triggerWords?: string[]; triggerWordAnywhere?: boolean; - token: string; + token?: string; scriptEnabled: boolean; - script: string; + script?: string; scriptCompiled?: string; scriptError?: Pick; runOnEdits?: boolean; diff --git a/packages/core-typings/src/IMeApiUser.ts b/packages/core-typings/src/IMeApiUser.ts new file mode 100644 index 0000000000000..e51b1863312f4 --- /dev/null +++ b/packages/core-typings/src/IMeApiUser.ts @@ -0,0 +1,70 @@ +import type { IUser, IUserCalendar } from './IUser'; + +/** + * Public service fields exposed by getUserInfo (password hash is never leaked). + */ +export type IMeApiUserServices = { + github?: Record; + gitlab?: Record; + email2fa?: { enabled: boolean; changedAt?: Date }; + totp?: { enabled: boolean }; + password: { exists: boolean }; + email?: { verificationTokens?: Array<{ token: string; address: string; when: Date }> }; + cloud?: { accessToken?: string; refreshToken?: string; expiresAt: Date }; + resume?: { loginTokens?: Array> }; + emailCode?: Array<{ code: string; expire: Date }>; +}; + +/** + * User document fields projected by getBaseUserFields() plus full `services` (e.g. GET /api/v1/me), + * after getUserInfo() reshapes `settings`, `services`, `email`, and adds `avatarUrl` / `isOAuthUser`. + */ +type MeProjectedUserFields = Pick< + IUser, + | 'name' + | 'username' + | 'nickname' + | 'emails' + | 'status' + | 'statusDefault' + | 'statusText' + | 'statusConnection' + | 'bio' + | 'avatarOrigin' + | 'utcOffset' + | 'language' + | 'roles' + | 'active' + | 'defaultRoom' + | 'customFields' + | 'requirePasswordChange' + | 'requirePasswordChangeReason' + | 'banners' + | '_updatedAt' + | 'avatarETag' + | 'abacAttributes' + | 'oauth' + | 'createdAt' + | 'lastLogin' + | 'ldap' +>; + +export type IMeApiUser = { _id: IUser['_id'] } & Partial & { + avatarUrl: string; + isOAuthUser: boolean; + settings: { + profile: Record; + preferences?: Record; + calendar: IUserCalendar; + }; + email?: string; + services?: IMeApiUserServices; + /** Present when projected via getBaseUserFields (not on IUser). */ + enableAutoAway?: boolean; + /** Present when projected via getBaseUserFields (not on IUser). */ + idleTimeLimit?: number; + /** Present when projected via getBaseUserFields (not on IUser). */ + statusLivechat?: string; + /** Present when projected via getBaseUserFields (not on IUser). */ + openBusinessHours?: string[]; + }; diff --git a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts index 576e92985ba96..395c5f437962c 100644 --- a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts +++ b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentDefault.ts @@ -1,5 +1,6 @@ import type { Root } from '@rocket.chat/message-parser'; +import type { MessageAttachment } from './MessageAttachment'; import type { MessageAttachmentBase } from './MessageAttachmentBase'; export type MarkdownFields = 'text' | 'pretext' | 'fields'; @@ -32,4 +33,9 @@ export type MessageAttachmentDefault = { thumb_url?: string; color?: string; + + attachments?: MessageAttachment[]; + + /** Encrypted content from e2e messages, preserved in pin attachments */ + content?: object; // TODO: check if MessageAttachmentDefault[content] is a valid type it does not seem to be used anywhere } & MessageAttachmentBase; diff --git a/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts b/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts index 237e8db4abb7a..03c1fcb9cb5e5 100644 --- a/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts +++ b/packages/core-typings/src/IMessage/MessageAttachment/MessageQuoteAttachment.ts @@ -5,9 +5,9 @@ import type { MessageAttachmentBase } from './MessageAttachmentBase'; export type MessageQuoteAttachment = { author_name: string; - author_link: string; + author_link?: string; author_icon: string; - message_link?: string; + message_link: string; text: string; md?: Root; attachments?: Array; // TODO this is causing issues to define a model, see @ts-expect-error at apps/meteor/app/api/server/v1/channels.ts:274 diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 10dcf5cd6211a..278bd66c23ab7 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -386,6 +386,8 @@ export type RoomAdminFieldsType = | 'avatarETag' | 'abacAttributes'; +export type IRoomAdmin = Pick; + export interface IRoomWithRetentionPolicy extends IRoom { retention: { enabled?: boolean; diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index 5c1e7204442b5..f2db5a8c49811 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -20,9 +20,9 @@ export interface ISubscription extends IRocketChatRecord { alert?: boolean; unread: number; t: RoomType; - ls: Date; + ls?: Date; f?: boolean; - lr: Date; + lr?: Date; hideUnreadStatus?: true; hideMentionStatus?: true; teamMain?: boolean; diff --git a/packages/core-typings/src/IVideoConference.ts b/packages/core-typings/src/IVideoConference.ts index 34c68c0742046..74cb7fc04ee63 100644 --- a/packages/core-typings/src/IVideoConference.ts +++ b/packages/core-typings/src/IVideoConference.ts @@ -52,11 +52,8 @@ export type LivechatInstructions = { export type VideoConferenceType = DirectCallInstructions['type'] | ConferenceInstructions['type'] | LivechatInstructions['type'] | 'voip'; -type Nullable = { - [P in K]: T[P] | null; -} & Omit; - -export interface IVideoConferenceUser extends Nullable, '_id' | 'username' | 'name' | 'avatarETag'>, 'avatarETag'> { +export interface IVideoConferenceUser extends Pick, '_id' | 'username' | 'name'> { + avatarETag: string | null; ts: Date; } diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 35ea69ad055eb..90dd9c4e638a8 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -36,6 +36,7 @@ export type * from './IUserDataFile'; export type * from './IUserSession'; export type * from './IUserStatus'; export * from './IUser'; +export type * from './IMeApiUser'; export type * from './ee/IAuditLog'; export type * from './ee/IWorkspaceCredentials'; diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 736ccc811c403..513682dd571a6 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -26,7 +26,7 @@ import type { FindPaginated, IBaseModel } from './IBaseModel'; export interface IChannelsWithNumberOfMessagesBetweenDate { room: { _id: IRoom['_id']; - name: IRoom['name'] | IRoom['fname']; + name: IRoom['name']; ts: IRoom['ts']; t: IRoom['t']; _updatedAt: IRoom['_updatedAt']; @@ -84,7 +84,7 @@ export interface IRoomsModel extends IBaseModel { findByTeamIdAndRoomsId(teamId: ITeam['_id'], rids: Array, options?: FindOptions): FindCursor; - findRoomsByNameOrFnameStarting(name: NonNullable, options?: FindOptions): FindCursor; + findRoomsByNameOrFnameStarting(name: NonNullable, options?: FindOptions): FindCursor; findRoomsWithoutDiscussionsByRoomIds( name: NonNullable, @@ -99,7 +99,7 @@ export interface IRoomsModel extends IBaseModel { ): FindPaginated>; findChannelAndGroupListWithoutTeamsByNameStartingByOwner( - name: NonNullable, + name: IRoom['name'], groupsToAccept: Array, options?: FindOptions, ): FindCursor; @@ -136,11 +136,11 @@ export interface IRoomsModel extends IBaseModel { incUsersCountByIds(ids: Array, inc: number, options?: UpdateOptions): Promise; - findOneByNameOrFname(name: NonNullable, options?: FindOptions): Promise; + findOneByNameOrFname(name: NonNullable, options?: FindOptions): Promise; findOneByJoinCodeAndId(joinCode: string, rid: IRoom['_id'], options?: FindOptions): Promise; - findOneByNonValidatedName(name: NonNullable, options?: FindOptions): Promise; + findOneByNonValidatedName(name: NonNullable, options?: FindOptions): Promise; allRoomSourcesCount(): AggregationCursor<{ _id: Required; count: number }>; @@ -286,7 +286,7 @@ export interface IRoomsModel extends IBaseModel { replaceUsernameOfUserByUserId(userId: string, newUsername: string): Promise; setJoinCodeById(rid: string, joinCode: string): Promise; setTypeById(rid: string, type: IRoom['t']): Promise; - setTopicById(rid: string, topic?: string | undefined): Promise; + setTopicById(rid: string, topic?: string): Promise; setAnnouncementById( rid: string, announcement: IRoom['announcement'], diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index f2a1d07a7e552..9e0fbdd3c29e2 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -314,7 +314,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.find(query, options); } - findRoomsByNameOrFnameStarting(name: NonNullable, options: FindOptions = {}): FindCursor { + findRoomsByNameOrFnameStarting(name: NonNullable, options: FindOptions = {}): FindCursor { const nameRegex = new RegExp(`^${escapeRegExp(name).trim()}`, 'i'); const query: Filter = { @@ -408,11 +408,11 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { } findChannelAndGroupListWithoutTeamsByNameStartingByOwner( - name: NonNullable, + name: IRoom['name'], groupsToAccept: Array, options: FindOptions = {}, ): FindCursor { - const nameRegex = new RegExp(`^${escapeRegExp(name).trim()}`, 'i'); + const nameRegex = name && new RegExp(`^${escapeRegExp(name).trim()}`, 'i'); const query: Filter = { teamId: { @@ -424,7 +424,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { _id: { $in: groupsToAccept, }, - name: nameRegex, + ...(name && { name: nameRegex }), $and: [{ $or: [{ federated: { $exists: false } }, { federated: false }] }], }; return this.find(query, options); @@ -610,7 +610,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { }); } - findOneByNameOrFname(name: NonNullable, options: FindOptions = {}): Promise { + findOneByNameOrFname(name: NonNullable, options: FindOptions = {}): Promise { const query = { $or: [ { @@ -634,7 +634,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.findOne(query, options); } - async findOneByNonValidatedName(name: NonNullable, options: FindOptions = {}) { + async findOneByNonValidatedName(name: NonNullable, options: FindOptions = {}) { const room = await this.findOneByNameOrFname(name, options); if (room) { return room; diff --git a/packages/rest-typings/src/default/index.ts b/packages/rest-typings/src/default/index.ts index 2ea5fcb3bf323..daf19c327e2ce 100644 --- a/packages/rest-typings/src/default/index.ts +++ b/packages/rest-typings/src/default/index.ts @@ -1,4 +1,4 @@ -import { ajv } from '../v1/Ajv'; +import { ajvQuery } from '../v1/Ajv'; type OpenAPIJSONEndpoint = { withUndocumented?: boolean }; @@ -14,7 +14,7 @@ const OpenAPIJSONEndpointSchema = { additionalProperties: false, }; -export const isOpenAPIJSONEndpoint = ajv.compile(OpenAPIJSONEndpointSchema); +export const isOpenAPIJSONEndpoint = ajvQuery.compile(OpenAPIJSONEndpointSchema); // eslint-disable-next-line @typescript-eslint/naming-convention export interface DefaultEndpoints { diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index deb8359b1f896..dca6dc2505404 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -214,6 +214,7 @@ export * from './v1/customSounds'; export type * from './v1/customUserStatus'; export * from './v1/subscriptionsEndpoints'; export type * from './v1/mailer'; +export * from './v1/me/meSuccessResponse'; export * from './v1/mailer/MailerParamsPOST'; export * from './v1/mailer/MailerUnsubscribeParamsPOST'; export * from './v1/misc'; @@ -223,6 +224,7 @@ export * from './v1/dm/DmHistoryProps'; export * from './v1/integrations'; export * from './v1/licenses'; export * from './v1/omnichannel'; +export * from './v1/push'; export type * from './helpers/IGetRoomRoles'; export type * from './helpers/PaginatedRequest'; export type * from './helpers/PaginatedResult'; @@ -238,8 +240,15 @@ export * from './v1/users/UsersUpdateOwnBasicInfoParamsPOST'; export * from './v1/users/UsersUpdateParamsPOST'; export * from './v1/users/UsersCheckUsernameAvailabilityParamsGET'; export * from './v1/users/UsersSendConfirmationEmailParamsPOST'; +export * from './v1/users/UsersGetAvatarParamsGET'; +export * from './v1/users/UsersListParamsGET'; +export * from './v1/users/UsersPresenceParamsGET'; +export * from './v1/users/UsersRequestDataDownloadParamsGET'; +export * from './v1/users/UsersGetPresenceParamsGET'; +export * from './v1/users/UsersGetStatusParamsGET'; export * from './v1/moderation'; export * from './v1/server-events'; +export * from './v1/statistics'; export * from './v1/autotranslate/AutotranslateGetSupportedLanguagesParamsGET'; export * from './v1/autotranslate/AutotranslateSaveSettingsParamsPOST'; diff --git a/packages/rest-typings/src/v1/Ajv.ts b/packages/rest-typings/src/v1/Ajv.ts index db410b91baa5f..b11f10d712525 100644 --- a/packages/rest-typings/src/v1/Ajv.ts +++ b/packages/rest-typings/src/v1/Ajv.ts @@ -1,4 +1,4 @@ -import Ajv from 'ajv'; +import Ajv from 'ajv/dist/2020'; import addFormats from 'ajv-formats'; const ajv = new Ajv({ @@ -8,9 +8,9 @@ const ajv = new Ajv({ discriminator: true, }); -/** AJV instance for query param validation; coerces types (e.g. string "50" → number) for URL query strings. */ +/** AJV instance for query param validation; coerces types (e.g. string "50" → number, "c" → ["c"]) for URL query strings. */ const ajvQuery = new Ajv({ - coerceTypes: true, + coerceTypes: 'array', allowUnionTypes: true, code: { source: true }, discriminator: true, @@ -44,10 +44,10 @@ export { ajv, ajvQuery }; type BadRequestErrorResponse = { success: false; - error?: string; + error?: unknown; errorType?: string; stack?: string; - details?: string | object; + details?: string | object | object[]; }; const BadRequestErrorResponseSchema = { @@ -57,7 +57,7 @@ const BadRequestErrorResponseSchema = { stack: { type: 'string' }, error: { type: 'string' }, errorType: { type: 'string' }, - details: { anyOf: [{ type: 'string' }, { type: 'object' }] }, + details: { anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }] }, }, required: ['success'], additionalProperties: false, diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index f3a6e61f677b0..8e8d52479e504 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -974,7 +974,7 @@ export type ChatEndpoints = { GET: (params: ChatSyncMessages) => { result: { updated: IMessage[]; - deleted: IMessage[]; + deleted: { _id: IMessage['_id']; _deletedAt: string }[]; cursor: { next: string | null; previous: string | null; diff --git a/packages/rest-typings/src/v1/commands.ts b/packages/rest-typings/src/v1/commands.ts index 7476dafa88db5..9d46105cac7f7 100644 --- a/packages/rest-typings/src/v1/commands.ts +++ b/packages/rest-typings/src/v1/commands.ts @@ -1,19 +1,6 @@ -import type { SlashCommand, SlashCommandPreviews } from '@rocket.chat/core-typings'; - -import type { PaginatedRequest } from '../helpers/PaginatedRequest'; -import type { PaginatedResult } from '../helpers/PaginatedResult'; +import type { SlashCommandPreviews } from '@rocket.chat/core-typings'; export type CommandsEndpoints = { - '/v1/commands.list': { - GET: ( - params?: PaginatedRequest<{ - fields?: string; - }>, - ) => PaginatedResult<{ - appsLoaded: boolean; - commands: Pick[]; - }>; - }; '/v1/commands.run': { POST: (params: { command: string; params?: string; roomId: string; tmid?: string; triggerId: string }) => { result: unknown; diff --git a/packages/rest-typings/src/v1/me.ts b/packages/rest-typings/src/v1/me.ts index f9ca62460d74f..e5573ccaa7da1 100644 --- a/packages/rest-typings/src/v1/me.ts +++ b/packages/rest-typings/src/v1/me.ts @@ -1,5 +1,7 @@ import type { IUser } from '@rocket.chat/core-typings'; +import type { MeApiSuccessResponse } from './me/meSuccessResponse'; + type Keys = | 'name' | 'username' @@ -34,13 +36,6 @@ type Keys = export type MeEndpoints = { '/v1/me': { - GET: (params?: { fields: Record | Record; user: IUser }) => IUser & { - email?: string; - settings?: { - profile: Record; - preferences: unknown; - }; - avatarUrl: string; - }; + GET: (params?: { fields: Record | Record; user: IUser }) => MeApiSuccessResponse; }; }; diff --git a/packages/rest-typings/src/v1/me/meSuccessResponse.ts b/packages/rest-typings/src/v1/me/meSuccessResponse.ts new file mode 100644 index 0000000000000..25b6f2d4c8dba --- /dev/null +++ b/packages/rest-typings/src/v1/me/meSuccessResponse.ts @@ -0,0 +1,19 @@ +import type { IMeApiUser } from '@rocket.chat/core-typings'; + +/** + * GET /api/v1/me success body: {@link IMeApiUser} flattened with `success: true` (API.v1.success). + * + * NOTE: This schema uses $ref to IMeApiUser which is registered at runtime via ajv.addSchema(). + * Do NOT compile this schema here — it must be compiled in the route file (misc.ts) where + * the $ref schemas are already registered. + */ +export type MeApiSuccessResponse = IMeApiUser & { success: true }; + +export const meSuccessResponseSchema = { + type: 'object', + allOf: [ + { $ref: '#/components/schemas/IMeApiUser' }, + { type: 'object', properties: { success: { type: 'boolean', enum: [true] } }, required: ['success'] }, + ], + unevaluatedProperties: false, +}; diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index fe5988d3583c1..14069df7b8c37 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -3484,7 +3484,7 @@ const GETLivechatRoomsParamsSchema = { nullable: true, }, departmentId: { - oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], }, open: { anyOf: [ diff --git a/packages/rest-typings/src/v1/push.ts b/packages/rest-typings/src/v1/push.ts index f09cfc892a611..d74a632a99f08 100644 --- a/packages/rest-typings/src/v1/push.ts +++ b/packages/rest-typings/src/v1/push.ts @@ -1,6 +1,6 @@ -import type { IMessage, IPushNotificationConfig, IPushTokenTypes } from '@rocket.chat/core-typings'; +import type { IPushTokenTypes } from '@rocket.chat/core-typings'; -import { ajv } from './Ajv'; +import { ajv, ajvQuery } from './Ajv'; type PushTokenProps = { id?: string; @@ -47,21 +47,20 @@ const PushGetPropsSchema = { additionalProperties: false, }; -export const isPushGetProps = ajv.compile(PushGetPropsSchema); +export const isPushGetProps = ajvQuery.compile(PushGetPropsSchema); export type PushEndpoints = { '/v1/push.get': { GET: (params: PushGetProps) => { - data: { - message: IMessage; - notification: IPushNotificationConfig; - }; + data: Record; + success: true; }; }; '/v1/push.info': { GET: () => { pushGatewayEnabled: boolean; defaultPushGateway: boolean; + success: true; }; }; }; diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index 273cc76869d7f..d2141f8bf69e6 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -8,6 +8,7 @@ import type { ITeam, ISubscription, RequiredField, + MessageTypesValues, } from '@rocket.chat/core-typings'; import { ajv, ajvQuery } from './Ajv'; @@ -27,7 +28,7 @@ const RoomsAutoCompleteChannelAndPrivateSchema = { additionalProperties: false, }; -export const isRoomsAutoCompleteChannelAndPrivateProps = ajv.compile( +export const isRoomsAutoCompleteChannelAndPrivateProps = ajvQuery.compile( RoomsAutoCompleteChannelAndPrivateSchema, ); @@ -60,11 +61,10 @@ const RoomsAutocompleteChannelAndPrivateWithPaginationSchema = { additionalProperties: false, }; -export const isRoomsAutocompleteChannelAndPrivateWithPaginationProps = ajv.compile( - RoomsAutocompleteChannelAndPrivateWithPaginationSchema, -); +export const isRoomsAutocompleteChannelAndPrivateWithPaginationProps = + ajvQuery.compile(RoomsAutocompleteChannelAndPrivateWithPaginationSchema); -type RoomsAutocompleteAvailableForTeamsProps = { name: string }; +type RoomsAutocompleteAvailableForTeamsProps = { name?: string }; const RoomsAutocompleteAvailableForTeamsSchema = { type: 'object', @@ -73,11 +73,11 @@ const RoomsAutocompleteAvailableForTeamsSchema = { type: 'string', }, }, - required: ['name'], + required: [], additionalProperties: false, }; -export const isRoomsAutocompleteAvailableForTeamsProps = ajv.compile( +export const isRoomsAutocompleteAvailableForTeamsProps = ajvQuery.compile( RoomsAutocompleteAvailableForTeamsSchema, ); @@ -94,7 +94,7 @@ const RoomsAutocompleteAdminRoomsPayloadSchema = { additionalProperties: false, }; -export const isRoomsAutocompleteAdminRoomsPayload = ajv.compile( +export const isRoomsAutocompleteAdminRoomsPayload = ajvQuery.compile( RoomsAutocompleteAdminRoomsPayloadSchema, ); @@ -152,6 +152,10 @@ const RoomsCreateDiscussionSchema = { type: 'string', nullable: true, }, + topic: { + type: 'string', + nullable: true, + }, users: { type: 'array', items: { @@ -272,9 +276,11 @@ const RoomsExportSchema = { export const isRoomsExportProps = ajv.compile(RoomsExportSchema); +type AdminRoomType = 'c' | 'd' | 'p' | 'l' | 'discussions' | 'teams'; + type RoomsAdminRoomsProps = PaginatedRequest<{ filter?: string; - types?: string[]; + types?: AdminRoomType[]; }>; const RoomsAdminRoomsSchema = { @@ -288,6 +294,7 @@ const RoomsAdminRoomsSchema = { type: 'array', items: { type: 'string', + enum: ['c', 'd', 'p', 'l', 'discussions', 'teams'], }, nullable: true, }, @@ -312,7 +319,7 @@ const RoomsAdminRoomsSchema = { additionalProperties: false, }; -export const isRoomsAdminRoomsProps = ajv.compile(RoomsAdminRoomsSchema); +export const isRoomsAdminRoomsProps = ajvQuery.compile(RoomsAdminRoomsSchema); type RoomsAdminRoomsGetRoomProps = { rid?: string }; @@ -328,7 +335,7 @@ const RoomsAdminRoomsGetRoomSchema = { additionalProperties: false, }; -export const isRoomsAdminRoomsGetRoomProps = ajv.compile(RoomsAdminRoomsGetRoomSchema); +export const isRoomsAdminRoomsGetRoomProps = ajvQuery.compile(RoomsAdminRoomsGetRoomSchema); type RoomsChangeArchivationStateProps = { rid: string; action?: string }; @@ -357,14 +364,17 @@ type RoomsSaveRoomSettingsProps = { roomTopic?: string; roomAnnouncement?: string; roomDescription?: string; + roomCustomFields?: Record; roomType?: IRoom['t']; readOnly?: boolean; reactWhenReadOnly?: boolean; + systemMessages?: MessageTypesValues[]; default?: boolean; + joinCode?: string; encrypted?: boolean; favorite?: { - defaultValue?: boolean; - favorite?: boolean; + defaultValue: boolean; + favorite: boolean; }; retentionEnabled?: boolean; retentionMaxAge?: number; @@ -404,6 +414,10 @@ const RoomsSaveRoomSettingsSchema = { type: 'string', nullable: true, }, + roomCustomFields: { + type: 'object', + nullable: true, + }, roomType: { type: 'string', nullable: true, @@ -416,10 +430,19 @@ const RoomsSaveRoomSettingsSchema = { type: 'boolean', nullable: true, }, + systemMessages: { + type: 'array', + items: { type: 'string' }, + nullable: true, + }, default: { type: 'boolean', nullable: true, }, + joinCode: { + type: 'string', + nullable: true, + }, encrypted: { type: 'boolean', nullable: true, @@ -429,13 +452,12 @@ const RoomsSaveRoomSettingsSchema = { properties: { defaultValue: { type: 'boolean', - nullable: true, }, favorite: { type: 'boolean', - nullable: true, }, }, + required: ['defaultValue', 'favorite'], nullable: true, }, retentionEnabled: { type: 'boolean', nullable: true }, diff --git a/packages/rest-typings/src/v1/statistics.ts b/packages/rest-typings/src/v1/statistics.ts index 2d071e6b0a0cf..124e161169630 100644 --- a/packages/rest-typings/src/v1/statistics.ts +++ b/packages/rest-typings/src/v1/statistics.ts @@ -68,6 +68,27 @@ const StatisticsListSchema = { export const isStatisticsListProps = ajv.compile(StatisticsListSchema); +const TelemetryPayloadSchema = { + type: 'object', + properties: { + params: { + type: 'array', + items: { + type: 'object', + properties: { + eventName: { type: 'string' }, + timestamp: { type: 'number', nullable: true }, + }, + required: ['eventName'], + }, + }, + }, + required: ['params'], + additionalProperties: false, +}; + +export const isTelemetryPayload = ajv.compile(TelemetryPayloadSchema); + export type StatisticsEndpoints = { '/v1/statistics': { GET: (params: StatisticsProps) => IStats; diff --git a/packages/rest-typings/src/v1/users/UsersGetAvatarParamsGET.ts b/packages/rest-typings/src/v1/users/UsersGetAvatarParamsGET.ts new file mode 100644 index 0000000000000..0cee571f4519d --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersGetAvatarParamsGET.ts @@ -0,0 +1,19 @@ +import { ajvQuery } from '../Ajv'; + +type UsersGetAvatarParamsGET = { + userId?: string; + username?: string; + user?: string; +}; + +const UsersGetAvatarParamsGetSchema = { + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, +}; + +export const isUsersGetAvatarParamsGET = ajvQuery.compile(UsersGetAvatarParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersGetPresenceParamsGET.ts b/packages/rest-typings/src/v1/users/UsersGetPresenceParamsGET.ts new file mode 100644 index 0000000000000..dc8c3a2471afd --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersGetPresenceParamsGET.ts @@ -0,0 +1,19 @@ +import { ajvQuery } from '../Ajv'; + +type UsersGetPresenceParamsGET = { + userId?: string; + username?: string; + user?: string; +}; + +const UsersGetPresenceParamsGetSchema = { + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, +}; + +export const isUsersGetPresenceParamsGET = ajvQuery.compile(UsersGetPresenceParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersGetStatusParamsGET.ts b/packages/rest-typings/src/v1/users/UsersGetStatusParamsGET.ts new file mode 100644 index 0000000000000..c6d9ae0d92d4f --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersGetStatusParamsGET.ts @@ -0,0 +1,19 @@ +import { ajvQuery } from '../Ajv'; + +type UsersGetStatusParamsGET = { + userId?: string; + username?: string; + user?: string; +}; + +const UsersGetStatusParamsGetSchema = { + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, +}; + +export const isUsersGetStatusParamsGET = ajvQuery.compile(UsersGetStatusParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersPresenceParamsGET.ts b/packages/rest-typings/src/v1/users/UsersPresenceParamsGET.ts new file mode 100644 index 0000000000000..9fcd9f49b9fd3 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersPresenceParamsGET.ts @@ -0,0 +1,19 @@ +import { ajvQuery } from '../Ajv'; + +type UsersPresenceParamsGET = { + from?: string; + ids?: string | string[]; +}; + +const UsersPresenceParamsGetSchema = { + type: 'object', + properties: { + from: { type: 'string', nullable: true }, + ids: { + anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + }, + }, + additionalProperties: false, +}; + +export const isUsersPresenceParamsGET = ajvQuery.compile(UsersPresenceParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersRequestDataDownloadParamsGET.ts b/packages/rest-typings/src/v1/users/UsersRequestDataDownloadParamsGET.ts new file mode 100644 index 0000000000000..c3ffa25364575 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersRequestDataDownloadParamsGET.ts @@ -0,0 +1,17 @@ +import { ajvQuery } from '../Ajv'; + +type UsersRequestDataDownloadParamsGET = { + fullExport?: string; +}; + +const UsersRequestDataDownloadParamsGetSchema = { + type: 'object', + properties: { + fullExport: { type: 'string', nullable: true }, + }, + additionalProperties: false, +}; + +export const isUsersRequestDataDownloadParamsGET = ajvQuery.compile( + UsersRequestDataDownloadParamsGetSchema, +); From 9d50e27846fb96d74f4db77ad4cba9d91c917e5b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 14 Apr 2026 23:58:45 -0300 Subject: [PATCH 02/14] chore: API framework changes (ApiClass, definition, helpers) Update forbidden/unauthorized/success method signatures, add SuccessStatusCodes support, improve getUserInfo helper. Remove debug console.log from authentication startup. --- apps/meteor/app/api/server/ApiClass.ts | 19 +++++++++++++++---- apps/meteor/app/api/server/ajv.ts | 7 +++++++ apps/meteor/app/api/server/definition.ts | 10 ++++++++++ .../app/api/server/helpers/getUserInfo.ts | 19 ++++++------------- .../authentication/server/startup/index.js | 14 ++++++++------ docs/api-endpoint-migration.md | 13 ------------- 6 files changed, 46 insertions(+), 36 deletions(-) diff --git a/apps/meteor/app/api/server/ApiClass.ts b/apps/meteor/app/api/server/ApiClass.ts index 02d78f46659b2..d51726049e0dd 100644 --- a/apps/meteor/app/api/server/ApiClass.ts +++ b/apps/meteor/app/api/server/ApiClass.ts @@ -37,6 +37,7 @@ import type { UnavailableResult, GenericRouteExecutionContext, TooManyRequestsResult, + SuccessStatusCodes, } from './definition'; import { getUserInfo } from './helpers/getUserInfo'; import { parseJsonQuery } from './helpers/parseJsonQuery'; @@ -266,15 +267,15 @@ export class APIClass; - public success(result: T): SuccessResult; + public success(result: T, statusCode?: SuccessStatusCodes): SuccessResult; - public success(result: T = {} as T): SuccessResult { + public success(result: T = {} as T, statusCode: SuccessStatusCodes = 200): SuccessResult { if (isObject(result)) { (result as Record).success = true; } const finalResult = { - statusCode: 200, + statusCode, body: result, } as SuccessResult; @@ -288,6 +289,8 @@ export class APIClass; + public failure(result?: T): FailureResult; public failure( @@ -363,6 +366,10 @@ export class APIClass; + + public unauthorized(msg: T): UnauthorizedResult; + public unauthorized(msg?: T): UnauthorizedResult { return { statusCode: 401, @@ -373,7 +380,11 @@ export class APIClass(msg?: T): ForbiddenResult { + public forbidden(): ForbiddenResult; + + public forbidden(msg: T): ForbiddenResult; + + public forbidden(msg?: T): ForbiddenResult { return { statusCode: 403, body: { diff --git a/apps/meteor/app/api/server/ajv.ts b/apps/meteor/app/api/server/ajv.ts index 00944159988ee..10556de26582f 100644 --- a/apps/meteor/app/api/server/ajv.ts +++ b/apps/meteor/app/api/server/ajv.ts @@ -3,6 +3,13 @@ import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; const components = schemas.components?.schemas; if (components) { + // Patch MessageAttachmentDefault to reject unknown properties so the oneOf + // discriminator works correctly (otherwise it matches every attachment). + const mad = components.MessageAttachmentDefault; + if (mad && typeof mad === 'object' && 'type' in mad) { + (mad as Record).additionalProperties = false; + } + for (const key in components) { if (Object.prototype.hasOwnProperty.call(components, key)) { const uri = `#/components/schemas/${key}`; diff --git a/apps/meteor/app/api/server/definition.ts b/apps/meteor/app/api/server/definition.ts index fc33864687885..9898a33ae27af 100644 --- a/apps/meteor/app/api/server/definition.ts +++ b/apps/meteor/app/api/server/definition.ts @@ -318,6 +318,16 @@ export type TypedThis requestIp?: string; route: string; response: Response; + readonly queryOperations: TOptions extends { queryOperations: infer T } ? T : never; + readonly queryFields: TOptions extends { queryFields: infer T } ? T : never; + readonly connection: { + token: string; + id: string; + close: () => void; + clientAddress: string; + httpHeaders: Record; + }; + readonly twoFactorChecked: boolean; } & (TOptions['authRequired'] extends true ? { user: TOptions extends { userWithoutUsername: true } ? IUser : RequiredField; diff --git a/apps/meteor/app/api/server/helpers/getUserInfo.ts b/apps/meteor/app/api/server/helpers/getUserInfo.ts index e567d7b468546..0e3eee9c3384f 100644 --- a/apps/meteor/app/api/server/helpers/getUserInfo.ts +++ b/apps/meteor/app/api/server/helpers/getUserInfo.ts @@ -1,4 +1,4 @@ -import { isOAuthUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings'; +import { isOAuthUser, type IMeApiUser, type IUser, type IUserEmail, type IUserCalendar } from '@rocket.chat/core-typings'; import semver from 'semver'; import { settings } from '../../../settings/server'; @@ -84,15 +84,7 @@ const getUserCalendar = (email: false | IUserEmail | undefined): IUserCalendar = return calendarSettings; }; -export async function getUserInfo( - me: IUser, - pullPreferences = true, -): Promise< - IUser & { - email?: string; - avatarUrl: string; - } -> { +export async function getUserInfo(me: IUser, pullPreferences = true): Promise { const verifiedEmail = isVerifiedEmail(me); const userPreferences = me.settings?.preferences ?? {}; @@ -110,8 +102,8 @@ export async function getUserInfo( isOAuthUser: isOAuthUser(me), ...(me.services && { services: { - ...(me.services.github && { github: me.services.github }), - ...(me.services.gitlab && { gitlab: me.services.gitlab }), + ...(me.services.github && { github: me.services.github as Record }), + ...(me.services.gitlab && { gitlab: me.services.gitlab as Record }), ...(me.services.email2fa?.enabled && { email2fa: { enabled: me.services.email2fa.enabled } }), ...(me.services.totp?.enabled && { totp: { enabled: me.services.totp.enabled } }), password: { @@ -120,5 +112,6 @@ export async function getUserInfo( }, }, }), - }; + // Cast needed: spread of full IUser produces a superset; runtime response schema validates the actual shape + } as IMeApiUser; } diff --git a/apps/meteor/app/authentication/server/startup/index.js b/apps/meteor/app/authentication/server/startup/index.js index 3e25537c38ed2..0d3e12e7c6e49 100644 --- a/apps/meteor/app/authentication/server/startup/index.js +++ b/apps/meteor/app/authentication/server/startup/index.js @@ -2,7 +2,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { User } from '@rocket.chat/core-services'; import { Roles, Settings, Users } from '@rocket.chat/models'; import { escapeRegExp, escapeHTML } from '@rocket.chat/string-helpers'; -import { getLoginExpirationInDays } from '@rocket.chat/tools'; +import { getLoginExpirationInDays, removeEmpty } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -214,11 +214,12 @@ const onCreateUserAsync = async function (options, user = {}) { if (!options.skipBeforeCreateUserCallback) { await beforeCreateUserCallback.run(options, user); } - user.status = 'offline'; user.active = user.active !== undefined ? user.active : !settings.get('Accounts_ManuallyApproveNewUsers'); - user.inactiveReason = settings.get('Accounts_ManuallyApproveNewUsers') && !user.active ? 'pending_approval' : undefined; + if (settings.get('Accounts_ManuallyApproveNewUsers') && !user.active) { + user.inactiveReason = 'pending_approval'; + } if (!user.name) { if (options.profile) { @@ -235,8 +236,9 @@ const onCreateUserAsync = async function (options, user = {}) { const verified = settings.get('Accounts_Verify_Email_For_External_Accounts'); for (const service of Object.values(user.services)) { - if (!user.name) { - user.name = service.name || service.username; + const suggestedName = service.name || service.username; + if (!user.name && suggestedName) { + user.name = suggestedName; } if (!user.emails && service.email) { @@ -283,7 +285,7 @@ const onCreateUserAsync = async function (options, user = {}) { throw new Meteor.Error(403, 'User validation failed'); } - return user; + return removeEmpty(user); }; Accounts.onCreateUser(function (...args) { diff --git a/docs/api-endpoint-migration.md b/docs/api-endpoint-migration.md index 5d71e90934bed..b65829978ccaa 100644 --- a/docs/api-endpoint-migration.md +++ b/docs/api-endpoint-migration.md @@ -596,18 +596,6 @@ When migrating an endpoint, search for its tests and update: 2. Remove `' [invalid-params]'` suffix from `error` message assertions 3. Verify that status codes remain the same (400 for validation errors) -## Tracking Migration Progress - -```bash -# Summary by file -node scripts/list-unmigrated-api-endpoints.mjs - -# Full list with line numbers (JSON) -node scripts/list-unmigrated-api-endpoints.mjs --json -``` - -The script scans for `API.v1.addRoute` and `API.default.addRoute` calls in `apps/meteor/app/api/`. - ## Reference Files | Pattern | File | @@ -624,4 +612,3 @@ The script scans for `API.v1.addRoute` and `API.default.addRoute` calls in `apps | Error response validators | `packages/rest-typings/src/v1/Ajv.ts` | | Request validators (examples) | `packages/rest-typings/src/v1/moderation/` | | Router implementation | `packages/http-router/src/Router.ts` | -| Unmigrated endpoints script | `scripts/list-unmigrated-api-endpoints.mjs` | From c842e4d0314d1153fdbe81861e12bc9873cd2178 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 15 Apr 2026 00:00:32 -0300 Subject: [PATCH 03/14] chore(api): migrate info, stats, mailer endpoints to OpenAPI --- apps/meteor/app/api/server/default/info.ts | 24 +++- apps/meteor/app/api/server/default/openApi.ts | 33 ++++- apps/meteor/app/api/server/v1/mailer.ts | 65 ++++++--- apps/meteor/app/api/server/v1/stats.ts | 136 +++++++++++++----- 4 files changed, 191 insertions(+), 67 deletions(-) diff --git a/apps/meteor/app/api/server/default/info.ts b/apps/meteor/app/api/server/default/info.ts index 173a733d13b47..71d570382b47e 100644 --- a/apps/meteor/app/api/server/default/info.ts +++ b/apps/meteor/app/api/server/default/info.ts @@ -1,12 +1,28 @@ +import type { IWorkspaceInfo } from '@rocket.chat/core-typings'; +import { ajv } from '@rocket.chat/rest-typings'; + import { API } from '../api'; import { getServerInfo } from '../lib/getServerInfo'; -API.default.addRoute( +const infoResponseSchema = ajv.compile({ + type: 'object', + properties: { + version: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); + +API.default.get( 'info', - { authRequired: false }, { - async get() { - return API.v1.success(await getServerInfo(this.userId)); + authRequired: false, + response: { + 200: infoResponseSchema, }, }, + async function action() { + return API.v1.success(await getServerInfo(this.userId)); + }, ); diff --git a/apps/meteor/app/api/server/default/openApi.ts b/apps/meteor/app/api/server/default/openApi.ts index d59e6b8b08c22..c4218da076d7e 100644 --- a/apps/meteor/app/api/server/default/openApi.ts +++ b/apps/meteor/app/api/server/default/openApi.ts @@ -1,6 +1,6 @@ import { schemas } from '@rocket.chat/core-typings'; import type { Route } from '@rocket.chat/http-router'; -import { isOpenAPIJSONEndpoint } from '@rocket.chat/rest-typings'; +import { ajv, isOpenAPIJSONEndpoint } from '@rocket.chat/rest-typings'; import express from 'express'; import { WebApp } from 'meteor/webapp'; import swaggerUi from 'swagger-ui-express'; @@ -72,16 +72,35 @@ const makeOpenAPIResponse = (paths: Record>) => ({ paths, }); -API.default.addRoute( +const openApiResponseSchema = ajv.compile>({ + type: 'object', + properties: { + openapi: { type: 'string' }, + info: { type: 'object' }, + servers: { type: 'array' }, + components: { type: 'object' }, + paths: { type: 'object' }, + schemas: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['openapi', 'info', 'paths', 'success'], + additionalProperties: false, +}); + +API.default.get( 'docs/json', - { authRequired: false, validateParams: isOpenAPIJSONEndpoint }, { - get() { - const { withUndocumented = false } = this.queryParams; - - return API.default.success(makeOpenAPIResponse(getTypedRoutes(API.api.typedRoutes, { withUndocumented }))); + authRequired: false, + query: isOpenAPIJSONEndpoint, + response: { + 200: openApiResponseSchema, }, }, + function action() { + const { withUndocumented = false } = this.queryParams; + + return API.default.success(makeOpenAPIResponse(getTypedRoutes(API.api.typedRoutes, { withUndocumented }))); + }, ); app.use( diff --git a/apps/meteor/app/api/server/v1/mailer.ts b/apps/meteor/app/api/server/v1/mailer.ts index 57fa62e85a6c3..2186d1e04e8dd 100644 --- a/apps/meteor/app/api/server/v1/mailer.ts +++ b/apps/meteor/app/api/server/v1/mailer.ts @@ -1,41 +1,72 @@ -import { isMailerProps, isMailerUnsubscribeProps } from '@rocket.chat/rest-typings'; +import { + ajv, + isMailerProps, + isMailerUnsubscribeProps, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; import { sendMail } from '../../../mail-messages/server/functions/sendMail'; import { Mailer } from '../../../mail-messages/server/lib/Mailer'; import { API } from '../api'; -API.v1.addRoute( +const mailerResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); + +const mailerUnsubscribeResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( 'mailer', { authRequired: true, - validateParams: isMailerProps, + body: isMailerProps, permissionsRequired: ['send-mail'], + response: { + 200: mailerResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { from, subject, body, dryrun, query } = this.bodyParams; + async function action() { + const { from, subject, body, dryrun, query } = this.bodyParams; - const result = await sendMail({ from, subject, body, dryrun: Boolean(dryrun), query }); + const result = await sendMail({ from, subject, body, dryrun: Boolean(dryrun), query }); - return API.v1.success(result); - }, + return API.v1.success(result); }, ); -API.v1.addRoute( +API.v1.post( 'mailer.unsubscribe', { authRequired: true, - validateParams: isMailerUnsubscribeProps, + body: isMailerUnsubscribeProps, rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 1 }, + response: { + 200: mailerUnsubscribeResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { _id, createdAt } = this.bodyParams; + async function action() { + const { _id, createdAt } = this.bodyParams; - await Mailer.unsubscribe(_id, createdAt); + await Mailer.unsubscribe(_id, createdAt); - return API.v1.success(); - }, + return API.v1.success(); }, ); diff --git a/apps/meteor/app/api/server/v1/stats.ts b/apps/meteor/app/api/server/v1/stats.ts index 27cea2c310574..ba5500457c1d8 100644 --- a/apps/meteor/app/api/server/v1/stats.ts +++ b/apps/meteor/app/api/server/v1/stats.ts @@ -1,62 +1,120 @@ +import type { IStats } from '@rocket.chat/core-typings'; +import { + ajv, + isTelemetryPayload, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateBadRequestErrorResponse, +} from '@rocket.chat/rest-typings'; + import { getStatistics, getLastStatistics } from '../../../statistics/server'; import telemetryEvent from '../../../statistics/server/lib/telemetryEvents'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; -API.v1.addRoute( +const statisticsResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); + +const statisticsListResponseSchema = ajv.compile<{ statistics: IStats[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + statistics: { type: 'array' }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['statistics', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const statisticsTelemetryResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +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, + response: { + 200: statisticsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { refresh } = this.queryParams; + + return API.v1.success( + await getLastStatistics({ + userId: this.userId, + refresh: refresh === 'true', + }), + ); + }, ); -API.v1.addRoute( +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, + response: { + 200: statisticsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + 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, + }, + }), + ); + }, ); -API.v1.addRoute( +API.v1.post( 'statistics.telemetry', - { authRequired: true }, { - post() { - const events = this.bodyParams; + authRequired: true, + body: isTelemetryPayload, + response: { + 200: statisticsTelemetryResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + function action() { + const { params } = this.bodyParams; - events?.params?.forEach((event) => { - const { eventName, ...params } = event; - void telemetryEvent.call(eventName, params); - }); + params.forEach((event) => { + const { eventName, ...rest } = event; + void telemetryEvent.call(eventName, rest); + }); - return API.v1.success(); - }, + return API.v1.success(); }, ); From cce80d7048d4972e56d278a0bcd23d6a15295f05 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 15 Apr 2026 00:00:32 -0300 Subject: [PATCH 04/14] chore(api): migrate push, subscriptions endpoints to OpenAPI --- apps/meteor/app/api/server/v1/push.ts | 122 ++++++++++++++---- .../meteor/app/api/server/v1/subscriptions.ts | 76 +++++++---- .../Info/EditRoomInfo/EditRoomInfo.tsx | 5 +- packages/rest-typings/src/v1/push.ts | 8 +- 4 files changed, 151 insertions(+), 60 deletions(-) diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index 3fe6765393ecf..e16dc9bb37de4 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -1,9 +1,10 @@ import { Push } from '@rocket.chat/core-services'; import { pushTokenTypes } from '@rocket.chat/core-typings'; -import type { IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings'; +import type { IMessage, IPushNotificationConfig, IPushToken, IPushTokenTypes } from '@rocket.chat/core-typings'; import { Messages, PushToken, Users, Rooms, Settings } from '@rocket.chat/models'; import { ajv, + isPushGetProps, validateNotFoundErrorResponse, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, @@ -11,7 +12,6 @@ import { } from '@rocket.chat/rest-typings'; import type { JSONSchemaType } from 'ajv'; import { Accounts } from 'meteor/accounts-base'; -import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { executePushTest } from '../../../../server/lib/pushConfig'; @@ -224,25 +224,89 @@ const pushTokenEndpoints = API.v1 }, ); -API.v1.addRoute( - 'push.get', - { authRequired: true }, - { - async get() { - const params = this.queryParams; - check( - params, - Match.ObjectIncluding({ - id: String, - }), - ); +const pushGetResponseSchema = ajv.compile<{ + data: { message: IMessage; notification: IPushNotificationConfig }; +}>({ + type: 'object', + properties: { + data: { + type: 'object', + properties: { + message: { type: 'object' }, + notification: { + type: 'object', + properties: { + from: { type: 'string' }, + title: { type: 'string' }, + text: { type: 'string' }, + userId: { type: 'string' }, + badge: { type: 'number', nullable: true }, + sound: { type: 'string', nullable: true }, + priority: { type: 'number', nullable: true }, + payload: { type: 'object', nullable: true }, + notId: { type: 'number', nullable: true }, + gcm: { + type: 'object', + properties: { + style: { type: 'string' }, + image: { type: 'string' }, + }, + required: ['style', 'image'], + nullable: true, + }, + apn: { + type: 'object', + properties: { + category: { type: 'string' }, + topicSuffix: { type: 'string', nullable: true }, + }, + required: ['category'], + nullable: true, + }, + }, + required: ['from', 'title', 'text', 'userId'], + }, + }, + required: ['message', 'notification'], + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'success'], + additionalProperties: false, +}); + +const pushInfoResponseSchema = ajv.compile<{ pushGatewayEnabled: boolean; defaultPushGateway: boolean }>({ + type: 'object', + properties: { + pushGatewayEnabled: { type: 'boolean' }, + defaultPushGateway: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['pushGatewayEnabled', 'defaultPushGateway', 'success'], + additionalProperties: false, +}); + +const pushGetInfoEndpoints = API.v1 + .get( + 'push.get', + { + authRequired: true, + query: isPushGetProps, + response: { + 200: pushGetResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { id } = this.queryParams; const receiver = await Users.findOneById(this.userId); if (!receiver) { throw new Error('error-user-not-found'); } - const message = await Messages.findOneById(params.id); + const message = await Messages.findOneById(id); if (!message) { throw new Error('error-message-not-found'); } @@ -260,23 +324,25 @@ API.v1.addRoute( return API.v1.success({ data }); }, - }, -); - -API.v1.addRoute( - 'push.info', - { authRequired: true }, - { - async get() { + ) + .get( + 'push.info', + { + authRequired: true, + response: { + 200: pushInfoResponseSchema, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const defaultGateway = (await Settings.findOneById('Push_gateway', { projection: { packageValue: 1 } }))?.packageValue; const defaultPushGateway = settings.get('Push_gateway') === defaultGateway; return API.v1.success({ - pushGatewayEnabled: settings.get('Push_enable'), + pushGatewayEnabled: Boolean(settings.get('Push_enable')), defaultPushGateway, }); }, - }, -); + ); const pushTestEndpoints = API.v1.post( 'push.test', @@ -322,7 +388,9 @@ type PushTestEndpoints = ExtractRoutesFromAPI; type PushTokenEndpoints = ExtractRoutesFromAPI; -type PushEndpoints = PushTestEndpoints & PushTokenEndpoints; +type PushGetInfoEndpoints = ExtractRoutesFromAPI; + +type PushEndpoints = PushTestEndpoints & PushTokenEndpoints & PushGetInfoEndpoints; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface diff --git a/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index 375107c016cd6..9b69aa5478d1c 100644 --- a/apps/meteor/app/api/server/v1/subscriptions.ts +++ b/apps/meteor/app/api/server/v1/subscriptions.ts @@ -1,3 +1,4 @@ +import type { ISubscription } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions } from '@rocket.chat/models'; import { ajv, @@ -15,9 +16,27 @@ import { getSubscriptions } from '../../../../server/publications/subscription'; import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages'; import { API } from '../api'; -const successResponseSchema = ajv.compile({ +const subscriptionsGetResponseSchema = ajv.compile<{ + update: ISubscription[]; + remove: (Pick & { _deletedAt: Date })[]; +}>({ type: 'object', - properties: { success: { type: 'boolean', enum: [true] } }, + properties: { + update: { type: 'array', items: { $ref: '#/components/schemas/ISubscription' } }, + remove: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + _deletedAt: { type: 'string', format: 'date-time' }, + }, + required: ['_id', '_deletedAt'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, required: ['success'], additionalProperties: true, }); @@ -28,16 +47,7 @@ API.v1.get( authRequired: true, query: isSubscriptionsGetProps, response: { - 200: ajv.compile({ - type: 'object', - properties: { - update: { type: 'array', items: { type: 'object' } }, - remove: { type: 'array', items: { type: 'object' } }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['success'], - additionalProperties: true, - }), + 200: subscriptionsGetResponseSchema, 401: validateUnauthorizedErrorResponse, }, }, @@ -46,11 +56,10 @@ API.v1.get( let updatedSinceDate: Date | undefined; if (updatedSince) { - const updatedSinceStr = String(updatedSince); - if (isNaN(Date.parse(updatedSinceStr))) { + if (isNaN(Date.parse(updatedSince))) { throw new Meteor.Error('error-roomId-param-invalid', 'The "lastUpdate" query parameter must be a valid date.'); } - updatedSinceDate = new Date(updatedSinceStr); + updatedSinceDate = new Date(updatedSince); } const result = await getSubscriptions(this.userId, updatedSinceDate); @@ -66,21 +75,23 @@ API.v1.get( }, ); +const subscriptionsGetOneResponseSchema = ajv.compile<{ subscription: ISubscription | null }>({ + type: 'object', + properties: { + subscription: { oneOf: [{ $ref: '#/components/schemas/ISubscription' }, { type: 'null' }] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['subscription', 'success'], + additionalProperties: false, +}); + API.v1.get( 'subscriptions.getOne', { authRequired: true, query: isSubscriptionsGetOneProps, response: { - 200: ajv.compile({ - type: 'object', - properties: { - subscription: { type: 'object', nullable: true }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['subscription', 'success'], - additionalProperties: false, - }), + 200: subscriptionsGetOneResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, @@ -107,13 +118,22 @@ API.v1.get( - rid: The rid of the room to be marked as read. - roomId: Alternative for rid. */ +const voidSuccessResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + API.v1.post( 'subscriptions.read', { authRequired: true, body: isSubscriptionsReadProps, response: { - 200: successResponseSchema, + 200: voidSuccessResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, @@ -129,7 +149,7 @@ API.v1.post( await readMessages(room, this.userId, readThreads); - return API.v1.success({}); + return API.v1.success(); }, ); @@ -139,7 +159,7 @@ API.v1.post( authRequired: true, body: isSubscriptionsUnreadProps, response: { - 200: successResponseSchema, + 200: voidSuccessResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, @@ -151,6 +171,6 @@ API.v1.post( 'roomId' in this.bodyParams ? this.bodyParams.roomId : undefined, ); - return API.v1.success({}); + return API.v1.success(); }, ); diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx index 4f23522d092f8..b63a7d18dffe0 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx @@ -170,8 +170,9 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => rid: room._id, ...data, ...((data.joinCode || 'joinCodeRequired' in data) && { joinCode: joinCodeRequired ? data.joinCode : '' }), - ...((data.systemMessages || !hideSysMes) && { - systemMessages: hideSysMes && data.systemMessages, + ...(data.systemMessages && + !hideSysMes && { + systemMessages: data.systemMessages, }), retentionEnabled, retentionOverrideGlobal, diff --git a/packages/rest-typings/src/v1/push.ts b/packages/rest-typings/src/v1/push.ts index d74a632a99f08..55f77f44b63d9 100644 --- a/packages/rest-typings/src/v1/push.ts +++ b/packages/rest-typings/src/v1/push.ts @@ -1,4 +1,4 @@ -import type { IPushTokenTypes } from '@rocket.chat/core-typings'; +import type { IMessage, IPushNotificationConfig, IPushTokenTypes } from '@rocket.chat/core-typings'; import { ajv, ajvQuery } from './Ajv'; @@ -52,8 +52,10 @@ export const isPushGetProps = ajvQuery.compile(PushGetPropsSchema) export type PushEndpoints = { '/v1/push.get': { GET: (params: PushGetProps) => { - data: Record; - success: true; + data: { + message: IMessage; + notification: IPushNotificationConfig; + }; }; }; '/v1/push.info': { From 9d049949bac4eb3c1ab52cc3b15e9cbc5217a209 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 15 Apr 2026 00:00:32 -0300 Subject: [PATCH 05/14] chore(api): migrate invites, custom-user-status, emoji-custom endpoints to OpenAPI --- .../app/api/server/v1/custom-user-status.ts | 197 ++++++++++++------ apps/meteor/app/api/server/v1/emoji-custom.ts | 106 +++++----- apps/meteor/app/api/server/v1/invites.ts | 111 +++++----- .../end-to-end/api/custom-user-status.ts | 2 +- 4 files changed, 238 insertions(+), 178 deletions(-) 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..f3ab5c944839e 100644 --- a/apps/meteor/app/api/server/v1/custom-user-status.ts +++ b/apps/meteor/app/api/server/v1/custom-user-status.ts @@ -1,9 +1,14 @@ import type { ICustomUserStatus } from '@rocket.chat/core-typings'; import { CustomUserStatus } from '@rocket.chat/models'; -import { ajv, ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; +import { + ajv, + ajvQuery, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, +} 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'; @@ -100,7 +105,7 @@ const customUserStatusEndpoints = API.v1.get( const filter = { ...query, - ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), + ...(name ? { name: { $regex: escapeRegExp(name), $options: 'i' } } : {}), ...(_id ? { _id } : {}), }; @@ -121,88 +126,152 @@ const customUserStatusEndpoints = API.v1.get( }, ); -API.v1.addRoute( +const isCustomUserStatusCreateProps = ajv.compile<{ name: string; statusType?: string }>({ + type: 'object', + properties: { + name: { type: 'string' }, + statusType: { type: 'string', nullable: true }, + }, + required: ['name'], + additionalProperties: false, +}); + +const customUserStatusCreateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({ + type: 'object', + properties: { + customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['customUserStatus', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'custom-user-status.create', - { authRequired: true }, { - async post() { - check(this.bodyParams, { - name: String, - statusType: Match.Maybe(String), - }); - - const userStatusData = { - name: this.bodyParams.name, - statusType: this.bodyParams.statusType || '', - }; - - 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'); - } - - return API.v1.success({ - customUserStatus, - }); + authRequired: true, + body: isCustomUserStatusCreateProps, + response: { + 200: customUserStatusCreateResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const userStatusData = { + name: this.bodyParams.name, + statusType: this.bodyParams.statusType || '', + }; + + 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'); + } + + return API.v1.success({ + customUserStatus, + }); + }, ); -API.v1.addRoute( +const isCustomUserStatusDeleteProps = ajv.compile<{ customUserStatusId: string }>({ + type: 'object', + properties: { + customUserStatusId: { type: 'string', minLength: 1 }, + }, + required: ['customUserStatusId'], + additionalProperties: false, +}); + +const customUserStatusDeleteResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +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: customUserStatusDeleteResponseSchema, + 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 API.v1.success(); }, ); -API.v1.addRoute( +const isCustomUserStatusUpdateProps = ajv.compile<{ _id: string; name: string; statusType?: string }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + name: { type: 'string' }, + statusType: { type: 'string', nullable: true }, + }, + required: ['_id', 'name'], + additionalProperties: false, +}); + +const customUserStatusUpdateResponseSchema = ajv.compile<{ customUserStatus: ICustomUserStatus }>({ + type: 'object', + properties: { + customUserStatus: { $ref: '#/components/schemas/ICustomUserStatus' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['customUserStatus', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'custom-user-status.update', - { authRequired: true }, { - async post() { - check(this.bodyParams, { - _id: String, - name: String, - statusType: Match.Maybe(String), - }); - - const userStatusData = { - _id: this.bodyParams._id, - name: this.bodyParams.name, - statusType: this.bodyParams.statusType, - }; + authRequired: true, + body: isCustomUserStatusUpdateProps, + response: { + 200: customUserStatusUpdateResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const userStatusData = { + _id: this.bodyParams._id, + name: this.bodyParams.name, + statusType: this.bodyParams.statusType || '', + }; - const customUserStatusToUpdate = await CustomUserStatus.findOneById(userStatusData._id); + const customUserStatusToUpdate = await CustomUserStatus.findOneById(userStatusData._id); - // Ensure the message exists - if (!customUserStatusToUpdate) { - return API.v1.failure(`No custom user status found with the id of "${userStatusData._id}".`); - } + // Ensure the message exists + if (!customUserStatusToUpdate) { + return API.v1.failure(`No custom user status found with the id of "${userStatusData._id}".`); + } - await insertOrUpdateUserStatus(this.userId, userStatusData); + await insertOrUpdateUserStatus(this.userId, userStatusData); - const customUserStatus = await CustomUserStatus.findOneById(userStatusData._id); + const customUserStatus = await CustomUserStatus.findOneById(userStatusData._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, + }); }, ); diff --git a/apps/meteor/app/api/server/v1/emoji-custom.ts b/apps/meteor/app/api/server/v1/emoji-custom.ts index 043da3b0c5396..ed757ab9f661e 100644 --- a/apps/meteor/app/api/server/v1/emoji-custom.ts +++ b/apps/meteor/app/api/server/v1/emoji-custom.ts @@ -1,11 +1,11 @@ import { Media } from '@rocket.chat/core-services'; -import type { IEmojiCustom } from '@rocket.chat/core-typings'; +import type { IEmojiCustom, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import { EmojiCustom } from '@rocket.chat/models'; -import { ajv, isEmojiCustomList, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; +import { ajv, isEmojiCustomList, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Meteor } from 'meteor/meteor'; +import type { WithId } from 'mongodb'; -import { SystemLogger } from '../../../../server/lib/logger/system'; import type { EmojiData } from '../../../emoji-custom/server/lib/insertOrUpdateEmoji'; import { insertOrUpdateEmoji } from '../../../emoji-custom/server/lib/insertOrUpdateEmoji'; import { uploadEmojiCustomWithBuffer } from '../../../emoji-custom/server/lib/uploadEmojiCustom'; @@ -17,6 +17,38 @@ import { getPaginationItems } from '../helpers/getPaginationItems'; import { findEmojisCustom } from '../lib/emoji-custom'; import { getUploadFormData } from '../lib/getUploadFormData'; +const emojiDeleteBodySchema = ajv.compile({ + type: 'object', + properties: { emojiId: { type: 'string', minLength: 1 } }, + required: ['emojiId'], + additionalProperties: false, +}); + +const emojiCustomAllResponseSchema = ajv.compile<{ emojis: IEmojiCustom[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + emojis: { + type: 'array', + items: { $ref: '#/components/schemas/IEmojiCustom' }, + }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['emojis', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); + +const emojiCustomDeleteResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + function validateDateParam(paramName: string, paramValue: string | undefined): Date | undefined { if (!paramValue) { return undefined; @@ -30,7 +62,9 @@ function validateDateParam(paramName: string, paramValue: string | undefined): D return date; } -const emojiListResponseSchema = ajv.compile({ +const emojiCustomListResponseSchema = ajv.compile<{ + emojis: { update: IEmojiCustom[]; remove: WithId>[] }; +}>({ type: 'object', properties: { emojis: { @@ -47,13 +81,6 @@ const emojiListResponseSchema = ajv.compile({ additionalProperties: false, }); -const emojiDeleteBodySchema = ajv.compile({ - type: 'object', - properties: { emojiId: { type: 'string' } }, - required: ['emojiId'], - additionalProperties: false, -}); - const emojiCustomCreateEndpoints = API.v1 .get( 'emoji-custom.list', @@ -61,7 +88,7 @@ const emojiCustomCreateEndpoints = API.v1 authRequired: true, query: isEmojiCustomList, response: { - 200: emojiListResponseSchema, + 200: emojiCustomListResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, @@ -109,18 +136,7 @@ const emojiCustomCreateEndpoints = API.v1 { authRequired: true, response: { - 200: ajv.compile({ - type: 'object', - properties: { - emojis: { type: 'array', items: { type: 'object' } }, - total: { type: 'number' }, - count: { type: 'number' }, - offset: { type: 'number' }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['emojis', 'total', 'count', 'offset', 'success'], - additionalProperties: false, - }), + 200: emojiCustomAllResponseSchema, 401: validateUnauthorizedErrorResponse, }, }, @@ -153,18 +169,6 @@ const emojiCustomCreateEndpoints = API.v1 { authRequired: true, response: { - 400: ajv.compile({ - type: 'object', - properties: { - success: { type: 'boolean', enum: [false] }, - stack: { type: 'string' }, - error: { type: 'string' }, - errorType: { type: 'string' }, - details: { type: 'string' }, - }, - required: ['success'], - additionalProperties: false, - }), 200: ajv.compile({ type: 'object', properties: { @@ -176,6 +180,8 @@ const emojiCustomCreateEndpoints = API.v1 required: ['success'], additionalProperties: false, }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, async function action() { @@ -199,20 +205,15 @@ const emojiCustomCreateEndpoints = API.v1 const [, extension] = mimetype.split('/'); fields.extension = extension; - try { - const emojiData = await insertOrUpdateEmoji(this.userId, { - ...fields, - newFile: true, - aliases: fields.aliases || '', - name: fields.name, - extension: fields.extension, - }); + const emojiData = await insertOrUpdateEmoji(this.userId, { + ...fields, + newFile: true, + aliases: fields.aliases || '', + name: fields.name, + extension: fields.extension, + }); - await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData); - } catch (err) { - SystemLogger.error({ err }); - return API.v1.failure(); - } + await uploadEmojiCustomWithBuffer(this.userId, fileBuffer, mimetype, emojiData); return API.v1.success(); }, @@ -290,12 +291,7 @@ const emojiCustomCreateEndpoints = API.v1 authRequired: true, body: emojiDeleteBodySchema, response: { - 200: ajv.compile({ - type: 'object', - properties: { success: { type: 'boolean', enum: [true] } }, - required: ['success'], - additionalProperties: false, - }), + 200: emojiCustomDeleteResponseSchema, 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, }, diff --git a/apps/meteor/app/api/server/v1/invites.ts b/apps/meteor/app/api/server/v1/invites.ts index 1c4b6ed4433ee..b5a6fbd12c36c 100644 --- a/apps/meteor/app/api/server/v1/invites.ts +++ b/apps/meteor/app/api/server/v1/invites.ts @@ -6,6 +6,7 @@ import { isValidateInviteTokenProps, isSendInvitationEmailParams, validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { findOrCreateInvite } from '../../../invites/server/functions/findOrCreateInvite'; @@ -17,6 +18,51 @@ import { validateInviteToken } from '../../../invites/server/functions/validateI import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; +const removeInviteResponseSchema = ajv.compile({ + type: 'boolean', + enum: [true], +}); + +const useInviteTokenResponseSchema = ajv.compile({ + type: 'object', + properties: { + room: { + type: 'object', + properties: { + rid: { type: 'string' }, + prid: { type: 'string', nullable: true }, + fname: { type: 'string', nullable: true }, + name: { type: 'string', nullable: true }, + t: { type: 'string' }, + }, + required: ['rid', 't'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, +}); + +const validateInviteTokenResponseSchema = ajv.compile<{ valid: boolean }>({ + type: 'object', + properties: { + valid: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['valid', 'success'], + additionalProperties: false, +}); + +const sendInvitationEmailResponseSchema = ajv.compile<{ success: boolean }>({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + const invites = API.v1 .get( 'listInvites', @@ -177,10 +223,7 @@ const invites = API.v1 { authRequired: true, response: { - 200: ajv.compile({ - type: 'boolean', - enum: [true], - }), + 200: removeInviteResponseSchema, 400: validateBadRequestErrorResponse, 401: ajv.compile({ type: 'object', @@ -192,7 +235,6 @@ const invites = API.v1 }, async function action() { const { _id } = this.urlParams; - return API.v1.success(await removeInvite(this.userId, { _id })); }, ) @@ -202,33 +244,9 @@ const invites = API.v1 authRequired: true, body: isUseInviteTokenProps, response: { - 200: ajv.compile({ - type: 'object', - properties: { - room: { - type: 'object', - properties: { - rid: { type: 'string' }, - prid: { type: 'string', nullable: true }, - fname: { type: 'string', nullable: true }, - name: { type: 'string', nullable: true }, - t: { type: 'string' }, - }, - required: ['rid', 't'], - additionalProperties: false, - }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['room', 'success'], - additionalProperties: false, - }), + 200: useInviteTokenResponseSchema, 400: validateBadRequestErrorResponse, - 401: ajv.compile({ - type: 'object', - properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, - required: ['success', 'error'], - additionalProperties: false, - }), + 401: validateUnauthorizedErrorResponse, }, }, async function action() { @@ -242,15 +260,7 @@ const invites = API.v1 authRequired: false, body: isValidateInviteTokenProps, response: { - 200: ajv.compile({ - type: 'object', - properties: { - valid: { type: 'boolean' }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['valid', 'success'], - additionalProperties: false, - }), + 200: validateInviteTokenResponseSchema, }, }, async function action() { @@ -268,24 +278,9 @@ const invites = API.v1 authRequired: true, body: isSendInvitationEmailParams, response: { - 200: ajv.compile({ - type: 'object', - properties: { success: { type: 'boolean' } }, - required: ['success'], - additionalProperties: false, - }), - 400: ajv.compile({ - type: 'object', - properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, - required: ['success', 'error'], - additionalProperties: false, - }), - 401: ajv.compile({ - type: 'object', - properties: { error: { type: 'string' }, success: { type: 'boolean', enum: [false] } }, - required: ['success', 'error'], - additionalProperties: false, - }), + 200: sendInvitationEmailResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, async function action() { diff --git a/apps/meteor/tests/end-to-end/api/custom-user-status.ts b/apps/meteor/tests/end-to-end/api/custom-user-status.ts index fab30b51fe33e..f56d8e37f8f34 100644 --- a/apps/meteor/tests/end-to-end/api/custom-user-status.ts +++ b/apps/meteor/tests/end-to-end/api/custom-user-status.ts @@ -377,7 +377,7 @@ describe('[CustomUserStatus]', () => { .expect(400) .expect((res: Response) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'The "customUserStatusId" params is required!'); + expect(res.body).to.have.property('error', "must have required property 'customUserStatusId'"); }); }); From 924465ba0d66455775b1b2960a2b3a1ee04f300c Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 15 Apr 2026 00:00:33 -0300 Subject: [PATCH 06/14] chore(api): migrate commands endpoints to OpenAPI --- apps/meteor/app/api/server/v1/commands.ts | 560 +++++++++++------- apps/meteor/tests/end-to-end/api/commands.ts | 32 +- .../apps/slash-command-test-simple.ts | 2 +- 3 files changed, 345 insertions(+), 249 deletions(-) diff --git a/apps/meteor/app/api/server/v1/commands.ts b/apps/meteor/app/api/server/v1/commands.ts index 408222052b7d9..801ad8953829f 100644 --- a/apps/meteor/app/api/server/v1/commands.ts +++ b/apps/meteor/app/api/server/v1/commands.ts @@ -1,8 +1,14 @@ import { Apps } from '@rocket.chat/apps'; -import type { SlashCommand } from '@rocket.chat/core-typings'; +import type { SlashCommand, SlashCommandPreviewItem } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; -import { ajv, ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings'; +import { + ajv, + ajvQuery, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, +} from '@rocket.chat/rest-typings'; import objectPath from 'object-path'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; @@ -26,63 +32,158 @@ const CommandsGetParamsSchema = { const isCommandsGetParams = ajvQuery.compile(CommandsGetParamsSchema); -const commandsEndpoints = API.v1.get( - 'commands.get', - { - authRequired: true, - query: isCommandsGetParams, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile<{ - command: Pick; - success: true; - }>({ - type: 'object', - properties: { - command: { - type: 'object', - properties: { - clientOnly: { type: 'boolean' }, - command: { type: 'string' }, - description: { type: 'string' }, - params: { type: 'string' }, - providesPreview: { type: 'boolean' }, +const commandsListResponseSchema = ajv.compile<{ + commands: SlashCommand[]; + appsLoaded: boolean; + offset: number; + count: number; + total: number; +}>({ + type: 'object', + properties: { + commands: { + type: 'array', + items: { $ref: '#/components/schemas/SlashCommand' }, + }, + appsLoaded: { type: 'boolean' }, + offset: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['commands', 'appsLoaded', 'offset', 'count', 'total', 'success'], + additionalProperties: false, +}); + +type CommandsListParams = { + offset?: number; + count?: number; + sort?: string; + query?: string; + fields?: string; +}; + +const isCommandsListParams = ajvQuery.compile({ + type: 'object', + properties: { + offset: { type: 'number', nullable: true }, + count: { type: 'number', nullable: true }, + sort: { type: 'string', nullable: true }, + query: { type: 'string', nullable: true }, + fields: { type: 'string', nullable: true }, + }, + additionalProperties: false, +}); + +const commandsEndpoints = API.v1 + .get( + 'commands.get', + { + authRequired: true, + query: isCommandsGetParams, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile<{ + command: Pick; + success: true; + }>({ + type: 'object', + properties: { + command: { + type: 'object', + properties: { + clientOnly: { type: 'boolean' }, + command: { type: 'string' }, + description: { type: 'string' }, + params: { type: 'string' }, + providesPreview: { type: 'boolean' }, + }, + required: ['command', 'providesPreview'], + additionalProperties: false, + }, + success: { + type: 'boolean', + enum: [true], }, - required: ['command', 'providesPreview'], - additionalProperties: false, - }, - success: { - type: 'boolean', - enum: [true], }, - }, - required: ['command', 'success'], - additionalProperties: false, - }), + required: ['command', 'success'], + additionalProperties: false, + }), + }, }, - }, - async function action() { - const params = this.queryParams; + async function action() { + const params = this.queryParams; - const cmd = slashCommands.commands[params.command.toLowerCase()]; + const cmd = slashCommands.commands[params.command.toLowerCase()]; - if (!cmd) { - return API.v1.failure(`There is no command in the system by the name of: ${params.command}`); - } + if (!cmd) { + return API.v1.failure(`There is no command in the system by the name of: ${params.command}`); + } - return API.v1.success({ - command: { - command: cmd.command, - description: cmd.description, - params: cmd.params, - clientOnly: cmd.clientOnly, - providesPreview: cmd.providesPreview, + return API.v1.success({ + command: { + command: cmd.command, + description: cmd.description, + params: cmd.params, + clientOnly: cmd.clientOnly, + providesPreview: cmd.providesPreview, + }, + }); + }, + ) + .get( + 'commands.list', + { + authRequired: true, + query: isCommandsListParams, + response: { + 200: commandsListResponseSchema, + 202: commandsListResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, - }); - }, -); + }, + async function action() { + if (!Apps.self?.isLoaded()) { + return API.v1.success( + { + commands: [], + appsLoaded: false as const, + offset: 0, + count: 0, + total: 0, + success: true as const, + }, + 202, + ); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, query } = await this.parseJsonQuery(); + + let commands = Object.values(slashCommands.commands); + + if (query?.command) { + commands = commands.filter((command) => command.command === query.command); + } + + const totalCount = commands.length; + + return API.v1.success({ + commands: processQueryOptionsOnResult(commands, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + }), + appsLoaded: true as const, + offset, + count: commands.length, + total: totalCount, + }); + }, + ); /* @deprecated */ const processQueryOptionsOnResult = , F extends keyof T>( @@ -190,216 +291,225 @@ const processQueryOptionsOnResult = ; - const { offset, count } = await getPaginationItems(params); - const { sort, query } = await this.parseJsonQuery(); - - let commands = Object.values(slashCommands.commands); +const isCommandsRunProps = ajv.compile<{ command: string; params?: string; roomId: string; tmid?: string; triggerId?: string }>({ + type: 'object', + properties: { + command: { type: 'string', minLength: 1 }, + params: { type: 'string', nullable: true }, + roomId: { type: 'string', minLength: 1 }, + tmid: { type: 'string', nullable: true }, + triggerId: { type: 'string', nullable: true }, + }, + required: ['command', 'roomId'], + additionalProperties: false, +}); - if (query?.command) { - commands = commands.filter((command) => command.command === query.command); - } +const commandsRunResponseSchema = ajv.compile<{ result: unknown }>({ + type: 'object', + properties: { + result: {}, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: true, +}); - const totalCount = commands.length; +const isCommandsPreviewGetProps = ajvQuery.compile<{ command: string; params?: string; roomId: string }>({ + type: 'object', + properties: { + command: { type: 'string', minLength: 1 }, + params: { type: 'string', nullable: true }, + roomId: { type: 'string', minLength: 1 }, + }, + required: ['command', 'roomId'], + additionalProperties: false, +}); - return API.v1.success({ - commands: processQueryOptionsOnResult(commands, { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - }), - appsLoaded: true, - offset, - count: commands.length, - total: totalCount, - }); +const commandsPreviewGetResponseSchema = ajv.compile<{ preview: Record | null }>({ + type: 'object', + properties: { + preview: { type: 'object', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +const isCommandsPreviewPostProps = ajv.compile<{ + command: string; + params?: string; + roomId: string; + tmid?: string; + triggerId?: string; + previewItem: SlashCommandPreviewItem; +}>({ + type: 'object', + properties: { + command: { type: 'string', minLength: 1 }, + params: { type: 'string', nullable: true }, + roomId: { type: 'string', minLength: 1 }, + tmid: { type: 'string', nullable: true }, + triggerId: { type: 'string', nullable: true }, + previewItem: { + type: 'object', + properties: { + id: { type: 'string' }, + type: { type: 'string', enum: ['image', 'video', 'audio', 'text', 'other'] }, + value: { type: 'string' }, + }, + required: ['id', 'type', 'value'], + additionalProperties: false, }, }, -); + required: ['command', 'roomId', 'previewItem'], + additionalProperties: false, +}); + +const commandsPreviewPostResponseSchema = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); // Expects a body of: { command: 'gimme', params: 'any string value', roomId: 'value', triggerId: 'value' } -API.v1.addRoute( +API.v1.post( 'commands.run', - { authRequired: true }, { - async post() { - const body = this.bodyParams; - - if (typeof body.command !== 'string') { - return API.v1.failure('You must provide a command to run.'); - } - - if (body.params && typeof body.params !== 'string') { - return API.v1.failure('The parameters for the command must be a single string.'); - } - - if (typeof body.roomId !== 'string') { - return API.v1.failure("The room's id where to execute this command must be provided and be a string."); - } - - if (body.tmid && typeof body.tmid !== 'string') { - return API.v1.failure('The tmid parameter when provided must be a string.'); - } + authRequired: true, + body: isCommandsRunProps, + response: { + 200: commandsRunResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const body = this.bodyParams; - const cmd = body.command.toLowerCase(); - if (!slashCommands.commands[cmd]) { - return API.v1.failure('The command provided does not exist (or is disabled).'); - } + const cmd = body.command.toLowerCase(); + if (!slashCommands.commands[cmd]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } - if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { - return API.v1.forbidden(); - } + if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { + return API.v1.forbidden(); + } - const params = body.params ? body.params : ''; - if (typeof body.tmid === 'string') { - const thread = await Messages.findOneById(body.tmid); - if (!thread || thread.rid !== body.roomId) { - return API.v1.failure('Invalid thread.'); - } + const params = body.params ? body.params : ''; + if (body.tmid) { + const thread = await Messages.findOneById(body.tmid); + if (thread?.rid !== body.roomId) { + return API.v1.failure('Invalid thread.'); } + } - const message = { - _id: Random.id(), - rid: body.roomId, - msg: `/${cmd} ${params}`, - ...(body.tmid && { tmid: body.tmid }), - }; + const message = { + _id: Random.id(), + rid: body.roomId, + msg: `/${cmd} ${params}`, + ...(body.tmid && { tmid: body.tmid }), + }; - const { triggerId } = body; + const { triggerId } = body; - const result = await slashCommands.run({ command: cmd, params, message, triggerId, userId: this.userId }); + const result = await slashCommands.run({ command: cmd, params, message, triggerId, userId: this.userId }); - return API.v1.success({ result }); - }, + return API.v1.success({ result }); }, ); -API.v1.addRoute( +// Expects these query params: command: 'giphy', params: 'mine', roomId: 'value' +API.v1.get( 'commands.preview', - { authRequired: true }, { - // Expects these query params: command: 'giphy', params: 'mine', roomId: 'value' - async get() { - const query = this.queryParams; - - if (typeof query.command !== 'string') { - return API.v1.failure('You must provide a command to get the previews from.'); - } - - if (query.params && typeof query.params !== 'string') { - return API.v1.failure('The parameters for the command must be a single string.'); - } - - if (typeof query.roomId !== 'string') { - return API.v1.failure("The room's id where the previews are being displayed must be provided and be a string."); - } - - const cmd = query.command.toLowerCase(); - if (!slashCommands.commands[cmd]) { - return API.v1.failure('The command provided does not exist (or is disabled).'); - } - - if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) { - return API.v1.forbidden(); - } - - const params = query.params ? query.params : ''; - - const preview = await getSlashCommandPreviews({ - cmd, - params, - msg: { rid: query.roomId }, - userId: this.userId, - }); - - return API.v1.success({ preview }); + authRequired: true, + query: isCommandsPreviewGetProps, + response: { + 200: commandsPreviewGetResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, + }, + async function action() { + const query = this.queryParams; - // Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', tmid: 'value', triggerId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif' } } - async post() { - const body = this.bodyParams; - - if (typeof body.command !== 'string') { - return API.v1.failure('You must provide a command to run the preview item on.'); - } + const cmd = query.command.toLowerCase(); + if (!slashCommands.commands[cmd]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } - if (body.params && typeof body.params !== 'string') { - return API.v1.failure('The parameters for the command must be a single string.'); - } + if (!(await canAccessRoomIdAsync(query.roomId, this.userId))) { + return API.v1.forbidden(); + } - if (typeof body.roomId !== 'string') { - return API.v1.failure("The room's id where the preview is being executed in must be provided and be a string."); - } + const params = query.params ? query.params : ''; - if (typeof body.previewItem === 'undefined') { - return API.v1.failure('The preview item being executed must be provided.'); - } + const preview = await getSlashCommandPreviews({ + cmd, + params, + msg: { rid: query.roomId }, + userId: this.userId, + }); - if (!body.previewItem.id || !body.previewItem.type || typeof body.previewItem.value === 'undefined') { - return API.v1.failure('The preview item being executed is in the wrong format.'); - } + return API.v1.success({ preview: preview ?? null }); + }, +); - if (body.tmid && typeof body.tmid !== 'string') { - return API.v1.failure('The tmid parameter when provided must be a string.'); - } +// Expects a body format of: { command: 'giphy', params: 'mine', roomId: 'value', tmid: 'value', triggerId: 'value', previewItem: { id: 'sadf8' type: 'image', value: 'https://dev.null/gif' } } +API.v1.post( + 'commands.preview', + { + authRequired: true, + body: isCommandsPreviewPostProps, + response: { + 200: commandsPreviewPostResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const body = this.bodyParams; - if (body.triggerId && typeof body.triggerId !== 'string') { - return API.v1.failure('The triggerId parameter when provided must be a string.'); - } + const cmd = body.command.toLowerCase(); + if (!slashCommands.commands[cmd]) { + return API.v1.failure('The command provided does not exist (or is disabled).'); + } - const cmd = body.command.toLowerCase(); - if (!slashCommands.commands[cmd]) { - return API.v1.failure('The command provided does not exist (or is disabled).'); - } + if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { + return API.v1.forbidden(); + } - if (!(await canAccessRoomIdAsync(body.roomId, this.userId))) { - return API.v1.forbidden(); + const { params = '' } = body; + if (body.tmid) { + const thread = await Messages.findOneById(body.tmid); + if (thread?.rid !== body.roomId) { + return API.v1.failure('Invalid thread.'); } + } - const { params = '' } = body; - if (body.tmid) { - const thread = await Messages.findOneById(body.tmid); - if (!thread || thread.rid !== body.roomId) { - return API.v1.failure('Invalid thread.'); - } - } + const msg = { + rid: body.roomId, + ...(body.tmid && { tmid: body.tmid }), + }; - const msg = { - rid: body.roomId, - ...(body.tmid && { tmid: body.tmid }), - }; - - await executeSlashCommandPreview( - { - cmd, - params, - msg, - triggerId: body.triggerId, - }, - body.previewItem, - this.userId, - ); + await executeSlashCommandPreview( + { + cmd, + params, + msg, + triggerId: body.triggerId, + }, + body.previewItem, + this.userId, + ); - return API.v1.success(); - }, + return API.v1.success(); }, ); diff --git a/apps/meteor/tests/end-to-end/api/commands.ts b/apps/meteor/tests/end-to-end/api/commands.ts index fbb115e87a6be..b2c36521a9404 100644 --- a/apps/meteor/tests/end-to-end/api/commands.ts +++ b/apps/meteor/tests/end-to-end/api/commands.ts @@ -124,22 +124,23 @@ describe('[Commands]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal('You must provide a command to run.'); + expect(res.body.error).to.be.equal("must have required property 'command'"); }) .end(done); }); - it('should return an error when call the endpoint with the param "params" and it is not a string', (done) => { + + it('should coerce non-string "params" to string via ajv coercion', (done) => { void request .post(api('commands.run')) .set(credentials) .send({ command: 'help', + roomId: 'GENERAL', params: true, }) - .expect(400) + .expect(200) .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal('The parameters for the command must be a single string.'); + expect(res.body).to.have.property('success', true); }) .end(done); }); @@ -154,11 +155,11 @@ describe('[Commands]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal("The room's id where to execute this command must be provided and be a string."); + expect(res.body.error).to.be.equal("must have required property 'roomId'"); }) .end(done); }); - it('should return an error when call the endpoint with the param "tmid" and it is not a string', (done) => { + it('should coerce non-string "tmid" to string via ajv coercion and fail with invalid thread', (done) => { void request .post(api('commands.run')) .set(credentials) @@ -171,7 +172,7 @@ describe('[Commands]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal('The tmid parameter when provided must be a string.'); + expect(res.body.error).to.be.equal('Invalid thread.'); }) .end(done); }); @@ -437,11 +438,6 @@ describe('[Commands]', () => { roomId: channel._id, command: 'invite-all-from', params: `#${group.name}`, - msg: { - _id: Random.id(), - rid: channel._id, - msg: `invite-all-from #${group.name}`, - }, triggerId: Random.id(), }) .expect(200) @@ -468,11 +464,6 @@ describe('[Commands]', () => { roomId: group1._id, command: 'invite-all-from', params: `#${group.name}`, - msg: { - _id: Random.id(), - rid: group1._id, - msg: `invite-all-from #${group.name}`, - }, triggerId: Random.id(), }) .expect(403) @@ -498,11 +489,6 @@ describe('[Commands]', () => { roomId: channel._id, command: 'invite-all-from', params: `#${group.name}`, - msg: { - _id: Random.id(), - rid: channel._id, - msg: `invite-all-from #${group.name}`, - }, triggerId: Random.id(), }) .expect(200) diff --git a/apps/meteor/tests/end-to-end/apps/slash-command-test-simple.ts b/apps/meteor/tests/end-to-end/apps/slash-command-test-simple.ts index 56031faf44869..4d547d6be07cc 100644 --- a/apps/meteor/tests/end-to-end/apps/slash-command-test-simple.ts +++ b/apps/meteor/tests/end-to-end/apps/slash-command-test-simple.ts @@ -27,7 +27,7 @@ import { IS_EE } from '../../e2e/config/constants'; .expect(400) .expect((res) => { expect(res.body).to.have.a.property('success', false); - expect(res.body.error).to.be.equal('You must provide a command to run.'); + expect(res.body.error).to.include('must NOT have fewer than 1 characters'); }) .end(done); }); From c833b3058fffaff796febc14c0bc0cf31e362316 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 15 Apr 2026 00:00:33 -0300 Subject: [PATCH 07/14] chore(api): migrate dm/im endpoints to OpenAPI --- apps/meteor/app/api/server/v1/im.ts | 1064 +++++++++++++++++---------- 1 file changed, 659 insertions(+), 405 deletions(-) diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index 30b9f362bddca..1cbcf3237e3ac 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -5,6 +5,7 @@ import type { IMessage, IRoom, ISubscription, IUser } from '@rocket.chat/core-ty import { Subscriptions, Uploads, Messages, Rooms, Users } from '@rocket.chat/models'; import { ajv, + ajvQuery, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, validateBadRequestErrorResponse, @@ -70,28 +71,6 @@ const findDirectMessageRoom = async ( }; }; -API.v1.addRoute( - ['dm.create', 'im.create'], - { - authRequired: true, - validateParams: isDmCreateProps, - }, - { - async post() { - const users = - 'username' in this.bodyParams - ? [this.bodyParams.username] - : this.bodyParams.usernames.split(',').map((username: string) => username.trim()); - - const room = await createDirectMessage(users, this.userId, this.bodyParams.excludeSelf); - - return API.v1.success({ - room: { ...room, _id: room.rid }, - }); - }, - }, -); - type DmDeleteProps = | { roomId: string; @@ -219,7 +198,7 @@ const dmCloseAction = (_path: Path): TypedAction(_path: Path): TypedAction({ + type: 'object', + properties: { roomId: { type: 'string' } }, + required: ['roomId'], + additionalProperties: false, +}); - if (!canAccess) { - return API.v1.forbidden(); - } +const dmOpenEndpointsProps = { + authRequired: true, + body: isDmOpenProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, + }), + }, +} as const; - const { room, subscription } = await findDirectMessageRoom({ roomId }, user); +const dmOpenAction = (_path: Path): TypedAction => + async function action() { + const { roomId } = this.bodyParams; + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } - lm = room?.lm ? new Date(room.lm).toISOString() : new Date(room._updatedAt).toISOString(); // lm is the last message timestamp + const { room, subscription } = await findDirectMessageRoom({ roomId }, this.userId); - if (subscription) { - unreads = subscription.unread ?? null; - if (subscription.ls && room.msgs) { - unreadsFrom = new Date(subscription.ls).toISOString(); // last read timestamp - } - userMentions = subscription.userMentions; - joined = true; - } + if (!subscription?.open) { + await openRoom(this.userId, room._id); + } - if (access || joined) { - msgs = room.msgs; - latest = lm; - members = await Users.countActiveUsersInDMRoom(room._id); - } + return API.v1.success(); + }; - return API.v1.success({ - joined, - members, - unreads, - unreadsFrom, - msgs, - latest, - userMentions, - }); - }, +const isDmSetTopicProps = ajv.compile<{ roomId: string; topic?: string }>({ + type: 'object', + properties: { + roomId: { type: 'string' }, + topic: { type: 'string', nullable: true }, }, -); + required: ['roomId'], + additionalProperties: false, +}); -API.v1.addRoute( - ['dm.files', 'im.files'], - { - authRequired: true, - validateParams: isDmFileProps, +const dmSetTopicResponseSchema = ajv.compile<{ topic?: string }>({ + type: 'object', + properties: { + topic: { type: 'string', nullable: true }, + success: { type: 'boolean', enum: [true] }, }, - { - async get() { - const { typeGroup, name, roomId, username, onlyConfirmed } = this.queryParams; + required: ['success'], + additionalProperties: false, +}); - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); +const dmSetTopicEndpointsProps = { + authRequired: true, + body: isDmSetTopicProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmSetTopicResponseSchema, + }, +}; - const { room } = await findDirectMessageRoom(roomId ? { roomId } : { username }, this.userId); +const dmSetTopicAction = (_path: Path): TypedAction => + async function action() { + const { roomId, topic } = this.bodyParams; + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); + } + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } - const canAccess = await canAccessRoomIdAsync(room._id, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } - const filter = { - ...query, - rid: room._id, - ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}), - ...(typeGroup ? { typeGroup } : {}), - ...(onlyConfirmed && { expiresAt: { $exists: false } }), - }; + const { room } = await findDirectMessageRoom({ roomId }, this.userId); - const { cursor, totalCount } = Uploads.findPaginatedWithoutThumbs(filter, { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - projection: fields, - }); + // saveRoomTopic treats undefined and '' identically + await saveRoomSettings(this.userId, room._id, 'roomTopic', topic ?? ''); - const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + return API.v1.success({ + topic, + }); + }; - return API.v1.success({ - files: await addUserToFileObj(files), - count: files.length, - offset, - total, - }); - }, +type DmCountersProps = { + roomId: string; + userId?: string; +}; + +const isDmCountersProps = ajvQuery.compile({ + type: 'object', + properties: { + roomId: { type: 'string' }, + userId: { type: 'string', nullable: true }, }, -); - -API.v1.addRoute( - ['dm.history', 'im.history'], - { authRequired: true, validateParams: isDmHistoryProps }, - { - async get() { - const { offset = 0, count = 20 } = await getPaginationItems(this.queryParams); - const { roomId, latest, oldest, inclusive, unreads, showThreadMessages } = this.queryParams; - - if (!roomId) { - throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" is required'); - } - const { room } = await findDirectMessageRoom({ roomId }, this.userId); - - const objectParams = { - rid: room._id, - fromUserId: this.userId, - latest: latest ? new Date(latest) : new Date(), - oldest: oldest ? new Date(oldest) : undefined, - inclusive: inclusive === 'true', - offset, - count, - unreads: unreads === 'true', - showThreadMessages: showThreadMessages === 'true', - }; + required: ['roomId'], + additionalProperties: false, +}); + +const dmCountersResponseSchema = ajv.compile<{ + joined: boolean; + members: number | null; + unreads: number | null; + unreadsFrom: string | null; + msgs: number | null; + latest: string | null; + userMentions: number | null; +}>({ + type: 'object', + properties: { + joined: { type: 'boolean' }, + members: { type: 'number', nullable: true }, + unreads: { type: 'number', nullable: true }, + unreadsFrom: { type: 'string', nullable: true }, + msgs: { type: 'number', nullable: true }, + latest: { type: 'string', nullable: true }, + userMentions: { type: 'number', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['joined', 'members', 'unreads', 'unreadsFrom', 'msgs', 'latest', 'userMentions', 'success'], + additionalProperties: false, +}); - const result = await getChannelHistory(objectParams); +const dmCountersEndpointsProps = { + authRequired: true, + query: isDmCountersProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmCountersResponseSchema, + }, +}; + +const dmCountersAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } - if (!result) { + const access = await hasPermissionAsync(this.userId, 'view-room-administration'); + const { roomId, userId: ruserId } = this.queryParams; + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" is required'); + } + let user = this.userId; + let unreads = null; + let userMentions = null; + let unreadsFrom = null; + let joined = false; + let msgs = null; + let latest = null; + let members = null; + let lm = null; + + if (ruserId) { + if (!access) { return API.v1.forbidden(); } + user = ruserId; + } + const canAccess = await canAccessRoomIdAsync(roomId, user); - return API.v1.success(result); - }, - }, -); + if (!canAccess) { + return API.v1.forbidden(); + } -API.v1.addRoute( - ['dm.members', 'im.members'], - { - authRequired: true, - validateParams: isDmMemberProps, - }, - { - async get() { - const { room } = await findDirectMessageRoom(this.queryParams, this.userId); + const { room, subscription } = await findDirectMessageRoom({ roomId }, user); - const canAccess = await canAccessRoomIdAsync(room._id, this.userId); - if (!canAccess) { - return API.v1.forbidden(); + lm = room?.lm ? new Date(room.lm).toISOString() : new Date(room._updatedAt).toISOString(); + + if (subscription) { + unreads = subscription.unread ?? null; + if (subscription.ls && room.msgs) { + unreadsFrom = new Date(subscription.ls).toISOString(); } + userMentions = subscription.userMentions; + joined = true; + } - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - - check( - this.queryParams, - Match.ObjectIncluding({ - status: Match.Maybe([String]), - filter: Match.Maybe(String), - }), - ); - const { status, filter } = this.queryParams; - - const extraQuery = { - _id: { $in: room.uids }, - ...(status && { status: { $in: status } }), - }; + if (access || joined) { + msgs = room.msgs; + latest = lm; + members = await Users.countActiveUsersInDMRoom(room._id); + } - const options: FindOptions = { - projection: { - _id: 1, - username: 1, - name: 1, - status: 1, - statusText: 1, - utcOffset: 1, - federated: 1, - freeSwitchExtension: 1, - }, - skip: offset, - limit: count, - sort: { - _updatedAt: -1, - username: sort?.username ? sort.username : 1, - }, - }; + return API.v1.success({ + joined, + members, + unreads, + unreadsFrom, + msgs, + latest, + userMentions, + }); + }; - const searchFields = settings.get('Accounts_SearchFields').trim().split(','); +const dmFilesResponseSchema = ajv.compile<{ files: object[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + files: { type: 'array', items: { type: 'object' } }, // relaxed: IUpload with addUserToFileObj transform + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['files', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); - const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(filter, [], options, searchFields, [extraQuery]); +const dmFilesEndpointsProps = { + authRequired: true, + query: isDmFileProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmFilesResponseSchema, + }, +}; - const [members, total] = await Promise.all([cursor.toArray(), totalCount]); +const dmFilesAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } - // find subscriptions of those users - const subs = await Subscriptions.findByRoomIdAndUserIds( - room._id, - members.map((member) => member._id), - { projection: { u: 1, status: 1, ts: 1, roles: 1 } }, - ).toArray(); + const { typeGroup, name, roomId, username, onlyConfirmed } = this.queryParams; - const membersWithSubscriptionInfo = members.map((member) => { - const sub = subs.find((sub) => sub.u._id === member._id); + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); - const { u: _u, ...subscription } = sub || {}; + const { room } = await findDirectMessageRoom(roomId ? { roomId } : { username }, this.userId); - return { - ...member, - subscription, - }; - }); + const canAccess = await canAccessRoomIdAsync(room._id, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } - return API.v1.success({ - members: membersWithSubscriptionInfo, - count: members.length, - offset, - total, - }); - }, + const filter = { + ...query, + rid: room._id, + ...(name ? { name: { $regex: name || '', $options: 'i' } } : {}), + ...(typeGroup ? { typeGroup } : {}), + ...(onlyConfirmed && { expiresAt: { $exists: false } }), + }; + + const { cursor, totalCount } = Uploads.findPaginatedWithoutThumbs(filter, { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + files: await addUserToFileObj(files), + count: files.length, + offset, + total, + }); + }; + +const dmMembersResponseSchema = ajv.compile<{ members: object[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + members: { type: 'array', items: { type: 'object' } }, // relaxed: projected IUser + subscription info + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, }, -); + required: ['members', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); -API.v1.addRoute( - ['dm.messages', 'im.messages'], - { - authRequired: true, - validateParams: isDmMessagesProps, +const dmMembersEndpointsProps = { + authRequired: true, + query: isDmMemberProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmMembersResponseSchema, }, - { - async get() { - const { roomId, username, mentionIds, starredIds, pinned } = this.queryParams; +}; - const { room } = await findDirectMessageRoom({ ...(roomId ? { roomId } : { username }) }, this.userId); +const dmMembersAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } - const canAccess = await canAccessRoomIdAsync(room._id, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } + const { room } = await findDirectMessageRoom(this.queryParams, this.userId); - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); + const canAccess = await canAccessRoomIdAsync(room._id, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + check( + this.queryParams, + Match.ObjectIncluding({ + status: Match.Maybe([String]), + filter: Match.Maybe(String), + }), + ); + const { status, filter } = this.queryParams; + + const extraQuery: Record = { + _id: { $in: room.uids }, + ...(status && { status: { $in: status } }), + }; + + const options: FindOptions = { + projection: { + _id: 1, + username: 1, + name: 1, + status: 1, + statusText: 1, + utcOffset: 1, + federated: 1, + freeSwitchExtension: 1, + }, + skip: offset, + limit: count, + sort: { + _updatedAt: -1, + username: sort?.username ? sort.username : 1, + }, + }; + + const searchFields = settings.get('Accounts_SearchFields').trim().split(','); - const parseIds = (ids: string | undefined, field: string) => - typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {}; + const { cursor, totalCount } = Users.findPaginatedByActiveUsersExcept(filter ?? '', [], options, searchFields, [extraQuery]); - const ourQuery = { - rid: room._id, - ...query, - ...parseIds(mentionIds, 'mentions._id'), - ...parseIds(starredIds, 'starred._id'), - ...(pinned && pinned.toLowerCase() === 'true' ? { pinned: true } : {}), - _hidden: { $ne: true }, + const [members, total] = await Promise.all([cursor.toArray(), totalCount]); + + // find subscriptions of those users + const subs = await Subscriptions.findByRoomIdAndUserIds( + room._id, + members.map((member) => member._id), + { projection: { u: 1, status: 1, ts: 1, roles: 1 } }, + ).toArray(); + + const membersWithSubscriptionInfo = members.map((member) => { + const sub = subs.find((sub) => sub.u._id === member._id); + + const { u: _u, ...subscription } = sub || {}; + + return { + ...member, + subscription, }; - const sortObj = sort || { ts: -1 }; + }); - const { cursor, totalCount } = Messages.findPaginated(ourQuery, { - sort: sortObj, - skip: offset, - limit: count, - ...(fields && { projection: fields }), - }); + return API.v1.success({ + members: membersWithSubscriptionInfo, + count: members.length, + offset, + total, + }); + }; - const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); +const dmMessagesResponseSchema = ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, +}); - return API.v1.success({ - messages: await normalizeMessagesForUser(messages, this.userId), - count: messages.length, - offset, - total, - }); - }, +const dmMessagesEndpointsProps = { + authRequired: true, + query: isDmMessagesProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmMessagesResponseSchema, }, -); - -API.v1.addRoute( - ['dm.messages.others', 'im.messages.others'], - { authRequired: true, permissionsRequired: ['view-room-administration'] }, - { - async get() { - if (settings.get('API_Enable_Direct_Message_History_EndPoint') !== true) { - throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { - route: '/api/v1/im.messages.others', - }); - } +}; - const { roomId } = this.queryParams; - if (!roomId) { - throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" is required'); - } +const dmMessagesAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } - const room = await Rooms.findOneById>(roomId, { projection: { _id: 1, t: 1 } }); - if (!room || room?.t !== 'd') { - throw new Meteor.Error('error-room-not-found', `No direct message room found by the id of: ${roomId}`); - } + const { roomId, username, mentionIds, starredIds, pinned } = this.queryParams as { + roomId?: string; + username?: string; + mentionIds?: string; + starredIds?: string; + pinned?: string; + }; - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - const ourQuery = Object.assign({}, query, { rid: room._id }); + const { room } = await findDirectMessageRoom({ ...(roomId ? { roomId } : { username }) }, this.userId); - const { cursor, totalCount } = Messages.findPaginated(ourQuery, { - sort: sort || { ts: -1 }, - skip: offset, - limit: count, - projection: fields, - }); + const canAccess = await canAccessRoomIdAsync(room._id, this.userId); + if (!canAccess) { + return API.v1.forbidden(); + } - const [msgs, total] = await Promise.all([cursor.toArray(), totalCount]); + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const parseIds = (ids: string | undefined, field: string) => + typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {}; + + const ourQuery = { + rid: room._id, + ...query, + ...parseIds(mentionIds, 'mentions._id'), + ...parseIds(starredIds, 'starred._id'), + ...(pinned?.toLowerCase() === 'true' && { pinned: true }), + _hidden: { $ne: true }, + }; + const sortObj = sort || { ts: -1 }; + + const { cursor, totalCount } = Messages.findPaginated(ourQuery, { + sort: sortObj, + skip: offset, + limit: count, + ...(fields && { projection: fields }), + }); - if (!msgs) { - throw new Meteor.Error('error-no-messages', 'No messages found'); - } + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - messages: await normalizeMessagesForUser(msgs, this.userId), - offset, - count: msgs.length, - total, - }); - }, + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId), + count: messages.length, + offset, + total, + }); + }; + +const dmHistoryResponseSchema = ajv.compile>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + success: { type: 'boolean', enum: [true] }, }, -); - -API.v1.addRoute( - ['dm.list', 'im.list'], - { authRequired: true }, - { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort = { name: 1 }, fields } = await this.parseJsonQuery(); - - // TODO: CACHE: Add Breaking notice since we removed the query param - - const subscriptions = await Subscriptions.find({ 'u._id': this.userId, 't': 'd' }, { projection: { rid: 1 } }) - .map((item) => item.rid) - .toArray(); - - const { cursor, totalCount } = Rooms.findPaginated( - { t: 'd', _id: { $in: subscriptions } }, - { - sort, - skip: offset, - limit: count, - projection: fields, - }, - ); + required: ['messages', 'success'], + additionalProperties: true, +}); - const [ims, total] = await Promise.all([cursor.toArray(), totalCount]); +const dmHistoryEndpointsProps = { + authRequired: true, + query: isDmHistoryProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: dmHistoryResponseSchema, + }, +}; - return API.v1.success({ - ims: await Promise.all(ims.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), - offset, - count: ims.length, - total, - }); - }, +const dmHistoryAction = (_path: Path): TypedAction => + async function action() { + if (!this.userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user'); + } + + const { offset = 0, count = 20 } = await getPaginationItems(this.queryParams); + const { roomId, latest, oldest, inclusive, unreads, showThreadMessages } = this.queryParams; + + if (!roomId) { + throw new Meteor.Error('error-room-param-not-provided', 'Query param "roomId" is required'); + } + const { room } = await findDirectMessageRoom({ roomId }, this.userId); + + const objectParams = { + rid: room._id, + fromUserId: this.userId, + latest: latest ? new Date(latest) : new Date(), + oldest: oldest ? new Date(oldest) : undefined, + inclusive: inclusive === 'true', + offset, + count, + unreads: unreads === 'true', + showThreadMessages: showThreadMessages === 'true', + }; + + const result = await getChannelHistory(objectParams); + + if (!result) { + return API.v1.forbidden(); + } + + return API.v1.success(result as Record); + }; + +const dmCreateResponseSchema = ajv.compile<{ room: IRoom & { rid: string } }>({ + type: 'object', + properties: { + room: { type: 'object' }, // relaxed: IRoom shape varies, + success: { type: 'boolean', enum: [true] }, }, -); - -API.v1.addRoute( - ['dm.list.everyone', 'im.list.everyone'], - { authRequired: true, permissionsRequired: ['view-room-administration'] }, - { - async get() { - const { offset, count }: { offset: number; count: number } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - - const { cursor, totalCount } = Rooms.findPaginated( - { ...query, t: 'd' }, - { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - projection: fields, - }, - ); + required: ['room', 'success'], + additionalProperties: false, +}); - const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); +const paginatedMessagesResponseSchema = ajv.compile<{ messages: IMessage[]; offset: number; count: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + offset: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'offset', 'count', 'total', 'success'], + additionalProperties: false, +}); - return API.v1.success({ - ims: await Promise.all(rooms.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), - offset, - count: rooms.length, - total, - }); - }, +const paginatedImsResponseSchema = ajv.compile<{ ims: IRoom[]; offset: number; count: number; total: number }>({ + type: 'object', + properties: { + ims: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with lastMessage compose + offset: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, }, -); + required: ['ims', 'offset', 'count', 'total', 'success'], + additionalProperties: false, +}); -API.v1.addRoute( - ['dm.open', 'im.open'], - { authRequired: true }, - { - async post() { - const { roomId } = this.bodyParams; +const dmMessagesOthersEndpointsProps = { + authRequired: true as const, + permissionsRequired: ['view-room-administration'], + response: { + 200: paginatedMessagesResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, +}; - if (!roomId) { - throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); - } - const canAccess = await canAccessRoomIdAsync(roomId, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } +const dmMessagesOthersAction = (_name: Path): TypedAction => + async function action() { + if (settings.get('API_Enable_Direct_Message_History_EndPoint') !== true) { + throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { + route: '/api/v1/im.messages.others', + }); + } - const { room, subscription } = await findDirectMessageRoom({ roomId }, this.userId); + const { roomId } = this.queryParams; + if (!roomId) { + throw new Meteor.Error('error-roomid-param-not-provided', 'The parameter "roomId" is required'); + } - if (!subscription?.open) { - await openRoom(this.userId, room._id); - } + const room = await Rooms.findOneById>(roomId, { projection: { _id: 1, t: 1 } }); + if (!room || room?.t !== 'd') { + throw new Meteor.Error('error-room-not-found', `No direct message room found by the id of: ${roomId}`); + } - return API.v1.success(); - }, + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const ourQuery = Object.assign({}, query, { rid: room._id }); + + const { cursor, totalCount } = Messages.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [msgs, total] = await Promise.all([cursor.toArray(), totalCount]); + + if (!msgs) { + throw new Meteor.Error('error-no-messages', 'No messages found'); + } + + return API.v1.success({ + messages: await normalizeMessagesForUser(msgs, this.userId), + offset, + count: msgs.length, + total, + }); + }; + +const dmListEndpointsProps = { + authRequired: true as const, + response: { + 200: paginatedImsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, -); +}; -API.v1.addRoute( - ['dm.setTopic', 'im.setTopic'], - { authRequired: true }, - { - async post() { - const { roomId, topic } = this.bodyParams; +const dmListAction = (_name: Path): TypedAction => + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort = { name: 1 }, fields } = await this.parseJsonQuery(); - if (!roomId) { - throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); - } + // TODO: CACHE: Add Breaking notice since we removed the query param - const canAccess = await canAccessRoomIdAsync(roomId, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } + const subscriptions = await Subscriptions.find({ 'u._id': this.userId, 't': 'd' }, { projection: { rid: 1 } }) + .map((item) => item.rid) + .toArray(); - const { room } = await findDirectMessageRoom({ roomId }, this.userId); + const { cursor, totalCount } = Rooms.findPaginated( + { t: 'd', _id: { $in: subscriptions } }, + { + sort, + skip: offset, + limit: count, + projection: fields, + }, + ); - await saveRoomSettings(this.userId, room._id, 'roomTopic', topic); + const [ims, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - topic, - }); - }, + return API.v1.success({ + ims: await Promise.all(ims.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: ims.length, + total, + }); + }; + +const dmListEveryoneEndpointsProps = { + authRequired: true as const, + permissionsRequired: ['view-room-administration'], + response: { + 200: paginatedImsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, +}; + +const dmListEveryoneAction = (_name: Path): TypedAction => + async function action() { + const { offset, count }: { offset: number; count: number } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + + const { cursor, totalCount } = Rooms.findPaginated( + { ...query, t: 'd' }, + { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }, + ); + + const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + ims: await Promise.all(rooms.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: rooms.length, + total, + }); + }; + +const dmCreateEndpointsProps = { + authRequired: true, + body: isDmCreateProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: dmCreateResponseSchema, }, -); +} as const; + +const dmCreateAction = (_path: Path): TypedAction => + async function action() { + const users = + 'username' in this.bodyParams + ? [this.bodyParams.username] + : this.bodyParams.usernames.split(',').map((username: string) => username.trim()); + + const room = await createDirectMessage(users, this.userId, this.bodyParams.excludeSelf); + + return API.v1.success({ + room: { ...room, _id: room.rid }, + }); + }; + +const dmEndpoints = API.v1 + .post('im.delete', dmDeleteEndpointsProps, dmDeleteAction('im.delete')) + .post('dm.delete', dmDeleteEndpointsProps, dmDeleteAction('dm.delete')) + .post('dm.close', dmCloseEndpointsProps, dmCloseAction('dm.close')) + .post('im.close', dmCloseEndpointsProps, dmCloseAction('im.close')) + .post('dm.create', dmCreateEndpointsProps, dmCreateAction('dm.create')) + .post('im.create', dmCreateEndpointsProps, dmCreateAction('im.create')) + .post('dm.open', dmOpenEndpointsProps, dmOpenAction('dm.open')) + .post('im.open', dmOpenEndpointsProps, dmOpenAction('im.open')) + .post('dm.setTopic', dmSetTopicEndpointsProps, dmSetTopicAction('dm.setTopic')) + .post('im.setTopic', dmSetTopicEndpointsProps, dmSetTopicAction('im.setTopic')) + .get('dm.counters', dmCountersEndpointsProps, dmCountersAction('dm.counters')) + .get('im.counters', dmCountersEndpointsProps, dmCountersAction('im.counters')) + .get('dm.files', dmFilesEndpointsProps, dmFilesAction('dm.files')) + .get('im.files', dmFilesEndpointsProps, dmFilesAction('im.files')) + .get('dm.members', dmMembersEndpointsProps, dmMembersAction('dm.members')) + .get('im.members', dmMembersEndpointsProps, dmMembersAction('im.members')) + .get('dm.messages', dmMessagesEndpointsProps, dmMessagesAction('dm.messages')) + .get('im.messages', dmMessagesEndpointsProps, dmMessagesAction('im.messages')) + .get('dm.history', dmHistoryEndpointsProps, dmHistoryAction('dm.history')) + .get('im.history', dmHistoryEndpointsProps, dmHistoryAction('im.history')) + .get('dm.messages.others', dmMessagesOthersEndpointsProps, dmMessagesOthersAction('dm.messages.others')) + .get('im.messages.others', dmMessagesOthersEndpointsProps, dmMessagesOthersAction('im.messages.others')) + .get('dm.list', dmListEndpointsProps, dmListAction('dm.list')) + .get('im.list', dmListEndpointsProps, dmListAction('im.list')) + .get('dm.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('dm.list.everyone')) + .get('im.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('im.list.everyone')); export type DmEndpoints = ExtractRoutesFromAPI; From 387040bd1649bda3bcd98a4bb1c07cd90336bfb2 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 15 Apr 2026 00:00:33 -0300 Subject: [PATCH 08/14] chore(api): migrate chat, autotranslate endpoints to OpenAPI --- .../meteor/app/api/server/v1/autotranslate.ts | 14 +- apps/meteor/app/api/server/v1/chat.ts | 979 ++++++++++++------ .../autotranslate/client/lib/autotranslate.ts | 1 - .../app/message-pin/server/pinMessage.ts | 4 +- apps/meteor/lib/createQuoteAttachment.ts | 2 +- apps/meteor/tests/end-to-end/api/chat.ts | 24 +- 6 files changed, 671 insertions(+), 353 deletions(-) diff --git a/apps/meteor/app/api/server/v1/autotranslate.ts b/apps/meteor/app/api/server/v1/autotranslate.ts index 74d857f5b63fa..4d9c734daaae1 100644 --- a/apps/meteor/app/api/server/v1/autotranslate.ts +++ b/apps/meteor/app/api/server/v1/autotranslate.ts @@ -91,16 +91,8 @@ const autotranslateEndpoints = API.v1 return API.v1.failure('AutoTranslate is disabled.'); } - if (!roomId) { - return API.v1.failure('The bodyParam "roomId" is required.'); - } - if (!field) { - return API.v1.failure('The bodyParam "field" is required.'); - } - if (value === undefined) { - return API.v1.failure('The bodyParam "value" is required.'); - } - if (field === 'autoTranslate' && typeof value !== 'boolean') { + // ajv 2020-12 with coerceTypes coerces booleans to strings, so check for both + if (field === 'autoTranslate' && value !== true && value !== 'true' && value !== false && value !== 'false') { return API.v1.failure('The bodyParam "autoTranslate" must be a boolean.'); } @@ -108,7 +100,7 @@ const autotranslateEndpoints = API.v1 return API.v1.failure('The bodyParam "autoTranslateLanguage" must be a string.'); } - await saveAutoTranslateSettings(this.userId, roomId, field, value === true ? '1' : String(value).valueOf(), { + await saveAutoTranslateSettings(this.userId, roomId, field, value === true || value === 'true' ? '1' : String(value), { defaultLanguage: defaultLanguage || '', }); diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index a00d57e46ae72..383f4312c53b5 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -127,118 +127,6 @@ const isChatFollowMessageLocalProps = ajv.compile(ChatFo const isChatUnfollowMessageLocalProps = ajv.compile(ChatUnfollowMessageLocalSchema); -API.v1.addRoute( - 'chat.delete', - { authRequired: true, validateParams: isChatDeleteProps }, - { - async post() { - const msg = await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); - - if (!msg) { - return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`); - } - - if (this.bodyParams.roomId !== msg.rid) { - return API.v1.failure('The room id provided does not match where the message is from.'); - } - - if ( - this.bodyParams.asUser && - msg.u._id !== this.userId && - !(await hasPermissionAsync(this.userId, 'force-delete-message', msg.rid)) - ) { - return API.v1.failure('Unauthorized. You must have the permission "force-delete-message" to delete other\'s message as them.'); - } - - const userId = this.bodyParams.asUser ? msg.u._id : this.userId; - const user = await Users.findOneById(userId, { projection: { _id: 1 } }); - - if (!user) { - return API.v1.failure('User not found'); - } - - await deleteMessageValidatingPermission(msg, user._id); - - return API.v1.success({ - _id: msg._id, - ts: Date.now().toString(), - message: msg, - }); - }, - }, -); - -API.v1.addRoute( - 'chat.syncMessages', - { authRequired: true, validateParams: isChatSyncMessagesProps }, - { - async get() { - const { roomId, lastUpdate, count, next, previous, type } = this.queryParams; - - if (!roomId) { - throw new Meteor.Error('error-param-required', 'The required "roomId" query param is missing'); - } - - if (!lastUpdate && !type) { - throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided'); - } - - if (lastUpdate && isNaN(Date.parse(lastUpdate))) { - throw new Meteor.Error('error-lastUpdate-param-invalid', 'The "lastUpdate" query parameter must be a valid date'); - } - - const getMessagesQuery = { - ...(lastUpdate && { lastUpdate: new Date(lastUpdate) }), - ...(next && { next }), - ...(previous && { previous }), - ...(count && { count }), - ...(type && { type }), - }; - - const result = await getMessageHistory(roomId, this.userId, getMessagesQuery); - - if (!result) { - return API.v1.failure(); - } - - return API.v1.success({ - result: { - updated: 'updated' in result ? await normalizeMessagesForUser(result.updated, this.userId) : [], - deleted: 'deleted' in result ? result.deleted : [], - cursor: 'cursor' in result ? result.cursor : undefined, - }, - }); - }, - }, -); - -API.v1.addRoute( - 'chat.getMessage', - { - authRequired: true, - validateParams: isChatGetMessageProps, - }, - { - async get() { - if (!this.queryParams.msgId) { - return API.v1.failure('The "msgId" query parameter must be provided.'); - } - - const msg = await getSingleMessage(this.userId, this.queryParams.msgId); - - if (!msg) { - return API.v1.failure(); - } - - const [message] = await normalizeMessagesForUser([msg], this.userId); - - return API.v1.success({ - message, - }); - }, - }, -); - type ChatPinMessage = { messageId: IMessage['_id']; }; @@ -359,7 +247,7 @@ const chatEndpoints = API.v1 200: ajv.compile<{ message: IMessage }>({ type: 'object', properties: { - message: { type: 'object' }, + message: { $ref: '#/components/schemas/IMessage' }, success: { type: 'boolean', enum: [true], @@ -558,106 +446,29 @@ const chatEndpoints = API.v1 return API.v1.success(); }, - ); - -API.v1.addRoute( - 'chat.postMessage', - { authRequired: true, validateParams: isChatPostMessageProps }, - { - async post() { - const { text, attachments } = this.bodyParams; - const maxAllowedSize = settings.get('Message_MaxAllowedSize') ?? 0; - - if (text && text.length > maxAllowedSize) { - return API.v1.failure('error-message-size-exceeded'); - } - - if (attachments && attachments.length > 0) { - for (const attachment of attachments) { - if (attachment.text && attachment.text.length > maxAllowedSize) { - return API.v1.failure('error-message-size-exceeded'); - } - } - } - - const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0]; - - if (!messageReturn?.message) { - return API.v1.failure('unknown-error'); - } - - const [message] = await normalizeMessagesForUser([messageReturn.message], this.userId); - - return API.v1.success({ - ts: Date.now(), - channel: messageReturn.channel, - message, - }); - }, - }, -); - -API.v1.addRoute( - 'chat.search', - { authRequired: true, validateParams: isChatSearchProps }, - { - async get() { - const { roomId, searchText } = this.queryParams; - const { offset, count } = await getPaginationItems(this.queryParams); - - if (!roomId) { - throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.'); - } - - if (!searchText) { - throw new Meteor.Error('error-searchText-param-not-provided', 'The required "searchText" query param is missing.'); - } - - const searchResult = await messageSearch(this.userId, searchText, roomId, count, offset); - if (searchResult === false) { - return API.v1.failure(); - } - if (!searchResult.message) { - return API.v1.failure(); - } - const result = searchResult.message.docs; - - return API.v1.success({ - messages: await normalizeMessagesForUser(result, this.userId), - }); - }, - }, -); - -// The difference between `chat.postMessage` and `chat.sendMessage` is that `chat.sendMessage` allows -// for passing a value for `_id` and the other one doesn't. Also, `chat.sendMessage` only sends it to -// one channel whereas the other one allows for sending to more than one channel at a time. -API.v1.addRoute( - 'chat.sendMessage', - { authRequired: true, validateParams: isChatSendMessageProps }, - { - async post() { - if (MessageTypes.isSystemMessage(this.bodyParams.message)) { - throw new Error("Cannot send system messages using 'chat.sendMessage'"); - } - - const sent = await applyAirGappedRestrictionsValidation(() => - executeSendMessage(this.user, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }), - ); - const [message] = await normalizeMessagesForUser([sent], this.userId); - - return API.v1.success({ - message, - }); + ) + .post( + 'chat.react', + { + authRequired: true, + body: isChatReactProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - }, -); - -API.v1.addRoute( - 'chat.react', - { authRequired: true, validateParams: isChatReactProps }, - { - async post() { + async function action() { const msg = await Messages.findOneById(this.bodyParams.messageId); if (!msg) { @@ -674,60 +485,428 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'chat.reportMessage', - { authRequired: true, validateParams: isChatReportMessageProps }, - { - async post() { + ) + .post( + 'chat.reportMessage', + { + authRequired: true, + body: isChatReportMessageProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { messageId, description } = this.bodyParams; - if (!messageId) { - return API.v1.failure('The required "messageId" param is missing.'); - } - - if (!description) { - return API.v1.failure('The required "description" param is missing.'); - } await reportMessage(messageId, description, this.userId); return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'chat.ignoreUser', - { authRequired: true, validateParams: isChatIgnoreUserProps }, - { - async get() { - const { rid, userId } = this.queryParams; - let { ignore = true } = this.queryParams; + ) + .post( + 'chat.delete', + { + authRequired: true, + body: isChatDeleteProps, + response: { + 200: ajv.compile<{ _id: string; ts: string; message: Pick }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + ts: { type: 'string' }, + message: { + type: 'object', + properties: { + _id: { type: 'string' }, + rid: { type: 'string' }, + u: { type: 'object' }, + }, + required: ['_id', 'rid', 'u'], + additionalProperties: true, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['_id', 'ts', 'message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const msg = await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); - ignore = typeof ignore === 'string' ? /true|1/.test(ignore) : ignore; + if (!msg) { + return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`); + } - if (!rid?.trim()) { - throw new Meteor.Error('error-room-id-param-not-provided', 'The required "rid" param is missing.'); + if (this.bodyParams.roomId !== msg.rid) { + return API.v1.failure('The room id provided does not match where the message is from.'); } - if (!userId?.trim()) { - throw new Meteor.Error('error-user-id-param-not-provided', 'The required "userId" param is missing.'); + if ( + this.bodyParams.asUser && + msg.u._id !== this.userId && + !(await hasPermissionAsync(this.userId, 'force-delete-message', msg.rid)) + ) { + return API.v1.failure('Unauthorized. You must have the permission "force-delete-message" to delete other\'s message as them.'); } - await ignoreUser(this.userId, { rid, userId, ignore }); + const userId = this.bodyParams.asUser ? msg.u._id : this.userId; + const user = await Users.findOneById(userId, { projection: { _id: 1 } }); - return API.v1.success(); - }, - }, -); + if (!user) { + return API.v1.failure('User not found'); + } -API.v1.addRoute( - 'chat.getDeletedMessages', - { authRequired: true, validateParams: isChatGetDeletedMessagesProps }, - { - async get() { + await deleteMessageValidatingPermission(msg, user._id); + + return API.v1.success({ + _id: msg._id, + ts: Date.now().toString(), + message: msg, + }); + }, + ) + .get( + 'chat.syncMessages', + { + authRequired: true, + query: isChatSyncMessagesProps, + response: { + 200: ajv.compile<{ + result: { + updated: IMessage[]; + deleted: { _id: string; _deletedAt: string }[]; + cursor?: { next: string | null; previous: string | null }; + }; + }>({ + type: 'object', + properties: { + result: { + type: 'object', + properties: { + updated: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + deleted: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + _deletedAt: { type: 'string', format: 'date-time' }, + }, + required: ['_id', '_deletedAt'], + additionalProperties: false, + }, + }, + cursor: { + type: 'object', + properties: { + next: { type: ['string', 'null'] }, + previous: { type: ['string', 'null'] }, + }, + }, + }, + required: ['updated', 'deleted'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['result', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, lastUpdate, count, next, previous, type } = this.queryParams; + + if (!roomId) { + throw new Meteor.Error('error-param-required', 'The required "roomId" query param is missing'); + } + + if (!lastUpdate && !type) { + throw new Meteor.Error('error-param-required', 'The "type" or "lastUpdate" parameters must be provided'); + } + + if (lastUpdate && isNaN(Date.parse(lastUpdate))) { + throw new Meteor.Error('error-lastUpdate-param-invalid', 'The "lastUpdate" query parameter must be a valid date'); + } + + const getMessagesQuery = { + ...(lastUpdate && { lastUpdate: new Date(lastUpdate) }), + ...(next && { next }), + ...(previous && { previous }), + ...(count && { count }), + ...(type && { type }), + }; + + const result = await getMessageHistory(roomId, this.userId, getMessagesQuery); + + if (!result) { + return API.v1.failure(); + } + + return API.v1.success({ + result: { + updated: 'updated' in result ? await normalizeMessagesForUser(result.updated, this.userId) : [], + deleted: + 'deleted' in result + ? result.deleted.map((msg) => ({ + _id: msg._id, + _deletedAt: + '_deletedAt' in msg && msg._deletedAt instanceof Date ? msg._deletedAt.toISOString() : new Date().toISOString(), + })) + : [], + cursor: 'cursor' in result ? result.cursor : undefined, + }, + }); + }, + ) + .get( + 'chat.getMessage', + { + authRequired: true, + query: isChatGetMessageProps, + response: { + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!this.queryParams.msgId) { + return API.v1.failure('The "msgId" query parameter must be provided.'); + } + + const msg = await getSingleMessage(this.userId, this.queryParams.msgId); + + if (!msg) { + return API.v1.failure(); + } + + const [message] = await normalizeMessagesForUser([msg], this.userId); + + return API.v1.success({ + message, + }); + }, + ) + .post( + 'chat.postMessage', + { + authRequired: true, + body: isChatPostMessageProps, + response: { + 200: ajv.compile<{ ts: number; channel: string; message: IMessage }>({ + type: 'object', + properties: { + ts: { type: 'number' }, + channel: { type: 'string' }, + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['ts', 'channel', 'message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { text, attachments } = this.bodyParams; + const maxAllowedSize = settings.get('Message_MaxAllowedSize') ?? 0; + + if (text && text.length > maxAllowedSize) { + return API.v1.failure('error-message-size-exceeded'); + } + + if (attachments && attachments.length > 0) { + for (const attachment of attachments) { + if (attachment.text && attachment.text.length > maxAllowedSize) { + return API.v1.failure('error-message-size-exceeded'); + } + } + } + + const messageReturn = (await applyAirGappedRestrictionsValidation(() => processWebhookMessage(this.bodyParams, this.user)))[0]; + + if (!messageReturn?.message) { + return API.v1.failure('unknown-error'); + } + + const [message] = await normalizeMessagesForUser([messageReturn.message], this.userId); + + return API.v1.success({ + ts: Date.now(), + channel: messageReturn.channel, + message, + }); + }, + ) + .get( + 'chat.search', + { + authRequired: true, + query: isChatSearchProps, + response: { + 200: ajv.compile<{ messages: IMessage[] }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId, searchText } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + + if (!roomId) { + throw new Meteor.Error('error-roomId-param-not-provided', 'The required "roomId" query param is missing.'); + } + + if (!searchText) { + throw new Meteor.Error('error-searchText-param-not-provided', 'The required "searchText" query param is missing.'); + } + + const searchResult = await messageSearch(this.userId, searchText, roomId, count, offset); + if (searchResult === false) { + return API.v1.failure(); + } + if (!searchResult.message) { + return API.v1.failure(); + } + const result = searchResult.message.docs; + + return API.v1.success({ + messages: await normalizeMessagesForUser(result, this.userId), + }); + }, + ) + // The difference between `chat.postMessage` and `chat.sendMessage` is that `chat.sendMessage` allows + // for passing a value for `_id` and the other one doesn't. Also, `chat.sendMessage` only sends it to + // one channel whereas the other one allows for sending to more than one channel at a time. + .post( + 'chat.sendMessage', + { + authRequired: true, + body: isChatSendMessageProps, + response: { + 200: ajv.compile<{ message: IMessage }>({ + type: 'object', + properties: { + message: { $ref: '#/components/schemas/IMessage' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (MessageTypes.isSystemMessage(this.bodyParams.message)) { + throw new Error("Cannot send system messages using 'chat.sendMessage'"); + } + + const sent = await applyAirGappedRestrictionsValidation(() => + executeSendMessage(this.user, this.bodyParams.message as Pick, { previewUrls: this.bodyParams.previewUrls }), + ); + const [message] = await normalizeMessagesForUser([sent], this.userId); + + return API.v1.success({ + message, + }); + }, + ) + .get( + 'chat.ignoreUser', + { + authRequired: true, + query: isChatIgnoreUserProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, userId } = this.queryParams; + let { ignore = true } = this.queryParams; + + ignore = typeof ignore === 'string' ? /true|1/.test(ignore) : ignore; + + if (!rid?.trim()) { + throw new Meteor.Error('error-room-id-param-not-provided', 'The required "rid" param is missing.'); + } + + if (!userId?.trim()) { + throw new Meteor.Error('error-user-id-param-not-provided', 'The required "userId" param is missing.'); + } + + await ignoreUser(this.userId, { rid, userId, ignore }); + + return API.v1.success(); + }, + ) + .get( + 'chat.getDeletedMessages', + { + authRequired: true, + query: isChatGetDeletedMessagesProps, + response: { + 200: ajv.compile<{ messages: Pick[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { type: 'object' } }, // relaxed: only _id is projected, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId, since } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); @@ -750,14 +929,30 @@ API.v1.addRoute( total, }); }, - }, -); - -API.v1.addRoute( - 'chat.getPinnedMessages', - { authRequired: true, validateParams: isChatGetPinnedMessagesProps }, - { - async get() { + ) + .get( + 'chat.getPinnedMessages', + { + authRequired: true, + query: isChatGetPinnedMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); @@ -779,14 +974,30 @@ API.v1.addRoute( total, }); }, - }, -); - -API.v1.addRoute( - 'chat.getThreadsList', - { authRequired: true, validateParams: isChatGetThreadsListProps }, - { - async get() { + ) + .get( + 'chat.getThreadsList', + { + authRequired: true, + query: isChatGetThreadsListProps, + response: { + 200: ajv.compile<{ threads: IThreadMainMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + threads: { type: 'array', items: { type: 'object' } }, // relaxed: IThreadMainMessage not in OpenAPI schemas, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['threads', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { rid, type, text } = this.queryParams; const { offset, count } = await getPaginationItems(this.queryParams); @@ -826,14 +1037,35 @@ API.v1.addRoute( total, }); }, - }, -); - -API.v1.addRoute( - 'chat.syncThreadsList', - { authRequired: true, validateParams: isChatSyncThreadsListProps }, - { - async get() { + ) + .get( + 'chat.syncThreadsList', + { + authRequired: true, + query: isChatSyncThreadsListProps, + response: { + 200: ajv.compile<{ threads: { update: IMessage[]; remove: IMessage[] } }>({ + type: 'object', + properties: { + threads: { + type: 'object', + properties: { + update: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + remove: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + }, + required: ['update', 'remove'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['threads', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { rid } = this.queryParams; const { query, fields, sort } = await this.parseJsonQuery(); const { updatedSince } = this.queryParams; @@ -870,14 +1102,30 @@ API.v1.addRoute( }, }); }, - }, -); - -API.v1.addRoute( - 'chat.getThreadMessages', - { authRequired: true, validateParams: isChatGetThreadMessagesProps }, - { - async get() { + ) + .get( + 'chat.getThreadMessages', + { + authRequired: true, + query: isChatGetThreadMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { tmid } = this.queryParams; const { query, fields, sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); @@ -915,14 +1163,35 @@ API.v1.addRoute( total, }); }, - }, -); - -API.v1.addRoute( - 'chat.syncThreadMessages', - { authRequired: true, validateParams: isChatSyncThreadMessagesProps }, - { - async get() { + ) + .get( + 'chat.syncThreadMessages', + { + authRequired: true, + query: isChatSyncThreadMessagesProps, + response: { + 200: ajv.compile<{ messages: { update: IMessage[]; remove: IMessage[] } }>({ + type: 'object', + properties: { + messages: { + type: 'object', + properties: { + update: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + remove: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + }, + required: ['update', 'remove'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { tmid } = this.queryParams; const { query, fields, sort } = await this.parseJsonQuery(); const { updatedSince } = this.queryParams; @@ -954,14 +1223,30 @@ API.v1.addRoute( }, }); }, - }, -); - -API.v1.addRoute( - 'chat.getMentionedMessages', - { authRequired: true, validateParams: isChatGetMentionedMessagesProps }, - { - async get() { + ) + .get( + 'chat.getMentionedMessages', + { + authRequired: true, + query: isChatGetMentionedMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId } = this.queryParams; const { sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); @@ -978,14 +1263,30 @@ API.v1.addRoute( return API.v1.success(messages); }, - }, -); - -API.v1.addRoute( - 'chat.getStarredMessages', - { authRequired: true, validateParams: isChatGetStarredMessagesProps }, - { - async get() { + ) + .get( + 'chat.getStarredMessages', + { + authRequired: true, + query: isChatGetStarredMessagesProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId } = this.queryParams; const { sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); @@ -1004,14 +1305,28 @@ API.v1.addRoute( return API.v1.success(messages); }, - }, -); - -API.v1.addRoute( - 'chat.getDiscussions', - { authRequired: true, validateParams: isChatGetDiscussionsProps }, - { - async get() { + ) + .get( + 'chat.getDiscussions', + { + authRequired: true, + query: isChatGetDiscussionsProps, + response: { + 200: ajv.compile<{ messages: IMessage[]; total: number }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { type: 'object' } }, // relaxed: discussions have extra room fields, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'total', 'success'], + additionalProperties: true, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId, text } = this.queryParams; const { sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); @@ -1028,14 +1343,27 @@ API.v1.addRoute( }); return API.v1.success(messages); }, - }, -); - -API.v1.addRoute( - 'chat.getURLPreview', - { authRequired: true, validateParams: isChatGetURLPreviewProps }, - { - async get() { + ) + .get( + 'chat.getURLPreview', + { + authRequired: true, + query: isChatGetURLPreviewProps, + response: { + 200: ajv.compile<{ urlPreview: object }>({ + type: 'object', + properties: { + urlPreview: { type: 'object' }, // relaxed: opaque preview shape, + success: { type: 'boolean', enum: [true] }, + }, + required: ['urlPreview', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { roomId, url } = this.queryParams; if (!(await canAccessRoomIdAsync(roomId, this.userId))) { @@ -1047,8 +1375,7 @@ API.v1.addRoute( return API.v1.success({ urlPreview }); }, - }, -); + ); export type ChatEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts index c7818db2b161d..1c294c2023137 100644 --- a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts +++ b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts @@ -70,7 +70,6 @@ export const AutoTranslate = { } } - // @ts-expect-error - not sure what to do with this if (attachment.attachments && attachment.attachments.length > 0) { // @ts-expect-error - not sure what to do with this attachment.attachments = this.translateAttachments(attachment.attachments, language); diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index 204868d9dcda3..14de6d696f9b4 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -119,7 +119,7 @@ export async function pinMessage(message: IMessage, userId: string, pinnedAt?: D text: originalMessage.msg, author_name: originalMessage.u.username, author_icon: getUserAvatarURL(originalMessage.u.username), - content: originalMessage.content, + ...(originalMessage.content && { content: originalMessage.content }), ts: originalMessage.ts, attachments: attachments.map(recursiveRemove), }, @@ -136,7 +136,7 @@ export const unpinMessage = async (userId: string, message: IMessage) => { } let originalMessage = await Messages.findOneById(message._id); - if (originalMessage == null || originalMessage._id == null) { + if (originalMessage?._id == null) { throw new Meteor.Error('error-invalid-message', 'Message you are unpinning was not found', { method: 'unpinMessage', action: 'Message_pinning', diff --git a/apps/meteor/lib/createQuoteAttachment.ts b/apps/meteor/lib/createQuoteAttachment.ts index d7a757b1549d9..7f2ca4e62202a 100644 --- a/apps/meteor/lib/createQuoteAttachment.ts +++ b/apps/meteor/lib/createQuoteAttachment.ts @@ -9,7 +9,7 @@ export function createQuoteAttachment( ) { return { text: message.msg, - md: message.md, + ...(message.md && { md: message.md }), ...(isTranslatedMessage(message) && { translations: message?.translations }), message_link: messageLink, author_name: message.alias || getUserDisplayName(message.u.name, message.u.username, useRealName), diff --git a/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts index 7fd725ed5bfbc..2b0cd868effb3 100644 --- a/apps/meteor/tests/end-to-end/api/chat.ts +++ b/apps/meteor/tests/end-to-end/api/chat.ts @@ -3416,7 +3416,7 @@ describe('[Chat]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.errorType).to.be.equal('error-invalid-params'); }) .end(done); }); @@ -3475,7 +3475,7 @@ describe('[Chat]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.errorType).to.be.equal('error-invalid-params'); }) .end(done); }); @@ -3676,7 +3676,7 @@ describe('[Chat]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.errorType).to.be.equal('error-invalid-params'); expect(res.body.error).to.include(`must have required property 'roomId'`); }) .end(done); @@ -3736,7 +3736,7 @@ describe('[Chat]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.errorType).to.be.equal('error-invalid-params'); expect(res.body.error).to.include('must be equal to one of the allowed values'); }) .end(done); @@ -4111,7 +4111,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }); }); @@ -4127,7 +4127,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }); }); @@ -4310,7 +4310,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); @@ -4328,7 +4328,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); @@ -4347,7 +4347,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); @@ -4568,7 +4568,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); @@ -4586,7 +4586,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); @@ -4605,7 +4605,7 @@ describe('Threads', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('errorType', 'error-invalid-params'); }) .end(done); }); From e4a2b0d8aaae0849769645e947882e97f2956653 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 15 Apr 2026 00:00:33 -0300 Subject: [PATCH 09/14] chore(api): migrate misc (spotlight, shield.svg) to OpenAPI --- apps/meteor/app/api/server/v1/misc.ts | 205 +++++++++++++------------- 1 file changed, 106 insertions(+), 99 deletions(-) diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index 1b24c8cde425f..c93450d937cd0 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -9,9 +9,11 @@ import { isDirectoryProps, isFingerprintProps, isMeteorCall, + meSuccessResponseSchema, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; +import type { MeApiSuccessResponse } from '@rocket.chat/rest-typings'; import { escapeHTML } from '@rocket.chat/string-helpers'; import EJSON from 'ejson'; import { check } from 'meteor/check'; @@ -170,18 +172,13 @@ import { getUserInfo } from '../helpers/getUserInfo'; * schema: * $ref: '#/components/schemas/ApiFailureV1' */ -const meResponseSchema = ajv.compile>({ - type: 'object', - additionalProperties: true, -}); - API.v1.get( 'me', { authRequired: true, userWithoutUsername: true, response: { - 200: meResponseSchema, + 200: ajv.compile(meSuccessResponseSchema), 401: validateUnauthorizedErrorResponse, }, }, @@ -189,7 +186,7 @@ API.v1.get( const userFields = { ...getBaseUserFields(), services: 1 }; const user = (await Users.findOneById(this.userId, { projection: userFields })) as IUser; - return API.v1.success((await getUserInfo(user)) as unknown as Record); + return API.v1.success(await getUserInfo(user)); }, ); @@ -197,7 +194,12 @@ let onlineCache = 0; let onlineCacheDate = 0; const cacheInvalid = 60000; // 1 minute -API.v1.addRoute( +const shieldSvgResponseSchema = ajv.compile({ + type: 'string', + description: 'SVG image markup', +}); + +API.v1.get( 'shield.svg', { authRequired: false, @@ -205,98 +207,99 @@ API.v1.addRoute( numRequestsAllowed: 60, intervalTimeInMS: 60000, }, - validateParams: isShieldSvgProps, + query: isShieldSvgProps, + response: { + 200: shieldSvgResponseSchema, + 400: validateBadRequestErrorResponse, + }, }, - { - async get() { - const { type, icon } = this.queryParams; - let { channel, name } = this.queryParams; - if (!settings.get('API_Enable_Shields')) { - throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { - route: '/api/v1/shield.svg', - }); - } + async function action() { + const { type, icon } = this.queryParams; + let { channel, name } = this.queryParams; + if (!settings.get('API_Enable_Shields')) { + throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', { + route: '/api/v1/shield.svg', + }); + } - const types = settings.get('API_Shield_Types'); - if ( - type && - types !== '*' && - !types - .split(',') - .map((t: string) => t.trim()) - .includes(type) - ) { - throw new Meteor.Error('error-shield-disabled', 'This shield type is disabled', { - route: '/api/v1/shield.svg', - }); - } - const hideIcon = icon === 'false'; - if (hideIcon && !name?.trim()) { - return API.v1.failure('Name cannot be empty when icon is hidden'); - } + const types = settings.get('API_Shield_Types'); + if ( + type && + types !== '*' && + !types + .split(',') + .map((t: string) => t.trim()) + .includes(type) + ) { + throw new Meteor.Error('error-shield-disabled', 'This shield type is disabled', { + route: '/api/v1/shield.svg', + }); + } + const hideIcon = icon === 'false'; + if (hideIcon && !name?.trim()) { + return API.v1.failure('Name cannot be empty when icon is hidden'); + } - let text; - let backgroundColor = '#4c1'; - switch (type) { - case 'online': - if (Date.now() - onlineCacheDate > cacheInvalid) { - onlineCache = await Users.countUsersNotOffline(); - onlineCacheDate = Date.now(); - } - - text = `${onlineCache} ${i18n.t('Online')}`; - break; - case 'channel': - if (!channel) { - return API.v1.failure('Shield channel is required for type "channel"'); - } - - text = `#${channel}`; - break; - case 'user': - if (settings.get('API_Shield_user_require_auth') && !this.user) { - return API.v1.failure('You must be logged in to do this.'); - } - const user = await getUserFromParams(this.queryParams); - - // Respect the server's choice for using their real names or not - if (user.name && settings.get('UI_Use_Real_Name')) { - text = `${user.name}`; - } else { - text = `@${user.username}`; - } - - switch (user.status) { - case 'online': - backgroundColor = '#1fb31f'; - break; - case 'away': - backgroundColor = '#dc9b01'; - break; - case 'busy': - backgroundColor = '#bc2031'; - break; - case 'offline': - backgroundColor = '#a5a1a1'; - } - break; - default: - text = i18n.t('Join_Chat').toUpperCase(); - } + let text; + let backgroundColor = '#4c1'; + switch (type) { + case 'online': + if (Date.now() - onlineCacheDate > cacheInvalid) { + onlineCache = await Users.countUsersNotOffline(); + onlineCacheDate = Date.now(); + } + + text = `${onlineCache} ${i18n.t('Online')}`; + break; + case 'channel': + if (!channel) { + return API.v1.failure('Shield channel is required for type "channel"'); + } + + text = `#${channel}`; + break; + case 'user': + if (settings.get('API_Shield_user_require_auth') && !this.user) { + return API.v1.failure('You must be logged in to do this.'); + } + const user = await getUserFromParams(this.queryParams); + + // Respect the server's choice for using their real names or not + if (user.name && settings.get('UI_Use_Real_Name')) { + text = `${user.name}`; + } else { + text = `@${user.username}`; + } + + switch (user.status) { + case 'online': + backgroundColor = '#1fb31f'; + break; + case 'away': + backgroundColor = '#dc9b01'; + break; + case 'busy': + backgroundColor = '#bc2031'; + break; + case 'offline': + backgroundColor = '#a5a1a1'; + } + break; + default: + text = i18n.t('Join_Chat').toUpperCase(); + } - const iconSize = hideIcon ? 7 : 24; - const leftSize = name ? name.length * 6 + 7 + iconSize : iconSize; - const rightSize = text.length * 6 + 20; - const width = leftSize + rightSize; - const height = 20; + const iconSize = hideIcon ? 7 : 24; + const leftSize = name ? name.length * 6 + 7 + iconSize : iconSize; + const rightSize = text.length * 6 + 20; + const width = leftSize + rightSize; + const height = 20; - channel = escapeHTML(channel); - text = escapeHTML(text); - name = escapeHTML(name); + channel = escapeHTML(channel); + text = escapeHTML(text); + name = escapeHTML(name); - return { - headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' }, - body: ` + const svgBody = ` @@ -323,10 +326,14 @@ API.v1.addRoute( ` - .trim() - .replace(/\>[\s]+\<'), - } as any; - }, + .trim() + .replace(/\>[\s]+\<'); + + return { + statusCode: 200 as const, + body: svgBody, + headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' }, + }; }, ); @@ -360,7 +367,7 @@ const spotlightResponseSchema = ajv.compile<{ _id: { type: 'string' }, t: { type: 'string' }, name: { type: 'string' }, - lastMessage: { type: 'object' }, + lastMessage: { $ref: '#/components/schemas/IMessage' }, }, required: ['_id', 't', 'name'], additionalProperties: true, From 8e3d3337dec61af834aba26aec34eaa3c68bb7fe Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 15 Apr 2026 00:04:16 -0300 Subject: [PATCH 10/14] chore(api): migrate rooms endpoints to OpenAPI --- apps/meteor/app/api/server/lib/rooms.ts | 2 +- apps/meteor/app/api/server/v1/call-history.ts | 20 +- apps/meteor/app/api/server/v1/channels.ts | 2 +- apps/meteor/app/api/server/v1/rooms.ts | 1187 ++++++++++------- .../app/livechat/imports/server/rest/rooms.ts | 2 +- .../client/views/admin/rooms/RoomsTable.tsx | 9 +- .../tests/end-to-end/api/livechat/00-rooms.ts | 15 +- apps/meteor/tests/end-to-end/api/rooms.ts | 65 +- 8 files changed, 780 insertions(+), 522 deletions(-) diff --git a/apps/meteor/app/api/server/lib/rooms.ts b/apps/meteor/app/api/server/lib/rooms.ts index 14b43c1e83d2d..d2bac79d6f790 100644 --- a/apps/meteor/app/api/server/lib/rooms.ts +++ b/apps/meteor/app/api/server/lib/rooms.ts @@ -153,7 +153,7 @@ export async function findChannelAndPrivateAutocompleteWithPagination({ }; } -export async function findRoomsAvailableForTeams({ uid, name }: { uid: string; name: string }): Promise<{ +export async function findRoomsAvailableForTeams({ uid, name }: { uid: string; name?: string }): Promise<{ items: IRoom[]; }> { const options: FindOptions = { diff --git a/apps/meteor/app/api/server/v1/call-history.ts b/apps/meteor/app/api/server/v1/call-history.ts index 33412c6ae58c7..b14c5621e9149 100644 --- a/apps/meteor/app/api/server/v1/call-history.ts +++ b/apps/meteor/app/api/server/v1/call-history.ts @@ -19,7 +19,7 @@ import { getPaginationItems } from '../helpers/getPaginationItems'; type CallHistoryList = PaginatedRequest<{ filter?: string; direction?: CallHistoryItem['direction']; - state?: CallHistoryItemState[] | CallHistoryItemState; + state?: CallHistoryItemState[]; }>; const CallHistoryListSchema = { @@ -42,20 +42,10 @@ const CallHistoryListSchema = { enum: ['inbound', 'outbound'], }, state: { - // our clients serialize arrays as `state=value1&state=value2`, but if there's a single value the parser doesn't know it is an array, so we need to support both arrays and direct values - // if a client tries to send a JSON array, our parser will treat it as a string and the type validation will reject it - // This means this param won't work from Swagger UI - oneOf: [ - { - type: 'array', - items: { - $ref: '#/components/schemas/CallHistoryItemState', - }, - }, - { - $ref: '#/components/schemas/CallHistoryItemState', - }, - ], + type: 'array', + items: { + $ref: '#/components/schemas/CallHistoryItemState', + }, }, }, required: [], diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index eaeab9a9bb108..84cd4ebbf03fc 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -640,7 +640,7 @@ API.v1.addRoute( const lm = room.lm ? room.lm : room._updatedAt; if (subscription?.open) { - unreads = await Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, subscription.ls, lm); + unreads = await Messages.countVisibleByRoomIdBetweenTimestampsInclusive(subscription.rid, subscription.ls ?? subscription.ts, lm); unreadsFrom = subscription.ls || subscription.ts; userMentions = subscription.userMentions; joined = true; diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index ae02a578ab05c..2faf320fadfb4 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1,5 +1,13 @@ import { FederationMatrix, MeteorError, Team } from '@rocket.chat/core-services'; -import { type IRoom, type IUpload, type RequiredField, isPrivateRoom, isPublicRoom, type IUser } from '@rocket.chat/core-typings'; +import { + type IRoom, + type IUpload, + type RequiredField, + type RoomAdminFieldsType, + isPrivateRoom, + isPublicRoom, + type IUser, +} from '@rocket.chat/core-typings'; import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models'; import type { Notifications } from '@rocket.chat/rest-typings'; import { @@ -19,9 +27,18 @@ import { isRoomsChangeArchivationStateProps, isRoomsHideProps, isRoomsInviteProps, + isRoomsCreateDiscussionProps, + isRoomsAdminRoomsProps, + isRoomsAutocompleteAdminRoomsPayload, + isRoomsAdminRoomsGetRoomProps, + isRoomsAutoCompleteChannelAndPrivateProps, + isRoomsAutocompleteChannelAndPrivateWithPaginationProps, + isRoomsAutocompleteAvailableForTeamsProps, + isRoomsSaveRoomSettingsProps, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, + validateNotFoundErrorResponse, } from '@rocket.chat/rest-typings'; import { isTruthy } from '@rocket.chat/tools'; import { Meteor } from 'meteor/meteor'; @@ -110,20 +127,31 @@ export async function findRoomByIdOrName({ return room; } -API.v1.addRoute( +API.v1.get( 'rooms.nameExists', { authRequired: true, - validateParams: isGETRoomsNameExists, + query: isGETRoomsNameExists, + response: { + 200: ajv.compile<{ exists: boolean }>({ + type: 'object', + properties: { + exists: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['exists', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async get() { - const { roomName } = this.queryParams; + async function action() { + const { roomName } = this.queryParams; - const room = await Rooms.findOneByName(roomName, { projection: { _id: 1 } }); + const room = await Rooms.findOneByName(roomName, { projection: { _id: 1 } }); - return API.v1.success({ exists: !!room }); - }, + return API.v1.success({ exists: !!room }); }, ); @@ -182,36 +210,50 @@ const roomDeleteEndpoint = API.v1.post( }, ); -API.v1.addRoute( +API.v1.get( 'rooms.get', - { authRequired: true }, { - async get() { - const { updatedSince } = this.queryParams; - - let updatedSinceDate; - if (updatedSince) { - if (isNaN(Date.parse(updatedSince))) { - throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); - } else { - updatedSinceDate = new Date(updatedSince); - } + authRequired: true, + response: { + 200: ajv.compile<{ update: IRoom[]; remove: IRoom[] }>({ + type: 'object', + properties: { + update: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom composed with lastMessage + remove: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom composed with lastMessage + success: { type: 'boolean', enum: [true] }, + }, + required: ['update', 'remove', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { updatedSince } = this.queryParams; + + let updatedSinceDate; + if (updatedSince) { + if (isNaN(Date.parse(updatedSince))) { + throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); + } else { + updatedSinceDate = new Date(updatedSince); } + } - let result = await roomsGetMethod(this.userId, updatedSinceDate); + let result = await roomsGetMethod(this.userId, updatedSinceDate); - if (Array.isArray(result)) { - result = { - update: result, - remove: [], - }; - } + if (Array.isArray(result)) { + result = { + update: result, + remove: [], + }; + } - return API.v1.success({ - update: await Promise.all(result.update.map((room) => composeRoomWithLastMessage(room, this.userId))), - remove: await Promise.all(result.remove.map((room) => composeRoomWithLastMessage(room, this.userId))), - }); - }, + return API.v1.success({ + update: await Promise.all(result.update.map((room) => composeRoomWithLastMessage(room, this.userId))), + remove: await Promise.all(result.remove.map((room) => composeRoomWithLastMessage(room, this.userId))), + }); }, ); @@ -372,82 +414,94 @@ const roomsSaveNotificationEndpoint = API.v1.post( }, ); -API.v1.addRoute( +API.v1.post( 'rooms.cleanHistory', - { authRequired: true, validateParams: isRoomsCleanHistoryProps }, { - async post() { - const room = await findRoomByIdOrName({ params: this.bodyParams }); - const { _id } = room; - - if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { - return API.v1.failure('User does not have access to the room [error-not-allowed]', 'error-not-allowed'); - } - - const { - latest, - oldest, - inclusive = false, - limit, - excludePinned, - filesOnly, - ignoreThreads, - ignoreDiscussion, - users, - } = this.bodyParams; - - if (!latest) { - return API.v1.failure('Body parameter "latest" is required.'); - } - - if (!oldest) { - return API.v1.failure('Body parameter "oldest" is required.'); - } + authRequired: true, + body: isRoomsCleanHistoryProps, + response: { + 200: ajv.compile<{ _id: string; count: number }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + count: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['_id', 'count', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.bodyParams }); + const { _id } = room; - const count = await cleanRoomHistoryMethod(this.userId, { - roomId: _id, - latest: new Date(latest), - oldest: new Date(oldest), - inclusive, - limit, - excludePinned: [true, 'true', 1, '1'].includes(excludePinned ?? false), - filesOnly: [true, 'true', 1, '1'].includes(filesOnly ?? false), - ignoreThreads: [true, 'true', 1, '1'].includes(ignoreThreads ?? false), - ignoreDiscussion: [true, 'true', 1, '1'].includes(ignoreDiscussion ?? false), - fromUsers: users?.filter(isTruthy) || [], - }); + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.failure('User does not have access to the room [error-not-allowed]', 'error-not-allowed'); + } - return API.v1.success({ _id, count }); - }, + const { latest, oldest, inclusive = false, limit, excludePinned, filesOnly, ignoreThreads, ignoreDiscussion, users } = this.bodyParams; + + const count = await cleanRoomHistoryMethod(this.userId, { + roomId: _id, + latest: new Date(latest), + oldest: new Date(oldest), + inclusive, + limit, + excludePinned: [true, 'true', 1, '1'].includes(excludePinned ?? false), + filesOnly: [true, 'true', 1, '1'].includes(filesOnly ?? false), + ignoreThreads: [true, 'true', 1, '1'].includes(ignoreThreads ?? false), + ignoreDiscussion: [true, 'true', 1, '1'].includes(ignoreDiscussion ?? false), + fromUsers: users?.filter(isTruthy) || [], + }); + + return API.v1.success({ _id, count }); }, ); -API.v1.addRoute( +API.v1.get( 'rooms.info', - { authRequired: true }, { - async get() { - const room = await findRoomByIdOrName({ params: this.queryParams }); - const { fields } = await this.parseJsonQuery(); - - if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { - return API.v1.failure('not-allowed', 'Not Allowed'); - } + authRequired: true, + response: { + 200: ajv.compile<{ room: IRoom | null }>({ + type: 'object', + properties: { + room: { type: ['object', 'null'] }, + team: { type: 'object' }, + parent: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.queryParams }); + const { fields } = await this.parseJsonQuery(); - const discussionParent = - room.prid && - (await Rooms.findOneById>(room.prid, { - projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 }, - })); - const { team, parentRoom } = await Team.getRoomInfo(room); - const parent = discussionParent || parentRoom; + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.failure('not-allowed', 'Not Allowed'); + } - return API.v1.success({ - room: await Rooms.findOneByIdOrName(room._id, { projection: fields }), - ...(team && { team }), - ...(parent && { parent }), - }); - }, + const discussionParent = + room.prid && + (await Rooms.findOneById>(room.prid, { + projection: { name: 1, fname: 1, t: 1, prid: 1, u: 1 }, + })); + const { team, parentRoom } = await Team.getRoomInfo(room); + const parent = discussionParent || parentRoom; + + return API.v1.success({ + room: await Rooms.findOneByIdOrName(room._id, { projection: fields }), + ...(team && { team }), + ...(parent && { parent }), + }); }, ); @@ -456,515 +510,724 @@ TO-DO: 8.0.0 should use the ajv validation which will change this endpoint's response errors. */ -API.v1.addRoute( +/* +TO-DO: 8.0.0 should use the ajv validation +which will change this endpoint's +response errors. +*/ +API.v1.post( 'rooms.createDiscussion', - { authRequired: true /* , validateParams: isRoomsCreateDiscussionProps */ }, { - async post() { - const { prid, pmid, reply, t_name, users, encrypted, topic } = this.bodyParams; - if (!prid) { - return API.v1.failure('Body parameter "prid" is required.'); - } - if (!t_name) { - return API.v1.failure('Body parameter "t_name" is required.'); - } - if (users && !Array.isArray(users)) { - return API.v1.failure('Body parameter "users" must be an array.'); - } - - if (encrypted !== undefined && typeof encrypted !== 'boolean') { - return API.v1.failure('Body parameter "encrypted" must be a boolean when included.'); - } - - const discussion = await applyAirGappedRestrictionsValidation(() => - createDiscussion(this.userId, { - prid, - pmid, - t_name, - reply, - users: users?.filter(isTruthy) || [], - encrypted, - topic, - }), - ); - - return API.v1.success({ discussion }); + authRequired: true, + body: isRoomsCreateDiscussionProps, + response: { + 200: ajv.compile<{ discussion: IRoom }>({ + type: 'object', + properties: { + discussion: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['discussion', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { prid, pmid, reply, t_name, users, encrypted, topic } = this.bodyParams; + + const discussion = await applyAirGappedRestrictionsValidation(() => + createDiscussion(this.userId, { + prid, + pmid, + t_name, + reply, + users: users?.filter(isTruthy) || [], + encrypted, + topic, + }), + ); + + return API.v1.success({ discussion }); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.getDiscussions', - { authRequired: true }, { - async get() { - const room = await findRoomByIdOrName({ params: this.queryParams }); - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); + authRequired: true, + response: { + 200: ajv.compile<{ discussions: IRoom[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + discussions: { type: 'array', items: { type: 'object' } }, // relaxed: discussions have extra room fields + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['discussions', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const room = await findRoomByIdOrName({ params: this.queryParams }); + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); - if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { - return API.v1.failure('not-allowed', 'Not Allowed'); - } + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.failure('not-allowed', 'Not Allowed'); + } - const ourQuery = Object.assign(query, { prid: room._id }); + const ourQuery = Object.assign(query, { prid: room._id }); - const { cursor, totalCount } = await Rooms.findPaginated(ourQuery, { - sort: sort || { fname: 1 }, - skip: offset, - limit: count, - projection: fields, - }); + const { cursor, totalCount } = await Rooms.findPaginated(ourQuery, { + sort: sort || { fname: 1 }, + skip: offset, + limit: count, + projection: fields, + }); - const [discussions, total] = await Promise.all([cursor.toArray(), totalCount]); + const [discussions, total] = await Promise.all([cursor.toArray(), totalCount]); - return API.v1.success({ - discussions, - count: discussions.length, - offset, - total, - }); - }, + return API.v1.success({ + discussions, + count: discussions.length, + offset, + total, + }); }, ); -API.v1.addRoute( +API.v1.get( 'rooms.images', - { authRequired: true, validateParams: isRoomsImagesProps }, { - async get() { - const room = await Rooms.findOneById>(this.queryParams.roomId, { - projection: { t: 1, teamId: 1, prid: 1 }, - }); + authRequired: true, + query: isRoomsImagesProps, + response: { + 200: ajv.compile<{ files: IUpload[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + files: { type: 'array', items: { type: 'object' } }, // relaxed: IUpload with user transform + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['files', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const room = await Rooms.findOneById>(this.queryParams.roomId, { + projection: { t: 1, teamId: 1, prid: 1 }, + }); - if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { - return API.v1.forbidden(); - } + if (!room || !(await canAccessRoomAsync(room, { _id: this.userId }))) { + return API.v1.forbidden(); + } - let initialImage: IUpload | null = null; - if (this.queryParams.startingFromId) { - initialImage = await Uploads.findOneById(this.queryParams.startingFromId); + let initialImage: IUpload | null = null; + if (this.queryParams.startingFromId) { + initialImage = await Uploads.findOneById(this.queryParams.startingFromId); + if (initialImage && initialImage.rid !== room._id) { + initialImage = null; } + } - const { offset, count } = await getPaginationItems(this.queryParams); + const { offset, count } = await getPaginationItems(this.queryParams); - const { cursor, totalCount } = Uploads.findImagesByRoomId(room._id, initialImage?.uploadedAt, { - skip: offset, - limit: count, - }); + const { cursor, totalCount } = Uploads.findImagesByRoomId(room._id, initialImage?.uploadedAt, { + skip: offset, + limit: count, + }); - const [files, total] = await Promise.all([cursor.toArray(), totalCount]); + const [files, total] = await Promise.all([cursor.toArray(), totalCount]); - // If the initial image was not returned in the query, insert it as the first element of the list - if (initialImage && !files.find(({ _id }) => _id === initialImage._id)) { - files.splice(0, 0, initialImage); - } + // If the initial image was not returned in the query, insert it as the first element of the list + if (initialImage && !files.find(({ _id }) => _id === initialImage._id)) { + files.splice(0, 0, initialImage); + } - return API.v1.success({ - files, - count, - offset, - total, - }); - }, + return API.v1.success({ + files, + count, + offset, + total, + }); }, ); -API.v1.addRoute( +API.v1.get( 'rooms.adminRooms', - { authRequired: true }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - const { types, filter } = this.queryParams; - - return API.v1.success( - await findAdminRooms({ - uid: this.userId, - filter: filter || '', - types: (types && !Array.isArray(types) ? [types] : types) ?? [], - pagination: { - offset, - count, - sort, - }, - }), - ); + authRequired: true, + query: isRoomsAdminRoomsProps, + response: { + 200: ajv.compile<{ rooms: IRoom[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + rooms: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with admin fields + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rooms', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { types, filter } = this.queryParams; + + return API.v1.success( + await findAdminRooms({ + uid: this.userId, + filter: filter || '', + types: types ?? [], + pagination: { + offset, + count, + sort, + }, + }), + ); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.autocomplete.adminRooms', - { authRequired: true }, { - async get() { - const { selector } = this.queryParams; - if (!selector) { - return API.v1.failure("The 'selector' param is required"); - } - - return API.v1.success( - await findAdminRoomsAutocomplete({ - uid: this.userId, - selector: JSON.parse(selector), - }), - ); + authRequired: true, + query: isRoomsAutocompleteAdminRoomsPayload, + response: { + 200: ajv.compile<{ items: IRoom[] }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { selector } = this.queryParams; + + return API.v1.success( + await findAdminRoomsAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }), + ); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.adminRooms.getRoom', - { authRequired: true }, { - async get() { - const { rid } = this.queryParams; - const room = await findAdminRoom({ - uid: this.userId, - rid: rid || '', - }); - - if (!room) { - return API.v1.failure('not-allowed', 'Not Allowed'); - } - return API.v1.success(room); + authRequired: true, + query: isRoomsAdminRoomsGetRoomProps, + response: { + 200: ajv.compile>({ + allOf: [ + { $ref: '#/components/schemas/IRoomAdmin' }, + { type: 'object', properties: { success: { type: 'boolean', enum: [true] } }, required: ['success'] }, + ], + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { rid } = this.queryParams; + const room = await findAdminRoom({ + uid: this.userId, + rid: rid || '', + }); + + if (!room) { + return API.v1.failure('not-allowed', 'Not Allowed'); + } + return API.v1.success(room); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.autocomplete.channelAndPrivate', - { authRequired: true }, { - async get() { - const { selector } = this.queryParams; - if (!selector) { - return API.v1.failure("The 'selector' param is required"); - } - - return API.v1.success( - await findChannelAndPrivateAutocomplete({ - uid: this.userId, - selector: JSON.parse(selector), - }), - ); + authRequired: true, + query: isRoomsAutoCompleteChannelAndPrivateProps, + response: { + 200: ajv.compile<{ items: IRoom[] }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { selector } = this.queryParams; + + return API.v1.success( + await findChannelAndPrivateAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }), + ); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.autocomplete.channelAndPrivate.withPagination', - { authRequired: true }, { - async get() { - const { selector } = this.queryParams; - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - - if (!selector) { - return API.v1.failure("The 'selector' param is required"); - } - - return API.v1.success( - await findChannelAndPrivateAutocompleteWithPagination({ - uid: this.userId, - selector: JSON.parse(selector), - pagination: { - offset, - count, - sort, - }, - }), - ); + authRequired: true, + query: isRoomsAutocompleteChannelAndPrivateWithPaginationProps, + response: { + 200: ajv.compile<{ items: IRoom[]; total: number }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { selector } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + return API.v1.success( + await findChannelAndPrivateAutocompleteWithPagination({ + uid: this.userId, + selector: JSON.parse(selector), + pagination: { + offset, + count, + sort, + }, + }), + ); + }, ); -API.v1.addRoute( +API.v1.get( 'rooms.autocomplete.availableForTeams', - { authRequired: true }, { - async get() { - const { name } = this.queryParams; - - if (name && typeof name !== 'string') { - return API.v1.failure("The 'name' param is invalid"); - } - - return API.v1.success( - await findRoomsAvailableForTeams({ - uid: this.userId, - name: name || '', - }), - ); + authRequired: true, + query: isRoomsAutocompleteAvailableForTeamsProps, + response: { + 200: ajv.compile<{ items: IRoom[] }>({ + type: 'object', + properties: { + items: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom autocomplete subset + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { name } = this.queryParams; + + return API.v1.success( + await findRoomsAvailableForTeams({ + uid: this.userId, + name, + }), + ); + }, ); -API.v1.addRoute( +API.v1.post( 'rooms.saveRoomSettings', - { authRequired: true }, { - async post() { - const { rid, ...params } = this.bodyParams; + authRequired: true, + body: isRoomsSaveRoomSettingsProps, + response: { + 200: ajv.compile<{ rid: string }>({ + type: 'object', + properties: { + rid: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rid', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, ...params } = this.bodyParams; - const result = await saveRoomSettings(this.userId, rid, params); + const result = await saveRoomSettings(this.userId, rid, params); - return API.v1.success({ rid: result.rid }); - }, + return API.v1.success({ rid: result.rid }); }, ); -API.v1.addRoute( +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +API.v1.post( 'rooms.changeArchivationState', - { authRequired: true, validateParams: isRoomsChangeArchivationStateProps }, { - async post() { - const { rid, action } = this.bodyParams; + authRequired: true, + body: isRoomsChangeArchivationStateProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, action } = this.bodyParams; - let result; - if (action === 'archive') { - result = await executeArchiveRoom(this.userId, rid); - } else { - result = await executeUnarchiveRoom(this.userId, rid); - } + if (action === 'archive') { + await executeArchiveRoom(this.userId, rid); + } else { + await executeUnarchiveRoom(this.userId, rid); + } - return API.v1.success({ result }); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'rooms.export', - { authRequired: true, validateParams: isRoomsExportProps }, { - async post() { - const { rid, type } = this.bodyParams; - - if (!(await hasPermissionAsync(this.userId, 'mail-messages', rid))) { - throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed'); - } + authRequired: true, + body: isRoomsExportProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + missing: { type: 'array', items: { type: 'string' } }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, type } = this.bodyParams; - const room = await Rooms.findOneById(rid); - if (!room) { - throw new Meteor.Error('error-invalid-room'); - } + if (!(await hasPermissionAsync(this.userId, 'mail-messages', rid))) { + throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed'); + } - const user = await Users.findOneById(this.userId); + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room'); + } - if (!user || !(await canAccessRoomAsync(room, user))) { - throw new Meteor.Error('error-not-allowed', 'Not Allowed'); - } + const user = await Users.findOneById(this.userId); - if (type === 'file') { - const { dateFrom, dateTo } = this.bodyParams; - const { format } = this.bodyParams; + if (!user || !(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not Allowed'); + } - const convertedDateFrom = dateFrom ? new Date(dateFrom) : new Date(0); - const convertedDateTo = dateTo ? new Date(dateTo) : new Date(); - convertedDateTo.setDate(convertedDateTo.getDate() + 1); + if (type === 'file') { + const { dateFrom, dateTo } = this.bodyParams; + const { format } = this.bodyParams; - if (convertedDateFrom > convertedDateTo) { - throw new Meteor.Error('error-invalid-dates', 'From date cannot be after To date'); - } + const convertedDateFrom = dateFrom ? new Date(dateFrom) : new Date(0); + const convertedDateTo = dateTo ? new Date(dateTo) : new Date(); + convertedDateTo.setDate(convertedDateTo.getDate() + 1); - void dataExport.sendFile( - { - rid, - format, - dateFrom: convertedDateFrom, - dateTo: convertedDateTo, - }, - user, - ); - return API.v1.success(); + if (convertedDateFrom > convertedDateTo) { + throw new Meteor.Error('error-invalid-dates', 'From date cannot be after To date'); } - if (type === 'email') { - const { toUsers, toEmails, subject, messages } = this.bodyParams; - - if ((!toUsers || toUsers.length === 0) && (!toEmails || toEmails.length === 0)) { - throw new Meteor.Error('error-invalid-recipient'); - } + void dataExport.sendFile( + { + rid, + format, + dateFrom: convertedDateFrom, + dateTo: convertedDateTo, + }, + user, + ); + return API.v1.success(); + } - const result = await dataExport.sendViaEmail( - { - rid, - toUsers: (toUsers as string[]) || [], - toEmails: toEmails || [], - subject: subject || '', - messages: messages || [], - language: user.language || 'en', - }, - user, - ); + if (type === 'email') { + const { toUsers, toEmails, subject, messages } = this.bodyParams; - return API.v1.success(result); + if ((!toUsers || toUsers.length === 0) && (!toEmails || toEmails.length === 0)) { + throw new Meteor.Error('error-invalid-recipient'); } - return API.v1.failure(); - }, + const result = await dataExport.sendViaEmail( + { + rid, + toUsers: (toUsers as string[]) || [], + toEmails: toEmails || [], + subject: subject || '', + messages: messages || [], + language: user.language || 'en', + }, + user, + ); + + return API.v1.success(result); + } + + return API.v1.failure(); }, ); -API.v1.addRoute( +API.v1.get( 'rooms.isMember', { authRequired: true, - validateParams: isRoomsIsMemberProps, + query: isRoomsIsMemberProps, + response: { + 200: ajv.compile<{ isMember: boolean }>({ + type: 'object', + properties: { + isMember: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['isMember', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - const { roomId, userId, username } = this.queryParams; - const [room, user] = await Promise.all([ - findRoomByIdOrName({ - params: { roomId }, - }), - Users.findOneByIdOrUsername(userId || username), - ]); + async function action() { + const { roomId } = this.queryParams; + const usernameOrUserId = 'userId' in this.queryParams ? this.queryParams.userId : this.queryParams.username; + const [room, user] = await Promise.all([ + findRoomByIdOrName({ + params: { roomId }, + }), + Users.findOneByIdOrUsername(usernameOrUserId), + ]); - if (!user?._id) { - return API.v1.failure('error-user-not-found'); - } + if (!user?._id) { + return API.v1.failure('error-user-not-found'); + } - if (await canAccessRoomAsync(room, { _id: this.user._id })) { - return API.v1.success({ - isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0, - }); - } - return API.v1.forbidden(); - }, + if (await canAccessRoomAsync(room, { _id: this.user._id })) { + return API.v1.success({ + isMember: (await Subscriptions.countByRoomIdAndUserId(room._id, user._id)) > 0, + }); + } + return API.v1.forbidden(); }, ); -API.v1.addRoute( +API.v1.get( 'rooms.membersOrderedByRole', - { authRequired: true, validateParams: isRoomsMembersOrderedByRoleProps }, { - async get() { - const findResult = await findRoomByIdOrName({ - params: this.queryParams, - checkedArchived: false, - }); - - if (!(await canAccessRoomAsync(findResult, this.user))) { - return API.v1.notFound('The required "roomId" or "roomName" param provided does not match any room'); - } - - if (!isPublicRoom(findResult) && !isPrivateRoom(findResult)) { - return API.v1.failure('error-room-type-not-supported'); - } - - if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult._id))) { - return API.v1.unauthorized(); - } - - // Ensures that role priorities for the specified room are synchronized correctly. - // This function acts as a soft migration. If the `roomRolePriorities` field - // for the room has already been created and is up-to-date, no updates will be performed. - // If not, it will synchronize the role priorities of the users of the room. - await syncRolePrioritiesForRoomIfRequired(findResult._id); + authRequired: true, + query: isRoomsMembersOrderedByRoleProps, + response: { + 200: ajv.compile<{ members: IUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + members: { type: 'array', items: { type: 'object' } }, // relaxed: projected IUser with role priority + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['members', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const findResult = await findRoomByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); - const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); - const { sort = {} } = await this.parseJsonQuery(); + if (!(await canAccessRoomAsync(findResult, this.user))) { + return API.v1.notFound('The required "roomId" or "roomName" param provided does not match any room'); + } - const { status, filter } = this.queryParams; + if (!isPublicRoom(findResult) && !isPrivateRoom(findResult)) { + return API.v1.failure('error-room-type-not-supported'); + } - const { members, total } = await findUsersOfRoomOrderedByRole({ - rid: findResult._id, - ...(status && { status: { $in: status } }), - skip, - limit, - filter, - sort, - }); + if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult._id))) { + return API.v1.unauthorized(); + } - return API.v1.success({ - members, - count: members.length, - offset: skip, - total, - }); - }, + // Ensures that role priorities for the specified room are synchronized correctly. + // This function acts as a soft migration. If the `roomRolePriorities` field + // for the room has already been created and is up-to-date, no updates will be performed. + // If not, it will synchronize the role priorities of the users of the room. + await syncRolePrioritiesForRoomIfRequired(findResult._id); + + const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); + + const { status, filter } = this.queryParams; + + const { members, total } = await findUsersOfRoomOrderedByRole({ + rid: findResult._id, + ...(status && { status: { $in: status } }), + skip, + limit, + filter, + sort, + }); + + return API.v1.success({ + members, + count: members.length, + offset: skip, + total, + }); }, ); -API.v1.addRoute( +API.v1.post( 'rooms.muteUser', - { authRequired: true, validateParams: isRoomsMuteUnmuteUserProps }, { - async post() { - const user = await getUserFromParams(this.bodyParams); + authRequired: true, + body: isRoomsMuteUnmuteUserProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); - if (!user.username) { - return API.v1.failure('Invalid user'); - } + if (!user.username) { + return API.v1.failure('Invalid user'); + } - await muteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); + await muteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'rooms.unmuteUser', - { authRequired: true, validateParams: isRoomsMuteUnmuteUserProps }, { - async post() { - const user = await getUserFromParams(this.bodyParams); + authRequired: true, + body: isRoomsMuteUnmuteUserProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); - if (!user.username) { - return API.v1.failure('Invalid user'); - } + if (!user.username) { + return API.v1.failure('Invalid user'); + } - await unmuteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); + await unmuteUserInRoom(this.userId, { rid: this.bodyParams.roomId, username: user.username }); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'rooms.open', - { authRequired: true, validateParams: isRoomsOpenProps }, { - async post() { - const { roomId } = this.bodyParams; + authRequired: true, + body: isRoomsOpenProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.bodyParams; - await openRoom(this.userId, roomId); + await openRoom(this.userId, roomId); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'rooms.hide', - { authRequired: true, validateParams: isRoomsHideProps }, { - async post() { - const { roomId } = this.bodyParams; + authRequired: true, + body: isRoomsHideProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { roomId } = this.bodyParams; - if (!(await canAccessRoomIdAsync(roomId, this.userId))) { - return API.v1.unauthorized(); - } + if (!(await canAccessRoomIdAsync(roomId, this.userId))) { + return API.v1.unauthorized(); + } - const user = await Users.findOneById(this.userId, { projections: { _id: 1 } }); + const user = await Users.findOneById(this.userId, { projections: { _id: 1 } }); - if (!user) { - return API.v1.failure('error-invalid-user'); - } + if (!user) { + return API.v1.failure('error-invalid-user'); + } - const modCount = await hideRoomMethod(this.userId, roomId); + const modCount = await hideRoomMethod(this.userId, roomId); - if (!modCount) { - return API.v1.failure('error-room-already-hidden'); - } + if (!modCount) { + return API.v1.failure('error-room-already-hidden'); + } - return API.v1.success(); - }, + return API.v1.success(); }, ); @@ -1153,7 +1416,7 @@ export const roomEndpoints = API.v1 properties: { rooms: { type: 'array', - items: { type: 'object' }, + items: { type: 'object' }, // relaxed: IRoom subset }, count: { type: 'number' }, offset: { type: 'number' }, diff --git a/apps/meteor/app/livechat/imports/server/rest/rooms.ts b/apps/meteor/app/livechat/imports/server/rest/rooms.ts index f7462495013bf..4fe1e8bb279f6 100644 --- a/apps/meteor/app/livechat/imports/server/rest/rooms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/rooms.ts @@ -63,7 +63,7 @@ API.v1.addRoute( agents, roomName, departmentId, - ...(isBoolean(open) && { open: open === 'true' }), + ...(isBoolean(open) && { open: open === true || open === 'true' }), createdAt: createdAtParam, closedAt: closedAtParam, tags, diff --git a/apps/meteor/client/views/admin/rooms/RoomsTable.tsx b/apps/meteor/client/views/admin/rooms/RoomsTable.tsx index 40ef5ecff8ca9..a3338a65929b2 100644 --- a/apps/meteor/client/views/admin/rooms/RoomsTable.tsx +++ b/apps/meteor/client/views/admin/rooms/RoomsTable.tsx @@ -49,7 +49,14 @@ const RoomsTable = ({ reload }: { reload: MutableRefObject<() => void> }): React sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, count: itemsPerPage, offset: searchText === prevRoomFilterText.current ? current : 0, - types: roomFilters.types.length ? [...roomFilters.types.map((roomType) => roomType.id)] : DEFAULT_TYPES, + types: (roomFilters.types.length ? [...roomFilters.types.map((roomType) => roomType.id)] : DEFAULT_TYPES) as unknown as ( + | 'c' + | 'd' + | 'p' + | 'l' + | 'discussions' + | 'teams' + )[], }; }, [searchText, sortBy, sortDirection, itemsPerPage, current, roomFilters.types, setCurrent]), 500, diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 848e47c5546f7..eefcf67192726 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -228,17 +228,6 @@ describe('LIVECHAT - rooms', () => { await restorePermissionToRoles('view-livechat-rooms'); }); - it('should return an error when the "agents" query parameter is not valid', async () => { - await request - .get(api('livechat/rooms')) - .query({ agents: 'invalid' }) - .set(credentials) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res: Response) => { - expect(res.body).to.have.property('success', false); - }); - }); it('should return an error when the "roomName" query parameter is not valid', async () => { await request .get(api('livechat/rooms')) @@ -265,7 +254,7 @@ describe('LIVECHAT - rooms', () => { it('should return an error when the "open" query parameter is not valid', async () => { await request .get(api('livechat/rooms')) - .query({ 'open[]': 'true' }) + .query({ open: { test: true } }) .set(credentials) .expect('Content-Type', 'application/json') .expect(400) @@ -276,7 +265,7 @@ describe('LIVECHAT - rooms', () => { it('should return an error when the "tags" query parameter is not valid', async () => { await request .get(api('livechat/rooms')) - .query({ tags: 'invalid' }) + .query({ tags: { obj: true } }) .set(credentials) .expect('Content-Type', 'application/json') .expect(400) diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index efc96c1ef552a..eaddd626f94ea 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -1548,7 +1548,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'Body parameter "prid" is required.'); + expect(res.body).to.have.property('error').that.includes("must have required property 'prid'"); }) .end(done); }); @@ -1562,7 +1562,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'Body parameter "t_name" is required.'); + expect(res.body).to.have.property('error').that.includes("must have required property 't_name'"); }) .end(done); }); @@ -1578,7 +1578,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'Body parameter "users" must be an array.'); + expect(res.body).to.have.property('error').that.includes('must be array'); }) .end(done); }); @@ -1832,7 +1832,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal("The 'selector' param is required"); + expect(res.body.error).to.include("must have required property 'selector'"); }) .end(done); }); @@ -1876,7 +1876,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal("The 'selector' param is required"); + expect(res.body.error).to.include("must have required property 'selector'"); }) .end(done); }); @@ -1972,7 +1972,7 @@ describe('[Rooms]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.error).to.be.equal("The 'selector' param is required"); + expect(res.body.error).to.include("must have required property 'selector'"); }) .end(done); }); @@ -2364,30 +2364,39 @@ describe('[Rooms]', () => { }); }); - it('should update group name if user changes name', async () => { - await updateSetting('UI_Use_Real_Name', true); - await request - .post(api('users.update')) - .set(credentials) - .send({ - userId: testUser._id, - data: { - name: `changed.name.${testUser.username}`, - }, - }); + describe('use real name', () => { + before(async () => { + await updateSetting('UI_Use_Real_Name', true); + }); - // need to wait for the name update finish - await sleep(300); + after(async () => { + await updateSetting('UI_Use_Real_Name', false); + }); - await request - .get(api('subscriptions.getOne')) - .set(credentials) - .query({ roomId }) - .send() - .expect((res) => { - const { subscription } = res.body; - expect(subscription.fname).to.equal(`changed.name.${testUser.username}, ${testUser2.name}`); - }); + it('should update group name if user changes name', async () => { + await request + .post(api('users.update')) + .set(credentials) + .send({ + userId: testUser._id, + data: { + name: `changed.name.${testUser.username}`, + }, + }); + + // need to wait for the name update finish + await sleep(300); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ roomId }) + .send() + .expect((res) => { + const { subscription } = res.body; + expect(subscription.fname).to.equal(`changed.name.${testUser.username}, ${testUser2.name}`); + }); + }); }); }); From 55a9dc670841ac8d4d5a6a1f3ca66a9fbc11175b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 15 Apr 2026 00:04:16 -0300 Subject: [PATCH 11/14] chore(api): migrate teams endpoints to OpenAPI --- apps/meteor/app/api/server/v1/teams.ts | 1041 +++++++++-------- apps/meteor/tests/end-to-end/api/abac.ts | 3 + apps/meteor/tests/end-to-end/api/users.ts | 10 + packages/rest-typings/src/index.ts | 1 - .../src/v1/teams/TeamsAddRoomsProps.ts | 30 + .../src/v1/teams/TeamsAutocompleteProps.ts | 14 + .../src/v1/teams/TeamsCreateProps.ts | 29 + .../src/v1/teams/TeamsInfoProps.ts | 15 + .../src/v1/teams/TeamsListRoomsOfUserProps.ts | 27 + .../src/v1/teams/TeamsListRoomsProps.ts | 26 + .../src/v1/teams/TeamsMembersProps.ts | 28 + .../src/v1/teams/TeamsUpdateRoomProps.ts | 22 + packages/rest-typings/src/v1/teams/index.ts | 10 +- 13 files changed, 787 insertions(+), 469 deletions(-) create mode 100644 packages/rest-typings/src/v1/teams/TeamsAddRoomsProps.ts create mode 100644 packages/rest-typings/src/v1/teams/TeamsAutocompleteProps.ts create mode 100644 packages/rest-typings/src/v1/teams/TeamsCreateProps.ts create mode 100644 packages/rest-typings/src/v1/teams/TeamsInfoProps.ts create mode 100644 packages/rest-typings/src/v1/teams/TeamsListRoomsOfUserProps.ts create mode 100644 packages/rest-typings/src/v1/teams/TeamsListRoomsProps.ts create mode 100644 packages/rest-typings/src/v1/teams/TeamsMembersProps.ts create mode 100644 packages/rest-typings/src/v1/teams/TeamsUpdateRoomProps.ts diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index 0281d0f7a8080..fe3de29cf22a4 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -1,35 +1,75 @@ import { Team } from '@rocket.chat/core-services'; -import type { ITeam, UserStatus } from '@rocket.chat/core-typings'; -import { TeamType } from '@rocket.chat/core-typings'; +import type { ITeamAutocompleteResult } from '@rocket.chat/core-services'; +import type { ITeam } from '@rocket.chat/core-typings'; import { Users, Rooms } from '@rocket.chat/models'; import { + ajv, + isTeamsAddRoomsProps, + isTeamsAutocompleteProps, isTeamsConvertToChannelProps, + isTeamsCreateProps, + isTeamsInfoProps, + isTeamsListRoomsProps, + isTeamsListRoomsOfUserProps, + isTeamsMembersProps, isTeamsRemoveRoomProps, isTeamsUpdateMemberProps, + isTeamsUpdateRoomProps, isTeamsRemoveMemberProps, isTeamsAddMembersProps, isTeamsDeleteProps, isTeamsLeaveProps, isTeamsUpdateProps, isTeamsListChildrenProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, + validateNotFoundErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; -import { Match, check } from 'meteor/check'; import { eraseRoom } from '../../../../server/lib/eraseRoom'; import { canAccessRoomAsync } from '../../../authorization/server'; import { hasPermissionAsync, hasAtLeastOnePermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { removeUserFromRoom } from '../../../lib/server/functions/removeUserFromRoom'; import { settings } from '../../../settings/server'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { eraseTeam } from '../lib/eraseTeam'; -API.v1.addRoute( - 'teams.list', - { authRequired: true }, - { - async get() { +const paginatedTeamsResponseSchema = ajv.compile<{ teams: ITeam[]; total: number; count: number; offset: number }>({ + type: 'object', + properties: { + teams: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teams', 'total', 'count', 'offset', 'success'], + additionalProperties: false, +}); + +const successResponseSchema = ajv.compile({ + type: 'object', + properties: { success: { type: 'boolean', enum: [true] } }, + required: ['success'], + additionalProperties: false, +}); + +const teamsEndpoints = API.v1 + .get( + 'teams.list', + { + authRequired: true, + response: { + 200: paginatedTeamsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { offset, count } = await getPaginationItems(this.queryParams); const { sort, query } = await this.parseJsonQuery(); @@ -42,14 +82,19 @@ API.v1.addRoute( offset, }); }, - }, -); - -API.v1.addRoute( - 'teams.listAll', - { authRequired: true, permissionsRequired: ['view-all-teams'] }, - { - async get() { + ) + .get( + 'teams.listAll', + { + authRequired: true, + permissionsRequired: ['view-all-teams'], + response: { + 200: paginatedTeamsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { offset, count } = await getPaginationItems(this.queryParams); const { records, total } = await Team.listAll({ offset, count }); @@ -61,25 +106,28 @@ API.v1.addRoute( offset, }); }, - }, -); - -API.v1.addRoute( - 'teams.create', - { authRequired: true, permissionsRequired: ['create-team'] }, - { - async post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - name: String, - type: Match.OneOf(TeamType.PRIVATE, TeamType.PUBLIC), - members: Match.Maybe([String]), - room: Match.Maybe(Match.Any), - owner: Match.Maybe(String), + ) + .post( + 'teams.create', + { + authRequired: true, + permissionsRequired: ['create-team'], + body: isTeamsCreateProps, + response: { + 200: ajv.compile<{ team: ITeam }>({ + type: 'object', + properties: { + team: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['team', 'success'], + additionalProperties: false, }), - ); - + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { name, type, members, room, owner } = this.bodyParams; const team = await Team.create(this.userId, { @@ -87,15 +135,14 @@ API.v1.addRoute( name, type, }, - room, + room: room as Parameters[1]['room'], members, owner, }); return API.v1.success({ team }); }, - }, -); + ); const getTeamByIdOrName = async (params: { teamId: string } | { teamName: string }): Promise => { if ('teamId' in params && params.teamId) { @@ -109,264 +156,266 @@ const getTeamByIdOrName = async (params: { teamId: string } | { teamName: string return null; }; -API.v1.addRoute( +API.v1.post( 'teams.convertToChannel', { authRequired: true, - validateParams: isTeamsConvertToChannelProps, + body: isTeamsConvertToChannelProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { roomsToRemove = [] } = this.bodyParams; + async function action() { + const { roomsToRemove = [] } = this.bodyParams; - const team = await getTeamByIdOrName(this.bodyParams); + const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'convert-team', team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasPermissionAsync(this.userId, 'convert-team', team.roomId))) { + return API.v1.forbidden(); + } - const rooms = await Team.getMatchingTeamRooms(team._id, roomsToRemove); + const rooms = await Team.getMatchingTeamRooms(team._id, roomsToRemove); - if (rooms.length) { - for (const room of rooms) { - await eraseRoom(room, this.user); - } + if (rooms.length) { + for (const room of rooms) { + await eraseRoom(room, this.user); } + } - await Promise.all([Team.unsetTeamIdOfRooms(this.user, team), Team.removeAllMembersFromTeam(team._id)]); + await Promise.all([Team.unsetTeamIdOfRooms(this.user, team), Team.removeAllMembersFromTeam(team._id)]); - await Team.deleteById(team._id); + await Team.deleteById(team._id); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +const roomResponseSchema = ajv.compile<{ room: object }>({ + type: 'object', + properties: { + room: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, +}); + +const roomsResponseSchema = ajv.compile<{ rooms: object[] }>({ + type: 'object', + properties: { + rooms: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rooms', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'teams.addRooms', - { authRequired: true }, { - async post() { - check( - this.bodyParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - rooms: [String] as [StringConstructor], - }), - Match.ObjectIncluding({ - teamName: String, - rooms: [String] as [StringConstructor], - }), - ), - ); - - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + authRequired: true, + body: isTeamsAddRoomsProps, + response: { + 200: roomsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'move-room-to-team', team.roomId))) { - return API.v1.forbidden('error-no-permission-team-channel'); - } + if (!(await hasPermissionAsync(this.userId, 'move-room-to-team', team.roomId))) { + return API.v1.forbidden('error-no-permission-team-channel'); + } - const { rooms } = this.bodyParams; + const { rooms } = this.bodyParams; - const validRooms = await Team.addRooms(this.userId, rooms, team._id); + const validRooms = await Team.addRooms(this.userId, rooms, team._id); - return API.v1.success({ rooms: validRooms }); - }, + return API.v1.success({ rooms: validRooms }); }, ); -API.v1.addRoute( +API.v1.post( 'teams.removeRoom', { authRequired: true, - validateParams: isTeamsRemoveRoomProps, + body: isTeamsRemoveRoomProps, + response: { + 200: roomResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + async function action() { + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'remove-team-channel', team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasPermissionAsync(this.userId, 'remove-team-channel', team.roomId))) { + return API.v1.forbidden(); + } - const canRemoveAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)); + const canRemoveAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)); - const { roomId } = this.bodyParams; + const { roomId } = this.bodyParams; - const room = await Team.removeRoom(this.userId, roomId, team._id, canRemoveAny); + const room = await Team.removeRoom(this.userId, roomId, team._id, canRemoveAny); - return API.v1.success({ room }); - }, + return API.v1.success({ room }); }, ); -API.v1.addRoute( +API.v1.post( 'teams.updateRoom', - { authRequired: true }, { - async post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - roomId: String, - isDefault: Boolean, - }), - ); - - const { roomId, isDefault } = this.bodyParams; + authRequired: true, + body: isTeamsUpdateRoomProps, + response: { + 200: roomResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { roomId, isDefault } = this.bodyParams; - const team = await Team.getOneByRoomId(roomId); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + const team = await Team.getOneByRoomId(roomId); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'edit-team-channel', team.roomId))) { - return API.v1.forbidden(); - } - const canUpdateAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)); + if (!(await hasPermissionAsync(this.userId, 'edit-team-channel', team.roomId))) { + return API.v1.forbidden(); + } + const canUpdateAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)); - if (settings.get('ABAC_Enabled') && isDefault) { - const room = await Rooms.findOneByIdAndType(roomId, 'p', { projection: { abacAttributes: 1 } }); - if (room?.abacAttributes?.length) { - return API.v1.failure('error-room-is-abac-managed'); - } + if (settings.get('ABAC_Enabled') && isDefault) { + const room = await Rooms.findOneByIdAndType(roomId, 'p', { projection: { abacAttributes: 1 } }); + if (room?.abacAttributes?.length) { + return API.v1.failure('error-room-is-abac-managed'); } + } - const room = await Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny); + const room = await Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny); - return API.v1.success({ room }); - }, + return API.v1.success({ room }); }, ); -API.v1.addRoute( +const paginatedRoomsResponseSchema = ajv.compile<{ rooms: object[]; total: number; count: number; offset: number }>({ + type: 'object', + properties: { + rooms: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['rooms', 'total', 'count', 'offset', 'success'], + additionalProperties: false, +}); + +API.v1.get( 'teams.listRooms', - { authRequired: true }, { - async get() { - check( - this.queryParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - }), - Match.ObjectIncluding({ - teamName: String, - }), - ), - ); - - check( - this.queryParams, - Match.ObjectIncluding({ - filter: Match.Maybe(String), - type: Match.Maybe(String), - offset: Match.Maybe(String), - count: Match.Maybe(String), - }), - ); - - const { filter, type } = this.queryParams; - const { offset, count } = await getPaginationItems(this.queryParams); - - const team = await getTeamByIdOrName(this.queryParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } - - const allowPrivateTeam: boolean = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); - - const getAllRooms = await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId); - - const listFilter = { - name: filter ?? undefined, - isDefault: type === 'autoJoin', - getAllRooms, - allowPrivateTeam, - }; - - const { records, total } = await Team.listRooms(this.userId, team._id, listFilter, { - offset, - count, - }); - - return API.v1.success({ - rooms: records, - total, - count: records.length, - offset, - }); + authRequired: true, + query: isTeamsListRoomsProps, + response: { + 200: paginatedRoomsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { filter, type } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + + const team = await getTeamByIdOrName(this.queryParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + const allowPrivateTeam: boolean = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); + + const getAllRooms = await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId); + + const listFilter = { + name: filter ?? undefined, + isDefault: type === 'autoJoin', + getAllRooms, + allowPrivateTeam, + }; + + const { records, total } = await Team.listRooms(this.userId, team._id, listFilter, { + offset, + count, + }); + + return API.v1.success({ + rooms: records, + total, + count: records.length, + offset, + }); + }, ); -API.v1.addRoute( +API.v1.get( 'teams.listRoomsOfUser', - { authRequired: true }, { - async get() { - check( - this.queryParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - }), - Match.ObjectIncluding({ - teamName: String, - }), - ), - ); - - check( - this.queryParams, - Match.ObjectIncluding({ - userId: String, - canUserDelete: Match.Maybe(String), - offset: Match.Maybe(String), - count: Match.Maybe(String), - }), - ); - - const { offset, count } = await getPaginationItems(this.queryParams); - - const team = await getTeamByIdOrName(this.queryParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } - - const allowPrivateTeam = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); - - const { userId, canUserDelete } = this.queryParams; - - if (!(this.userId === userId || (await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)))) { - return API.v1.forbidden(); - } - - const booleanCanUserDelete = canUserDelete === 'true'; - const { records, total } = await Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, booleanCanUserDelete, { - offset, - count, - }); - - return API.v1.success({ - rooms: records, - total, - count: records.length, - offset: 0, - }); + authRequired: true, + query: isTeamsListRoomsOfUserProps, + response: { + 200: paginatedRoomsResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + + const team = await getTeamByIdOrName(this.queryParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + const allowPrivateTeam = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); + + const { userId, canUserDelete } = this.queryParams; + + if (!(this.userId === userId || (await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)))) { + return API.v1.forbidden(); + } + + const booleanCanUserDelete = canUserDelete === 'true'; + const { records, total } = await Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, booleanCanUserDelete, { + offset, + count, + }); + + return API.v1.success({ + rooms: records, + total, + count: records.length, + offset: 0, + }); + }, ); const getTeamByIdOrNameOrParentRoom = async ( @@ -386,322 +435,380 @@ const getTeamByIdOrNameOrParentRoom = async ( // This should accept a teamId, filter (search by name on rooms collection) and sort/pagination // should return a list of rooms/discussions from the team. the discussions will only be returned from the main room -API.v1.addRoute( +API.v1.get( 'teams.listChildren', - { authRequired: true, validateParams: isTeamsListChildrenProps }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - const { filter, type } = this.queryParams; + authRequired: true, + query: isTeamsListChildrenProps, + response: { + 200: ajv.compile<{ data: object[]; total: number; offset: number; count: number }>({ + type: 'object', + properties: { + data: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + offset: { type: 'number' }, + count: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['data', 'total', 'offset', 'count', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 404: validateNotFoundErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { filter, type } = this.queryParams; - const team = await getTeamByIdOrNameOrParentRoom(this.queryParams); - if (!team) { - return API.v1.notFound(); - } + const team = await getTeamByIdOrNameOrParentRoom(this.queryParams); + if (!team) { + return API.v1.notFound(); + } - const data = await Team.listChildren(this.userId, team, filter, type, sort, offset, count); + const result = await Team.listChildren(this.userId, team, filter, type, sort, offset, count); - return API.v1.success({ ...data, offset, count }); - }, + return API.v1.success({ data: result.data, total: result.total, offset, count }); }, ); -API.v1.addRoute( +API.v1.get( 'teams.members', - { authRequired: true }, { - async get() { - const { offset, count } = await getPaginationItems(this.queryParams); - - check( - this.queryParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - }), - Match.ObjectIncluding({ - teamName: String, - }), - ), - ); - - check( - this.queryParams, - Match.ObjectIncluding({ - status: Match.Maybe([String]), - username: Match.Maybe(String), - name: Match.Maybe(String), - }), - ); - - const { status, username, name } = this.queryParams; - - const team = await getTeamByIdOrName(this.queryParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } - - const canSeeAllMembers = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); - - const query = { - ...(username && { username: new RegExp(escapeRegExp(username), 'i') }), - ...(name && { name: new RegExp(escapeRegExp(name), 'i') }), - ...(status && { status: { $in: status as UserStatus[] } }), - }; - - const { records, total } = await Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query); - - return API.v1.success({ - members: records, - total, - count: records.length, - offset, - }); + authRequired: true, + query: isTeamsMembersProps, + response: { + 200: ajv.compile<{ members: object[]; total: number; count: number; offset: number }>({ + type: 'object', + properties: { + members: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + count: { type: 'number' }, + offset: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['members', 'total', 'count', 'offset', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + + const { status, username, name } = this.queryParams; + + const team = await getTeamByIdOrName(this.queryParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + const canSeeAllMembers = await hasPermissionAsync(this.userId, 'view-all-teams', team.roomId); + + const query: Record = {}; + if (username) { + query.username = new RegExp(escapeRegExp(username), 'i'); + } + if (name) { + query.name = new RegExp(escapeRegExp(name), 'i'); + } + if (status) { + query.status = { $in: status }; + } + + const { records, total } = await Team.members(this.userId, team._id, canSeeAllMembers, { offset, count }, query); + + return API.v1.success({ + members: records, + total, + count: records.length, + offset, + }); + }, ); -API.v1.addRoute( +API.v1.post( 'teams.addMembers', { authRequired: true, - validateParams: isTeamsAddMembersProps, + body: isTeamsAddMembersProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { bodyParams } = this; - const { members } = bodyParams; + async function action() { + const { bodyParams } = this; + const { members } = bodyParams; - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasAtLeastOnePermissionAsync(this.userId, ['add-team-member', 'edit-team-member'], team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasAtLeastOnePermissionAsync(this.userId, ['add-team-member', 'edit-team-member'], team.roomId))) { + return API.v1.forbidden(); + } - await Team.addMembers(this.userId, team._id, members); + await Team.addMembers(this.userId, team._id, members); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'teams.updateMember', { authRequired: true, - validateParams: isTeamsUpdateMemberProps, + body: isTeamsUpdateMemberProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { bodyParams } = this; - const { member } = bodyParams; + async function action() { + const { bodyParams } = this; + const { member } = bodyParams; - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) { + return API.v1.forbidden(); + } - await Team.updateMember(team._id, member); + await Team.updateMember(team._id, member); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'teams.removeMember', { authRequired: true, - validateParams: isTeamsRemoveMemberProps, - }, - { - async post() { - const { bodyParams } = this; - const { userId, rooms } = bodyParams; - - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } - - if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) { - return API.v1.forbidden(); - } - - const user = await Users.findOneActiveById(userId, {}); - if (!user) { - return API.v1.failure('invalid-user'); - } - - if (!(await Team.removeMembers(this.userId, team._id, [{ userId }]))) { - return API.v1.failure(); - } - - if (rooms?.length) { - const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); - - await Promise.all( - roomsFromTeam.map((rid) => - removeUserFromRoom(rid, user, { - byUser: this.user, - }), - ), - ); - } - return API.v1.success(); + body: isTeamsRemoveMemberProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, }, }, + async function action() { + const { bodyParams } = this; + const { userId, rooms } = bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + if (!(await hasAtLeastOnePermissionAsync(this.userId, ['edit-team-member'], team.roomId))) { + return API.v1.forbidden(); + } + + const user = await Users.findOneActiveById(userId, {}); + if (!user) { + return API.v1.failure('invalid-user'); + } + + if (!(await Team.removeMembers(this.userId, team._id, [{ userId }]))) { + return API.v1.failure('could-not-remove-member'); + } + + if (rooms?.length) { + const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); + + await Promise.all( + roomsFromTeam.map((rid) => + removeUserFromRoom(rid, user, { + byUser: this.user, + }), + ), + ); + } + return API.v1.success(); + }, ); -API.v1.addRoute( +API.v1.post( 'teams.leave', { authRequired: true, - validateParams: isTeamsLeaveProps, - }, - { - async post() { - const { rooms = [] } = this.bodyParams; - - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } - - await Team.removeMembers(this.userId, team._id, [ - { - userId: this.userId, - }, - ]); - - if (rooms.length) { - const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); - await Promise.all(roomsFromTeam.map((rid) => removeUserFromRoom(rid, this.user))); - } - - return API.v1.success(); + body: isTeamsLeaveProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const { rooms = [] } = this.bodyParams; + + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } + + await Team.removeMembers(this.userId, team._id, [ + { + userId: this.userId, + }, + ]); + + if (rooms.length) { + const roomsFromTeam: string[] = await Team.getMatchingTeamRooms(team._id, rooms); + await Promise.all(roomsFromTeam.map((rid) => removeUserFromRoom(rid, this.user))); + } + + return API.v1.success(); + }, ); -API.v1.addRoute( +API.v1.get( 'teams.info', - { authRequired: true }, { - async get() { - check( - this.queryParams, - Match.OneOf( - Match.ObjectIncluding({ - teamId: String, - }), - Match.ObjectIncluding({ - teamName: String, - }), - ), - ); - - const teamInfo = await getTeamByIdOrName(this.queryParams); - if (!teamInfo) { - return API.v1.failure('Team not found'); - } + authRequired: true, + query: isTeamsInfoProps, + response: { + 200: ajv.compile<{ teamInfo: ITeam }>({ + type: 'object', + properties: { + teamInfo: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teamInfo', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const teamInfo = await getTeamByIdOrName(this.queryParams); + if (!teamInfo) { + return API.v1.failure('Team not found'); + } - const room = await Rooms.findOneById(teamInfo.roomId); + const room = await Rooms.findOneById(teamInfo.roomId); - if (!room) { - return API.v1.failure('Room not found'); - } + if (!room) { + return API.v1.failure('Room not found'); + } - const canViewInfo = - (await canAccessRoomAsync(room, { _id: this.userId })) || (await hasPermissionAsync(this.userId, 'view-all-teams')); + const canViewInfo = (await canAccessRoomAsync(room, { _id: this.userId })) || (await hasPermissionAsync(this.userId, 'view-all-teams')); - if (!canViewInfo) { - return API.v1.forbidden(); - } + if (!canViewInfo) { + return API.v1.forbidden(); + } - return API.v1.success({ teamInfo }); - }, + return API.v1.success({ teamInfo }); }, ); -API.v1.addRoute( +API.v1.post( 'teams.delete', { authRequired: true, - validateParams: isTeamsDeleteProps, + body: isTeamsDeleteProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { roomsToRemove = [] } = this.bodyParams; + async function action() { + const { roomsToRemove = [] } = this.bodyParams; - const team = await getTeamByIdOrName(this.bodyParams); + const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'delete-team', team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasPermissionAsync(this.userId, 'delete-team', team.roomId))) { + return API.v1.forbidden(); + } - await eraseTeam(this.user, team, roomsToRemove); + await eraseTeam(this.user, team, roomsToRemove); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.get( 'teams.autocomplete', - { authRequired: true }, { - async get() { - check( - this.queryParams, - Match.ObjectIncluding({ - name: String, - }), - ); - - const { name } = this.queryParams; + authRequired: true, + query: isTeamsAutocompleteProps, + response: { + 200: ajv.compile<{ teams: ITeamAutocompleteResult[] }>({ + type: 'object', + properties: { + teams: { type: 'array', items: { type: 'object' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teams', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { name } = this.queryParams; - const teams = await Team.autocomplete(this.userId, name); + const teams = await Team.autocomplete(this.userId, name); - return API.v1.success({ teams }); - }, + return API.v1.success({ teams }); }, ); -API.v1.addRoute( +API.v1.post( 'teams.update', { authRequired: true, - validateParams: isTeamsUpdateProps, + body: isTeamsUpdateProps, + response: { + 200: successResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async post() { - const { data } = this.bodyParams; + async function action() { + const { data } = this.bodyParams; - const team = await getTeamByIdOrName(this.bodyParams); - if (!team) { - return API.v1.failure('team-does-not-exist'); - } + const team = await getTeamByIdOrName(this.bodyParams); + if (!team) { + return API.v1.failure('team-does-not-exist'); + } - if (!(await hasPermissionAsync(this.userId, 'edit-team', team.roomId))) { - return API.v1.forbidden(); - } + if (!(await hasPermissionAsync(this.userId, 'edit-team', team.roomId))) { + return API.v1.forbidden(); + } - await Team.update(this.userId, team._id, data); + await Team.update(this.userId, team._id, data); - return API.v1.success(); - }, + return API.v1.success(); }, ); + +export type TeamsEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends TeamsEndpoints {} +} diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index d0d4d594d64b2..014cc470948a5 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -747,6 +747,7 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I const setDefaultRes = await request .post(`${v1}/teams.updateRoom`) .set(credentials) + // TODO: teamId is accepted but never used by the endpoint handler — callers should stop sending it .send({ teamId, roomId: teamPrivateRoomId, isDefault: true }) .expect(200); @@ -796,6 +797,7 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I await request .post(`${v1}/teams.updateRoom`) .set(credentials) + // TODO: teamId is accepted but never used by the endpoint handler — callers should stop sending it .send({ teamId, roomId: teamDefaultRoomId, isDefault: false }) .expect(200); @@ -987,6 +989,7 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I await request .post(`${v1}/teams.updateRoom`) .set(credentials) + // TODO: teamId is accepted but never used by the endpoint handler — callers should stop sending it .send({ teamId: teamIdForConversion, roomId: teamRoomId, isDefault: true }) .expect(400) .expect((res) => { diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index fade0321b5250..50513d7d2b900 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -1669,6 +1669,16 @@ describe('[Users]', () => { }); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.list')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('Avatars', () => { diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index dca6dc2505404..9c25e22e983f8 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -241,7 +241,6 @@ export * from './v1/users/UsersUpdateParamsPOST'; export * from './v1/users/UsersCheckUsernameAvailabilityParamsGET'; export * from './v1/users/UsersSendConfirmationEmailParamsPOST'; export * from './v1/users/UsersGetAvatarParamsGET'; -export * from './v1/users/UsersListParamsGET'; export * from './v1/users/UsersPresenceParamsGET'; export * from './v1/users/UsersRequestDataDownloadParamsGET'; export * from './v1/users/UsersGetPresenceParamsGET'; diff --git a/packages/rest-typings/src/v1/teams/TeamsAddRoomsProps.ts b/packages/rest-typings/src/v1/teams/TeamsAddRoomsProps.ts new file mode 100644 index 0000000000000..60d76996a8eb8 --- /dev/null +++ b/packages/rest-typings/src/v1/teams/TeamsAddRoomsProps.ts @@ -0,0 +1,30 @@ +import { ajv } from '../Ajv'; + +export type TeamsAddRoomsProps = ({ teamId: string } | { teamName: string }) & { + rooms: string[]; +}; + +const teamsAddRoomsPropsSchema = { + oneOf: [ + { + type: 'object', + properties: { + teamId: { type: 'string' }, + rooms: { type: 'array', items: { type: 'string' }, minItems: 1 }, + }, + required: ['teamId', 'rooms'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + teamName: { type: 'string' }, + rooms: { type: 'array', items: { type: 'string' }, minItems: 1 }, + }, + required: ['teamName', 'rooms'], + additionalProperties: false, + }, + ], +}; + +export const isTeamsAddRoomsProps = ajv.compile(teamsAddRoomsPropsSchema); diff --git a/packages/rest-typings/src/v1/teams/TeamsAutocompleteProps.ts b/packages/rest-typings/src/v1/teams/TeamsAutocompleteProps.ts new file mode 100644 index 0000000000000..8615db3dbd76d --- /dev/null +++ b/packages/rest-typings/src/v1/teams/TeamsAutocompleteProps.ts @@ -0,0 +1,14 @@ +import { ajvQuery } from '../Ajv'; + +export type TeamsAutocompleteProps = { name: string }; + +const teamsAutocompletePropsSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, +}; + +export const isTeamsAutocompleteProps = ajvQuery.compile(teamsAutocompletePropsSchema); diff --git a/packages/rest-typings/src/v1/teams/TeamsCreateProps.ts b/packages/rest-typings/src/v1/teams/TeamsCreateProps.ts new file mode 100644 index 0000000000000..dd4497502650f --- /dev/null +++ b/packages/rest-typings/src/v1/teams/TeamsCreateProps.ts @@ -0,0 +1,29 @@ +import { ajv } from '../Ajv'; + +export type TeamsCreateProps = { + name: string; + type: 0 | 1; + members?: string[]; + room?: Record & { name?: string; id?: string }; + owner?: string; +}; + +const teamsCreatePropsSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + type: { type: 'number', enum: [0, 1] }, + members: { + type: 'array', + items: { type: 'string' }, + }, + room: { + type: 'object', + }, + owner: { type: 'string' }, + }, + required: ['name', 'type'], + additionalProperties: false, +}; + +export const isTeamsCreateProps = ajv.compile(teamsCreatePropsSchema); diff --git a/packages/rest-typings/src/v1/teams/TeamsInfoProps.ts b/packages/rest-typings/src/v1/teams/TeamsInfoProps.ts new file mode 100644 index 0000000000000..ec561aab15a56 --- /dev/null +++ b/packages/rest-typings/src/v1/teams/TeamsInfoProps.ts @@ -0,0 +1,15 @@ +import { ajvQuery } from '../Ajv'; + +export type TeamsInfoProps = { teamId: string } | { teamName: string }; + +const teamsInfoPropsSchema = { + type: 'object', + properties: { + teamId: { type: 'string' }, + teamName: { type: 'string' }, + }, + oneOf: [{ required: ['teamId'] }, { required: ['teamName'] }], + additionalProperties: false, +}; + +export const isTeamsInfoProps = ajvQuery.compile(teamsInfoPropsSchema); diff --git a/packages/rest-typings/src/v1/teams/TeamsListRoomsOfUserProps.ts b/packages/rest-typings/src/v1/teams/TeamsListRoomsOfUserProps.ts new file mode 100644 index 0000000000000..0fe4cc18cf1b7 --- /dev/null +++ b/packages/rest-typings/src/v1/teams/TeamsListRoomsOfUserProps.ts @@ -0,0 +1,27 @@ +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; +import { ajvQuery } from '../Ajv'; + +export type TeamsListRoomsOfUserProps = PaginatedRequest< + ({ teamId: string } | { teamName: string }) & { + userId: string; + canUserDelete?: string; + } +>; + +const teamsListRoomsOfUserPropsSchema = { + type: 'object', + properties: { + teamId: { type: 'string' }, + teamName: { type: 'string' }, + userId: { type: 'string' }, + canUserDelete: { type: 'string', nullable: true }, + offset: { type: 'number', nullable: true }, + count: { type: 'number', nullable: true }, + sort: { type: 'string', nullable: true }, + }, + oneOf: [{ required: ['teamId'] }, { required: ['teamName'] }], + required: ['userId'], + additionalProperties: false, +}; + +export const isTeamsListRoomsOfUserProps = ajvQuery.compile(teamsListRoomsOfUserPropsSchema); diff --git a/packages/rest-typings/src/v1/teams/TeamsListRoomsProps.ts b/packages/rest-typings/src/v1/teams/TeamsListRoomsProps.ts new file mode 100644 index 0000000000000..b0cb8e245f456 --- /dev/null +++ b/packages/rest-typings/src/v1/teams/TeamsListRoomsProps.ts @@ -0,0 +1,26 @@ +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; +import { ajvQuery } from '../Ajv'; + +export type TeamsListRoomsProps = PaginatedRequest< + ({ teamId: string } | { teamName: string }) & { + filter?: string; + type?: string; + } +>; + +const teamsListRoomsPropsSchema = { + type: 'object', + properties: { + teamId: { type: 'string' }, + teamName: { type: 'string' }, + filter: { type: 'string', nullable: true }, + type: { type: 'string', nullable: true }, + offset: { type: 'number', nullable: true }, + count: { type: 'number', nullable: true }, + sort: { type: 'string', nullable: true }, + }, + oneOf: [{ required: ['teamId'] }, { required: ['teamName'] }], + additionalProperties: false, +}; + +export const isTeamsListRoomsProps = ajvQuery.compile(teamsListRoomsPropsSchema); diff --git a/packages/rest-typings/src/v1/teams/TeamsMembersProps.ts b/packages/rest-typings/src/v1/teams/TeamsMembersProps.ts new file mode 100644 index 0000000000000..19451e9c8fe11 --- /dev/null +++ b/packages/rest-typings/src/v1/teams/TeamsMembersProps.ts @@ -0,0 +1,28 @@ +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; +import { ajvQuery } from '../Ajv'; + +export type TeamsMembersProps = PaginatedRequest< + ({ teamId: string } | { teamName: string }) & { + status?: string[]; + username?: string; + name?: string; + } +>; + +const teamsMembersPropsSchema = { + type: 'object', + properties: { + teamId: { type: 'string' }, + teamName: { type: 'string' }, + status: { type: 'array', items: { type: 'string' }, nullable: true }, + username: { type: 'string', nullable: true }, + name: { type: 'string', nullable: true }, + offset: { type: 'number', nullable: true }, + count: { type: 'number', nullable: true }, + sort: { type: 'string', nullable: true }, + }, + oneOf: [{ required: ['teamId'] }, { required: ['teamName'] }], + additionalProperties: false, +}; + +export const isTeamsMembersProps = ajvQuery.compile(teamsMembersPropsSchema); diff --git a/packages/rest-typings/src/v1/teams/TeamsUpdateRoomProps.ts b/packages/rest-typings/src/v1/teams/TeamsUpdateRoomProps.ts new file mode 100644 index 0000000000000..3c98baeed5582 --- /dev/null +++ b/packages/rest-typings/src/v1/teams/TeamsUpdateRoomProps.ts @@ -0,0 +1,22 @@ +import { ajv } from '../Ajv'; + +export type TeamsUpdateRoomProps = { + roomId: string; + isDefault: boolean; + // TODO: teamId is accepted but never used by the endpoint handler — callers should stop sending it + teamId?: string; +}; + +const teamsUpdateRoomPropsSchema = { + type: 'object', + properties: { + roomId: { type: 'string' }, + isDefault: { type: 'boolean' }, + // TODO: teamId is accepted but never used by the endpoint handler — callers should stop sending it + teamId: { type: 'string' }, + }, + required: ['roomId', 'isDefault'], + additionalProperties: false, +}; + +export const isTeamsUpdateRoomProps = ajv.compile(teamsUpdateRoomPropsSchema); diff --git a/packages/rest-typings/src/v1/teams/index.ts b/packages/rest-typings/src/v1/teams/index.ts index bc2f0dc9ddc42..0862eb8667320 100644 --- a/packages/rest-typings/src/v1/teams/index.ts +++ b/packages/rest-typings/src/v1/teams/index.ts @@ -13,14 +13,22 @@ import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; import type { PaginatedResult } from '../../helpers/PaginatedResult'; export * from './TeamsAddMembersProps'; +export * from './TeamsAddRoomsProps'; +export * from './TeamsAutocompleteProps'; export * from './TeamsConvertToChannelProps'; +export * from './TeamsCreateProps'; export * from './TeamsDeleteProps'; +export * from './TeamsInfoProps'; export * from './TeamsLeaveProps'; +export * from './TeamsListChildren'; +export * from './TeamsListRoomsOfUserProps'; +export * from './TeamsListRoomsProps'; +export * from './TeamsMembersProps'; export * from './TeamsRemoveMemberProps'; export * from './TeamsRemoveRoomProps'; export * from './TeamsUpdateMemberProps'; export * from './TeamsUpdateProps'; -export * from './TeamsListChildren'; +export * from './TeamsUpdateRoomProps'; type ITeamAutocompleteResult = Pick; From dad2232570ec354e3007aa1142071014ba61cd89 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 15 Apr 2026 00:04:17 -0300 Subject: [PATCH 12/14] chore(api): migrate users endpoints to OpenAPI --- apps/meteor/app/api/server/lib/users.ts | 10 +- apps/meteor/app/api/server/v1/users.ts | 1612 +++++++++++------ .../Info/EditRoomInfo/EditRoomInfo.tsx | 2 +- apps/meteor/tests/end-to-end/api/users.ts | 511 +++++- 4 files changed, 1563 insertions(+), 572 deletions(-) diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 00dfc3e8f5292..19e9ccba16585 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -126,11 +126,11 @@ type FindPaginatedUsersByStatusProps = { offset: number; count: number; sort: Record; - status: 'active' | 'deactivated'; - roles: string[] | null; - searchTerm: string; - hasLoggedIn: boolean; - type: string; + status?: 'active' | 'deactivated'; + roles?: string[] | null; + searchTerm?: string; + hasLoggedIn?: boolean; + type?: string; inactiveReason?: ('deactivated' | 'pending_approval' | 'idle_too_long')[]; }; diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 85e76035fa358..c2d5cf6841829 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -19,9 +19,14 @@ import { isUsersCheckUsernameAvailabilityParamsGET, isUsersSendConfirmationEmailParamsPOST, isUsersListParamsGET, + isUsersPresenceParamsGET, + isUsersRequestDataDownloadParamsGET, + isUsersGetPresenceParamsGET, + isUsersGetStatusParamsGET, ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools'; @@ -101,59 +106,91 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +const voidSuccessResponse = ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, +}); + +// user shape varies by projection and permissions — use $ref when IUser is available in typia +const userObjectResponse = ajv.compile<{ user: object }>({ + type: 'object', + properties: { + user: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'success'], + additionalProperties: false, +}); + +API.v1.post( 'users.update', - { authRequired: true, twoFactorRequired: true, validateParams: isUsersUpdateParamsPOST }, { - async post() { - const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data }; + authRequired: true, + twoFactorRequired: true, + body: isUsersUpdateParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data }; - if (userData.name && !validateNameChars(userData.name)) { - return API.v1.failure('Name contains invalid characters'); - } - const auditStore = new UserChangedAuditStore({ - _id: this.user._id, - ip: this.requestIp, - useragent: this.request.headers.get('user-agent') || '', - username: this.user.username, - }); + if (userData.name && !validateNameChars(userData.name)) { + return API.v1.failure('Name contains invalid characters'); + } + const auditStore = new UserChangedAuditStore({ + _id: this.user._id, + ip: this.requestIp || '', + useragent: this.request.headers.get('user-agent') || '', + username: this.user.username, + }); - await saveUser(this.userId, userData, { auditStore }); + await saveUser(this.userId, userData, { auditStore }); - if (typeof this.bodyParams.data.active !== 'undefined') { - const { - userId, - data: { active }, - confirmRelinquish, - } = this.bodyParams; - await executeSetUserActiveStatus(this.userId, userId, active, Boolean(confirmRelinquish)); - } + if (typeof this.bodyParams.data.active !== 'undefined') { + const { + userId, + data: { active }, + confirmRelinquish, + } = this.bodyParams; + await executeSetUserActiveStatus(this.userId, userId, active, Boolean(confirmRelinquish)); + } - const { fields } = await this.parseJsonQuery(); + const { fields } = await this.parseJsonQuery(); - const user = await Users.findOneById(this.bodyParams.userId, { projection: fields }); - if (!user) { - return API.v1.failure('User not found'); - } + const user = await Users.findOneById(this.bodyParams.userId, { projection: fields }); + if (!user) { + return API.v1.failure('User not found'); + } - return API.v1.success({ user }); - }, + return API.v1.success({ user }); }, ); -API.v1.addRoute( - 'users.updateOwnBasicInfo', - { - authRequired: true, - userWithoutUsername: true, - validateParams: isUsersUpdateOwnBasicInfoParamsPOST, - rateLimiterOptions: { - numRequestsAllowed: 1, - intervalTimeInMS: 60000, +API.v1 + .post( + 'users.updateOwnBasicInfo', + { + authRequired: true, + userWithoutUsername: true, + body: isUsersUpdateOwnBasicInfoParamsPOST, + rateLimiterOptions: { + numRequestsAllowed: 1, + intervalTimeInMS: 60000, + }, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - }, - { - async post() { + async function action() { const userData = { email: this.bodyParams.data.email, realname: this.bodyParams.data.name, @@ -184,14 +221,19 @@ API.v1.addRoute( user: await getUserInfo((await Users.findOneById(this.userId, { projection: API.v1.defaultFieldsToExclude })) as IUser, false), }); }, - }, -); - -API.v1.addRoute( - 'users.setPreferences', - { authRequired: true, validateParams: isUsersSetPreferencesParamsPOST }, - { - async post() { + ) + .post( + 'users.setPreferences', + { + authRequired: true, + body: isUsersSetPreferencesParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { if ( this.bodyParams.userId && this.bodyParams.userId !== this.userId && @@ -228,14 +270,20 @@ API.v1.addRoute( } as unknown as Required>, }); }, - }, -); - -API.v1.addRoute( - 'users.setAvatar', - { authRequired: true, validateParams: isUsersSetAvatarProps }, - { - async post() { + ) + .post( + 'users.setAvatar', + { + authRequired: true, + body: isUsersSetAvatarProps, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { const canEditOtherUserAvatar = await hasPermissionAsync(this.userId, 'edit-other-user-avatar'); if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { @@ -299,14 +347,19 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'users.create', - { authRequired: true, validateParams: isUserCreateParamsPOST }, - { - async post() { + ) + .post( + 'users.create', + { + authRequired: true, + body: isUserCreateParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { // New change made by pull request #5152 if (typeof this.bodyParams.joinDefaultChannels === 'undefined') { this.bodyParams.joinDefaultChannels = true; @@ -344,141 +397,225 @@ API.v1.addRoute( return API.v1.success({ user }); }, - }, -); + ); -API.v1.addRoute( +API.v1.post( 'users.delete', - { authRequired: true, permissionsRequired: ['delete-user'] }, { - async post() { - const user = await getUserFromParams(this.bodyParams); - const { confirmRelinquish = false } = this.bodyParams; + authRequired: true, + permissionsRequired: ['delete-user'], + body: ajv.compile<{ userId?: string; username?: string; user?: string; confirmRelinquish?: boolean }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + confirmRelinquish: { type: 'boolean', nullable: true }, + }, + anyOf: [{ required: ['userId'] }, { required: ['username'] }, { required: ['user'] }], + additionalProperties: false, + }), + response: { + 200: ajv.compile<{ deletedRooms: string[] }>({ + type: 'object', + properties: { + deletedRooms: { type: 'array', items: { type: 'string' } }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['deletedRooms', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + const { confirmRelinquish = false } = this.bodyParams; - const { deletedRooms } = await deleteUser(user._id, confirmRelinquish, this.userId); + const { deletedRooms } = await deleteUser(user._id, confirmRelinquish, this.userId); - return API.v1.success({ deletedRooms }); - }, + return API.v1.success({ deletedRooms }); }, ); -API.v1.addRoute( +API.v1.post( 'users.deleteOwnAccount', - { authRequired: true }, { - async post() { - const { password } = this.bodyParams; - if (!password) { - return API.v1.failure('Body parameter "password" is required.'); - } - if (!settings.get('Accounts_AllowDeleteOwnAccount')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + authRequired: true, + body: ajv.compile<{ password: string; confirmRelinquish?: boolean }>({ + type: 'object', + properties: { + password: { type: 'string' }, + confirmRelinquish: { type: 'boolean', nullable: true }, + }, + required: ['password'], + additionalProperties: false, + }), + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + if (!settings.get('Accounts_AllowDeleteOwnAccount')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - const { confirmRelinquish = false } = this.bodyParams; + const { confirmRelinquish = false } = this.bodyParams; - await deleteUserOwnAccount(this.userId, password, confirmRelinquish); + await deleteUserOwnAccount(this.userId, this.bodyParams.password, confirmRelinquish); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'users.setActiveStatus', { authRequired: true, - validateParams: isUserSetActiveStatusParamsPOST, + body: isUserSetActiveStatusParamsPOST, permissionsRequired: { POST: { permissions: ['edit-other-user-active-status', 'manage-moderation-actions'], operation: 'hasAny' }, }, + response: { + 200: ajv.compile<{ user: Pick }>({ + type: 'object', + properties: { + user: { + type: 'object', + properties: { + _id: { type: 'string' }, + active: { type: 'boolean' }, + }, + required: ['_id', 'active'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['user', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; - await executeSetUserActiveStatus(this.userId, userId, activeStatus, confirmRelinquish); + async function action() { + const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; + await executeSetUserActiveStatus(this.userId, userId, activeStatus, confirmRelinquish); - const user = await Users.findOneById(this.bodyParams.userId, { projection: { active: 1 } }); - if (!user) { - return API.v1.failure('User not found'); - } - return API.v1.success({ - user, - }); - }, + const user = await Users.findOneById(this.bodyParams.userId, { projection: { active: 1 } }); + if (!user) { + return API.v1.failure('User not found'); + } + return API.v1.success({ + user, + }); }, ); -API.v1.addRoute( +API.v1.post( 'users.deactivateIdle', - { authRequired: true, validateParams: isUserDeactivateIdleParamsPOST, permissionsRequired: ['edit-other-user-active-status'] }, { - async post() { - const { daysIdle, role = 'user' } = this.bodyParams; + authRequired: true, + body: isUserDeactivateIdleParamsPOST, + permissionsRequired: ['edit-other-user-active-status'], + response: { + 200: ajv.compile<{ count: number }>({ + type: 'object', + properties: { + count: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['count', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { daysIdle, role = 'user' } = this.bodyParams; - const lastLoggedIn = new Date(); - lastLoggedIn.setDate(lastLoggedIn.getDate() - daysIdle); + const lastLoggedIn = new Date(); + lastLoggedIn.setDate(lastLoggedIn.getDate() - daysIdle); - // since we're deactiving users that are not logged in, there is no need to send data through WS - const { modifiedCount: count } = await Users.setActiveNotLoggedInAfterWithRole(lastLoggedIn, role, false); + // since we're deactiving users that are not logged in, there is no need to send data through WS + const { modifiedCount: count } = await Users.setActiveNotLoggedInAfterWithRole(lastLoggedIn, role, false); - return API.v1.success({ - count, - }); - }, + return API.v1.success({ + count, + }); }, ); -API.v1.addRoute( +API.v1.get( 'users.info', - { authRequired: true, validateParams: isUsersInfoParamsGetProps }, { - async get() { - const searchTerms: [string, 'id' | 'username' | 'importId' | 'email'] | 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']) || - ('email' in this.queryParams && !!this.queryParams.email && [this.queryParams.email, 'email']); - - if (!searchTerms) { - return API.v1.failure('Invalid search query.'); - } - - const user = await getFullUserDataByIdOrUsernameOrImportIdOrEmail(this.userId, ...searchTerms); + authRequired: true, + query: isUsersInfoParamsGetProps, + response: { + // user shape varies by projection, permissions, and includeUserRooms + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const searchTerms: [string, 'id' | 'username' | 'importId' | 'email'] | 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']) || + ('email' in this.queryParams && !!this.queryParams.email && [this.queryParams.email, 'email']); + + if (!searchTerms) { + return API.v1.failure('Invalid search query.'); + } - 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(), - }, - }); - } + const user = await getFullUserDataByIdOrUsernameOrImportIdOrEmail(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: { + ...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, + }); }, ); +// users.list accepts arbitrary query filter fields (name, username, etc.) +// that cannot be statically defined — keeping as addRoute until params are known API.v1.addRoute( 'users.list', { @@ -606,77 +743,103 @@ API.v1.addRoute( }, ); -API.v1.addRoute( +API.v1.get( 'users.listByStatus', { authRequired: true, - validateParams: isUsersListStatusProps, + query: isUsersListStatusProps, permissionsRequired: ['view-d-room'], + response: { + 200: ajv.compile<{ users: IUser[]; count: number; offset: number; total: number }>({ + type: 'object', + properties: { + // user shape varies by projection and permissions + users: { type: 'array' }, + count: { type: 'number' }, + offset: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'count', 'offset', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - { - async get() { - if ( - settings.get('API_Apply_permission_view-outside-room_on_users-list') && - !(await hasPermissionAsync(this.userId, 'view-outside-room')) - ) { - return API.v1.forbidden(); - } + async function action() { + if ( + settings.get('API_Apply_permission_view-outside-room_on_users-list') && + !(await hasPermissionAsync(this.userId, 'view-outside-room')) + ) { + return API.v1.forbidden(); + } - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort } = await this.parseJsonQuery(); - const { status, hasLoggedIn, type, roles, searchTerm, inactiveReason } = this.queryParams; - - return API.v1.success( - await findPaginatedUsersByStatus({ - uid: this.userId, - offset, - count, - sort, - status, - roles, - searchTerm, - hasLoggedIn, - type, - inactiveReason, - }), - ); - }, + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { status, hasLoggedIn, type, roles, searchTerm, inactiveReason } = this.queryParams; + + return API.v1.success( + await findPaginatedUsersByStatus({ + uid: this.userId, + offset, + count, + sort, + status, + roles, + searchTerm, + hasLoggedIn, + type, + inactiveReason, + }), + ); }, ); -API.v1.addRoute( +API.v1.post( 'users.sendWelcomeEmail', { authRequired: true, - validateParams: isUsersSendWelcomeEmailProps, + body: isUsersSendWelcomeEmailProps, permissionsRequired: ['send-mail'], + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { email } = this.bodyParams; + async function action() { + const { email } = this.bodyParams; - if (!isSMTPConfigured()) { - throw new MeteorError('error-email-send-failed', 'SMTP is not configured', { - method: 'sendWelcomeEmail', - }); - } + if (!isSMTPConfigured()) { + throw new MeteorError('error-email-send-failed', 'SMTP is not configured', { + method: 'sendWelcomeEmail', + }); + } - const user = await Users.findOneByEmailAddress(email.trim(), { projection: { name: 1 } }); + const user = await Users.findOneByEmailAddress(email.trim(), { projection: { name: 1 } }); - if (!user) { - throw new MeteorError('error-invalid-user', 'Invalid user', { - method: 'sendWelcomeEmail', - }); - } + if (!user) { + throw new MeteorError('error-invalid-user', 'Invalid user', { + method: 'sendWelcomeEmail', + }); + } - await sendWelcomeEmail({ ...user, email }); + await sendWelcomeEmail({ ...user, email }); - return API.v1.success(); - }, + return API.v1.success(); }, ); -API.v1.addRoute( +API.v1.post( 'users.register', { authRequired: false, @@ -684,89 +847,105 @@ API.v1.addRoute( numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser') ?? 1, intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default') ?? 60000, }, - validateParams: isUserRegisterParamsPOST, + body: isUserRegisterParamsPOST, + response: { + 200: userObjectResponse, + 400: validateBadRequestErrorResponse, + }, }, - { - async post() { - const { secret: secretURL, ...params } = this.bodyParams; + async function action() { + const { secret: secretURL, ...params } = this.bodyParams; - if (this.userId) { - return API.v1.failure('Logged in users can not register again.'); - } + if (this.userId) { + return API.v1.failure('Logged in users can not register again.'); + } - if (params.name && !validateNameChars(params.name)) { - return API.v1.failure('Name contains invalid characters'); - } + if (params.name && !validateNameChars(params.name)) { + return API.v1.failure('Name contains invalid characters'); + } - if (!validateUsername(this.bodyParams.username)) { - return API.v1.failure(`The username provided is not valid`); - } + if (!validateUsername(this.bodyParams.username)) { + return API.v1.failure(`The username provided is not valid`); + } - if (!(await checkUsernameAvailability(this.bodyParams.username))) { - return API.v1.failure('Username is already in use'); - } - if (!(await checkEmailAvailability(this.bodyParams.email))) { - return API.v1.failure('Email already exists'); - } - if (this.bodyParams.customFields) { - try { - await validateCustomFields(this.bodyParams.customFields); - } catch (e) { - return API.v1.failure(e); - } + if (!(await checkUsernameAvailability(this.bodyParams.username))) { + return API.v1.failure('Username is already in use'); + } + if (!(await checkEmailAvailability(this.bodyParams.email))) { + return API.v1.failure('Email already exists'); + } + if (this.bodyParams.customFields) { + try { + validateCustomFields(this.bodyParams.customFields); + } catch (e) { + return API.v1.failure(e); } + } - // Register the user - const userId = await registerUser({ - ...params, - ...(secretURL && { secretURL }), - }); + // Register the user + const userId = await registerUser({ + ...params, + ...(secretURL && { secretURL }), + }); - if (typeof userId !== 'string') { - return API.v1.failure('Error creating user'); - } + if (typeof userId !== 'string') { + return API.v1.failure('Error creating user'); + } - // Now set their username - const { fields } = await this.parseJsonQuery(); - await setUsernameWithValidation(userId, this.bodyParams.username); + // Now set their username + const { fields } = await this.parseJsonQuery(); + await setUsernameWithValidation(userId, this.bodyParams.username); - const user = await Users.findOneById(userId, { projection: fields }); - if (!user) { - return API.v1.failure('User not found'); - } + const user = await Users.findOneById(userId, { projection: fields }); + if (!user) { + return API.v1.failure('User not found'); + } - if (this.bodyParams.customFields) { - await saveCustomFields(userId, this.bodyParams.customFields); - } + if (this.bodyParams.customFields) { + await saveCustomFields(userId, this.bodyParams.customFields); + } - return API.v1.success({ user }); - }, + return API.v1.success({ user }); }, ); -API.v1.addRoute( +API.v1.post( 'users.resetAvatar', - { authRequired: true }, { - async post() { - const user = await getUserFromParams(this.bodyParams); - - if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { - await resetAvatar(this.userId, this.userId); - } else if ( - (await hasPermissionAsync(this.userId, 'edit-other-user-avatar')) || - (await hasPermissionAsync(this.userId, 'manage-moderation-actions')) - ) { - await resetAvatar(this.userId, user._id); - } else { - throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { - method: 'users.resetAvatar', - }); - } - - return API.v1.success(); + authRequired: true, + body: ajv.compile<{ userId?: string; username?: string; user?: string }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const user = await getUserFromParams(this.bodyParams); + + if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { + await resetAvatar(this.userId, this.userId); + } else if ( + (await hasPermissionAsync(this.userId, 'edit-other-user-avatar')) || + (await hasPermissionAsync(this.userId, 'manage-moderation-actions')) + ) { + await resetAvatar(this.userId, user._id); + } else { + throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { + method: 'users.resetAvatar', + }); + } + + return API.v1.success(); + }, ); const usersEndpoints = API.v1 @@ -805,7 +984,7 @@ const usersEndpoints = API.v1 minLength: 1, }, }, - required: ['userId'], + required: ['userId', 'authToken'], additionalProperties: false, }, success: { @@ -896,113 +1075,230 @@ const usersEndpoints = API.v1 }, ); -API.v1.addRoute( +API.v1.get( 'users.getPreferences', - { authRequired: true }, { - async get() { - const user = await Users.findOneById(this.userId); - if (user?.settings) { - const { preferences = {} } = user?.settings; - preferences.language = user?.language; - - return API.v1.success({ - preferences, - }); - } - return API.v1.failure(i18n.t('Accounts_Default_User_Preferences_not_available').toUpperCase()); + authRequired: true, + response: { + 200: ajv.compile<{ preferences: Record }>({ + type: 'object', + properties: { + // preferences is a dynamic key-value object that varies per user + preferences: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['preferences', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, }, }, + async function action() { + const user = await Users.findOneById(this.userId); + if (user?.settings) { + const { preferences = {} } = user?.settings; + preferences.language = user?.language; + + return API.v1.success({ + preferences, + }); + } + return API.v1.failure(i18n.t('Accounts_Default_User_Preferences_not_available').toUpperCase()); + }, ); -API.v1.addRoute( - 'users.forgotPassword', - { authRequired: false }, - { - async post() { +API.v1 + .post( + 'users.forgotPassword', + { + authRequired: false, + body: ajv.compile<{ email: string }>({ + type: 'object', + properties: { + email: { type: 'string' }, + }, + required: ['email'], + additionalProperties: false, + }), + response: { + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { const isPasswordResetEnabled = settings.get('Accounts_PasswordReset'); if (!isPasswordResetEnabled) { return API.v1.failure('Password reset is not enabled'); } - const { email } = this.bodyParams; - if (!email) { - return API.v1.failure("The 'email' param is required"); - } - - await sendForgotPasswordEmail(email.toLowerCase()); + await sendForgotPasswordEmail(this.bodyParams.email.toLowerCase()); return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'users.getUsernameSuggestion', - { authRequired: true, userWithoutUsername: true }, - { - async get() { + ) + .get( + 'users.getUsernameSuggestion', + { + authRequired: true, + userWithoutUsername: true, + response: { + 200: ajv.compile<{ result: string }>({ + type: 'object', + properties: { + result: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['result', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const result = await generateUsernameSuggestion(this.user); + if (!result) { + return API.v1.failure('No username suggestion found'); + } + return API.v1.success({ result }); }, + ); + +const tokenNameBodySchema = ajv.compile<{ tokenName: string; bypassTwoFactor?: boolean }>({ + type: 'object', + properties: { + tokenName: { type: 'string' }, + bypassTwoFactor: { type: 'boolean', nullable: true }, }, -); + required: ['tokenName'], + additionalProperties: false, +}); -API.v1.addRoute( - 'users.checkUsernameAvailability', - { - authRequired: true, - validateParams: isUsersCheckUsernameAvailabilityParamsGET, +const tokenResponseSchema = ajv.compile<{ token: string }>({ + type: 'object', + properties: { + token: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, }, - { - async get() { + required: ['token', 'success'], + additionalProperties: false, +}); + +API.v1 + .get( + 'users.checkUsernameAvailability', + { + authRequired: true, + query: isUsersCheckUsernameAvailabilityParamsGET, + response: { + 200: ajv.compile<{ result: boolean }>({ + type: 'object', + properties: { + result: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['result', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { username } = this.queryParams; const result = await checkUsernameAvailabilityWithValidation(this.userId, username); return API.v1.success({ result }); }, - }, -); - -API.v1.addRoute( - 'users.generatePersonalAccessToken', - { authRequired: true, twoFactorRequired: true }, - { - async post() { + ) + .post( + 'users.generatePersonalAccessToken', + { + authRequired: true, + twoFactorRequired: true, + body: tokenNameBodySchema, + response: { + 200: tokenResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { tokenName, bypassTwoFactor = false } = this.bodyParams; - if (!tokenName) { - return API.v1.failure("The 'tokenName' param is required"); - } const token = await generatePersonalAccessTokenOfUser({ tokenName, userId: this.userId, bypassTwoFactor }); return API.v1.success({ token }); }, - }, -); - -API.v1.addRoute( - 'users.regeneratePersonalAccessToken', - { authRequired: true, twoFactorRequired: true }, - { - async post() { + ) + .post( + 'users.regeneratePersonalAccessToken', + { + authRequired: true, + twoFactorRequired: true, + body: ajv.compile<{ tokenName: string }>({ + type: 'object', + properties: { + tokenName: { type: 'string' }, + }, + required: ['tokenName'], + additionalProperties: false, + }), + response: { + 200: tokenResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { tokenName } = this.bodyParams; - if (!tokenName) { - return API.v1.failure("The 'tokenName' param is required"); - } const token = await regeneratePersonalAccessTokenOfUser(tokenName, this.userId); return API.v1.success({ token }); }, - }, -); - -API.v1.addRoute( - 'users.getPersonalAccessTokens', - { authRequired: true, permissionsRequired: ['create-personal-access-tokens'] }, - { - async get() { + ) + .get( + 'users.getPersonalAccessTokens', + { + authRequired: true, + permissionsRequired: ['create-personal-access-tokens'], + response: { + 200: ajv.compile<{ tokens: { name: string; createdAt: string; lastTokenPart: string; bypassTwoFactor: boolean }[] }>({ + type: 'object', + properties: { + tokens: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + createdAt: { type: 'string' }, + lastTokenPart: { type: 'string' }, + bypassTwoFactor: { type: 'boolean' }, + }, + required: ['name', 'createdAt', 'lastTokenPart', 'bypassTwoFactor'], + additionalProperties: false, + }, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['tokens', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const user = (await Users.getLoginTokensByUserId(this.userId).toArray())[0] as unknown as IUser | undefined; const isPersonalAccessToken = (loginToken: ILoginToken | IPersonalAccessToken): loginToken is IPersonalAccessToken => @@ -1018,30 +1314,45 @@ API.v1.addRoute( })) || [], }); }, - }, -); - -API.v1.addRoute( - 'users.removePersonalAccessToken', - { authRequired: true, twoFactorRequired: true }, - { - async post() { - const { tokenName } = this.bodyParams; - if (!tokenName) { - return API.v1.failure("The 'tokenName' param is required"); - } - await removePersonalAccessTokenOfUser(tokenName, this.userId); + ) + .post( + 'users.removePersonalAccessToken', + { + authRequired: true, + twoFactorRequired: true, + body: ajv.compile<{ tokenName: string }>({ + type: 'object', + properties: { + tokenName: { type: 'string' }, + }, + required: ['tokenName'], + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + await removePersonalAccessTokenOfUser(this.bodyParams.tokenName, this.userId); return API.v1.success(); }, - }, -); + ); -API.v1.addRoute( - 'users.2fa.enableEmail', - { authRequired: true }, - { - async post() { +API.v1 + .post( + 'users.2fa.enableEmail', + { + authRequired: true, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const hasUnverifiedEmail = this.user.emails?.some((email) => !email.verified); if (hasUnverifiedEmail) { throw new MeteorError('error-invalid-user', 'You need to verify your emails before setting up 2FA'); @@ -1078,14 +1389,20 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'users.2fa.disableEmail', - { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, - { - async post() { + ) + .post( + 'users.2fa.disableEmail', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { await Users.disableEmail2FAByUserId(this.userId); void notifyOnUserChangeAsync(async () => { @@ -1103,116 +1420,160 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); + ) + .post( + 'users.2fa.sendEmailCode', + { + body: ajv.compile<{ emailOrUsername: string }>({ + type: 'object', + properties: { + emailOrUsername: { type: 'string' }, + }, + required: ['emailOrUsername'], + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + }, + }, + async function action() { + const { emailOrUsername } = this.bodyParams; -API.v1.addRoute('users.2fa.sendEmailCode', { - async post() { - const { emailOrUsername } = this.bodyParams; + const method = emailOrUsername.includes('@') ? 'findOneByEmailAddress' : 'findOneByUsername'; + const userId = this.userId || (await Users[method](emailOrUsername, { projection: { _id: 1 } }))?._id; - if (!emailOrUsername) { - throw new Meteor.Error('error-parameter-required', 'emailOrUsername is required'); - } + if (!userId) { + // this.logger.error('[2fa] User was not found when requesting 2fa email code'); + return API.v1.success(); + } + const user = await getUserForCheck(userId); + if (!user) { + // this.logger.error('[2fa] User was not found when requesting 2fa email code'); + return API.v1.success(); + } - const method = emailOrUsername.includes('@') ? 'findOneByEmailAddress' : 'findOneByUsername'; - const userId = this.userId || (await Users[method](emailOrUsername, { projection: { _id: 1 } }))?._id; + await emailCheck.sendEmailCode(user); - if (!userId) { - // this.logger.error('[2fa] User was not found when requesting 2fa email code'); - return API.v1.success(); - } - const user = await getUserForCheck(userId); - if (!user) { - // this.logger.error('[2fa] User was not found when requesting 2fa email code'); return API.v1.success(); - } - - await emailCheck.sendEmailCode(user); - - return API.v1.success(); - }, -}); + }, + ); -API.v1.addRoute( +API.v1.post( 'users.sendConfirmationEmail', { authRequired: true, - validateParams: isUsersSendConfirmationEmailParamsPOST, + body: isUsersSendConfirmationEmailParamsPOST, rateLimiterOptions: { numRequestsAllowed: 1, intervalTimeInMS: 60000, }, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - { - async post() { - const { email } = this.bodyParams; + async function action() { + const { email } = this.bodyParams; - if (await sendConfirmationEmail(email)) { - return API.v1.success(); - } - return API.v1.failure(); - }, + if (await sendConfirmationEmail(email)) { + return API.v1.success(); + } + return API.v1.failure(); }, ); -API.v1.addRoute( +API.v1.get( 'users.presence', - { authRequired: true }, { - async get() { - // if presence broadcast is disabled, return an empty array (all users are "offline") - if (settings.get('Presence_broadcast_disabled')) { - return API.v1.success({ - users: [], - full: true, - }); - } + authRequired: true, + query: isUsersPresenceParamsGET, + response: { + 200: ajv.compile<{ users: object[]; full: boolean }>({ + type: 'object', + properties: { + // user shape varies by projection and permissions + users: { type: 'array' }, + full: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['users', 'full', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + // if presence broadcast is disabled, return an empty array (all users are "offline") + if (settings.get('Presence_broadcast_disabled')) { + return API.v1.success({ + users: [], + full: true, + }); + } - const { from, ids } = this.queryParams; + const { from, ids } = this.queryParams; - const options = { - projection: { - username: 1, - name: 1, - status: 1, - utcOffset: 1, - statusText: 1, - avatarETag: 1, - }, - }; + const options = { + projection: { + username: 1, + name: 1, + status: 1, + utcOffset: 1, + statusText: 1, + avatarETag: 1, + }, + }; + + if (ids) { + return API.v1.success({ + users: await Users.findNotOfflineByIds(Array.isArray(ids) ? ids : ids.split(','), options).toArray(), + full: false, + }); + } + + if (from) { + const ts = new Date(from); + const diff = (Date.now() - Number(ts)) / 1000 / 60; - if (ids) { + if (diff < 10) { return API.v1.success({ - users: await Users.findNotOfflineByIds(Array.isArray(ids) ? ids : ids.split(','), options).toArray(), + users: await Users.findNotIdUpdatedFrom(this.userId, ts, options).toArray(), full: false, }); } + } - if (from) { - const ts = new Date(from); - const diff = (Date.now() - Number(ts)) / 1000 / 60; - - if (diff < 10) { - return API.v1.success({ - users: await Users.findNotIdUpdatedFrom(this.userId, ts, options).toArray(), - full: false, - }); - } - } - - return API.v1.success({ - users: await Users.findUsersNotOffline(options).toArray(), - full: true, - }); - }, + return API.v1.success({ + users: await Users.findUsersNotOffline(options).toArray(), + full: true, + }); }, ); -API.v1.addRoute( - 'users.requestDataDownload', - { authRequired: true }, - { - async get() { +API.v1 + .get( + 'users.requestDataDownload', + { + authRequired: true, + query: isUsersRequestDataDownloadParamsGET, + response: { + 200: ajv.compile<{ requested: boolean; exportOperation: IExportOperation }>({ + type: 'object', + properties: { + requested: { type: 'boolean' }, + // IExportOperation has complex/dynamic shape not yet in typia + exportOperation: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['requested', 'exportOperation', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const { fullExport = false } = this.queryParams; const result = (await requestDataDownload({ userData: this.user, fullExport: fullExport === 'true' })) as { requested: boolean; @@ -1224,14 +1585,26 @@ API.v1.addRoute( exportOperation: result.exportOperation, }); }, - }, -); - -API.v1.addRoute( - 'users.logoutOtherClients', - { authRequired: true }, - { - async post() { + ) + .post( + 'users.logoutOtherClients', + { + authRequired: true, + response: { + 200: ajv.compile<{ token: string; tokenExpires: string }>({ + type: 'object', + properties: { + token: { type: 'string' }, + tokenExpires: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['token', 'tokenExpires', 'success'], + additionalProperties: false, + }), + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { const xAuthToken = this.request.headers.get('x-auth-token') as string; if (!xAuthToken) { @@ -1262,58 +1635,93 @@ API.v1.addRoute( tokenExpires: tokenExpires?.toISOString() || '', }); }, - }, -); + ); -API.v1.addRoute( +API.v1.get( 'users.autocomplete', - { authRequired: true, validateParams: isUsersAutocompleteProps }, { - async get() { - const { selector: selectorRaw } = this.queryParams; + authRequired: true, + query: isUsersAutocompleteProps, + response: { + 200: ajv.compile<{ items: object[] }>({ + type: 'object', + properties: { + // autocomplete items shape varies by permissions + items: { type: 'array' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['items', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { selector: selectorRaw } = this.queryParams; - const selector: { exceptions: Required['username'][]; conditions: Filter; term: string } = JSON.parse(selectorRaw); + const selector: { exceptions: Required['username'][]; conditions: Filter; term: string } = JSON.parse(selectorRaw); - try { - if (selector?.conditions) { - const canViewFullInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info'); - const allowedFields = canViewFullInfo ? [...Object.keys(defaultFields), ...Object.keys(fullFields)] : Object.keys(defaultFields); + try { + if (selector?.conditions) { + const canViewFullInfo = await hasPermissionAsync(this.userId, 'view-full-other-user-info'); + const allowedFields = canViewFullInfo ? [...Object.keys(defaultFields), ...Object.keys(fullFields)] : Object.keys(defaultFields); - if (!isValidQuery(selector.conditions, allowedFields, ['$and', '$ne', '$exists'])) { - throw new Error('error-invalid-query'); - } + if (!isValidQuery(selector.conditions, allowedFields, ['$and', '$ne', '$exists'])) { + throw new Error('error-invalid-query'); } - } catch (e) { - return API.v1.failure(e); } + } catch (e) { + return API.v1.failure(e); + } - return API.v1.success( - await findUsersToAutocomplete({ - uid: this.userId, - selector, - }), - ); - }, + return API.v1.success( + await findUsersToAutocomplete({ + uid: this.userId, + selector, + }), + ); }, ); -API.v1.addRoute( - 'users.removeOtherTokens', - { authRequired: true }, - { - async post() { - return API.v1.success(await Users.removeNonLoginTokensExcept(this.userId, this.token)); +API.v1 + .post( + 'users.removeOtherTokens', + { + authRequired: true, + response: { + 200: voidSuccessResponse, + 401: validateUnauthorizedErrorResponse, + }, }, - }, -); - -API.v1.addRoute( - 'users.resetE2EKey', - { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, - { - async post() { + async function action() { + await Users.removeNonLoginTokensExcept(this.userId, this.token); + return API.v1.success(); + }, + ) + .post( + 'users.resetE2EKey', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + body: ajv.compile<{ userId?: string; username?: string; user?: string }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { - // reset other user keys const user = await getUserFromParams(this.bodyParams); if (!user) { throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); @@ -1332,17 +1740,30 @@ API.v1.addRoute( await resetUserE2EEncriptionKey(this.userId, false); return API.v1.success(); }, - }, -); - -API.v1.addRoute( - 'users.resetTOTP', - { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, - { - async post() { - // // reset own keys + ) + .post( + 'users.resetTOTP', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + body: ajv.compile<{ userId?: string; username?: string; user?: string }>({ + type: 'object', + properties: { + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { - // reset other user keys if (!(await hasPermissionAsync(this.userId, 'edit-other-user-totp'))) { throw new Meteor.Error('error-not-allowed', 'Not allowed'); } @@ -1363,14 +1784,29 @@ API.v1.addRoute( await resetTOTP(this.userId, false); return API.v1.success(); }, - }, -); + ); -API.v1.addRoute( - 'users.listTeams', - { authRequired: true, validateParams: isUsersListTeamsProps }, - { - async get() { +API.v1 + .get( + 'users.listTeams', + { + authRequired: true, + query: isUsersListTeamsProps, + response: { + 200: ajv.compile<{ teams: unknown[] }>({ + type: 'object', + properties: { + teams: { type: 'array' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['teams', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { check( this.queryParams, Match.ObjectIncluding({ @@ -1389,14 +1825,28 @@ API.v1.addRoute( teams, }); }, - }, -); - -API.v1.addRoute( - 'users.logout', - { authRequired: true, validateParams: isUserLogoutParamsPOST }, - { - async post() { + ) + .post( + 'users.logout', + { + authRequired: true, + body: isUserLogoutParamsPOST, + response: { + 200: ajv.compile<{ message: string }>({ + type: 'object', + properties: { + message: { type: 'string' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['message', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { const userId = this.bodyParams.userId || this.userId; if (userId !== this.userId && !(await hasPermissionAsync(this.userId, 'logout-other-user'))) { @@ -1416,14 +1866,33 @@ API.v1.addRoute( message: `User ${userId} has been logged out!`, }); }, - }, -); + ); -API.v1.addRoute( - 'users.getPresence', - { authRequired: true }, - { - async get() { +const statusType = { type: 'string', enum: ['online', 'offline', 'away', 'busy'] } as const; + +API.v1 + .get( + 'users.getPresence', + { + authRequired: true, + query: isUsersGetPresenceParamsGET, + response: { + 200: ajv.compile<{ presence: UserStatus; connectionStatus?: string; lastLogin?: Date }>({ + type: 'object', + properties: { + presence: statusType, + connectionStatus: { type: 'string', nullable: true }, + lastLogin: { type: 'string', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['presence', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { if (isUserFromParams(this.queryParams, this.userId, this.user)) { const user = await Users.findOneById(this.userId); return API.v1.success({ @@ -1439,20 +1908,40 @@ API.v1.addRoute( presence: user.status || ('offline' as UserStatus), }); }, - }, -); - -API.v1.addRoute( - 'users.setStatus', - { - authRequired: true, - rateLimiterOptions: { - numRequestsAllowed: 5, - intervalTimeInMS: 60000, + ) + .post( + 'users.setStatus', + { + authRequired: true, + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, + body: ajv.compile<{ + status?: UserStatus; + message?: string; + userId?: string; + username?: string; + user?: string; + }>({ + type: 'object', + properties: { + status: { type: 'string', enum: ['online', 'away', 'offline', 'busy'] }, + message: { type: 'string', nullable: true }, + userId: { type: 'string' }, + username: { type: 'string' }, + user: { type: 'string' }, + }, + additionalProperties: false, + }), + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, }, - }, - { - async post() { + async function action() { check( this.bodyParams, Match.OneOf( @@ -1530,27 +2019,35 @@ API.v1.addRoute( return API.v1.success(); }, - }, -); - -// status: 'online' | 'offline' | 'away' | 'busy'; -// message?: string; -// _id: string; -// connectionStatus?: 'online' | 'offline' | 'away' | 'busy'; -// }; - -API.v1.addRoute( - 'users.getStatus', - { authRequired: true }, - { - async get() { + ) + .get( + 'users.getStatus', + { + authRequired: true, + query: isUsersGetStatusParamsGET, + response: { + 200: ajv.compile<{ _id: string; status: string; connectionStatus?: string }>({ + type: 'object', + properties: { + _id: { type: 'string' }, + status: statusType, + connectionStatus: { type: 'string', nullable: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['_id', 'status', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { if (isUserFromParams(this.queryParams, this.userId, this.user)) { - const user: IUser | null = await Users.findOneById(this.userId); return API.v1.success({ - _id: user?._id, + _id: this.userId, // message: user.statusText, - connectionStatus: (user?.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy', - status: (user?.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + connectionStatus: (this.user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy', + status: (this.user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', }); } @@ -1562,8 +2059,7 @@ API.v1.addRoute( status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', }); }, - }, -); + ); settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { const userRegisterRoute = '/api/v1/users.registerpost'; diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx index b63a7d18dffe0..e3f2d079b0fd2 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx @@ -173,7 +173,7 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => ...(data.systemMessages && !hideSysMes && { systemMessages: data.systemMessages, - }), + }), retentionEnabled, retentionOverrideGlobal, ...(retentionEnabled && diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index 50513d7d2b900..30e59d9a554aa 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -7,6 +7,7 @@ import type { IGetRoomRoles, PaginatedResult, DefaultUserInfo } from '@rocket.ch import { assert, expect } from 'chai'; import { after, afterEach, before, beforeEach, describe, it } from 'mocha'; import { MongoClient } from 'mongodb'; +import type { Response } from 'supertest'; import { getCredentials, api, request, credentials, apiEmail, apiUsername, wait, reservedWords } from '../../data/api-data'; import { imgURL } from '../../data/interactions'; @@ -805,6 +806,28 @@ describe('[Users]', () => { }); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.create')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.create')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.register]', () => { @@ -906,6 +929,17 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.register')) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.info]', () => { @@ -1300,6 +1334,16 @@ describe('[Users]', () => { }); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getPresence')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.presence]', () => { @@ -1788,6 +1832,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.setAvatar')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.resetAvatar]', () => { @@ -1886,6 +1940,16 @@ describe('[Users]', () => { }); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.resetAvatar')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.getAvatar]', () => { @@ -2100,7 +2164,7 @@ describe('[Users]', () => { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'invalid-params'); - expect(res.body).to.have.property('error', 'must NOT have fewer than 1 characters [invalid-params]'); + expect(res.body).to.have.property('error', 'must NOT have fewer than 1 characters'); }); }); @@ -2119,7 +2183,7 @@ describe('[Users]', () => { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'invalid-params'); - expect(res.body).to.have.property('error', 'must NOT have additional properties [invalid-params]'); + expect(res.body).to.have.property('error', 'must NOT have additional properties'); }); }); @@ -2759,6 +2823,16 @@ describe('[Users]', () => { }); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.update')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.updateOwnBasicInfo]', () => { @@ -3286,6 +3360,16 @@ describe('[Users]', () => { .expect(200); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.updateOwnBasicInfo')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); // TODO check for all response fields @@ -3402,6 +3486,28 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.setPreferences')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.getPreferences]', () => { @@ -3421,6 +3527,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getPreferences')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.forgotPassword]', () => { @@ -3470,6 +3586,20 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return an error when email is missing', (done) => { + void request + .post(api('users.forgotPassword')) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error', "must have required property 'email'"); + }) + .end(done); + }); }); describe('[/users.sendConfirmationEmail]', () => { @@ -3502,6 +3632,28 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.sendConfirmationEmail')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.sendConfirmationEmail')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.getUsernameSuggestion]', () => { @@ -3536,6 +3688,18 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', (done) => { + void request + .get(api('users.getUsernameSuggestion')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('status', 'error'); + expect(res.body).to.have.property('message'); + }) + .end(done); + }); }); describe('[/users.checkUsernameAvailability]', () => { @@ -3740,6 +3904,28 @@ describe('[Users]', () => { expect(roles[0].u).to.have.property('_id', credentials['X-User-Id']); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.deleteOwnAccount')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.deleteOwnAccount')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.delete]', () => { @@ -3874,7 +4060,6 @@ describe('[Users]', () => { .set(credentials) .send({ tokenName: 'test', - loginToken: '1234567890', }) .expect('Content-Type', 'application/json') .expect(200) @@ -3905,6 +4090,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getPersonalAccessTokens')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.generatePersonalAccessToken]', () => { @@ -3937,6 +4132,28 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.generatePersonalAccessToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.generatePersonalAccessToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.regeneratePersonalAccessToken]', () => { it('should return a personal access token to user when user regenerates the token', (done) => { @@ -3968,6 +4185,28 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.regeneratePersonalAccessToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.regeneratePersonalAccessToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.getPersonalAccessTokens]', () => { it('should return my personal access tokens', (done) => { @@ -4012,6 +4251,28 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.removePersonalAccessToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.removePersonalAccessToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); }); describe('unsuccessful cases', () => { @@ -4391,6 +4652,18 @@ describe('[Users]', () => { expect(originalCreator.u).to.have.property('_id', credentials['X-User-Id']); }); }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.setActiveStatus')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.deactivateIdle]', () => { @@ -4485,6 +4758,18 @@ describe('[Users]', () => { .end(done); }); }); + + it('should return 400 when body is empty', async () => { + await request + .post(api('users.deactivateIdle')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.requestDataDownload]', () => { @@ -4532,6 +4817,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.requestDataDownload')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.logoutOtherClients]', function () { @@ -4644,6 +4939,16 @@ describe('[Users]', () => { expect(res.body).to.have.property('success', true); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.logoutOtherClients')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.autocomplete]', () => { @@ -4814,6 +5119,16 @@ describe('[Users]', () => { .end(done); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.autocomplete')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.getStatus]', () => { @@ -4844,6 +5159,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.getStatus')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.setStatus]', () => { @@ -4943,8 +5268,8 @@ describe('[Users]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('error-invalid-status'); - expect(res.body.error).to.be.equal('Valid status types include online, away, offline, and busy. [error-invalid-status]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.error).to.include('must be equal to one of the allowed values'); }) .end(done); }); @@ -4980,6 +5305,16 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.setStatus')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.removeOtherTokens]', () => { @@ -5018,6 +5353,16 @@ describe('[Users]', () => { void request.post(api('users.removeOtherTokens')).set(newCredentials).expect(200).then(tryAuthentication); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.removeOtherTokens')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.listTeams]', () => { @@ -5129,6 +5474,27 @@ describe('[Users]', () => { }) .end(done); }); + + it('should return 401 when not authenticated', async () => { + await request + .get(api('users.listTeams')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when query is empty', async () => { + await request + .get(api('users.listTeams')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); describe('[/users.logout]', () => { @@ -5216,6 +5582,16 @@ describe('[Users]', () => { expect(res.body).to.have.property('success', false); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.logout')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); }); describe('[/users.listByStatus]', () => { @@ -5360,8 +5736,8 @@ describe('[Users]', () => { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('invalid-params'); - expect(res.body.error).to.be.equal('must be equal to one of the allowed values [invalid-params]'); + expect(res.body.errorType).to.be.equal('error-invalid-params'); + expect(res.body.error).to.be.equal('must be equal to one of the allowed values'); }); }); @@ -5449,7 +5825,7 @@ describe('[Users]', () => { .expect((res) => { expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('errorType', 'invalid-params'); - expect(res.body).to.have.property('error', "must have required property 'email' [invalid-params]"); + expect(res.body).to.have.property('error', "must have required property 'email'"); }); }); @@ -5468,5 +5844,124 @@ describe('[Users]', () => { expect(res.body).to.have.property('error', 'Invalid user [error-invalid-user]'); }); }); + + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.sendWelcomeEmail')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.createToken]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.createToken')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body is missing required fields', async () => { + await request + .post(api('users.createToken')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.resetE2EKey]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.resetE2EKey')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body has invalid properties', async () => { + await request + .post(api('users.resetE2EKey')) + .set(credentials) + .send({ invalidProp: true }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.resetTOTP]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.resetTOTP')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + + it('should return 400 when body has invalid properties', async () => { + await request + .post(api('users.resetTOTP')) + .set(credentials) + .send({ invalidProp: true }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + + describe('[/users.2fa.enableEmail]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.2fa.enableEmail')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.2fa.disableEmail]', () => { + it('should return 401 when not authenticated', async () => { + await request + .post(api('users.2fa.disableEmail')) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res: Response) => { + expect(res.body).to.have.property('status', 'error'); + }); + }); + }); + + describe('[/users.2fa.sendEmailCode]', () => { + it('should return 400 when emailOrUsername is missing', async () => { + await request + .post(api('users.2fa.sendEmailCode')) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); }); }); From fef4d1cc34c87e423c01a79e95d183cbc741dc9d Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 15 Apr 2026 00:04:17 -0300 Subject: [PATCH 13/14] chore(api): migrate integrations, moderation endpoints to OpenAPI --- apps/meteor/app/api/server/v1/integrations.ts | 12 ++++-- apps/meteor/app/api/server/v1/moderation.ts | 39 +++++++++++++++++-- .../integrations/server/lib/triggerHandler.ts | 2 +- .../incoming/addIncomingIntegration.ts | 11 +++--- .../incoming/updateIncomingIntegration.ts | 12 +++--- .../tests/end-to-end/api/livechat/00-rooms.ts | 11 ++++++ 6 files changed, 68 insertions(+), 19 deletions(-) diff --git a/apps/meteor/app/api/server/v1/integrations.ts b/apps/meteor/app/api/server/v1/integrations.ts index d3dd51139bbc2..2870fdb8236e8 100644 --- a/apps/meteor/app/api/server/v1/integrations.ts +++ b/apps/meteor/app/api/server/v1/integrations.ts @@ -33,7 +33,7 @@ import { findOneIntegration } from '../lib/integrations'; const integrationSuccessSchema = ajv.compile<{ integration: IIntegration | null }>({ type: 'object', properties: { - integration: { type: 'object' }, + integration: { oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }] }, success: { type: 'boolean', enum: [true] }, }, required: ['integration', 'success'], @@ -79,7 +79,7 @@ API.v1.get( 200: ajv.compile<{ history: IIntegrationHistory[]; offset: number; items: number; count: number; total: number }>({ type: 'object', properties: { - history: { type: 'array', items: { type: 'object' } }, + history: { type: 'array', items: { $ref: '#/components/schemas/IIntegrationHistory' } }, offset: { type: 'number' }, items: { type: 'number' }, count: { type: 'number' }, @@ -147,7 +147,9 @@ API.v1.get( properties: { integrations: { type: 'array', - items: { type: 'object' }, + items: { + oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], + }, }, offset: { type: 'number' }, items: { type: 'number' }, @@ -280,7 +282,9 @@ API.v1.get( 200: ajv.compile<{ integration: IIntegration | null }>({ type: 'object', properties: { - integration: { type: 'object' }, + integration: { + oneOf: [{ $ref: '#/components/schemas/IIncomingIntegration' }, { $ref: '#/components/schemas/IOutgoingIntegration' }], + }, success: { type: 'boolean', enum: [true] }, }, required: ['integration', 'success'], diff --git a/apps/meteor/app/api/server/v1/moderation.ts b/apps/meteor/app/api/server/v1/moderation.ts index 5651d9ea983df..ef4dde807d603 100644 --- a/apps/meteor/app/api/server/v1/moderation.ts +++ b/apps/meteor/app/api/server/v1/moderation.ts @@ -104,7 +104,21 @@ const reportedMessagesResponseSchema = ajv.compile<{ }>({ type: 'object', properties: { - user: { type: ['object', 'null'] }, + user: { + oneOf: [ + { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['_id', 'username'], + additionalProperties: false, + }, + { type: 'null' }, + ], + }, messages: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, total: { type: 'number' }, @@ -122,7 +136,7 @@ const reportedMessagesResponseSchema = ajv.compile<{ // aggregation actually returns, or adjusting the AJV schema generation for union types), we use a // relaxed inline schema here that accepts `ts` as a string. const reportsByUserIdResponseSchema = ajv.compile<{ - user: IUser | null; + user: Pick | null; reports: IModerationReport[]; count: number; total: number; @@ -130,7 +144,26 @@ const reportsByUserIdResponseSchema = ajv.compile<{ }>({ type: 'object', properties: { - user: { type: ['object', 'null'] }, + user: { + oneOf: [ + { + type: 'object', + properties: { + _id: { type: 'string' }, + username: { type: 'string' }, + name: { type: 'string' }, + avatarETag: { type: 'string' }, + active: { type: 'boolean' }, + roles: { type: 'array', items: { type: 'string' } }, + emails: { type: 'array', items: { type: 'object' } }, + createdAt: { type: 'string' }, + }, + required: ['_id', 'username'], + additionalProperties: false, + }, + { type: 'null' }, + ], + }, reports: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, total: { type: 'number' }, diff --git a/apps/meteor/app/integrations/server/lib/triggerHandler.ts b/apps/meteor/app/integrations/server/lib/triggerHandler.ts index 09cc1f906ca09..e2b2fb84bb99d 100644 --- a/apps/meteor/app/integrations/server/lib/triggerHandler.ts +++ b/apps/meteor/app/integrations/server/lib/triggerHandler.ts @@ -35,7 +35,7 @@ type ArgumentsObject = { user?: IUser; }; type IntegrationData = { - token: string; + token?: string; bot: boolean; trigger_word?: string; channel_id?: string; diff --git a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts index 3a7a48835a427..9520c2b416039 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts @@ -168,13 +168,14 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn const { insertedId } = await Integrations.insertOne(strippedIntegrationData); - if (insertedId) { - void notifyOnIntegrationChanged({ ...integrationData, _id: insertedId }, 'inserted'); - } + const integrationStored = await Integrations.findOne({ _id: insertedId }); - integrationData._id = insertedId; + if (!integrationStored) { + throw new Error('Error inserting integration'); + } + void notifyOnIntegrationChanged({ ...integrationStored, _id: insertedId }, 'inserted'); - return integrationData; + return integrationStored as IIncomingIntegration; }; Meteor.methods({ diff --git a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts index 5ce2ffd4496aa..90da52e610738 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts @@ -181,17 +181,17 @@ export const updateIncomingIntegration = async ( $set: { enabled: integration.enabled, name: integration.name, - avatar: integration.avatar, - emoji: integration.emoji, - alias: integration.alias, - channel: channels, + ...(typeof integration.avatar !== 'undefined' && { avatar: integration.avatar }), + ...(typeof integration.emoji !== 'undefined' && { emoji: integration.emoji }), + ...(typeof integration.alias !== 'undefined' && { alias: integration.alias }), + ...(channels && { channel: channels }), ...('username' in integration && { username: user.username, userId: user._id }), ...(isFrozen ? {} : { - script: integration.script, + ...(typeof integration.script !== 'undefined' && { script: integration.script }), scriptEnabled: integration.scriptEnabled, - scriptEngine, + ...(scriptEngine && { scriptEngine }), }), ...(typeof integration.overrideDestinationChannelEnabled !== 'undefined' && { overrideDestinationChannelEnabled: integration.overrideDestinationChannelEnabled, diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index eefcf67192726..7658f9f4fcede 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -228,6 +228,17 @@ describe('LIVECHAT - rooms', () => { await restorePermissionToRoles('view-livechat-rooms'); }); + it('should return an error when the "agents" query parameter is not valid', async () => { + await request + .get(api('livechat/rooms')) + .query({ agents: { test: true } }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + }); + }); it('should return an error when the "roomName" query parameter is not valid', async () => { await request .get(api('livechat/rooms')) From 238872f13a0f7d3881882ee0928d453365eefb2c Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 17 Apr 2026 10:56:47 -0300 Subject: [PATCH 14/14] chore(api): remove outdated TODO comments from rooms endpoint --- apps/meteor/app/api/server/v1/rooms.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 2faf320fadfb4..8904d97dfbc93 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -505,16 +505,6 @@ API.v1.get( }, ); -/* -TO-DO: 8.0.0 should use the ajv validation -which will change this endpoint's -response errors. -*/ -/* -TO-DO: 8.0.0 should use the ajv validation -which will change this endpoint's -response errors. -*/ API.v1.post( 'rooms.createDiscussion', {