diff --git a/apps/meteor/app/api/server/v1/integrations.ts b/apps/meteor/app/api/server/v1/integrations.ts index 78d77fe007f39..bf3fb920351ef 100644 --- a/apps/meteor/app/api/server/v1/integrations.ts +++ b/apps/meteor/app/api/server/v1/integrations.ts @@ -1,15 +1,19 @@ -import type { IIntegration, INewIncomingIntegration, INewOutgoingIntegration } from '@rocket.chat/core-typings'; +import type { IIntegration, IIntegrationHistory, INewIncomingIntegration, INewOutgoingIntegration } from '@rocket.chat/core-typings'; import { Integrations, IntegrationHistory } from '@rocket.chat/models'; +import type { PaginatedResult } from '@rocket.chat/rest-typings'; import { + ajv, isIntegrationsCreateProps, isIntegrationsHistoryProps, isIntegrationsRemoveProps, isIntegrationsGetProps, isIntegrationsUpdateProps, isIntegrationsListProps, + validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, + validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; -import { Match, check } from 'meteor/check'; import type { Filter } from 'mongodb'; import { @@ -22,10 +26,314 @@ import { updateIncomingIntegration } from '../../../integrations/server/methods/ import { addOutgoingIntegration } from '../../../integrations/server/methods/outgoing/addOutgoingIntegration'; import { deleteOutgoingIntegration } from '../../../integrations/server/methods/outgoing/deleteOutgoingIntegration'; import { updateOutgoingIntegration } from '../../../integrations/server/methods/outgoing/updateOutgoingIntegration'; +import type { ExtractRoutesFromAPI } from '../ApiClass'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { findOneIntegration } from '../lib/integrations'; +const integrationResponseSchema = { + type: 'object', + properties: { + integration: { type: 'object', additionalProperties: true }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['integration', 'success'], + additionalProperties: false, +} as const; + +const integrationsEndpoints = API.v1 + .post( + 'integrations.create', + { + authRequired: true, + body: isIntegrationsCreateProps, + response: { + 200: ajv.compile<{ integration: IIntegration }>(integrationResponseSchema), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + switch (this.bodyParams.type) { + case 'webhook-outgoing': + return API.v1.success({ integration: await addOutgoingIntegration(this.userId, this.bodyParams as INewOutgoingIntegration) }); + case 'webhook-incoming': + return API.v1.success({ integration: await addIncomingIntegration(this.userId, this.bodyParams as INewIncomingIntegration) }); + default: + return API.v1.failure('Invalid integration type.'); + } + }, + ) + .get( + 'integrations.history', + { + authRequired: true, + query: isIntegrationsHistoryProps, + permissionsRequired: { permissions: ['manage-outgoing-integrations', 'manage-own-outgoing-integrations'], operation: 'hasAny' }, + response: { + 200: ajv.compile>({ + type: 'object', + properties: { + history: { type: 'array', items: { type: 'object', additionalProperties: true } }, + offset: { type: 'number' }, + count: { type: 'number' }, + items: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['history', 'offset', 'count', 'items', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { userId, queryParams } = this; + + if (!queryParams.id || queryParams.id.trim() === '') { + return API.v1.failure('Invalid integration id.'); + } + + const { id } = queryParams; + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields: projection, query } = await this.parseJsonQuery(); + const ourQuery = Object.assign(await mountIntegrationHistoryQueryBasedOnPermissions(userId, id), query); + + const { cursor, totalCount } = IntegrationHistory.findPaginated(ourQuery, { + sort: sort || { _updatedAt: -1 }, + skip: offset, + limit: count, + projection, + }); + + const [history, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + history, + offset, + items: history.length, + count: history.length, + total, + }); + }, + ) + .get( + 'integrations.list', + { + authRequired: true, + query: isIntegrationsListProps, + permissionsRequired: { + permissions: [ + 'manage-outgoing-integrations', + 'manage-own-outgoing-integrations', + 'manage-incoming-integrations', + 'manage-own-incoming-integrations', + ], + operation: 'hasAny', + }, + response: { + 200: ajv.compile>({ + type: 'object', + properties: { + integrations: { type: 'array', items: { type: 'object', additionalProperties: true } }, + offset: { type: 'number' }, + count: { type: 'number' }, + items: { type: 'number' }, + total: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['integrations', 'offset', 'count', 'items', 'total', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort, fields, query } = await this.parseJsonQuery(); + const { name, type } = this.queryParams; + + const filter = { + ...query, + ...(name ? { name: { $regex: escapeRegExp(name as string), $options: 'i' } } : {}), + ...(type ? { type } : {}), + }; + + const ourQuery = Object.assign(await mountIntegrationQueryBasedOnPermissions(this.userId), filter) as Filter; + + const { cursor, totalCount } = Integrations.findPaginated(ourQuery, { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + projection: fields, + }); + + const [integrations, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + integrations, + offset, + items: integrations.length, + count: integrations.length, + total, + }); + }, + ) + .post( + 'integrations.remove', + { + authRequired: true, + body: isIntegrationsRemoveProps, + permissionsRequired: { + permissions: [ + 'manage-outgoing-integrations', + 'manage-own-outgoing-integrations', + 'manage-incoming-integrations', + 'manage-own-incoming-integrations', + ], + operation: 'hasAny', + }, + response: { + 200: ajv.compile<{ integration: IIntegration }>(integrationResponseSchema), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { bodyParams } = this; + + let integration: IIntegration | null = null; + switch (bodyParams.type) { + case 'webhook-outgoing': + if (!bodyParams.target_url && !bodyParams.integrationId) { + return API.v1.failure('An integrationId or target_url needs to be provided.'); + } + + if (bodyParams.target_url) { + integration = await Integrations.findOne({ urls: bodyParams.target_url }); + } else if (bodyParams.integrationId) { + integration = await Integrations.findOne({ _id: bodyParams.integrationId }); + } + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + await deleteOutgoingIntegration(integration._id, this.userId); + + return API.v1.success({ + integration, + }); + case 'webhook-incoming': + if (!bodyParams.integrationId) { + return API.v1.failure('An integrationId needs to be provided.'); + } + + integration = await Integrations.findOne({ _id: bodyParams.integrationId }); + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + await deleteIncomingIntegration(integration._id, this.userId); + + return API.v1.success({ + integration, + }); + default: + return API.v1.failure('Invalid integration type.'); + } + }, + ) + .get( + 'integrations.get', + { + authRequired: true, + query: isIntegrationsGetProps, + response: { + 200: ajv.compile<{ integration: IIntegration }>(integrationResponseSchema), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { integrationId, createdBy } = this.queryParams; + if (!integrationId) { + return API.v1.failure('The query parameter "integrationId" is required.'); + } + + return API.v1.success({ + integration: await findOneIntegration({ + userId: this.userId, + integrationId, + createdBy, + }), + }); + }, + ) + .put( + 'integrations.update', + { + authRequired: true, + body: isIntegrationsUpdateProps, + response: { + 200: ajv.compile<{ integration: IIntegration | null }>(integrationResponseSchema), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { bodyParams } = this; + + let integration; + switch (bodyParams.type) { + case 'webhook-outgoing': + if (bodyParams.target_url) { + integration = await Integrations.findOne({ urls: bodyParams.target_url }); + } else if (bodyParams.integrationId) { + integration = await Integrations.findOne({ _id: bodyParams.integrationId }); + } + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + await updateOutgoingIntegration(this.userId, integration._id, bodyParams); + + return API.v1.success({ + integration: await Integrations.findOne({ _id: integration._id }), + }); + case 'webhook-incoming': + integration = await Integrations.findOne({ _id: bodyParams.integrationId }); + + if (!integration) { + return API.v1.failure('No integration found.'); + } + + await updateIncomingIntegration(this.userId, integration._id, bodyParams); + + return API.v1.success({ + integration: await Integrations.findOne({ _id: integration._id }), + }); + default: + return API.v1.failure('Invalid integration type.'); + } + }, + ); + +export type IntegrationsEndpointsNew = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends IntegrationsEndpointsNew {} +} + + API.v1.addRoute( 'integrations.create', { authRequired: true, validateParams: isIntegrationsCreateProps }, diff --git a/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts b/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts index 80cf172c9dcb1..9787ab029ec4c 100644 --- a/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts +++ b/apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts @@ -92,7 +92,7 @@ async function updateUsersSubscriptions(message: IMessage, room: IRoom): Promise const [mentions, highlightIds] = await Promise.all([getMentions(message), getUserIdsFromHighlights(room._id, message)]); const { toAll, toHere, mentionIds } = mentions; - const userIds = [...new Set([...mentionIds, ...highlightIds])]; + const userIdSet = new Set([...mentionIds, ...highlightIds]); const unreadCount = getUnreadSettingCount(room.t); const unreadAllMessages = unreadCount === 'all_messages'; @@ -104,19 +104,19 @@ async function updateUsersSubscriptions(message: IMessage, room: IRoom): Promise const subs = await Subscriptions.findByRoomIdAndNotAlertOrOpenExcludingUserIds({ roomId: room._id, uidsExclude: [message.u._id], - uidsInclude: userIds, + uidsInclude: [...userIdSet], onlyRead: !toAll && !toHere && !unreadAllMessages, }).toArray(); // Give priority to user mentions over group mentions - if (userIds.length) { - await Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, userIds, 1, userMentionInc); + if (userIdSet.size) { + await Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, [...userIdSet], 1, userMentionInc); } else if (toAll || toHere) { await Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(room._id, message.u._id, 1, groupMentionInc); } if (!toAll && !toHere && unreadAllMessages) { - await Subscriptions.incUnreadForRoomIdExcludingUserIds(room._id, [...userIds, message.u._id], 1); + await Subscriptions.incUnreadForRoomIdExcludingUserIds(room._id, [...userIdSet, message.u._id], 1); } // update subscriptions of other members of the room @@ -126,7 +126,7 @@ async function updateUsersSubscriptions(message: IMessage, room: IRoom): Promise ]); subs.forEach((sub) => { - const hasUserMention = userIds.includes(sub.u._id); + const hasUserMention = userIdSet.has(sub.u._id); const shouldIncUnread = hasUserMention || toAll || toHere || unreadAllMessages; void notifyOnSubscriptionChanged( { diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index b0e2dacff7a85..f025b5823f795 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -22,7 +22,6 @@ import type { FederationEndpoints } from './v1/federation'; import type { GroupsEndpoints } from './v1/groups'; import type { ImportEndpoints } from './v1/import'; import type { InstancesEndpoints } from './v1/instances'; -import type { IntegrationsEndpoints } from './v1/integrations'; import type { IntegrationHooksEndpoints } from './v1/integrations/hooks'; import type { InvitesEndpoints } from './v1/invites'; import type { LDAPEndpoints } from './v1/ldap'; @@ -74,7 +73,6 @@ export interface Endpoints MiscEndpoints, PresenceEndpoints, InstancesEndpoints, - IntegrationsEndpoints, IntegrationHooksEndpoints, VideoConferenceEndpoints, InvitesEndpoints,