diff --git a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.spec.ts b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.spec.ts index 091f865b27b..a95cbb8ac0b 100644 --- a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.spec.ts +++ b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.spec.ts @@ -128,51 +128,7 @@ describe('UpdatePreferences', () => { } }); - it('should create user preference if absent', async () => { - const command = { - environmentId: 'env-1', - organizationId: 'org-1', - subscriberId: 'test-mockSubscriber', - level: PreferenceLevelEnum.GLOBAL, - chat: true, - }; - - subscriberRepositoryMock.findBySubscriberId.resolves(mockedSubscriber); - subscriberPreferenceRepositoryMock.findOne.resolves(undefined); - getSubscriberGlobalPreferenceMock.execute.resolves(mockedGlobalPreference); - - const result = await updatePreferences.execute(command); - - expect(getSubscriberGlobalPreferenceMock.execute.called).to.be.true; - expect(getSubscriberGlobalPreferenceMock.execute.lastCall.args).to.deep.equal([ - GetSubscriberGlobalPreferenceCommand.create({ - environmentId: command.environmentId, - organizationId: command.organizationId, - subscriberId: mockedSubscriber.subscriberId, - }), - ]); - - expect(analyticsServiceMock.mixpanelTrack.firstCall.args).to.deep.equal([ - AnalyticsEventsEnum.CREATE_PREFERENCES, - '', - { - _organization: command.organizationId, - _subscriber: mockedSubscriber._id, - level: command.level, - _workflowId: undefined, - channels: { - chat: true, - }, - }, - ]); - - expect(result).to.deep.equal({ - level: command.level, - ...mockedGlobalPreference.preference, - }); - }); - - it('should update user preference if preference exists', async () => { + it('should update subscriber preference', async () => { const command = { environmentId: 'env-1', organizationId: 'org-1', @@ -216,7 +172,7 @@ describe('UpdatePreferences', () => { }); }); - it('should update user preference if preference exists and level is template', async () => { + it('should update subscriber preference if preference exists and level is template', async () => { const command = { environmentId: 'env-1', organizationId: 'org-1', diff --git a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts index e25dcd59d8a..0ae63fb2f8d 100644 --- a/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts +++ b/apps/api/src/app/inbox/usecases/update-preferences/update-preferences.usecase.ts @@ -9,13 +9,13 @@ import { UpsertSubscriberWorkflowPreferencesCommand, UpsertSubscriberGlobalPreferencesCommand, InstrumentUsecase, + Instrument, } from '@novu/application-generic'; import { NotificationTemplateEntity, NotificationTemplateRepository, PreferenceLevelEnum, SubscriberEntity, - SubscriberPreferenceEntity, SubscriberPreferenceRepository, SubscriberRepository, } from '@novu/dal'; @@ -55,46 +55,16 @@ export class UpdatePreferences { } } - const userPreference: SubscriberPreferenceEntity | null = await this.subscriberPreferenceRepository.findOne( - this.commonQuery(command, subscriber) - ); - if (!userPreference) { - await this.createUserPreference(command, subscriber); - } else { - await this.updateUserPreference(command, subscriber); - } + await this.updateSubscriberPreference(command, subscriber); return await this.findPreference(command, subscriber); } - private async createUserPreference(command: UpdatePreferencesCommand, subscriber: SubscriberEntity): Promise { - const channelPreferences: IPreferenceChannels = this.buildPreferenceChannels(command); - - await this.storePreferencesV2({ - channels: channelPreferences, - organizationId: command.organizationId, - environmentId: command.environmentId, - _subscriberId: subscriber._id, - templateId: command.workflowId, - }); - - this.analyticsService.mixpanelTrack(AnalyticsEventsEnum.CREATE_PREFERENCES, '', { - _organization: command.organizationId, - _subscriber: subscriber._id, - _workflowId: command.workflowId, - level: command.level, - channels: channelPreferences, - }); - - const query = this.commonQuery(command, subscriber); - await this.subscriberPreferenceRepository.create({ - ...query, - enabled: true, - channels: channelPreferences, - }); - } - - private async updateUserPreference(command: UpdatePreferencesCommand, subscriber: SubscriberEntity): Promise { + @Instrument() + private async updateSubscriberPreference( + command: UpdatePreferencesCommand, + subscriber: SubscriberEntity + ): Promise { const channelPreferences: IPreferenceChannels = this.buildPreferenceChannels(command); await this.storePreferencesV2({ @@ -136,6 +106,7 @@ export class UpdatePreferences { }; } + @Instrument() private async findPreference( command: UpdatePreferencesCommand, subscriber: SubscriberEntity @@ -198,6 +169,7 @@ export class UpdatePreferences { /** * Strangler pattern to migrate to V2 preferences. */ + @Instrument() private async storePreferencesV2(item: { channels: IPreferenceChannels; organizationId: string; diff --git a/apps/api/src/app/inbox/utils/analytics.ts b/apps/api/src/app/inbox/utils/analytics.ts index 4952783f8a7..1333e0934cb 100644 --- a/apps/api/src/app/inbox/utils/analytics.ts +++ b/apps/api/src/app/inbox/utils/analytics.ts @@ -6,5 +6,4 @@ export enum AnalyticsEventsEnum { UPDATE_ALL_NOTIFICATIONS = 'Update All Notifications - [Inbox]', FETCH_PREFERENCES = 'Fetch Preferences - [Inbox]', UPDATE_PREFERENCES = 'Update Preferences - [Inbox]', - CREATE_PREFERENCES = 'Create Preferences - [Inbox]', } diff --git a/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts index 4949898322b..8171bbb3daa 100644 --- a/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts +++ b/apps/api/src/app/subscribers/e2e/update-global-preference.e2e.ts @@ -77,7 +77,14 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre expect(response.data.data.preference.channels).to.eql({}); }); - it('should update user global preference and disable the flag for the future channels update', async function () { + it('should unset all preferences when the preferences object is empty', async function () { + const response = await updateGlobalPreferences({}, session); + + expect(response.data.data.preference.channels).to.eql({}); + }); + + // `enabled` flag is not used anymore. The presence of a preference object means that the subscriber has enabled notifications. + it.skip('should update user global preference and disable the flag for the future channels update', async function () { const disablePreferenceData = { enabled: false, }; diff --git a/apps/api/src/app/subscribers/e2e/update-preference.e2e.ts b/apps/api/src/app/subscribers/e2e/update-preference.e2e.ts index b0884de9f0a..340226cc8a3 100644 --- a/apps/api/src/app/subscribers/e2e/update-preference.e2e.ts +++ b/apps/api/src/app/subscribers/e2e/update-preference.e2e.ts @@ -83,7 +83,7 @@ describe('Update Subscribers preferences - /subscribers/:subscriberId/preference expect(response.status).to.eql(404); expect(response.data).to.have.include({ statusCode: 404, - message: 'Template with id 63cc6e0b561e0a609f223e27 is not found', + message: 'Workflow with id: 63cc6e0b561e0a609f223e27 is not found', error: 'Not Found', }); } @@ -148,7 +148,8 @@ describe('Update Subscribers preferences - /subscribers/:subscriberId/preference }); }); - it('should update user preference and disable the flag for the future general notification template preference', async function () { + // `enabled` flag is not used anymore. The presence of a preference object means that the subscriber has enabled notifications. + it.skip('should update user preference and disable the flag for the future general notification template preference', async function () { const initialPreferences = (await getPreference(session)).data.data[0]; expect(initialPreferences.preference.enabled).to.eql(true); expect(initialPreferences.preference.channels).to.eql({ @@ -186,7 +187,8 @@ describe('Update Subscribers preferences - /subscribers/:subscriberId/preference }); }); - it('should update user preference and enable the flag for the future general notification template preference', async function () { + // `enabled` flag is not used anymore. The presence of a preference object means that the subscriber has enabled notifications. + it.skip('should update user preference and enable the flag for the future general notification template preference', async function () { const initialPreferences = (await getPreference(session)).data.data[0]; expect(initialPreferences.preference.enabled).to.eql(true); expect(initialPreferences.preference.channels).to.eql({ diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index 56a0b438a56..c169a996739 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -7,6 +7,7 @@ import { Get, HttpCode, HttpStatus, + NotFoundException, Param, Patch, Post, @@ -29,6 +30,8 @@ import { ApiRateLimitCostEnum, ButtonTypeEnum, ChatProviderIdEnum, + IPreferenceChannels, + TriggerTypeEnum, UserSessionData, } from '@novu/shared'; import { MessageEntity, PreferenceLevelEnum } from '@novu/dal'; @@ -50,8 +53,6 @@ import { GetSubscribers, GetSubscribersCommand } from './usecases/get-subscriber import { GetSubscriber, GetSubscriberCommand } from './usecases/get-subscriber'; import { GetPreferencesByLevelCommand } from './usecases/get-preferences-by-level/get-preferences-by-level.command'; import { GetPreferencesByLevel } from './usecases/get-preferences-by-level/get-preferences-by-level.usecase'; -import { UpdatePreference } from './usecases/update-preference/update-preference.usecase'; -import { UpdateSubscriberPreferenceCommand } from './usecases/update-subscriber-preference'; import { UpdateSubscriberPreferenceResponseDto } from '../widgets/dtos/update-subscriber-preference-response.dto'; import { UpdateSubscriberPreferenceRequestDto } from '../widgets/dtos/update-subscriber-preference-request.dto'; import { MessageResponseDto } from '../widgets/dtos/message-response.dto'; @@ -90,10 +91,6 @@ import { MarkAllMessagesAs } from '../widgets/usecases/mark-all-messages-as/mark import { MarkAllMessageAsRequestDto } from './dtos/mark-all-messages-as-request.dto'; import { BulkCreateSubscribers } from './usecases/bulk-create-subscribers/bulk-create-subscribers.usecase'; import { BulkCreateSubscribersCommand } from './usecases/bulk-create-subscribers'; -import { - UpdateSubscriberGlobalPreferences, - UpdateSubscriberGlobalPreferencesCommand, -} from './usecases/update-subscriber-global-preferences'; import { GetSubscriberPreferencesByLevelParams } from './params'; import { ThrottlerCategory, ThrottlerCost } from '../rate-limiting/guards'; import { MessageMarkAsRequestDto } from '../widgets/dtos/mark-as-request.dto'; @@ -102,6 +99,8 @@ import { MarkMessageAsByMark } from '../widgets/usecases/mark-message-as-by-mark import { FeedResponseDto } from '../widgets/dtos/feeds-response.dto'; import { UserAuthentication } from '../shared/framework/swagger/api.key.security'; import { SdkGroupName, SdkMethodName, SdkUsePagination } from '../shared/framework/swagger/sdk.decorators'; +import { UpdatePreferences } from '../inbox/usecases/update-preferences/update-preferences.usecase'; +import { UpdatePreferencesCommand } from '../inbox/usecases/update-preferences/update-preferences.command'; @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @ApiCommonResponses() @@ -117,8 +116,7 @@ export class SubscribersController { private getSubscriberUseCase: GetSubscriber, private getSubscribersUsecase: GetSubscribers, private getPreferenceUsecase: GetPreferencesByLevel, - private updatePreferenceUsecase: UpdatePreference, - private updateGlobalPreferenceUsecase: UpdateSubscriberGlobalPreferences, + private updatePreferencesUsecase: UpdatePreferences, private getNotificationsFeedUsecase: GetNotificationsFeed, private getFeedCountUsecase: GetFeedCount, private markMessageAsUsecase: MarkMessageAs, @@ -465,16 +463,37 @@ export class SubscribersController { @Param('parameter') templateId: string, @Body() body: UpdateSubscriberPreferenceRequestDto ): Promise { - const command = UpdateSubscriberPreferenceCommand.create({ - organizationId: user.organizationId, - subscriberId, - environmentId: user.environmentId, - templateId, - ...(typeof body.enabled === 'boolean' && { enabled: body.enabled }), - ...(body.channel && { channel: body.channel }), - }); + const result = await this.updatePreferencesUsecase.execute( + UpdatePreferencesCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + subscriberId, + workflowId: templateId, + level: PreferenceLevelEnum.TEMPLATE, + ...(body.channel && { [body.channel.type]: body.channel.enabled }), + }) + ); - return await this.updatePreferenceUsecase.execute(command); + if (!result.workflow) throw new NotFoundException('Workflow not found'); + + return { + preference: { + channels: result.channels, + enabled: result.enabled, + }, + template: { + _id: result.workflow.id, + name: result.workflow.name, + critical: result.workflow.critical, + triggers: [ + { + identifier: result.workflow.identifier, + type: TriggerTypeEnum.EVENT, + variables: [], + }, + ], + }, + }; } @Patch('/:subscriberId/preferences') @@ -490,16 +509,29 @@ export class SubscribersController { @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: UpdateSubscriberGlobalPreferencesRequestDto - ) { - const command = UpdateSubscriberGlobalPreferencesCommand.create({ - organizationId: user.organizationId, - subscriberId, - environmentId: user.environmentId, - enabled: body.enabled, - preferences: body.preferences, - }); + ): Promise> { + const channels = body.preferences?.reduce((acc, curr) => { + acc[curr.type] = curr.enabled; + + return acc; + }, {} as IPreferenceChannels); + + const result = await this.updatePreferencesUsecase.execute( + UpdatePreferencesCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + subscriberId, + level: PreferenceLevelEnum.GLOBAL, + ...channels, + }) + ); - return await this.updateGlobalPreferenceUsecase.execute(command); + return { + preference: { + channels: result.channels, + enabled: result.enabled, + }, + }; } @ExternalApiAccessible() diff --git a/apps/api/src/app/subscribers/subscribers.module.ts b/apps/api/src/app/subscribers/subscribers.module.ts index d34a275441c..cdcbe4c23f3 100644 --- a/apps/api/src/app/subscribers/subscribers.module.ts +++ b/apps/api/src/app/subscribers/subscribers.module.ts @@ -1,7 +1,5 @@ import { forwardRef, Module } from '@nestjs/common'; import { TerminusModule } from '@nestjs/terminus'; -import { GetPreferences, UpsertPreferences } from '@novu/application-generic'; -import { PreferencesRepository } from '@novu/dal'; import { AuthModule } from '../auth/auth.module'; import { SharedModule } from '../shared/shared.module'; import { WidgetsModule } from '../widgets/widgets.module'; diff --git a/apps/api/src/app/subscribers/usecases/index.ts b/apps/api/src/app/subscribers/usecases/index.ts index 33c32b03372..df96298dc85 100644 --- a/apps/api/src/app/subscribers/usecases/index.ts +++ b/apps/api/src/app/subscribers/usecases/index.ts @@ -12,17 +12,15 @@ import { GetSubscriber } from './get-subscriber'; import { GetPreferencesByLevel } from './get-preferences-by-level/get-preferences-by-level.usecase'; import { RemoveSubscriber } from './remove-subscriber'; import { SearchByExternalSubscriberIds } from './search-by-external-subscriber-ids'; -import { UpdatePreference } from './update-preference/update-preference.usecase'; -import { UpdateSubscriberPreference } from './update-subscriber-preference'; import { UpdateSubscriberOnlineFlag } from './update-subscriber-online-flag'; import { ChatOauth } from './chat-oauth/chat-oauth.usecase'; import { ChatOauthCallback } from './chat-oauth-callback/chat-oauth-callback.usecase'; import { DeleteSubscriberCredentials } from './delete-subscriber-credentials/delete-subscriber-credentials.usecase'; import { BulkCreateSubscribers } from './bulk-create-subscribers/bulk-create-subscribers.usecase'; -import { UpdateSubscriberGlobalPreferences } from './update-subscriber-global-preferences'; import { CreateIntegration } from '../../integrations/usecases/create-integration/create-integration.usecase'; import { CheckIntegration } from '../../integrations/usecases/check-integration/check-integration.usecase'; import { CheckIntegrationEMail } from '../../integrations/usecases/check-integration/check-integration-email.usecase'; +import { UpdatePreferences } from '../../inbox/usecases/update-preferences/update-preferences.usecase'; export { SearchByExternalSubscriberIds, @@ -38,18 +36,16 @@ export const USE_CASES = [ GetPreferencesByLevel, RemoveSubscriber, SearchByExternalSubscriberIds, - UpdatePreference, UpdateSubscriber, UpdateSubscriberChannel, - UpdateSubscriberPreference, UpdateSubscriberOnlineFlag, ChatOauthCallback, ChatOauth, DeleteSubscriberCredentials, BulkCreateSubscribers, - UpdateSubscriberGlobalPreferences, GetSubscriberGlobalPreference, CreateIntegration, CheckIntegration, CheckIntegrationEMail, + UpdatePreferences, ]; diff --git a/apps/api/src/app/subscribers/usecases/update-preference/update-preference.command.ts b/apps/api/src/app/subscribers/usecases/update-preference/update-preference.command.ts deleted file mode 100644 index 965ad53cb08..00000000000 --- a/apps/api/src/app/subscribers/usecases/update-preference/update-preference.command.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IsBoolean, IsDefined, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; -import { EnvironmentCommand } from '../../../shared/commands/project.command'; -import { ChannelPreference } from '../../../shared/dtos/channel-preference'; - -export class UpdatePreferenceCommand extends EnvironmentCommand { - @IsString() - @IsDefined() - subscriberId: string; - - @IsDefined() - @IsNotEmpty() - @IsString() - templateId: string; - - @IsBoolean() - @IsOptional() - enabled?: boolean; - - @ValidateNested() - @IsOptional() - channel?: ChannelPreference; -} diff --git a/apps/api/src/app/subscribers/usecases/update-preference/update-preference.usecase.ts b/apps/api/src/app/subscribers/usecases/update-preference/update-preference.usecase.ts deleted file mode 100644 index 7b65a0271c5..00000000000 --- a/apps/api/src/app/subscribers/usecases/update-preference/update-preference.usecase.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { UpdateSubscriberPreference, UpdateSubscriberPreferenceCommand } from '../update-subscriber-preference'; -import { UpdatePreferenceCommand } from './update-preference.command'; - -@Injectable() -export class UpdatePreference { - constructor(private updateSubscriberPreferenceUsecase: UpdateSubscriberPreference) {} - - async execute(command: UpdatePreferenceCommand) { - const updateCommand = UpdateSubscriberPreferenceCommand.create({ - organizationId: command.organizationId, - subscriberId: command.subscriberId, - environmentId: command.environmentId, - templateId: command.templateId, - channel: command.channel, - enabled: command.enabled, - }); - - return await this.updateSubscriberPreferenceUsecase.execute(updateCommand); - } -} diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/index.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/index.ts deleted file mode 100644 index 59adeb0c6ff..00000000000 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './update-subscriber-global-preferences.command'; -export * from './update-subscriber-global-preferences.usecase'; diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts deleted file mode 100644 index 9c3cf879b18..00000000000 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.command.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Type } from 'class-transformer'; -import { IsBoolean, IsOptional, ValidateNested } from 'class-validator'; -import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; -import { ChannelPreference } from '../../../shared/dtos/channel-preference'; - -export class UpdateSubscriberGlobalPreferencesCommand extends EnvironmentWithSubscriber { - @IsBoolean() - @IsOptional() - enabled?: boolean; - - @IsOptional() - @ValidateNested() - @Type(() => ChannelPreference) - preferences?: ChannelPreference[]; -} diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts deleted file mode 100644 index 580e5672423..00000000000 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-global-preferences/update-subscriber-global-preferences.usecase.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { GetSubscriberGlobalPreference, GetSubscriberGlobalPreferenceCommand } from '@novu/application-generic'; -import { - ChannelTypeEnum, - PreferenceLevelEnum, - SubscriberEntity, - SubscriberPreferenceEntity, - SubscriberPreferenceRepository, - SubscriberRepository, -} from '@novu/dal'; - -import { UpdateSubscriberGlobalPreferencesCommand } from './update-subscriber-global-preferences.command'; - -@Injectable() -export class UpdateSubscriberGlobalPreferences { - constructor( - private subscriberPreferenceRepository: SubscriberPreferenceRepository, - private subscriberRepository: SubscriberRepository, - private getSubscriberGlobalPreference: GetSubscriberGlobalPreference - ) {} - - async execute(command: UpdateSubscriberGlobalPreferencesCommand) { - const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); - if (!subscriber) throw new NotFoundException(`Subscriber not found`); - - const userGlobalPreference = await this.subscriberPreferenceRepository.findOne({ - _organizationId: command.organizationId, - _environmentId: command.environmentId, - _subscriberId: subscriber._id, - level: PreferenceLevelEnum.GLOBAL, - }); - - if (!userGlobalPreference) { - await this.createUserPreference(command, subscriber); - } else { - await this.updateUserPreference(command, subscriber); - } - - return await this.getSubscriberGlobalPreference.execute( - GetSubscriberGlobalPreferenceCommand.create({ - organizationId: command.organizationId, - environmentId: command.environmentId, - subscriberId: command.subscriberId, - }) - ); - } - - private async createUserPreference( - command: UpdateSubscriberGlobalPreferencesCommand, - subscriber: SubscriberEntity - ): Promise { - const channelObj = {} as Record; - if (command.preferences && command.preferences.length > 0) { - for (const preference of command.preferences) { - if (preference.type) { - channelObj[preference.type] = preference.enabled; - } - } - } - - await this.subscriberPreferenceRepository.create({ - _environmentId: command.environmentId, - _organizationId: command.organizationId, - _subscriberId: subscriber._id, - /* - * Unless explicitly set to false when creating a user preference we want it to be enabled - * even if not passing at first enabled to true. - */ - enabled: command.enabled !== false, - channels: command.preferences && command.preferences.length > 0 ? channelObj : null, - level: PreferenceLevelEnum.GLOBAL, - }); - } - - private async updateUserPreference( - command: UpdateSubscriberGlobalPreferencesCommand, - subscriber: SubscriberEntity - ): Promise { - const updatePayload: Partial = {}; - - if (command.enabled != null) { - updatePayload.enabled = command.enabled; - } - - if (command.preferences && command.preferences.length > 0) { - for (const preference of command.preferences) { - if (preference.type) { - updatePayload[`channels.${preference.type}`] = preference.enabled; - } - } - } - - await this.subscriberPreferenceRepository.update( - { - _environmentId: command.environmentId, - _organizationId: command.organizationId, - _subscriberId: subscriber._id, - level: PreferenceLevelEnum.GLOBAL, - }, - { - $set: updatePayload, - } - ); - } -} diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-preference/index.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-preference/index.ts deleted file mode 100644 index 65157082561..00000000000 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-preference/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './update-subscriber-preference.command'; -export * from './update-subscriber-preference.usecase'; diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-preference/update-subscriber-preference.command.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-preference/update-subscriber-preference.command.ts deleted file mode 100644 index 9687edd78c5..00000000000 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-preference/update-subscriber-preference.command.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsBoolean, IsDefined, IsMongoId, IsOptional, ValidateNested } from 'class-validator'; -import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; -import { ChannelPreference } from '../../../shared/dtos/channel-preference'; - -export class UpdateSubscriberPreferenceCommand extends EnvironmentWithSubscriber { - @IsDefined() - @IsMongoId() - templateId: string; - - @IsBoolean() - @IsOptional() - enabled?: boolean; - - @ValidateNested() - @IsOptional() - channel?: ChannelPreference; -} diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber-preference/update-subscriber-preference.usecase.ts b/apps/api/src/app/subscribers/usecases/update-subscriber-preference/update-subscriber-preference.usecase.ts deleted file mode 100644 index 8660da5d550..00000000000 --- a/apps/api/src/app/subscribers/usecases/update-subscriber-preference/update-subscriber-preference.usecase.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { - SubscriberPreferenceEntity, - SubscriberPreferenceRepository, - NotificationTemplateRepository, - SubscriberEntity, - SubscriberRepository, - MemberRepository, - PreferenceLevelEnum, -} from '@novu/dal'; -import { - AnalyticsService, - GetSubscriberTemplatePreference, - GetSubscriberTemplatePreferenceCommand, -} from '@novu/application-generic'; -import { ISubscriberPreferenceResponse } from '@novu/shared'; - -import { UpdateSubscriberPreferenceCommand } from './update-subscriber-preference.command'; -import { ApiException } from '../../../shared/exceptions/api.exception'; - -@Injectable() -export class UpdateSubscriberPreference { - constructor( - private subscriberPreferenceRepository: SubscriberPreferenceRepository, - private getSubscriberTemplatePreference: GetSubscriberTemplatePreference, - private notificationTemplateRepository: NotificationTemplateRepository, - private analyticsService: AnalyticsService, - private subscriberRepository: SubscriberRepository, - private memberRepository: MemberRepository - ) {} - - async execute(command: UpdateSubscriberPreferenceCommand): Promise { - const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); - if (!subscriber) throw new NotFoundException(`Subscriber not found`); - - const userPreference = await this.subscriberPreferenceRepository.findOne({ - _organizationId: command.organizationId, - _environmentId: command.environmentId, - _subscriberId: subscriber._id, - _templateId: command.templateId, - }); - - this.analyticsService.mixpanelTrack('Update User Preference - [Notification Center]', '', { - _organization: command.organizationId, - _subscriber: subscriber._id, - _template: command.templateId, - channel: command.channel?.type, - enabled: command.channel?.enabled, - }); - - if (!userPreference) { - await this.createUserPreference(command, subscriber); - } else { - await this.updateUserPreference(command, subscriber); - } - - const template = await this.notificationTemplateRepository.findById(command.templateId, command.environmentId); - if (!template) { - throw new NotFoundException(`Template with id ${command.templateId} is not found`); - } - - const getSubscriberPreferenceCommand = GetSubscriberTemplatePreferenceCommand.create({ - organizationId: command.organizationId, - subscriberId: command.subscriberId, - environmentId: command.environmentId, - template, - }); - - return await this.getSubscriberTemplatePreference.execute(getSubscriberPreferenceCommand); - } - - private async createUserPreference( - command: UpdateSubscriberPreferenceCommand, - subscriber: SubscriberEntity - ): Promise { - const channelObj = {} as Record<'email' | 'sms' | 'in_app' | 'chat' | 'push', boolean>; - if (command.channel) { - channelObj[command.channel.type] = command.channel.enabled; - } - - await this.subscriberPreferenceRepository.create({ - _environmentId: command.environmentId, - _organizationId: command.organizationId, - _subscriberId: subscriber._id, - _templateId: command.templateId, - /* - * Unless explicitly set to false when creating a user preference we want it to be enabled - * even if not passing at first enabled to true. - */ - enabled: command.enabled !== false, - channels: command.channel?.type ? channelObj : null, - level: PreferenceLevelEnum.TEMPLATE, - }); - } - - private async updateUserPreference( - command: UpdateSubscriberPreferenceCommand, - subscriber: SubscriberEntity - ): Promise { - const updatePayload: Partial = {}; - - if (command.enabled != null) { - updatePayload.enabled = command.enabled; - } - - if (command.channel?.type) { - updatePayload[`channels.${command.channel.type}`] = command.channel.enabled; - } - - if (Object.keys(updatePayload).length === 0) { - throw new ApiException('In order to make an update you need to provider channel or enabled'); - } - - await this.subscriberPreferenceRepository.update( - { - _environmentId: command.environmentId, - _organizationId: command.organizationId, - _subscriberId: subscriber._id, - _templateId: command.templateId, - }, - { - $set: updatePayload, - } - ); - } -} diff --git a/apps/api/src/app/widgets/e2e/get-subscriber-preference.e2e.ts b/apps/api/src/app/widgets/e2e/get-subscriber-preference.e2e.ts index aebb905dd17..acacd2e6485 100644 --- a/apps/api/src/app/widgets/e2e/get-subscriber-preference.e2e.ts +++ b/apps/api/src/app/widgets/e2e/get-subscriber-preference.e2e.ts @@ -51,7 +51,8 @@ describe('GET /widget/preferences', function () { expect(data.preference.overrides.find((sources) => sources.channel === 'email').source).to.equal('template'); }); - it('should fetch according to merged subscriber and template preferences ', async function () { + // `enabled` flag is not used anymore. The presence of a preference object means that the subscriber has enabled notifications. + it.skip('should fetch according to merged subscriber and template preferences ', async function () { const templateDefaultSettings = await session.createTemplate({ preferenceSettingsOverride: { email: true, chat: true, push: true, sms: true, in_app: false }, noFeedId: true, diff --git a/apps/api/src/app/widgets/e2e/update-subscriber-preference.e2e.ts b/apps/api/src/app/widgets/e2e/update-subscriber-preference.e2e.ts index 4890cdfb94f..2c288c08640 100644 --- a/apps/api/src/app/widgets/e2e/update-subscriber-preference.e2e.ts +++ b/apps/api/src/app/widgets/e2e/update-subscriber-preference.e2e.ts @@ -21,7 +21,8 @@ describe('PATCH /widgets/preferences/:templateId', function () { }); }); - it('should create user preference', async function () { + // `enabled` flag is not used anymore. The presence of a preference object means that the subscriber has enabled notifications. + it.skip('should create user preference', async function () { const updateData = { enabled: false, }; diff --git a/apps/api/src/app/widgets/widgets.controller.ts b/apps/api/src/app/widgets/widgets.controller.ts index 1010b0cb1fa..bf4a02d2982 100644 --- a/apps/api/src/app/widgets/widgets.controller.ts +++ b/apps/api/src/app/widgets/widgets.controller.ts @@ -7,6 +7,7 @@ import { Get, HttpCode, HttpStatus, + NotFoundException, Param, Patch, Post, @@ -17,13 +18,15 @@ import { AuthGuard } from '@nestjs/passport'; import { ApiExcludeController, ApiOperation, ApiQuery } from '@nestjs/swagger'; import { AnalyticsService, GetSubscriberPreference, GetSubscriberPreferenceCommand } from '@novu/application-generic'; import { MessageEntity, PreferenceLevelEnum, SubscriberEntity } from '@novu/dal'; -import { MessagesStatusEnum, ButtonTypeEnum, MessageActionStatusEnum } from '@novu/shared'; +import { + MessagesStatusEnum, + ButtonTypeEnum, + MessageActionStatusEnum, + TriggerTypeEnum, + IPreferenceChannels, +} from '@novu/shared'; import { SubscriberSession } from '../shared/framework/user.decorator'; -import { - UpdateSubscriberPreference, - UpdateSubscriberPreferenceCommand, -} from '../subscribers/usecases/update-subscriber-preference'; import { LogUsageRequestDto } from './dtos/log-usage-request.dto'; import { LogUsageResponseDto } from './dtos/log-usage-response.dto'; import { OrganizationResponseDto } from './dtos/organization-response.dto'; @@ -54,10 +57,6 @@ import { LimitPipe } from './pipes/limit-pipe/limit-pipe'; import { RemoveAllMessagesCommand } from './usecases/remove-messages/remove-all-messages.command'; import { RemoveAllMessages } from './usecases/remove-messages/remove-all-messages.usecase'; import { RemoveAllMessagesDto } from './dtos/remove-all-messages.dto'; -import { - UpdateSubscriberGlobalPreferences, - UpdateSubscriberGlobalPreferencesCommand, -} from '../subscribers/usecases/update-subscriber-global-preferences'; import { UpdateSubscriberGlobalPreferencesRequestDto } from '../subscribers/dtos/update-subscriber-global-preferences-request.dto'; import { GetPreferencesByLevel } from '../subscribers/usecases/get-preferences-by-level/get-preferences-by-level.usecase'; import { GetPreferencesByLevelCommand } from '../subscribers/usecases/get-preferences-by-level/get-preferences-by-level.command'; @@ -68,6 +67,8 @@ import { RemoveMessagesBulkRequestDto } from './dtos/remove-messages-bulk-reques import { MessageMarkAsRequestDto } from './dtos/mark-as-request.dto'; import { MarkMessageAsByMark } from './usecases/mark-message-as-by-mark/mark-message-as-by-mark.usecase'; import { MarkMessageAsByMarkCommand } from './usecases/mark-message-as-by-mark/mark-message-as-by-mark.command'; +import { UpdatePreferences } from '../inbox/usecases/update-preferences/update-preferences.usecase'; +import { UpdatePreferencesCommand } from '../inbox/usecases/update-preferences/update-preferences.command'; @ApiCommonResponses() @Controller('/widgets') @@ -86,8 +87,7 @@ export class WidgetsController { private getOrganizationUsecase: GetOrganizationData, private getSubscriberPreferenceUsecase: GetSubscriberPreference, private getSubscriberPreferenceByLevelUsecase: GetPreferencesByLevel, - private updateSubscriberPreferenceUsecase: UpdateSubscriberPreference, - private updateSubscriberGlobalPreferenceUsecase: UpdateSubscriberGlobalPreferences, + private updatePreferencesUsecase: UpdatePreferences, private markAllMessagesAsUsecase: MarkAllMessagesAs, private analyticsService: AnalyticsService ) {} @@ -442,16 +442,37 @@ export class WidgetsController { @Param('templateId') templateId: string, @Body() body: UpdateSubscriberPreferenceRequestDto ): Promise { - const command = UpdateSubscriberPreferenceCommand.create({ - organizationId: subscriberSession._organizationId, - subscriberId: subscriberSession.subscriberId, - environmentId: subscriberSession._environmentId, - templateId, - channel: body.channel, - enabled: body.enabled, - }); + const result = await this.updatePreferencesUsecase.execute( + UpdatePreferencesCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + subscriberId: subscriberSession.subscriberId, + workflowId: templateId, + level: PreferenceLevelEnum.TEMPLATE, + ...(body.channel && { [body.channel.type]: body.channel.enabled }), + }) + ); + + if (!result.workflow) throw new NotFoundException('Workflow not found'); - return await this.updateSubscriberPreferenceUsecase.execute(command); + return { + preference: { + channels: result.channels, + enabled: result.enabled, + }, + template: { + _id: result.workflow.id, + name: result.workflow.name, + critical: result.workflow.critical, + triggers: [ + { + identifier: result.workflow.identifier, + type: TriggerTypeEnum.EVENT, + variables: [], + }, + ], + }, + }; } @UseGuards(AuthGuard('subscriberJwt')) @@ -460,15 +481,28 @@ export class WidgetsController { @SubscriberSession() subscriberSession: SubscriberEntity, @Body() body: UpdateSubscriberGlobalPreferencesRequestDto ) { - const command = UpdateSubscriberGlobalPreferencesCommand.create({ - organizationId: subscriberSession._organizationId, - subscriberId: subscriberSession.subscriberId, - environmentId: subscriberSession._environmentId, - preferences: body.preferences, - enabled: body.enabled, - }); + const channels = body.preferences?.reduce((acc, curr) => { + acc[curr.type] = curr.enabled; + + return acc; + }, {} as IPreferenceChannels); + + const result = await this.updatePreferencesUsecase.execute( + UpdatePreferencesCommand.create({ + environmentId: subscriberSession._environmentId, + organizationId: subscriberSession._organizationId, + subscriberId: subscriberSession.subscriberId, + level: PreferenceLevelEnum.GLOBAL, + ...channels, + }) + ); - return await this.updateSubscriberGlobalPreferenceUsecase.execute(command); + return { + preference: { + channels: result.channels, + enabled: result.enabled, + }, + }; } @UseGuards(AuthGuard('subscriberJwt')) diff --git a/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts b/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts index a338cb4f70d..43a31bca136 100644 --- a/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts @@ -53,6 +53,11 @@ export class UpsertPreferences { ) { const channelTypes = Object.keys(command.preferences?.channels || {}); + if (channelTypes.length === 0) { + // If there are no channels to update, we don't need to run the update query + return; + } + const preferenceUnsetPayload = channelTypes.reduce((acc, channelType) => { acc[`preferences.channels.${channelType}`] = '';