From bd2f04dee4aec8014298bb9072c7894406c369c5 Mon Sep 17 00:00:00 2001 From: Richard Fontein <32132657+rifont@users.noreply.github.com> Date: Mon, 28 Oct 2024 16:41:56 +0000 Subject: [PATCH] feat(api): Delete subscriber channel preference when updating global channel (#6767) --- .github/actions/setup-project/action.yml | 4 +- .../app/bridge/usecases/sync/sync.usecase.ts | 14 +- .../src/app/inbox/e2e/get-preferences.e2e.ts | 19 +- .../app/inbox/e2e/update-preferences.e2e.ts | 107 +++++-- apps/api/src/app/inbox/inbox.controller.ts | 10 +- .../get-inbox-preferences.command.ts} | 2 +- .../get-inbox-preferences.spec.ts | 259 ++++++++++++++++ .../get-inbox-preferences.usecase.ts | 65 ++++ .../get-preferences/get-preferences.spec.ts | 287 ------------------ .../get-preferences.usecase.ts | 98 ------ apps/api/src/app/inbox/usecases/index.ts | 4 +- .../update-preferences.spec.ts | 15 +- .../update-preferences.usecase.ts | 108 +++---- .../src/app/shared/commands/base.command.ts | 25 +- .../e2e/update-global-preference.e2e.ts | 24 +- .../upsert-workflow.usecase.ts | 18 +- .../src/commands/base.command.ts | 27 +- .../get-preferences/get-preferences.dto.ts | 13 +- .../get-preferences.usecase.ts | 14 +- ...et-subscriber-global-preference.command.ts | 4 +- ...et-subscriber-global-preference.usecase.ts | 123 +++++--- .../get-subscriber-preference.usecase.ts | 15 +- ...-subscriber-template-preference.usecase.ts | 148 +++++---- .../upsert-preferences.command.ts | 107 ++++++- .../upsert-preferences.usecase.ts | 105 ++++--- ...t-subscriber-global-preferences.command.ts | 12 +- ...subscriber-workflow-preferences.command.ts | 5 +- ...psert-user-workflow-preferences.command.ts | 5 +- .../upsert-workflow-preferences.command.ts | 13 +- .../preferences/preferences.entity.ts | 4 +- .../preferences/preferences.schema.ts | 7 - packages/js/src/preferences/helpers.ts | 67 +++- packages/js/src/preferences/preference.ts | 20 +- packages/js/src/preferences/preferences.ts | 8 +- .../nextjs/src/pages/preferences/index.tsx | 2 +- 35 files changed, 1008 insertions(+), 750 deletions(-) rename apps/api/src/app/inbox/usecases/{get-preferences/get-preferences.command.ts => get-inbox-preferences/get-inbox-preferences.command.ts} (75%) create mode 100644 apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.spec.ts create mode 100644 apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.usecase.ts delete mode 100644 apps/api/src/app/inbox/usecases/get-preferences/get-preferences.spec.ts delete mode 100644 apps/api/src/app/inbox/usecases/get-preferences/get-preferences.usecase.ts diff --git a/.github/actions/setup-project/action.yml b/.github/actions/setup-project/action.yml index dc13839cae3..01e4c10156d 100644 --- a/.github/actions/setup-project/action.yml +++ b/.github/actions/setup-project/action.yml @@ -39,9 +39,9 @@ runs: - name: 📚 Start MongoDB if: ${{ inputs.slim == 'false' }} - uses: supercharge/mongodb-github-action@v1.9.0 + uses: supercharge/mongodb-github-action@1.11.0 with: - mongodb-version: 4.2.8 + mongodb-version: 5.0.29 - name: 🛟 Install dependencies shell: bash 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 4f25374e353..7cacb5fb6b5 100644 --- a/apps/api/src/app/bridge/usecases/sync/sync.usecase.ts +++ b/apps/api/src/app/bridge/usecases/sync/sync.usecase.ts @@ -19,7 +19,13 @@ import { UpsertPreferences, UpsertWorkflowPreferencesCommand, } from '@novu/application-generic'; -import { FeatureFlagsKeysEnum, WorkflowCreationSourceEnum, WorkflowOriginEnum, WorkflowTypeEnum } from '@novu/shared'; +import { + FeatureFlagsKeysEnum, + WorkflowCreationSourceEnum, + WorkflowOriginEnum, + WorkflowTypeEnum, + WorkflowPreferencesPartial, +} from '@novu/shared'; import { DiscoverOutput, DiscoverStepOutput, DiscoverWorkflowOutput, GetActionEnum } from '@novu/framework/internal'; import { SyncCommand } from './sync.command'; @@ -186,7 +192,7 @@ export class Sync { environmentId: savedWorkflow._environmentId, organizationId: savedWorkflow._organizationId, templateId: savedWorkflow._id, - preferences: workflow.preferences, + preferences: this.getWorkflowPreferences(workflow), }) ); } @@ -323,6 +329,10 @@ export class Sync { return notificationGroupId; } + private getWorkflowPreferences(workflow: DiscoverWorkflowOutput): WorkflowPreferencesPartial { + return workflow.preferences || {}; + } + private getWorkflowName(workflow: DiscoverWorkflowOutput): string { return workflow.name || workflow.workflowId; } diff --git a/apps/api/src/app/inbox/e2e/get-preferences.e2e.ts b/apps/api/src/app/inbox/e2e/get-preferences.e2e.ts index 0a6141b1fdd..5842f034029 100644 --- a/apps/api/src/app/inbox/e2e/get-preferences.e2e.ts +++ b/apps/api/src/app/inbox/e2e/get-preferences.e2e.ts @@ -1,5 +1,6 @@ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; +import { StepTypeEnum } from '@novu/shared'; describe('Get all preferences - /inbox/preferences (GET)', function () { let session: UserSession; @@ -9,22 +10,28 @@ describe('Get all preferences - /inbox/preferences (GET)', function () { await session.initialize(); }); - it('should always get the global preferences even if workflow preferences are not present', async function () { + it('should return no global preferences if workflow preferences are not present', async function () { const response = await session.testAgent .get('/v1/inbox/preferences') .set('Authorization', `Bearer ${session.subscriberToken}`); const globalPreference = response.body.data[0]; - expect(globalPreference.channels.email).to.equal(true); - expect(globalPreference.channels.in_app).to.equal(true); + expect(globalPreference.channels.email).to.equal(undefined); + expect(globalPreference.channels.in_app).to.equal(undefined); expect(globalPreference.level).to.equal('global'); expect(response.body.data.length).to.equal(1); }); - it('should get both global and workflow preferences if workflow is present', async function () { + it('should get both global preferences for active channels and workflow preferences if workflow is present', async function () { await session.createTemplate({ noFeedId: true, + steps: [ + { + type: StepTypeEnum.EMAIL, + content: 'Test notification content', + }, + ], }); const response = await session.testAgent @@ -34,13 +41,13 @@ describe('Get all preferences - /inbox/preferences (GET)', function () { const globalPreference = response.body.data[0]; expect(globalPreference.channels.email).to.equal(true); - expect(globalPreference.channels.in_app).to.equal(true); + expect(globalPreference.channels.in_app).to.equal(undefined); expect(globalPreference.level).to.equal('global'); const workflowPreference = response.body.data[1]; expect(workflowPreference.channels.email).to.equal(true); - expect(workflowPreference.channels.in_app).to.equal(true); + expect(workflowPreference.channels.in_app).to.equal(undefined); expect(workflowPreference.level).to.equal('template'); }); diff --git a/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts b/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts index bee249c39f9..e7ca38b8ec3 100644 --- a/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts +++ b/apps/api/src/app/inbox/e2e/update-preferences.e2e.ts @@ -1,6 +1,7 @@ import { EmailBlockTypeEnum, PreferenceLevelEnum, StepTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; +import e from 'express'; describe('Update global preferences - /inbox/preferences (PATCH)', function () { let session: UserSession; @@ -38,48 +39,53 @@ describe('Update global preferences - /inbox/preferences (PATCH)', function () { .set('Authorization', `Bearer ${session.subscriberToken}`); expect(response.status).to.equal(200); - expect(response.body.data.channels.email).to.equal(true); - expect(response.body.data.channels.in_app).to.equal(true); - expect(response.body.data.channels.sms).to.equal(false); - expect(response.body.data.channels.push).to.equal(false); - expect(response.body.data.channels.chat).to.equal(true); + expect(response.body.data.channels.email).to.equal(undefined); + expect(response.body.data.channels.in_app).to.equal(undefined); + expect(response.body.data.channels.sms).to.equal(undefined); + expect(response.body.data.channels.push).to.equal(undefined); + expect(response.body.data.channels.chat).to.equal(undefined); expect(response.body.data.level).to.equal(PreferenceLevelEnum.GLOBAL); }); - it('should update the particular channel sent in the body and return all channels', async function () { + it('should update the particular channel sent in the body and return only active channels', async function () { + await session.createTemplate({ + noFeedId: true, + steps: [ + { + type: StepTypeEnum.IN_APP, + content: 'Test notification content', + }, + ], + }); + const response = await session.testAgent .patch('/v1/inbox/preferences') .send({ - email: true, in_app: true, - sms: false, - push: false, - chat: true, }) .set('Authorization', `Bearer ${session.subscriberToken}`); expect(response.status).to.equal(200); - expect(response.body.data.channels.email).to.equal(true); + expect(response.body.data.channels.email).to.equal(undefined); expect(response.body.data.channels.in_app).to.equal(true); - expect(response.body.data.channels.sms).to.equal(false); - expect(response.body.data.channels.push).to.equal(false); - expect(response.body.data.channels.chat).to.equal(true); + expect(response.body.data.channels.sms).to.equal(undefined); + expect(response.body.data.channels.push).to.equal(undefined); + expect(response.body.data.channels.chat).to.equal(undefined); expect(response.body.data.level).to.equal(PreferenceLevelEnum.GLOBAL); const responseSecond = await session.testAgent .patch('/v1/inbox/preferences') .send({ - email: false, in_app: true, }) .set('Authorization', `Bearer ${session.subscriberToken}`); expect(responseSecond.status).to.equal(200); - expect(responseSecond.body.data.channels.email).to.equal(false); + expect(responseSecond.body.data.channels.email).to.equal(undefined); expect(responseSecond.body.data.channels.in_app).to.equal(true); - expect(responseSecond.body.data.channels.sms).to.equal(false); - expect(responseSecond.body.data.channels.push).to.equal(false); - expect(responseSecond.body.data.channels.chat).to.equal(true); + expect(responseSecond.body.data.channels.sms).to.equal(undefined); + expect(responseSecond.body.data.channels.push).to.equal(undefined); + expect(responseSecond.body.data.channels.chat).to.equal(undefined); expect(responseSecond.body.data.level).to.equal(PreferenceLevelEnum.GLOBAL); }); }); @@ -256,4 +262,67 @@ describe('Update workflow preferences - /inbox/preferences/:workflowId (PATCH)', expect(responseSecond.body.data.channels.chat).to.equal(true); expect(responseSecond.body.data.level).to.equal(PreferenceLevelEnum.TEMPLATE); }); + + it('should unset the suscribers workflow preference for the specified channels when the global preference is updated', async function () { + const workflow = await session.createTemplate({ + noFeedId: true, + steps: [ + { + type: StepTypeEnum.IN_APP, + content: 'Test notification content', + }, + { + type: StepTypeEnum.EMAIL, + content: 'Test notification content', + }, + ], + }); + + const updateWorkflowPrefResponse = await session.testAgent + .patch(`/v1/inbox/preferences/${workflow._id}`) + .send({ + email: false, + in_app: false, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(updateWorkflowPrefResponse.status).to.equal(200); + expect(updateWorkflowPrefResponse.body.data.channels.email).to.equal(false); + expect(updateWorkflowPrefResponse.body.data.channels.in_app).to.equal(false); + expect(updateWorkflowPrefResponse.body.data.channels.sms).to.equal(undefined); + expect(updateWorkflowPrefResponse.body.data.channels.push).to.equal(undefined); + expect(updateWorkflowPrefResponse.body.data.channels.chat).to.equal(undefined); + expect(updateWorkflowPrefResponse.body.data.level).to.equal(PreferenceLevelEnum.TEMPLATE); + + const updateGlobalPrefResponse = await session.testAgent + .patch(`/v1/inbox/preferences`) + .send({ + email: true, + }) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + expect(updateGlobalPrefResponse.status).to.equal(200); + expect(updateGlobalPrefResponse.body.data.channels.email).to.equal(true); + expect(updateGlobalPrefResponse.body.data.channels.in_app).to.equal(true); + expect(updateGlobalPrefResponse.body.data.channels.sms).to.equal(undefined); + expect(updateGlobalPrefResponse.body.data.channels.push).to.equal(undefined); + expect(updateGlobalPrefResponse.body.data.channels.chat).to.equal(undefined); + expect(updateGlobalPrefResponse.body.data.level).to.equal(PreferenceLevelEnum.GLOBAL); + + const getInboxPrefResponse = await session.testAgent + .get(`/v1/inbox/preferences`) + .set('Authorization', `Bearer ${session.subscriberToken}`); + + const workflowPref = getInboxPrefResponse.body.data.find( + (pref) => pref.level === PreferenceLevelEnum.TEMPLATE && pref.workflow.id === workflow._id + ); + + expect(getInboxPrefResponse.status).to.equal(200); + expect(workflowPref.channels.email).to.equal(true); + expect(workflowPref.channels.in_app).to.equal(false); + expect(workflowPref.channels.sms).to.equal(undefined); + expect(workflowPref.channels.push).to.equal(undefined); + expect(workflowPref.channels.chat).to.equal(undefined); + expect(workflowPref.level).to.equal(PreferenceLevelEnum.TEMPLATE); + }); }); diff --git a/apps/api/src/app/inbox/inbox.controller.ts b/apps/api/src/app/inbox/inbox.controller.ts index 85d94b295db..23bcf8b2fae 100644 --- a/apps/api/src/app/inbox/inbox.controller.ts +++ b/apps/api/src/app/inbox/inbox.controller.ts @@ -27,8 +27,8 @@ import { UpdateNotificationActionCommand } from './usecases/update-notification- import { UpdateAllNotificationsRequestDto } from './dtos/update-all-notifications-request.dto'; import { UpdateAllNotificationsCommand } from './usecases/update-all-notifications/update-all-notifications.command'; import { UpdateAllNotifications } from './usecases/update-all-notifications/update-all-notifications.usecase'; -import { GetPreferences } from './usecases/get-preferences/get-preferences.usecase'; -import { GetPreferencesCommand } from './usecases/get-preferences/get-preferences.command'; +import { GetInboxPreferences } from './usecases/get-inbox-preferences/get-inbox-preferences.usecase'; +import { GetInboxPreferencesCommand } from './usecases/get-inbox-preferences/get-inbox-preferences.command'; import { GetPreferencesResponseDto } from './dtos/get-preferences-response.dto'; import { UpdatePreferencesRequestDto } from './dtos/update-preferences-request.dto'; import { UpdatePreferences } from './usecases/update-preferences/update-preferences.usecase'; @@ -46,7 +46,7 @@ export class InboxController { private markNotificationAsUsecase: MarkNotificationAs, private updateNotificationActionUsecase: UpdateNotificationAction, private updateAllNotifications: UpdateAllNotifications, - private getPreferencesUsecase: GetPreferences, + private getInboxPreferencesUsecase: GetInboxPreferences, private updatePreferencesUsecase: UpdatePreferences ) {} @@ -107,8 +107,8 @@ export class InboxController { @SubscriberSession() subscriberSession: SubscriberEntity, @Query() query: GetPreferencesRequestDto ): Promise { - return await this.getPreferencesUsecase.execute( - GetPreferencesCommand.create({ + return await this.getInboxPreferencesUsecase.execute( + GetInboxPreferencesCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, diff --git a/apps/api/src/app/inbox/usecases/get-preferences/get-preferences.command.ts b/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.command.ts similarity index 75% rename from apps/api/src/app/inbox/usecases/get-preferences/get-preferences.command.ts rename to apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.command.ts index 8173eaaea69..78a6f244c92 100644 --- a/apps/api/src/app/inbox/usecases/get-preferences/get-preferences.command.ts +++ b/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.command.ts @@ -1,7 +1,7 @@ import { IsArray, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; -export class GetPreferencesCommand extends EnvironmentWithSubscriber { +export class GetInboxPreferencesCommand extends EnvironmentWithSubscriber { @IsOptional() @IsArray() @IsString({ each: true }) diff --git a/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.spec.ts b/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.spec.ts new file mode 100644 index 00000000000..e32813d6176 --- /dev/null +++ b/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.spec.ts @@ -0,0 +1,259 @@ +import { + AnalyticsService, + GetSubscriberGlobalPreference, + GetSubscriberGlobalPreferenceCommand, + GetSubscriberPreference, + GetSubscriberPreferenceCommand, +} from '@novu/application-generic'; +import { + ChannelTypeEnum, + ISubscriberPreferenceResponse, + ITemplateConfiguration, + PreferenceLevelEnum, + PreferenceOverrideSourceEnum, + PreferencesTypeEnum, + TriggerTypeEnum, +} from '@novu/shared'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { AnalyticsEventsEnum } from '../../utils'; +import { GetInboxPreferences } from './get-inbox-preferences.usecase'; + +const mockedWorkflow = { + _id: '123', + name: 'workflow', + triggers: [{ identifier: '123', type: TriggerTypeEnum.EVENT, variables: [] }], + critical: false, + tags: [], +} satisfies ITemplateConfiguration; +const mockedWorkflowPreference = { + type: PreferencesTypeEnum.USER_WORKFLOW, + template: mockedWorkflow, + preference: { + enabled: true, + channels: { + email: true, + in_app: true, + sms: false, + push: false, + chat: true, + }, + overrides: [ + { + channel: ChannelTypeEnum.EMAIL, + source: PreferenceOverrideSourceEnum.SUBSCRIBER, + }, + ], + }, +} satisfies ISubscriberPreferenceResponse; + +const mockedGlobalPreferences = { + enabled: true, + channels: { + email: true, + in_app: true, + sms: false, + push: false, + chat: true, + }, +}; + +describe('GetInboxPreferences', () => { + let getInboxPreferences: GetInboxPreferences; + + let analyticsServiceMock: sinon.SinonStubbedInstance; + let getSubscriberGlobalPreferenceMock: sinon.SinonStubbedInstance; + let getSubscriberPreferenceMock: sinon.SinonStubbedInstance; + + beforeEach(() => { + getSubscriberPreferenceMock = sinon.createStubInstance(GetSubscriberPreference); + analyticsServiceMock = sinon.createStubInstance(AnalyticsService); + getSubscriberGlobalPreferenceMock = sinon.createStubInstance(GetSubscriberGlobalPreference); + + getInboxPreferences = new GetInboxPreferences( + getSubscriberGlobalPreferenceMock as any, + analyticsServiceMock as any, + getSubscriberPreferenceMock as any + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('it should throw exception when subscriber is not found', async () => { + const command = { + environmentId: 'env-1', + organizationId: 'org-1', + subscriberId: 'bad-subscriber-id', + }; + + getSubscriberGlobalPreferenceMock.execute.rejects( + new Error(`Subscriber with id ${command.subscriberId} not found`) + ); + + try { + await getInboxPreferences.execute(command); + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect(error.message).to.equal(`Subscriber with id ${command.subscriberId} not found`); + } + }); + + it('it should return subscriber preferences', async () => { + const command = { + environmentId: 'env-1', + organizationId: 'org-1', + subscriberId: 'test-mockSubscriber', + }; + + getSubscriberGlobalPreferenceMock.execute.resolves({ + preference: mockedGlobalPreferences, + }); + getSubscriberPreferenceMock.execute.resolves([mockedWorkflowPreference]); + + const result = await getInboxPreferences.execute(command); + + expect(getSubscriberGlobalPreferenceMock.execute.calledOnce).to.be.true; + expect(getSubscriberGlobalPreferenceMock.execute.firstCall.args[0]).to.deep.equal( + GetSubscriberGlobalPreferenceCommand.create({ + organizationId: command.organizationId, + environmentId: command.environmentId, + subscriberId: command.subscriberId, + }) + ); + + expect(getSubscriberPreferenceMock.execute.calledOnce).to.be.true; + expect(getSubscriberPreferenceMock.execute.firstCall.args[0]).to.deep.equal( + GetSubscriberPreferenceCommand.create({ + environmentId: command.environmentId, + subscriberId: command.subscriberId, + organizationId: command.organizationId, + }) + ); + + expect(analyticsServiceMock.mixpanelTrack.calledOnce).to.be.true; + expect(analyticsServiceMock.mixpanelTrack.firstCall.args).to.deep.equal([ + AnalyticsEventsEnum.FETCH_PREFERENCES, + '', + { + _organization: command.organizationId, + subscriberId: command.subscriberId, + workflowSize: 1, + }, + ]); + + expect(result).to.deep.equal([ + { + level: PreferenceLevelEnum.GLOBAL, + ...mockedGlobalPreferences, + }, + { + ...mockedWorkflowPreference.preference, + level: PreferenceLevelEnum.TEMPLATE, + workflow: { + id: mockedWorkflow._id, + identifier: mockedWorkflow.triggers[0].identifier, + name: mockedWorkflow.name, + critical: mockedWorkflow.critical, + tags: mockedWorkflow.tags, + }, + }, + ]); + }); + + it('it should return subscriber preferences filtered by tags', async () => { + const workflowsWithTags = [ + { + template: { + _id: '111', + name: 'workflow', + triggers: [{ identifier: '111', type: TriggerTypeEnum.EVENT, variables: [] }], + critical: false, + tags: ['newsletter'], + }, + preference: mockedWorkflowPreference.preference, + type: PreferencesTypeEnum.USER_WORKFLOW, + }, + { + template: { + _id: '222', + name: 'workflow', + triggers: [{ identifier: '222', type: TriggerTypeEnum.EVENT, variables: [] }], + critical: false, + tags: ['security'], + }, + preference: mockedWorkflowPreference.preference, + type: PreferencesTypeEnum.USER_WORKFLOW, + }, + ] satisfies ISubscriberPreferenceResponse[]; + const command = { + environmentId: 'env-1', + organizationId: 'org-1', + subscriberId: 'test-mockSubscriber', + tags: ['newsletter', 'security'], + }; + + getSubscriberGlobalPreferenceMock.execute.resolves({ + preference: mockedGlobalPreferences, + }); + getSubscriberPreferenceMock.execute.resolves(workflowsWithTags); + + const result = await getInboxPreferences.execute(command); + + expect(getSubscriberGlobalPreferenceMock.execute.calledOnce).to.be.true; + expect(getSubscriberGlobalPreferenceMock.execute.firstCall.args[0]).to.deep.equal( + GetSubscriberGlobalPreferenceCommand.create({ + organizationId: command.organizationId, + environmentId: command.environmentId, + subscriberId: command.subscriberId, + }) + ); + + expect(getSubscriberPreferenceMock.execute.calledOnce).to.be.true; + expect(getSubscriberPreferenceMock.execute.firstCall.args[0]).to.deep.equal( + GetSubscriberPreferenceCommand.create({ + environmentId: command.environmentId, + subscriberId: command.subscriberId, + organizationId: command.organizationId, + }) + ); + + expect(analyticsServiceMock.mixpanelTrack.calledOnce).to.be.true; + expect(analyticsServiceMock.mixpanelTrack.firstCall.args).to.deep.equal([ + AnalyticsEventsEnum.FETCH_PREFERENCES, + '', + { + _organization: command.organizationId, + subscriberId: command.subscriberId, + workflowSize: 2, + }, + ]); + + expect(result).to.deep.equal([ + { level: PreferenceLevelEnum.GLOBAL, ...mockedGlobalPreferences }, + { + level: PreferenceLevelEnum.TEMPLATE, + workflow: { + id: workflowsWithTags[0].template._id, + identifier: workflowsWithTags[0].template.triggers[0].identifier, + name: workflowsWithTags[0].template.name, + critical: workflowsWithTags[0].template.critical, + tags: workflowsWithTags[0].template.tags, + }, + ...mockedWorkflowPreference.preference, + }, + { + level: PreferenceLevelEnum.TEMPLATE, + workflow: { + id: workflowsWithTags[1].template._id, + identifier: workflowsWithTags[1].template.triggers[0].identifier, + name: workflowsWithTags[1].template.name, + critical: workflowsWithTags[1].template.critical, + tags: workflowsWithTags[1].template.tags, + }, + ...mockedWorkflowPreference.preference, + }, + ]); + }); +}); diff --git a/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.usecase.ts b/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.usecase.ts new file mode 100644 index 00000000000..f511f5ddd4d --- /dev/null +++ b/apps/api/src/app/inbox/usecases/get-inbox-preferences/get-inbox-preferences.usecase.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { + AnalyticsService, + GetSubscriberPreference, + GetSubscriberPreferenceCommand, + GetSubscriberGlobalPreference, + GetSubscriberGlobalPreferenceCommand, +} from '@novu/application-generic'; +import { PreferenceLevelEnum } from '@novu/shared'; +import { AnalyticsEventsEnum } from '../../utils'; +import { InboxPreference } from '../../utils/types'; +import { GetInboxPreferencesCommand } from './get-inbox-preferences.command'; + +@Injectable() +export class GetInboxPreferences { + constructor( + private getSubscriberGlobalPreference: GetSubscriberGlobalPreference, + private analyticsService: AnalyticsService, + private getSubscriberPreference: GetSubscriberPreference + ) {} + + async execute(command: GetInboxPreferencesCommand): Promise { + const globalPreference = await this.getSubscriberGlobalPreference.execute( + GetSubscriberGlobalPreferenceCommand.create({ + organizationId: command.organizationId, + environmentId: command.environmentId, + subscriberId: command.subscriberId, + }) + ); + + const updatedGlobalPreference = { + level: PreferenceLevelEnum.GLOBAL, + ...globalPreference.preference, + }; + + const subscriberWorkflowPreferences = await this.getSubscriberPreference.execute( + GetSubscriberPreferenceCommand.create({ + environmentId: command.environmentId, + subscriberId: command.subscriberId, + organizationId: command.organizationId, + }) + ); + const workflowPreferences = subscriberWorkflowPreferences.map((subscriberWorkflowPreference) => { + return { + ...subscriberWorkflowPreference.preference, + level: PreferenceLevelEnum.TEMPLATE, + workflow: { + id: subscriberWorkflowPreference.template._id, + identifier: subscriberWorkflowPreference.template.triggers[0].identifier, + name: subscriberWorkflowPreference.template.name, + critical: subscriberWorkflowPreference.template.critical, + tags: subscriberWorkflowPreference.template.tags, + }, + } satisfies InboxPreference; + }); + + this.analyticsService.mixpanelTrack(AnalyticsEventsEnum.FETCH_PREFERENCES, '', { + _organization: command.organizationId, + subscriberId: command.subscriberId, + workflowSize: workflowPreferences.length, + }); + + return [updatedGlobalPreference, ...workflowPreferences]; + } +} diff --git a/apps/api/src/app/inbox/usecases/get-preferences/get-preferences.spec.ts b/apps/api/src/app/inbox/usecases/get-preferences/get-preferences.spec.ts deleted file mode 100644 index fa19aee6a36..00000000000 --- a/apps/api/src/app/inbox/usecases/get-preferences/get-preferences.spec.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { - AnalyticsService, - GetSubscriberGlobalPreference, - GetSubscriberTemplatePreference, -} from '@novu/application-generic'; -import { NotificationTemplateRepository, SubscriberRepository } from '@novu/dal'; -import { PreferenceLevelEnum } from '@novu/shared'; -import { expect } from 'chai'; -import sinon from 'sinon'; -import { AnalyticsEventsEnum } from '../../utils'; -import { GetPreferences } from './get-preferences.usecase'; - -const mockedSubscriber: any = { _id: '123', subscriberId: 'test-mockSubscriber', firstName: 'test', lastName: 'test' }; -const mockedWorkflow = { - _id: '123', - name: 'workflow', - triggers: [{ identifier: '123' }], - critical: false, - tags: [], -}; -const mockedWorkflowPreference: any = { - template: {}, - - enabled: true, - channels: { - email: true, - in_app: true, - sms: false, - push: false, - chat: true, - }, - overrides: { - email: false, - in_app: false, - sms: true, - push: true, - chat: false, - }, -}; - -const mockedGlobalPreferences: any = { - enabled: true, - channels: { - email: true, - in_app: true, - sms: false, - push: false, - chat: true, - }, -}; -const mockedPreferencesResponse: any = [ - { level: PreferenceLevelEnum.GLOBAL, ...mockedGlobalPreferences.preference }, - { - level: PreferenceLevelEnum.TEMPLATE, - workflow: { - id: mockedWorkflow._id, - identifier: mockedWorkflow.triggers[0].identifier, - name: mockedWorkflow.name, - critical: mockedWorkflow.critical, - tags: mockedWorkflow.tags, - }, - ...mockedWorkflowPreference.preference, - }, -]; - -const mockedWorkflows: any = [ - { - _id: '123', - name: 'workflow', - triggers: [{ identifier: '123' }], - critical: false, - tags: [], - }, -]; - -describe('GetPreferences', () => { - let getPreferences: GetPreferences; - let subscriberRepositoryMock: sinon.SinonStubbedInstance; - let getSubscriberWorkflowMock: sinon.SinonStubbedInstance; - let analyticsServiceMock: sinon.SinonStubbedInstance; - let getSubscriberGlobalPreferenceMock: sinon.SinonStubbedInstance; - let notificationTemplateRepositoryMock: sinon.SinonStubbedInstance; - - beforeEach(() => { - subscriberRepositoryMock = sinon.createStubInstance(SubscriberRepository); - getSubscriberWorkflowMock = sinon.createStubInstance(GetSubscriberTemplatePreference); - analyticsServiceMock = sinon.createStubInstance(AnalyticsService); - getSubscriberGlobalPreferenceMock = sinon.createStubInstance(GetSubscriberGlobalPreference); - notificationTemplateRepositoryMock = sinon.createStubInstance(NotificationTemplateRepository); - - getPreferences = new GetPreferences( - subscriberRepositoryMock as any, - notificationTemplateRepositoryMock as any, - getSubscriberWorkflowMock as any, - getSubscriberGlobalPreferenceMock as any, - analyticsServiceMock as any - ); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('it should throw exception when subscriber is not found', async () => { - const command = { - environmentId: 'env-1', - organizationId: 'org-1', - subscriberId: 'not-found', - }; - - subscriberRepositoryMock.findOne.resolves(undefined); - - try { - await getPreferences.execute(command); - } catch (error) { - expect(error).to.be.instanceOf(Error); - expect(error.message).to.equal(`Subscriber with id: ${command.subscriberId} not found`); - } - }); - - it('it should return subscriber preferences', async () => { - const command = { - environmentId: 'env-1', - organizationId: 'org-1', - subscriberId: 'test-mockSubscriber', - }; - - subscriberRepositoryMock.findBySubscriberId.resolves(mockedSubscriber); - getSubscriberGlobalPreferenceMock.execute.resolves(mockedGlobalPreferences); - notificationTemplateRepositoryMock.filterActive.resolves(mockedWorkflows); - getSubscriberWorkflowMock.execute.resolves(mockedWorkflowPreference); - - const result = await getPreferences.execute(command); - - expect(subscriberRepositoryMock.findBySubscriberId.calledOnce).to.be.true; - expect(subscriberRepositoryMock.findBySubscriberId.firstCall.args).to.deep.equal([ - command.environmentId, - command.subscriberId, - ]); - expect(getSubscriberGlobalPreferenceMock.execute.calledOnce).to.be.true; - expect(getSubscriberGlobalPreferenceMock.execute.firstCall.args).to.deep.equal([command]); - expect(notificationTemplateRepositoryMock.filterActive.calledOnce).to.be.true; - expect(notificationTemplateRepositoryMock.filterActive.firstCall.args).to.deep.equal([ - { - organizationId: command.organizationId, - environmentId: command.environmentId, - tags: undefined, - critical: false, - }, - ]); - expect(getSubscriberWorkflowMock.execute.calledOnce).to.be.true; - expect(getSubscriberWorkflowMock.execute.firstCall.args).to.deep.equal([ - { - organizationId: command.organizationId, - subscriberId: command.subscriberId, - environmentId: command.environmentId, - template: mockedWorkflow, - subscriber: mockedSubscriber, - }, - ]); - - expect(analyticsServiceMock.mixpanelTrack.calledOnce).to.be.true; - expect(analyticsServiceMock.mixpanelTrack.firstCall.args).to.deep.equal([ - AnalyticsEventsEnum.FETCH_PREFERENCES, - '', - { - _organization: command.organizationId, - subscriberId: command.subscriberId, - workflowSize: 1, - }, - ]); - - expect(result).to.deep.equal(mockedPreferencesResponse); - }); - - it('it should return subscriber preferences filtered by tags', async () => { - const workflowsWithTags: any = [ - { - _id: '111', - name: 'workflow', - triggers: [{ identifier: '111' }], - critical: false, - tags: ['newsletter'], - }, - { - _id: '222', - name: 'workflow', - triggers: [{ identifier: '222' }], - critical: false, - tags: ['security'], - }, - ]; - const response: any = [ - { level: PreferenceLevelEnum.GLOBAL, ...mockedGlobalPreferences.preference }, - { - level: PreferenceLevelEnum.TEMPLATE, - workflow: { - id: workflowsWithTags[0]._id, - identifier: workflowsWithTags[0].triggers[0].identifier, - name: workflowsWithTags[0].name, - critical: workflowsWithTags[0].critical, - tags: workflowsWithTags[0].tags, - }, - ...mockedWorkflowPreference.preference, - }, - { - level: PreferenceLevelEnum.TEMPLATE, - workflow: { - id: workflowsWithTags[1]._id, - identifier: workflowsWithTags[1].triggers[0].identifier, - name: workflowsWithTags[1].name, - critical: workflowsWithTags[1].critical, - tags: workflowsWithTags[1].tags, - }, - ...mockedWorkflowPreference.preference, - }, - ]; - const command = { - environmentId: 'env-1', - organizationId: 'org-1', - subscriberId: 'test-mockSubscriber', - tags: ['newsletter', 'security'], - }; - - subscriberRepositoryMock.findBySubscriberId.resolves(mockedSubscriber); - getSubscriberGlobalPreferenceMock.execute.resolves(mockedGlobalPreferences); - notificationTemplateRepositoryMock.filterActive.resolves(workflowsWithTags); - getSubscriberWorkflowMock.execute.resolves(mockedWorkflowPreference); - - const result = await getPreferences.execute(command); - - expect(subscriberRepositoryMock.findBySubscriberId.calledOnce).to.be.true; - expect(subscriberRepositoryMock.findBySubscriberId.firstCall.args).to.deep.equal([ - command.environmentId, - command.subscriberId, - ]); - expect(getSubscriberGlobalPreferenceMock.execute.calledOnce).to.be.true; - expect(getSubscriberGlobalPreferenceMock.execute.firstCall.args).to.deep.equal([ - { - organizationId: command.organizationId, - environmentId: command.environmentId, - subscriberId: command.subscriberId, - }, - ]); - expect(notificationTemplateRepositoryMock.filterActive.calledOnce).to.be.true; - expect(notificationTemplateRepositoryMock.filterActive.firstCall.args).to.deep.equal([ - { - organizationId: command.organizationId, - environmentId: command.environmentId, - tags: command.tags, - critical: false, - }, - ]); - expect(getSubscriberWorkflowMock.execute.calledTwice).to.be.true; - expect(getSubscriberWorkflowMock.execute.firstCall.args).to.deep.equal([ - { - organizationId: command.organizationId, - subscriberId: command.subscriberId, - environmentId: command.environmentId, - template: workflowsWithTags[0], - subscriber: mockedSubscriber, - }, - ]); - expect(getSubscriberWorkflowMock.execute.secondCall.args).to.deep.equal([ - { - organizationId: command.organizationId, - subscriberId: command.subscriberId, - environmentId: command.environmentId, - template: workflowsWithTags[1], - subscriber: mockedSubscriber, - }, - ]); - - expect(analyticsServiceMock.mixpanelTrack.calledOnce).to.be.true; - expect(analyticsServiceMock.mixpanelTrack.firstCall.args).to.deep.equal([ - AnalyticsEventsEnum.FETCH_PREFERENCES, - '', - { - _organization: command.organizationId, - subscriberId: command.subscriberId, - workflowSize: 2, - }, - ]); - - expect(result).to.deep.equal(response); - }); -}); diff --git a/apps/api/src/app/inbox/usecases/get-preferences/get-preferences.usecase.ts b/apps/api/src/app/inbox/usecases/get-preferences/get-preferences.usecase.ts deleted file mode 100644 index 1279539303f..00000000000 --- a/apps/api/src/app/inbox/usecases/get-preferences/get-preferences.usecase.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { - AnalyticsService, - GetSubscriberGlobalPreference, - GetSubscriberGlobalPreferenceCommand, - GetSubscriberTemplatePreference, - GetSubscriberTemplatePreferenceCommand, -} from '@novu/application-generic'; -import { NotificationTemplateRepository, SubscriberRepository } from '@novu/dal'; -import { PreferenceLevelEnum } from '@novu/shared'; -import { AnalyticsEventsEnum } from '../../utils'; -import { InboxPreference } from '../../utils/types'; -import { GetPreferencesCommand } from './get-preferences.command'; - -@Injectable() -export class GetPreferences { - constructor( - private subscriberRepository: SubscriberRepository, - private notificationTemplateRepository: NotificationTemplateRepository, - private getSubscriberTemplatePreferenceUsecase: GetSubscriberTemplatePreference, - private getSubscriberGlobalPreference: GetSubscriberGlobalPreference, - private analyticsService: AnalyticsService - ) {} - - async execute(command: GetPreferencesCommand): Promise { - const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); - - if (!subscriber) { - throw new NotFoundException(`Subscriber with id: ${command.subscriberId} not found`); - } - - const globalPreference = await this.getSubscriberGlobalPreference.execute( - GetSubscriberGlobalPreferenceCommand.create({ - organizationId: command.organizationId, - environmentId: command.environmentId, - subscriberId: command.subscriberId, - }) - ); - - const updatedGlobalPreference = { - level: PreferenceLevelEnum.GLOBAL, - ...globalPreference.preference, - }; - - const workflowList = - (await this.notificationTemplateRepository.filterActive({ - organizationId: command.organizationId, - environmentId: command.environmentId, - tags: command.tags, - critical: false, - })) || []; - - this.analyticsService.mixpanelTrack(AnalyticsEventsEnum.FETCH_PREFERENCES, '', { - _organization: command.organizationId, - subscriberId: command.subscriberId, - workflowSize: workflowList.length, - }); - - const workflowPreferences = await Promise.all( - workflowList.map(async (workflow) => { - const workflowPreference = await this.getSubscriberTemplatePreferenceUsecase.execute( - GetSubscriberTemplatePreferenceCommand.create({ - organizationId: command.organizationId, - subscriberId: command.subscriberId, - environmentId: command.environmentId, - template: workflow, - subscriber, - }) - ); - - return { - ...workflowPreference.preference, - level: PreferenceLevelEnum.TEMPLATE, - workflow: { - id: workflow._id, - identifier: workflow.triggers[0].identifier, - name: workflow.name, - /* - * V1 Preferences define `critial` flag on the workflow level. - * V2 Preferences define `critical` flag on the template returned via Preferences. - * This pattern safely returns false when: - * 1. Workflow V1 with no critical flag set - * 2. Workflow V2 with no critical flag set - * 3. Workflow V1 with critical flag set to false - * 4. Workflow V2 with critical flag set to false - */ - critical: workflow.critical || workflowPreference.template.critical || false, - tags: workflow.tags, - }, - } satisfies InboxPreference; - }) - ); - - const nonCriticalWorkflows = workflowPreferences.filter((preference) => preference.workflow.critical === false); - - return [updatedGlobalPreference, ...nonCriticalWorkflows]; - } -} diff --git a/apps/api/src/app/inbox/usecases/index.ts b/apps/api/src/app/inbox/usecases/index.ts index 7d93e268b76..2de3f44276f 100644 --- a/apps/api/src/app/inbox/usecases/index.ts +++ b/apps/api/src/app/inbox/usecases/index.ts @@ -1,6 +1,6 @@ import { GetSubscriberGlobalPreference, GetSubscriberTemplatePreference } from '@novu/application-generic'; import { GetNotifications } from './get-notifications/get-notifications.usecase'; -import { GetPreferences } from './get-preferences/get-preferences.usecase'; +import { GetInboxPreferences } from './get-inbox-preferences/get-inbox-preferences.usecase'; import { MarkManyNotificationsAs } from './mark-many-notifications-as/mark-many-notifications-as.usecase'; import { MarkNotificationAs } from './mark-notification-as/mark-notification-as.usecase'; import { NotificationsCount } from './notifications-count/notifications-count.usecase'; @@ -17,7 +17,7 @@ export const USE_CASES = [ MarkNotificationAs, UpdateNotificationAction, UpdateAllNotifications, - GetPreferences, + GetInboxPreferences, GetSubscriberGlobalPreference, GetSubscriberTemplatePreference, UpdatePreferences, 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 79f72ec0287..091f865b27b 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 @@ -47,7 +47,7 @@ const mockedGlobalPreference: any = { }; const mockedWorkflow: any = { - _id: 'workflow-1', + _id: '6447aff3d89122e250412c28', name: 'test-workflow', critical: false, triggers: [{ identifier: 'test-trigger' }], @@ -162,10 +162,6 @@ describe('UpdatePreferences', () => { _workflowId: undefined, channels: { chat: true, - email: undefined, - sms: undefined, - in_app: undefined, - push: undefined, }, }, ]); @@ -210,10 +206,6 @@ describe('UpdatePreferences', () => { _workflowId: undefined, channels: { chat: true, - email: undefined, - sms: undefined, - in_app: undefined, - push: undefined, }, }, ]); @@ -230,7 +222,7 @@ describe('UpdatePreferences', () => { organizationId: 'org-1', subscriberId: 'test-mockSubscriber', level: PreferenceLevelEnum.TEMPLATE, - workflowId: 'workflow-1', + workflowId: '6447aff3d89122e250412c28', chat: true, email: false, }; @@ -272,9 +264,6 @@ describe('UpdatePreferences', () => { channels: { chat: true, email: false, - in_app: undefined, - push: undefined, - sms: undefined, }, }, ]); 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 462fe08b33d..ad3c507a999 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 @@ -10,7 +10,6 @@ import { UpsertSubscriberGlobalPreferencesCommand, } from '@novu/application-generic'; import { - ChannelTypeEnum, NotificationTemplateEntity, NotificationTemplateRepository, PreferenceLevelEnum, @@ -25,8 +24,6 @@ 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( @@ -62,34 +59,16 @@ export class UpdatePreferences { if (!userPreference) { await this.createUserPreference(command, subscriber); } else { - await this.updateUserPreference(command, subscriber, userPreference); + await this.updateUserPreference(command, subscriber); } return await this.findPreference(command, subscriber); } private async createUserPreference(command: UpdatePreferencesCommand, subscriber: SubscriberEntity): Promise { - const channelObj = { - chat: command.chat, - email: command.email, - in_app: command.in_app, - push: command.push, - sms: command.sms, - } as Record; - - const channelPreferences = Object.values(ChannelTypeEnum).reduce((acc, key) => { - acc[key] = channelObj[key] !== undefined ? channelObj[key] : PREFERENCE_DEFAULT_VALUE; + const channelPreferences: IPreferenceChannels = this.buildPreferenceChannels(command); - return acc; - }, {} as IPreferenceChannels); - /* - * Backwards compatible storage of new Preferences DTO. - * - * Currently, this is a side-effect due to the way that Preferences are stored - * and resolved with overrides in cascading order, necessitating a lookup against - * the old preferences structure before we can store the new Preferences DTO. - */ - await this.storePreferences({ + await this.storePreferencesV2({ channels: channelPreferences, organizationId: command.organizationId, environmentId: command.environmentId, @@ -102,47 +81,21 @@ export class UpdatePreferences { _subscriber: subscriber._id, _workflowId: command.workflowId, level: command.level, - channels: channelObj, + channels: channelPreferences, }); const query = this.commonQuery(command, subscriber); await this.subscriberPreferenceRepository.create({ ...query, enabled: true, - channels: channelObj, + channels: channelPreferences, }); } - private async updateUserPreference( - command: UpdatePreferencesCommand, - subscriber: SubscriberEntity, - userPreference: SubscriberPreferenceEntity - ): Promise { - const channelObj = { - chat: command.chat, - email: command.email, - in_app: command.in_app, - push: command.push, - sms: command.sms, - } as Record; - - const channelPreferences = Object.values(ChannelTypeEnum).reduce((acc, key) => { - acc[key] = channelObj[key]; + private async updateUserPreference(command: UpdatePreferencesCommand, subscriber: SubscriberEntity): Promise { + const channelPreferences: IPreferenceChannels = this.buildPreferenceChannels(command); - if (acc[key] === undefined) { - acc[key] = userPreference.channels[key] === undefined ? PREFERENCE_DEFAULT_VALUE : userPreference.channels[key]; - } - - return acc; - }, {} as IPreferenceChannels); - /* - * Backwards compatible storage of new Preferences DTO. - * - * Currently, this is a side-effect due to the way that Preferences are stored - * and resolved with overrides in cascading order, necessitating a lookup against - * the old preferences structure before we can store the new Preferences DTO. - */ - await this.storePreferences({ + await this.storePreferencesV2({ channels: channelPreferences, organizationId: command.organizationId, environmentId: command.environmentId, @@ -155,11 +108,11 @@ export class UpdatePreferences { _subscriber: subscriber._id, _workflowId: command.workflowId, level: command.level, - channels: channelObj, + channels: channelPreferences, }); const updateFields = {}; - for (const [key, value] of Object.entries(channelObj)) { + for (const [key, value] of Object.entries(channelPreferences)) { if (value !== undefined) { updateFields[`channels.${key}`] = value; } @@ -171,6 +124,16 @@ export class UpdatePreferences { }); } + private buildPreferenceChannels(command: UpdatePreferencesCommand): IPreferenceChannels { + return { + ...(command.chat !== undefined && { chat: command.chat }), + ...(command.email !== undefined && { email: command.email }), + ...(command.in_app !== undefined && { in_app: command.in_app }), + ...(command.push !== undefined && { push: command.push }), + ...(command.sms !== undefined && { sms: command.sms }), + }; + } + private async findPreference( command: UpdatePreferencesCommand, subscriber: SubscriberEntity @@ -230,18 +193,17 @@ export class UpdatePreferences { }; } - private async storePreferences(item: { + /** + * Strangler pattern to migrate to V2 preferences. + */ + private async storePreferencesV2(item: { channels: IPreferenceChannels; organizationId: string; _subscriberId: string; environmentId: string; templateId?: string; - }) { + }): Promise { const preferences: WorkflowPreferencesPartial = { - all: { - enabled: PREFERENCE_DEFAULT_VALUE, - readOnly: false, - }, channels: Object.entries(item.channels).reduce( (outputChannels, [channel, enabled]) => ({ ...outputChannels, @@ -252,7 +214,7 @@ export class UpdatePreferences { }; if (item.templateId) { - return await this.upsertPreferences.upsertSubscriberWorkflowPreferences( + await this.upsertPreferences.upsertSubscriberWorkflowPreferences( UpsertSubscriberWorkflowPreferencesCommand.create({ environmentId: item.environmentId, organizationId: item.organizationId, @@ -261,15 +223,15 @@ export class UpdatePreferences { preferences, }) ); + } else { + await this.upsertPreferences.upsertSubscriberGlobalPreferences( + UpsertSubscriberGlobalPreferencesCommand.create({ + preferences, + environmentId: item.environmentId, + organizationId: item.organizationId, + _subscriberId: item._subscriberId, + }) + ); } - - return await this.upsertPreferences.upsertSubscriberGlobalPreferences( - UpsertSubscriberGlobalPreferencesCommand.create({ - preferences, - environmentId: item.environmentId, - organizationId: item.organizationId, - _subscriberId: item._subscriberId, - }) - ); } } diff --git a/apps/api/src/app/shared/commands/base.command.ts b/apps/api/src/app/shared/commands/base.command.ts index 914867a7361..9dbdb9fac69 100644 --- a/apps/api/src/app/shared/commands/base.command.ts +++ b/apps/api/src/app/shared/commands/base.command.ts @@ -1,25 +1,32 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { addBreadcrumb } from '@sentry/node'; -import { BadRequestException, flatten } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; export abstract class BaseCommand { - static create(this: new (...args: any[]) => T, data: T): T { - const convertedObject = plainToInstance(this, { + static create(this: new (...args: unknown[]) => T, data: T): T { + const convertedObject = plainToInstance(this, { ...data, }); const errors = validateSync(convertedObject as unknown as object); if (errors?.length) { - const mappedErrors = flatten(errors.map((item) => Object.values(item.constraints ?? {}))); + const mappedErrors = errors.flatMap((item) => { + if (!item.constraints) { + return []; + } - addBreadcrumb({ - category: 'BaseCommand', - data: mappedErrors, + return Object.values(item.constraints); }); - throw new BadRequestException(mappedErrors); + if (mappedErrors.length > 0) { + addBreadcrumb({ + category: 'BaseCommand', + data: mappedErrors, + }); + + throw new BadRequestException(mappedErrors); + } } return convertedObject; 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 2da13942648..4949898322b 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 @@ -58,13 +58,7 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre expect(response.data.data.preference.channels).to.not.eql({ [ChannelTypeEnum.IN_APP]: true, }); - expect(response.data.data.preference.channels).to.eql({ - [ChannelTypeEnum.EMAIL]: true, - [ChannelTypeEnum.SMS]: true, - [ChannelTypeEnum.CHAT]: true, - [ChannelTypeEnum.PUSH]: true, - [ChannelTypeEnum.IN_APP]: true, - }); + expect(response.data.data.preference.channels).to.eql({}); }); it('should update user global preferences for multiple channels', async function () { @@ -80,13 +74,7 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre const response = await updateGlobalPreferences(payload, session); expect(response.data.data.preference.enabled).to.eql(true); - expect(response.data.data.preference.channels).to.eql({ - [ChannelTypeEnum.PUSH]: true, - [ChannelTypeEnum.IN_APP]: false, - [ChannelTypeEnum.SMS]: true, - [ChannelTypeEnum.EMAIL]: true, - [ChannelTypeEnum.CHAT]: true, - }); + 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 () { @@ -104,12 +92,6 @@ describe('Update Subscribers global preferences - /subscribers/:subscriberId/pre const res = await updateGlobalPreferences(preferenceChannel, session); - expect(res.data.data.preference.channels).to.eql({ - [ChannelTypeEnum.EMAIL]: true, - [ChannelTypeEnum.SMS]: true, - [ChannelTypeEnum.CHAT]: true, - [ChannelTypeEnum.PUSH]: true, - [ChannelTypeEnum.IN_APP]: true, - }); + expect(res.data.data.preference.channels).to.eql({}); }); }); diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts index a815a7f3163..9cf5d477730 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts @@ -24,11 +24,13 @@ import { } from '@novu/application-generic'; import { CreateWorkflowDto, + DEFAULT_WORKFLOW_PREFERENCES, StepCreateDto, StepDto, StepUpdateDto, WorkflowCreationSourceEnum, WorkflowOriginEnum, + WorkflowPreferences, WorkflowResponseDto, WorkflowTypeEnum, } from '@novu/shared'; @@ -131,7 +133,7 @@ export class UpsertWorkflowUseCase { return await this.getPersistedPreferences(workflow); } - private async getPersistedPreferences(workflow) { + private async getPersistedPreferences(workflow: NotificationTemplateEntity) { return await this.getPreferencesUseCase.safeExecute( GetPreferencesCommand.create({ environmentId: workflow._environmentId, @@ -141,14 +143,24 @@ export class UpsertWorkflowUseCase { ); } - private async upsertPreferences(workflow, command: UpsertWorkflowCommand): Promise { + private async upsertPreferences( + workflow: NotificationTemplateEntity, + command: UpsertWorkflowCommand + ): Promise { + let preferences: WorkflowPreferences | null; + if (command.workflowDto.preferences?.user !== undefined) { + preferences = command.workflowDto.preferences.user; + } else { + preferences = DEFAULT_WORKFLOW_PREFERENCES; + } + return await this.upsertPreferencesUsecase.upsertUserWorkflowPreferences( UpsertUserWorkflowPreferencesCommand.create({ environmentId: workflow._environmentId, organizationId: workflow._organizationId, userId: command.user._id, templateId: workflow._id, - preferences: command.workflowDto.preferences?.user, + preferences, }) ); } diff --git a/libs/application-generic/src/commands/base.command.ts b/libs/application-generic/src/commands/base.command.ts index c96a1d5bdc2..ca652725352 100644 --- a/libs/application-generic/src/commands/base.command.ts +++ b/libs/application-generic/src/commands/base.command.ts @@ -1,30 +1,35 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { addBreadcrumb } from '@sentry/node'; -import { BadRequestException, flatten } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; export abstract class BaseCommand { static create( - this: new (...args: any[]) => T, + this: new (...args: unknown[]) => T, data: T, ): T { - const convertedObject = plainToInstance(this, { + const convertedObject = plainToInstance(this, { ...data, }); const errors = validateSync(convertedObject as unknown as object); if (errors?.length) { - const mappedErrors = flatten( - errors.map((item) => Object.values((item as any).constraints)), - ); + const mappedErrors = errors.flatMap((item) => { + if (!item.constraints) { + return []; + } - addBreadcrumb({ - category: 'BaseCommand', - data: mappedErrors, + return Object.values(item.constraints); }); - throw new BadRequestException(mappedErrors); + if (mappedErrors.length > 0) { + addBreadcrumb({ + category: 'BaseCommand', + data: mappedErrors, + }); + + throw new BadRequestException(mappedErrors); + } } return convertedObject; diff --git a/libs/application-generic/src/usecases/get-preferences/get-preferences.dto.ts b/libs/application-generic/src/usecases/get-preferences/get-preferences.dto.ts index 51dbf7dcdb8..824c23884ec 100644 --- a/libs/application-generic/src/usecases/get-preferences/get-preferences.dto.ts +++ b/libs/application-generic/src/usecases/get-preferences/get-preferences.dto.ts @@ -1,9 +1,18 @@ -import { PreferencesTypeEnum, WorkflowPreferences } from '@novu/shared'; +import { + PreferencesTypeEnum, + WorkflowPreferences, + WorkflowPreferencesPartial, +} from '@novu/shared'; export class GetPreferencesResponseDto { preferences: WorkflowPreferences; type: PreferencesTypeEnum; - source: Record; + source: { + [PreferencesTypeEnum.WORKFLOW_RESOURCE]: WorkflowPreferences; + [PreferencesTypeEnum.USER_WORKFLOW]: WorkflowPreferences | null; + [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: WorkflowPreferencesPartial | null; + [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: WorkflowPreferencesPartial | null; + }; } diff --git a/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts b/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts index 0f324f9ec48..6b0d13028d2 100644 --- a/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts +++ b/libs/application-generic/src/usecases/get-preferences/get-preferences.usecase.ts @@ -6,6 +6,7 @@ import { IPreferenceChannels, PreferencesTypeEnum, WorkflowPreferences, + WorkflowPreferencesPartial, } from '@novu/shared'; import { deepMerge } from '../../utils'; import { GetFeatureFlag, GetFeatureFlagCommand } from '../get-feature-flag'; @@ -113,11 +114,13 @@ export class GetPreferences { /** Transform WorkflowPreferences into IPreferenceChannels */ public static mapWorkflowPreferencesToChannelPreferences( - workflowPreferences: WorkflowPreferences, + workflowPreferences: WorkflowPreferencesPartial, ): IPreferenceChannels { const builtPreferences = buildWorkflowPreferences(workflowPreferences); - const mappedPreferences = Object.entries(builtPreferences.channels).reduce( + const mappedPreferences = Object.entries( + builtPreferences.channels ?? {}, + ).reduce( (acc, [channel, preference]) => ({ ...acc, [channel]: preference.enabled, @@ -140,7 +143,7 @@ export class GetPreferences { [workflowResourcePreferences, workflowUserPreferences] .filter((preference) => preference !== undefined) .map((item) => item.preferences), - ); + ) as WorkflowPreferences; const subscriberGlobalPreferences = this.getSubscriberGlobalPreferences(items); @@ -171,7 +174,7 @@ export class GetPreferences { (acc, type) => { const preference = items.find((item) => item.type === type); if (preference) { - acc[type] = preference.preferences; + acc[type] = preference.preferences as WorkflowPreferences; } else { acc[type] = null; } @@ -247,9 +250,10 @@ export class GetPreferences { // making sure we respond with correct readonly values. const mergedPreferences = deepMerge([ + workflowPreferences, subscriberPreferences, readOnlyPreference, - ]); + ]) as WorkflowPreferences; return { preferences: mergedPreferences, diff --git a/libs/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts b/libs/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts index 54bf8e703cf..cbb3092c99a 100644 --- a/libs/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts +++ b/libs/application-generic/src/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts @@ -2,6 +2,4 @@ import { SubscriberEntity } from '@novu/dal'; import { EnvironmentWithSubscriber } from '../../commands'; -export class GetSubscriberGlobalPreferenceCommand extends EnvironmentWithSubscriber { - subscriber?: SubscriberEntity; -} +export class GetSubscriberGlobalPreferenceCommand extends EnvironmentWithSubscriber {} 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 df75b19dd10..472714f036a 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 @@ -6,11 +6,13 @@ import { SubscriberRepository, } from '@novu/dal'; -import { IPreferenceChannels } from '@novu/shared'; +import { IPreferenceChannels, ChannelTypeEnum } 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'; +import { GetSubscriberPreference } from '../get-subscriber-preference/get-subscriber-preference.usecase'; +import { filteredPreference } from '../get-subscriber-template-preference/get-subscriber-template-preference.usecase'; @Injectable() export class GetSubscriberGlobalPreference { @@ -18,45 +20,96 @@ export class GetSubscriberGlobalPreference { private subscriberPreferenceRepository: SubscriberPreferenceRepository, private subscriberRepository: SubscriberRepository, private getPreferences: GetPreferences, + private getSubscriberPreference: GetSubscriberPreference, ) {} async execute(command: GetSubscriberGlobalPreferenceCommand) { - const subscriber = - command.subscriber ?? - (await this.fetchSubscriber({ - subscriberId: command.subscriberId, - _environmentId: command.environmentId, - })); + const subscriber = await this.getSubscriber(command); - if (!subscriber) { - throw new ApiException(`Subscriber ${command.subscriberId} not found`); - } + const activeChannels = await this.getActiveChannels(command); + + const subscriberGlobalPreference = await this.getSubscriberGlobalPreference( + command, + subscriber._id, + ); + + const channelsWithDefaults = this.buildDefaultPreferences( + subscriberGlobalPreference.channels, + ); + + const channels = filteredPreference(channelsWithDefaults, activeChannels); + + return { + preference: { + enabled: subscriberGlobalPreference.enabled, + channels, + }, + }; + } - const subscriberPreference = + private async getSubscriberGlobalPreference( + command: GetSubscriberGlobalPreferenceCommand, + subscriberId: string, + ): Promise<{ + channels: IPreferenceChannels; + enabled: boolean; + }> { + let subscriberGlobalChannels: IPreferenceChannels; + let enabled: boolean; + /** @deprecated */ + const subscriberGlobalPreferenceV1 = await this.subscriberPreferenceRepository.findOne({ _environmentId: command.environmentId, - _subscriberId: subscriber._id, + _subscriberId: subscriberId, level: PreferenceLevelEnum.GLOBAL, }); - const subscriberChannelPreference = - (await this.getPreferences.getPreferenceChannels({ + const subscriberGlobalPreferenceV2 = + await this.getPreferences.getPreferenceChannels({ environmentId: command.environmentId, organizationId: command.organizationId, - subscriberId: subscriber._id, - })) || subscriberPreference?.channels; - const channels = this.updatePreferenceStateWithDefault( - subscriberChannelPreference ?? {}, - ); + subscriberId, + }); + + // Prefer the V2 preference object if it exists, otherwise fallback to V1 + if (subscriberGlobalPreferenceV2 !== undefined) { + subscriberGlobalChannels = subscriberGlobalPreferenceV2; + enabled = true; + } else { + subscriberGlobalChannels = subscriberGlobalPreferenceV1?.channels ?? {}; + enabled = subscriberGlobalPreferenceV1?.enabled ?? true; + } return { - preference: { - enabled: subscriberPreference?.enabled ?? true, - channels, - }, + channels: subscriberGlobalChannels, + enabled, }; } + private async getActiveChannels( + command: GetSubscriberGlobalPreferenceCommand, + ): Promise { + const subscriberWorkflowPreferences = + await this.getSubscriberPreference.execute( + GetSubscriberGlobalPreferenceCommand.create({ + environmentId: command.environmentId, + subscriberId: command.subscriberId, + organizationId: command.organizationId, + }), + ); + + const activeChannels = new Set(); + subscriberWorkflowPreferences.forEach((subscriberWorkflowPreference) => { + Object.keys(subscriberWorkflowPreference.preference.channels).forEach( + (channel) => { + activeChannels.add(channel as ChannelTypeEnum); + }, + ); + }); + + return Array.from(activeChannels); + } + @CachedEntity({ builder: (command: { subscriberId: string; _environmentId: string }) => buildSubscriberKey({ @@ -64,20 +117,22 @@ export class GetSubscriberGlobalPreference { subscriberId: command.subscriberId, }), }) - private async fetchSubscriber({ - subscriberId, - _environmentId, - }: { - subscriberId: string; - _environmentId: string; - }): Promise { - return await this.subscriberRepository.findBySubscriberId( - _environmentId, - subscriberId, + private async getSubscriber( + command: GetSubscriberGlobalPreferenceCommand, + ): Promise { + const subscriber = await this.subscriberRepository.findBySubscriberId( + command.environmentId, + command.subscriberId, ); + + if (!subscriber) { + throw new ApiException(`Subscriber ${command.subscriberId} not found`); + } + + return subscriber; } // adds default state for missing channels - private updatePreferenceStateWithDefault(preference: IPreferenceChannels) { + private buildDefaultPreferences(preference: IPreferenceChannels) { const defaultPreference: IPreferenceChannels = { email: true, sms: true, diff --git a/libs/application-generic/src/usecases/get-subscriber-preference/get-subscriber-preference.usecase.ts b/libs/application-generic/src/usecases/get-subscriber-preference/get-subscriber-preference.usecase.ts index 494a673cc9d..cb5d5326669 100644 --- a/libs/application-generic/src/usecases/get-subscriber-preference/get-subscriber-preference.usecase.ts +++ b/libs/application-generic/src/usecases/get-subscriber-preference/get-subscriber-preference.usecase.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { NotificationTemplateRepository, SubscriberRepository, @@ -28,6 +28,11 @@ export class GetSubscriberPreference { command.environmentId, command.subscriberId, ); + if (!subscriber) { + throw new NotFoundException( + `Subscriber with id: ${command.subscriberId} not found`, + ); + } const templateList = await this.notificationTemplateRepository.filterActive( { @@ -45,7 +50,7 @@ export class GetSubscriberPreference { }, ); - return await Promise.all( + const subscriberWorkflowPreferences = await Promise.all( templateList.map(async (template) => this.getSubscriberTemplatePreferenceUsecase.execute( GetSubscriberTemplatePreferenceCommand.create({ @@ -58,5 +63,11 @@ export class GetSubscriberPreference { ), ), ); + + const nonCriticalWorkflowPreferences = subscriberWorkflowPreferences.filter( + (preference) => preference.template.critical === false, + ); + + return nonCriticalWorkflowPreferences; } } 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 801c48f0ef0..54561d4dfdd 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 @@ -46,60 +46,23 @@ export class GetSubscriberTemplatePreference { async execute( command: GetSubscriberTemplatePreferenceCommand, ): Promise { - const subscriber = - command.subscriber ?? - (await this.fetchSubscriber({ - subscriberId: command.subscriberId, - _environmentId: command.environmentId, - })); - - if (!subscriber) { - throw new ApiException(`Subscriber ${command.subscriberId} not found`); - } + const subscriber = await this.getSubscriber(command); const initialActiveChannels = await this.getActiveChannels(command); - /** - * V1 preference object. - */ - const subscriberPreference = - await this.subscriberPreferenceRepository.findOne( - { - _environmentId: command.environmentId, - _subscriberId: subscriber._id, - _templateId: command.template._id, - }, - 'enabled channels', - { readPreference: 'secondaryPreferred' }, - ); const workflowOverride = await this.getWorkflowOverride(command); const templateChannelPreference = command.template.preferenceSettings; - /** - * V2 preference object. - */ - const subscriberWorkflowPreferences = await this.getPreferences.safeExecute( - { - environmentId: command.environmentId, - organizationId: command.organizationId, - subscriberId: subscriber._id, - templateId: command.template._id, - }, - ); - - const subscriberPreferenceChannels = subscriberWorkflowPreferences - ? GetPreferences.mapWorkflowPreferencesToChannelPreferences( - subscriberWorkflowPreferences.preferences, - ) - : subscriberPreference?.channels; + const subscriberWorkflowPreference = + await this.getSubscriberWorkflowPreference(command, subscriber._id); const workflowOverrideChannelPreference = workflowOverride?.preferenceSettings; const { channels, overrides } = overridePreferences( { template: templateChannelPreference, - subscriber: subscriberPreferenceChannels, + subscriber: subscriberWorkflowPreference.channels, workflowOverride: workflowOverrideChannelPreference, }, initialActiveChannels, @@ -108,26 +71,77 @@ export class GetSubscriberTemplatePreference { const template = mapTemplateConfiguration({ ...command.template, // Use the critical flag from the V2 Preference object if it exists - ...(subscriberWorkflowPreferences && { - critical: - subscriberWorkflowPreferences.preferences?.all?.readOnly === true, + ...(subscriberWorkflowPreference.critical !== undefined && { + critical: subscriberWorkflowPreference.critical, }), }); return { template, preference: { - enabled: subscriberPreference?.enabled ?? true, + enabled: subscriberWorkflowPreference.enabled, channels, overrides, }, - /* - * TODO: Remove the fallback after we deprecate V1 preferences, as - * a type is always present for V2 preferences - */ - type: - subscriberWorkflowPreferences?.type || - PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + type: subscriberWorkflowPreference.type, + }; + } + + private async getSubscriberWorkflowPreference( + command: GetSubscriberTemplatePreferenceCommand, + subscriberId: string, + ): Promise<{ + channels: IPreferenceChannels; + critical?: boolean; + type: PreferencesTypeEnum; + enabled: boolean; + }> { + /** @deprecated */ + const subscriberWorkflowPreferenceV1 = + await this.subscriberPreferenceRepository.findOne( + { + _environmentId: command.environmentId, + _subscriberId: subscriberId, + _templateId: command.template._id, + }, + 'enabled channels', + { readPreference: 'secondaryPreferred' }, + ); + + const subscriberWorkflowPreferenceV2 = + await this.getPreferences.safeExecute({ + environmentId: command.environmentId, + organizationId: command.organizationId, + subscriberId, + templateId: command.template._id, + }); + + let subscriberWorkflowChannels: IPreferenceChannels; + let subscriberPreferenceType: PreferencesTypeEnum; + let critical: boolean | undefined; + let enabled: boolean; + // Prefer the V2 preference object if it exists, otherwise fallback to V1 + if (subscriberWorkflowPreferenceV2 !== undefined) { + subscriberWorkflowChannels = + GetPreferences.mapWorkflowPreferencesToChannelPreferences( + subscriberWorkflowPreferenceV2.preferences, + ); + subscriberPreferenceType = subscriberWorkflowPreferenceV2.type; + critical = subscriberWorkflowPreferenceV2.preferences?.all?.readOnly; + enabled = true; + } else { + subscriberWorkflowChannels = + subscriberWorkflowPreferenceV1?.channels ?? {}; + subscriberPreferenceType = PreferencesTypeEnum.SUBSCRIBER_WORKFLOW; + critical = undefined; + enabled = subscriberWorkflowPreferenceV1?.enabled ?? true; + } + + return { + channels: subscriberWorkflowChannels, + critical, + type: subscriberPreferenceType, + enabled, }; } @@ -216,23 +230,29 @@ export class GetSubscriberTemplatePreference { } @CachedEntity({ - builder: (command: { subscriberId: string; _environmentId: string }) => + builder: (command: GetSubscriberTemplatePreferenceCommand) => buildSubscriberKey({ - _environmentId: command._environmentId, + _environmentId: command.environmentId, subscriberId: command.subscriberId, }), }) - private async fetchSubscriber({ - subscriberId, - _environmentId, - }: { - subscriberId: string; - _environmentId: string; - }): Promise { - return await this.subscriberRepository.findBySubscriberId( - _environmentId, - subscriberId, + private async getSubscriber( + command: GetSubscriberTemplatePreferenceCommand, + ): Promise { + if (command.subscriber) { + return command.subscriber; + } + + const subscriber = await this.subscriberRepository.findBySubscriberId( + command.environmentId, + command.subscriberId, ); + + if (!subscriber) { + throw new ApiException(`Subscriber ${command.subscriberId} not found`); + } + + return subscriber; } } 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 index e478df37ce6..085ae8ded77 100644 --- a/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.command.ts +++ b/libs/application-generic/src/usecases/upsert-preferences/upsert-preferences.command.ts @@ -1,17 +1,108 @@ -import { IsDefined, IsEnum } from 'class-validator'; -import { PreferencesTypeEnum, WorkflowPreferencesPartial } from '@novu/shared'; +import { + IsEnum, + IsBoolean, + IsOptional, + IsObject, + ValidateNested, + IsNotEmpty, + IsMongoId, + ValidateIf, +} from 'class-validator'; +import { + ChannelPreference as ChannelPreferenceType, + PreferencesTypeEnum, + WorkflowPreferencesPartial, + WorkflowPreference as WorkflowPreferenceType, + ChannelTypeEnum, +} from '@novu/shared'; +import { Type } from 'class-transformer'; import { EnvironmentCommand } from '../../commands'; -export class UpsertPreferencesCommand extends EnvironmentCommand { - @IsDefined() - readonly preferences: WorkflowPreferencesPartial; +class WorkflowPreference implements Partial { + @IsOptional() + @IsBoolean() + readonly enabled?: boolean; - _subscriberId?: string; + @IsOptional() + @IsBoolean() + readonly readOnly?: boolean; +} + +export class ChannelPreference implements Partial { + @IsOptional() + @IsBoolean() + readonly enabled?: boolean; +} + +class ChannelPreferences + implements Partial> +{ + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => ChannelPreference) + readonly email?: ChannelPreference; + + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => ChannelPreference) + readonly sms?: ChannelPreference; + + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => ChannelPreference) + readonly in_app?: ChannelPreference; + + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => ChannelPreference) + readonly push?: ChannelPreference; + + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => ChannelPreference) + readonly chat?: ChannelPreference; +} + +export class Preferences implements WorkflowPreferencesPartial { + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => WorkflowPreference) + readonly all?: WorkflowPreference; + + @IsObject() + @ValidateNested() + @Type(() => ChannelPreferences) + readonly channels?: ChannelPreferences; +} + +export class UpsertPreferencesBaseCommand extends EnvironmentCommand { + @IsObject() + @ValidateNested() + @Type(() => Preferences) + @ValidateIf((object, value) => value !== null) + readonly preferences: Preferences | null; +} + +export class UpsertPreferencesCommand extends UpsertPreferencesBaseCommand { + @IsOptional() + @IsMongoId() + readonly _subscriberId?: string; - userId?: string; + @IsOptional() + @IsMongoId() + readonly userId?: string; - templateId?: string; + @IsOptional() + @IsMongoId() + readonly templateId?: string; + @IsNotEmpty() @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 index 3c778d62f4f..7b9e3d388dd 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 @@ -1,11 +1,16 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { PreferencesEntity, PreferencesRepository } from '@novu/dal'; -import { buildWorkflowPreferences, PreferencesTypeEnum } from '@novu/shared'; +import { + buildWorkflowPreferences, + PreferencesTypeEnum, + WorkflowPreferencesPartial, +} from '@novu/shared'; 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'; +import { deepMerge } from '../../utils'; @Injectable() export class UpsertPreferences { @@ -14,11 +19,17 @@ export class UpsertPreferences { public async upsertWorkflowPreferences( command: UpsertWorkflowPreferencesCommand, ) { + /* + * Only Workflow Preferences need to be built with default values to ensure + * there is always a value to fall back to during preference merging. + */ + const builtPreferences = buildWorkflowPreferences(command.preferences); + return this.upsert({ templateId: command.templateId, environmentId: command.environmentId, organizationId: command.organizationId, - preferences: command.preferences, + preferences: builtPreferences, type: PreferencesTypeEnum.WORKFLOW_RESOURCE, }); } @@ -26,6 +37,8 @@ export class UpsertPreferences { public async upsertSubscriberGlobalPreferences( command: UpsertSubscriberGlobalPreferencesCommand, ) { + await this.deleteSubscriberWorkflowChannelPreferences(command); + return this.upsert({ _subscriberId: command._subscriberId, environmentId: command.environmentId, @@ -35,6 +48,32 @@ export class UpsertPreferences { }); } + private async deleteSubscriberWorkflowChannelPreferences( + command: UpsertSubscriberGlobalPreferencesCommand, + ) { + const channelTypes = Object.keys(command.preferences?.channels || {}); + + const preferenceUnsetPayload = channelTypes.reduce((acc, channelType) => { + acc[`preferences.channels.${channelType}`] = ''; + + return acc; + }, {}); + + await this.preferencesRepository.update( + { + _organizationId: command.organizationId, + _subscriberId: command._subscriberId, + type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + $or: channelTypes.map((channelType) => ({ + [`preferences.channels.${channelType}`]: { $exists: true }, + })), + }, + { + $unset: preferenceUnsetPayload, + }, + ); + } + public async upsertSubscriberWorkflowPreferences( command: UpsertSubscriberWorkflowPreferencesCommand, ) { @@ -64,24 +103,21 @@ export class UpsertPreferences { private async upsert( command: UpsertPreferencesCommand, ): Promise { - const foundId = await this.getPreferencesId(command); + const foundPreference = await this.getPreference(command); if (command.preferences === null) { - return this.deletePreferences(command, foundId); - } - - const builtPreferences = buildWorkflowPreferences(command.preferences); + if (!foundPreference) { + throw new BadRequestException('Preference not found'); + } - const builtCommand = { - ...command, - preferences: builtPreferences, - }; + return this.deletePreferences(command, foundPreference?._id); + } - if (foundId) { - return this.updatePreferences(foundId, builtCommand); + if (foundPreference) { + return this.updatePreferences(foundPreference, command); } - return this.createPreferences(builtCommand); + return this.createPreferences(command); } private async createPreferences( @@ -99,26 +135,28 @@ export class UpsertPreferences { } private async updatePreferences( - preferencesId: string, + foundPreference: PreferencesEntity, command: UpsertPreferencesCommand, ): Promise { + const mergedPreferences = deepMerge([ + foundPreference.preferences, + command.preferences as WorkflowPreferencesPartial, + ]); + await this.preferencesRepository.update( { - _id: preferencesId, + _id: foundPreference._id, _environmentId: command.environmentId, }, { $set: { - preferences: command.preferences, + preferences: mergedPreferences, _userId: command.userId, }, }, ); - return await this.preferencesRepository.findOne({ - _id: preferencesId, - _environmentId: command.environmentId, - }); + return await this.getPreference(command); } private async deletePreferences( @@ -133,20 +171,15 @@ export class UpsertPreferences { }); } - private async getPreferencesId( + private async getPreference( command: UpsertPreferencesCommand, - ): Promise { - const found = await this.preferencesRepository.findOne( - { - _subscriberId: command._subscriberId, - _environmentId: command.environmentId, - _organizationId: command.organizationId, - _templateId: command.templateId, - type: command.type, - }, - '_id', - ); - - return found?._id; + ): Promise { + return await this.preferencesRepository.findOne({ + _subscriberId: command._subscriberId, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + _templateId: command.templateId, + type: command.type, + }); } } 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 index fe1329cb5fa..4e2b68ca2e5 100644 --- 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 @@ -1,12 +1,8 @@ -import { IsDefined, IsMongoId, IsNotEmpty } from 'class-validator'; -import { WorkflowPreferencesPartial } from '@novu/shared'; -import { EnvironmentCommand } from '../../commands'; - -export class UpsertSubscriberGlobalPreferencesCommand extends EnvironmentCommand { - @IsDefined() - readonly preferences: WorkflowPreferencesPartial; +import { IsMongoId, IsNotEmpty } from 'class-validator'; +import { UpsertPreferencesBaseCommand } from './upsert-preferences.command'; +export class UpsertSubscriberGlobalPreferencesCommand extends UpsertPreferencesBaseCommand { @IsNotEmpty() @IsMongoId() - _subscriberId: string; + readonly _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 index a46863d8566..50444252edc 100644 --- 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 @@ -1,7 +1,8 @@ -import { IsNotEmpty } from 'class-validator'; +import { IsMongoId, IsNotEmpty } from 'class-validator'; import { UpsertSubscriberGlobalPreferencesCommand } from './upsert-subscriber-global-preferences.command'; export class UpsertSubscriberWorkflowPreferencesCommand extends UpsertSubscriberGlobalPreferencesCommand { @IsNotEmpty() - templateId: string; + @IsMongoId() + readonly 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 index 7161a56f3cb..8c52cdfe2be 100644 --- 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 @@ -1,7 +1,8 @@ -import { IsNotEmpty } from 'class-validator'; +import { IsMongoId, IsNotEmpty } from 'class-validator'; import { UpsertWorkflowPreferencesCommand } from './upsert-workflow-preferences.command'; export class UpsertUserWorkflowPreferencesCommand extends UpsertWorkflowPreferencesCommand { @IsNotEmpty() - userId: string; + @IsMongoId() + readonly 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 index 734665a17ec..37514245d71 100644 --- 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 @@ -1,11 +1,8 @@ -import { IsNotEmpty, IsOptional } from 'class-validator'; -import { WorkflowPreferencesPartial } from '@novu/shared'; -import { EnvironmentCommand } from '../../commands'; - -export class UpsertWorkflowPreferencesCommand extends EnvironmentCommand { - @IsOptional() - readonly preferences?: WorkflowPreferencesPartial | null; +import { IsMongoId, IsNotEmpty } from 'class-validator'; +import { UpsertPreferencesBaseCommand } from './upsert-preferences.command'; +export class UpsertWorkflowPreferencesCommand extends UpsertPreferencesBaseCommand { @IsNotEmpty() - templateId: string; + @IsMongoId() + readonly templateId: string; } diff --git a/libs/dal/src/repositories/preferences/preferences.entity.ts b/libs/dal/src/repositories/preferences/preferences.entity.ts index ffd1941b072..cd6e5151512 100644 --- a/libs/dal/src/repositories/preferences/preferences.entity.ts +++ b/libs/dal/src/repositories/preferences/preferences.entity.ts @@ -1,4 +1,4 @@ -import type { WorkflowPreferences } from '@novu/shared'; +import type { WorkflowPreferencesPartial } from '@novu/shared'; import { PreferencesTypeEnum } from '@novu/shared'; import type { OrganizationId } from '../organization'; import type { EnvironmentId } from '../environment'; @@ -26,5 +26,5 @@ export class PreferencesEntity { type: PreferencesTypeEnum; - preferences: WorkflowPreferences; + preferences: WorkflowPreferencesPartial; } diff --git a/libs/dal/src/repositories/preferences/preferences.schema.ts b/libs/dal/src/repositories/preferences/preferences.schema.ts index 291c6e3887a..fccfcf7b44c 100644 --- a/libs/dal/src/repositories/preferences/preferences.schema.ts +++ b/libs/dal/src/repositories/preferences/preferences.schema.ts @@ -32,42 +32,35 @@ const preferencesSchema = new Schema( all: { enabled: { type: Schema.Types.Boolean, - default: true, }, readOnly: { type: Schema.Types.Boolean, - default: false, }, }, channels: { [ChannelTypeEnum.EMAIL]: { enabled: { type: Schema.Types.Boolean, - default: true, }, }, [ChannelTypeEnum.SMS]: { enabled: { type: Schema.Types.Boolean, - default: true, }, }, [ChannelTypeEnum.IN_APP]: { enabled: { type: Schema.Types.Boolean, - default: true, }, }, [ChannelTypeEnum.CHAT]: { enabled: { type: Schema.Types.Boolean, - default: true, }, }, [ChannelTypeEnum.PUSH]: { enabled: { type: Schema.Types.Boolean, - default: true, }, }, }, diff --git a/packages/js/src/preferences/helpers.ts b/packages/js/src/preferences/helpers.ts index 627c7a7a24e..1fd9ce0453b 100644 --- a/packages/js/src/preferences/helpers.ts +++ b/packages/js/src/preferences/helpers.ts @@ -1,19 +1,27 @@ import { InboxService } from '../api'; import type { NovuEventEmitter } from '../event-emitter'; -import type { Result } from '../types'; +import type { ChannelPreference, Result } from '../types'; +import { ChannelType, PreferenceLevel } from '../types'; import { Preference } from './preference'; import type { UpdatePreferencesArgs } from './types'; import { NovuError } from '../utils/errors'; +import { PreferencesCache } from '../cache/preferences-cache'; + +type UpdatePreferenceParams = { + emitter: NovuEventEmitter; + apiService: InboxService; + cache: PreferencesCache; + useCache: boolean; + args: UpdatePreferencesArgs; +}; export const updatePreference = async ({ emitter, apiService, + cache, + useCache, args, -}: { - emitter: NovuEventEmitter; - apiService: InboxService; - args: UpdatePreferencesArgs; -}): Result => { +}: UpdatePreferenceParams): Result => { const { workflowId, channelPreferences } = args; try { emitter.emit('preference.update.pending', { @@ -30,6 +38,8 @@ export const updatePreference = async ({ { emitterInstance: emitter, inboxServiceInstance: apiService, + cache, + useCache, } ) : undefined, @@ -39,12 +49,15 @@ export const updatePreference = async ({ if (workflowId) { response = await apiService.updateWorkflowPreferences({ workflowId, channelPreferences }); } else { + optimisticUpdateWorkflowPreferences({ emitter, apiService, cache, useCache, args }); response = await apiService.updateGlobalPreferences(channelPreferences); } const preference = new Preference(response, { emitterInstance: emitter, inboxServiceInstance: apiService, + cache, + useCache, }); emitter.emit('preference.update.resolved', { args, data: preference }); @@ -55,3 +68,45 @@ export const updatePreference = async ({ return { error: new NovuError('Failed to fetch notifications', error) }; } }; + +const optimisticUpdateWorkflowPreferences = ({ + emitter, + apiService, + cache, + useCache, + args, +}: UpdatePreferenceParams): void => { + const allPreferences = useCache ? cache?.getAll({}) : undefined; + + allPreferences?.forEach((el) => { + if (el.level === PreferenceLevel.TEMPLATE) { + const mergedPreference = { + ...el, + channels: Object.entries(el.channels).reduce((acc, [key, value]) => { + const channelType = key as ChannelType; + acc[channelType] = args.channelPreferences[channelType] ?? value; + + return acc; + }, {} as ChannelPreference), + }; + const updatedPreference = args.preference + ? new Preference(mergedPreference, { + emitterInstance: emitter, + inboxServiceInstance: apiService, + cache, + useCache, + }) + : undefined; + + if (updatedPreference) { + emitter.emit('preference.update.pending', { + args: { + workflowId: el.workflow?.id, + channelPreferences: updatedPreference.channels, + }, + data: updatedPreference, + }); + } + } + }); +}; diff --git a/packages/js/src/preferences/preference.ts b/packages/js/src/preferences/preference.ts index c358d54258e..be6f0f80508 100644 --- a/packages/js/src/preferences/preference.ts +++ b/packages/js/src/preferences/preference.ts @@ -3,12 +3,15 @@ import { InboxService } from '../api'; import { NovuEventEmitter } from '../event-emitter'; import { ChannelPreference, PreferenceLevel, Result, Workflow } from '../types'; import { updatePreference } from './helpers'; +import { PreferencesCache } from '../cache/preferences-cache'; type PreferenceLike = Pick; export class Preference { #emitter: NovuEventEmitter; #apiService: InboxService; + #cache: PreferencesCache; + #useCache: boolean; readonly level: PreferenceLevel; readonly enabled: boolean; @@ -17,11 +20,22 @@ export class Preference { constructor( preference: PreferenceLike, - { emitterInstance, inboxServiceInstance }: { emitterInstance: NovuEventEmitter; inboxServiceInstance: InboxService } + { + emitterInstance, + inboxServiceInstance, + cache, + useCache, + }: { + emitterInstance: NovuEventEmitter; + inboxServiceInstance: InboxService; + cache: PreferencesCache; + useCache: boolean; + } ) { this.#emitter = emitterInstance; this.#apiService = inboxServiceInstance; - + this.#cache = cache; + this.#useCache = useCache; this.level = preference.level; this.enabled = preference.enabled; this.channels = preference.channels; @@ -32,6 +46,8 @@ export class Preference { return updatePreference({ emitter: this.#emitter, apiService: this.#apiService, + cache: this.#cache, + useCache: this.#useCache, args: { workflowId: this.workflow?.id, channelPreferences, diff --git a/packages/js/src/preferences/preferences.ts b/packages/js/src/preferences/preferences.ts index 46423b5b966..62c3e565507 100644 --- a/packages/js/src/preferences/preferences.ts +++ b/packages/js/src/preferences/preferences.ts @@ -44,6 +44,8 @@ export class Preferences extends BaseModule { new Preference(el, { emitterInstance: this._emitter, inboxServiceInstance: this._inboxService, + cache: this.cache, + useCache: this.#useCache, }) ); @@ -62,10 +64,4 @@ export class Preferences extends BaseModule { } }); } - - async update(args: UpdatePreferencesArgs): Result { - return this.callWithSession(async () => - updatePreference({ emitter: this._emitter, apiService: this._inboxService, args }) - ); - } } diff --git a/playground/nextjs/src/pages/preferences/index.tsx b/playground/nextjs/src/pages/preferences/index.tsx index 55f56111126..d331c5adba8 100644 --- a/playground/nextjs/src/pages/preferences/index.tsx +++ b/playground/nextjs/src/pages/preferences/index.tsx @@ -6,7 +6,7 @@ export default function Home() { return ( <> - <div className="w-96 h-96 overflow-y-auto"> + <div className="w-96 h-[600px] overflow-y-auto"> <Inbox {...novuConfig}> <Preferences /> </Inbox>