Skip to content

Commit

Permalink
feat(web): Add Workflow Preferences for Cloud & Studio (#6447)
Browse files Browse the repository at this point in the history
Co-authored-by: Joel Anton <[email protected]>
Co-authored-by: Gali Ainouz Baum <[email protected]>
Co-authored-by: Richard Fontein <[email protected]>
  • Loading branch information
4 people authored Sep 13, 2024
1 parent 85726d6 commit 03c972a
Show file tree
Hide file tree
Showing 33 changed files with 1,016 additions and 354 deletions.
1 change: 0 additions & 1 deletion apps/api/src/app/bridge/usecases/sync/sync.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
EnvironmentRepository,
NotificationGroupRepository,
NotificationTemplateEntity,
PreferencesActorEnum,
} from '@novu/dal';
import {
AnalyticsService,
Expand Down
60 changes: 59 additions & 1 deletion apps/api/src/app/events/e2e/bridge-sync.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { EnvironmentRepository, NotificationTemplateRepository, MessageTemplateRepository } from '@novu/dal';
import { WorkflowTypeEnum } from '@novu/shared';
import { FeatureFlagsKeysEnum, WorkflowTypeEnum } from '@novu/shared';
import { workflow } from '@novu/framework';
import { BridgeServer } from '../../../../e2e/bridge.server';

Expand All @@ -22,13 +22,17 @@ describe('Bridge Sync - /bridge/sync (POST)', async () => {

let bridgeServer: BridgeServer;
beforeEach(async () => {
// @ts-ignore
process.env[FeatureFlagsKeysEnum.IS_WORKFLOW_PREFERENCES_ENABLED] = 'true';
session = new UserSession();
await session.initialize();
bridgeServer = new BridgeServer();
});

afterEach(async () => {
await bridgeServer.stop();
// @ts-ignore
process.env[FeatureFlagsKeysEnum.IS_WORKFLOW_PREFERENCES_ENABLED] = 'false';
});

it('should update bridge url', async () => {
Expand Down Expand Up @@ -307,4 +311,58 @@ describe('Bridge Sync - /bridge/sync (POST)', async () => {
expect(workflowData.steps[1].uuid).to.equal('send-sms-2');
expect(workflowData.steps[1].name).to.equal('send-sms-2');
});

it('should create workflow preferences', async () => {
const workflowId = 'hello-world-preferences';
const newWorkflow = workflow(
workflowId,
async ({ step }) => {
await step.inApp('send-in-app', () => ({
subject: 'Welcome!',
body: 'Hello there',
}));
},
{
preferences: {
workflow: {
defaultValue: false,
readOnly: true,
},
channels: {
inApp: {
defaultValue: true,
readOnly: true,
},
},
},
}
);
await bridgeServer.start({ workflows: [newWorkflow] });

const result = await session.testAgent.post(`/v1/bridge/sync`).send({
bridgeUrl: bridgeServer.serverPath,
});

const dashboardPreferences = {
workflow: { defaultValue: false, readOnly: true },
channels: {
email: { defaultValue: true, readOnly: false },
sms: { defaultValue: true, readOnly: false },
inApp: { defaultValue: false, readOnly: true },
chat: { defaultValue: true, readOnly: false },
push: { defaultValue: true, readOnly: false },
},
};

await session.testAgent.post(`/v1/preferences`).send({
preferences: dashboardPreferences,
workflowId: result.body.data[0]._id,
});

const response = await session.testAgent
.get('/v1/inbox/preferences')
.set('Authorization', `Bearer ${session.subscriberToken}`);

expect(response.status).to.equal(200);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export class GetPreferences {
id: workflow._id,
identifier: workflow.triggers[0].identifier,
name: workflow.name,
critical: workflow.critical,
critical: workflow.critical || workflowPreference.template.critical,
tags: workflow.tags,
},
} satisfies InboxPreference;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
NotificationTemplateRepository,
PreferenceLevelEnum,
SubscriberEntity,
SubscriberPreferenceEntity,
SubscriberPreferenceRepository,
SubscriberRepository,
} from '@novu/dal';
Expand Down Expand Up @@ -55,11 +56,13 @@ export class UpdatePreferences {
}
}

const userPreference = await this.subscriberPreferenceRepository.findOne(this.commonQuery(command, subscriber));
const userPreference: SubscriberPreferenceEntity | null = await this.subscriberPreferenceRepository.findOne(
this.commonQuery(command, subscriber)
);
if (!userPreference) {
await this.createUserPreference(command, subscriber);
} else {
await this.updateUserPreference(command, subscriber);
await this.updateUserPreference(command, subscriber, userPreference);
}

return await this.findPreference(command, subscriber);
Expand All @@ -74,6 +77,26 @@ export class UpdatePreferences {
sms: command.sms,
} as Record<ChannelTypeEnum, boolean>;

const channelPreferences = Object.values(ChannelTypeEnum).reduce((acc, key) => {
acc[key] = channelObj[key] !== undefined ? channelObj[key] : PREFERENCE_DEFAULT_VALUE;

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({
channels: channelPreferences,
organizationId: command.organizationId,
environmentId: command.environmentId,
_subscriberId: subscriber._id,
templateId: command.workflowId,
});

this.analyticsService.mixpanelTrack(AnalyticsEventsEnum.CREATE_PREFERENCES, '', {
_organization: command.organizationId,
_subscriber: subscriber._id,
Expand All @@ -90,7 +113,11 @@ export class UpdatePreferences {
});
}

private async updateUserPreference(command: UpdatePreferencesCommand, subscriber: SubscriberEntity): Promise<void> {
private async updateUserPreference(
command: UpdatePreferencesCommand,
subscriber: SubscriberEntity,
userPreference: SubscriberPreferenceEntity
): Promise<void> {
const channelObj = {
chat: command.chat,
email: command.email,
Expand All @@ -99,6 +126,30 @@ export class UpdatePreferences {
sms: command.sms,
} as Record<ChannelTypeEnum, boolean>;

const channelPreferences = Object.values(ChannelTypeEnum).reduce((acc, key) => {
acc[key] = channelObj[key];

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({
channels: channelPreferences,
organizationId: command.organizationId,
environmentId: command.environmentId,
_subscriberId: subscriber._id,
templateId: command.workflowId,
});

this.analyticsService.mixpanelTrack(AnalyticsEventsEnum.UPDATE_PREFERENCES, '', {
_organization: command.organizationId,
_subscriber: subscriber._id,
Expand Down Expand Up @@ -140,15 +191,6 @@ export class UpdatePreferences {
})
);

await this.storePreferences({
enabled: preference.enabled,
channels: preference.channels,
organizationId: command.organizationId,
environmentId: command.environmentId,
_subscriberId: subscriber._id,
templateId: workflow._id,
});

return {
level: PreferenceLevelEnum.TEMPLATE,
enabled: preference.enabled,
Expand All @@ -171,14 +213,6 @@ export class UpdatePreferences {
})
);

await this.storePreferences({
enabled: preference.enabled,
channels: preference.channels,
organizationId: command.organizationId,
environmentId: command.environmentId,
_subscriberId: subscriber._id,
});

return {
level: PreferenceLevelEnum.GLOBAL,
enabled: preference.enabled,
Expand All @@ -197,7 +231,6 @@ export class UpdatePreferences {
}

private async storePreferences(item: {
enabled: boolean;
channels: IPreferenceChannels;
organizationId: string;
_subscriberId: string;
Expand All @@ -206,28 +239,28 @@ export class UpdatePreferences {
}) {
const preferences = {
workflow: {
defaultValue: item.enabled || PREFERENCE_DEFAULT_VALUE,
defaultValue: PREFERENCE_DEFAULT_VALUE,
readOnly: false,
},
channels: {
in_app: {
defaultValue: item.channels.in_app || PREFERENCE_DEFAULT_VALUE,
defaultValue: item.channels.in_app !== undefined ? item.channels.in_app : PREFERENCE_DEFAULT_VALUE,
readOnly: false,
},
sms: {
defaultValue: item.channels.sms || PREFERENCE_DEFAULT_VALUE,
defaultValue: item.channels.sms !== undefined ? item.channels.sms : PREFERENCE_DEFAULT_VALUE,
readOnly: false,
},
email: {
defaultValue: item.channels.email || PREFERENCE_DEFAULT_VALUE,
defaultValue: item.channels.email !== undefined ? item.channels.email : PREFERENCE_DEFAULT_VALUE,
readOnly: false,
},
push: {
defaultValue: item.channels.push || PREFERENCE_DEFAULT_VALUE,
defaultValue: item.channels.push !== undefined ? item.channels.push : PREFERENCE_DEFAULT_VALUE,
readOnly: false,
},
chat: {
defaultValue: item.channels.chat || PREFERENCE_DEFAULT_VALUE,
defaultValue: item.channels.chat !== undefined ? item.channels.chat : PREFERENCE_DEFAULT_VALUE,
readOnly: false,
},
},
Expand Down
20 changes: 10 additions & 10 deletions apps/api/src/app/preferences/preferences.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
UpsertUserWorkflowPreferencesCommand,
UpsertWorkflowPreferencesCommand,
} from '@novu/application-generic';
import { PreferencesActorEnum, PreferencesRepository, SubscriberRepository } from '@novu/dal';
import { PreferencesRepository, PreferencesTypeEnum, SubscriberRepository } from '@novu/dal';
import { FeatureFlagsKeysEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
Expand Down Expand Up @@ -80,7 +80,7 @@ describe('Preferences', function () {
expect(workflowPreferences._templateId).to.equal(workflowId);
expect(workflowPreferences._userId).to.be.undefined;
expect(workflowPreferences._subscriberId).to.be.undefined;
expect(workflowPreferences.actor).to.equal(PreferencesActorEnum.WORKFLOW);
expect(workflowPreferences.type).to.equal(PreferencesTypeEnum.WORKFLOW_RESOURCE);
});

it('should create user workflow preferences', async function () {
Expand Down Expand Up @@ -126,7 +126,7 @@ describe('Preferences', function () {
expect(userPreferences._templateId).to.equal(workflowId);
expect(userPreferences._userId).to.equal(session.user._id);
expect(userPreferences._subscriberId).to.be.undefined;
expect(userPreferences.actor).to.equal(PreferencesActorEnum.USER);
expect(userPreferences.type).to.equal(PreferencesTypeEnum.USER_WORKFLOW);
});

it('should create global subscriber preferences', async function () {
Expand Down Expand Up @@ -171,7 +171,7 @@ describe('Preferences', function () {
expect(subscriberGlobalPreferences._templateId).to.be.undefined;
expect(subscriberGlobalPreferences._userId).to.be.undefined;
expect(subscriberGlobalPreferences._subscriberId).to.equal(subscriberId);
expect(subscriberGlobalPreferences.actor).to.equal(PreferencesActorEnum.SUBSCRIBER);
expect(subscriberGlobalPreferences.type).to.equal(PreferencesTypeEnum.SUBSCRIBER_GLOBAL);
});

it('should create subscriber workflow preferences', async function () {
Expand Down Expand Up @@ -217,7 +217,7 @@ describe('Preferences', function () {
expect(subscriberWorkflowPreferences._templateId).to.equal(workflowId);
expect(subscriberWorkflowPreferences._userId).to.be.undefined;
expect(subscriberWorkflowPreferences._subscriberId).to.equal(subscriberId);
expect(subscriberWorkflowPreferences.actor).to.equal(PreferencesActorEnum.SUBSCRIBER);
expect(subscriberWorkflowPreferences.type).to.equal(PreferencesTypeEnum.SUBSCRIBER_WORKFLOW);
});

it('should update preferences', async function () {
Expand Down Expand Up @@ -262,7 +262,7 @@ describe('Preferences', function () {
expect(workflowPreferences._templateId).to.equal(workflowId);
expect(workflowPreferences._userId).to.be.undefined;
expect(workflowPreferences._subscriberId).to.be.undefined;
expect(workflowPreferences.actor).to.equal(PreferencesActorEnum.WORKFLOW);
expect(workflowPreferences.type).to.equal(PreferencesTypeEnum.WORKFLOW_RESOURCE);

workflowPreferences = await upsertPreferences.upsertWorkflowPreferences(
UpsertWorkflowPreferencesCommand.create({
Expand Down Expand Up @@ -462,7 +462,7 @@ describe('Preferences', function () {
channels: {
in_app: {
defaultValue: false,
readOnly: true,
readOnly: false,
},
sms: {
defaultValue: false,
Expand Down Expand Up @@ -503,7 +503,7 @@ describe('Preferences', function () {
channels: {
in_app: {
defaultValue: false,
readOnly: true,
readOnly: false,
},
sms: {
defaultValue: false,
Expand Down Expand Up @@ -577,11 +577,11 @@ describe('Preferences', function () {
channels: {
in_app: {
defaultValue: false,
readOnly: true,
readOnly: false,
},
sms: {
defaultValue: false,
readOnly: true,
readOnly: false,
},
email: {
defaultValue: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,7 @@ describe('Update workflow by id - /workflows/:workflowId (PUT)', async () => {
const { body } = await session.testAgent.put(`/v1/workflows/${template2._id}`).send(update);

expect(body.statusCode).to.equal(400);
expect(body.message).to.equal(
`Notification template with identifier ${template1.triggers[0].identifier} already exists`
);
expect(body.message).to.equal(`Workflow with identifier ${template1.triggers[0].identifier} already exists`);
expect(body.error).to.equal('Bad Request');
});

Expand Down
Loading

0 comments on commit 03c972a

Please sign in to comment.