diff --git a/.idea/runConfigurations/_template__of_mocha_javascript_test_runner.xml b/.idea/runConfigurations/_template__of_mocha_javascript_test_runner.xml index 3feb4656ed2..2ebef3abd61 100644 --- a/.idea/runConfigurations/_template__of_mocha_javascript_test_runner.xml +++ b/.idea/runConfigurations/_template__of_mocha_javascript_test_runner.xml @@ -8,10 +8,10 @@ - + - --timeout 30000 --require ts-node/register --exit --file e2e/setup.ts + --require ts-node/register --exit --file e2e/setup.ts DIRECTORY false 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 cf0789ceee9..7edd39ca0b0 100644 --- a/apps/api/src/app/events/e2e/trigger-event.e2e.ts +++ b/apps/api/src/app/events/e2e/trigger-event.e2e.ts @@ -4,40 +4,41 @@ import axios, { AxiosResponse } from 'axios'; import { v4 as uuid } from 'uuid'; import { differenceInMilliseconds, subDays } from 'date-fns'; import { + EnvironmentRepository, + ExecutionDetailsRepository, + IntegrationRepository, + JobRepository, + JobStatusEnum, MessageRepository, NotificationRepository, NotificationTemplateEntity, + NotificationTemplateRepository, SubscriberEntity, SubscriberRepository, - JobRepository, - JobStatusEnum, - IntegrationRepository, - ExecutionDetailsRepository, - EnvironmentRepository, TenantRepository, - NotificationTemplateRepository, } from '@novu/dal'; -import { UserSession, SubscribersService, WorkflowOverrideService } from '@novu/testing'; +import { SubscribersService, UserSession, WorkflowOverrideService } from '@novu/testing'; import { + ActorTypeEnum, ChannelTypeEnum, + ChatProviderIdEnum, + DEFAULT_MESSAGE_GENERIC_RETENTION_DAYS, + DEFAULT_MESSAGE_IN_APP_RETENTION_DAYS, + DelayTypeEnum, + DigestUnitEnum, EmailBlockTypeEnum, + EmailProviderIdEnum, FieldLogicalOperatorEnum, FieldOperatorEnum, FilterPartTypeEnum, - StepTypeEnum, IEmailBlock, + InAppProviderIdEnum, ISubscribersDefine, - TemplateVariableTypeEnum, - EmailProviderIdEnum, - SmsProviderIdEnum, - DigestUnitEnum, - DelayTypeEnum, PreviousStepTypeEnum, - InAppProviderIdEnum, - DEFAULT_MESSAGE_IN_APP_RETENTION_DAYS, - DEFAULT_MESSAGE_GENERIC_RETENTION_DAYS, - ActorTypeEnum, + SmsProviderIdEnum, + StepTypeEnum, SystemAvatarIconEnum, + TemplateVariableTypeEnum, } from '@novu/shared'; import { EmailEventStatusEnum } from '@novu/stateless'; import { createTenant } from '../../tenant/e2e/create-tenant.e2e'; @@ -1068,6 +1069,132 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { expect(updatedSubscriber?.locale).to.equal(payload.locale); }); + describe('Subscriber channels', function () { + it('should set a new subscriber with channels array', async function () { + const subscriberId = SubscriberRepository.createObjectId(); + const payload: ISubscribersDefine = { + subscriberId, + firstName: 'Test Name', + lastName: 'Last of name', + email: undefined, + locale: 'en', + channels: [ + { + providerId: ChatProviderIdEnum.Slack, + credentials: { + webhookUrl: 'https://slack.com/webhook/test', + deviceTokens: ['1', '2'], + }, + }, + ], + }; + + await axiosInstance.post( + `${session.serverUrl}${eventTriggerPath}`, + { + name: template.triggers[0].identifier, + to: { + ...payload, + }, + payload: { + urlVar: '/test/url/path', + }, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + + await session.awaitRunningJobs(); + + const createdSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); + + expect(createdSubscriber?.channels?.length).to.equal(1); + expect(createdSubscriber?.channels[0]?.providerId).to.equal(ChatProviderIdEnum.Slack); + expect(createdSubscriber?.channels[0]?.credentials?.webhookUrl).to.equal('https://slack.com/webhook/test'); + expect(createdSubscriber?.channels[0]?.credentials?.deviceTokens.length).to.equal(2); + }); + + it('should update a subscribers channels array', async function () { + const subscriberId = SubscriberRepository.createObjectId(); + const payload: ISubscribersDefine = { + subscriberId, + firstName: 'Test Name', + lastName: 'Last of name', + email: undefined, + locale: 'en', + channels: [ + { + providerId: ChatProviderIdEnum.Slack, + credentials: { + webhookUrl: 'https://slack.com/webhook/test', + }, + }, + ], + }; + + await axiosInstance.post( + `${session.serverUrl}${eventTriggerPath}`, + { + name: template.triggers[0].identifier, + to: { + ...payload, + }, + payload: { + urlVar: '/test/url/path', + }, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + + await session.awaitRunningJobs(); + const createdSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); + + expect(createdSubscriber?.subscriberId).to.equal(subscriberId); + expect(createdSubscriber?.channels?.length).to.equal(1); + + await axiosInstance.post( + `${session.serverUrl}${eventTriggerPath}`, + { + name: template.triggers[0].identifier, + to: { + ...payload, + channels: [ + { + providerId: ChatProviderIdEnum.Slack, + credentials: { + webhookUrl: 'https://slack.com/webhook/test2', + }, + }, + ], + }, + payload: { + urlVar: '/test/url/path', + }, + }, + { + headers: { + authorization: `ApiKey ${session.apiKey}`, + }, + } + ); + + await session.awaitRunningJobs(); + + const updatedSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); + + expect(updatedSubscriber?.channels?.length).to.equal(1); + expect(updatedSubscriber?.channels[0]?.providerId).to.equal(ChatProviderIdEnum.Slack); + expect(updatedSubscriber?.channels[0]?.credentials?.webhookUrl).to.equal('https://slack.com/webhook/test2'); + }); + }); + it('should not unset a subscriber email', async function () { const subscriberId = SubscriberRepository.createObjectId(); const payload = { diff --git a/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts b/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts index cd0cd51b9ff..d99a034511f 100644 --- a/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts +++ b/apps/api/src/app/subscribers/dtos/update-subscriber-channel-request.dto.ts @@ -1,10 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsDefined, IsObject, IsOptional, IsString } from 'class-validator'; -import { ChatProviderIdEnum, PushProviderIdEnum } from '@novu/shared'; +import { ChatProviderIdEnum, ISubscriberChannel, PushProviderIdEnum } from '@novu/shared'; import { ChannelCredentials } from '../../shared/dtos/subscriber-channel'; -export class UpdateSubscriberChannelRequestDto { +export class UpdateSubscriberChannelRequestDto implements ISubscriberChannel { @ApiProperty({ enum: { ...ChatProviderIdEnum, ...PushProviderIdEnum }, description: 'The provider identifier for the credentials', diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index fd9716c5ef2..820ded0cf2f 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -20,6 +20,9 @@ import { CreateSubscriberCommand, UpdateSubscriber, UpdateSubscriberCommand, + OAuthHandlerEnum, + UpdateSubscriberChannel, + UpdateSubscriberChannelCommand, } from '@novu/application-generic'; import { ApiOperation, ApiTags, ApiParam } from '@nestjs/swagger'; import { @@ -45,7 +48,6 @@ import { UpdateSubscriberGlobalPreferencesRequestDto, UpdateSubscriberRequestDto, } from './dtos'; -import { UpdateSubscriberChannel, UpdateSubscriberChannelCommand } from './usecases/update-subscriber-channel'; import { GetSubscribers, GetSubscribersCommand } from './usecases/get-subscribers'; import { GetSubscriber, GetSubscriberCommand } from './usecases/get-subscriber'; import { GetPreferencesByLevelCommand } from './usecases/get-preferences-by-level/get-preferences-by-level.command'; @@ -77,7 +79,6 @@ import { GetSubscribersDto } from './dtos/get-subscribers.dto'; import { GetInAppNotificationsFeedForSubscriberDto } from './dtos/get-in-app-notification-feed-for-subscriber.dto'; import { ApiCommonResponses, ApiResponse, ApiNoContentResponse } from '../shared/framework/response.decorator'; import { ChatOauthCallbackRequestDto, ChatOauthRequestDto } from './dtos/chat-oauth-request.dto'; -import { OAuthHandlerEnum } from './types'; import { ChatOauthCallback } from './usecases/chat-oauth-callback/chat-oauth-callback.usecase'; import { ChatOauthCallbackCommand } from './usecases/chat-oauth-callback/chat-oauth-callback.command'; import { ChatOauth } from './usecases/chat-oauth/chat-oauth.usecase'; diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-channel/update-subscriber-channel.spec.ts b/apps/api/src/app/subscribers/unit/update-subscriber-channel.spec.ts similarity index 98% rename from apps/api/src/app/subscribers/usecases/update-subscriber-channel/update-subscriber-channel.spec.ts rename to apps/api/src/app/subscribers/unit/update-subscriber-channel.spec.ts index 73bd7604e2c..fd6a04426ba 100644 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-channel/update-subscriber-channel.spec.ts +++ b/apps/api/src/app/subscribers/unit/update-subscriber-channel.spec.ts @@ -2,12 +2,10 @@ import { IntegrationRepository, SubscriberRepository } from '@novu/dal'; import { SubscribersService, UserSession } from '@novu/testing'; import { Test } from '@nestjs/testing'; import { expect } from 'chai'; -import { SharedModule } from '../../../shared/shared.module'; import { ChannelTypeEnum, ChatProviderIdEnum, PushProviderIdEnum } from '@novu/shared'; -import { UpdateSubscriberChannel } from './update-subscriber-channel.usecase'; -import { UpdateSubscriberChannelCommand } from './update-subscriber-channel.command'; -import { OAuthHandlerEnum } from '../../types'; import { faker } from '@faker-js/faker'; +import { OAuthHandlerEnum, UpdateSubscriberChannel, UpdateSubscriberChannelCommand } from '@novu/application-generic'; +import { SharedModule } from '../../shared/shared.module'; describe('Update Subscriber channel credentials', function () { let updateSubscriberChannelUsecase: UpdateSubscriberChannel; diff --git a/apps/api/src/app/subscribers/usecases/chat-oauth-callback/chat-oauth-callback.usecase.ts b/apps/api/src/app/subscribers/usecases/chat-oauth-callback/chat-oauth-callback.usecase.ts index 6f3fa5caf53..410c075432a 100644 --- a/apps/api/src/app/subscribers/usecases/chat-oauth-callback/chat-oauth-callback.usecase.ts +++ b/apps/api/src/app/subscribers/usecases/chat-oauth-callback/chat-oauth-callback.usecase.ts @@ -1,7 +1,15 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import axios from 'axios'; -import { CreateSubscriber, CreateSubscriberCommand, decryptCredentials } from '@novu/application-generic'; +import { + CreateSubscriber, + CreateSubscriberCommand, + decryptCredentials, + OAuthHandlerEnum, + IChannelCredentialsCommand, + UpdateSubscriberChannel, + UpdateSubscriberChannelCommand, +} from '@novu/application-generic'; import { ICredentialsDto } from '@novu/shared'; import { ChannelTypeEnum, @@ -12,13 +20,7 @@ import { } from '@novu/dal'; import { ChatOauthCallbackCommand } from './chat-oauth-callback.command'; -import { - IChannelCredentialsCommand, - UpdateSubscriberChannel, - UpdateSubscriberChannelCommand, -} from '../update-subscriber-channel'; import { ApiException } from '../../../shared/exceptions/api.exception'; -import { OAuthHandlerEnum } from '../../types'; import { validateEncryption } from '../chat-oauth/chat-oauth.usecase'; @Injectable() diff --git a/apps/api/src/app/subscribers/usecases/delete-subscriber-credentials/delete-subscriber-credentials.spec.ts b/apps/api/src/app/subscribers/usecases/delete-subscriber-credentials/delete-subscriber-credentials.spec.ts index ef808b462b7..d2c3ee1a937 100644 --- a/apps/api/src/app/subscribers/usecases/delete-subscriber-credentials/delete-subscriber-credentials.spec.ts +++ b/apps/api/src/app/subscribers/usecases/delete-subscriber-credentials/delete-subscriber-credentials.spec.ts @@ -6,10 +6,8 @@ import { SharedModule } from '../../../shared/shared.module'; import { ChatProviderIdEnum, PushProviderIdEnum } from '@novu/shared'; import { DeleteSubscriberCredentials } from './delete-subscriber-credentials.usecase'; import { DeleteSubscriberCredentialsCommand } from './delete-subscriber-credentials.command'; -import { UpdateSubscriberChannel } from '../update-subscriber-channel/update-subscriber-channel.usecase'; -import { UpdateSubscriberChannelCommand } from '../update-subscriber-channel/update-subscriber-channel.command'; -import { OAuthHandlerEnum } from '../../types'; import { GetSubscriber } from '../get-subscriber/get-subscriber.usecase'; +import { OAuthHandlerEnum, UpdateSubscriberChannel, UpdateSubscriberChannelCommand } from '@novu/application-generic'; describe('Delete subscriber provider credentials', function () { let updateSubscriberChannelUsecase: UpdateSubscriberChannel; diff --git a/apps/api/src/app/subscribers/usecases/index.ts b/apps/api/src/app/subscribers/usecases/index.ts index 7569c1263fc..3d8aa6097d5 100644 --- a/apps/api/src/app/subscribers/usecases/index.ts +++ b/apps/api/src/app/subscribers/usecases/index.ts @@ -4,6 +4,7 @@ import { UpdateSubscriber, CreateSubscriber, GetSubscriberGlobalPreference, + UpdateSubscriberChannel, } from '@novu/application-generic'; import { GetSubscribers } from './get-subscribers'; @@ -12,7 +13,6 @@ import { GetPreferencesByLevel } from './get-preferences-by-level/get-preference import { RemoveSubscriber } from './remove-subscriber'; import { SearchByExternalSubscriberIds } from './search-by-external-subscriber-ids'; import { UpdatePreference } from './update-preference/update-preference.usecase'; -import { UpdateSubscriberChannel } from './update-subscriber-channel'; import { UpdateSubscriberPreference } from './update-subscriber-preference'; import { UpdateSubscriberOnlineFlag } from './update-subscriber-online-flag'; import { ChatOauth } from './chat-oauth/chat-oauth.usecase'; diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-channel/update-subscriber-channel.command.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-channel/update-subscriber-channel.command.ts deleted file mode 100644 index 73dbb2ed04f..00000000000 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-channel/update-subscriber-channel.command.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { IsBoolean, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; -import { EnvironmentCommand } from '../../../shared/commands/project.command'; -import { ChatProviderIdEnum, PushProviderIdEnum } from '@novu/shared'; -import { ChannelCredentials, SubscriberChannel } from '../../../shared/dtos/subscriber-channel'; -import { OAuthHandlerEnum } from '../../types'; - -export class IChannelCredentialsCommand implements ChannelCredentials { - @IsString() - @IsOptional() - webhookUrl?: string; - - @IsString() - @IsOptional() - channel?: string; - - @IsString({ each: true }) - @IsOptional() - deviceTokens?: string[]; -} - -export class UpdateSubscriberChannelCommand extends EnvironmentCommand implements SubscriberChannel { - @IsString() - subscriberId: string; - - providerId: ChatProviderIdEnum | PushProviderIdEnum; - - @ValidateNested() - credentials: IChannelCredentialsCommand; - - @IsNotEmpty() - oauthHandler: OAuthHandlerEnum; - - @IsOptional() - @IsString() - integrationIdentifier?: string; - - @IsBoolean() - isIdempotentOperation: boolean; -} diff --git a/apps/worker/src/app/shared/shared.module.ts b/apps/worker/src/app/shared/shared.module.ts index 8229158fe4f..7905076ec05 100644 --- a/apps/worker/src/app/shared/shared.module.ts +++ b/apps/worker/src/app/shared/shared.module.ts @@ -49,6 +49,7 @@ import { StorageHelperService, storageService, UpdateSubscriber, + UpdateSubscriberChannel, UpdateTenant, } from '@novu/application-generic'; @@ -114,6 +115,7 @@ const PROVIDERS = [ StorageHelperService, storageService, UpdateSubscriber, + UpdateSubscriberChannel, UpdateTenant, GetTenant, CreateTenant, diff --git a/libs/shared/src/types/subscriber/index.ts b/libs/shared/src/types/subscriber/index.ts index 3cfe79ceed4..530b77faa21 100644 --- a/libs/shared/src/types/subscriber/index.ts +++ b/libs/shared/src/types/subscriber/index.ts @@ -1,10 +1,18 @@ import { CustomDataType } from '../shared'; +import { ChatProviderIdEnum, PushProviderIdEnum } from '../../consts'; +import { IChannelCredentials } from '../../entities/subscriber'; export type ExternalSubscriberId = string; export type SubscriberId = string; export type SubscriberCustomData = CustomDataType; +export interface ISubscriberChannel { + providerId: ChatProviderIdEnum | PushProviderIdEnum; + integrationIdentifier?: string; + credentials: IChannelCredentials; +} + export interface ISubscriberPayload { firstName?: string; lastName?: string; @@ -13,6 +21,7 @@ export interface ISubscriberPayload { avatar?: string; locale?: string; data?: SubscriberCustomData; + channels?: ISubscriberChannel[]; } export interface ISubscribersDefine extends ISubscriberPayload { diff --git a/packages/application-generic/src/usecases/create-subscriber/create-subscriber.command.ts b/packages/application-generic/src/usecases/create-subscriber/create-subscriber.command.ts index e635dbff911..9bbbd0950c6 100644 --- a/packages/application-generic/src/usecases/create-subscriber/create-subscriber.command.ts +++ b/packages/application-generic/src/usecases/create-subscriber/create-subscriber.command.ts @@ -7,7 +7,7 @@ import { } from 'class-validator'; import { Transform } from 'class-transformer'; import { SubscriberEntity } from '@novu/dal'; -import { SubscriberCustomData } from '@novu/shared'; +import { ISubscriberChannel, SubscriberCustomData } from '@novu/shared'; import { EnvironmentCommand } from '../../commands/project.command'; @@ -46,4 +46,7 @@ export class CreateSubscriberCommand extends EnvironmentCommand { @IsOptional() subscriber?: SubscriberEntity; + + @IsOptional() + channels?: ISubscriberChannel[]; } diff --git a/packages/application-generic/src/usecases/create-subscriber/create-subscriber.usecase.ts b/packages/application-generic/src/usecases/create-subscriber/create-subscriber.usecase.ts index a49078fd0ac..513cfbd1385 100644 --- a/packages/application-generic/src/usecases/create-subscriber/create-subscriber.usecase.ts +++ b/packages/application-generic/src/usecases/create-subscriber/create-subscriber.usecase.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import { SubscriberRepository } from '@novu/dal'; import { SubscriberEntity, ErrorCodesEnum } from '@novu/dal'; @@ -12,13 +12,19 @@ import { UpdateSubscriber, UpdateSubscriberCommand, } from '../update-subscriber'; +import { + OAuthHandlerEnum, + UpdateSubscriberChannel, + UpdateSubscriberChannelCommand, +} from '../subscribers'; @Injectable() export class CreateSubscriber { constructor( private invalidateCache: InvalidateCacheService, private subscriberRepository: SubscriberRepository, - private updateSubscriber: UpdateSubscriber + private updateSubscriber: UpdateSubscriber, + private updateSubscriberChannel: UpdateSubscriberChannel ) {} async execute(command: CreateSubscriberCommand) { @@ -31,6 +37,10 @@ export class CreateSubscriber { if (!subscriber) { subscriber = await this.createSubscriber(command); + + if (command.channels?.length) { + await this.updateCredentials(command); + } } else { subscriber = await this.updateSubscriber.execute( UpdateSubscriberCommand.create({ @@ -45,6 +55,7 @@ export class CreateSubscriber { locale: command.locale, data: command.data, subscriber, + channels: command.channels, }) ); } @@ -52,6 +63,23 @@ export class CreateSubscriber { return subscriber; } + private async updateCredentials(command: CreateSubscriberCommand) { + for (const channel of command.channels) { + await this.updateSubscriberChannel.execute( + UpdateSubscriberChannelCommand.create({ + organizationId: command.organizationId, + environmentId: command.environmentId, + subscriberId: command.subscriberId, + providerId: channel.providerId, + credentials: channel.credentials, + integrationIdentifier: channel.integrationIdentifier, + oauthHandler: OAuthHandlerEnum.EXTERNAL, + isIdempotentOperation: false, + }) + ); + } + } + private async createSubscriber(command: CreateSubscriberCommand) { try { await this.invalidateCache.invalidateByKey({ diff --git a/packages/application-generic/src/usecases/index.ts b/packages/application-generic/src/usecases/index.ts index 9866e217fb7..6e0adabfa09 100644 --- a/packages/application-generic/src/usecases/index.ts +++ b/packages/application-generic/src/usecases/index.ts @@ -39,3 +39,4 @@ export * from './compile-step-template'; export * from './create-workflow'; export * from './workflow'; export * from './message-template'; +export * from './subscribers'; diff --git a/packages/application-generic/src/usecases/process-subscriber/process-subscriber.usecase.ts b/packages/application-generic/src/usecases/process-subscriber/process-subscriber.usecase.ts index e9148501f7a..825b63a6df6 100644 --- a/packages/application-generic/src/usecases/process-subscriber/process-subscriber.usecase.ts +++ b/packages/application-generic/src/usecases/process-subscriber/process-subscriber.usecase.ts @@ -7,7 +7,6 @@ import { CreateSubscriberCommand, } from '../create-subscriber'; import { InstrumentUsecase } from '../../instrumentation'; -import { subscriberNeedUpdate } from '../../utils/subscriber'; import { ProcessSubscriberCommand } from './process-subscriber.command'; import { buildSubscriberKey, CachedEntity } from '../../services/cache'; @@ -47,10 +46,6 @@ export class ProcessSubscriber { subscriberId: subscriberPayload.subscriberId, }); - if (subscriber && !subscriberNeedUpdate(subscriber, subscriberPayload)) { - return subscriber; - } - return await this.createOrUpdateSubscriber( environmentId, organizationId, @@ -79,6 +74,7 @@ export class ProcessSubscriber { locale: subscriberPayload?.locale, subscriber: subscriber ?? undefined, data: subscriberPayload?.data, + channels: subscriberPayload?.channels, }) ); } diff --git a/packages/application-generic/src/usecases/subscribers/index.ts b/packages/application-generic/src/usecases/subscribers/index.ts new file mode 100644 index 00000000000..b0c8098d56b --- /dev/null +++ b/packages/application-generic/src/usecases/subscribers/index.ts @@ -0,0 +1,2 @@ +export * from './update-subscriber-channel'; +export * from './types'; diff --git a/apps/api/src/app/subscribers/types/index.ts b/packages/application-generic/src/usecases/subscribers/types/index.ts similarity index 100% rename from apps/api/src/app/subscribers/types/index.ts rename to packages/application-generic/src/usecases/subscribers/types/index.ts diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-channel/index.ts b/packages/application-generic/src/usecases/subscribers/update-subscriber-channel/index.ts similarity index 100% rename from apps/api/src/app/subscribers/usecases/update-subscriber-channel/index.ts rename to packages/application-generic/src/usecases/subscribers/update-subscriber-channel/index.ts diff --git a/packages/application-generic/src/usecases/subscribers/update-subscriber-channel/update-subscriber-channel.command.ts b/packages/application-generic/src/usecases/subscribers/update-subscriber-channel/update-subscriber-channel.command.ts new file mode 100644 index 00000000000..fca398028b4 --- /dev/null +++ b/packages/application-generic/src/usecases/subscribers/update-subscriber-channel/update-subscriber-channel.command.ts @@ -0,0 +1,75 @@ +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { + ChatProviderIdEnum, + IChannelCredentials, + PushProviderIdEnum, +} from '@novu/shared'; +import { EnvironmentCommand } from '../../../commands'; +import { OAuthHandlerEnum } from '../types'; +import { SubscriberEntity } from '@novu/dal'; + +export class SubscriberChannel { + providerId: ChatProviderIdEnum | PushProviderIdEnum; + + credentials: IChannelCredentials; +} + +export class IChannelCredentialsCommand implements IChannelCredentials { + @IsString() + @IsOptional() + webhookUrl?: string; + + @IsString() + @IsOptional() + channel?: string; + + @IsString({ each: true }) + @IsOptional() + deviceTokens?: string[]; + + @IsOptional() + alertUid?: string; + + @IsOptional() + title?: string; + + @IsOptional() + imageUrl?: string; + + @IsOptional() + state?: string; + + @IsOptional() + externalUrl?: string; +} + +export class UpdateSubscriberChannelCommand + extends EnvironmentCommand + implements SubscriberChannel +{ + @IsString() + subscriberId: string; + + providerId: ChatProviderIdEnum | PushProviderIdEnum; + + subscriber?: SubscriberEntity; + + @ValidateNested() + credentials: IChannelCredentialsCommand; + + @IsNotEmpty() + oauthHandler: OAuthHandlerEnum; + + @IsOptional() + @IsString() + integrationIdentifier?: string; + + @IsBoolean() + isIdempotentOperation: boolean; +} diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-channel/update-subscriber-channel.usecase.ts b/packages/application-generic/src/usecases/subscribers/update-subscriber-channel/update-subscriber-channel.usecase.ts similarity index 68% rename from apps/api/src/app/subscribers/usecases/update-subscriber-channel/update-subscriber-channel.usecase.ts rename to packages/application-generic/src/usecases/subscribers/update-subscriber-channel/update-subscriber-channel.usecase.ts index d5b767931e3..a754dd23c9b 100644 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-channel/update-subscriber-channel.usecase.ts +++ b/packages/application-generic/src/usecases/subscribers/update-subscriber-channel/update-subscriber-channel.usecase.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { isEqual } from 'lodash'; import { IChannelSettings, @@ -7,25 +7,33 @@ import { SubscriberEntity, IntegrationEntity, } from '@novu/dal'; -import { AnalyticsService, buildSubscriberKey, InvalidateCacheService } from '@novu/application-generic'; -import { ApiException } from '../../../shared/exceptions/api.exception'; import { UpdateSubscriberChannelCommand } from './update-subscriber-channel.command'; +import { ApiException } from '../../../utils/exceptions'; +import { + AnalyticsService, + buildSubscriberKey, + InvalidateCacheService, +} from '../../../services'; @Injectable() export class UpdateSubscriberChannel { constructor( + @Inject(forwardRef(() => InvalidateCacheService)) private invalidateCache: InvalidateCacheService, private subscriberRepository: SubscriberRepository, private integrationRepository: IntegrationRepository, + @Inject(forwardRef(() => AnalyticsService)) private analyticsService: AnalyticsService ) {} async execute(command: UpdateSubscriberChannelCommand) { - const foundSubscriber = await this.subscriberRepository.findBySubscriberId( - command.environmentId, - command.subscriberId - ); + const foundSubscriber = + command.subscriber ?? + (await this.subscriberRepository.findBySubscriberId( + command.environmentId, + command.subscriberId + )); if (!foundSubscriber) { throw new ApiException(`SubscriberId: ${command.subscriberId} not found`); @@ -40,9 +48,13 @@ export class UpdateSubscriberChannel { query.identifier = command.integrationIdentifier; } - const foundIntegration = await this.integrationRepository.findOne(query, undefined, { - query: { sort: { createdAt: -1 } }, - }); + const foundIntegration = await this.integrationRepository.findOne( + query, + undefined, + { + query: { sort: { createdAt: -1 } }, + } + ); if (!foundIntegration) { throw new ApiException( @@ -53,7 +65,8 @@ export class UpdateSubscriberChannel { const existingChannel = foundSubscriber?.channels?.find( (subscriberChannel) => - subscriberChannel.providerId === command.providerId && subscriberChannel._integrationId === foundIntegration._id + subscriberChannel.providerId === command.providerId && + subscriberChannel._integrationId === foundIntegration._id ); if (existingChannel) { @@ -65,15 +78,24 @@ export class UpdateSubscriberChannel { command.isIdempotentOperation ); } else { - await this.addChannelToSubscriber(updatePayload, foundIntegration, command, foundSubscriber); + await this.addChannelToSubscriber( + updatePayload, + foundIntegration, + command, + foundSubscriber + ); } - this.analyticsService.mixpanelTrack('Set Subscriber Credentials - [Subscribers]', '', { - providerId: command.providerId, - _organization: command.organizationId, - oauthHandler: command.oauthHandler, - _subscriberId: foundSubscriber._id, - }); + this.analyticsService.mixpanelTrack( + 'Set Subscriber Credentials - [Subscribers]', + '', + { + providerId: command.providerId, + _organization: command.organizationId, + oauthHandler: command.oauthHandler, + _subscriberId: foundSubscriber._id, + } + ); return (await this.subscriberRepository.findBySubscriberId( command.environmentId, @@ -114,7 +136,10 @@ export class UpdateSubscriberChannel { foundSubscriber: SubscriberEntity, isIdempotentOperation: boolean ) { - const equal = isEqual(existingChannel.credentials, updatePayload.credentials); + const equal = isEqual( + existingChannel.credentials, + updatePayload.credentials + ); if (equal) { return; @@ -124,7 +149,10 @@ export class UpdateSubscriberChannel { if (updatePayload.credentials?.deviceTokens) { if (isIdempotentOperation) { - deviceTokens = this.unionDeviceTokens([], updatePayload.credentials.deviceTokens); + deviceTokens = this.unionDeviceTokens( + [], + updatePayload.credentials.deviceTokens + ); } else { deviceTokens = this.unionDeviceTokens( existingChannel.credentials.deviceTokens ?? [], @@ -140,7 +168,11 @@ export class UpdateSubscriberChannel { }), }); - const mappedChannel: IChannelSettings = this.mapChannel(updatePayload, existingChannel, deviceTokens); + const mappedChannel: IChannelSettings = this.mapChannel( + updatePayload, + existingChannel, + deviceTokens + ); await this.subscriberRepository.update( { @@ -158,13 +190,21 @@ export class UpdateSubscriberChannel { deviceTokens: string[] ): IChannelSettings { return { - _integrationId: updatePayload._integrationId || existingChannel._integrationId, + _integrationId: + updatePayload._integrationId || existingChannel._integrationId, providerId: updatePayload.providerId || existingChannel.providerId, - credentials: { ...existingChannel.credentials, ...updatePayload.credentials, deviceTokens }, + credentials: { + ...existingChannel.credentials, + ...updatePayload.credentials, + deviceTokens, + }, }; } - private unionDeviceTokens(existingDeviceTokens: string[], updateDeviceTokens: string[]): string[] { + private unionDeviceTokens( + existingDeviceTokens: string[], + updateDeviceTokens: string[] + ): string[] { // in order to not have breaking change we will support [] update if (updateDeviceTokens?.length === 0) return []; @@ -180,8 +220,13 @@ export class UpdateSubscriberChannel { if (command.credentials.webhookUrl != null && updatePayload.credentials) { updatePayload.credentials.webhookUrl = command.credentials.webhookUrl; } - if (command.credentials.deviceTokens != null && updatePayload.credentials) { - updatePayload.credentials.deviceTokens = [...new Set([...command.credentials.deviceTokens])]; + if ( + command.credentials.deviceTokens != null && + updatePayload.credentials + ) { + updatePayload.credentials.deviceTokens = [ + ...new Set([...command.credentials.deviceTokens]), + ]; } if (command.credentials.channel != null && updatePayload.credentials) { updatePayload.credentials.channel = command.credentials.channel; diff --git a/packages/application-generic/src/usecases/update-subscriber/update-subscriber.command.ts b/packages/application-generic/src/usecases/update-subscriber/update-subscriber.command.ts index 413c05e18e6..4faa17d37f3 100644 --- a/packages/application-generic/src/usecases/update-subscriber/update-subscriber.command.ts +++ b/packages/application-generic/src/usecases/update-subscriber/update-subscriber.command.ts @@ -1,6 +1,6 @@ import { IsEmail, IsLocale, IsOptional, IsString } from 'class-validator'; import { SubscriberEntity } from '@novu/dal'; -import { SubscriberCustomData } from '@novu/shared'; +import { ISubscriberChannel, SubscriberCustomData } from '@novu/shared'; import { Transform } from 'class-transformer'; import { EnvironmentCommand } from '../../commands'; @@ -37,4 +37,7 @@ export class UpdateSubscriberCommand extends EnvironmentCommand { @IsOptional() subscriber?: SubscriberEntity; + + @IsOptional() + channels?: ISubscriberChannel[]; } diff --git a/packages/application-generic/src/usecases/update-subscriber/update-subscriber.usecase.ts b/packages/application-generic/src/usecases/update-subscriber/update-subscriber.usecase.ts index b55717495dc..fabb5c67879 100644 --- a/packages/application-generic/src/usecases/update-subscriber/update-subscriber.usecase.ts +++ b/packages/application-generic/src/usecases/update-subscriber/update-subscriber.usecase.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { @@ -9,12 +9,18 @@ import { subscriberNeedUpdate } from '../../utils/subscriber'; import { UpdateSubscriberCommand } from './update-subscriber.command'; import { ApiException } from '../../utils/exceptions'; +import { + OAuthHandlerEnum, + UpdateSubscriberChannel, + UpdateSubscriberChannelCommand, +} from '../subscribers'; @Injectable() export class UpdateSubscriber { constructor( private invalidateCache: InvalidateCacheService, - private subscriberRepository: SubscriberRepository + private subscriberRepository: SubscriberRepository, + private updateSubscriberChannel: UpdateSubscriberChannel ) {} public async execute( @@ -61,6 +67,10 @@ export class UpdateSubscriber { updatePayload.data = command.data; } + if (command.channels?.length) { + await this.updateSubscriberChannels(command, foundSubscriber); + } + if (!subscriberNeedUpdate(foundSubscriber, updatePayload)) { return { ...foundSubscriber, @@ -89,4 +99,25 @@ export class UpdateSubscriber { ...updatePayload, }; } + + private async updateSubscriberChannels( + command: UpdateSubscriberCommand, + foundSubscriber: SubscriberEntity + ) { + for (const channel of command.channels) { + await this.updateSubscriberChannel.execute( + UpdateSubscriberChannelCommand.create({ + subscriber: foundSubscriber, + organizationId: command.organizationId, + environmentId: command.environmentId, + subscriberId: command.subscriberId, + providerId: channel.providerId, + credentials: channel.credentials, + integrationIdentifier: channel.integrationIdentifier, + oauthHandler: OAuthHandlerEnum.EXTERNAL, + isIdempotentOperation: false, + }) + ); + } + } } diff --git a/packages/application-generic/src/utils/subscriber.ts b/packages/application-generic/src/utils/subscriber.ts index 947e5707335..27319b7249d 100644 --- a/packages/application-generic/src/utils/subscriber.ts +++ b/packages/application-generic/src/utils/subscriber.ts @@ -3,7 +3,7 @@ import { isEqual } from 'lodash'; export function subscriberNeedUpdate( subscriber: SubscriberEntity, - subscriberPayload: Partial + subscriberPayload: Partial> ): boolean { return ( !!(