From 0269ddc5e1ca905adf76d1014e583de1a8c21672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=B6derberg?= <2233092+davidsoderberg@users.noreply.github.com> Date: Thu, 5 Sep 2024 12:57:38 +0200 Subject: [PATCH] feat(api): add preferences entity and schema (#6396) --- apps/api/src/app.module.ts | 2 + apps/api/src/app/bridge/bridge.controller.ts | 2 +- apps/api/src/app/bridge/bridge.module.ts | 8 +- .../app/bridge/usecases/sync/sync.usecase.ts | 23 +- apps/api/src/app/inbox/inbox.module.ts | 12 +- .../update-preferences.usecase.ts | 85 +- .../app/preferences/dtos/preferences.dto.ts | 43 + .../dtos/upsert-preferences.dto.ts | 12 + apps/api/src/app/preferences/index.ts | 1 + .../app/preferences/preferences.controller.ts | 80 ++ .../src/app/preferences/preferences.module.ts | 17 + .../src/app/preferences/preferences.spec.ts | 739 ++++++++++++++++++ .../src/app/subscribers/subscribers.module.ts | 13 +- apps/api/src/app/workflows/workflow.module.ts | 13 +- .../useCloudWorkflowChannelPreferences.ts | 2 +- .../useStudioWorkflowChannelPreferences.ts | 2 +- .../useUpdateWorkflowChannelPreferences.ts | 3 +- .../src/app/workflow/workflow.module.ts | 6 +- .../get-preferences.command.ts | 6 + .../get-preferences.usecase.ts | 163 ++++ .../src/usecases/get-preferences/index.ts | 2 + ...et-subscriber-global-preference.usecase.ts | 13 +- ...-subscriber-template-preference.usecase.ts | 46 +- .../application-generic/src/usecases/index.ts | 2 + .../src/usecases/upsert-preferences/index.ts | 5 + .../upsert-preferences.command.ts | 21 + .../upsert-preferences.usecase.ts | 139 ++++ ...t-subscriber-global-preferences.command.ts | 11 + ...subscriber-workflow-preferences.command.ts | 7 + ...psert-user-workflow-preferences.command.ts | 7 + .../upsert-workflow-preferences.command.ts | 11 + .../src/utils/deepmerge.ts | 232 ++++++ libs/application-generic/src/utils/index.ts | 1 + libs/dal/src/index.ts | 1 + .../dal/src/repositories/preferences/index.ts | 3 + .../preferences/preferences.entity.ts | 44 ++ .../preferences/preferences.repository.ts | 43 + .../preferences/preferences.schema.ts | 104 +++ libs/shared/src/types/index.ts | 1 + .../workflow-channel-preferences/index.ts | 1 + .../workflow-channel-preferences}/types.ts | 2 +- 41 files changed, 1874 insertions(+), 54 deletions(-) create mode 100644 apps/api/src/app/preferences/dtos/preferences.dto.ts create mode 100644 apps/api/src/app/preferences/dtos/upsert-preferences.dto.ts create mode 100644 apps/api/src/app/preferences/index.ts create mode 100644 apps/api/src/app/preferences/preferences.controller.ts create mode 100644 apps/api/src/app/preferences/preferences.module.ts create mode 100644 apps/api/src/app/preferences/preferences.spec.ts create mode 100644 libs/application-generic/src/usecases/get-preferences/get-preferences.command.ts create mode 100644 libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts create mode 100644 libs/application-generic/src/usecases/get-preferences/index.ts create mode 100644 libs/application-generic/src/usecases/upsert-preferences/index.ts create mode 100644 libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.command.ts create mode 100644 libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts create mode 100644 libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts create mode 100644 libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-workflow-preferences.command.ts create mode 100644 libs/application-generic/src/usecases/upsert-preferences/upsert-user-workflow-preferences.command.ts create mode 100644 libs/application-generic/src/usecases/upsert-preferences/upsert-workflow-preferences.command.ts create mode 100644 libs/application-generic/src/utils/deepmerge.ts create mode 100644 libs/dal/src/repositories/preferences/index.ts create mode 100644 libs/dal/src/repositories/preferences/preferences.entity.ts create mode 100644 libs/dal/src/repositories/preferences/preferences.repository.ts create mode 100644 libs/dal/src/repositories/preferences/preferences.schema.ts create mode 100644 libs/shared/src/types/workflow-channel-preferences/index.ts rename {apps/web/src/hooks/workflowChannelPreferences => libs/shared/src/types/workflow-channel-preferences}/types.ts (81%) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 3d49ee1ef50..4f1936994ad 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -42,6 +42,7 @@ import { ProductFeatureInterceptor } from './app/shared/interceptors/product-fea import { AnalyticsModule } from './app/analytics/analytics.module'; import { InboxModule } from './app/inbox/inbox.module'; import { BridgeModule } from './app/bridge/bridge.module'; +import { PreferencesModule } from './app/preferences'; const enterpriseImports = (): Array | ForwardReference> => { const modules: Array | ForwardReference> = []; @@ -101,6 +102,7 @@ const baseModules: Array | Forward ProfilingModule.register(packageJson.name), TracingModule.register(packageJson.name, packageJson.version), BridgeModule, + PreferencesModule, ]; const enterpriseModules = enterpriseImports(); diff --git a/apps/api/src/app/bridge/bridge.controller.ts b/apps/api/src/app/bridge/bridge.controller.ts index 16f368fc961..199d83d4955 100644 --- a/apps/api/src/app/bridge/bridge.controller.ts +++ b/apps/api/src/app/bridge/bridge.controller.ts @@ -16,10 +16,10 @@ import { import { UserSessionData, ControlVariablesLevelEnum, WorkflowTypeEnum } from '@novu/shared'; import { AnalyticsService, ExternalApiAccessible, UserAuthGuard, UserSession } from '@novu/application-generic'; - import { EnvironmentRepository, NotificationTemplateRepository, ControlVariablesRepository } from '@novu/dal'; import { ApiExcludeController } from '@nestjs/swagger'; + import { StoreControlVariables, StoreControlVariablesCommand } from './usecases/store-control-variables'; import { PreviewStep, PreviewStepCommand } from './usecases/preview-step'; import { SyncCommand } from './usecases/sync'; diff --git a/apps/api/src/app/bridge/bridge.module.ts b/apps/api/src/app/bridge/bridge.module.ts index 321f9d9b469..f98181d95f0 100644 --- a/apps/api/src/app/bridge/bridge.module.ts +++ b/apps/api/src/app/bridge/bridge.module.ts @@ -1,5 +1,4 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; - import { CreateChange, CreateMessageTemplate, @@ -8,11 +7,12 @@ import { UpdateChange, UpdateMessageTemplate, UpdateWorkflow, + UpsertPreferences, } from '@novu/application-generic'; - +import { PreferencesRepository } from '@novu/dal'; +import { SharedModule } from '../shared/shared.module'; import { BridgeController } from './bridge.controller'; import { USECASES } from './usecases'; -import { SharedModule } from '../shared/shared.module'; const PROVIDERS = [ CreateWorkflow, @@ -22,6 +22,8 @@ const PROVIDERS = [ DeleteMessageTemplate, CreateChange, UpdateChange, + PreferencesRepository, + UpsertPreferences, ]; @Module({ diff --git a/apps/api/src/app/bridge/usecases/sync/sync.usecase.ts b/apps/api/src/app/bridge/usecases/sync/sync.usecase.ts index 94b58d7f273..f5ec0c9b038 100644 --- a/apps/api/src/app/bridge/usecases/sync/sync.usecase.ts +++ b/apps/api/src/app/bridge/usecases/sync/sync.usecase.ts @@ -5,6 +5,7 @@ import { EnvironmentRepository, NotificationGroupRepository, NotificationTemplateEntity, + PreferencesActorEnum, } from '@novu/dal'; import { AnalyticsService, @@ -14,6 +15,8 @@ import { UpdateWorkflow, UpdateWorkflowCommand, ExecuteBridgeRequest, + UpsertPreferences, + UpsertWorkflowPreferencesCommand, } from '@novu/application-generic'; import { WorkflowTypeEnum } from '@novu/shared'; import { DiscoverOutput, DiscoverStepOutput, DiscoverWorkflowOutput, GetActionEnum } from '@novu/framework'; @@ -32,7 +35,8 @@ export class Sync { private notificationGroupRepository: NotificationGroupRepository, private environmentRepository: EnvironmentRepository, private executeBridgeRequest: ExecuteBridgeRequest, - private analyticsService: AnalyticsService + private analyticsService: AnalyticsService, + private upsertPreferences: UpsertPreferences ) {} async execute(command: SyncCommand): Promise { const environment = await this.environmentRepository.findOne({ _id: command.environmentId }); @@ -126,8 +130,10 @@ export class Sync { workflow.workflowId ); + let savedWorkflow: NotificationTemplateEntity | undefined; + if (workflowExist) { - return await this.updateWorkflowUsecase.execute( + savedWorkflow = await this.updateWorkflowUsecase.execute( UpdateWorkflowCommand.create({ id: workflowExist._id, environmentId: command.environmentId, @@ -165,7 +171,7 @@ export class Sync { } const isWorkflowActive = this.castToAnyNotSupportedParam(workflow.options)?.active ?? true; - return this.createWorkflowUsecase.execute( + savedWorkflow = await this.createWorkflowUsecase.execute( CreateWorkflowCommand.create({ notificationGroupId, draft: !isWorkflowActive, @@ -197,6 +203,17 @@ export class Sync { }) ); } + + await this.upsertPreferences.upsertWorkflowPreferences( + UpsertWorkflowPreferencesCommand.create({ + environmentId: savedWorkflow._environmentId, + organizationId: savedWorkflow._organizationId, + templateId: savedWorkflow._id, + preferences: workflow.preferences, + }) + ); + + return savedWorkflow; }) ); } diff --git a/apps/api/src/app/inbox/inbox.module.ts b/apps/api/src/app/inbox/inbox.module.ts index 0885b7713f6..7dcb9813399 100644 --- a/apps/api/src/app/inbox/inbox.module.ts +++ b/apps/api/src/app/inbox/inbox.module.ts @@ -1,14 +1,14 @@ import { Module } from '@nestjs/common'; - -import { USE_CASES } from './usecases'; -import { InboxController } from './inbox.controller'; -import { SharedModule } from '../shared/shared.module'; import { AuthModule } from '../auth/auth.module'; -import { SubscribersModule } from '../subscribers/subscribers.module'; import { IntegrationModule } from '../integrations/integrations.module'; +import { SharedModule } from '../shared/shared.module'; +import { SubscribersModule } from '../subscribers/subscribers.module'; +import { InboxController } from './inbox.controller'; +import { USE_CASES } from './usecases'; +import { PreferencesModule } from '../preferences'; @Module({ - imports: [SharedModule, SubscribersModule, AuthModule, IntegrationModule], + imports: [SharedModule, SubscribersModule, AuthModule, IntegrationModule, PreferencesModule], providers: [...USE_CASES], exports: [...USE_CASES], controllers: [InboxController], 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 ffd6c1f0422..40cae411d44 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 @@ -5,6 +5,9 @@ import { GetSubscriberGlobalPreferenceCommand, GetSubscriberTemplatePreference, GetSubscriberTemplatePreferenceCommand, + UpsertPreferences, + UpsertSubscriberWorkflowPreferencesCommand, + UpsertSubscriberGlobalPreferencesCommand, } from '@novu/application-generic'; import { ChannelTypeEnum, @@ -15,11 +18,14 @@ import { SubscriberPreferenceRepository, SubscriberRepository, } from '@novu/dal'; +import { IPreferenceChannels } from '@novu/shared'; import { ApiException } from '../../../shared/exceptions/api.exception'; import { AnalyticsEventsEnum } from '../../utils'; import { InboxPreference } from '../../utils/types'; import { UpdatePreferencesCommand } from './update-preferences.command'; +const PREFERENCE_DEFAULT_VALUE = true; + @Injectable() export class UpdatePreferences { constructor( @@ -28,7 +34,8 @@ export class UpdatePreferences { private subscriberRepository: SubscriberRepository, private analyticsService: AnalyticsService, private getSubscriberGlobalPreference: GetSubscriberGlobalPreference, - private getSubscriberTemplatePreferenceUsecase: GetSubscriberTemplatePreference + private getSubscriberTemplatePreferenceUsecase: GetSubscriberTemplatePreference, + private upsertPreferences: UpsertPreferences ) {} async execute(command: UpdatePreferencesCommand): Promise { @@ -133,6 +140,15 @@ export class UpdatePreferences { }) ); + await this.storePreferences({ + enabled: preference.enabled, + channels: preference.channels, + organizationId: command.organizationId, + environmentId: command.environmentId, + subscriberId: command.subscriberId, + templateId: workflow._id, + }); + return { level: PreferenceLevelEnum.TEMPLATE, enabled: preference.enabled, @@ -155,6 +171,14 @@ export class UpdatePreferences { }) ); + await this.storePreferences({ + enabled: preference.enabled, + channels: preference.channels, + organizationId: command.organizationId, + environmentId: command.environmentId, + subscriberId: command.subscriberId, + }); + return { level: PreferenceLevelEnum.GLOBAL, enabled: preference.enabled, @@ -171,4 +195,63 @@ export class UpdatePreferences { ...(command.level === PreferenceLevelEnum.TEMPLATE && command.workflowId && { _templateId: command.workflowId }), }; } + + private async storePreferences(item: { + enabled: boolean; + channels: IPreferenceChannels; + organizationId: string; + subscriberId: string; + environmentId: string; + templateId?: string; + }) { + const preferences = { + workflow: { + defaultValue: item.enabled || PREFERENCE_DEFAULT_VALUE, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: item.channels.in_app || PREFERENCE_DEFAULT_VALUE, + readOnly: false, + }, + sms: { + defaultValue: item.channels.sms || PREFERENCE_DEFAULT_VALUE, + readOnly: false, + }, + email: { + defaultValue: item.channels.email || PREFERENCE_DEFAULT_VALUE, + readOnly: false, + }, + push: { + defaultValue: item.channels.push || PREFERENCE_DEFAULT_VALUE, + readOnly: false, + }, + chat: { + defaultValue: item.channels.chat || PREFERENCE_DEFAULT_VALUE, + readOnly: false, + }, + }, + }; + + if (item.templateId) { + return await this.upsertPreferences.upsertSubscriberWorkflowPreferences( + UpsertSubscriberWorkflowPreferencesCommand.create({ + environmentId: item.environmentId, + organizationId: item.organizationId, + subscriberId: item.subscriberId, + templateId: item.templateId, + preferences, + }) + ); + } + + return await this.upsertPreferences.upsertSubscriberGlobalPreferences( + UpsertSubscriberGlobalPreferencesCommand.create({ + preferences, + environmentId: item.environmentId, + organizationId: item.organizationId, + subscriberId: item.subscriberId, + }) + ); + } } diff --git a/apps/api/src/app/preferences/dtos/preferences.dto.ts b/apps/api/src/app/preferences/dtos/preferences.dto.ts new file mode 100644 index 00000000000..18f1e6bb32d --- /dev/null +++ b/apps/api/src/app/preferences/dtos/preferences.dto.ts @@ -0,0 +1,43 @@ +import { ChannelTypeEnum } from '@novu/shared'; +import { Type } from 'class-transformer'; +import { IsBoolean, ValidateNested } from 'class-validator'; + +export class Preference { + @IsBoolean() + defaultValue: boolean; + + @IsBoolean() + readOnly: boolean; +} + +export class Channels { + @ValidateNested({ each: true }) + @Type(() => Preference) + [ChannelTypeEnum.IN_APP]: Preference; + + @ValidateNested({ each: true }) + @Type(() => Preference) + [ChannelTypeEnum.EMAIL]: Preference; + + @ValidateNested({ each: true }) + @Type(() => Preference) + [ChannelTypeEnum.SMS]: Preference; + + @ValidateNested({ each: true }) + @Type(() => Preference) + [ChannelTypeEnum.CHAT]: Preference; + + @ValidateNested({ each: true }) + @Type(() => Preference) + [ChannelTypeEnum.PUSH]: Preference; +} + +export class PreferencesDto { + @ValidateNested({ each: true }) + @Type(() => Preference) + workflow: Preference; + + @ValidateNested({ each: true }) + @Type(() => Channels) + channels: Channels; +} diff --git a/apps/api/src/app/preferences/dtos/upsert-preferences.dto.ts b/apps/api/src/app/preferences/dtos/upsert-preferences.dto.ts new file mode 100644 index 00000000000..2f31ac09eb9 --- /dev/null +++ b/apps/api/src/app/preferences/dtos/upsert-preferences.dto.ts @@ -0,0 +1,12 @@ +import { Type } from 'class-transformer'; +import { IsString, ValidateNested } from 'class-validator'; +import { PreferencesDto } from './preferences.dto'; + +export class UpsertPreferencesDto { + @IsString() + workflowId: string; + + @ValidateNested({ each: true }) + @Type(() => PreferencesDto) + preferences: PreferencesDto; +} diff --git a/apps/api/src/app/preferences/index.ts b/apps/api/src/app/preferences/index.ts new file mode 100644 index 00000000000..9588b8269fd --- /dev/null +++ b/apps/api/src/app/preferences/index.ts @@ -0,0 +1 @@ +export { PreferencesModule } from './preferences.module'; diff --git a/apps/api/src/app/preferences/preferences.controller.ts b/apps/api/src/app/preferences/preferences.controller.ts new file mode 100644 index 00000000000..77826bf044e --- /dev/null +++ b/apps/api/src/app/preferences/preferences.controller.ts @@ -0,0 +1,80 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Get, + NotFoundException, + Post, + Query, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { + GetFeatureFlag, + GetFeatureFlagCommand, + GetPreferences, + GetPreferencesCommand, + UpsertPreferences, + UserAuthGuard, + UserSession, + UpsertUserWorkflowPreferencesCommand, +} from '@novu/application-generic'; +import { FeatureFlagsKeysEnum, UserSessionData } from '@novu/shared'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { UpsertPreferencesDto } from './dtos/upsert-preferences.dto'; + +@Controller('/preferences') +@UseInterceptors(ClassSerializerInterceptor) +@ApiExcludeController() +export class PreferencesController { + constructor( + private upsertPreferences: UpsertPreferences, + private getPreferences: GetPreferences, + private getFeatureFlag: GetFeatureFlag + ) {} + + @Get('/') + @UseGuards(UserAuthGuard) + async get(@UserSession() user: UserSessionData, @Query('workflowId') workflowId: string) { + await this.verifyPreferencesApiAvailability(user); + + return this.getPreferences.execute( + GetPreferencesCommand.create({ + templateId: workflowId, + environmentId: user.environmentId, + organizationId: user.organizationId, + }) + ); + } + + @Post('/') + @UseGuards(UserAuthGuard) + async upsert(@Body() data: UpsertPreferencesDto, @UserSession() user: UserSessionData) { + await this.verifyPreferencesApiAvailability(user); + + return this.upsertPreferences.upsertUserWorkflowPreferences( + UpsertUserWorkflowPreferencesCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + userId: user._id, + preferences: data.preferences, + templateId: data.workflowId, + }) + ); + } + + private async verifyPreferencesApiAvailability(user: UserSessionData) { + const isEnabled = await this.getFeatureFlag.execute( + GetFeatureFlagCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + key: FeatureFlagsKeysEnum.IS_WORKFLOW_PREFERENCES_ENABLED, + }) + ); + + if (!isEnabled) { + throw new NotFoundException(); + } + } +} diff --git a/apps/api/src/app/preferences/preferences.module.ts b/apps/api/src/app/preferences/preferences.module.ts new file mode 100644 index 00000000000..674464ffc36 --- /dev/null +++ b/apps/api/src/app/preferences/preferences.module.ts @@ -0,0 +1,17 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { GetPreferences, UpsertPreferences } from '@novu/application-generic'; +import { PreferencesRepository } from '@novu/dal'; +import { SharedModule } from '../shared/shared.module'; +import { PreferencesController } from './preferences.controller'; + +const PROVIDERS = [PreferencesRepository, UpsertPreferences, GetPreferences]; + +@Module({ + imports: [SharedModule], + providers: [...PROVIDERS], + controllers: [PreferencesController], + exports: [...PROVIDERS], +}) +export class PreferencesModule implements NestModule { + public configure(consumer: MiddlewareConsumer) {} +} diff --git a/apps/api/src/app/preferences/preferences.spec.ts b/apps/api/src/app/preferences/preferences.spec.ts new file mode 100644 index 00000000000..cf6b535fb33 --- /dev/null +++ b/apps/api/src/app/preferences/preferences.spec.ts @@ -0,0 +1,739 @@ +import { Test } from '@nestjs/testing'; +import { + GetPreferences, + UpsertPreferences, + UpsertSubscriberGlobalPreferencesCommand, + UpsertSubscriberWorkflowPreferencesCommand, + UpsertUserWorkflowPreferencesCommand, + UpsertWorkflowPreferencesCommand, +} from '@novu/application-generic'; +import { PreferencesActorEnum, PreferencesRepository } from '@novu/dal'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; +import { AuthModule } from '../auth/auth.module'; +import { PreferencesModule } from './preferences.module'; + +describe('Preferences', function () { + let getPreferences: GetPreferences; + let subscriberId: string; + const workflowId = PreferencesRepository.createObjectId(); + let upsertPreferences: UpsertPreferences; + let session: UserSession; + + beforeEach(async () => { + // @ts-ignore + process.env[FeatureFlagsKeysEnum.IS_WORKFLOW_PREFERENCES_ENABLED] = 'true'; + const moduleRef = await Test.createTestingModule({ + imports: [PreferencesModule, AuthModule], + providers: [], + }).compile(); + + session = new UserSession(); + await session.initialize(); + + subscriberId = session.subscriberId; + + getPreferences = moduleRef.get(GetPreferences); + upsertPreferences = moduleRef.get(UpsertPreferences); + }); + + describe('Upsert preferences', function () { + it('should create workflow preferences', async function () { + const workflowPreferences = await upsertPreferences.upsertWorkflowPreferences( + UpsertWorkflowPreferencesCommand.create({ + preferences: { + workflow: { + defaultValue: false, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + }) + ); + + expect(workflowPreferences._environmentId).to.equal(session.environment._id); + expect(workflowPreferences._organizationId).to.equal(session.organization._id); + expect(workflowPreferences._templateId).to.equal(workflowId); + expect(workflowPreferences._userId).to.be.undefined; + expect(workflowPreferences._subscriberId).to.be.undefined; + expect(workflowPreferences.actor).to.equal(PreferencesActorEnum.WORKFLOW); + }); + + it('should create user workflow preferences', async function () { + const userPreferences = await upsertPreferences.upsertUserWorkflowPreferences( + UpsertUserWorkflowPreferencesCommand.create({ + preferences: { + workflow: { + defaultValue: false, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + userId: session.user._id, + }) + ); + + expect(userPreferences._environmentId).to.equal(session.environment._id); + expect(userPreferences._organizationId).to.equal(session.organization._id); + expect(userPreferences._templateId).to.equal(workflowId); + expect(userPreferences._userId).to.equal(session.user._id); + expect(userPreferences._subscriberId).to.be.undefined; + expect(userPreferences.actor).to.equal(PreferencesActorEnum.USER); + }); + + it('should create global subscriber preferences', async function () { + const subscriberGlobalPreferences = await upsertPreferences.upsertSubscriberGlobalPreferences( + UpsertSubscriberGlobalPreferencesCommand.create({ + preferences: { + workflow: { + defaultValue: false, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + environmentId: session.environment._id, + organizationId: session.organization._id, + subscriberId, + }) + ); + + expect(subscriberGlobalPreferences._environmentId).to.equal(session.environment._id); + expect(subscriberGlobalPreferences._organizationId).to.equal(session.organization._id); + expect(subscriberGlobalPreferences._templateId).to.be.undefined; + expect(subscriberGlobalPreferences._userId).to.be.undefined; + expect(subscriberGlobalPreferences._subscriberId).to.equal(subscriberId); + expect(subscriberGlobalPreferences.actor).to.equal(PreferencesActorEnum.SUBSCRIBER); + }); + + it('should create subscriber workflow preferences', async function () { + const subscriberWorkflowPreferences = await upsertPreferences.upsertSubscriberWorkflowPreferences( + UpsertSubscriberWorkflowPreferencesCommand.create({ + preferences: { + workflow: { + defaultValue: false, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + subscriberId, + }) + ); + + expect(subscriberWorkflowPreferences._environmentId).to.equal(session.environment._id); + expect(subscriberWorkflowPreferences._organizationId).to.equal(session.organization._id); + expect(subscriberWorkflowPreferences._templateId).to.equal(workflowId); + expect(subscriberWorkflowPreferences._userId).to.be.undefined; + expect(subscriberWorkflowPreferences._subscriberId).to.equal(subscriberId); + expect(subscriberWorkflowPreferences.actor).to.equal(PreferencesActorEnum.SUBSCRIBER); + }); + + it('should update preferences', async function () { + let workflowPreferences = await upsertPreferences.upsertWorkflowPreferences( + UpsertWorkflowPreferencesCommand.create({ + preferences: { + workflow: { + defaultValue: false, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + }) + ); + + expect(workflowPreferences._environmentId).to.equal(session.environment._id); + expect(workflowPreferences._organizationId).to.equal(session.organization._id); + expect(workflowPreferences._templateId).to.equal(workflowId); + expect(workflowPreferences._userId).to.be.undefined; + expect(workflowPreferences._subscriberId).to.be.undefined; + expect(workflowPreferences.actor).to.equal(PreferencesActorEnum.WORKFLOW); + + workflowPreferences = await upsertPreferences.upsertWorkflowPreferences( + UpsertWorkflowPreferencesCommand.create({ + preferences: { + workflow: { + defaultValue: false, + readOnly: true, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + }) + ); + + expect(workflowPreferences.preferences.workflow.readOnly).to.be.true; + }); + }); + + describe('Get preferences', function () { + it('should merge preferences when get preferences', async function () { + // Workflow preferences + await upsertPreferences.upsertWorkflowPreferences( + UpsertWorkflowPreferencesCommand.create({ + preferences: { + workflow: { + defaultValue: false, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + }) + ); + + let preferences = await getPreferences.execute({ + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + }); + + expect(preferences).to.deep.equal({ + workflow: { + defaultValue: false, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }); + + // User Workflow preferences + await upsertPreferences.upsertUserWorkflowPreferences( + UpsertUserWorkflowPreferencesCommand.create({ + preferences: { + workflow: { + defaultValue: false, + readOnly: true, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + userId: session.user._id, + }) + ); + + preferences = await getPreferences.execute({ + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + }); + + expect(preferences).to.deep.equal({ + workflow: { + defaultValue: false, + readOnly: true, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }); + + // Subscriber global preferences + await upsertPreferences.upsertSubscriberGlobalPreferences( + UpsertSubscriberGlobalPreferencesCommand.create({ + preferences: { + workflow: { + defaultValue: false, + readOnly: true, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: true, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + environmentId: session.environment._id, + organizationId: session.organization._id, + subscriberId, + }) + ); + + preferences = await getPreferences.execute({ + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + subscriberId, + }); + + expect(preferences).to.deep.equal({ + workflow: { + defaultValue: false, + readOnly: true, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: true, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }); + + // Subscriber Workflow preferences + await upsertPreferences.upsertSubscriberWorkflowPreferences( + UpsertSubscriberWorkflowPreferencesCommand.create({ + preferences: { + workflow: { + defaultValue: false, + readOnly: true, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: true, + }, + sms: { + defaultValue: false, + readOnly: true, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + subscriberId, + }) + ); + + preferences = await getPreferences.execute({ + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + subscriberId, + }); + + expect(preferences).to.deep.equal({ + workflow: { + defaultValue: false, + readOnly: true, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: true, + }, + sms: { + defaultValue: false, + readOnly: true, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }); + }); + }); + + describe('Preferences endpoints', function () { + it('should get preferences', async function () { + const useCase: UpsertPreferences = session.testServer?.getService(UpsertPreferences); + + await useCase.upsertWorkflowPreferences( + UpsertWorkflowPreferencesCommand.create({ + preferences: { + workflow: { + defaultValue: false, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + environmentId: session.environment._id, + organizationId: session.organization._id, + templateId: workflowId, + }) + ); + + const { body } = await session.testAgent.get(`/v1/preferences?workflowId=${workflowId}`).send(); + + expect(body.data).to.deep.equal({ + workflow: { + defaultValue: false, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }); + }); + + it('should upsert preferences', async function () { + const { body } = await session.testAgent.post('/v1/preferences').send({ + workflowId, + preferences: { + workflow: { + defaultValue: false, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }, + }); + + expect(body.data.preferences).to.deep.equal({ + workflow: { + defaultValue: false, + readOnly: false, + }, + channels: { + in_app: { + defaultValue: false, + readOnly: false, + }, + sms: { + defaultValue: false, + readOnly: false, + }, + email: { + defaultValue: false, + readOnly: false, + }, + push: { + defaultValue: false, + readOnly: false, + }, + chat: { + defaultValue: false, + readOnly: false, + }, + }, + }); + }); + }); +}); diff --git a/apps/api/src/app/subscribers/subscribers.module.ts b/apps/api/src/app/subscribers/subscribers.module.ts index d74e6a69710..d34a275441c 100644 --- a/apps/api/src/app/subscribers/subscribers.module.ts +++ b/apps/api/src/app/subscribers/subscribers.module.ts @@ -1,13 +1,16 @@ -import { Module, forwardRef } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TerminusModule } from '@nestjs/terminus'; -import { SharedModule } from '../shared/shared.module'; -import { USE_CASES } from './usecases'; -import { SubscribersController } from './subscribers.controller'; +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'; +import { SubscribersController } from './subscribers.controller'; +import { USE_CASES } from './usecases'; +import { PreferencesModule } from '../preferences'; @Module({ - imports: [SharedModule, AuthModule, TerminusModule, forwardRef(() => WidgetsModule)], + imports: [SharedModule, AuthModule, TerminusModule, forwardRef(() => WidgetsModule), PreferencesModule], controllers: [SubscribersController], providers: [...USE_CASES], exports: [...USE_CASES], diff --git a/apps/api/src/app/workflows/workflow.module.ts b/apps/api/src/app/workflows/workflow.module.ts index 489ecfe0982..4eba215c234 100644 --- a/apps/api/src/app/workflows/workflow.module.ts +++ b/apps/api/src/app/workflows/workflow.module.ts @@ -1,15 +1,16 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; -import { SharedModule } from '../shared/shared.module'; -import { USE_CASES } from './usecases'; -import { NotificationTemplateController } from './notification-template.controller'; -import { MessageTemplateModule } from '../message-template/message-template.module'; -import { ChangeModule } from '../change/change.module'; import { AuthModule } from '../auth/auth.module'; +import { ChangeModule } from '../change/change.module'; import { IntegrationModule } from '../integrations/integrations.module'; +import { MessageTemplateModule } from '../message-template/message-template.module'; +import { SharedModule } from '../shared/shared.module'; +import { NotificationTemplateController } from './notification-template.controller'; +import { USE_CASES } from './usecases'; import { WorkflowController } from './workflow.controller'; +import { PreferencesModule } from '../preferences'; @Module({ - imports: [SharedModule, MessageTemplateModule, ChangeModule, AuthModule, IntegrationModule], + imports: [SharedModule, MessageTemplateModule, ChangeModule, AuthModule, IntegrationModule, PreferencesModule], controllers: [NotificationTemplateController, WorkflowController], providers: [...USE_CASES], exports: [...USE_CASES], diff --git a/apps/web/src/hooks/workflowChannelPreferences/useCloudWorkflowChannelPreferences.ts b/apps/web/src/hooks/workflowChannelPreferences/useCloudWorkflowChannelPreferences.ts index fe7d7b52f0b..a58c546f597 100644 --- a/apps/web/src/hooks/workflowChannelPreferences/useCloudWorkflowChannelPreferences.ts +++ b/apps/web/src/hooks/workflowChannelPreferences/useCloudWorkflowChannelPreferences.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { QueryKeys } from '../../api/query.keys'; import { useNovuAPI } from '../useNovuAPI'; -import { WorkflowChannelPreferences } from './types'; +import { WorkflowChannelPreferences } from '@novu/shared'; export const useCloudWorkflowChannelPreferences = ( workflowId: string diff --git a/apps/web/src/hooks/workflowChannelPreferences/useStudioWorkflowChannelPreferences.ts b/apps/web/src/hooks/workflowChannelPreferences/useStudioWorkflowChannelPreferences.ts index ee327317b73..10ec62128c0 100644 --- a/apps/web/src/hooks/workflowChannelPreferences/useStudioWorkflowChannelPreferences.ts +++ b/apps/web/src/hooks/workflowChannelPreferences/useStudioWorkflowChannelPreferences.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useDiscover } from '../../studio/hooks/useBridgeAPI'; -import { WorkflowChannelPreferences } from './types'; +import { WorkflowChannelPreferences } from '@novu/shared'; export const useStudioWorkflowChannelPreferences = ( workflowId: string diff --git a/apps/web/src/hooks/workflowChannelPreferences/useUpdateWorkflowChannelPreferences.ts b/apps/web/src/hooks/workflowChannelPreferences/useUpdateWorkflowChannelPreferences.ts index 72c01e2dc8f..d149dc15d47 100644 --- a/apps/web/src/hooks/workflowChannelPreferences/useUpdateWorkflowChannelPreferences.ts +++ b/apps/web/src/hooks/workflowChannelPreferences/useUpdateWorkflowChannelPreferences.ts @@ -1,8 +1,7 @@ -import { IResponseError } from '@novu/shared'; +import { IResponseError, WorkflowChannelPreferences } from '@novu/shared'; import { useMutation } from '@tanstack/react-query'; import { errorMessage, successMessage } from '../../utils/notifications'; import { useNovuAPI } from '../useNovuAPI'; -import { WorkflowChannelPreferences } from './types'; export const useUpdateWorkflowChannelPreferences = ( workflowId: string diff --git a/apps/worker/src/app/workflow/workflow.module.ts b/apps/worker/src/app/workflow/workflow.module.ts index df2eb2e56f8..5584e8f994a 100644 --- a/apps/worker/src/app/workflow/workflow.module.ts +++ b/apps/worker/src/app/workflow/workflow.module.ts @@ -26,8 +26,9 @@ import { CompileInAppTemplate, WorkflowInMemoryProviderService, ExecutionLogRoute, + GetPreferences, } from '@novu/application-generic'; -import { CommunityOrganizationRepository, JobRepository } from '@novu/dal'; +import { CommunityOrganizationRepository, JobRepository, PreferencesRepository } from '@novu/dal'; import { Type } from '@nestjs/common/interfaces/type.interface'; import { ForwardReference } from '@nestjs/common/interfaces/modules/forward-reference.interface'; @@ -83,7 +84,7 @@ const enterpriseImports = (): Array { + const isEnabled = await this.getFeatureFlag.execute( + GetFeatureFlagCommand.create({ + userId: 'system', + environmentId: command.environmentId, + organizationId: command.organizationId, + key: FeatureFlagsKeysEnum.IS_WORKFLOW_PREFERENCES_ENABLED, + }), + ); + + if (!isEnabled) { + throw new NotFoundException(); + } + + const items = await this.getPreferencesFromDb(command); + + if (items.length === 0) { + throw new NotFoundException('We could not find any preferences'); + } + + const workflowPreferences = this.getWorkflowPreferences(items); + + const userPreferences = this.getUserPreferences(items); + + const subscriberGlobalPreferences = + this.getSubscriberGlobalPreferences(items); + + const subscriberWorkflowPreferences = this.getSubscriberWorkflowPreferences( + items, + command.templateId, + ); + + /* + * Order is important here because we like the workflowPreferences (that comes from the bridge) + * to be overridden by any other preferences and then we have preferences defined in dashboard and + * then subscribers global preferences and the once that should be used if it says other then anything before it + * we use subscribers workflow preferences + */ + const preferences = [ + workflowPreferences, + userPreferences, + subscriberGlobalPreferences, + subscriberWorkflowPreferences, + ] + .filter((preference) => preference !== undefined) + .map((item) => item.preferences); + + return deepMerge(preferences); + } + + public async getPreferenceChannels(command: { + environmentId: string; + organizationId: string; + subscriberId: string; + templateId?: string; + }): Promise { + try { + const result = await this.execute( + GetPreferencesCommand.create({ + environmentId: command.environmentId, + organizationId: command.organizationId, + subscriberId: command.subscriberId, + templateId: command.templateId, + }), + ); + + return { + in_app: + result.channels.in_app.defaultValue || result.workflow.defaultValue, + sms: result.channels.sms.defaultValue || result.workflow.defaultValue, + email: + result.channels.email.defaultValue || result.workflow.defaultValue, + push: result.channels.push.defaultValue || result.workflow.defaultValue, + chat: result.channels.chat.defaultValue || result.workflow.defaultValue, + }; + } catch (e) { + // If we cant find preferences lets return undefined instead of throwing it up to caller to make it easier for caller to handle. + if ((e as Error).name === NotFoundException.name) { + return undefined; + } + throw e; + } + } + + private getSubscriberWorkflowPreferences( + items: PreferencesEntity[], + templateId: string, + ) { + return items.find( + (item) => + item.type === PreferencesTypeEnum.SUBSCRIBER_WORKFLOW && + item._templateId == templateId, + ); + } + + private getSubscriberGlobalPreferences(items: PreferencesEntity[]) { + return items.find( + (item) => item.type === PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + ); + } + + private getUserPreferences(items: PreferencesEntity[]) { + return items.find( + (item) => item.type === PreferencesTypeEnum.USER_WORKFLOW, + ); + } + + private getWorkflowPreferences(items: PreferencesEntity[]) { + return items.find( + (item) => item.type === PreferencesTypeEnum.WORKFLOW_RESOURCE, + ); + } + + private async getPreferencesFromDb(command: GetPreferencesCommand) { + const items: PreferencesEntity[] = []; + + if (command.templateId) { + const workflowPreferences = await this.preferencesRepository.find({ + _templateId: command.templateId, + _environmentId: command.environmentId, + actor: { + $ne: PreferencesActorEnum.SUBSCRIBER, + }, + }); + + items.push(...workflowPreferences); + } + + if (command.subscriberId) { + const subscriberPreferences = await this.preferencesRepository.find({ + _subscriberId: command.subscriberId, + _environmentId: command.environmentId, + actor: PreferencesActorEnum.SUBSCRIBER, + }); + + items.push(...subscriberPreferences); + } + + return items; + } +} diff --git a/libs/application-generic/src/usecases/get-preferences/index.ts b/libs/application-generic/src/usecases/get-preferences/index.ts new file mode 100644 index 00000000000..134e74b93c7 --- /dev/null +++ b/libs/application-generic/src/usecases/get-preferences/index.ts @@ -0,0 +1,2 @@ +export * from './get-preferences.command'; +export * from './get-preferences.usecase'; diff --git a/libs/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts b/libs/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts index 87ad88afcdd..e266dc78e22 100644 --- a/libs/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts +++ b/libs/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts @@ -10,12 +10,14 @@ import { IPreferenceChannels } from '@novu/shared'; import { GetSubscriberGlobalPreferenceCommand } from './get-subscriber-global-preference.command'; import { buildSubscriberKey, CachedEntity } from '../../services/cache'; import { ApiException } from '../../utils/exceptions'; +import { GetPreferences } from '../get-preferences'; @Injectable() export class GetSubscriberGlobalPreference { constructor( private subscriberPreferenceRepository: SubscriberPreferenceRepository, private subscriberRepository: SubscriberRepository, + private getPreferences: GetPreferences ) {} async execute(command: GetSubscriberGlobalPreferenceCommand) { @@ -37,9 +39,14 @@ export class GetSubscriberGlobalPreference { level: PreferenceLevelEnum.GLOBAL, }); - const subscriberChannelPreference = subscriberPreference?.channels; + const subscriberChannelPreference = + (await this.getPreferences.getPreferenceChannels({ + environmentId: command.environmentId, + organizationId: command.organizationId, + subscriberId: command.subscriberId, + })) || subscriberPreference?.channels; const channels = this.updatePreferenceStateWithDefault( - subscriberChannelPreference ?? {}, + subscriberChannelPreference ?? {} ); return { @@ -66,7 +73,7 @@ export class GetSubscriberGlobalPreference { }): Promise { return await this.subscriberRepository.findBySubscriberId( _environmentId, - subscriberId, + subscriberId ); } // adds default state for missing channels diff --git a/libs/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts b/libs/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts index b546d7e8a56..06bb52312b9 100644 --- a/libs/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts +++ b/libs/application-generic/src/usecases/get-subscriber-template-preference/get-subscriber-template-preference.usecase.ts @@ -23,6 +23,7 @@ import { GetSubscriberTemplatePreferenceCommand } from './get-subscriber-templat import { ApiException } from '../../utils/exceptions'; import { CachedEntity, buildSubscriberKey } from '../../services/cache'; +import { GetPreferences, GetPreferencesCommand } from '../get-preferences'; const PRIORITY_ORDER = [ PreferenceOverrideSourceEnum.TEMPLATE, @@ -38,10 +39,11 @@ export class GetSubscriberTemplatePreference { private subscriberRepository: SubscriberRepository, private workflowOverrideRepository: WorkflowOverrideRepository, private tenantRepository: TenantRepository, + private getPreferences: GetPreferences ) {} async execute( - command: GetSubscriberTemplatePreferenceCommand, + command: GetSubscriberTemplatePreferenceCommand ): Promise { const subscriber = command.subscriber ?? @@ -63,12 +65,18 @@ export class GetSubscriberTemplatePreference { _templateId: command.template._id, }, 'enabled channels', - { readPreference: 'secondaryPreferred' }, + { readPreference: 'secondaryPreferred' } ); const workflowOverride = await this.getWorkflowOverride(command); const templateChannelPreference = command.template.preferenceSettings; - const subscriberChannelPreference = subscriberPreference?.channels; + const subscriberChannelPreference = + (await this.getPreferences.getPreferenceChannels({ + environmentId: command.environmentId, + organizationId: command.organizationId, + subscriberId: command.subscriberId, + templateId: command.template._id, + })) || subscriberPreference?.channels; const workflowOverrideChannelPreference = workflowOverride?.preferenceSettings; @@ -78,7 +86,7 @@ export class GetSubscriberTemplatePreference { subscriber: subscriberChannelPreference, workflowOverride: workflowOverrideChannelPreference, }, - initialActiveChannels, + initialActiveChannels ); return { @@ -92,7 +100,7 @@ export class GetSubscriberTemplatePreference { } private async getWorkflowOverride( - command: GetSubscriberTemplatePreferenceCommand, + command: GetSubscriberTemplatePreferenceCommand ) { if (!command.tenant?.identifier) { return null; @@ -116,7 +124,7 @@ export class GetSubscriberTemplatePreference { } private async getActiveChannels( - command: GetSubscriberTemplatePreferenceCommand, + command: GetSubscriberTemplatePreferenceCommand ): Promise { const activeChannels = await this.queryActiveChannels(command); const initialActiveChannels = filteredPreference( @@ -127,17 +135,17 @@ export class GetSubscriberTemplatePreference { chat: true, push: true, }, - activeChannels, + activeChannels ); return initialActiveChannels; } private async queryActiveChannels( - command: GetSubscriberTemplatePreferenceCommand, + command: GetSubscriberTemplatePreferenceCommand ): Promise { const activeSteps = command.template.steps.filter( - (step) => step.active === true, + (step) => step.active === true ); const stepMissingTemplate = activeSteps.some((step) => !step.template); @@ -155,8 +163,8 @@ export class GetSubscriberTemplatePreference { return [ ...new Set( messageTemplates.map( - (messageTemplate) => messageTemplate.type, - ) as unknown as ChannelTypeEnum[], + (messageTemplate) => messageTemplate.type + ) as unknown as ChannelTypeEnum[] ), ]; } @@ -191,7 +199,7 @@ export class GetSubscriberTemplatePreference { }): Promise { return await this.subscriberRepository.findBySubscriberId( _environmentId, - subscriberId, + subscriberId ); } } @@ -200,7 +208,7 @@ function updateOverrideReasons( channelName, sourceName: PreferenceOverrideSourceEnum, index: number, - overrideReasons: IPreferenceOverride[], + overrideReasons: IPreferenceOverride[] ) { const currentOverride: IPreferenceOverride = { channel: channelName as ChannelTypeEnum, @@ -223,7 +231,7 @@ function overridePreference( channels: IPreferenceChannels; }, sourcePreference: IPreferenceChannels, - sourceName: PreferenceOverrideSourceEnum, + sourceName: PreferenceOverrideSourceEnum ) { const channels = { ...oldPreferenceState.channels }; const overrides = [...oldPreferenceState.overrides]; @@ -232,7 +240,7 @@ function overridePreference( if (typeof channels[channelName] !== 'boolean') continue; const index = overrides.findIndex( - (overrideReason) => overrideReason.channel === channelName, + (overrideReason) => overrideReason.channel === channelName ); const isSameReason = overrides[index]?.source !== channelValue; @@ -251,7 +259,7 @@ function overridePreference( export function overridePreferences( preferenceSources: IOverridePreferencesSources, - initialActiveChannels: IPreferenceChannels, + initialActiveChannels: IPreferenceChannels ) { let result: { overrides: IPreferenceOverride[]; @@ -277,16 +285,16 @@ export function overridePreferences( export const filteredPreference = ( preferences: IPreferenceChannels, - filterKeys: string[], + filterKeys: string[] ): IPreferenceChannels => Object.entries(preferences).reduce( (obj, [key, value]) => filterKeys.includes(key) ? { ...obj, [key]: value } : obj, - {}, + {} ); function mapTemplateConfiguration( - template: NotificationTemplateEntity, + template: NotificationTemplateEntity ): ITemplateConfiguration { return { _id: template._id, diff --git a/libs/application-generic/src/usecases/index.ts b/libs/application-generic/src/usecases/index.ts index 16b664a4447..3bfb8944312 100644 --- a/libs/application-generic/src/usecases/index.ts +++ b/libs/application-generic/src/usecases/index.ts @@ -42,3 +42,5 @@ export * from './workflow'; export * from './message-template'; export * from './subscribers'; export * from './execute-bridge-request'; +export * from './upsert-preferences'; +export * from './get-preferences'; diff --git a/libs/application-generic/src/usecases/upsert-preferences/index.ts b/libs/application-generic/src/usecases/upsert-preferences/index.ts new file mode 100644 index 00000000000..ee3d854e643 --- /dev/null +++ b/libs/application-generic/src/usecases/upsert-preferences/index.ts @@ -0,0 +1,5 @@ +export * from './upsert-preferences.usecase'; +export * from './upsert-subscriber-global-preferences.command'; +export * from './upsert-subscriber-workflow-preferences.command'; +export * from './upsert-user-workflow-preferences.command'; +export * from './upsert-workflow-preferences.command'; diff --git a/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.command.ts b/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.command.ts new file mode 100644 index 00000000000..a27bddfc7c0 --- /dev/null +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.command.ts @@ -0,0 +1,21 @@ +import { IsDefined, IsEnum } from 'class-validator'; +import { DiscoverWorkflowOutputPreferences } from '@novu/framework'; +import { EnvironmentCommand } from '../../commands'; +import { PreferencesActorEnum, PreferencesTypeEnum } from '@novu/dal'; + +export class UpsertPreferencesCommand extends EnvironmentCommand { + @IsDefined() + readonly preferences: DiscoverWorkflowOutputPreferences; + + subscriberId?: string; + + userId?: string; + + templateId?: string; + + @IsEnum(PreferencesActorEnum) + readonly actor: PreferencesActorEnum; + + @IsEnum(PreferencesTypeEnum) + readonly type: PreferencesTypeEnum; +} 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 new file mode 100644 index 00000000000..e5b69aa4112 --- /dev/null +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.usecase.ts @@ -0,0 +1,139 @@ +import { Injectable } from '@nestjs/common'; +import { + PreferencesActorEnum, + PreferencesEntity, + PreferencesRepository, + PreferencesTypeEnum, +} from '@novu/dal'; +import { UpsertPreferencesCommand } from './upsert-preferences.command'; +import { UpsertWorkflowPreferencesCommand } from './upsert-workflow-preferences.command'; +import { UpsertSubscriberGlobalPreferencesCommand } from './upsert-subscriber-global-preferences.command'; +import { UpsertSubscriberWorkflowPreferencesCommand } from './upsert-subscriber-workflow-preferences.command'; +import { UpsertUserWorkflowPreferencesCommand } from './upsert-user-workflow-preferences.command'; + +@Injectable() +export class UpsertPreferences { + constructor(private preferencesRepository: PreferencesRepository) {} + + public async upsertWorkflowPreferences( + command: UpsertWorkflowPreferencesCommand, + ) { + return this.upsert({ + templateId: command.templateId, + environmentId: command.environmentId, + organizationId: command.organizationId, + actor: PreferencesActorEnum.WORKFLOW, + preferences: command.preferences, + type: PreferencesTypeEnum.WORKFLOW_RESOURCE, + }); + } + + public async upsertSubscriberGlobalPreferences( + command: UpsertSubscriberGlobalPreferencesCommand, + ) { + return this.upsert({ + subscriberId: command.subscriberId, + environmentId: command.environmentId, + organizationId: command.organizationId, + actor: PreferencesActorEnum.SUBSCRIBER, + preferences: command.preferences, + type: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + }); + } + + public async upsertSubscriberWorkflowPreferences( + command: UpsertSubscriberWorkflowPreferencesCommand, + ) { + return this.upsert({ + subscriberId: command.subscriberId, + environmentId: command.environmentId, + organizationId: command.organizationId, + actor: PreferencesActorEnum.SUBSCRIBER, + preferences: command.preferences, + templateId: command.templateId, + type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + }); + } + + public async upsertUserWorkflowPreferences( + command: UpsertUserWorkflowPreferencesCommand, + ) { + return this.upsert({ + userId: command.userId, + environmentId: command.environmentId, + organizationId: command.organizationId, + actor: PreferencesActorEnum.USER, + preferences: command.preferences, + templateId: command.templateId, + type: PreferencesTypeEnum.USER_WORKFLOW, + }); + } + + private async upsert( + command: UpsertPreferencesCommand, + ): Promise { + const foundId = await this.getPreferencesId(command); + + if (foundId) { + return this.updatePreferences(foundId, command); + } + + return this.createPreferences(command); + } + + private async createPreferences( + command: UpsertPreferencesCommand, + ): Promise { + return await this.preferencesRepository.create({ + _subscriberId: command.subscriberId, + _userId: command.userId, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + _templateId: command.templateId, + actor: command.actor, + preferences: command.preferences, + type: command.type, + }); + } + + private async updatePreferences( + preferencesId: string, + command: UpsertPreferencesCommand, + ): Promise { + await this.preferencesRepository.update( + { + _id: preferencesId, + _environmentId: command.environmentId, + }, + { + $set: { + preferences: command.preferences, + _userId: command.userId, + }, + }, + ); + + return await this.preferencesRepository.findOne({ + _id: preferencesId, + _environmentId: command.environmentId, + }); + } + + private async getPreferencesId( + command: UpsertPreferencesCommand, + ): Promise { + const found = await this.preferencesRepository.findOne( + { + _subscriberId: command.subscriberId, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + _templateId: command.templateId, + actor: command.actor, + type: command.type, + }, + '_id', + ); + + return found?._id; + } +} diff --git a/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts b/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts new file mode 100644 index 00000000000..40a25f437ba --- /dev/null +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-global-preferences.command.ts @@ -0,0 +1,11 @@ +import { DiscoverWorkflowOutputPreferences } from '@novu/framework'; +import { EnvironmentCommand } from '../../commands'; +import { IsDefined, IsNotEmpty } from 'class-validator'; + +export class UpsertSubscriberGlobalPreferencesCommand extends EnvironmentCommand { + @IsDefined() + readonly preferences: DiscoverWorkflowOutputPreferences; + + @IsNotEmpty() + subscriberId: string; +} diff --git a/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-workflow-preferences.command.ts b/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-workflow-preferences.command.ts new file mode 100644 index 00000000000..a46863d8566 --- /dev/null +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-subscriber-workflow-preferences.command.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty } from 'class-validator'; +import { UpsertSubscriberGlobalPreferencesCommand } from './upsert-subscriber-global-preferences.command'; + +export class UpsertSubscriberWorkflowPreferencesCommand extends UpsertSubscriberGlobalPreferencesCommand { + @IsNotEmpty() + templateId: string; +} diff --git a/libs/application-generic/src/usecases/upsert-preferences/upsert-user-workflow-preferences.command.ts b/libs/application-generic/src/usecases/upsert-preferences/upsert-user-workflow-preferences.command.ts new file mode 100644 index 00000000000..7161a56f3cb --- /dev/null +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-user-workflow-preferences.command.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty } from 'class-validator'; +import { UpsertWorkflowPreferencesCommand } from './upsert-workflow-preferences.command'; + +export class UpsertUserWorkflowPreferencesCommand extends UpsertWorkflowPreferencesCommand { + @IsNotEmpty() + userId: string; +} diff --git a/libs/application-generic/src/usecases/upsert-preferences/upsert-workflow-preferences.command.ts b/libs/application-generic/src/usecases/upsert-preferences/upsert-workflow-preferences.command.ts new file mode 100644 index 00000000000..65a3c2e8989 --- /dev/null +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-workflow-preferences.command.ts @@ -0,0 +1,11 @@ +import { DiscoverWorkflowOutputPreferences } from '@novu/framework'; +import { EnvironmentCommand } from '../../commands'; +import { IsDefined, IsNotEmpty } from 'class-validator'; + +export class UpsertWorkflowPreferencesCommand extends EnvironmentCommand { + @IsDefined() + readonly preferences: DiscoverWorkflowOutputPreferences; + + @IsNotEmpty() + templateId: string; +} diff --git a/libs/application-generic/src/utils/deepmerge.ts b/libs/application-generic/src/utils/deepmerge.ts new file mode 100644 index 00000000000..88c7f03ceea --- /dev/null +++ b/libs/application-generic/src/utils/deepmerge.ts @@ -0,0 +1,232 @@ +// from: https://github.com/TehShrike/deepmerge/tree/master + +function isMergeableObject(value: unknown) { + return isNonNullObject(value) && !isSpecial(value as Record); +} + +function isNonNullObject(value: unknown) { + return !!value && typeof value === 'object'; +} + +function isSpecial(value: Record) { + const stringValue = Object.prototype.toString.call(value); + + return ( + stringValue === '[object RegExp]' || + stringValue === '[object Date]' || + stringValue === '[object Uint8Array]' + ); +} + +function emptyTarget(val: unknown) { + return Array.isArray(val) ? [] : {}; +} + +function cloneUnlessOtherwiseSpecified( + value: Record, + options: IOptions +): Record | Record[] { + return options.clone !== false && options.isMergeableObject(value) + ? deepMergeObjects(emptyTarget(value), value, options) + : value; +} + +function defaultArrayMerge( + target: Record[], + source: Record[], + options: IOptions +): Record[] { + return target.concat(source).map(function (element) { + return cloneUnlessOtherwiseSpecified( + element, + options + ) as Record; + }); +} + +function getMergeFunction(key: string, options: IOptions) { + if (!options.customMerge) { + return deepMergeObjects; + } + const customMerge = options.customMerge(key); + + return typeof customMerge === 'function' ? customMerge : deepMergeObjects; +} + +function getKeys(target: Record): unknown[] { + return Object.keys(target); +} + +function propertyIsOnObject(object: Record, property: string) { + try { + return property in object; + } catch (_) { + return false; + } +} + +// Protects from prototype poisoning and unexpected merging up the prototype chain. +function propertyIsUnsafe(target: Record, key: string) { + return ( + propertyIsOnObject(target, key) && // Properties are safe to merge if they don't exist in the target yet, + !( + Object.hasOwnProperty.call(target, key) && // unsafe if they exist up the prototype chain, + Object.propertyIsEnumerable.call(target, key) + ) + ); // and also unsafe if they're nonenumerable. +} + +function mergeObject( + target: Record, + source: Record, + options: IOptions +): Record { + const destination = {}; + if (options.isMergeableObject(target)) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + getKeys(target).forEach((key: string) => { + destination[key] = cloneUnlessOtherwiseSpecified( + target[key] as Record, + options + ); + }); + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + getKeys(source).forEach(function (key: string) { + if (propertyIsUnsafe(target, key as string)) { + return; + } + + if ( + propertyIsOnObject(target, key as string) && + options.isMergeableObject(source[key]) + ) { + destination[key] = getMergeFunction(key as string, options)( + target[key] as Record, + source[key] as Record, + options + ); + } else { + destination[key] = cloneUnlessOtherwiseSpecified( + source[key] as Record, + options + ); + } + }); + + return destination; +} + +interface IOptions { + customMerge: ( + key: string + ) => ( + target: Record, + source: Record, + options: IOptions + ) => Record; + arrayMerge: ( + target: Record[], + source: Record[], + options: IOptions + ) => Record[]; + isMergeableObject: (value: unknown) => boolean; + cloneUnlessOtherwiseSpecified: ( + value: Record, + options: IOptions + ) => Record | Record[]; + clone?: boolean; +} + +interface IDeepMergeOptions { + customMerge?: ( + key: string + ) => ( + target: Record, + source: Record, + options: IOptions + ) => Record; + arrayMerge?: ( + target: Record[], + source: Record[], + options: IOptions + ) => Record[]; + isMergeableObject?: (value: unknown) => boolean; + cloneUnlessOtherwiseSpecified?: ( + value: Record, + options: IOptions + ) => Record | Record[]; + clone?: boolean; +} + +/** + * Merges two objects or arrays of objects using deepMerge. The second object + * takes precedence for any keys that are present in both objects. + * @param source - The source object or array of objects to merge from. + * @param target - The target object or array of objects to merge into. + * @param options - The options to pass to deepMerge. + * @returns The merged object or array of objects. + */ +function deepMergeObjects< + T extends Record | Record[] +>( + target: Record | Record[], + source: Record | Record[], + options?: IDeepMergeOptions +): T { + options = options || {}; + options.arrayMerge = options.arrayMerge || defaultArrayMerge; + options.isMergeableObject = options.isMergeableObject || isMergeableObject; + /* + * cloneUnlessOtherwiseSpecified is added to `options` so that custom arrayMerge() + * implementations can use it. The caller may not replace it. + */ + options.cloneUnlessOtherwiseSpecified = cloneUnlessOtherwiseSpecified; + + const sourceIsArray = Array.isArray(source); + const targetIsArray = Array.isArray(target); + const sourceAndTargetTypesMatch = sourceIsArray === targetIsArray; + + if (!sourceAndTargetTypesMatch) { + return cloneUnlessOtherwiseSpecified( + source as Record, + options as IOptions + ) as T; + } + if (sourceIsArray) { + return options.arrayMerge( + target as Record[], + source as Record[], + options as IOptions + ) as T; + } + + return mergeObject( + target as Record, + source, + options as IOptions + ) as T; +} + +/** + * Merges an array of objects using deepMerge. Items later in the array take + * precedence for any keys that are present in multiple objects. + * + * @param array - The array of objects to merge. + * @param options - The options to pass to deepMerge. + * @returns The merged object. + */ +export function deepMerge>( + array: T[], + options?: IDeepMergeOptions +): T { + if (!Array.isArray(array)) { + throw new Error('first argument should be an array'); + } + + return array.reduce(function (prev, next) { + return deepMergeObjects(prev, next, options); + }, {} as T); +} diff --git a/libs/application-generic/src/utils/index.ts b/libs/application-generic/src/utils/index.ts index 5cb6c29329e..dc1cb77b7b7 100644 --- a/libs/application-generic/src/utils/index.ts +++ b/libs/application-generic/src/utils/index.ts @@ -10,3 +10,4 @@ export * from './object'; export * from './bridge'; export * from './subscriber'; export * from './variants'; +export * from './deepmerge'; diff --git a/libs/dal/src/index.ts b/libs/dal/src/index.ts index 1ab9cc36fc6..20af701fd07 100644 --- a/libs/dal/src/index.ts +++ b/libs/dal/src/index.ts @@ -25,4 +25,5 @@ export * from './shared'; export * from './repositories/base-repository'; export * from './repositories/schema-default.options'; export * from './repositories/control-variables'; +export * from './repositories/preferences'; export * from './types'; diff --git a/libs/dal/src/repositories/preferences/index.ts b/libs/dal/src/repositories/preferences/index.ts new file mode 100644 index 00000000000..22578b048f6 --- /dev/null +++ b/libs/dal/src/repositories/preferences/index.ts @@ -0,0 +1,3 @@ +export * from './preferences.entity'; +export * from './preferences.schema'; +export * from './preferences.repository'; diff --git a/libs/dal/src/repositories/preferences/preferences.entity.ts b/libs/dal/src/repositories/preferences/preferences.entity.ts new file mode 100644 index 00000000000..c4d1ced0b08 --- /dev/null +++ b/libs/dal/src/repositories/preferences/preferences.entity.ts @@ -0,0 +1,44 @@ +import { WorkflowChannelPreferences } from '@novu/shared'; +import type { OrganizationId } from '../organization'; +import type { EnvironmentId } from '../environment'; +import type { SubscriberId } from '../subscriber'; +import { UserId } from '../user'; +import { ChangePropsValueType } from '../../types'; + +export enum PreferencesTypeEnum { + SUBSCRIBER_GLOBAL = 'SUBSCRIBER_GLOBAL', + SUBSCRIBER_WORKFLOW = 'SUBSCRIBER_WORKFLOW', + USER_WORKFLOW = 'USER_WORKFLOW', + WORKFLOW_RESOURCE = 'WORKFLOW_RESOURCE', +} + +export enum PreferencesActorEnum { + USER = 'USER', + SUBSCRIBER = 'SUBSCRIBER', + WORKFLOW = 'WORKFLOW', +} + +export type PreferencesDBModel = ChangePropsValueType< + PreferencesEntity, + '_environmentId' | '_organizationId' | '_subscriberId' | '_templateId' | '_userId' +>; + +export class PreferencesEntity { + _id: string; + + _organizationId: OrganizationId; + + _environmentId: EnvironmentId; + + _subscriberId?: SubscriberId; + + _userId?: UserId; + + _templateId?: string; + + actor: PreferencesActorEnum; + + type: PreferencesTypeEnum; + + preferences: WorkflowChannelPreferences; +} diff --git a/libs/dal/src/repositories/preferences/preferences.repository.ts b/libs/dal/src/repositories/preferences/preferences.repository.ts new file mode 100644 index 00000000000..96aa65f1ff5 --- /dev/null +++ b/libs/dal/src/repositories/preferences/preferences.repository.ts @@ -0,0 +1,43 @@ +import { FilterQuery } from 'mongoose'; +import { SoftDeleteModel } from 'mongoose-delete'; + +import { BaseRepository } from '../base-repository'; +import { DalException } from '../../shared'; +import type { EnforceEnvOrOrgIds } from '../../types/enforce'; +import { PreferencesDBModel, PreferencesEntity } from './preferences.entity'; +import { Preferences } from './preferences.schema'; + +type PreferencesQuery = FilterQuery & EnforceEnvOrOrgIds; + +export class PreferencesRepository extends BaseRepository { + private preferences: SoftDeleteModel; + + constructor() { + super(Preferences, PreferencesEntity); + this.preferences = Preferences; + } + + async findById(id: string, environmentId: string) { + const requestQuery: PreferencesQuery = { + _id: id, + _environmentId: environmentId, + }; + + const item = await this.MongooseModel.findOne(requestQuery); + + return this.mapEntity(item); + } + + async delete(query: PreferencesQuery) { + const item = await this.findOne({ _id: query._id, _environmentId: query._environmentId }); + if (!item) throw new DalException(`Could not find preferences with id ${query._id}`); + + return await this.preferences.delete({ _id: item._id, _environmentId: item._environmentId }); + } + + async findDeleted(query: PreferencesQuery): Promise { + const res: PreferencesEntity = await this.preferences.findDeleted(query); + + return this.mapEntity(res); + } +} diff --git a/libs/dal/src/repositories/preferences/preferences.schema.ts b/libs/dal/src/repositories/preferences/preferences.schema.ts new file mode 100644 index 00000000000..081f3c3caf5 --- /dev/null +++ b/libs/dal/src/repositories/preferences/preferences.schema.ts @@ -0,0 +1,104 @@ +import mongoose, { Schema } from 'mongoose'; +import { ChannelTypeEnum } from '@novu/shared'; +import { schemaOptions } from '../schema-default.options'; +import { PreferencesDBModel } from './preferences.entity'; + +const mongooseDelete = require('mongoose-delete'); + +const preferencesSchema = new Schema( + { + _environmentId: { + type: Schema.Types.ObjectId, + ref: 'Environment', + }, + _organizationId: { + type: Schema.Types.ObjectId, + ref: 'Organization', + }, + _subscriberId: { + type: Schema.Types.ObjectId, + ref: 'Subscriber', + }, + _userId: { + type: Schema.Types.ObjectId, + ref: 'User', + }, + _templateId: { + type: Schema.Types.ObjectId, + ref: 'NotificationTemplate', + }, + actor: Schema.Types.String, + type: Schema.Types.String, + preferences: { + workflow: { + defaultValue: { + type: Schema.Types.Boolean, + default: true, + }, + readOnly: { + type: Schema.Types.Boolean, + default: false, + }, + }, + channels: { + [ChannelTypeEnum.EMAIL]: { + defaultValue: { + type: Schema.Types.Boolean, + default: true, + }, + readOnly: { + type: Schema.Types.Boolean, + default: false, + }, + }, + [ChannelTypeEnum.SMS]: { + defaultValue: { + type: Schema.Types.Boolean, + default: true, + }, + readOnly: { + type: Schema.Types.Boolean, + default: false, + }, + }, + [ChannelTypeEnum.IN_APP]: { + defaultValue: { + type: Schema.Types.Boolean, + default: true, + }, + readOnly: { + type: Schema.Types.Boolean, + default: false, + }, + }, + [ChannelTypeEnum.CHAT]: { + defaultValue: { + type: Schema.Types.Boolean, + default: true, + }, + readOnly: { + type: Schema.Types.Boolean, + default: false, + }, + }, + [ChannelTypeEnum.PUSH]: { + defaultValue: { + type: Schema.Types.Boolean, + default: true, + }, + readOnly: { + type: Schema.Types.Boolean, + default: false, + }, + }, + }, + }, + }, + { ...schemaOptions, minimize: false } +); + +preferencesSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); + +export const Preferences = + (mongoose.models.Preferences as mongoose.Model) || + mongoose.model('Preferences', preferencesSchema); diff --git a/libs/shared/src/types/index.ts b/libs/shared/src/types/index.ts index 91e8d44ab7a..aa07d9e1478 100644 --- a/libs/shared/src/types/index.ts +++ b/libs/shared/src/types/index.ts @@ -29,3 +29,4 @@ export * from './topic'; export * from './user'; export * from './web-sockets'; export * from './workflow-override'; +export * from './workflow-channel-preferences'; diff --git a/libs/shared/src/types/workflow-channel-preferences/index.ts b/libs/shared/src/types/workflow-channel-preferences/index.ts new file mode 100644 index 00000000000..fcb073fefcd --- /dev/null +++ b/libs/shared/src/types/workflow-channel-preferences/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/apps/web/src/hooks/workflowChannelPreferences/types.ts b/libs/shared/src/types/workflow-channel-preferences/types.ts similarity index 81% rename from apps/web/src/hooks/workflowChannelPreferences/types.ts rename to libs/shared/src/types/workflow-channel-preferences/types.ts index ebee71905ad..dc640d56025 100644 --- a/apps/web/src/hooks/workflowChannelPreferences/types.ts +++ b/libs/shared/src/types/workflow-channel-preferences/types.ts @@ -1,4 +1,4 @@ -import { ChannelTypeEnum } from '@novu/shared'; +import { ChannelTypeEnum } from '../channel'; type ChannelPreference = { defaultValue: boolean;