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 091602705d4..9fcc3c6e593 100644 --- a/apps/api/src/app/events/e2e/trigger-event.e2e.ts +++ b/apps/api/src/app/events/e2e/trigger-event.e2e.ts @@ -34,6 +34,7 @@ import { MESSAGE_GENERIC_RETENTION_DAYS, } from '@novu/shared'; import { EmailEventStatusEnum } from '@novu/stateless'; +import { createTenant } from '../../tenant/e2e/create-tenant.e2e'; const axiosInstance = axios.create(); @@ -65,6 +66,129 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { subscriber = await subscriberService.createSubscriber(); }); + it('should use conditions to select integration', async function () { + const payload = { + providerId: EmailProviderIdEnum.Mailgun, + channel: 'email', + credentials: { apiKey: '123', secretKey: 'abc' }, + _environmentId: session.environment._id, + conditions: [ + { + children: [{ field: 'identifier', value: 'test', operator: 'EQUAL', on: 'tenant' }], + }, + ], + active: true, + check: false, + }; + + await session.testAgent.post('/v1/integrations').send(payload); + + template = await createTemplate(session, ChannelTypeEnum.EMAIL); + + await createTenant({ session, identifier: 'test', name: 'test' }); + + await sendTrigger(session, template, subscriber.subscriberId, {}, {}, 'test'); + + await session.awaitRunningJobs(template._id); + + const createdSubscriber = await subscriberRepository.findBySubscriberId( + session.environment._id, + subscriber.subscriberId + ); + + const message = await messageRepository.findOne({ + _environmentId: session.environment._id, + _subscriberId: createdSubscriber?._id, + channel: ChannelTypeEnum.EMAIL, + }); + + expect(message?.providerId).to.equal(payload.providerId); + }); + + it('should use or conditions to select integration', async function () { + const payload = { + providerId: EmailProviderIdEnum.Mailgun, + channel: 'email', + credentials: { apiKey: '123', secretKey: 'abc' }, + _environmentId: session.environment._id, + conditions: [ + { + value: 'OR', + children: [ + { field: 'identifier', value: 'test3', operator: 'EQUAL', on: 'tenant' }, + { field: 'identifier', value: 'test2', operator: 'EQUAL', on: 'tenant' }, + ], + }, + ], + active: true, + check: false, + }; + + await session.testAgent.post('/v1/integrations').send(payload); + + template = await createTemplate(session, ChannelTypeEnum.EMAIL); + + await createTenant({ session, identifier: 'test3', name: 'test3' }); + await createTenant({ session, identifier: 'test2', name: 'test2' }); + + await sendTrigger(session, template, subscriber.subscriberId, {}, {}, 'test3'); + + await session.awaitRunningJobs(template._id); + + const createdSubscriber = await subscriberRepository.findBySubscriberId( + session.environment._id, + subscriber.subscriberId + ); + + const firstMessage = await messageRepository.findOne({ + _environmentId: session.environment._id, + _subscriberId: createdSubscriber?._id, + channel: ChannelTypeEnum.EMAIL, + }); + + expect(firstMessage?.providerId).to.equal(payload.providerId); + + await sendTrigger(session, template, subscriber.subscriberId, {}, {}, 'test2'); + + await session.awaitRunningJobs(template._id); + + const secondMessage = await messageRepository.findOne({ + _environmentId: session.environment._id, + _subscriberId: createdSubscriber?._id, + channel: ChannelTypeEnum.EMAIL, + _id: { + $ne: firstMessage?._id, + }, + }); + + expect(secondMessage?.providerId).to.equal(payload.providerId); + expect(firstMessage?._id).to.not.equal(secondMessage?._id); + }); + + it('should return correct status when using a non existing tenant', async function () { + const payload = { + providerId: EmailProviderIdEnum.Mailgun, + channel: 'email', + credentials: { apiKey: '123', secretKey: 'abc' }, + _environmentId: session.environment._id, + conditions: [ + { + children: [{ field: 'identifier', value: 'test1', operator: 'EQUAL', on: 'tenant' }], + }, + ], + active: true, + check: false, + }; + + await session.testAgent.post('/v1/integrations').send(payload); + + template = await createTemplate(session, ChannelTypeEnum.EMAIL); + + const result = await sendTrigger(session, template, subscriber.subscriberId, {}, {}, 'test1'); + + expect(result.data.data.status).to.equal('no_tenant_found'); + }); + it('should trigger an event successfully', async function () { const response = await axiosInstance.post( `${session.serverUrl}${eventTriggerPath}`, @@ -1848,7 +1972,8 @@ export async function sendTrigger( template, newSubscriberIdInAppNotification: string, payload: Record = {}, - overrides: Record = {} + overrides: Record = {}, + tenant?: string ): Promise { return await axiosInstance.post( `${session.serverUrl}${eventTriggerPath}`, @@ -1861,6 +1986,7 @@ export async function sendTrigger( ...payload, }, overrides, + tenant, }, { headers: { diff --git a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts index ec3655cc283..78cefae31d0 100644 --- a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts +++ b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts @@ -11,7 +11,7 @@ import { StorageHelperService, WorkflowQueueService, } from '@novu/application-generic'; -import { NotificationTemplateRepository, NotificationTemplateEntity } from '@novu/dal'; +import { NotificationTemplateRepository, NotificationTemplateEntity, TenantRepository } from '@novu/dal'; import { ISubscribersDefine, ITenantDefine, @@ -35,7 +35,8 @@ export class ParseEventRequest { private verifyPayload: VerifyPayload, private storageHelperService: StorageHelperService, private workflowQueueService: WorkflowQueueService, - private mapTriggerRecipients: MapTriggerRecipients + private mapTriggerRecipients: MapTriggerRecipients, + private tenantRepository: TenantRepository ) {} @InstrumentUsecase() @@ -90,6 +91,17 @@ export class ParseEventRequest { }; } + if (command.tenant) { + try { + await this.validateTenant(typeof command.tenant === 'string' ? command.tenant : command.tenant.identifier); + } catch (e) { + return { + acknowledged: true, + status: 'no_tenant_found', + }; + } + } + Sentry.addBreadcrumb({ message: 'Sending trigger', data: { @@ -146,6 +158,15 @@ export class ParseEventRequest { ); } + private async validateTenant(identifier: string) { + const found = await this.tenantRepository.findOne({ + identifier, + }); + if (!found) { + throw new ApiException(`Tenant with identifier ${identifier} cound not be found`); + } + } + @Instrument() private async validateSubscriberIdProperty(to: ISubscribersDefine[]): Promise { for (const subscriber of to) { diff --git a/apps/api/src/app/integrations/dtos/create-integration-request.dto.ts b/apps/api/src/app/integrations/dtos/create-integration-request.dto.ts index 8e1bb5aba3d..324735cb258 100644 --- a/apps/api/src/app/integrations/dtos/create-integration-request.dto.ts +++ b/apps/api/src/app/integrations/dtos/create-integration-request.dto.ts @@ -1,9 +1,19 @@ -import { IsBoolean, IsDefined, IsEnum, IsMongoId, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsDefined, + IsEnum, + IsMongoId, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ChannelTypeEnum, ICreateIntegrationBodyDto } from '@novu/shared'; import { CredentialsDto } from './credentials.dto'; +import { StepFilter } from '../../shared/dtos/step-filter'; export class CreateIntegrationRequestDto implements ICreateIntegrationBodyDto { @ApiPropertyOptional({ type: String }) @@ -53,4 +63,12 @@ export class CreateIntegrationRequestDto implements ICreateIntegrationBodyDto { @IsOptional() @IsBoolean() check?: boolean; + + @ApiPropertyOptional({ + type: [StepFilter], + }) + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + conditions?: StepFilter[]; } diff --git a/apps/api/src/app/integrations/dtos/integration-response.dto.ts b/apps/api/src/app/integrations/dtos/integration-response.dto.ts index a3a4551a387..5c3e0fe95c1 100644 --- a/apps/api/src/app/integrations/dtos/integration-response.dto.ts +++ b/apps/api/src/app/integrations/dtos/integration-response.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ChannelTypeEnum } from '@novu/shared'; +import { StepFilter } from '../../shared/dtos/step-filter'; import { CredentialsDto } from './credentials.dto'; export class IntegrationResponseDto { @@ -45,4 +46,9 @@ export class IntegrationResponseDto { @ApiProperty() primary: boolean; + + @ApiPropertyOptional({ + type: [StepFilter], + }) + conditions?: StepFilter[]; } diff --git a/apps/api/src/app/integrations/dtos/update-integration.dto.ts b/apps/api/src/app/integrations/dtos/update-integration.dto.ts index bdb7ee1a052..4ccc2b59beb 100644 --- a/apps/api/src/app/integrations/dtos/update-integration.dto.ts +++ b/apps/api/src/app/integrations/dtos/update-integration.dto.ts @@ -1,8 +1,9 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IUpdateIntegrationBodyDto } from '@novu/shared'; -import { IsBoolean, IsMongoId, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsArray, IsBoolean, IsMongoId, IsOptional, IsString, ValidateNested } from 'class-validator'; import { CredentialsDto } from './credentials.dto'; import { Type } from 'class-transformer'; +import { StepFilter } from '../../shared/dtos/step-filter'; export class UpdateIntegrationRequestDto implements IUpdateIntegrationBodyDto { @ApiPropertyOptional({ type: String }) @@ -40,4 +41,12 @@ export class UpdateIntegrationRequestDto implements IUpdateIntegrationBodyDto { @IsOptional() @IsBoolean() check?: boolean; + + @ApiPropertyOptional({ + type: [StepFilter], + }) + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + conditions?: StepFilter[]; } 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 646d2419949..5d3399c3111 100644 --- a/apps/api/src/app/integrations/e2e/create-integration.e2e.ts +++ b/apps/api/src/app/integrations/e2e/create-integration.e2e.ts @@ -104,6 +104,50 @@ describe('Create Integration - /integration (POST)', function () { } }); + it('should create integration with conditions', async function () { + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + identifier: 'identifier-conditions', + active: false, + check: false, + conditions: [ + { + children: [{ field: 'identifier', value: 'test', operator: 'EQUAL', on: 'tenant' }], + }, + ], + }; + + const { body } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(body.data.conditions.length).to.equal(1); + expect(body.data.conditions[0].children.length).to.equal(1); + expect(body.data.conditions[0].children[0].on).to.equal('tenant'); + expect(body.data.conditions[0].children[0].field).to.equal('identifier'); + expect(body.data.conditions[0].children[0].value).to.equal('test'); + expect(body.data.conditions[0].children[0].operator).to.equal('EQUAL'); + }); + + it('should return error with malformed conditions', async function () { + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + identifier: 'identifier-conditions', + active: false, + check: false, + conditions: [ + { + children: 'test', + }, + ], + }; + + const { body } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(body.statusCode).to.equal(400); + expect(body.error).to.equal('Bad Request'); + }); + it('should not allow to create integration with same identifier', async function () { const payload = { providerId: EmailProviderIdEnum.SendGrid, 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 index db3805efa7a..1e32cde3ee0 100644 --- 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 @@ -65,6 +65,34 @@ describe('Set Integration As Primary - /integrations/:integrationId/set-primary expect(body.message).to.equal(`Channel ${inAppIntegration.channel} does not support primary`); }); + it('clears conditions when set as primary', async () => { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integration = await integrationRepository.create({ + name: 'Email with conditions', + identifier: 'identifier1', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + conditions: [{}], + }); + + await session.testAgent.post(`/v1/integrations/${integration._id}/set-primary`).send({}); + + const found = await integrationRepository.findOne({ + _id: integration._id, + _organizationId: session.organization._id, + }); + + expect(found?.conditions).to.deep.equal([]); + expect(found?.primary).to.equal(true); + }); + 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, 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 45768f84476..4cbc2ec7c42 100644 --- a/apps/api/src/app/integrations/e2e/update-integration.e2e.ts +++ b/apps/api/src/app/integrations/e2e/update-integration.e2e.ts @@ -6,6 +6,7 @@ import { ChatProviderIdEnum, EmailProviderIdEnum, InAppProviderIdEnum, + ITenantFilterPart, PushProviderIdEnum, } from '@novu/shared'; @@ -66,6 +67,68 @@ describe('Update Integration - /integrations/:integrationId (PUT)', function () expect(integration.credentials.secretKey).to.equal(payload.credentials.secretKey); }); + it('should update conditions on integration', async function () { + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + credentials: { apiKey: 'SG.123', secretKey: 'abc' }, + active: true, + check: false, + conditions: [ + { + children: [{ field: 'identifier', value: 'test', operator: 'EQUAL', on: 'tenant' }], + }, + ], + }; + + const data = (await session.testAgent.get(`/v1/integrations`)).body.data; + + const integration = data.find((i) => i.primary && i.channel === 'email'); + + expect(integration.conditions.length).to.equal(0); + + await session.testAgent.put(`/v1/integrations/${integration._id}`).send(payload); + + const result = await integrationRepository.findOne({ + _id: integration._id, + _organizationId: session.organization._id, + }); + + expect(result?.conditions?.length).to.equal(1); + expect(result?.primary).to.equal(false); + expect(result?.conditions?.at(0)?.children.length).to.equal(1); + expect(result?.conditions?.at(0)?.children.at(0)?.on).to.equal('tenant'); + expect((result?.conditions?.at(0)?.children.at(0) as ITenantFilterPart)?.field).to.equal('identifier'); + expect((result?.conditions?.at(0)?.children.at(0) as ITenantFilterPart)?.value).to.equal('test'); + expect((result?.conditions?.at(0)?.children.at(0) as ITenantFilterPart)?.operator).to.equal('EQUAL'); + }); + + it('should return error with malformed conditions', async function () { + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + credentials: { apiKey: 'SG.123', secretKey: 'abc' }, + active: true, + check: false, + conditions: [ + { + children: 'test', + }, + ], + }; + + const data = (await session.testAgent.get(`/v1/integrations`)).body.data; + + const integration = data.find((i) => i.primary && i.channel === 'email'); + + expect(integration.conditions.length).to.equal(0); + + const { body } = await session.testAgent.put(`/v1/integrations/${integration._id}`).send(payload); + + expect(body.statusCode).to.equal(400); + expect(body.error).to.equal('Bad Request'); + }); + it('should not allow to update the integration with same identifier', async function () { const identifier2 = 'identifier2'; const integrationOne = await integrationRepository.create({ diff --git a/apps/api/src/app/integrations/integrations.controller.ts b/apps/api/src/app/integrations/integrations.controller.ts index 3f8b0c984ce..0b3ecb9043a 100644 --- a/apps/api/src/app/integrations/integrations.controller.ts +++ b/apps/api/src/app/integrations/integrations.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, ClassSerializerInterceptor, Controller, @@ -135,20 +136,29 @@ export class IntegrationsController { @UserSession() user: IJwtPayload, @Body() body: CreateIntegrationRequestDto ): Promise { - return await this.createIntegrationUsecase.execute( - CreateIntegrationCommand.create({ - userId: user._id, - name: body.name, - identifier: body.identifier, - environmentId: body._environmentId ?? user.environmentId, - organizationId: user.organizationId, - providerId: body.providerId, - channel: body.channel, - credentials: body.credentials, - active: body.active ?? false, - check: body.check ?? true, - }) - ); + try { + return await this.createIntegrationUsecase.execute( + CreateIntegrationCommand.create({ + userId: user._id, + name: body.name, + identifier: body.identifier, + environmentId: body._environmentId ?? user.environmentId, + organizationId: user.organizationId, + providerId: body.providerId, + channel: body.channel, + credentials: body.credentials, + active: body.active ?? false, + check: body.check ?? true, + conditions: body.conditions, + }) + ); + } catch (e) { + if (e.message.includes('Integration validation failed') || e.message.includes('Cast to embedded')) { + throw new BadRequestException(e.message); + } + + throw e; + } } @Put('/:integrationId') @@ -161,25 +171,34 @@ export class IntegrationsController { summary: 'Update integration', }) @ExternalApiAccessible() - updateIntegrationById( + async updateIntegrationById( @UserSession() user: IJwtPayload, @Param('integrationId') integrationId: string, @Body() body: UpdateIntegrationRequestDto ): Promise { - return this.updateIntegrationUsecase.execute( - UpdateIntegrationCommand.create({ - userId: user._id, - name: body.name, - identifier: body.identifier, - environmentId: body._environmentId, - userEnvironmentId: user.environmentId, - organizationId: user.organizationId, - integrationId, - credentials: body.credentials, - active: body.active, - check: body.check ?? true, - }) - ); + try { + return await this.updateIntegrationUsecase.execute( + UpdateIntegrationCommand.create({ + userId: user._id, + name: body.name, + identifier: body.identifier, + environmentId: body._environmentId, + userEnvironmentId: user.environmentId, + organizationId: user.organizationId, + integrationId, + credentials: body.credentials, + active: body.active, + check: body.check ?? true, + conditions: body.conditions, + }) + ); + } catch (e) { + if (e.message.includes('Integration validation failed') || e.message.includes('Cast to embedded')) { + throw new BadRequestException(e.message); + } + + throw e; + } } @Post('/:integrationId/set-primary') diff --git a/apps/api/src/app/integrations/usecases/create-integration/create-integration.command.ts b/apps/api/src/app/integrations/usecases/create-integration/create-integration.command.ts index 90c00c11105..d425a29ea21 100644 --- a/apps/api/src/app/integrations/usecases/create-integration/create-integration.command.ts +++ b/apps/api/src/app/integrations/usecases/create-integration/create-integration.command.ts @@ -1,7 +1,8 @@ -import { IsDefined, IsEnum, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsDefined, IsEnum, IsOptional, IsString, ValidateNested } from 'class-validator'; import { ChannelTypeEnum, ICredentialsDto } from '@novu/shared'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; +import { MessageFilter } from '../../../workflows/usecases/create-notification-template'; export class CreateIntegrationCommand extends EnvironmentCommand { @IsOptional() @@ -31,4 +32,9 @@ export class CreateIntegrationCommand extends EnvironmentCommand { @IsDefined() userId: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + conditions?: MessageFilter[]; } 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 6d8163ce6dc..73d2893d3d3 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 @@ -69,6 +69,10 @@ export class CreateIntegration { result.priority = highestPriorityIntegration ? highestPriorityIntegration.priority + 1 : 1; } + if (command.conditions && command.conditions.length > 0) { + return result; + } + const activeIntegrationsCount = await this.integrationRepository.countActiveExcludingNovu({ _organizationId: command.organizationId, _environmentId: command.environmentId, @@ -176,6 +180,7 @@ export class CreateIntegration { channel: command.channel, credentials: encryptCredentials(command.credentials ?? {}), active: command.active, + conditions: command.conditions, }; const isActiveAndChannelSupportsPrimary = command.active && CHANNELS_WITH_PRIMARY.includes(command.channel); diff --git a/apps/api/src/app/integrations/usecases/index.ts b/apps/api/src/app/integrations/usecases/index.ts index d04e0dabab5..3a236504c10 100644 --- a/apps/api/src/app/integrations/usecases/index.ts +++ b/apps/api/src/app/integrations/usecases/index.ts @@ -1,4 +1,9 @@ -import { SelectIntegration, GetDecryptedIntegrations, CalculateLimitNovuIntegration } from '@novu/application-generic'; +import { + SelectIntegration, + GetDecryptedIntegrations, + CalculateLimitNovuIntegration, + ConditionsFilter, +} from '@novu/application-generic'; import { GetWebhookSupportStatus } from './get-webhook-support-status/get-webhook-support-status.usecase'; import { CreateIntegration } from './create-integration/create-integration.usecase'; @@ -18,6 +23,7 @@ export const USE_CASES = [ GetInAppActivated, GetWebhookSupportStatus, CreateIntegration, + ConditionsFilter, GetIntegrations, GetActiveIntegrations, SelectIntegration, 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 index 3643c39f5b3..b6cf7c877a0 100644 --- 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 @@ -46,6 +46,7 @@ export class SetIntegrationAsPrimary { $set: { active: true, primary: true, + conditions: [], }, } ); diff --git a/apps/api/src/app/integrations/usecases/update-integration/update-integration.command.ts b/apps/api/src/app/integrations/usecases/update-integration/update-integration.command.ts index 69211eebaa2..9b8d2a2db38 100644 --- a/apps/api/src/app/integrations/usecases/update-integration/update-integration.command.ts +++ b/apps/api/src/app/integrations/usecases/update-integration/update-integration.command.ts @@ -1,7 +1,9 @@ -import { IsDefined, IsMongoId, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsDefined, IsMongoId, IsOptional, IsString, ValidateNested } from 'class-validator'; import { ICredentialsDto } from '@novu/shared'; import { OrganizationCommand } from '../../../shared/commands/organization.command'; +import { StepFilter } from '../../../shared/dtos/step-filter'; +import { MessageFilter } from '../../../workflows/usecases/create-notification-template'; export class UpdateIntegrationCommand extends OrganizationCommand { @IsOptional() @@ -31,4 +33,9 @@ export class UpdateIntegrationCommand extends OrganizationCommand { @IsOptional() check?: boolean; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + conditions?: MessageFilter[]; } 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 d8c1016f505..637aed8881c 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 @@ -31,8 +31,10 @@ export class UpdateIntegration { private async calculatePriorityAndPrimaryForActive({ existingIntegration, + conditionsApplied = false, }: { existingIntegration: IntegrationEntity; + conditionsApplied?: boolean; }) { const result: { primary: boolean; priority: number } = { primary: existingIntegration.primary, @@ -74,15 +76,21 @@ export class UpdateIntegration { result.primary = true; } + if (conditionsApplied) { + result.primary = false; + } + return result; } private async calculatePriorityAndPrimary({ existingIntegration, active, + conditionsApplied = false, }: { existingIntegration: IntegrationEntity; active: boolean; + conditionsApplied?: boolean; }) { let result: { primary: boolean; priority: number } = { primary: existingIntegration.primary, @@ -92,6 +100,7 @@ export class UpdateIntegration { if (active) { result = await this.calculatePriorityAndPrimaryForActive({ existingIntegration, + conditionsApplied, }); } else { await this.integrationRepository.recalculatePriorityForAllActive({ @@ -182,6 +191,10 @@ export class UpdateIntegration { updatePayload.credentials = encryptCredentials(command.credentials); } + if (command.conditions) { + updatePayload.conditions = command.conditions; + } + if (!Object.keys(updatePayload).length) { throw new BadRequestException('No properties found for update'); } @@ -194,6 +207,8 @@ export class UpdateIntegration { }) ); + const haveConditions = updatePayload.conditions && updatePayload.conditions?.length > 0; + const isChannelSupportsPrimary = CHANNELS_WITH_PRIMARY.includes(existingIntegration.channel); if (isMultiProviderConfigurationEnabled && isActiveChanged && isChannelSupportsPrimary) { if (command.active) { @@ -209,12 +224,19 @@ export class UpdateIntegration { const { primary, priority } = await this.calculatePriorityAndPrimary({ existingIntegration, active: !!command.active, + conditionsApplied: haveConditions, }); updatePayload.primary = primary; updatePayload.priority = priority; } + const shouldRemovePrimary = haveConditions && existingIntegration.primary; + + if (shouldRemovePrimary) { + updatePayload.primary = false; + } + await this.integrationRepository.update( { _id: existingIntegration._id, @@ -225,6 +247,15 @@ export class UpdateIntegration { } ); + if (shouldRemovePrimary) { + await this.integrationRepository.recalculatePriorityForAllActive({ + _id: existingIntegration._id, + _organizationId: existingIntegration._organizationId, + _environmentId: existingIntegration._organizationId, + channel: existingIntegration.channel, + }); + } + if ( !isMultiProviderConfigurationEnabled && command.active && diff --git a/apps/api/src/app/shared/dtos/step-filter.ts b/apps/api/src/app/shared/dtos/step-filter.ts index 449b5c9d8a7..cd06682db44 100644 --- a/apps/api/src/app/shared/dtos/step-filter.ts +++ b/apps/api/src/app/shared/dtos/step-filter.ts @@ -97,12 +97,21 @@ class PreviousStepFilterPart extends BaseFilterPart { stepType: PreviousStepTypeEnum; } +class TenantFilterPart extends BaseFieldFilterPart { + @ApiProperty({ + enum: [FilterPartTypeEnum.TENANT], + description: 'Only on integrations right now', + }) + on: FilterPartTypeEnum.TENANT; +} + type FilterParts = | FieldFilterPart | WebhookFilterPart | RealtimeOnlineFilterPart | OnlineInLastFilterPart - | PreviousStepFilterPart; + | PreviousStepFilterPart + | TenantFilterPart; export class StepFilter { @ApiProperty() @@ -125,6 +134,7 @@ export class StepFilter { RealtimeOnlineFilterPart, OnlineInLastFilterPart, PreviousStepFilterPart, + TenantFilterPart, ], }) children: FilterParts[]; diff --git a/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.usecase.ts b/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.usecase.ts index 2db361686c3..80027dabef8 100644 --- a/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.usecase.ts +++ b/apps/api/src/app/widgets/usecases/initialize-session/initialize-session.usecase.ts @@ -42,6 +42,7 @@ export class InitializeSession { userId: command.subscriberId, channelType: ChannelTypeEnum.IN_APP, providerId: InAppProviderIdEnum.Novu, + filterData: {}, }) ); diff --git a/apps/web/cypress/tests/integrations-list-page.spec.ts b/apps/web/cypress/tests/integrations-list-page.spec.ts index f90fbd0a27e..0f59121bbb8 100644 --- a/apps/web/cypress/tests/integrations-list-page.spec.ts +++ b/apps/web/cypress/tests/integrations-list-page.spec.ts @@ -30,6 +30,21 @@ describe('Integrations List Page', function () { .as('session'); }); + const interceptIntegrationRequests = () => { + cy.intercept('GET', '*/integrations', async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }).as('getIntegrations'); + cy.intercept('POST', '*/integrations', async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }).as('createIntegration'); + cy.intercept('*/environments').as('getEnvironments'); + + cy.visit('/integrations'); + + cy.wait('@getIntegrations'); + cy.wait('@getEnvironments'); + }; + const checkTableLoading = () => { cy.getByTestId('integration-name-cell-loading').should('have.length', 10).first().should('be.visible'); cy.getByTestId('integration-provider-cell-loading').should('have.length', 10).first().should('be.visible'); @@ -46,6 +61,7 @@ describe('Integrations List Page', function () { channel, environment, status, + conditions, }: { name: string; isFree?: boolean; @@ -53,6 +69,7 @@ describe('Integrations List Page', function () { channel: string; environment?: string; status: string; + conditions?: number; }, nth: number ) => { @@ -95,6 +112,14 @@ describe('Integrations List Page', function () { .should('be.visible') .contains(environment); } + if (conditions) { + cy.get('@integrations-table') + .get('tr') + .eq(nth) + .getByTestId('integration-conditions-cell') + .should('be.visible') + .contains(conditions); + } cy.get('@integrations-table') .get('tr') @@ -398,7 +423,7 @@ describe('Integrations List Page', function () { cy.getByTestId('select-provider-sidebar-next').should('be.disabled').contains('Next'); }); - it('should show emply search results', () => { + it('should show empty search results', () => { cy.intercept('*/integrations', async () => { await new Promise((resolve) => setTimeout(resolve, 1000)); }).as('getIntegrations'); @@ -535,18 +560,39 @@ describe('Integrations List Page', function () { }); it('should create a new mailjet integration', () => { - cy.intercept('GET', '*/integrations', async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - }).as('getIntegrations'); - cy.intercept('POST', '*/integrations', async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - }).as('createIntegration'); - cy.intercept('*/environments').as('getEnvironments'); + interceptIntegrationRequests(); - cy.visit('/integrations'); + cy.getByTestId('add-provider').should('be.enabled').click(); + cy.location('pathname').should('equal', '/integrations/create'); + cy.getByTestId('select-provider-sidebar').should('be.visible'); - cy.wait('@getIntegrations'); - cy.wait('@getEnvironments'); + cy.getByTestId(`provider-${EmailProviderIdEnum.Mailjet}`).contains('Mailjet').click(); + cy.getByTestId('select-provider-sidebar-next').should('not.be.disabled').contains('Next').click(); + + cy.location('pathname').should('equal', '/integrations/create/email/mailjet'); + cy.getByTestId('provider-instance-name').clear().type('Mailjet Integration'); + cy.getByTestId('create-provider-instance-sidebar-create').should('not.be.disabled').contains('Create').click(); + cy.getByTestId('create-provider-instance-sidebar-create').should('be.disabled'); + + cy.wait('@createIntegration'); + + cy.getByTestId('update-provider-sidebar').should('be.visible'); + cy.location('pathname').should('contain', '/integrations/'); + + checkTableRow( + { + name: 'Mailjet Integration', + provider: 'Mailjet', + channel: 'Email', + environment: 'Development', + status: 'Disabled', + }, + 6 + ); + }); + + it('should create a new mailjet integration with conditions', () => { + interceptIntegrationRequests(); cy.getByTestId('add-provider').should('be.enabled').click(); cy.location('pathname').should('equal', '/integrations/create'); @@ -557,12 +603,27 @@ describe('Integrations List Page', function () { cy.location('pathname').should('equal', '/integrations/create/email/mailjet'); cy.getByTestId('provider-instance-name').clear().type('Mailjet Integration'); + cy.getByTestId('add-conditions-btn').click(); + cy.getByTestId('conditions-form-title').contains('Conditions for Mailjet Integration provider instance'); + cy.getByTestId('add-new-condition').click(); + cy.getByTestId('conditions-form-on').should('have.value', 'Tenant'); + cy.getByTestId('conditions-form-key').should('have.value', 'Identifier'); + cy.getByTestId('conditions-form-operator').should('have.value', 'Equal'); + cy.getByTestId('conditions-form-value').type('tenant123'); + cy.getByTestId('apply-conditions-btn').click(); + cy.getByTestId('add-conditions-btn').contains('Edit conditions'); + cy.getByTestId('create-provider-instance-sidebar-create').should('not.be.disabled').contains('Create').click(); cy.getByTestId('create-provider-instance-sidebar-create').should('be.disabled'); cy.wait('@createIntegration'); cy.getByTestId('update-provider-sidebar').should('be.visible'); + cy.getByTestId('header-add-conditions-btn').contains('1').click(); + cy.getByTestId('add-new-condition').click(); + cy.getByTestId('conditions-form-value').last().type('tenant456'); + cy.getByTestId('apply-conditions-btn').click(); + cy.getByTestId('header-add-conditions-btn').contains('2'); cy.location('pathname').should('contain', '/integrations/'); checkTableRow( @@ -572,24 +633,96 @@ describe('Integrations List Page', function () { channel: 'Email', environment: 'Development', status: 'Disabled', + conditions: 1, }, 6 ); }); - it('should update the mailjet integration', () => { - cy.intercept('GET', '*/integrations', async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - }).as('getIntegrations'); - cy.intercept('POST', '*/integrations', async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - }).as('createIntegration'); - cy.intercept('*/environments').as('getEnvironments'); + it('should remove as primary when adding conditions', () => { + interceptIntegrationRequests(); - cy.visit('/integrations'); + cy.getByTestId('integrations-list-table') + .getByTestId('integration-name-cell') + .contains('SendGrid') + .getByTestId('integration-name-cell-primary') + .should('be.visible'); - cy.wait('@getIntegrations'); - cy.wait('@getEnvironments'); + clickOnListRow('SendGrid'); + cy.getByTestId('header-add-conditions-btn').click(); + + cy.getByTestId('remove-primary-flag-modal').should('be.visible'); + cy.getByTestId('remove-primary-flag-modal').contains('Primary flag will be removed'); + cy.getByTestId('remove-primary-flag-modal').contains( + 'Adding conditions to the primary provider instance removes its primary status when a user applies changes by' + ); + cy.getByTestId('remove-primary-flag-modal').find('button').contains('Cancel').should('be.visible'); + cy.getByTestId('remove-primary-flag-modal').find('button').contains('Got it').should('be.visible').click(); + + cy.getByTestId('conditions-form-title').contains('Conditions for SendGrid provider instance'); + cy.getByTestId('add-new-condition').click(); + cy.getByTestId('conditions-form-on').should('have.value', 'Tenant'); + cy.getByTestId('conditions-form-key').should('have.value', 'Identifier'); + cy.getByTestId('conditions-form-operator').should('have.value', 'Equal'); + cy.getByTestId('conditions-form-value').type('tenant123'); + + cy.getByTestId('apply-conditions-btn').click(); + cy.getByTestId('provider-instance-name').first().clear().type('SendGrid test'); + + cy.getByTestId('from').type('info@novu.co'); + cy.getByTestId('senderName').type('Novu'); + + cy.getByTestId('update-provider-sidebar-update').should('not.be.disabled').contains('Update').click(); + cy.get('.mantine-Modal-modal button').contains('Make primary'); + + cy.get('.mantine-Modal-close').click(); + }); + + it('should remove conditions when set to primary', () => { + interceptIntegrationRequests(); + + cy.getByTestId('add-provider').should('be.enabled').click(); + cy.location('pathname').should('equal', '/integrations/create'); + cy.getByTestId('select-provider-sidebar').should('be.visible'); + + cy.getByTestId(`provider-${EmailProviderIdEnum.Mailjet}`).contains('Mailjet').click(); + cy.getByTestId('select-provider-sidebar-next').should('not.be.disabled').contains('Next').click(); + + cy.location('pathname').should('equal', '/integrations/create/email/mailjet'); + cy.getByTestId('provider-instance-name').clear().type('Mailjet Integration'); + cy.getByTestId('add-conditions-btn').click(); + cy.getByTestId('conditions-form-title').contains('Conditions for Mailjet Integration provider instance'); + cy.getByTestId('add-new-condition').click(); + + cy.getByTestId('conditions-form-value').type('tenant123'); + cy.getByTestId('apply-conditions-btn').click(); + + cy.getByTestId('create-provider-instance-sidebar-create').should('not.be.disabled').contains('Create').click(); + + cy.wait('@createIntegration'); + + cy.getByTestId('update-provider-sidebar').should('be.visible'); + cy.getByTestId('header-add-conditions-btn').contains('1'); + + cy.getByTestId('header-make-primary-btn').click(); + + cy.getByTestId('remove-conditions-modal').should('be.visible'); + cy.getByTestId('remove-conditions-modal').contains('Conditions will be removed'); + cy.getByTestId('remove-conditions-modal').contains('Marking this instance as primary will remove all conditions'); + cy.getByTestId('remove-conditions-modal').find('button').contains('Cancel').should('be.visible'); + cy.getByTestId('remove-conditions-modal').find('button').contains('Remove conditions').should('be.visible').click(); + + cy.getByTestId('header-make-primary-btn').should('not.exist'); + + cy.getByTestId('integrations-list-table') + .getByTestId('integration-name-cell') + .contains('Mailjet Integration') + .getByTestId('integration-name-cell-primary') + .should('be.visible'); + }); + + it('should update the mailjet integration', () => { + interceptIntegrationRequests(); cy.getByTestId('add-provider').should('be.enabled').click(); cy.location('pathname').should('equal', '/integrations/create'); @@ -641,18 +774,7 @@ describe('Integrations List Page', function () { }); it('should update the mailjet integration from the list', () => { - cy.intercept('GET', '*/integrations', async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - }).as('getIntegrations'); - cy.intercept('POST', '*/integrations', async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - }).as('createIntegration'); - cy.intercept('*/environments').as('getEnvironments'); - - cy.visit('/integrations'); - - cy.wait('@getIntegrations'); - cy.wait('@getEnvironments'); + interceptIntegrationRequests(); cy.getByTestId('add-provider').should('be.enabled').click(); cy.location('pathname').should('equal', '/integrations/create'); diff --git a/apps/web/src/components/conditions/Conditions.tsx b/apps/web/src/components/conditions/Conditions.tsx new file mode 100644 index 00000000000..65ad7d0ad46 --- /dev/null +++ b/apps/web/src/components/conditions/Conditions.tsx @@ -0,0 +1,314 @@ +import { Grid, Group, ActionIcon, Center, useMantineTheme } from '@mantine/core'; +import styled from '@emotion/styled'; +import { useMemo } from 'react'; +import { Control, Controller, useFieldArray, useForm, useWatch } from 'react-hook-form'; + +import { FILTER_TO_LABEL, FilterPartTypeEnum } from '@novu/shared'; + +import { Button, colors, Dropdown, Input, Select, Sidebar, Text, Title, Tooltip } from '../../design-system'; +import { ConditionPlus, DotsHorizontal, Duplicate, Trash, Condition, ErrorIcon } from '../../design-system/icons'; +import { When } from '../utils/When'; +import { ConditionsContextEnum, ConditionsContextFields, IConditions } from './types'; + +interface IConditionsForm { + conditions: IConditions[]; +} +export function Conditions({ + isOpened, + conditions, + onClose, + setConditions, + name, + context = ConditionsContextEnum.INTEGRATIONS, +}: { + isOpened: boolean; + onClose: () => void; + setConditions: (data: IConditions[]) => void; + conditions?: IConditions[]; + name: string; + context?: ConditionsContextEnum; +}) { + const { colorScheme } = useMantineTheme(); + + const { + control, + getValues, + trigger, + formState: { errors, isValid, isDirty }, + } = useForm({ + defaultValues: { conditions }, + mode: 'onChange', + }); + + const { fields, append, remove, insert } = useFieldArray({ + control, + name: `conditions.0.children`, + }); + + const { label, filterPartsList } = ConditionsContextFields[context]; + + const FilterPartTypeList = useMemo(() => { + return filterPartsList.map((filterType) => { + return { + value: filterType, + label: FILTER_TO_LABEL[filterType], + }; + }); + }, [context]); + + function handleDuplicate(index: number) { + insert(index + 1, getValues(`conditions.0.children.${index}`)); + } + + function handleDelete(index: number) { + remove(index); + } + + const onApplyConditions = async () => { + await trigger('conditions'); + if (!errors.conditions) { + updateConditions(getValues('conditions')); + } + }; + + function updateConditions(data) { + setConditions(data); + onClose(); + } + + return ( + + + + Conditions for {name} {label} + + + } + customFooter={ + + + +
+ +
+
+
+ } + > + {fields.map((item, index) => { + return ( +
+ + + {index > 0 ? ( + + { + return ( + + ); + }} + /> + + + + + + + } + middlewares={{ flip: false, shift: false }} + position="bottom-end" + > + handleDuplicate(index)} + icon={} + > + Duplicate + + handleDelete(index)} + icon={} + > + Delete + + + + +
+ ); + })} + + + + +
+ ); +} + +function EqualityForm({ control, index }: { control: Control; index: number }) { + const operator = useWatch({ + control, + name: `conditions.0.children.${index}.operator`, + }); + + return ( + <> + + { + return ( + + ); + }} + /> + + + + {operator !== 'IS_DEFINED' && ( + { + return ( + + + + + + + + } + error={!!fieldState.error} + placeholder="Value" + data-test-id="conditions-form-value" + /> + ); + }} + /> + )} + + + ); +} + +const Wrapper = styled.div` + .mantine-Select-wrapper:not(:hover) { + .mantine-Select-input { + border-color: transparent; + color: ${colors.B60}; + } + .mantine-Input-rightSection.mantine-Select-rightSection { + svg { + display: none; + } + } + } +`; diff --git a/apps/web/src/components/conditions/index.ts b/apps/web/src/components/conditions/index.ts new file mode 100644 index 00000000000..a4dfe570c58 --- /dev/null +++ b/apps/web/src/components/conditions/index.ts @@ -0,0 +1,2 @@ +export * from './Conditions'; +export * from './types'; diff --git a/apps/web/src/components/conditions/types.ts b/apps/web/src/components/conditions/types.ts new file mode 100644 index 00000000000..fc8556e2ed5 --- /dev/null +++ b/apps/web/src/components/conditions/types.ts @@ -0,0 +1,19 @@ +import { BuilderFieldType, BuilderGroupValues, FilterParts, FilterPartTypeEnum } from '@novu/shared'; + +export interface IConditions { + isNegated?: boolean; + type?: BuilderFieldType; + value?: BuilderGroupValues; + children?: FilterParts[]; +} + +export enum ConditionsContextEnum { + INTEGRATIONS = 'INTEGRATIONS', +} + +export const ConditionsContextFields = { + [ConditionsContextEnum.INTEGRATIONS]: { + label: 'provider instance', + filterPartsList: [FilterPartTypeEnum.TENANT], + }, +}; diff --git a/apps/web/src/components/execution-detail/ExecutionDetailsConditionItem.cy.tsx b/apps/web/src/components/execution-detail/ExecutionDetailsConditionItem.cy.tsx index c47f61b64c2..5a6e0009e9f 100644 --- a/apps/web/src/components/execution-detail/ExecutionDetailsConditionItem.cy.tsx +++ b/apps/web/src/components/execution-detail/ExecutionDetailsConditionItem.cy.tsx @@ -12,7 +12,7 @@ const condition: ICondition = { }; describe('Execution Details Condition Component', function () { - it('should render ExecutionDetailsCondtions properly', function () { + it('should render ExecutionDetailsConditions properly', function () { cy.mount( diff --git a/apps/web/src/components/execution-detail/ExecutionDetailsConditions.cy.tsx b/apps/web/src/components/execution-detail/ExecutionDetailsConditions.cy.tsx index d6cdc2c55b6..dab60f4c9d5 100644 --- a/apps/web/src/components/execution-detail/ExecutionDetailsConditions.cy.tsx +++ b/apps/web/src/components/execution-detail/ExecutionDetailsConditions.cy.tsx @@ -39,7 +39,7 @@ const conditions: ICondition[] = [ ]; describe('Execution Details Condition Component', function () { - it('should render ExecutionDetailsCondtions properly', function () { + it('should render ExecutionDetailsConditions properly', function () { cy.mount( diff --git a/apps/web/src/design-system/icons/actions/ConditionPlus.tsx b/apps/web/src/design-system/icons/actions/ConditionPlus.tsx new file mode 100644 index 00000000000..1877a995c0b --- /dev/null +++ b/apps/web/src/design-system/icons/actions/ConditionPlus.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +export function ConditionPlus(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + + + + + + ); +} diff --git a/apps/web/src/design-system/icons/actions/Duplicate.tsx b/apps/web/src/design-system/icons/actions/Duplicate.tsx new file mode 100644 index 00000000000..10eee70d2ca --- /dev/null +++ b/apps/web/src/design-system/icons/actions/Duplicate.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +/* eslint-disable */ +export function Duplicate(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + ); +} diff --git a/apps/web/src/design-system/icons/actions/PlusFilled.tsx b/apps/web/src/design-system/icons/actions/PlusFilled.tsx index 6087e41ca02..8c1eac0e7ea 100644 --- a/apps/web/src/design-system/icons/actions/PlusFilled.tsx +++ b/apps/web/src/design-system/icons/actions/PlusFilled.tsx @@ -7,14 +7,14 @@ export function PlusFilled(props: React.ComponentPropsWithoutRef<'svg'>) { - - + + diff --git a/apps/web/src/design-system/icons/general/Condition.tsx b/apps/web/src/design-system/icons/general/Condition.tsx new file mode 100644 index 00000000000..cfa890652db --- /dev/null +++ b/apps/web/src/design-system/icons/general/Condition.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +export function Condition(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/design-system/icons/general/RemoveCondition.tsx b/apps/web/src/design-system/icons/general/RemoveCondition.tsx new file mode 100644 index 00000000000..8c61a4abe27 --- /dev/null +++ b/apps/web/src/design-system/icons/general/RemoveCondition.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +/* eslint-disable */ +export function RemoveCondition(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + ); +} diff --git a/apps/web/src/design-system/icons/general/StarEmpty.tsx b/apps/web/src/design-system/icons/general/StarEmpty.tsx index c0a3d3b062f..b64ae38ae9d 100644 --- a/apps/web/src/design-system/icons/general/StarEmpty.tsx +++ b/apps/web/src/design-system/icons/general/StarEmpty.tsx @@ -4,7 +4,7 @@ export const StarEmpty = (props: React.ComponentPropsWithoutRef<'svg'>) => { return ( diff --git a/apps/web/src/design-system/icons/general/Warning.tsx b/apps/web/src/design-system/icons/general/Warning.tsx new file mode 100644 index 00000000000..5fa4fa4c0a4 --- /dev/null +++ b/apps/web/src/design-system/icons/general/Warning.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +/* eslint-disable */ +export function Warning(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + ); +} diff --git a/apps/web/src/design-system/icons/index.ts b/apps/web/src/design-system/icons/index.ts index 28dd35feee6..d781a96e70b 100644 --- a/apps/web/src/design-system/icons/index.ts +++ b/apps/web/src/design-system/icons/index.ts @@ -81,6 +81,9 @@ export { Translate } from './general/Translate'; export { UserAccess } from './general/UserAccess'; export { SSO } from './general/SSO'; export { Cloud } from './general/Cloud'; +export { Condition } from './general/Condition'; +export { RemoveCondition } from './general/RemoveCondition'; +export { Warning } from './general/Warning'; export { Copy } from './actions/Copy'; export { Close } from './actions/Close'; @@ -93,6 +96,8 @@ export { Edit } from './actions/Edit'; export { Upload } from './actions/Upload'; export { Invite } from './actions/Invite'; export { PlusFilled } from './actions/PlusFilled'; +export { ConditionPlus } from './actions/ConditionPlus'; +export { Duplicate } from './actions/Duplicate'; export { ArrowDown } from './arrows/ArrowDown'; export { DoubleArrowRight } from './arrows/DoubleArrowRight'; diff --git a/apps/web/src/design-system/sidebar/Sidebar.tsx b/apps/web/src/design-system/sidebar/Sidebar.tsx index 59508b2f91f..274fc147e2c 100644 --- a/apps/web/src/design-system/sidebar/Sidebar.tsx +++ b/apps/web/src/design-system/sidebar/Sidebar.tsx @@ -124,9 +124,9 @@ export const Sidebar = ({ trapFocus={false} data-expanded={isExpanded} > -
+ - {isExpanded && ( + {isExpanded && onBack && ( diff --git a/apps/web/src/design-system/tooltip/Tooltip.styles.ts b/apps/web/src/design-system/tooltip/Tooltip.styles.ts index aab5ac085da..ed0bd07b040 100644 --- a/apps/web/src/design-system/tooltip/Tooltip.styles.ts +++ b/apps/web/src/design-system/tooltip/Tooltip.styles.ts @@ -1,20 +1,27 @@ import { createStyles, MantineTheme } from '@mantine/core'; import { colors, shadows } from '../config'; +import { getGradient } from '../config/helper'; -export default createStyles((theme: MantineTheme) => { +export default createStyles((theme: MantineTheme, { error }: { error: boolean }) => { const dark = theme.colorScheme === 'dark'; + const opacityErrorColor = theme.fn.rgba(colors.error, 0.2); + const errorGradient = getGradient(opacityErrorColor); + const backgroundErrorColor = dark ? colors.B17 : colors.white; + const backgroundColor = dark ? colors.B20 : colors.white; + const background = error ? `${errorGradient}, ${backgroundErrorColor}` : backgroundColor; + const color = error ? colors.error : colors.B60; return { tooltip: { - backgroundColor: dark ? colors.B20 : theme.white, - color: colors.B60, + background, + color, boxShadow: dark ? shadows.dark : shadows.medium, padding: '12px 15px', fontSize: '14px', fontWeight: 400, }, arrow: { - backgroundColor: dark ? colors.B20 : theme.white, + background, }, }; }); diff --git a/apps/web/src/design-system/tooltip/Tooltip.tsx b/apps/web/src/design-system/tooltip/Tooltip.tsx index bd268add1d6..53d854a1c26 100644 --- a/apps/web/src/design-system/tooltip/Tooltip.tsx +++ b/apps/web/src/design-system/tooltip/Tooltip.tsx @@ -2,30 +2,29 @@ import { Tooltip as MantineTooltip, TooltipProps } from '@mantine/core'; import useStyles from './Tooltip.styles'; +interface ITooltipProps + extends Pick< + TooltipProps, + | 'multiline' + | 'width' + | 'label' + | 'opened' + | 'position' + | 'disabled' + | 'children' + | 'sx' + | 'withinPortal' + | 'offset' + | 'classNames' + > { + error?: boolean; +} /** * Tooltip component * */ -export function Tooltip({ - children, - label, - opened = undefined, - ...props -}: Pick< - TooltipProps, - | 'multiline' - | 'width' - | 'label' - | 'opened' - | 'position' - | 'disabled' - | 'children' - | 'sx' - | 'withinPortal' - | 'offset' - | 'classNames' ->) { - const { classes } = useStyles(); +export function Tooltip({ children, label, opened = undefined, error = false, ...props }: ITooltipProps) { + const { classes } = useStyles({ error }); return ( ({ + sx={{ fontWeight: size === 1 ? 800 : 700, - color: theme.colorScheme === 'dark' ? colors.white : colors.B40, - })} + }} order={size} - {...rest} + color={textColor} + {...props} > {children} diff --git a/apps/web/src/pages/integrations/IntegrationsList.tsx b/apps/web/src/pages/integrations/IntegrationsList.tsx index 721ce176bd1..daff3f5f1ac 100644 --- a/apps/web/src/pages/integrations/IntegrationsList.tsx +++ b/apps/web/src/pages/integrations/IntegrationsList.tsx @@ -18,6 +18,7 @@ import { IntegrationStatusCell } from './components/IntegrationStatusCell'; import { When } from '../../components/utils/When'; import { IntegrationsListNoData } from './components/IntegrationsListNoData'; import { mapToTableIntegration } from './utils'; +import { ConditionCell } from './components/ConditionCell'; const columns: IExtendedColumn[] = [ { @@ -49,6 +50,13 @@ const columns: IExtendedColumn[] = [ Header: 'Environment', Cell: IntegrationEnvironmentCell, }, + { + accessor: 'conditions', + Header: 'Condition', + width: 100, + maxWidth: 100, + Cell: ConditionCell, + }, { accessor: 'active', Header: 'Status', diff --git a/apps/web/src/pages/integrations/components/ConditionCell.tsx b/apps/web/src/pages/integrations/components/ConditionCell.tsx new file mode 100644 index 00000000000..eabd3fc969f --- /dev/null +++ b/apps/web/src/pages/integrations/components/ConditionCell.tsx @@ -0,0 +1,42 @@ +import { Group, useMantineColorScheme } from '@mantine/core'; +import { colors, IExtendedCellProps, withCellLoading } from '../../../design-system'; +import { Condition } from '../../../design-system/icons'; +import type { ITableIntegration } from '../types'; + +const ConditionCellBase = ({ row: { original } }: IExtendedCellProps) => { + const { colorScheme } = useMantineColorScheme(); + + if (!original.conditions || original.conditions.length < 1) { + return ( +
+ - +
+ ); + } + + return ( + + +
+ {original.conditions?.[0]?.children?.length} +
+
+ ); +}; + +export const ConditionCell = withCellLoading(ConditionCellBase); diff --git a/apps/web/src/pages/integrations/components/ConditionIconButton.tsx b/apps/web/src/pages/integrations/components/ConditionIconButton.tsx new file mode 100644 index 00000000000..2444756a267 --- /dev/null +++ b/apps/web/src/pages/integrations/components/ConditionIconButton.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react'; +import styled from '@emotion/styled'; +import { Group, ActionIcon, Center } from '@mantine/core'; +import { When } from '../../../components/utils/When'; +import { colors, Tooltip, Text, Modal, Button, Title } from '../../../design-system'; +import { Condition, ConditionPlus, Warning } from '../../../design-system/icons'; + +const IconButton = styled(Group)` + text-align: center; + border-radius: 8px; + width: 32px; + height: 32px; + color: ${({ theme }) => (theme.colorScheme === 'dark' ? colors.B60 : colors.B30)}; + + &:hover { + background: ${({ theme }) => (theme.colorScheme === 'dark' ? colors.B30 : colors.B85)}; + color: ${({ theme }) => (theme.colorScheme === 'dark' ? colors.white : colors.B30)}; + } +`; + +const RemovesPrimary = () => { + return ( + + This action replaces the +
+ primary provider flag +
+ ); +}; + +export const ConditionIconButton = ({ + conditions = 0, + primary = false, + onClick, +}: { + conditions?: number; + primary?: boolean; + onClick: () => void; +}) => { + const [modalOpen, setModalOpen] = useState(false); + + return ( + <> + + {conditions > 0 ? 'Edit' : 'Add'} Conditions + + + + + } + position="bottom" + > + { + if (primary && conditions === 0) { + setModalOpen(true); + + return; + } + onClick(); + }} + variant="transparent" + > + + + + + 0}> +
+ +
{conditions}
+
+
+
+
+
+ + + + Primary flag will be removed + + + } + size="lg" + onClose={() => { + setModalOpen(false); + }} + > + + Adding conditions to the primary provider instance removes its primary status when a user applies changes by + clicking the Update button. This can potentially cause notification failures for the steps that were using the + primary provider. + + + + + + + + ); +}; diff --git a/apps/web/src/pages/integrations/components/IntegrationNameCell.tsx b/apps/web/src/pages/integrations/components/IntegrationNameCell.tsx index e0b65da5227..14dafb26ede 100644 --- a/apps/web/src/pages/integrations/components/IntegrationNameCell.tsx +++ b/apps/web/src/pages/integrations/components/IntegrationNameCell.tsx @@ -93,7 +93,7 @@ export const IntegrationNameCell = ({ row: { original }, isLoading }: IExtendedC target={ setPopoverOpened(true)} onMouseLeave={() => setPopoverOpened(false)}> {original.name} - {original.primary && } + {original.primary && } } /> diff --git a/apps/web/src/pages/integrations/components/PrimaryIconButton.tsx b/apps/web/src/pages/integrations/components/PrimaryIconButton.tsx new file mode 100644 index 00000000000..250807840a7 --- /dev/null +++ b/apps/web/src/pages/integrations/components/PrimaryIconButton.tsx @@ -0,0 +1,116 @@ +import styled from '@emotion/styled'; +import { Group, ActionIcon, Text } from '@mantine/core'; +import { useState } from 'react'; +import { When } from '../../../components/utils/When'; +import { Tooltip, Button, colors, Modal, Title } from '../../../design-system'; +import { RemoveCondition, StarEmpty, Warning } from '../../../design-system/icons'; + +const IconButton = styled(Group)` + text-align: center; + border-radius: 8px; + width: 32px; + height: 32px; + color: ${({ theme }) => (theme.colorScheme === 'dark' ? colors.B60 : colors.B30)}; + + &:hover { + background: ${({ theme }) => (theme.colorScheme === 'dark' ? colors.B30 : colors.B85)}; + color: ${({ theme }) => (theme.colorScheme === 'dark' ? colors.white : colors.B30)}; + } +`; + +const RemovesCondition = () => { + return ( + + This action remove +
applied conditions +
+ ); +}; + +export const PrimaryIconButton = ({ + conditions = 0, + primary = false, + onClick, +}: { + conditions?: number; + primary?: boolean; + onClick: () => void; +}) => { + const [modalOpen, setModalOpen] = useState(false); + + if (primary) { + return null; + } + + return ( + <> + + Mark as Primary + 0}> + + + + } + position="bottom" + > + { + if (conditions > 0) { + setModalOpen(true); + + return; + } + onClick(); + }} + variant="transparent" + > + + + + + + + + Conditions will be removed + + } + size="lg" + onClose={() => { + setModalOpen(false); + }} + > + + Marking this instance as primary will remove all conditions since primary instances can not have any + conditions. + + + + + + + + ); +}; diff --git a/apps/web/src/pages/integrations/components/UpdateIntegrationSidebarHeader.tsx b/apps/web/src/pages/integrations/components/UpdateIntegrationSidebarHeader.tsx index e676f506b60..bf50ae76243 100644 --- a/apps/web/src/pages/integrations/components/UpdateIntegrationSidebarHeader.tsx +++ b/apps/web/src/pages/integrations/components/UpdateIntegrationSidebarHeader.tsx @@ -1,12 +1,12 @@ import { ReactNode, useMemo, useState } from 'react'; -import { Group } from '@mantine/core'; -import { Controller, useFormContext } from 'react-hook-form'; +import { Group, useMantineTheme } from '@mantine/core'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { CHANNELS_WITH_PRIMARY } from '@novu/shared'; import { Button, colors, Dropdown, Modal, NameInput, Text, Title } from '../../../design-system'; import { useFetchEnvironments } from '../../../hooks/useFetchEnvironments'; import { ProviderImage } from './multi-provider/SelectProviderSidebar'; -import type { IIntegratedProvider } from '../types'; +import type { IIntegratedProvider, IntegrationEntity } from '../types'; import { useProviders } from '../useProviders'; import { useDeleteIntegration } from '../../../api/hooks'; import { errorMessage, successMessage } from '../../../utils/notifications'; @@ -14,23 +14,37 @@ import { DotsHorizontal, StarEmpty, Trash } from '../../../design-system/icons'; import { ProviderInfo } from './multi-provider/ProviderInfo'; import { useSelectPrimaryIntegrationModal } from './multi-provider/useSelectPrimaryIntegrationModal'; import { useMakePrimaryIntegration } from '../../../api/hooks/useMakePrimaryIntegration'; +import { ConditionIconButton } from './ConditionIconButton'; +import { PrimaryIconButton } from './PrimaryIconButton'; export const UpdateIntegrationSidebarHeader = ({ provider, onSuccessDelete, children = null, + openConditions, }: { provider: IIntegratedProvider | null; onSuccessDelete: () => void; children?: ReactNode | null; + openConditions: () => void; }) => { const [isModalOpened, setModalIsOpened] = useState(false); const { control } = useFormContext(); const { environments } = useFetchEnvironments(); + const { colorScheme } = useMantineTheme(); const { providers, isLoading } = useProviders(); const canMarkAsPrimary = provider && !provider.primary && CHANNELS_WITH_PRIMARY.includes(provider.channel); const { openModal, SelectPrimaryIntegrationModal } = useSelectPrimaryIntegrationModal(); + const watchedConditions = useWatch({ control, name: 'conditions' }); + const numOfConditions: number = useMemo(() => { + if (watchedConditions && watchedConditions[0] && watchedConditions[0].children) { + return watchedConditions[0].children.length; + } + + return 0; + }, [watchedConditions]); + const shouldSetNewPrimary = useMemo(() => { if (!provider) return false; @@ -63,7 +77,9 @@ export const UpdateIntegrationSidebarHeader = ({ openModal({ environmentId: provider.environmentId, channelType: provider.channel, - exclude: [provider.integrationId], + exclude: (el: IntegrationEntity) => { + return el._id === provider.integrationId; + }, onClose: () => { deleteIntegration({ id: provider.integrationId, @@ -105,6 +121,14 @@ export const UpdateIntegrationSidebarHeader = ({ /> {children} + { + makePrimaryIntegration({ id: provider.integrationId }); + }} + conditions={numOfConditions} + /> +
{ makePrimaryIntegration({ id: provider.integrationId }); }} - icon={} + icon={} disabled={isLoading || isMarkingPrimary} > Mark as primary diff --git a/apps/web/src/pages/integrations/components/multi-provider/CreateProviderInstanceSidebar.tsx b/apps/web/src/pages/integrations/components/multi-provider/CreateProviderInstanceSidebar.tsx index 52c3d37a996..41ab9eb2a4a 100644 --- a/apps/web/src/pages/integrations/components/multi-provider/CreateProviderInstanceSidebar.tsx +++ b/apps/web/src/pages/integrations/components/multi-provider/CreateProviderInstanceSidebar.tsx @@ -1,17 +1,19 @@ -import styled from '@emotion/styled'; -import { ActionIcon, Group, Radio, Text } from '@mantine/core'; -import { ChannelTypeEnum, ICreateIntegrationBodyDto, NOVU_PROVIDERS, providers } from '@novu/shared'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import styled from '@emotion/styled'; +import { ActionIcon, Group, Radio, Text, Input, useMantineTheme } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; + +import { ChannelTypeEnum, ICreateIntegrationBodyDto, NOVU_PROVIDERS, providers } from '@novu/shared'; import { createIntegration } from '../../../../api/integration'; import { QueryKeys } from '../../../../api/query.keys'; import { useSegment } from '../../../../components/providers/SegmentProvider'; import { When } from '../../../../components/utils/When'; import { Button, colors, NameInput, Sidebar } from '../../../../design-system'; +import { ConditionPlus, ArrowLeft, Condition } from '../../../../design-system/icons'; import { inputStyles } from '../../../../design-system/config/inputs.styles'; -import { ArrowLeft } from '../../../../design-system/icons'; import { useFetchEnvironments } from '../../../../hooks/useFetchEnvironments'; import { CHANNEL_TYPE_TO_STRING } from '../../../../utils/channels'; import { errorMessage, successMessage } from '../../../../utils/notifications'; @@ -19,10 +21,13 @@ import { IntegrationsStoreModalAnalytics } from '../../constants'; import type { IntegrationEntity } from '../../types'; import { useProviders } from '../../useProviders'; import { ProviderImage } from './SelectProviderSidebar'; +import { Conditions, IConditions } from '../../../../components/conditions'; +import { ConditionIconButton } from '../ConditionIconButton'; interface ICreateProviderInstanceForm { name: string; environmentId: string; + conditions: IConditions[]; } export function CreateProviderInstanceSidebar({ @@ -40,11 +45,13 @@ export function CreateProviderInstanceSidebar({ onGoBack: () => void; onIntegrationCreated: (id: string) => void; }) { + const { colorScheme } = useMantineTheme(); const { environments, isLoading: areEnvironmentsLoading } = useFetchEnvironments(); const { isLoading: areIntegrationsLoading, providers: integrations } = useProviders(); const isLoading = areEnvironmentsLoading || areIntegrationsLoading; const queryClient = useQueryClient(); const segment = useSegment(); + const [conditionsFormOpened, { close: closeConditionsForm, open: openConditionsForm }] = useDisclosure(false); const provider = useMemo( () => providers.find((el) => el.channel === channel && el.id === providerId), @@ -57,14 +64,23 @@ export function CreateProviderInstanceSidebar({ ICreateIntegrationBodyDto >(createIntegration); - const { handleSubmit, control, reset, watch } = useForm({ + const { handleSubmit, control, reset, watch, setValue, getValues } = useForm({ shouldUseNativeValidation: false, defaultValues: { name: '', environmentId: '', + conditions: [], }, }); + const watchedConditions = watch('conditions'); + const numOfConditions: number = useMemo(() => { + if (watchedConditions && watchedConditions[0] && watchedConditions[0].children) { + return watchedConditions[0].children.length; + } + + return 0; + }, [watchedConditions]); const selectedEnvironmentId = watch('environmentId'); const showNovuProvidersErrorMessage = useMemo(() => { @@ -86,7 +102,7 @@ export function CreateProviderInstanceSidebar({ } const { channel: selectedChannel } = provider; - const { environmentId } = data; + const { environmentId, conditions } = data; const { _id: integrationId } = await createIntegrationApi({ providerId: provider.id, @@ -95,6 +111,7 @@ export function CreateProviderInstanceSidebar({ credentials: {}, active: provider.channel === ChannelTypeEnum.IN_APP ? true : false, check: false, + conditions, _environmentId: environmentId, }); @@ -124,12 +141,30 @@ export function CreateProviderInstanceSidebar({ reset({ name: provider?.displayName ?? '', environmentId: environments.find((env) => env.name === 'Development')?._id || '', + conditions: [], }); }, [environments, provider]); if (!provider) { return null; } + const updateConditions = (conditions: IConditions[]) => { + setValue('conditions', conditions, { shouldDirty: true }); + }; + + if (conditionsFormOpened) { + const [conditions, name] = getValues(['conditions', 'name']); + + return ( + + ); + } return ( + @@ -162,6 +197,9 @@ export function CreateProviderInstanceSidebar({ ); }} /> + + + } customFooter={ @@ -231,6 +269,46 @@ export function CreateProviderInstanceSidebar({ ); }} /> + + + + Conditions + + (optional) + + + + } + description="Add a condition if you want to apply the provider instance to a specific tenant." + styles={inputStyles} + > + + + + diff --git a/apps/web/src/pages/integrations/components/multi-provider/SelectPrimaryIntegrationModal.tsx b/apps/web/src/pages/integrations/components/multi-provider/SelectPrimaryIntegrationModal.tsx index 4c74643e7c7..53ebc5dd8d4 100644 --- a/apps/web/src/pages/integrations/components/multi-provider/SelectPrimaryIntegrationModal.tsx +++ b/apps/web/src/pages/integrations/components/multi-provider/SelectPrimaryIntegrationModal.tsx @@ -20,7 +20,7 @@ import { IntegrationEnvironmentPill } from '../IntegrationEnvironmentPill'; import { useFetchEnvironments } from '../../../../hooks/useFetchEnvironments'; import { CHANNEL_TYPE_TO_STRING } from '../../../../utils/channels'; import { useIntegrations } from '../../../../hooks'; -import { ITableIntegration } from '../../types'; +import { IntegrationEntity, ITableIntegration } from '../../types'; import { mapToTableIntegration } from '../../utils'; import { IntegrationStatusCell } from '../IntegrationStatusCell'; import { IntegrationNameCell } from '../IntegrationNameCell'; @@ -114,7 +114,7 @@ export interface ISelectPrimaryIntegrationModalProps { isOpened: boolean; environmentId?: string; channelType?: ChannelTypeEnum; - exclude?: string[]; + exclude?: (integration: IntegrationEntity) => boolean; onClose: () => void; } @@ -139,11 +139,14 @@ export const SelectPrimaryIntegrationModal = ({ const { integrations, loading: areIntegrationsLoading } = useIntegrations(); const { makePrimaryIntegration, isLoading: isMarkingPrimaryIntegration } = useMakePrimaryIntegration({ - onSuccess: onCloseCallback, + onSuccess: () => onCloseCallback(), }); const integrationsByEnvAndChannel = useMemo(() => { const filteredIntegrations = (integrations ?? []).filter((el) => { - const isNotExcluded = !exclude?.includes(el._id ?? ''); + let isNotExcluded = true; + if (exclude) { + isNotExcluded = !exclude(el); + } if (environmentId) { return el.channel === channelType && el._environmentId === environmentId && isNotExcluded; @@ -206,7 +209,7 @@ export const SelectPrimaryIntegrationModal = ({ shadow={theme.colorScheme === 'dark' ? shadows.dark : shadows.medium} radius="md" size="lg" - onClose={onCloseCallback} + onClose={() => onCloseCallback()} > @@ -243,7 +246,7 @@ export const SelectPrimaryIntegrationModal = ({ {!isActive && !isInitialProviderSelected && ( - The selected provider instance will be activated as the primary provider cannot be disabled. + The selected provider instance will be activated as the primary provider can not be disabled. )}