diff --git a/apps/api/src/app/events/e2e/trigger-event.e2e.ts b/apps/api/src/app/events/e2e/trigger-event.e2e.ts index 749477a935d..cfb48d3c77e 100644 --- a/apps/api/src/app/events/e2e/trigger-event.e2e.ts +++ b/apps/api/src/app/events/e2e/trigger-event.e2e.ts @@ -800,6 +800,8 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { { $set: { active: true, + primary: true, + priority: 1, }, } ); @@ -851,8 +853,6 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { const newSubscriberIdInAppNotification = SubscriberRepository.createObjectId(); const channelType = ChannelTypeEnum.EMAIL; - template = await createTemplate(session, channelType); - template = await session.createTemplate({ steps: [ { @@ -894,7 +894,11 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { check: false, }; - await session.testAgent.post('/v1/integrations').send(payload); + const { + body: { data }, + } = await session.testAgent.post('/v1/integrations').send(payload); + await session.testAgent.post(`/v1/integrations/${data._id}/set-primary`).send({}); + await sendTrigger(session, template, newSubscriberIdInAppNotification, { nested: { subject: 'a subject nested', diff --git a/apps/api/src/app/integrations/dtos/get-active-integration-response.dto.ts b/apps/api/src/app/integrations/dtos/get-active-integration-response.dto.ts deleted file mode 100644 index 3b3bd539991..00000000000 --- a/apps/api/src/app/integrations/dtos/get-active-integration-response.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; - -import { IntegrationResponseDto } from './integration-response.dto'; - -export class GetActiveIntegrationResponseDto extends IntegrationResponseDto { - @ApiPropertyOptional() - selected?: boolean; -} diff --git a/apps/api/src/app/integrations/e2e/create-integration.e2e.ts b/apps/api/src/app/integrations/e2e/create-integration.e2e.ts index af7434d3af1..ac303cf21eb 100644 --- a/apps/api/src/app/integrations/e2e/create-integration.e2e.ts +++ b/apps/api/src/app/integrations/e2e/create-integration.e2e.ts @@ -1,6 +1,13 @@ import { IntegrationRepository, EnvironmentRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; -import { ChannelTypeEnum, EmailProviderIdEnum, SmsProviderIdEnum } from '@novu/shared'; +import { + ChannelTypeEnum, + ChatProviderIdEnum, + EmailProviderIdEnum, + InAppProviderIdEnum, + PushProviderIdEnum, + SmsProviderIdEnum, +} from '@novu/shared'; import { expect } from 'chai'; const ORIGINAL_IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED; @@ -187,6 +194,267 @@ describe('Create Integration - /integration (POST)', function () { expect(data.credentials?.tlsOptions).to.eql(payload.credentials.tlsOptions); expect(data.active).to.equal(true); }); + + it('should not calculate primary and priority fields when is not active', async function () { + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(data.priority).to.equal(0); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(false); + }); + + it('should not calculate primary and priority fields for in-app channel', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + providerId: InAppProviderIdEnum.Novu, + channel: ChannelTypeEnum.IN_APP, + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(data.priority).to.equal(0); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(true); + }); + + it('should not calculate primary and priority fields for push channel', async function () { + const payload = { + providerId: PushProviderIdEnum.FCM, + channel: ChannelTypeEnum.PUSH, + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(data.priority).to.equal(0); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(true); + }); + + it('should not calculate primary and priority fields for chat channel', async function () { + const payload = { + providerId: ChatProviderIdEnum.Slack, + channel: ChannelTypeEnum.CHAT, + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(data.priority).to.equal(0); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(true); + }); + + it('should set the integration as primary when its active and there are no other active integrations', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(data.priority).to.equal(1); + expect(data.primary).to.equal(true); + expect(data.active).to.equal(true); + }); + + it( + 'should set the integration as primary when its active ' + + 'and there are no other active integrations excluding Novu', + async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const novuEmail = await integrationRepository.create({ + name: 'novuEmail', + identifier: 'novuEmail', + providerId: EmailProviderIdEnum.Novu, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(data.priority).to.equal(2); + expect(data.primary).to.equal(true); + expect(data.active).to.equal(true); + + const [first, second] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(data._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(2); + + expect(second._id).to.equal(novuEmail._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(false); + expect(second.priority).to.equal(1); + } + ); + + it('should not set the integration as primary when there is primary integration', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const primaryIntegration = await integrationRepository.create({ + name: 'primaryIntegration', + identifier: 'primaryIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: true, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(data.priority).to.equal(1); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(true); + + const [first, second] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(primaryIntegration._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(2); + + expect(second._id).to.equal(data._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(1); + }); + + it('should calculate the highest priority but not set primary if there is another active integration', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const activeIntegration = await integrationRepository.create({ + name: 'activeIntegration', + identifier: 'activeIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(data.priority).to.equal(2); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(true); + + const [first, second] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(data._id); + expect(first.primary).to.equal(false); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(2); + + expect(second._id).to.equal(activeIntegration._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(1); + }); }); async function insertIntegrationTwice( diff --git a/apps/api/src/app/integrations/e2e/get-active-integration.e2e.ts b/apps/api/src/app/integrations/e2e/get-active-integration.e2e.ts index abb0a824850..fd57cb55dd2 100644 --- a/apps/api/src/app/integrations/e2e/get-active-integration.e2e.ts +++ b/apps/api/src/app/integrations/e2e/get-active-integration.e2e.ts @@ -4,10 +4,6 @@ import { ChannelTypeEnum, EmailProviderIdEnum, SmsProviderIdEnum } from '@novu/s import { IntegrationService } from '@novu/testing'; import { IntegrationEntity } from '@novu/dal'; -interface IActiveIntegration extends IntegrationEntity { - selected: boolean; -} - describe('Get Active Integrations [IS_MULTI_PROVIDER_CONFIGURATION_ENABLED=true] - /integrations/active (GET)', function () { let session: UserSession; const integrationService = new IntegrationService(); @@ -38,7 +34,7 @@ describe('Get Active Integrations [IS_MULTI_PROVIDER_CONFIGURATION_ENABLED=true] channel: ChannelTypeEnum.SMS, }); - const activeIntegrations: IActiveIntegration[] = (await session.testAgent.get(`/v1/integrations/active`)).body.data; + const activeIntegrations: IntegrationEntity[] = (await session.testAgent.get(`/v1/integrations/active`)).body.data; const { inAppIntegration, emailIntegration, smsIntegration, chatIntegration, pushIntegration } = splitByChannels(activeIntegrations); @@ -50,7 +46,7 @@ describe('Get Active Integrations [IS_MULTI_PROVIDER_CONFIGURATION_ENABLED=true] expect(chatIntegration.length).to.equal(4); const selectedInAppIntegrations = filterEnvIntegrations(inAppIntegration, session.environment._id); - expect(selectedInAppIntegrations.length).to.equal(1); + expect(selectedInAppIntegrations.length).to.equal(0); const selectedEmailIntegrations = filterEnvIntegrations(emailIntegration, session.environment._id); expect(selectedEmailIntegrations.length).to.equal(1); @@ -59,13 +55,10 @@ describe('Get Active Integrations [IS_MULTI_PROVIDER_CONFIGURATION_ENABLED=true] expect(selectedSmsIntegrations.length).to.equal(1); const selectedPushIntegrations = filterEnvIntegrations(pushIntegration, session.environment._id); - expect(selectedPushIntegrations.length).to.equal(1); - - const selected = chatIntegration.filter((integration) => integration.selected); - const notSelected = chatIntegration.filter((integration) => !integration.selected); + expect(selectedPushIntegrations.length).to.equal(0); - expect(selected.length).to.equal(2); - expect(notSelected.length).to.equal(2); + const selectedChatIntegrations = filterEnvIntegrations(chatIntegration, session.environment._id); + expect(selectedChatIntegrations.length).to.equal(0); for (const integration of activeIntegrations) { expect(integration.active).to.equal(true); @@ -82,11 +75,11 @@ describe('Get Active Integrations [IS_MULTI_PROVIDER_CONFIGURATION_ENABLED=true] }); it('should have additional unselected integration after creating a new one', async function () { - const initialActiveIntegrations: IActiveIntegration[] = (await session.testAgent.get(`/v1/integrations/active`)) - .body.data; + const initialActiveIntegrations: IntegrationEntity[] = (await session.testAgent.get(`/v1/integrations/active`)).body + .data; const { emailIntegration: initialEmailIntegrations } = splitByChannels(initialActiveIntegrations); - let allOrgSelectedIntegrations = initialEmailIntegrations.filter((integration) => integration.selected); + let allOrgSelectedIntegrations = initialEmailIntegrations.filter((integration) => integration.primary); let allEnvSelectedIntegrations = filterEnvIntegrations(initialEmailIntegrations, session.environment._id); let allEnvNotSelectedIntegrations = filterEnvIntegrations(initialEmailIntegrations, session.environment._id, false); @@ -102,10 +95,10 @@ describe('Get Active Integrations [IS_MULTI_PROVIDER_CONFIGURATION_ENABLED=true] active: true, }); - const activeIntegrations: IActiveIntegration[] = (await session.testAgent.get(`/v1/integrations/active`)).body.data; + const activeIntegrations: IntegrationEntity[] = (await session.testAgent.get(`/v1/integrations/active`)).body.data; const { emailIntegration } = splitByChannels(activeIntegrations); - allOrgSelectedIntegrations = emailIntegration.filter((integration) => integration.selected); + allOrgSelectedIntegrations = emailIntegration.filter((integration) => integration.primary); allEnvSelectedIntegrations = filterEnvIntegrations(emailIntegration, session.environment._id); allEnvNotSelectedIntegrations = filterEnvIntegrations(emailIntegration, session.environment._id, false); @@ -115,19 +108,19 @@ describe('Get Active Integrations [IS_MULTI_PROVIDER_CONFIGURATION_ENABLED=true] }); }); -function filterEnvIntegrations(integrations: IActiveIntegration[], environmentId: string, selected = true) { +function filterEnvIntegrations(integrations: IntegrationEntity[], environmentId: string, primary = true) { return integrations.filter( - (integration) => integration.selected === selected && integration._environmentId === environmentId + (integration) => integration.primary === primary && integration._environmentId === environmentId ); } -function splitByChannels(activeIntegrations: IActiveIntegration[]) { +function splitByChannels(activeIntegrations: IntegrationEntity[]) { return activeIntegrations.reduce<{ - inAppIntegration: IActiveIntegration[]; - emailIntegration: IActiveIntegration[]; - smsIntegration: IActiveIntegration[]; - chatIntegration: IActiveIntegration[]; - pushIntegration: IActiveIntegration[]; + inAppIntegration: IntegrationEntity[]; + emailIntegration: IntegrationEntity[]; + smsIntegration: IntegrationEntity[]; + chatIntegration: IntegrationEntity[]; + pushIntegration: IntegrationEntity[]; }>( (acc, integration) => { if (integration.channel === ChannelTypeEnum.IN_APP) { diff --git a/apps/api/src/app/integrations/e2e/remove-integration.e2e.ts b/apps/api/src/app/integrations/e2e/remove-integration.e2e.ts index 11b7c498fe7..383df2a5fd5 100644 --- a/apps/api/src/app/integrations/e2e/remove-integration.e2e.ts +++ b/apps/api/src/app/integrations/e2e/remove-integration.e2e.ts @@ -1,9 +1,17 @@ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { IntegrationRepository } from '@novu/dal'; -import { ChannelTypeEnum, EmailProviderIdEnum } from '@novu/shared'; +import { + ChannelTypeEnum, + EmailProviderIdEnum, + InAppProviderIdEnum, + ChatProviderIdEnum, + PushProviderIdEnum, +} from '@novu/shared'; import { HttpStatus } from '@nestjs/common'; +const ORIGINAL_IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED; + describe('Delete Integration - /integration/:integrationId (DELETE)', function () { let session: UserSession; const integrationRepository = new IntegrationRepository(); @@ -11,6 +19,277 @@ describe('Delete Integration - /integration/:integrationId (DELETE)', function ( beforeEach(async () => { session = new UserSession(); await session.initialize(); + process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = 'true'; + }); + + afterEach(async () => { + process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = ORIGINAL_IS_MULTI_PROVIDER_CONFIGURATION_ENABLED; + }); + + it('should throw not found exception when integration is not found', async function () { + const integrationId = IntegrationRepository.createObjectId(); + const { body } = await session.testAgent.delete(`/v1/integrations/${integrationId}`).send(); + + expect(body.statusCode).to.equal(404); + expect(body.message).to.equal(`Entity with id ${integrationId} not found`); + }); + + it('should not recalculate primary and priority fields for in-app channel', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const primaryIntegration = await integrationRepository.create({ + name: 'primaryIntegration', + identifier: 'primaryIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: true, + priority: 2, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integration = await integrationRepository.create({ + name: 'integration', + identifier: 'integration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const inAppIntegration = await integrationRepository.create({ + name: 'Novu In-App', + identifier: 'identifier1', + providerId: InAppProviderIdEnum.Novu, + channel: ChannelTypeEnum.IN_APP, + active: false, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { statusCode } = await session.testAgent.delete(`/v1/integrations/${inAppIntegration._id}`).send(); + expect(statusCode).to.equal(200); + + const [first, second] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(primaryIntegration._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(2); + + expect(second._id).to.equal(integration._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(1); + }); + + it('should not recalculate primary and priority fields for push channel', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const primaryIntegration = await integrationRepository.create({ + name: 'primaryIntegration', + identifier: 'primaryIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: true, + priority: 2, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integration = await integrationRepository.create({ + name: 'integration', + identifier: 'integration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const pushIntegration = await integrationRepository.create({ + name: 'FCM', + identifier: 'identifier1', + providerId: PushProviderIdEnum.FCM, + channel: ChannelTypeEnum.PUSH, + active: false, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { statusCode } = await session.testAgent.delete(`/v1/integrations/${pushIntegration._id}`).send(); + expect(statusCode).to.equal(200); + + const [first, second] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(primaryIntegration._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(2); + + expect(second._id).to.equal(integration._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(1); + }); + + it('should not recalculate primary and priority fields for chat channel', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const primaryIntegration = await integrationRepository.create({ + name: 'primaryIntegration', + identifier: 'primaryIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: true, + priority: 2, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integration = await integrationRepository.create({ + name: 'integration', + identifier: 'integration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const chatIntegration = await integrationRepository.create({ + name: 'Slack', + identifier: 'identifier1', + providerId: ChatProviderIdEnum.Slack, + channel: ChannelTypeEnum.CHAT, + active: false, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { statusCode } = await session.testAgent.delete(`/v1/integrations/${chatIntegration._id}`).send(); + expect(statusCode).to.equal(200); + + const [first, second] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(primaryIntegration._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(2); + + expect(second._id).to.equal(integration._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(1); + }); + + it('should recalculate primary and priority fields for email channel', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const primaryIntegration = await integrationRepository.create({ + name: 'primaryIntegration', + identifier: 'primaryIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: true, + priority: 3, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integrationOne = await integrationRepository.create({ + name: 'integrationOne', + identifier: 'integrationOne', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 2, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integrationTwo = await integrationRepository.create({ + name: 'integrationTwo', + identifier: 'integrationTwo', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 2, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { statusCode } = await session.testAgent.delete(`/v1/integrations/${integrationOne._id}`).send(); + expect(statusCode).to.equal(200); + + const [first, second] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(primaryIntegration._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(2); + + expect(second._id).to.equal(integrationTwo._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(1); }); it('should remove existing integration', async function () { @@ -43,7 +322,7 @@ describe('Delete Integration - /integration/:integrationId (DELETE)', function ( expect(deletedIntegration.deleted).to.equal(true); }); - it.skip('should remove a newly created integration', async function () { + it('should remove a newly created integration', async function () { const payload = { providerId: EmailProviderIdEnum.SendGrid, channel: ChannelTypeEnum.EMAIL, @@ -72,11 +351,4 @@ describe('Delete Integration - /integration/:integrationId (DELETE)', function ( expect(deletedIntegration.deleted).to.equal(true); }); - - it('fail remove none existing integration', async function () { - const dummyId = '012345678912'; - const response = await session.testAgent.delete(`/v1/integrations/${dummyId}`).send(); - - expect(response.body.message).to.contains('Could not find integration with id'); - }); }); diff --git a/apps/api/src/app/integrations/e2e/set-itegration-as-primary.e2e.ts b/apps/api/src/app/integrations/e2e/set-itegration-as-primary.e2e.ts new file mode 100644 index 00000000000..db3805efa7a --- /dev/null +++ b/apps/api/src/app/integrations/e2e/set-itegration-as-primary.e2e.ts @@ -0,0 +1,454 @@ +import { IntegrationEntity, IntegrationRepository } from '@novu/dal'; +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; +import { + ChannelTypeEnum, + ChatProviderIdEnum, + EmailProviderIdEnum, + InAppProviderIdEnum, + PushProviderIdEnum, +} from '@novu/shared'; + +const ORIGINAL_IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED; + +describe('Set Integration As Primary - /integrations/:integrationId/set-primary (POST)', function () { + let session: UserSession; + const integrationRepository = new IntegrationRepository(); + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = 'true'; + }); + + afterEach(async () => { + process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = ORIGINAL_IS_MULTI_PROVIDER_CONFIGURATION_ENABLED; + }); + + it('when integration id is not valid should throw bad request exception', async () => { + const fakeIntegrationId = 'fakeIntegrationId'; + + const { body } = await session.testAgent.post(`/v1/integrations/${fakeIntegrationId}/set-primary`).send({}); + + expect(body.statusCode).to.equal(400); + expect(body.message[0]).to.equal(`integrationId must be a mongodb id`); + }); + + it('when integration does not exist should throw not found exception', async () => { + const fakeIntegrationId = IntegrationRepository.createObjectId(); + + const { body } = await session.testAgent.post(`/v1/integrations/${fakeIntegrationId}/set-primary`).send({}); + + expect(body.statusCode).to.equal(404); + expect(body.message).to.equal(`Integration with id ${fakeIntegrationId} not found`); + }); + + it('in-app channel does not support primary flag, then for integration it should throw bad request exception', async () => { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const inAppIntegration = await integrationRepository.create({ + name: 'Novu In-App', + identifier: 'identifier1', + providerId: InAppProviderIdEnum.Novu, + channel: ChannelTypeEnum.IN_APP, + active: false, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { body } = await session.testAgent.post(`/v1/integrations/${inAppIntegration._id}/set-primary`).send({}); + + expect(body.statusCode).to.equal(400); + expect(body.message).to.equal(`Channel ${inAppIntegration.channel} does not support primary`); + }); + + it('push channel does not support primary flag, then for integration it should throw bad request exception', async () => { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const pushIntegration = await integrationRepository.create({ + name: 'FCM', + identifier: 'identifier1', + providerId: PushProviderIdEnum.FCM, + channel: ChannelTypeEnum.PUSH, + active: false, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { body } = await session.testAgent.post(`/v1/integrations/${pushIntegration._id}/set-primary`).send({}); + + expect(body.statusCode).to.equal(400); + expect(body.message).to.equal(`Channel ${pushIntegration.channel} does not support primary`); + }); + + it('chat channel does not support primary flag, then for integration it should throw bad request exception', async () => { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const chatIntegration = await integrationRepository.create({ + name: 'Slack', + identifier: 'identifier1', + providerId: ChatProviderIdEnum.Slack, + channel: ChannelTypeEnum.CHAT, + active: false, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { body } = await session.testAgent.post(`/v1/integrations/${chatIntegration._id}/set-primary`).send({}); + + expect(body.statusCode).to.equal(400); + expect(body.message).to.equal(`Channel ${chatIntegration.channel} does not support primary`); + }); + + it('should not update the primary integration if already is primary', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integrationOne = await integrationRepository.create({ + name: 'Test1', + identifier: 'identifier1', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: true, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { + body: { data }, + } = await session.testAgent.post(`/v1/integrations/${integrationOne._id}/set-primary`).send({}); + + expect(data.primary).to.equal(true); + expect(data.priority).to.equal(1); + }); + + it('should set primary and active when there are no other active integrations', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integrationOne = await integrationRepository.create({ + name: 'Test1', + identifier: 'identifier1', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { + body: { data }, + } = await session.testAgent.post(`/v1/integrations/${integrationOne._id}/set-primary`).send({}); + + expect(data.primary).to.equal(true); + expect(data.active).to.equal(true); + expect(data.priority).to.equal(1); + }); + + it('should set primary and active and update old primary', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const oldPrimaryIntegration = await integrationRepository.create({ + name: 'Test1', + identifier: 'primaryIdentifier', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: true, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integrationOne = await integrationRepository.create({ + name: 'Test1', + identifier: 'identifier1', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { + body: { data }, + } = await session.testAgent.post(`/v1/integrations/${integrationOne._id}/set-primary`).send({}); + + expect(data.primary).to.equal(true); + expect(data.active).to.equal(true); + expect(data.priority).to.equal(2); + + const updatedOldPrimary = (await integrationRepository.findById(oldPrimaryIntegration._id)) as IntegrationEntity; + + expect(updatedOldPrimary.primary).to.equal(false); + expect(updatedOldPrimary.active).to.equal(true); + expect(updatedOldPrimary.priority).to.equal(1); + }); + + it('should set primary and active and update priority for other active integrations', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const oldPrimaryIntegration = await integrationRepository.create({ + name: 'oldPrimaryIntegration', + identifier: 'oldPrimaryIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: true, + priority: 2, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const activeIntegration = await integrationRepository.create({ + name: 'activeIntegration', + identifier: 'activeIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const inactiveIntegration = await integrationRepository.create({ + name: 'inactiveIntegration', + identifier: 'inactiveIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integrationToSetPrimary = await integrationRepository.create({ + name: 'integrationToSetPrimary', + identifier: 'identifier1', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { + body: { data }, + } = await session.testAgent.post(`/v1/integrations/${integrationToSetPrimary._id}/set-primary`).send({}); + + expect(data.primary).to.equal(true); + expect(data.active).to.equal(true); + expect(data.priority).to.equal(3); + + const [first, second, third, fourth] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(data._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(3); + + expect(second._id).to.equal(oldPrimaryIntegration._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(2); + + expect(third._id).to.equal(activeIntegration._id); + expect(third.primary).to.equal(false); + expect(third.active).to.equal(true); + expect(third.priority).to.equal(1); + + expect(fourth._id).to.equal(inactiveIntegration._id); + expect(fourth.primary).to.equal(false); + expect(fourth.active).to.equal(false); + expect(fourth.priority).to.equal(0); + }); + + it('should allow set primary for active and recalculate priority for other', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const oldPrimaryIntegration = await integrationRepository.create({ + name: 'oldPrimaryIntegration', + identifier: 'oldPrimaryIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: true, + priority: 3, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const activeIntegrationOne = await integrationRepository.create({ + name: 'activeIntegrationOne', + identifier: 'activeIntegrationOne', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 2, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const activeIntegrationTwo = await integrationRepository.create({ + name: 'activeIntegrationTwo', + identifier: 'activeIntegrationTwo', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { + body: { data }, + } = await session.testAgent.post(`/v1/integrations/${activeIntegrationTwo._id}/set-primary`).send({}); + + expect(data.primary).to.equal(true); + expect(data.active).to.equal(true); + expect(data.priority).to.equal(3); + + const [first, second, third] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(activeIntegrationTwo._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(3); + + expect(second._id).to.equal(oldPrimaryIntegration._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(2); + + expect(third._id).to.equal(activeIntegrationOne._id); + expect(third.primary).to.equal(false); + expect(third.active).to.equal(true); + expect(third.priority).to.equal(1); + }); + + it('should allow to set primary and do not recalculate priority for all inactive', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const inactiveIntegrationOne = await integrationRepository.create({ + name: 'inactiveIntegrationOne', + identifier: 'inactiveIntegrationOne', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const inactiveIntegrationTwo = await integrationRepository.create({ + name: 'inactiveIntegrationTwo', + identifier: 'inactiveIntegrationTwo', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integrationToSetPrimary = await integrationRepository.create({ + name: 'integrationToSetPrimary', + identifier: 'integrationToSetPrimary', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const { + body: { data }, + } = await session.testAgent.post(`/v1/integrations/${integrationToSetPrimary._id}/set-primary`).send({}); + + expect(data.primary).to.equal(true); + expect(data.active).to.equal(true); + expect(data.priority).to.equal(1); + + const [first, second, third] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(integrationToSetPrimary._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(1); + + expect(second._id).to.equal(inactiveIntegrationOne._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(false); + expect(second.priority).to.equal(0); + + expect(third._id).to.equal(inactiveIntegrationTwo._id); + expect(third.primary).to.equal(false); + expect(third.active).to.equal(false); + expect(third.priority).to.equal(0); + }); +}); diff --git a/apps/api/src/app/integrations/e2e/update-integration.e2e.ts b/apps/api/src/app/integrations/e2e/update-integration.e2e.ts index b342790abc0..8c2c95e375c 100644 --- a/apps/api/src/app/integrations/e2e/update-integration.e2e.ts +++ b/apps/api/src/app/integrations/e2e/update-integration.e2e.ts @@ -1,7 +1,13 @@ import { EnvironmentRepository, IntegrationRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; -import { ChannelTypeEnum, EmailProviderIdEnum } from '@novu/shared'; +import { + ChannelTypeEnum, + ChatProviderIdEnum, + EmailProviderIdEnum, + InAppProviderIdEnum, + PushProviderIdEnum, +} from '@novu/shared'; const ORIGINAL_IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED; @@ -20,9 +26,25 @@ describe('Update Integration - /integrations/:integrationId (PUT)', function () process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = ORIGINAL_IS_MULTI_PROVIDER_CONFIGURATION_ENABLED; }); + it('should throw not found exception when integration is not found', async function () { + const integrationId = IntegrationRepository.createObjectId(); + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + credentials: { apiKey: 'new_key', secretKey: 'new_secret' }, + active: true, + check: false, + }; + + const { body } = await session.testAgent.put(`/v1/integrations/${integrationId}`).send(payload); + + expect(body.statusCode).to.equal(404); + expect(body.message).to.equal(`Entity with id ${integrationId} not found`); + }); + it('should update newly created integration', async function () { const payload = { - providerId: 'sendgrid', + providerId: EmailProviderIdEnum.SendGrid, channel: ChannelTypeEnum.EMAIL, credentials: { apiKey: 'new_key', secretKey: 'new_secret' }, active: true, @@ -195,4 +217,630 @@ describe('Update Integration - /integrations/:integrationId (PUT)', function () ); expect(nodeMailerIntegration?.active).to.equal(true); }); + + it('should not calculate primary and priority if active is not defined', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const emailIntegration = await integrationRepository.create({ + name: 'SendGrid', + identifier: 'identifier1', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + name: 'SendGrid Email', + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.put(`/v1/integrations/${emailIntegration._id}`).send(payload); + + expect(data.name).to.equal('SendGrid Email'); + expect(data.priority).to.equal(0); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(false); + }); + + it('should not calculate primary and priority if active not changed', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const emailIntegration = await integrationRepository.create({ + name: 'SendGrid Email', + identifier: 'identifier1', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + active: false, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.put(`/v1/integrations/${emailIntegration._id}`).send(payload); + + expect(data.priority).to.equal(0); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(false); + }); + + it('should not calculate primary and priority fields for in-app channel', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const inAppIntegration = await integrationRepository.create({ + name: 'Novu In-App', + identifier: 'identifier1', + providerId: InAppProviderIdEnum.Novu, + channel: ChannelTypeEnum.IN_APP, + active: false, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.put(`/v1/integrations/${inAppIntegration._id}`).send(payload); + + expect(data.priority).to.equal(0); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(true); + }); + + it('should not calculate primary and priority fields for push channel', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const pushIntegration = await integrationRepository.create({ + name: 'FCM', + identifier: 'identifier1', + providerId: PushProviderIdEnum.FCM, + channel: ChannelTypeEnum.PUSH, + active: false, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.put(`/v1/integrations/${pushIntegration._id}`).send(payload); + + expect(data.priority).to.equal(0); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(true); + }); + + it('should not calculate primary and priority fields for chat channel', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const chatIntegration = await integrationRepository.create({ + name: 'Slack', + identifier: 'identifier1', + providerId: ChatProviderIdEnum.Slack, + channel: ChannelTypeEnum.CHAT, + active: false, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.put(`/v1/integrations/${chatIntegration._id}`).send(payload); + + expect(data.priority).to.equal(0); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(true); + }); + + it('should set the primary if there are no other active integrations', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integration = await integrationRepository.create({ + name: 'integration', + identifier: 'integration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.put(`/v1/integrations/${integration._id}`).send(payload); + + expect(data.priority).to.equal(1); + expect(data.primary).to.equal(true); + expect(data.active).to.equal(true); + }); + + it('should set the primary if there are no other active integrations excluding Novu', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const novuEmail = await integrationRepository.create({ + name: 'novuEmail', + identifier: 'novuEmail', + providerId: EmailProviderIdEnum.Novu, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integration = await integrationRepository.create({ + name: 'integration', + identifier: 'integration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.put(`/v1/integrations/${integration._id}`).send(payload); + + expect(data.priority).to.equal(2); + expect(data.primary).to.equal(true); + expect(data.active).to.equal(true); + + const [first, second] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(integration._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(2); + + expect(second._id).to.equal(novuEmail._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(false); + expect(second.priority).to.equal(1); + }); + + it('should calculate the highest priority but not set primary if there is another active integration', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const firstActiveIntegration = await integrationRepository.create({ + name: 'firstActiveIntegration', + identifier: 'firstActiveIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const secondActiveIntegration = await integrationRepository.create({ + name: 'secondActiveIntegration', + identifier: 'secondActiveIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.put(`/v1/integrations/${secondActiveIntegration._id}`).send(payload); + + expect(data.priority).to.equal(2); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(true); + + const [first, second] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(secondActiveIntegration._id); + expect(first.primary).to.equal(false); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(2); + + expect(second._id).to.equal(firstActiveIntegration._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(1); + }); + + it('should calculate the priority but not higher than the primary integration', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const primaryIntegration = await integrationRepository.create({ + name: 'primaryIntegration', + identifier: 'primaryIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: true, + priority: 3, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const activeIntegrationOne = await integrationRepository.create({ + name: 'activeIntegrationOne', + identifier: 'activeIntegrationOne', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 2, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const activeIntegrationTwo = await integrationRepository.create({ + name: 'activeIntegrationTwo', + identifier: 'activeIntegrationTwo', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const inactiveIntegration = await integrationRepository.create({ + name: 'inactiveIntegration', + identifier: 'inactiveIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integration = await integrationRepository.create({ + name: 'integration', + identifier: 'integration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + active: true, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.put(`/v1/integrations/${integration._id}`).send(payload); + + expect(data.priority).to.equal(3); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(true); + + const [first, second, third, fourth, fifth] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1 } } + ); + + expect(first._id).to.equal(primaryIntegration._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(4); + + expect(second._id).to.equal(integration._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(3); + + expect(third._id).to.equal(activeIntegrationOne._id); + expect(third.primary).to.equal(false); + expect(third.active).to.equal(true); + expect(third.priority).to.equal(2); + + expect(fourth._id).to.equal(activeIntegrationTwo._id); + expect(fourth.primary).to.equal(false); + expect(fourth.active).to.equal(true); + expect(fourth.priority).to.equal(1); + + expect(fifth._id).to.equal(inactiveIntegration._id); + expect(fifth.primary).to.equal(false); + expect(fifth.active).to.equal(false); + expect(fifth.priority).to.equal(0); + }); + + it('should recalculate the priority when integration is deactivated', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const primaryIntegration = await integrationRepository.create({ + name: 'primaryIntegration', + identifier: 'primaryIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: true, + priority: 3, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const activeIntegrationOne = await integrationRepository.create({ + name: 'activeIntegrationOne', + identifier: 'activeIntegrationOne', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 2, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const activeIntegrationTwo = await integrationRepository.create({ + name: 'activeIntegrationTwo', + identifier: 'activeIntegrationTwo', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const inactiveIntegration = await integrationRepository.create({ + name: 'inactiveIntegration', + identifier: 'inactiveIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + active: false, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.put(`/v1/integrations/${activeIntegrationOne._id}`).send(payload); + + expect(data.priority).to.equal(0); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(false); + + const [first, second, third, fourth] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1, createdAt: -1 } } + ); + + expect(first._id).to.equal(primaryIntegration._id); + expect(first.primary).to.equal(true); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(2); + + expect(second._id).to.equal(activeIntegrationTwo._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(1); + + expect(third._id).to.equal(inactiveIntegration._id); + expect(third.primary).to.equal(false); + expect(third.active).to.equal(false); + expect(third.priority).to.equal(0); + + expect(fourth._id).to.equal(activeIntegrationOne._id); + expect(fourth.primary).to.equal(false); + expect(fourth.active).to.equal(false); + expect(fourth.priority).to.equal(0); + }); + + it('should recalculate the priority when the primary integration is deactivated', async function () { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const primaryIntegration = await integrationRepository.create({ + name: 'primaryIntegration', + identifier: 'primaryIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: true, + priority: 3, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const activeIntegrationOne = await integrationRepository.create({ + name: 'activeIntegrationOne', + identifier: 'activeIntegrationOne', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 2, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const activeIntegrationTwo = await integrationRepository.create({ + name: 'activeIntegrationTwo', + identifier: 'activeIntegrationTwo', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: true, + primary: false, + priority: 1, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const inactiveIntegration = await integrationRepository.create({ + name: 'inactiveIntegration', + identifier: 'inactiveIntegration', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + primary: false, + priority: 0, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const payload = { + active: false, + check: false, + }; + + const { + body: { data }, + } = await session.testAgent.put(`/v1/integrations/${primaryIntegration._id}`).send(payload); + + expect(data.priority).to.equal(0); + expect(data.primary).to.equal(false); + expect(data.active).to.equal(false); + + const [first, second, third, fourth] = await await integrationRepository.find( + { + _organizationId: session.organization._id, + _environmentId: session.environment._id, + channel: ChannelTypeEnum.EMAIL, + }, + undefined, + { sort: { priority: -1, createdAt: -1 } } + ); + + expect(first._id).to.equal(activeIntegrationOne._id); + expect(first.primary).to.equal(false); + expect(first.active).to.equal(true); + expect(first.priority).to.equal(2); + + expect(second._id).to.equal(activeIntegrationTwo._id); + expect(second.primary).to.equal(false); + expect(second.active).to.equal(true); + expect(second.priority).to.equal(1); + + expect(third._id).to.equal(inactiveIntegration._id); + expect(third.primary).to.equal(false); + expect(third.active).to.equal(false); + expect(third.priority).to.equal(0); + + expect(fourth._id).to.equal(primaryIntegration._id); + expect(fourth.primary).to.equal(false); + expect(fourth.active).to.equal(false); + expect(fourth.priority).to.equal(0); + }); }); diff --git a/apps/api/src/app/integrations/integrations.controller.ts b/apps/api/src/app/integrations/integrations.controller.ts index 00f79e09804..3f8b0c984ce 100644 --- a/apps/api/src/app/integrations/integrations.controller.ts +++ b/apps/api/src/app/integrations/integrations.controller.ts @@ -37,7 +37,8 @@ import { GetInAppActivated } from './usecases/get-in-app-activated/get-in-app-ac import { ApiResponse } from '../shared/framework/response.decorator'; import { ChannelTypeLimitDto } from './dtos/get-channel-type-limit.sto'; import { GetActiveIntegrationsCommand } from './usecases/get-active-integration/get-active-integration.command'; -import { GetActiveIntegrationResponseDto } from './dtos/get-active-integration-response.dto'; +import { SetIntegrationAsPrimary } from './usecases/set-integration-as-primary/set-integration-as-primary.usecase'; +import { SetIntegrationAsPrimaryCommand } from './usecases/set-integration-as-primary/set-integration-as-primary.command'; @Controller('/integrations') @UseInterceptors(ClassSerializerInterceptor) @@ -51,6 +52,7 @@ export class IntegrationsController { private getWebhookSupportStatusUsecase: GetWebhookSupportStatus, private createIntegrationUsecase: CreateIntegration, private updateIntegrationUsecase: UpdateIntegration, + private setIntegrationAsPrimaryUsecase: SetIntegrationAsPrimary, private removeIntegrationUsecase: RemoveIntegration, private calculateLimitNovuIntegration: CalculateLimitNovuIntegration ) {} @@ -87,7 +89,7 @@ export class IntegrationsController { 'Return all the active integrations the user has created for that organization. Review v.0.17.0 changelog for a breaking change', }) @ExternalApiAccessible() - async getActiveIntegrations(@UserSession() user: IJwtPayload): Promise { + async getActiveIntegrations(@UserSession() user: IJwtPayload): Promise { return await this.getActiveIntegrationsUsecase.execute( GetActiveIntegrationsCommand.create({ environmentId: user.environmentId, @@ -180,6 +182,30 @@ export class IntegrationsController { ); } + @Post('/:integrationId/set-primary') + @Roles(MemberRoleEnum.ADMIN) + @ApiResponse(IntegrationResponseDto) + @ApiNotFoundResponse({ + description: 'The integration with the integrationId provided does not exist in the database.', + }) + @ApiOperation({ + summary: 'Set integration as primary', + }) + @ExternalApiAccessible() + setIntegrationAsPrimary( + @UserSession() user: IJwtPayload, + @Param('integrationId') integrationId: string + ): Promise { + return this.setIntegrationAsPrimaryUsecase.execute( + SetIntegrationAsPrimaryCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + integrationId, + }) + ); + } + @Delete('/:integrationId') @ApiResponse(IntegrationResponseDto, 200, true) @ApiOperation({ @@ -192,6 +218,7 @@ export class IntegrationsController { ): Promise { return await this.removeIntegrationUsecase.execute( RemoveIntegrationCommand.create({ + userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, integrationId, diff --git a/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts b/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts index 2d5b8de9c17..b62b47c3d35 100644 --- a/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts +++ b/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts @@ -1,8 +1,15 @@ import { BadRequestException, ConflictException, Inject, Injectable } from '@nestjs/common'; import * as shortid from 'shortid'; import slugify from 'slugify'; -import { IntegrationEntity, IntegrationRepository, DalException } from '@novu/dal'; -import { ChannelTypeEnum, EmailProviderIdEnum, providers, SmsProviderIdEnum, InAppProviderIdEnum } from '@novu/shared'; +import { IntegrationEntity, IntegrationRepository, DalException, IntegrationQuery } from '@novu/dal'; +import { + ChannelTypeEnum, + EmailProviderIdEnum, + providers, + SmsProviderIdEnum, + InAppProviderIdEnum, + CHANNELS_WITH_PRIMARY, +} from '@novu/shared'; import { AnalyticsService, encryptCredentials, @@ -32,6 +39,49 @@ export class CreateIntegration { private disableNovuIntegration: DisableNovuIntegration ) {} + private async calculatePriorityAndPrimary(command: CreateIntegrationCommand) { + const result: { primary: boolean; priority: number } = { + primary: false, + priority: 0, + }; + + const highestPriorityIntegration = await this.integrationRepository.findHighestPriorityIntegration({ + _organizationId: command.organizationId, + _environmentId: command.environmentId, + channel: command.channel, + }); + + if (highestPriorityIntegration?.primary) { + result.priority = highestPriorityIntegration.priority; + await this.integrationRepository.update( + { + _id: highestPriorityIntegration._id, + _organizationId: command.organizationId, + _environmentId: command.environmentId, + }, + { + $set: { + priority: highestPriorityIntegration.priority + 1, + }, + } + ); + } else { + result.priority = highestPriorityIntegration ? highestPriorityIntegration.priority + 1 : 1; + } + + const activeIntegrationsCount = await this.integrationRepository.countActiveExcludingNovu({ + _organizationId: command.organizationId, + _environmentId: command.environmentId, + channel: command.channel, + }); + + if (activeIntegrationsCount === 0) { + result.primary = true; + } + + return result; + } + async execute(command: CreateIntegrationCommand): Promise { const isMultiProviderConfigurationEnabled = await this.getFeatureFlag.isMultiProviderConfigurationEnabled( FeatureFlagCommand.create({ @@ -117,7 +167,7 @@ export class CreateIntegration { const name = command.name ?? defaultName; const identifier = command.identifier ?? `${slugify(name, { lower: true, strict: true })}-${shortid.generate()}`; - const integrationEntity = await this.integrationRepository.create({ + const query: IntegrationQuery = { name, identifier, _environmentId: command.environmentId, @@ -126,7 +176,17 @@ export class CreateIntegration { channel: command.channel, credentials: encryptCredentials(command.credentials ?? {}), active: command.active, - }); + }; + + const isActiveAndChannelSupportsPrimary = command.active && CHANNELS_WITH_PRIMARY.includes(command.channel); + if (isMultiProviderConfigurationEnabled && isActiveAndChannelSupportsPrimary) { + const { primary, priority } = await this.calculatePriorityAndPrimary(command); + + query.primary = primary; + query.priority = priority; + } + + const integrationEntity = await this.integrationRepository.create(query); if ( !isMultiProviderConfigurationEnabled && diff --git a/apps/api/src/app/integrations/usecases/get-active-integration/get-active-integration.command.ts b/apps/api/src/app/integrations/usecases/get-active-integration/get-active-integration.command.ts index f22f17127ba..230385d42be 100644 --- a/apps/api/src/app/integrations/usecases/get-active-integration/get-active-integration.command.ts +++ b/apps/api/src/app/integrations/usecases/get-active-integration/get-active-integration.command.ts @@ -1,8 +1,3 @@ import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; -import { IsOptional } from 'class-validator'; -import { ProvidersIdEnum } from '@novu/shared'; -export class GetActiveIntegrationsCommand extends EnvironmentWithUserCommand { - @IsOptional() - providerId?: ProvidersIdEnum; -} +export class GetActiveIntegrationsCommand extends EnvironmentWithUserCommand {} diff --git a/apps/api/src/app/integrations/usecases/get-active-integration/get-active-integration.usecase.ts b/apps/api/src/app/integrations/usecases/get-active-integration/get-active-integration.usecase.ts index dca52eef023..9acf2eeb49a 100644 --- a/apps/api/src/app/integrations/usecases/get-active-integration/get-active-integration.usecase.ts +++ b/apps/api/src/app/integrations/usecases/get-active-integration/get-active-integration.usecase.ts @@ -1,38 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { - GetDecryptedIntegrations, - GetDecryptedIntegrationsCommand, - SelectIntegration, - SelectIntegrationCommand, - FeatureFlagCommand, - GetFeatureFlag, -} from '@novu/application-generic'; -import { ChannelTypeEnum } from '@novu/shared'; -import { EnvironmentEntity, EnvironmentRepository, IntegrationEntity, IntegrationRepository } from '@novu/dal'; - +import { GetDecryptedIntegrations, GetDecryptedIntegrationsCommand } from '@novu/application-generic'; +import { IntegrationResponseDto } from '../../dtos/integration-response.dto'; import { GetActiveIntegrationsCommand } from './get-active-integration.command'; -import { GetActiveIntegrationResponseDto } from '../../dtos/get-active-integration-response.dto'; @Injectable() export class GetActiveIntegrations { - constructor( - private integrationRepository: IntegrationRepository, - private selectIntegration: SelectIntegration, - private environmentRepository: EnvironmentRepository, - private getDecryptedIntegrationsUsecase: GetDecryptedIntegrations, - private getFeatureFlag: GetFeatureFlag - ) {} - - async execute(command: GetActiveIntegrationsCommand): Promise { - const isMultiProviderConfigurationEnabled = await this.getFeatureFlag.isMultiProviderConfigurationEnabled( - FeatureFlagCommand.create({ - environmentId: command.environmentId, - organizationId: command.organizationId, - userId: command.userId, - }) - ); + constructor(private getDecryptedIntegrationsUsecase: GetDecryptedIntegrations) {} + async execute(command: GetActiveIntegrationsCommand): Promise { const activeIntegrations = await this.getDecryptedIntegrationsUsecase.execute( GetDecryptedIntegrationsCommand.create({ organizationId: command.organizationId, @@ -46,85 +22,7 @@ export class GetActiveIntegrations { return []; } - if (!isMultiProviderConfigurationEnabled) { - return activeIntegrations; - } - - const environments = await this.environmentRepository.findOrganizationEnvironments(command.organizationId); - const activeIntegrationChannelTypes = this.getDistinctChannelTypes(activeIntegrations); - const selectedIntegrations = await this.getSelectedIntegrations( - command, - activeIntegrationChannelTypes, - environments - ); - - return this.mapBySelectedIntegration(activeIntegrations, selectedIntegrations); - } - - private getDistinctChannelTypes(activeIntegration: IntegrationEntity[]): ChannelTypeEnum[] { - return activeIntegration.map((integration) => integration.channel).filter(this.distinct); - } - - distinct = (value, index, self) => { - return self.indexOf(value) === index; - }; - - private mapBySelectedIntegration( - activeIntegration: IntegrationEntity[], - selectedIntegrations: IntegrationEntity[] - ): GetActiveIntegrationResponseDto[] { - return activeIntegration.map((integration) => { - // novu integrations doesn't have unique id that's why we need to compare by environmentId - const selected = selectedIntegrations.find( - (selectedIntegration) => - selectedIntegration._id === integration._id && - selectedIntegration._environmentId === integration._environmentId - ); - - return selected ? { ...integration, selected: true } : { ...integration, selected: false }; - }); - } - - private async getSelectedIntegrations( - command: GetActiveIntegrationsCommand, - activeIntegrationChannelTypes: ChannelTypeEnum[], - environments: EnvironmentEntity[] - ) { - const integrationPromises = this.selectIntegrationByEnvironment( - environments, - command, - activeIntegrationChannelTypes - ); - - return (await Promise.all(integrationPromises)).filter(notNullish); - } - - private selectIntegrationByEnvironment( - environments, - command: GetActiveIntegrationsCommand, - activeIntegrationChannelTypes: ChannelTypeEnum[] - ) { - return environments.flatMap((environment) => - this.selectIntegrationByChannelType(environment._id, command, activeIntegrationChannelTypes) - ); - } - - private selectIntegrationByChannelType( - environmentId, - command: GetActiveIntegrationsCommand, - activeIntegrationChannelTypes: ChannelTypeEnum[] - ) { - return activeIntegrationChannelTypes.map((channelType) => - this.selectIntegration.execute( - SelectIntegrationCommand.create({ - environmentId: environmentId, - organizationId: command.organizationId, - userId: command.userId, - channelType: channelType as ChannelTypeEnum, - providerId: command.providerId, - }) - ) - ); + return activeIntegrations; } } diff --git a/apps/api/src/app/integrations/usecases/index.ts b/apps/api/src/app/integrations/usecases/index.ts index 10ada3e6c11..d04e0dabab5 100644 --- a/apps/api/src/app/integrations/usecases/index.ts +++ b/apps/api/src/app/integrations/usecases/index.ts @@ -10,6 +10,7 @@ import { GetActiveIntegrations } from './get-active-integration/get-active-integ import { CheckIntegration } from './check-integration/check-integration.usecase'; import { CheckIntegrationEMail } from './check-integration/check-integration-email.usecase'; import { GetInAppActivated } from './get-in-app-activated/get-in-app-activated.usecase'; +import { SetIntegrationAsPrimary } from './set-integration-as-primary/set-integration-as-primary.usecase'; import { CreateNovuIntegrations } from './create-novu-integrations/create-novu-integrations.usecase'; import { DisableNovuIntegration } from './disable-novu-integration/disable-novu-integration.usecase'; @@ -27,6 +28,7 @@ export const USE_CASES = [ CheckIntegration, CheckIntegrationEMail, CalculateLimitNovuIntegration, + SetIntegrationAsPrimary, CreateNovuIntegrations, DisableNovuIntegration, ]; diff --git a/apps/api/src/app/integrations/usecases/remove-integration/remove-integration.command.ts b/apps/api/src/app/integrations/usecases/remove-integration/remove-integration.command.ts index 7348699850c..6ff4071d247 100644 --- a/apps/api/src/app/integrations/usecases/remove-integration/remove-integration.command.ts +++ b/apps/api/src/app/integrations/usecases/remove-integration/remove-integration.command.ts @@ -1,7 +1,8 @@ import { IsDefined } from 'class-validator'; -import { EnvironmentCommand } from '../../../shared/commands/project.command'; -export class RemoveIntegrationCommand extends EnvironmentCommand { +import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; + +export class RemoveIntegrationCommand extends EnvironmentWithUserCommand { @IsDefined() integrationId: string; } diff --git a/apps/api/src/app/integrations/usecases/remove-integration/remove-integration.usecase.ts b/apps/api/src/app/integrations/usecases/remove-integration/remove-integration.usecase.ts index 0747656949b..e2fdc0bc674 100644 --- a/apps/api/src/app/integrations/usecases/remove-integration/remove-integration.usecase.ts +++ b/apps/api/src/app/integrations/usecases/remove-integration/remove-integration.usecase.ts @@ -1,6 +1,12 @@ -import { Injectable, Scope } from '@nestjs/common'; +import { Injectable, NotFoundException, Scope } from '@nestjs/common'; import { IntegrationRepository, DalException } from '@novu/dal'; -import { buildIntegrationKey, InvalidateCacheService } from '@novu/application-generic'; +import { CHANNELS_WITH_PRIMARY } from '@novu/shared'; +import { + buildIntegrationKey, + FeatureFlagCommand, + GetFeatureFlag, + InvalidateCacheService, +} from '@novu/application-generic'; import { RemoveIntegrationCommand } from './remove-integration.command'; import { ApiException } from '../../../shared/exceptions/api.exception'; @@ -9,18 +15,46 @@ import { ApiException } from '../../../shared/exceptions/api.exception'; scope: Scope.REQUEST, }) export class RemoveIntegration { - constructor(private invalidateCache: InvalidateCacheService, private integrationRepository: IntegrationRepository) {} + constructor( + private invalidateCache: InvalidateCacheService, + private integrationRepository: IntegrationRepository, + private getFeatureFlag: GetFeatureFlag + ) {} async execute(command: RemoveIntegrationCommand) { try { - // TODO: We should check first if the Integration exists in the database + const existingIntegration = await this.integrationRepository.findById(command.integrationId); + if (!existingIntegration) { + throw new NotFoundException(`Entity with id ${command.integrationId} not found`); + } + await this.invalidateCache.invalidateQuery({ key: buildIntegrationKey().invalidate({ _organizationId: command.organizationId, }), }); - await this.integrationRepository.delete({ _id: command.integrationId, _organizationId: command.organizationId }); + await this.integrationRepository.delete({ + _id: existingIntegration._id, + _organizationId: existingIntegration._organizationId, + }); + + const isMultiProviderConfigurationEnabled = await this.getFeatureFlag.isMultiProviderConfigurationEnabled( + FeatureFlagCommand.create({ + userId: command.userId, + organizationId: command.organizationId, + environmentId: command.environmentId, + }) + ); + + const isChannelSupportsPrimary = CHANNELS_WITH_PRIMARY.includes(existingIntegration.channel); + if (isMultiProviderConfigurationEnabled && isChannelSupportsPrimary) { + await this.integrationRepository.recalculatePriorityForAllActive({ + _organizationId: existingIntegration._organizationId, + _environmentId: existingIntegration._environmentId, + channel: existingIntegration.channel, + }); + } } catch (e) { if (e instanceof DalException) { throw new ApiException(e.message); diff --git a/apps/api/src/app/integrations/usecases/set-integration-as-primary/set-integration-as-primary.command.ts b/apps/api/src/app/integrations/usecases/set-integration-as-primary/set-integration-as-primary.command.ts new file mode 100644 index 00000000000..baf2a28a1a7 --- /dev/null +++ b/apps/api/src/app/integrations/usecases/set-integration-as-primary/set-integration-as-primary.command.ts @@ -0,0 +1,9 @@ +import { IsDefined, IsMongoId } from 'class-validator'; + +import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; + +export class SetIntegrationAsPrimaryCommand extends EnvironmentWithUserCommand { + @IsDefined() + @IsMongoId() + integrationId: string; +} diff --git a/apps/api/src/app/integrations/usecases/set-integration-as-primary/set-integration-as-primary.usecase.ts b/apps/api/src/app/integrations/usecases/set-integration-as-primary/set-integration-as-primary.usecase.ts new file mode 100644 index 00000000000..d28ccce68c7 --- /dev/null +++ b/apps/api/src/app/integrations/usecases/set-integration-as-primary/set-integration-as-primary.usecase.ts @@ -0,0 +1,109 @@ +import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common'; +import { IntegrationEntity, IntegrationRepository } from '@novu/dal'; +import { CHANNELS_WITH_PRIMARY } from '@novu/shared'; +import { + AnalyticsService, + buildIntegrationKey, + InvalidateCacheService, + GetFeatureFlag, + FeatureFlagCommand, +} from '@novu/application-generic'; + +import { SetIntegrationAsPrimaryCommand } from './set-integration-as-primary.command'; + +@Injectable() +export class SetIntegrationAsPrimary { + constructor( + private invalidateCache: InvalidateCacheService, + private integrationRepository: IntegrationRepository, + private analyticsService: AnalyticsService, + private getFeatureFlag: GetFeatureFlag + ) {} + + private async updatePrimaryFlag({ existingIntegration }: { existingIntegration: IntegrationEntity }) { + await this.integrationRepository.update( + { + _organizationId: existingIntegration._organizationId, + _environmentId: existingIntegration._environmentId, + channel: existingIntegration.channel, + active: true, + primary: true, + }, + { + $set: { + primary: false, + }, + } + ); + + await this.integrationRepository.update( + { + _id: existingIntegration._id, + _organizationId: existingIntegration._organizationId, + _environmentId: existingIntegration._environmentId, + }, + { + $set: { + active: true, + primary: true, + }, + } + ); + } + + async execute(command: SetIntegrationAsPrimaryCommand): Promise { + Logger.verbose('Executing Set Integration As Primary Usecase'); + + const existingIntegration = await this.integrationRepository.findById(command.integrationId); + if (!existingIntegration) { + throw new NotFoundException(`Integration with id ${command.integrationId} not found`); + } + + if (!CHANNELS_WITH_PRIMARY.includes(existingIntegration.channel)) { + throw new BadRequestException(`Channel ${existingIntegration.channel} does not support primary`); + } + + const { _organizationId, _environmentId, channel, providerId } = existingIntegration; + const isMultiProviderConfigurationEnabled = await this.getFeatureFlag.isMultiProviderConfigurationEnabled( + FeatureFlagCommand.create({ + userId: command.userId, + organizationId: _organizationId, + environmentId: _environmentId, + }) + ); + if (!isMultiProviderConfigurationEnabled || existingIntegration.primary) { + return existingIntegration; + } + + this.analyticsService.track('Set Integration As Primary - [Integrations]', command.userId, { + providerId, + channel, + _organizationId, + _environmentId, + }); + + await this.invalidateCache.invalidateQuery({ + key: buildIntegrationKey().invalidate({ + _organizationId, + }), + }); + + await this.updatePrimaryFlag({ existingIntegration }); + + await this.integrationRepository.recalculatePriorityForAllActive({ + _id: existingIntegration._id, + _organizationId, + _environmentId, + channel, + }); + + const updatedIntegration = await this.integrationRepository.findOne({ + _id: command.integrationId, + _organizationId, + _environmentId, + }); + if (!updatedIntegration) throw new NotFoundException(`Integration with id ${command.integrationId} is not found`); + + return updatedIntegration; + } +} diff --git a/apps/api/src/app/integrations/usecases/update-integration/update-integration.usecase.ts b/apps/api/src/app/integrations/usecases/update-integration/update-integration.usecase.ts index fef98dd0de9..62adbb8bff8 100644 --- a/apps/api/src/app/integrations/usecases/update-integration/update-integration.usecase.ts +++ b/apps/api/src/app/integrations/usecases/update-integration/update-integration.usecase.ts @@ -8,7 +8,7 @@ import { GetFeatureFlag, FeatureFlagCommand, } from '@novu/application-generic'; -import { ChannelTypeEnum } from '@novu/shared'; +import { ChannelTypeEnum, CHANNELS_WITH_PRIMARY } from '@novu/shared'; import { UpdateIntegrationCommand } from './update-integration.command'; import { DeactivateSimilarChannelIntegrations } from '../deactivate-integration/deactivate-integration.usecase'; @@ -29,6 +29,88 @@ export class UpdateIntegration { private disableNovuIntegration: DisableNovuIntegration ) {} + private async calculatePriorityAndPrimaryForActive({ + existingIntegration, + }: { + existingIntegration: IntegrationEntity; + }) { + const result: { primary: boolean; priority: number } = { + primary: existingIntegration.primary, + priority: existingIntegration.priority, + }; + + const isChannelSupportsPrimary = CHANNELS_WITH_PRIMARY.includes(existingIntegration.channel); + + const highestPriorityIntegration = await this.integrationRepository.findHighestPriorityIntegration({ + _organizationId: existingIntegration._organizationId, + _environmentId: existingIntegration._environmentId, + channel: existingIntegration.channel, + }); + + if (highestPriorityIntegration?.primary) { + result.priority = highestPriorityIntegration.priority; + await this.integrationRepository.update( + { + _id: highestPriorityIntegration._id, + _organizationId: highestPriorityIntegration._organizationId, + _environmentId: highestPriorityIntegration._environmentId, + }, + { + $set: { + priority: highestPriorityIntegration.priority + 1, + }, + } + ); + } else { + result.priority = highestPriorityIntegration ? highestPriorityIntegration.priority + 1 : 1; + } + + const activeIntegrationsCount = await this.integrationRepository.countActiveExcludingNovu({ + _organizationId: existingIntegration._organizationId, + _environmentId: existingIntegration._environmentId, + channel: existingIntegration.channel, + }); + if (activeIntegrationsCount === 0 && isChannelSupportsPrimary) { + result.primary = true; + } + + return result; + } + + private async calculatePriorityAndPrimary({ + existingIntegration, + active, + }: { + existingIntegration: IntegrationEntity; + active: boolean; + }) { + let result: { primary: boolean; priority: number } = { + primary: existingIntegration.primary, + priority: existingIntegration.priority, + }; + + if (active) { + result = await this.calculatePriorityAndPrimaryForActive({ + existingIntegration, + }); + } else { + await this.integrationRepository.recalculatePriorityForAllActive({ + _id: existingIntegration._id, + _organizationId: existingIntegration._organizationId, + _environmentId: existingIntegration._environmentId, + channel: existingIntegration.channel, + exclude: true, + }); + + result = { + priority: 0, + primary: false, + }; + } + + return result; + } + async execute(command: UpdateIntegrationCommand): Promise { Logger.verbose('Executing Update Integration Command'); @@ -77,6 +159,8 @@ export class UpdateIntegration { } const updatePayload: Partial = {}; + const isActiveDefined = typeof command.active !== 'undefined'; + const isActiveChanged = isActiveDefined && existingIntegration.active !== command.active; if (command.name) { updatePayload.name = command.name; @@ -90,7 +174,7 @@ export class UpdateIntegration { updatePayload._environmentId = environmentId; } - if (typeof command.active !== 'undefined') { + if (isActiveDefined) { updatePayload.active = command.active; } @@ -102,6 +186,25 @@ export class UpdateIntegration { throw new BadRequestException('No properties found for update'); } + const isMultiProviderConfigurationEnabled = await this.getFeatureFlag.isMultiProviderConfigurationEnabled( + FeatureFlagCommand.create({ + userId: command.userId, + organizationId: command.organizationId, + environmentId: command.userEnvironmentId, + }) + ); + + const isChannelSupportsPrimary = CHANNELS_WITH_PRIMARY.includes(existingIntegration.channel); + if (isMultiProviderConfigurationEnabled && isActiveChanged && isChannelSupportsPrimary) { + const { primary, priority } = await this.calculatePriorityAndPrimary({ + existingIntegration, + active: !!command.active, + }); + + updatePayload.primary = primary; + updatePayload.priority = priority; + } + await this.integrationRepository.update( { _id: existingIntegration._id, @@ -112,14 +215,6 @@ export class UpdateIntegration { } ); - const isMultiProviderConfigurationEnabled = await this.getFeatureFlag.isMultiProviderConfigurationEnabled( - FeatureFlagCommand.create({ - userId: command.userId, - organizationId: command.organizationId, - environmentId: command.userEnvironmentId, - }) - ); - if ( !isMultiProviderConfigurationEnabled && command.active && diff --git a/libs/dal/src/repositories/integration/integration.entity.ts b/libs/dal/src/repositories/integration/integration.entity.ts index 963c78d29ca..afe29c4245b 100644 --- a/libs/dal/src/repositories/integration/integration.entity.ts +++ b/libs/dal/src/repositories/integration/integration.entity.ts @@ -23,6 +23,10 @@ export class IntegrationEntity { identifier: string; + priority: number; + + primary: boolean; + deleted: boolean; deletedAt: string; diff --git a/libs/dal/src/repositories/integration/integration.repository.ts b/libs/dal/src/repositories/integration/integration.repository.ts index d9a4f867548..e5a3f5d1786 100644 --- a/libs/dal/src/repositories/integration/integration.repository.ts +++ b/libs/dal/src/repositories/integration/integration.repository.ts @@ -1,5 +1,6 @@ import { FilterQuery } from 'mongoose'; import { SoftDeleteModel } from 'mongoose-delete'; +import { NOVU_PROVIDERS } from '@novu/shared'; import { IntegrationEntity, IntegrationDBModel } from './integration.entity'; import { Integration } from './integration.schema'; @@ -8,7 +9,7 @@ import { BaseRepository } from '../base-repository'; import { DalException } from '../../shared'; import type { EnforceEnvOrOrgIds, IDeleteResult } from '../../types'; -type IntegrationQuery = FilterQuery & EnforceEnvOrOrgIds; +export type IntegrationQuery = FilterQuery & EnforceEnvOrOrgIds; export class IntegrationRepository extends BaseRepository { private integration: SoftDeleteModel; @@ -32,6 +33,39 @@ export class IntegrationRepository extends BaseRepository) { + return await this.findOne( + { + _organizationId, + _environmentId, + channel, + active: true, + }, + undefined, + { query: { sort: { priority: -1 } } } + ); + } + + async countActiveExcludingNovu({ + _organizationId, + _environmentId, + channel, + }: Pick) { + return await this.count({ + _organizationId, + _environmentId, + channel, + active: true, + providerId: { + $nin: NOVU_PROVIDERS, + }, + }); + } + async create(data: IntegrationQuery): Promise { return await super.create(data); } @@ -70,4 +104,51 @@ export class IntegrationRepository extends BaseRepository & { + _id?: string; + exclude?: boolean; + }) { + const otherActiveIntegrations = await this.find( + { + _organizationId, + _environmentId, + channel, + active: true, + ...(_id && { + _id: { + $nin: [_id], + }, + }), + }, + '_id', + { sort: { priority: -1 } } + ); + + let ids = otherActiveIntegrations.map((integration) => integration._id); + if (_id) { + ids = [_id, ...otherActiveIntegrations.map((integration) => integration._id)]; + } + + const promises = ids.map((id, index) => + this.update( + { + _id: id, + _organizationId, + _environmentId, + }, + { + $set: { + priority: ids.length - index, + }, + } + ) + ); + await Promise.all(promises); + } } diff --git a/libs/dal/src/repositories/integration/integration.schema.ts b/libs/dal/src/repositories/integration/integration.schema.ts index 80473fd312a..e76494438c6 100644 --- a/libs/dal/src/repositories/integration/integration.schema.ts +++ b/libs/dal/src/repositories/integration/integration.schema.ts @@ -52,6 +52,14 @@ const integrationSchema = new Schema( }, name: Schema.Types.String, identifier: Schema.Types.String, + priority: { + type: Schema.Types.Number, + default: 0, + }, + primary: { + type: Schema.Types.Boolean, + default: false, + }, }, schemaOptions ); diff --git a/libs/testing/src/integration.service.ts b/libs/testing/src/integration.service.ts index ed510a573ea..eacd28f4a81 100644 --- a/libs/testing/src/integration.service.ts +++ b/libs/testing/src/integration.service.ts @@ -109,6 +109,8 @@ export class IntegrationService { channel: ChannelTypeEnum.EMAIL, credentials: { apiKey: 'SG.123', secretKey: 'abc' }, active: true, + primary: true, + priority: 1, }; await this.integrationRepository.create(mailPayload); @@ -120,6 +122,8 @@ export class IntegrationService { channel: ChannelTypeEnum.SMS, credentials: { accountSid: 'AC123', token: '123', from: 'me' }, active: true, + primary: true, + priority: 1, }; await this.integrationRepository.create(smsPayload); diff --git a/packages/application-generic/src/usecases/select-integration/select-integration.spec.ts b/packages/application-generic/src/usecases/select-integration/select-integration.spec.ts index ea71841c6f2..10c19cf07c2 100644 --- a/packages/application-generic/src/usecases/select-integration/select-integration.spec.ts +++ b/packages/application-generic/src/usecases/select-integration/select-integration.spec.ts @@ -36,6 +36,8 @@ const testIntegration: IntegrationEntity = { deleted: false, identifier: 'test-integration-identifier', name: 'test-integration-name', + primary: true, + priority: 1, deletedAt: null, deletedBy: null, }; @@ -51,6 +53,8 @@ const novuIntegration: IntegrationEntity = { deleted: false, identifier: 'test-novu-integration-identifier', name: 'test-novu-integration-name', + primary: true, + priority: 1, deletedAt: null, deletedBy: null, }; @@ -90,6 +94,7 @@ describe('select integration', function () { // @ts-ignore new GetDecryptedIntegrations() ); + jest.clearAllMocks(); }); it('should select the integration', async function () { @@ -121,4 +126,47 @@ describe('select integration', function () { expect(integration).not.toBeNull(); expect(integration?.providerId).toEqual(EmailProviderIdEnum.Novu); }); + + it.each` + channel | shouldUsePrimary + ${ChannelTypeEnum.PUSH} | ${false} + ${ChannelTypeEnum.CHAT} | ${false} + ${ChannelTypeEnum.IN_APP} | ${false} + ${ChannelTypeEnum.EMAIL} | ${true} + ${ChannelTypeEnum.SMS} | ${true} + `( + 'for channel $channel it should select integration by primary: $shouldUsePrimary', + async ({ channel, shouldUsePrimary }) => { + const environmentId = 'environmentId'; + const organizationId = 'organizationId'; + const userId = 'userId'; + findOneMock.mockImplementation(() => ({ + ...testIntegration, + channel, + })); + + const integration = await useCase.execute( + SelectIntegrationCommand.create({ + channelType: channel, + environmentId, + organizationId, + userId, + }) + ); + + expect(findOneMock).toHaveBeenCalledWith( + { + _organizationId: organizationId, + _environmentId: environmentId, + channel, + active: true, + ...(shouldUsePrimary && { + primary: true, + }), + }, + undefined, + { query: { sort: { createdAt: -1 } } } + ); + } + ); }); diff --git a/packages/application-generic/src/usecases/select-integration/select-integration.usecase.ts b/packages/application-generic/src/usecases/select-integration/select-integration.usecase.ts index 3bb7f1b725c..234e72926b1 100644 --- a/packages/application-generic/src/usecases/select-integration/select-integration.usecase.ts +++ b/packages/application-generic/src/usecases/select-integration/select-integration.usecase.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { IntegrationEntity, IntegrationRepository } from '@novu/dal'; +import { CHANNELS_WITH_PRIMARY } from '@novu/shared'; import { SelectIntegrationCommand } from './select-integration.command'; import { buildIntegrationKey, CachedQuery } from '../../services'; @@ -51,6 +52,10 @@ export class SelectIntegration { return integrations[0]; } + const isChannelSupportsPrimary = CHANNELS_WITH_PRIMARY.includes( + command.channelType + ); + let query: Partial & { _organizationId: string } = { ...(command.id ? { id: command.id } : {}), _organizationId: command.organizationId, @@ -58,6 +63,9 @@ export class SelectIntegration { channel: command.channelType, ...(command.providerId ? { providerId: command.providerId } : {}), active: true, + ...(isChannelSupportsPrimary && { + primary: true, + }), }; if (command.identifier) {