Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 310 additions & 2 deletions apps/meteor/app/api/server/v1/integrations.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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',
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The integrations.* routes are registered twice: the new chained API definitions were added, but the legacy API.v1.addRoute blocks for the same endpoints still remain below. This risks duplicate-route errors or handler overrides and should be cleaned up during the migration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/app/api/server/v1/integrations.ts, line 46:

<comment>The integrations.* routes are registered twice: the new chained API definitions were added, but the legacy API.v1.addRoute blocks for the same endpoints still remain below. This risks duplicate-route errors or handler overrides and should be cleaned up during the migration.</comment>

<file context>
@@ -22,10 +26,314 @@ import { updateIncomingIntegration } from '../../../integrations/server/methods/
+
+const integrationsEndpoints = API.v1
+	.post(
+		'integrations.create',
+		{
+			authRequired: true,
</file context>
Fix with Cubic

{
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<PaginatedResult<{ history: IIntegrationHistory[]; items: number }>>({
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<PaginatedResult<{ integrations: IIntegration[]; items: number }>>({
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<IIntegration>;

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.');
}
},
);
Comment on lines +44 to +327
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Remove legacy API.v1.addRoute registrations before merging.

The new chained routes are added here, but the legacy registrations for the same paths still exist starting at Line 337. This creates duplicate route definitions (integrations.create/history/list/remove/get/update) and can cause registration conflicts or shadowing at runtime.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/meteor/app/api/server/v1/integrations.ts` around lines 44 - 327,
Duplicate route registrations exist because legacy API.v1.addRoute calls for the
same endpoints are still present alongside the new chained
integrationsEndpoints; remove the old API.v1.addRoute blocks that register
integrations.create, integrations.history, integrations.list,
integrations.remove, integrations.get, and integrations.update so only the
integrationsEndpoints chain defines those routes, and ensure
integrationsEndpoints is exported/registered where required (look for the
integrationsEndpoints variable and any API.v1.addRoute usages referencing the
same route names to delete).


export type IntegrationsEndpointsNew = ExtractRoutesFromAPI<typeof integrationsEndpoints>;

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 },
Expand Down
12 changes: 6 additions & 6 deletions apps/meteor/app/lib/server/lib/notifyUsersOnMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
Expand All @@ -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(
{
Expand Down
2 changes: 0 additions & 2 deletions packages/rest-typings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -74,7 +73,6 @@ export interface Endpoints
MiscEndpoints,
PresenceEndpoints,
InstancesEndpoints,
IntegrationsEndpoints,
IntegrationHooksEndpoints,
VideoConferenceEndpoints,
InvitesEndpoints,
Expand Down