From 4a72a0011b0f7502c8cd2b8086e50513c13f5fb8 Mon Sep 17 00:00:00 2001 From: ahmed-n-abdeltwab Date: Wed, 4 Mar 2026 15:47:51 +0200 Subject: [PATCH 1/6] chore: Add OpenAPI support for the Rocket.Chat dm/im APIs endpoints - migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. --- .changeset/wicked-bugs-itch.md | 7 + apps/meteor/app/api/server/v1/im.ts | 1492 ++++++++++++----- packages/core-typings/src/Ajv.ts | 4 +- packages/rest-typings/src/index.ts | 1 - .../rest-typings/src/v1/dm/DmCreateProps.ts | 41 - .../rest-typings/src/v1/dm/DmFileProps.ts | 57 - .../rest-typings/src/v1/dm/DmHistoryProps.ts | 54 - .../rest-typings/src/v1/dm/DmLeaveProps.ts | 34 - .../rest-typings/src/v1/dm/DmMembersProps.ts | 83 - .../rest-typings/src/v1/dm/DmMessagesProps.ts | 85 - packages/rest-typings/src/v1/dm/dm.ts | 10 - packages/rest-typings/src/v1/dm/im.ts | 78 +- packages/rest-typings/src/v1/dm/index.ts | 4 - 13 files changed, 1112 insertions(+), 838 deletions(-) create mode 100644 .changeset/wicked-bugs-itch.md delete mode 100644 packages/rest-typings/src/v1/dm/DmCreateProps.ts delete mode 100644 packages/rest-typings/src/v1/dm/DmFileProps.ts delete mode 100644 packages/rest-typings/src/v1/dm/DmHistoryProps.ts delete mode 100644 packages/rest-typings/src/v1/dm/DmLeaveProps.ts delete mode 100644 packages/rest-typings/src/v1/dm/DmMembersProps.ts delete mode 100644 packages/rest-typings/src/v1/dm/DmMessagesProps.ts diff --git a/.changeset/wicked-bugs-itch.md b/.changeset/wicked-bugs-itch.md new file mode 100644 index 0000000000000..0ca09eb98d240 --- /dev/null +++ b/.changeset/wicked-bugs-itch.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +chore: Add OpenAPI support for the Rocket.Chat dm/im APIs endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. \ No newline at end of file diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index f97bd6e7e9161..56306b9648fed 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -1,22 +1,19 @@ /** * Docs: https://github.com/RocketChat/developer-docs/blob/master/reference/api/rest-api/endpoints/team-collaboration-endpoints/im-endpoints */ -import type { IMessage, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; +import type { ICreatedRoom, IMessage, IRoom, ISubscription, IUploadWithUser, IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Uploads, Messages, Rooms, Users } from '@rocket.chat/models'; import { ajv, validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, validateBadRequestErrorResponse, - isDmFileProps, - isDmMemberProps, - isDmMessagesProps, - isDmCreateProps, - isDmHistoryProps, + type PaginatedResult, + type PaginatedRequest, } from '@rocket.chat/rest-typings'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import type { FindOptions } from 'mongodb'; +import type { FindOptions, Filter } from 'mongodb'; import { eraseRoom } from '../../../../server/lib/eraseRoom'; import { openRoom } from '../../../../server/lib/openRoom'; @@ -29,7 +26,7 @@ import { getRoomByNameOrIdWithOptionToJoin } from '../../../lib/server/functions import { getChannelHistory } from '../../../lib/server/methods/getChannelHistory'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; -import type { ExtractRoutesFromAPI } from '../ApiClass'; +import type { ExtractRoutesFromAPI, Prettify } from '../ApiClass'; import { API } from '../api'; import type { TypedAction } from '../definition'; import { addUserToFileObj } from '../helpers/addUserToFileObj'; @@ -70,50 +67,28 @@ 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 = +type DmCreateProps = ( | { - roomId: string; + usernames: string; } | { username: string; - }; - -type DmCloseProps = { - roomId: string; -}; + } +) & { excludeSelf?: boolean }; -const isDmDeleteProps = ajv.compile({ +const isDmCreateProps = ajv.compile({ oneOf: [ { type: 'object', properties: { - roomId: { + usernames: { type: 'string', }, + excludeSelf: { + type: 'boolean', + }, }, - required: ['roomId'], + required: ['usernames'], additionalProperties: false, }, { @@ -122,6 +97,9 @@ const isDmDeleteProps = ajv.compile({ username: { type: 'string', }, + excludeSelf: { + type: 'boolean', + }, }, required: ['username'], additionalProperties: false, @@ -129,191 +107,192 @@ const isDmDeleteProps = ajv.compile({ ], }); -const dmDeleteEndpointsProps = { - authRequired: true, - body: isDmDeleteProps, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 200: ajv.compile({ - type: 'object', - properties: { - success: { - type: 'boolean', - enum: [true], - }, - }, - required: ['success'], - additionalProperties: false, - }), +API.v1.addRoute( + ['dm.create', 'im.create'], + { + authRequired: true, + validateParams: isDmCreateProps, }, -} as const; + { + async post() { + const users = + 'username' in this.bodyParams + ? [this.bodyParams.username] + : this.bodyParams.usernames.split(',').map((username: string) => username.trim()); -const DmClosePropsSchema = { + const room = await createDirectMessage(users, this.userId, this.bodyParams.excludeSelf); + + return API.v1.success({ + room: { ...room, _id: room.rid }, + }); + }, + }, +); + +type DmListProps = PaginatedRequest<{ fields?: string }>; + +const isDmListProps = ajv.compile({ type: 'object', properties: { - roomId: { + fields: { type: 'string', }, - userId: { + offset: { + type: 'number', + }, + count: { + type: 'number', + }, + sort: { type: 'string', }, }, - required: ['roomId', 'userId'], additionalProperties: false, -}; - -const isDmCloseProps = ajv.compile(DmClosePropsSchema); - -const dmCloseEndpointsProps = { - authRequired: true, - body: isDmCloseProps, - response: { - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 403: validateForbiddenErrorResponse, - 200: ajv.compile({ - type: 'object', - properties: { - success: { - type: 'boolean', - enum: [true], - }, - }, - required: ['success'], - additionalProperties: false, - }), - }, -}; +}); -const dmDeleteAction = (_path: Path): TypedAction => - async function action() { - const { room } = await findDirectMessageRoom(this.bodyParams, this.userId); +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(); - const canAccess = - (await canAccessRoomIdAsync(room._id, this.userId)) || (await hasPermissionAsync(this.userId, 'view-room-administration')); + // TODO: CACHE: Add Breaking notice since we removed the query param - if (!canAccess) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + const subscriptions = await Subscriptions.find({ 'u._id': this.userId, 't': 'd' }, { projection: { rid: 1 } }) + .map((item) => item.rid) + .toArray(); - await eraseRoom(room._id, this.user); + const { cursor, totalCount } = Rooms.findPaginated( + { t: 'd', _id: { $in: subscriptions } }, + { + sort, + skip: offset, + limit: count, + projection: fields, + }, + ); - return API.v1.success(); - }; + const [ims, total] = await Promise.all([cursor.toArray(), totalCount]); -const dmCloseAction = (_path: Path): TypedAction => - async function action() { - const { roomId } = 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', { - method: 'dm.close', + return API.v1.success({ + ims: await Promise.all(ims.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: ims.length, + total, }); - } - let subscription; - - const roomExists = !!(await Rooms.findOneById(roomId)); - if (!roomExists) { - // even if the room doesn't exist, we should allow the user to close the subscription anyways - subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId); - } else { - const canAccess = await canAccessRoomIdAsync(roomId, this.userId); - if (!canAccess) { - return API.v1.forbidden('error-not-allowed'); - } - - const { subscription: subs } = await findDirectMessageRoom({ roomId }, this.userId); - - subscription = subs; - } - - if (!subscription) { - return API.v1.failure(`The user is not subscribed to the room`); - } - - if (!subscription.open) { - return API.v1.failure(`The direct message room, is already closed to the sender`); - } - - await hideRoomMethod(this.userId, roomId); + }, + }, +); - return API.v1.success(); - }; +type DmListEveryoneProps = PaginatedRequest<{ query: string; fields?: string }>; -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')); +const isDmListEveryoneProps = ajv.compile({ + type: 'object', + properties: { + query: { + type: 'string', + }, + fields: { + type: 'string', + }, + offset: { + type: 'number', + }, + count: { + type: 'number', + }, + sort: { + type: 'string', + }, + }, + additionalProperties: false, +}); -// https://github.com/RocketChat/Rocket.Chat/pull/9679 as reference API.v1.addRoute( - ['dm.counters', 'im.counters'], - { authRequired: true }, + ['dm.list.everyone', 'im.list.everyone'], + { authRequired: true, permissionsRequired: ['view-room-administration'], validateParams: isDmListEveryoneProps }, { async get() { - 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); - - if (!canAccess) { - return API.v1.forbidden(); - } - - const { room, subscription } = await findDirectMessageRoom({ roomId }, user); - - lm = room?.lm ? new Date(room.lm).toISOString() : new Date(room._updatedAt).toISOString(); // lm is the last message timestamp + const { offset, count }: { offset: number; count: number } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); - 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; - } + const { cursor, totalCount } = Rooms.findPaginated( + { ...query, t: 'd' }, + { + sort: sort || { name: 1 }, + skip: offset, + limit: count, + projection: fields, + }, + ); - if (access || joined) { - msgs = room.msgs; - latest = lm; - members = await Users.countActiveUsersInDMRoom(room._id); - } + const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); return API.v1.success({ - joined, - members, - unreads, - unreadsFrom, - msgs, - latest, - userMentions, + ims: await Promise.all(rooms.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), + offset, + count: rooms.length, + total, }); }, }, ); +type DmFileProps = PaginatedRequest< + ({ roomId: string; username?: string } | { roomId?: string; username: string }) & { + name?: string; + typeGroup?: string; + query?: string; + onlyConfirmed?: boolean; + } +>; + +const isDmFileProps = ajv.compile({ + type: 'object', + properties: { + roomId: { + type: 'string', + nullable: true, + }, + username: { + type: 'string', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + count: { + type: 'number', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + nullable: true, + }, + typeGroup: { + type: 'string', + nullable: true, + }, + query: { + type: 'string', + nullable: true, + }, + onlyConfirmed: { + type: 'boolean', + }, + }, + oneOf: [{ required: ['roomId'] }, { required: ['username'] }], + required: [], + additionalProperties: false, +}); + API.v1.addRoute( ['dm.files', 'im.files'], { @@ -361,41 +340,86 @@ API.v1.addRoute( }, ); -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', - }; - - const result = await getChannelHistory(objectParams); - - if (!result) { - return API.v1.forbidden(); - } +type DmMemberProps = PaginatedRequest< + ( + | { + roomId: string; + } + | { + username: string; + } + ) & { + status?: string[]; + filter?: string; + } +>; - return API.v1.success(result); +const isDmMemberProps = ajv.compile({ + oneOf: [ + { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + status: { + type: 'array', + items: { + type: 'string', + }, + }, + filter: { + type: 'string', + }, + query: { + type: 'string', + }, + sort: { + type: 'string', + }, + count: { + type: 'number', + }, + offset: { + type: 'number', + }, + }, + required: ['roomId'], + additionalProperties: false, }, - }, -); + { + type: 'object', + properties: { + username: { + type: 'string', + }, + status: { + type: 'array', + items: { + type: 'string', + }, + }, + filter: { + type: 'string', + }, + query: { + type: 'string', + }, + sort: { + type: 'string', + }, + count: { + type: 'number', + }, + offset: { + type: 'number', + }, + }, + required: ['username'], + additionalProperties: false, + }, + ], +}); API.v1.addRoute( ['dm.members', 'im.members'], @@ -482,226 +506,896 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - ['dm.messages', 'im.messages'], - { - authRequired: true, - validateParams: isDmMessagesProps, - }, - { - async get() { - const { roomId, username, mentionIds, starredIds, pinned } = this.queryParams; +type DmDeleteProps = + | { + roomId: string; + } + | { + username: string; + }; - const { room } = await findDirectMessageRoom({ ...(roomId ? { roomId } : { username }) }, this.userId); +const isDmDeleteProps = ajv.compile({ + oneOf: [ + { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + }, + required: ['roomId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + username: { + type: 'string', + }, + }, + required: ['username'], + additionalProperties: false, + }, + ], +}); - const canAccess = await canAccessRoomIdAsync(room._id, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } +const dmDeleteEndpointsProps = { + authRequired: true, + body: isDmDeleteProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +} as const; - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); +const dmDeleteAction = (_path: Path): TypedAction => + async function action() { + const { room } = await findDirectMessageRoom(this.bodyParams, this.userId); - const parseIds = (ids: string | undefined, field: string) => - typeof ids === 'string' && ids ? { [field]: { $in: ids.split(',').map((id) => id.trim()) } } : {}; + const canAccess = + (await canAccessRoomIdAsync(room._id, this.userId)) || (await hasPermissionAsync(this.userId, 'view-room-administration')); - const ourQuery = { - rid: room._id, - ...query, - ...parseIds(mentionIds, 'mentions._id'), - ...parseIds(starredIds, 'starred._id'), - ...(pinned && pinned.toLowerCase() === 'true' ? { pinned: true } : {}), - _hidden: { $ne: true }, - }; - const sortObj = sort || { ts: -1 }; + if (!canAccess) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - const { cursor, totalCount } = Messages.findPaginated(ourQuery, { - sort: sortObj, - skip: offset, - limit: count, - ...(fields && { projection: fields }), - }); + await eraseRoom(room._id, this.user); - const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); + return API.v1.success(); + }; - return API.v1.success({ - messages: await normalizeMessagesForUser(messages, this.userId), - count: messages.length, - offset, - total, - }); +type DmCloseProps = { + roomId: string; +}; + +const DmClosePropsSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + userId: { + type: 'string', }, }, -); - -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'); - } + required: ['roomId', 'userId'], + additionalProperties: false, +}; - 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 isDmCloseProps = ajv.compile(DmClosePropsSchema); - const { offset, count } = await getPaginationItems(this.queryParams); - const { sort, fields, query } = await this.parseJsonQuery(); - const ourQuery = Object.assign({}, query, { rid: room._id }); +const dmCloseEndpointsProps = { + authRequired: true, + body: isDmCloseProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +}; - const { cursor, totalCount } = Messages.findPaginated(ourQuery, { - sort: sort || { ts: -1 }, - skip: offset, - limit: count, - projection: fields, +const dmCloseAction = (_path: Path): TypedAction => + async function action() { + const { roomId } = 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', { + method: 'dm.close', }); + } + let subscription; - const [msgs, total] = await Promise.all([cursor.toArray(), totalCount]); - - if (!msgs) { - throw new Meteor.Error('error-no-messages', 'No messages found'); + const roomExists = !!(await Rooms.findOneById(roomId)); + if (!roomExists) { + // even if the room doesn't exist, we should allow the user to close the subscription anyways + subscription = await Subscriptions.findOneByRoomIdAndUserId(roomId, this.userId); + } else { + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden('unauthorized'); } - return API.v1.success({ - messages: await normalizeMessagesForUser(msgs, this.userId), - offset, - count: msgs.length, - total, - }); - }, - }, -); + const { subscription: subs } = await findDirectMessageRoom({ roomId }, this.userId); -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(); + subscription = subs; + } - // TODO: CACHE: Add Breaking notice since we removed the query param + if (!subscription) { + return API.v1.failure(`The user is not subscribed to the room`); + } - const subscriptions = await Subscriptions.find({ 'u._id': this.userId, 't': 'd' }, { projection: { rid: 1 } }) - .map((item) => item.rid) - .toArray(); + if (!subscription.open) { + return API.v1.failure(`The direct message room, is already closed to the sender`); + } - const { cursor, totalCount } = Rooms.findPaginated( - { t: 'd', _id: { $in: subscriptions } }, - { - sort, - skip: offset, - limit: count, - projection: fields, - }, - ); + await hideRoomMethod(this.userId, roomId); - const [ims, total] = await Promise.all([cursor.toArray(), totalCount]); + return API.v1.success(); + }; - return API.v1.success({ - ims: await Promise.all(ims.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), - offset, - count: ims.length, - total, - }); - }, - }, -); +type DmCountersProps = { + roomId: string; + userId?: string; +}; -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 isDmCountersProps = ajv.compile({ + type: 'object', + properties: { + roomId: { + type: 'string', + }, + userId: { + type: 'string', + }, + }, + additionalProperties: false, +}); - const { cursor, totalCount } = Rooms.findPaginated( - { ...query, t: 'd' }, - { - sort: sort || { name: 1 }, - skip: offset, - limit: count, - projection: fields, +const dmCountersEndpointsProps = { + authRequired: true, + query: isDmCountersProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile<{ + joined: boolean; + unreads: number | null; + unreadsFrom: string | null; + msgs: number | null; + members: number | null; + latest: string | null; + userMentions: number | null; + }>({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], }, - ); + joined: { + type: 'boolean', + }, + unreads: { + type: ['number', 'null'], + }, + unreadsFrom: { + type: ['string', 'null'], + }, + msgs: { + type: ['number', 'null'], + }, + members: { + type: ['number', 'null'], + }, + latest: { + type: ['string', 'null'], + }, + userMentions: { + type: ['number', 'null'], + }, + }, + required: ['success', 'joined', 'unreads', 'unreadsFrom', 'msgs', 'members', 'latest', 'userMentions'], + additionalProperties: false, + }), + }, +} as const; - const [rooms, total] = await Promise.all([cursor.toArray(), totalCount]); +const dmCountersAction = (_path: Path): TypedAction => + async function action() { + 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('unauthorized'); + } + user = ruserId; + } + const canAccess = await canAccessRoomIdAsync(roomId, user); - return API.v1.success({ - ims: await Promise.all(rooms.map((room: IRoom) => composeRoomWithLastMessage(room, this.userId))), - offset, - count: rooms.length, - total, - }); + if (!canAccess) { + return API.v1.forbidden('unauthorized'); + } + + const { room, subscription } = await findDirectMessageRoom({ roomId }, user); + + lm = room?.lm ? new Date(room.lm).toISOString() : new Date(room._updatedAt).toISOString(); // lm is the last message timestamp + + 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 (access || joined) { + msgs = room.msgs; + latest = lm; + members = await Users.countActiveUsersInDMRoom(room._id); + } + + return API.v1.success({ + joined, + members, + unreads, + unreadsFrom, + msgs, + latest, + userMentions, + }); + }; + +type DmHistoryProps = PaginatedRequest<{ + roomId: string; + latest?: string; + oldest?: string; + inclusive?: 'false' | 'true'; + unreads?: 'true' | 'false'; + showThreadMessages?: 'false' | 'true'; +}>; + +const isDmHistoryProps = ajv.compile({ + type: 'object', + properties: { + roomId: { + type: 'string', + minLength: 1, + }, + latest: { + type: 'string', + minLength: 1, + }, + showThreadMessages: { + type: 'string', + enum: ['false', 'true'], + }, + oldest: { + type: 'string', + minLength: 1, + }, + inclusive: { + type: 'string', + enum: ['false', 'true'], + }, + unreads: { + type: 'string', + enum: ['true', 'false'], + }, + count: { + type: 'number', + }, + offset: { + type: 'number', + }, + sort: { + type: 'string', }, }, -); + required: ['roomId'], + additionalProperties: false, +}); -API.v1.addRoute( - ['dm.open', 'im.open'], - { authRequired: true }, - { - async post() { - const { roomId } = this.bodyParams; +const dmHistoryEndpointsProps = { + authRequired: true, + query: isDmHistoryProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile({ + anyOf: [ + { + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + messages: { + type: 'array', + items: { + $ref: '#/components/schemas/IMessage', + }, + }, + }, + required: ['success', 'messages'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + messages: { + type: 'array', + items: { + $ref: '#/components/schemas/IMessage', + }, + }, + firstUnread: { + type: 'object', + additionalProperties: true, + }, + unreadNotLoaded: { + type: 'number', + }, + }, + required: ['success', 'messages'], + additionalProperties: false, + }, + ], + }), + }, +} as const; - 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 dmHistoryAction = (_path: Path): TypedAction => + async function action() { + 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('unauthorized'); + } - const { room, subscription } = await findDirectMessageRoom({ roomId }, this.userId); + return API.v1.success(result); + }; - if (!subscription?.open) { - await openRoom(this.userId, room._id); - } +type DmMessagesProps = Prettify< + PaginatedRequest< + ({ roomId: string } | { username: string }) & { + query?: string; + mentionIds?: string; + starredIds?: string; + pinned?: string; + fields?: string; + } + > +>; - return API.v1.success(); +const isDmMessagesProps = ajv.compile({ + oneOf: [ + { + type: 'object', + properties: { + roomId: { + type: 'string', + }, + mentionIds: { + type: 'string', + }, + starredIds: { + type: 'string', + }, + pinned: { + type: 'string', + }, + fields: { + type: 'string', + }, + query: { + type: 'string', + }, + sort: { + type: 'string', + }, + count: { + type: 'number', + }, + offset: { + type: 'number', + }, + }, + required: ['roomId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + username: { + type: 'string', + }, + mentionIds: { + type: 'string', + }, + starredIds: { + type: 'string', + }, + pinned: { + type: 'string', + }, + query: { + type: 'string', + }, + fields: { + type: 'string', + }, + sort: { + type: 'string', + }, + count: { + type: 'number', + }, + offset: { + type: 'number', + }, + }, + required: ['username'], + additionalProperties: false, }, + ], +}); + +const dmMessagesEndpointsProps = { + authRequired: true, + query: isDmMessagesProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile< + PaginatedResult<{ + messages: IMessage[]; + }> + >({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + messages: { + type: 'array', + items: { + $ref: '#/components/schemas/IMessage', + }, + }, + count: { + type: 'number', + }, + offset: { + type: 'number', + }, + total: { + type: 'number', + }, + }, + required: ['success', 'messages', 'count', 'offset', 'total'], + additionalProperties: false, + }), }, -); +} as const; -API.v1.addRoute( - ['dm.setTopic', 'im.setTopic'], - { authRequired: true }, - { - async post() { - const { roomId, topic } = this.bodyParams; +const dmMessagesAction = (_path: Path): TypedAction => + async function action() { + const { roomId, username, mentionIds, starredIds, pinned } = this.queryParams; - if (!roomId) { - throw new Meteor.Error('error-room-param-not-provided', 'Body param "roomId" is required'); - } + const { room } = await findDirectMessageRoom({ ...(roomId ? { roomId } : { username }) }, this.userId); - const canAccess = await canAccessRoomIdAsync(roomId, this.userId); - if (!canAccess) { - return API.v1.forbidden(); - } + const canAccess = await canAccessRoomIdAsync(room._id, this.userId); + if (!canAccess) { + return API.v1.forbidden('unauthorized'); + } + + 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 }), + }); + + const [messages, total] = await Promise.all([cursor.toArray(), totalCount]); - const { room } = await findDirectMessageRoom({ roomId }, this.userId); + return API.v1.success({ + messages: await normalizeMessagesForUser(messages, this.userId), + count: messages.length, + offset, + total, + }); + }; - await saveRoomSettings(this.userId, room._id, 'roomTopic', topic); +type DmMessagesOthersProps = PaginatedRequest<{ roomId: IRoom['_id']; query?: string; fields?: string }>; - return API.v1.success({ - topic, +const isDmMessagesOthersProps = ajv.compile({ + type: 'object', + properties: { + roomId: { + type: 'string', + }, + offset: { + type: 'number', + }, + count: { + type: 'number', + }, + sort: { + type: 'string', + }, + fields: { + type: 'string', + }, + query: { + type: 'string', + }, + }, + required: ['roomId'], + additionalProperties: false, +}); + +const dmMessagesOthersEndpointsProps = { + authRequired: true, + permissionsRequired: ['view-room-administration'], + query: isDmMessagesOthersProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile>({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + messages: { + type: 'array', + items: { + $ref: '#/components/schemas/IMessage', + }, + }, + count: { + type: 'number', + }, + offset: { + type: 'number', + }, + total: { + type: 'number', + }, + }, + required: ['success', 'messages', 'count', 'offset', 'total'], + additionalProperties: false, + }), + }, +}; + +const dmMessagesOthersAction = (_path: 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 { roomId } = this.queryParams; + + 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 { 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, + }); + }; + +type DmOpenProps = { + roomId: string; +}; + +const isDmOpenProps = ajv.compile({ + type: 'object', + properties: { + roomId: { + type: 'string', }, }, -); + required: ['roomId'], + additionalProperties: false, +}); -export type DmEndpoints = ExtractRoutesFromAPI; +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 dmOpenAction = (_path: Path): TypedAction => + async function action() { + const { roomId } = this.bodyParams; + + 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('unauthorized'); + } + + const { room, subscription } = await findDirectMessageRoom({ roomId }, this.userId); + + if (!subscription?.open) { + await openRoom(this.userId, room._id); + } + + return API.v1.success(); + }; + +type DmSetTopicProps = { + roomId: string; + topic?: string; +}; + +const isDmSetTopicProps = ajv.compile({ + type: 'object', + properties: { + roomId: { + type: 'string', + }, + topic: { + type: 'string', + }, + }, + required: ['roomId'], + additionalProperties: false, +}); + +const dmSetTopicEndpointsProps = { + authRequired: true, + body: isDmSetTopicProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + 200: ajv.compile<{ + topic?: string; + }>({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [true], + }, + topic: { + type: 'string', + }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +} as const; + +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'); + } + + const canAccess = await canAccessRoomIdAsync(roomId, this.userId); + if (!canAccess) { + return API.v1.forbidden('unauthorized'); + } + + const { room } = await findDirectMessageRoom({ roomId }, this.userId); + + await saveRoomSettings(this.userId, room._id, 'roomTopic', topic); + + return API.v1.success({ + topic, + }); + }; + +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')) + .get('dm.counters', dmCountersEndpointsProps, dmCountersAction('dm.counters')) + .get('im.counters', dmCountersEndpointsProps, dmCountersAction('im.counters')) + .get('dm.history', dmHistoryEndpointsProps, dmHistoryAction('dm.history')) + .get('im.history', dmHistoryEndpointsProps, dmHistoryAction('im.history')) + .get('dm.messages', dmMessagesEndpointsProps, dmMessagesAction('dm.messages')) + .get('im.messages', dmMessagesEndpointsProps, dmMessagesAction('im.messages')) + .get('dm.messages.others', dmMessagesOthersEndpointsProps, dmMessagesOthersAction('dm.messages.others')) + .get('im.messages.others', dmMessagesOthersEndpointsProps, dmMessagesOthersAction('im.messages.others')) + .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')); + +type DmKickProps = { + roomId: string; +}; + +type DmLeaveProps = + | { + roomId: string; + } + | { roomName: string }; + +type DmEndpoints = ExtractRoutesFromAPI & { + '/v1/im.kick': { + POST: (params: DmKickProps) => void; + }; + '/v1/dm.kick': { + POST: (params: DmKickProps) => void; + }; + '/v1/im.leave': { + POST: (params: DmLeaveProps) => void; + }; + '/v1/dm.leave': { + POST: (params: DmLeaveProps) => void; + }; + '/v1/im.list': { + GET: (params: PaginatedRequest<{ fields?: string }>) => PaginatedResult<{ ims: IRoom[] }>; + }; + '/v1/dm.list': { + GET: (params: PaginatedRequest<{ fields?: string }>) => PaginatedResult<{ ims: IRoom[] }>; + }; + '/v1/im.list.everyone': { + GET: (params: PaginatedRequest<{ query: string; fields?: string }>) => PaginatedResult<{ ims: IRoom[] }>; + }; + '/v1/dm.list.everyone': { + GET: (params: PaginatedRequest<{ query: string; fields?: string }>) => PaginatedResult<{ ims: IRoom[] }>; + }; + '/v1/im.files': { + GET: (params: DmFileProps) => PaginatedResult<{ + files: IUploadWithUser[]; + }>; + }; + '/v1/dm.files': { + GET: (params: DmFileProps) => PaginatedResult<{ + files: IUploadWithUser[]; + }>; + }; + '/v1/im.members': { + GET: (params: DmMemberProps) => PaginatedResult<{ + members: (Pick & { + subscription: Pick; + })[]; + }>; + }; + '/v1/dm.members': { + GET: (params: DmMemberProps) => PaginatedResult<{ + members: (Pick & { + subscription: Pick; + })[]; + }>; + }; +}; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface diff --git a/packages/core-typings/src/Ajv.ts b/packages/core-typings/src/Ajv.ts index eb91852edb6de..f909409025ac8 100644 --- a/packages/core-typings/src/Ajv.ts +++ b/packages/core-typings/src/Ajv.ts @@ -7,13 +7,15 @@ import type { IInvite } from './IInvite'; import type { IMessage } from './IMessage'; import type { IOAuthApps } from './IOAuthApps'; import type { IPermission } from './IPermission'; +import type { IRoom } from './IRoom'; import type { ISubscription } from './ISubscription'; +import type { IUser } from './IUser'; import type { SlashCommand } from './SlashCommands'; import type { IMediaCall } from './mediaCalls/IMediaCall'; export const schemas = typia.json.schemas< [ - ISubscription | IInvite | ICustomSound | IMessage | IOAuthApps | IPermission | IMediaCall, + ISubscription | IInvite | ICustomSound | IMessage | IOAuthApps | IPermission | IMediaCall | IRoom | IUser, CallHistoryItem, ICustomUserStatus, SlashCommand, diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index b0e2dacff7a85..d357690aff38d 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -219,7 +219,6 @@ export * from './v1/mailer/MailerUnsubscribeParamsPOST'; export * from './v1/misc'; export * from './v1/invites'; export * from './v1/dm'; -export * from './v1/dm/DmHistoryProps'; export * from './v1/integrations'; export * from './v1/licenses'; export * from './v1/omnichannel'; diff --git a/packages/rest-typings/src/v1/dm/DmCreateProps.ts b/packages/rest-typings/src/v1/dm/DmCreateProps.ts deleted file mode 100644 index 1f2ea894026cd..0000000000000 --- a/packages/rest-typings/src/v1/dm/DmCreateProps.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ajv } from '../Ajv'; - -export type DmCreateProps = ( - | { - usernames: string; - } - | { - username: string; - } -) & { excludeSelf?: boolean }; - -export const isDmCreateProps = ajv.compile({ - oneOf: [ - { - type: 'object', - properties: { - usernames: { - type: 'string', - }, - excludeSelf: { - type: 'boolean', - }, - }, - required: ['usernames'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - username: { - type: 'string', - }, - excludeSelf: { - type: 'boolean', - }, - }, - required: ['username'], - additionalProperties: false, - }, - ], -}); diff --git a/packages/rest-typings/src/v1/dm/DmFileProps.ts b/packages/rest-typings/src/v1/dm/DmFileProps.ts deleted file mode 100644 index a6a86c99cb5b2..0000000000000 --- a/packages/rest-typings/src/v1/dm/DmFileProps.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; -import { ajv } from '../Ajv'; - -export type DmFileProps = PaginatedRequest< - ({ roomId: string; username?: string } | { roomId?: string; username: string }) & { - name?: string; - typeGroup?: string; - query?: string; - onlyConfirmed?: boolean; - } ->; - -const dmFilesListPropsSchema = { - type: 'object', - properties: { - roomId: { - type: 'string', - nullable: true, - }, - username: { - type: 'string', - nullable: true, - }, - offset: { - type: 'number', - nullable: true, - }, - count: { - type: 'number', - nullable: true, - }, - sort: { - type: 'string', - nullable: true, - }, - name: { - type: 'string', - nullable: true, - }, - typeGroup: { - type: 'string', - nullable: true, - }, - query: { - type: 'string', - nullable: true, - }, - onlyConfirmed: { - type: 'boolean', - }, - }, - oneOf: [{ required: ['roomId'] }, { required: ['username'] }], - required: [], - additionalProperties: false, -}; - -export const isDmFileProps = ajv.compile(dmFilesListPropsSchema); diff --git a/packages/rest-typings/src/v1/dm/DmHistoryProps.ts b/packages/rest-typings/src/v1/dm/DmHistoryProps.ts deleted file mode 100644 index 9a745a06deb76..0000000000000 --- a/packages/rest-typings/src/v1/dm/DmHistoryProps.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; -import { ajv } from '../Ajv'; - -export type DmHistoryProps = PaginatedRequest<{ - roomId: string; - latest?: string; - oldest?: string; - inclusive?: 'false' | 'true'; - unreads?: 'true' | 'false'; - showThreadMessages?: 'false' | 'true'; -}>; - -const DmHistoryPropsSchema = { - type: 'object', - properties: { - roomId: { - type: 'string', - minLength: 1, - }, - latest: { - type: 'string', - minLength: 1, - }, - showThreadMessages: { - type: 'string', - enum: ['false', 'true'], - }, - oldest: { - type: 'string', - minLength: 1, - }, - inclusive: { - type: 'string', - enum: ['false', 'true'], - }, - unreads: { - type: 'string', - enum: ['true', 'false'], - }, - count: { - type: 'number', - }, - offset: { - type: 'number', - }, - sort: { - type: 'string', - }, - }, - required: ['roomId'], - additionalProperties: false, -}; - -export const isDmHistoryProps = ajv.compile(DmHistoryPropsSchema); diff --git a/packages/rest-typings/src/v1/dm/DmLeaveProps.ts b/packages/rest-typings/src/v1/dm/DmLeaveProps.ts deleted file mode 100644 index 9fbe99b3b62e0..0000000000000 --- a/packages/rest-typings/src/v1/dm/DmLeaveProps.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ajv } from '../Ajv'; - -export type DmLeaveProps = - | { - roomId: string; - } - | { roomName: string }; - -const DmLeavePropsSchema = { - oneOf: [ - { - type: 'object', - properties: { - roomId: { - type: 'string', - }, - }, - required: ['roomId'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - roomName: { - type: 'string', - }, - }, - required: ['roomName'], - additionalProperties: false, - }, - ], -}; - -export const isDmLeaveProps = ajv.compile(DmLeavePropsSchema); diff --git a/packages/rest-typings/src/v1/dm/DmMembersProps.ts b/packages/rest-typings/src/v1/dm/DmMembersProps.ts deleted file mode 100644 index 9cebf3fa7b1d1..0000000000000 --- a/packages/rest-typings/src/v1/dm/DmMembersProps.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; -import { ajv } from '../Ajv'; - -export type DmMemberProps = PaginatedRequest< - ( - | { - roomId: string; - } - | { - username: string; - } - ) & { - status?: string[]; - filter?: string; - } ->; - -export const isDmMemberProps = ajv.compile({ - oneOf: [ - { - type: 'object', - properties: { - roomId: { - type: 'string', - }, - status: { - type: 'array', - items: { - type: 'string', - }, - }, - filter: { - type: 'string', - }, - query: { - type: 'string', - }, - sort: { - type: 'string', - }, - count: { - type: 'number', - }, - offset: { - type: 'number', - }, - }, - required: ['roomId'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - username: { - type: 'string', - }, - status: { - type: 'array', - items: { - type: 'string', - }, - }, - filter: { - type: 'string', - }, - query: { - type: 'string', - }, - sort: { - type: 'string', - }, - count: { - type: 'number', - }, - offset: { - type: 'number', - }, - }, - required: ['username'], - additionalProperties: false, - }, - ], -}); diff --git a/packages/rest-typings/src/v1/dm/DmMessagesProps.ts b/packages/rest-typings/src/v1/dm/DmMessagesProps.ts deleted file mode 100644 index a829c88ac8722..0000000000000 --- a/packages/rest-typings/src/v1/dm/DmMessagesProps.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; -import { ajv } from '../Ajv'; - -export type DmMessagesProps = PaginatedRequest< - ({ roomId: string } | { username: string }) & { - query?: string; - mentionIds?: string; - starredIds?: string; - pinned?: string; - fields?: string; - } ->; - -export const isDmMessagesProps = ajv.compile({ - oneOf: [ - { - type: 'object', - properties: { - roomId: { - type: 'string', - }, - mentionIds: { - type: 'string', - }, - starredIds: { - type: 'string', - }, - pinned: { - type: 'string', - }, - fields: { - type: 'string', - }, - query: { - type: 'string', - }, - sort: { - type: 'string', - }, - count: { - type: 'number', - }, - offset: { - type: 'number', - }, - }, - required: ['roomId'], - additionalProperties: false, - }, - { - type: 'object', - properties: { - username: { - type: 'string', - }, - mentionIds: { - type: 'string', - }, - starredIds: { - type: 'string', - }, - pinned: { - type: 'string', - }, - query: { - type: 'string', - }, - fields: { - type: 'string', - }, - sort: { - type: 'string', - }, - count: { - type: 'number', - }, - offset: { - type: 'number', - }, - }, - required: ['username'], - additionalProperties: false, - }, - ], -}); diff --git a/packages/rest-typings/src/v1/dm/dm.ts b/packages/rest-typings/src/v1/dm/dm.ts index bbc4af18659a8..c565f6a46639b 100644 --- a/packages/rest-typings/src/v1/dm/dm.ts +++ b/packages/rest-typings/src/v1/dm/dm.ts @@ -2,14 +2,4 @@ import type { ImEndpoints } from './im'; export type DmEndpoints = { '/v1/dm.create': ImEndpoints['/v1/im.create']; - '/v1/dm.counters': ImEndpoints['/v1/im.counters']; - '/v1/dm.files': ImEndpoints['/v1/im.files']; - '/v1/dm.history': ImEndpoints['/v1/im.history']; - '/v1/dm.members': ImEndpoints['/v1/im.members']; - '/v1/dm.messages': ImEndpoints['/v1/im.messages']; - '/v1/dm.messages.others': ImEndpoints['/v1/im.messages.others']; - '/v1/dm.list': ImEndpoints['/v1/im.list']; - '/v1/dm.list.everyone': ImEndpoints['/v1/im.list.everyone']; - '/v1/dm.open': ImEndpoints['/v1/im.open']; - '/v1/dm.setTopic': ImEndpoints['/v1/im.setTopic']; }; diff --git a/packages/rest-typings/src/v1/dm/im.ts b/packages/rest-typings/src/v1/dm/im.ts index 82b9e5b909f3a..884d22e910e92 100644 --- a/packages/rest-typings/src/v1/dm/im.ts +++ b/packages/rest-typings/src/v1/dm/im.ts @@ -1,17 +1,14 @@ -import type { IMessage, IRoom, IUser, IUploadWithUser, ISubscription } from '@rocket.chat/core-typings'; +import type { IRoom } from '@rocket.chat/core-typings'; -import type { DmCreateProps } from './DmCreateProps'; -import type { DmFileProps } from './DmFileProps'; -import type { DmHistoryProps } from './DmHistoryProps'; -import type { DmLeaveProps } from './DmLeaveProps'; -import type { DmMemberProps } from './DmMembersProps'; -import type { DmMessagesProps } from './DmMessagesProps'; -import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; -import type { PaginatedResult } from '../../helpers/PaginatedResult'; +type DmCreateProps = ( + | { + usernames: string; + } + | { + username: string; + } +) & { excludeSelf?: boolean }; -type DmKickProps = { - roomId: string; -}; export type ImEndpoints = { '/v1/im.create': { @@ -19,61 +16,4 @@ export type ImEndpoints = { room: IRoom & { rid: IRoom['_id'] }; }; }; - '/v1/im.kick': { - POST: (params: DmKickProps) => void; - }; - '/v1/im.leave': { - POST: (params: DmLeaveProps) => void; - }; - '/v1/im.counters': { - GET: (params: { roomId: string; userId?: string }) => { - joined: boolean; - unreads: number | null; - unreadsFrom: string | null; - msgs: number | null; - members: number | null; - latest: string | null; - userMentions: number | null; - }; - }; - '/v1/im.files': { - GET: (params: DmFileProps) => PaginatedResult<{ - files: IUploadWithUser[]; - }>; - }; - '/v1/im.history': { - GET: (params: DmHistoryProps) => { - messages: Pick[]; - }; - }; - - '/v1/im.members': { - GET: (params: DmMemberProps) => PaginatedResult<{ - members: (Pick & { - subscription: Pick; - })[]; - }>; - }; - '/v1/im.messages': { - GET: (params: DmMessagesProps) => PaginatedResult<{ - messages: IMessage[]; - }>; - }; - '/v1/im.messages.others': { - GET: (params: PaginatedRequest<{ roomId: IRoom['_id']; query?: string; fields?: string }>) => PaginatedResult<{ messages: IMessage[] }>; - }; - '/v1/im.list': { - GET: (params: PaginatedRequest<{ fields?: string }>) => PaginatedResult<{ ims: IRoom[] }>; - }; - '/v1/im.list.everyone': { - GET: (params: PaginatedRequest<{ query: string; fields?: string }>) => PaginatedResult<{ ims: IRoom[] }>; - }; - '/v1/im.open': { - POST: (params: { roomId: string }) => void; - }; - '/v1/im.setTopic': { - POST: (params: { roomId: string; topic?: string }) => { - topic?: string; - }; - }; }; diff --git a/packages/rest-typings/src/v1/dm/index.ts b/packages/rest-typings/src/v1/dm/index.ts index 09ec09e706814..79a7bb2a9f52a 100644 --- a/packages/rest-typings/src/v1/dm/index.ts +++ b/packages/rest-typings/src/v1/dm/index.ts @@ -1,6 +1,2 @@ export * from './dm'; export * from './im'; -export * from './DmCreateProps'; -export * from './DmFileProps'; -export * from './DmMembersProps'; -export * from './DmMessagesProps'; From 5b8cd4e77278515399841f9b476c8432766e118e Mon Sep 17 00:00:00 2001 From: ahmed-n-abdeltwab Date: Wed, 4 Mar 2026 15:47:51 +0200 Subject: [PATCH 2/6] chore: Add OpenAPI support for the Rocket.Chat dm/im APIs endpoints - migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. --- .changeset/wicked-bugs-itch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wicked-bugs-itch.md b/.changeset/wicked-bugs-itch.md index 0ca09eb98d240..07d8794391082 100644 --- a/.changeset/wicked-bugs-itch.md +++ b/.changeset/wicked-bugs-itch.md @@ -4,4 +4,4 @@ '@rocket.chat/meteor': minor --- -chore: Add OpenAPI support for the Rocket.Chat dm/im APIs endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. \ No newline at end of file +Add OpenAPI support for the Rocket.Chat dm/im APIs endpoints by migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. From 4b04790e0b3e1e9bef3a3e9ccccdddc3c2fd4804 Mon Sep 17 00:00:00 2001 From: ahmed-n-abdeltwab Date: Wed, 4 Mar 2026 15:47:51 +0200 Subject: [PATCH 3/6] chore: Add OpenAPI support for the Rocket.Chat dm/im APIs endpoints - migrating to a modern chained route definition syntax and utilizing shared AJV schemas for validation to enhance API documentation and ensure type safety through response validation. --- apps/meteor/app/api/server/v1/im.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index 56306b9648fed..27c271a8aff9e 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -1,7 +1,7 @@ /** * Docs: https://github.com/RocketChat/developer-docs/blob/master/reference/api/rest-api/endpoints/team-collaboration-endpoints/im-endpoints */ -import type { ICreatedRoom, IMessage, IRoom, ISubscription, IUploadWithUser, IUser } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, ISubscription, IUploadWithUser, IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Uploads, Messages, Rooms, Users } from '@rocket.chat/models'; import { ajv, @@ -13,7 +13,7 @@ import { } from '@rocket.chat/rest-typings'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import type { FindOptions, Filter } from 'mongodb'; +import type { FindOptions } from 'mongodb'; import { eraseRoom } from '../../../../server/lib/eraseRoom'; import { openRoom } from '../../../../server/lib/openRoom'; From 54203d1712bd2e15af157327024696ad371fecd3 Mon Sep 17 00:00:00 2001 From: ahmed-n-abdeltwab Date: Thu, 12 Mar 2026 12:22:53 +0200 Subject: [PATCH 4/6] chore: fix some lint error and add missing query for dm.list --- apps/meteor/app/api/server/v1/im.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index 27c271a8aff9e..ec688f9e0ab91 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -152,7 +152,7 @@ const isDmListProps = ajv.compile({ API.v1.addRoute( ['dm.list', 'im.list'], - { authRequired: true }, + { authRequired: true, query: isDmListProps }, { async get() { const { offset, count } = await getPaginationItems(this.queryParams); From 9abd1273094fdcbb2d2b0797d44b83d84424d8ee Mon Sep 17 00:00:00 2001 From: ahmed-n-abdeltwab Date: Thu, 12 Mar 2026 12:22:53 +0200 Subject: [PATCH 5/6] chore: fix some lint error and add missing query for dm.list --- apps/meteor/app/api/server/v1/im.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index ec688f9e0ab91..aed48a7f47006 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -131,25 +131,6 @@ API.v1.addRoute( type DmListProps = PaginatedRequest<{ fields?: string }>; -const isDmListProps = ajv.compile({ - type: 'object', - properties: { - fields: { - type: 'string', - }, - offset: { - type: 'number', - }, - count: { - type: 'number', - }, - sort: { - type: 'string', - }, - }, - additionalProperties: false, -}); - API.v1.addRoute( ['dm.list', 'im.list'], { authRequired: true, query: isDmListProps }, From 35ce78aaf7cb98c2d29d17be51842bc9259149fd Mon Sep 17 00:00:00 2001 From: ahmed-n-abdeltwab Date: Thu, 12 Mar 2026 13:14:54 +0200 Subject: [PATCH 6/6] chore: fix some lint error and add missing query for dm.list --- apps/meteor/app/api/server/v1/im.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index aed48a7f47006..ec688f9e0ab91 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -131,6 +131,25 @@ API.v1.addRoute( type DmListProps = PaginatedRequest<{ fields?: string }>; +const isDmListProps = ajv.compile({ + type: 'object', + properties: { + fields: { + type: 'string', + }, + offset: { + type: 'number', + }, + count: { + type: 'number', + }, + sort: { + type: 'string', + }, + }, + additionalProperties: false, +}); + API.v1.addRoute( ['dm.list', 'im.list'], { authRequired: true, query: isDmListProps },