diff --git a/libs/application-generic/src/http/utils.types.ts b/libs/application-generic/src/http/utils.types.ts index 11226e0f161..310d7f1977b 100644 --- a/libs/application-generic/src/http/utils.types.ts +++ b/libs/application-generic/src/http/utils.types.ts @@ -4,6 +4,15 @@ */ export type WithRequired = T & { [P in K]-?: T[P] }; +/** + * Recursively make all properties of type `T` required. + */ +export type DeepRequired = T extends object + ? { + [P in keyof T]-?: DeepRequired; + } + : T; + /** * Transform S to CONSTANT_CASE. */ 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 68f2e7dc7e1..bd7cf780f82 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 @@ -7,10 +7,26 @@ import { WorkflowPreferences, WorkflowPreferencesPartial, } from '@novu/shared'; -import { deepMerge } from '../../utils'; import { GetPreferencesCommand } from './get-preferences.command'; import { GetPreferencesResponseDto } from './get-preferences.dto'; import { InstrumentUsecase } from '../../instrumentation'; +import { MergePreferences } from '../merge-preferences/merge-preferences.usecase'; +import { MergePreferencesCommand } from '../merge-preferences/merge-preferences.command'; + +export type PreferenceSet = { + workflowResourcePreference?: PreferencesEntity & { + preferences: WorkflowPreferences; + }; + workflowUserPreference?: PreferencesEntity & { + preferences: WorkflowPreferences; + }; + subscriberGlobalPreference?: PreferencesEntity & { + preferences: WorkflowPreferencesPartial; + }; + subscriberWorkflowPreference?: PreferencesEntity & { + preferences: WorkflowPreferencesPartial; + }; +}; class PreferencesNotFoundException extends BadRequestException { constructor(featureFlagCommand: GetPreferencesCommand) { @@ -28,11 +44,9 @@ export class GetPreferences { ): Promise { const items = await this.getPreferencesFromDb(command); - if (items.length === 0) { - throw new PreferencesNotFoundException(command); - } - - const mergedPreferences = this.mergePreferences(items, command.templateId); + const mergedPreferences = MergePreferences.execute( + MergePreferencesCommand.create(items), + ); if (!mergedPreferences.preferences) { throw new PreferencesNotFoundException(command); @@ -99,221 +113,43 @@ export class GetPreferences { return mappedPreferences; } - private mergePreferences( - items: PreferencesEntity[], - workflowId?: string, - ): GetPreferencesResponseDto { - const workflowResourcePreferences = - this.getWorkflowResourcePreferences(items); - const workflowUserPreferences = this.getWorkflowUserPreferences(items); - - const workflowPreferences = deepMerge( - [workflowResourcePreferences, workflowUserPreferences] - .filter((preference) => preference !== undefined) - .map((item) => item.preferences), - ) as WorkflowPreferences; - - const subscriberGlobalPreferences = - this.getSubscriberGlobalPreferences(items); - const subscriberWorkflowPreferences = this.getSubscriberWorkflowPreferences( - items, - workflowId, - ); - - const subscriberPreferences = deepMerge( - [subscriberGlobalPreferences, subscriberWorkflowPreferences] - .filter((preference) => preference !== undefined) - .map((item) => item.preferences), - ); - - /** - * Order is important here because we like the workflowPreferences (that comes from the bridge) - * to be overridden by any other preferences and then we have preferences defined in dashboard and - * then subscribers global preferences and the once that should be used if it says other then anything before it - * we use subscribers workflow preferences - */ - const preferencesEntities = [ - workflowResourcePreferences, - workflowUserPreferences, - subscriberGlobalPreferences, - subscriberWorkflowPreferences, - ]; - const source = Object.values(PreferencesTypeEnum).reduce( - (acc, type) => { - const preference = items.find((item) => item.type === type); - if (preference) { - acc[type] = preference.preferences as WorkflowPreferences; - } else { - acc[type] = null; - } - - return acc; - }, - {} as GetPreferencesResponseDto['source'], - ); - const preferences = preferencesEntities - .filter((preference) => preference !== undefined) - .map((item) => item.preferences); - - // ensure we don't merge on an empty list - if (preferences.length === 0) { - return { preferences: undefined, type: undefined, source }; - } - - const readOnlyFlag = workflowPreferences?.all?.readOnly; - - // Determine the most specific preference applied - let mostSpecificPreference: PreferencesTypeEnum | undefined; - if (subscriberWorkflowPreferences && !readOnlyFlag) { - mostSpecificPreference = PreferencesTypeEnum.SUBSCRIBER_WORKFLOW; - } else if (subscriberGlobalPreferences && !readOnlyFlag) { - mostSpecificPreference = PreferencesTypeEnum.SUBSCRIBER_GLOBAL; - } else if (workflowUserPreferences) { - mostSpecificPreference = PreferencesTypeEnum.USER_WORKFLOW; - } else if (workflowResourcePreferences) { - mostSpecificPreference = PreferencesTypeEnum.WORKFLOW_RESOURCE; - } - - // If workflowPreferences have readOnly flag set to true, disregard subscriber preferences - if (readOnlyFlag) { - return { - preferences: workflowPreferences, - type: mostSpecificPreference, - source, - }; - } - - /** - * Order is (almost exactly) reversed of that above because 'readOnly' should be prioritized - * by the Dashboard (userPreferences) the most. - */ - const orderedPreferencesForReadOnly = [ - subscriberWorkflowPreferences, - subscriberGlobalPreferences, - workflowResourcePreferences, - workflowUserPreferences, - ] - .filter((preference) => preference !== undefined) - .map((item) => item.preferences); - - const readOnlyPreferences = orderedPreferencesForReadOnly.map( - ({ all }) => ({ - all: { readOnly: all?.readOnly || false }, - }), - ) as WorkflowPreferences[]; - - const readOnlyPreference = deepMerge([...readOnlyPreferences]); - - if (Object.keys(subscriberPreferences).length === 0) { - return { - preferences: workflowPreferences, - type: mostSpecificPreference, - source, - }; - } - // if the workflow should be readonly, we return the resource preferences default value for workflow. - if (readOnlyPreference?.all?.readOnly) { - subscriberPreferences.all.enabled = workflowPreferences?.all?.enabled; - } - - // making sure we respond with correct readonly values. - const mergedPreferences = deepMerge([ - workflowPreferences, - subscriberPreferences, - readOnlyPreference, - ]) as WorkflowPreferences; - - return { - preferences: mergedPreferences, - type: mostSpecificPreference, - source, - }; - } - - private getSubscriberWorkflowPreferences( - items: PreferencesEntity[], - templateId: string, - ) { - return items.find( - (item) => - item.type === PreferencesTypeEnum.SUBSCRIBER_WORKFLOW && - item._templateId === templateId, - ); - } - - private getSubscriberGlobalPreferences( - items: PreferencesEntity[], - ): PreferencesEntity | undefined { - return items.find( - (item) => item.type === PreferencesTypeEnum.SUBSCRIBER_GLOBAL, - ); - } - - private getWorkflowUserPreferences( - items: PreferencesEntity[], - ): PreferencesEntity | undefined { - return items.find( - (item) => item.type === PreferencesTypeEnum.USER_WORKFLOW, - ); - } - - private getWorkflowResourcePreferences( - items: PreferencesEntity[], - ): PreferencesEntity | undefined { - return items.find( - (item) => item.type === PreferencesTypeEnum.WORKFLOW_RESOURCE, - ); - } - private async getPreferencesFromDb( command: GetPreferencesCommand, - ): Promise { - const items: PreferencesEntity[] = []; - - /* - * Fetch the Workflow Preferences. This includes: - * - Workflow Resource Preferences - the Code-defined Workflow Preferences - * - User Workflow Preferences - the Dashboard-defined Workflow Preferences - */ - if (command.templateId) { - const workflowPreferences = await this.preferencesRepository.find({ + ): Promise { + const [ + workflowResourcePreference, + workflowUserPreference, + subscriberWorkflowPreference, + subscriberGlobalPreference, + ] = await Promise.all([ + this.preferencesRepository.findOne({ _templateId: command.templateId, _environmentId: command.environmentId, - type: { - $in: [ - PreferencesTypeEnum.WORKFLOW_RESOURCE, - PreferencesTypeEnum.USER_WORKFLOW, - ], - }, - }); - - items.push(...workflowPreferences); - } - - // Fetch the Subscriber Global Preference. - if (command.subscriberId) { - const subscriberGlobalPreference = await this.preferencesRepository.find({ + type: PreferencesTypeEnum.WORKFLOW_RESOURCE, + }) as Promise, + this.preferencesRepository.findOne({ + _templateId: command.templateId, + _environmentId: command.environmentId, + type: PreferencesTypeEnum.USER_WORKFLOW, + }) as Promise, + this.preferencesRepository.findOne({ + _subscriberId: command.subscriberId, + _environmentId: command.environmentId, + _templateId: command.templateId, + type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + }) as Promise, + this.preferencesRepository.findOne({ _subscriberId: command.subscriberId, _environmentId: command.environmentId, type: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, - }); + }) as Promise, + ]); - items.push(...subscriberGlobalPreference); - } - - // Fetch the Subscriber Workflow Preference. - if (command.subscriberId && command.templateId) { - const subscriberWorkflowPreference = - await this.preferencesRepository.find({ - _subscriberId: command.subscriberId, - _templateId: command.templateId, - _environmentId: command.environmentId, - type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, - }); - - items.push(...subscriberWorkflowPreference); - } - - return items; + return { + ...(workflowResourcePreference ? { workflowResourcePreference } : {}), + ...(workflowUserPreference ? { workflowUserPreference } : {}), + ...(subscriberWorkflowPreference ? { subscriberWorkflowPreference } : {}), + ...(subscriberGlobalPreference ? { subscriberGlobalPreference } : {}), + }; } } 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 ff31121fc16..b863c583c35 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 @@ -13,7 +13,7 @@ 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'; -import { InstrumentUsecase } from '../../instrumentation'; +import { Instrument, InstrumentUsecase } from '../../instrumentation'; @Injectable() export class GetSubscriberGlobalPreference { @@ -54,6 +54,7 @@ export class GetSubscriberGlobalPreference { }; } + @Instrument() private async getSubscriberGlobalPreference( command: GetSubscriberGlobalPreferenceCommand, subscriberId: string, @@ -95,6 +96,7 @@ export class GetSubscriberGlobalPreference { }; } + @Instrument() private async getActiveChannels( command: GetSubscriberGlobalPreferenceCommand, ): Promise { 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 a738f7de11e..4ede47df64b 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 @@ -2,24 +2,36 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { NotificationTemplateRepository, SubscriberRepository, + PreferencesRepository, + PreferencesEntity, + NotificationTemplateEntity, } from '@novu/dal'; -import { ISubscriberPreferenceResponse } from '@novu/shared'; +import { + ChannelTypeEnum, + ISubscriberPreferenceResponse, + PreferencesTypeEnum, + StepTypeEnum, +} from '@novu/shared'; import { AnalyticsService } from '../../services/analytics.service'; import { GetSubscriberPreferenceCommand } from './get-subscriber-preference.command'; +import { InstrumentUsecase } from '../../instrumentation'; +import { MergePreferences } from '../merge-preferences/merge-preferences.usecase'; +import { GetPreferences, PreferenceSet } from '../get-preferences'; import { - GetSubscriberTemplatePreference, - GetSubscriberTemplatePreferenceCommand, + filteredPreference, + overridePreferences, } from '../get-subscriber-template-preference'; -import { InstrumentUsecase } from '../../instrumentation'; +import { MergePreferencesCommand } from '../merge-preferences/merge-preferences.command'; +import { mapTemplateConfiguration } from '../get-subscriber-template-preference/get-subscriber-template-preference.usecase'; @Injectable() export class GetSubscriberPreference { constructor( private subscriberRepository: SubscriberRepository, private notificationTemplateRepository: NotificationTemplateRepository, - private getSubscriberTemplatePreferenceUsecase: GetSubscriberTemplatePreference, private analyticsService: AnalyticsService, + private preferencesRepository: PreferencesRepository, ) {} @InstrumentUsecase() @@ -36,7 +48,7 @@ export class GetSubscriberPreference { ); } - const templateList = await this.notificationTemplateRepository.filterActive( + const workflowList = await this.notificationTemplateRepository.filterActive( { organizationId: command.organizationId, environmentId: command.environmentId, @@ -49,30 +61,166 @@ export class GetSubscriberPreference { '', { _organization: command.organizationId, - templatesSize: templateList.length, + templatesSize: workflowList.length, }, ); + const workflowIds = workflowList.map((wf) => wf._id); + + const [ + workflowResourcePreferences, + workflowUserPreferences, + subscriberWorkflowPreferences, + subscriberGlobalPreference, + ] = await Promise.all([ + this.preferencesRepository.find({ + _templateId: { $in: workflowIds }, + _environmentId: command.environmentId, + type: PreferencesTypeEnum.WORKFLOW_RESOURCE, + }) as Promise, + this.preferencesRepository.find({ + _templateId: { $in: workflowIds }, + _environmentId: command.environmentId, + type: PreferencesTypeEnum.USER_WORKFLOW, + }) as Promise, + this.preferencesRepository.find({ + _templateId: { $in: workflowIds }, + _subscriberId: subscriber._id, + _environmentId: command.environmentId, + type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + }), + this.preferencesRepository.findOne({ + _subscriberId: subscriber._id, + _environmentId: command.environmentId, + type: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + }), + ]); + + const allWorkflowPreferences = [ + ...workflowResourcePreferences, + ...workflowUserPreferences, + ...subscriberWorkflowPreferences, + ]; + + const workflowPreferenceSets = allWorkflowPreferences.reduce< + Record + >((acc, preference) => { + const workflowId = preference._templateId; + + // Skip if the preference is not for a workflow + if (workflowId === undefined) { + return acc; + } + + if (!acc[workflowId]) { + acc[workflowId] = { + workflowResourcePreference: undefined, + workflowUserPreference: undefined, + subscriberWorkflowPreference: undefined, + }; + } + switch (preference.type) { + case PreferencesTypeEnum.WORKFLOW_RESOURCE: + acc[workflowId].workflowResourcePreference = + preference as PreferenceSet['workflowResourcePreference']; + break; + case PreferencesTypeEnum.USER_WORKFLOW: + acc[workflowId].workflowUserPreference = + preference as PreferenceSet['workflowUserPreference']; + break; + case PreferencesTypeEnum.SUBSCRIBER_WORKFLOW: + acc[workflowId].subscriberWorkflowPreference = preference; + break; + default: + } + + return acc; + }, {}); + + const mergedPreferences: ISubscriberPreferenceResponse[] = workflowList.map( + (workflow) => { + const preferences = workflowPreferenceSets[workflow._id]; + const mergeCommand = MergePreferencesCommand.create({ + workflowResourcePreference: preferences.workflowResourcePreference, + workflowUserPreference: preferences.workflowUserPreference, + subscriberWorkflowPreference: + preferences.subscriberWorkflowPreference, + ...(subscriberGlobalPreference ? { subscriberGlobalPreference } : {}), + }); + const merged = MergePreferences.execute(mergeCommand); + + const includedChannels = this.getChannels( + workflow, + command.includeInactiveChannels, + ); - // TODO: replace this runtime mapping with a single query to the database - const subscriberWorkflowPreferences = await Promise.all( - templateList.map(async (template) => - this.getSubscriberTemplatePreferenceUsecase.execute( - GetSubscriberTemplatePreferenceCommand.create({ - organizationId: command.organizationId, - subscriberId: command.subscriberId, - environmentId: command.environmentId, - template, - subscriber, - includeInactiveChannels: command.includeInactiveChannels, + const initialChannels = filteredPreference( + { + email: true, + sms: true, + in_app: true, + chat: true, + push: true, + }, + includedChannels, + ); + + const { channels, overrides } = overridePreferences( + { + template: GetPreferences.mapWorkflowPreferencesToChannelPreferences( + merged.source.WORKFLOW_RESOURCE, + ), + subscriber: + GetPreferences.mapWorkflowPreferencesToChannelPreferences( + merged.preferences, + ), + workflowOverride: {}, + }, + initialChannels, + ); + + return { + preference: { + channels, + enabled: true, + overrides, + }, + template: mapTemplateConfiguration({ + ...workflow, + critical: merged.preferences.all.readOnly, }), - ), - ), + type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + }; + }, ); - const nonCriticalWorkflowPreferences = subscriberWorkflowPreferences.filter( - (preference) => preference.template.critical === false, + const nonCriticalWorkflowPreferences = mergedPreferences.filter( + (preference) => !preference.template.critical, ); return nonCriticalWorkflowPreferences; } + + private getChannels( + workflow: NotificationTemplateEntity, + includeInactiveChannels: boolean, + ): ChannelTypeEnum[] { + if (includeInactiveChannels) { + return Object.values(ChannelTypeEnum); + } + + const activeSteps = workflow.steps.filter((step) => step.active === true); + + const channels = activeSteps + .map((item) => item.template.type as StepTypeEnum) + .reduce((list, channel) => { + if (list.includes(channel)) { + return list; + } + list.push(channel); + + return list; + }, []); + + return channels as unknown as ChannelTypeEnum[]; + } } 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 4842663a370..f4f9da13cff 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 @@ -25,7 +25,7 @@ import { GetSubscriberTemplatePreferenceCommand } from './get-subscriber-templat import { ApiException } from '../../utils/exceptions'; import { buildSubscriberKey, CachedEntity } from '../../services/cache'; import { GetPreferences } from '../get-preferences'; -import { InstrumentUsecase } from '../../instrumentation'; +import { Instrument, InstrumentUsecase } from '../../instrumentation'; const PRIORITY_ORDER = [ PreferenceOverrideSourceEnum.TEMPLATE, @@ -89,6 +89,7 @@ export class GetSubscriberTemplatePreference { }; } + @Instrument() private async getSubscriberWorkflowPreference( command: GetSubscriberTemplatePreferenceCommand, subscriberId: string, @@ -148,6 +149,7 @@ export class GetSubscriberTemplatePreference { }; } + @Instrument() private async getWorkflowOverride( command: GetSubscriberTemplatePreferenceCommand, ) { @@ -172,6 +174,7 @@ export class GetSubscriberTemplatePreference { }); } + @Instrument() private async getChannels( command: GetSubscriberTemplatePreferenceCommand, ): Promise { @@ -196,6 +199,7 @@ export class GetSubscriberTemplatePreference { return initialChannels; } + @Instrument() private async queryActiveChannels( command: GetSubscriberTemplatePreferenceCommand, ): Promise { @@ -354,7 +358,7 @@ export const filteredPreference = ( {}, ); -function mapTemplateConfiguration( +export function mapTemplateConfiguration( template: NotificationTemplateEntity, ): ITemplateConfiguration { return { diff --git a/libs/application-generic/src/usecases/merge-preferences/merge-preferences.command.ts b/libs/application-generic/src/usecases/merge-preferences/merge-preferences.command.ts new file mode 100644 index 00000000000..742db0fc0f1 --- /dev/null +++ b/libs/application-generic/src/usecases/merge-preferences/merge-preferences.command.ts @@ -0,0 +1,9 @@ +import { BaseCommand } from '../../commands'; +import { PreferenceSet } from '../get-preferences/get-preferences.usecase'; + +export class MergePreferencesCommand extends BaseCommand { + workflowResourcePreference?: PreferenceSet['workflowResourcePreference']; + workflowUserPreference?: PreferenceSet['workflowUserPreference']; + subscriberGlobalPreference?: PreferenceSet['subscriberGlobalPreference']; + subscriberWorkflowPreference?: PreferenceSet['subscriberWorkflowPreference']; +} diff --git a/libs/application-generic/src/usecases/merge-preferences/merge-preferences.spec.ts b/libs/application-generic/src/usecases/merge-preferences/merge-preferences.spec.ts new file mode 100644 index 00000000000..8b6caa27f19 --- /dev/null +++ b/libs/application-generic/src/usecases/merge-preferences/merge-preferences.spec.ts @@ -0,0 +1,392 @@ +import { + DEFAULT_WORKFLOW_PREFERENCES, + PreferencesTypeEnum, + WorkflowPreferences, +} from '@novu/shared'; +import { describe, expect, it } from 'vitest'; +import { MergePreferencesCommand } from './merge-preferences.command'; +import { MergePreferences } from './merge-preferences.usecase'; +import { PreferenceSet } from '../get-preferences/get-preferences.usecase'; + +/** + * This test spec is used to test the merge preferences usecase. + * It covers all the possible combinations of preferences types and readOnly flag. + */ + +const MOCK_SUBSCRIBER_GLOBAL_PREFERENCE = { + ...DEFAULT_WORKFLOW_PREFERENCES, + channels: { + ...DEFAULT_WORKFLOW_PREFERENCES.channels, + email: { enabled: false }, + in_app: { enabled: true }, + }, +}; + +const MOCK_SUBSCRIBER_WORKFLOW_PREFERENCE = { + ...DEFAULT_WORKFLOW_PREFERENCES, + channels: { + ...DEFAULT_WORKFLOW_PREFERENCES.channels, + email: { enabled: true }, + in_app: { enabled: true }, + }, +}; + +type TestCase = { + comment: string; + types: PreferencesTypeEnum[]; + expectedType: PreferencesTypeEnum; + readOnly: boolean; +}; + +const testCases: TestCase[] = [ + // readOnly false scenarios + { + comment: 'Workflow resource only', + types: [PreferencesTypeEnum.WORKFLOW_RESOURCE], + expectedType: PreferencesTypeEnum.WORKFLOW_RESOURCE, + readOnly: false, + }, + { + comment: 'Subscriber global only', + types: [PreferencesTypeEnum.SUBSCRIBER_GLOBAL], + expectedType: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + readOnly: false, + }, + { + comment: 'Subscriber workflow overrides workflow resource', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + readOnly: false, + }, + { + comment: 'Subscriber global overrides workflow resource', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + ], + expectedType: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + readOnly: false, + }, + { + comment: 'Subscriber workflow overrides subscriber global', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + readOnly: false, + }, + { + comment: 'User workflow has priority over workflow resource', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.USER_WORKFLOW, + readOnly: false, + }, + { + comment: 'User workflow overrides workflow resource', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + readOnly: false, + }, + { + comment: 'Subscriber global overrides user workflow', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + ], + expectedType: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + readOnly: false, + }, + { + comment: 'Subscriber workflow overrides user workflow', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + readOnly: false, + }, + // readOnly true scenarios + { + comment: 'Workflow resource readOnly flag has priority over subscriber', + types: [PreferencesTypeEnum.WORKFLOW_RESOURCE], + expectedType: PreferencesTypeEnum.WORKFLOW_RESOURCE, + readOnly: true, + }, + { + comment: + 'Workflow resource readOnly flag has priority over subscriber workflow', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.WORKFLOW_RESOURCE, + readOnly: true, + }, + { + comment: + 'Workflow resource readOnly flag has priority over subscriber global', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + ], + expectedType: PreferencesTypeEnum.WORKFLOW_RESOURCE, + readOnly: true, + }, + { + comment: 'User workflow readOnly flag has priority over workflow resource', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.USER_WORKFLOW, + readOnly: true, + }, + // Subscriber overrides behavior with readOnly false + { + comment: 'Subscriber workflow overrides workflow resource', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + readOnly: false, + }, + { + comment: 'Subscriber global overrides workflow resource', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + ], + expectedType: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + readOnly: false, + }, + { + comment: 'Subscriber workflow overrides user workflow', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + readOnly: false, + }, + { + comment: 'Subscriber global overrides user workflow', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + ], + expectedType: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + readOnly: false, + }, + { + comment: 'Subscriber workflow overrides subscriber global', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + readOnly: false, + }, + // Subscriber overrides with readOnly true behavior + { + comment: + 'Subscriber workflow cannot override workflow resource when readOnly is true', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.WORKFLOW_RESOURCE, + readOnly: true, + }, + { + comment: + 'Subscriber global cannot override workflow resource when readOnly is true', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + ], + expectedType: PreferencesTypeEnum.WORKFLOW_RESOURCE, + readOnly: true, + }, + { + comment: + 'Subscriber workflow cannot override user workflow when readOnly is true', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.USER_WORKFLOW, + readOnly: true, + }, + { + comment: + 'Subscriber global cannot override user workflow when readOnly is true', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + ], + expectedType: PreferencesTypeEnum.USER_WORKFLOW, + readOnly: true, + }, + { + comment: + 'Subscriber global+workflow cannot override user workflow when readOnly is true', + types: [ + PreferencesTypeEnum.WORKFLOW_RESOURCE, + PreferencesTypeEnum.USER_WORKFLOW, + PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + ], + expectedType: PreferencesTypeEnum.USER_WORKFLOW, + readOnly: true, + }, +]; + +describe('MergePreferences', () => { + describe('merging readOnly and subscriberOverrides', () => { + testCases.forEach(({ types, expectedType, readOnly, comment = '' }) => { + it(`should merge preferences for types: ${types.join(', ')} with readOnly: ${readOnly}${comment ? ` (${comment})` : ''}`, () => { + const preferenceSet = types.reduce((acc, type, index) => { + const preference = { + _id: `${index + 1}`, + _organizationId: '1', + _environmentId: '1', + type, + preferences: { + // default + ...DEFAULT_WORKFLOW_PREFERENCES, + // readOnly + all: { ...DEFAULT_WORKFLOW_PREFERENCES.all, readOnly }, + // subscriber overrides + ...(PreferencesTypeEnum.SUBSCRIBER_GLOBAL === type + ? MOCK_SUBSCRIBER_GLOBAL_PREFERENCE + : {}), + ...(PreferencesTypeEnum.SUBSCRIBER_WORKFLOW === type + ? MOCK_SUBSCRIBER_WORKFLOW_PREFERENCE + : {}), + }, + }; + + switch (type) { + case PreferencesTypeEnum.WORKFLOW_RESOURCE: + acc.workflowResourcePreference = preference; + break; + case PreferencesTypeEnum.USER_WORKFLOW: + acc.workflowUserPreference = preference; + break; + case PreferencesTypeEnum.SUBSCRIBER_GLOBAL: + acc.subscriberGlobalPreference = preference; + break; + case PreferencesTypeEnum.SUBSCRIBER_WORKFLOW: + acc.subscriberWorkflowPreference = preference; + break; + default: + throw new Error(`Unknown preference type: ${type}`); + } + + return acc; + }, {} as PreferenceSet); + + const command = MergePreferencesCommand.create(preferenceSet); + + const result = MergePreferences.execute(command); + + const hasSubscriberGlobalPreference = + !!preferenceSet.subscriberGlobalPreference; + const hasSubscriberWorkflowPreference = + !!preferenceSet.subscriberWorkflowPreference; + + let expectedPreferences: WorkflowPreferences; + + if (!readOnly) { + if (hasSubscriberWorkflowPreference) { + expectedPreferences = MOCK_SUBSCRIBER_WORKFLOW_PREFERENCE; + } else if (hasSubscriberGlobalPreference) { + expectedPreferences = MOCK_SUBSCRIBER_GLOBAL_PREFERENCE; + } else { + expectedPreferences = DEFAULT_WORKFLOW_PREFERENCES; + } + } else { + expectedPreferences = { + ...DEFAULT_WORKFLOW_PREFERENCES, + all: { ...DEFAULT_WORKFLOW_PREFERENCES.all, readOnly }, + }; + } + + expect(result).toEqual({ + preferences: expectedPreferences, + type: expectedType, + source: { + [PreferencesTypeEnum.WORKFLOW_RESOURCE]: null, + [PreferencesTypeEnum.USER_WORKFLOW]: null, + [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: null, + [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null, + ...Object.entries(preferenceSet).reduce((acc, [key, pref]) => { + if (pref) { + acc[pref.type] = pref.preferences; + } + + return acc; + }, {}), + }, + }); + }); + }); + }); + + it('should have test cases for all combinations of PreferencesTypeEnum', () => { + // Function to generate all subsets of an array, ensuring requiredTypes are included + function generateSubsets( + arr: PreferencesTypeEnum[], + required: PreferencesTypeEnum[], + ) { + return arr + .reduce( + (subsets, value) => + subsets.concat(subsets.map((set) => [value, ...set])), + [[]] as PreferencesTypeEnum[][], + ) + .map((subset) => [...new Set([...required, ...subset])]); + } + + const allTypes = Object.values(PreferencesTypeEnum); + const requiredTypes = [PreferencesTypeEnum.WORKFLOW_RESOURCE]; + + const allCombinations = generateSubsets(allTypes, requiredTypes).filter( + (subset) => subset.length > 0, + ); + + const coveredCombinations = testCases.map((testCase) => + testCase.types.sort().join(','), + ); + + allCombinations.forEach((combination) => { + const combinationKey = combination.sort().join(','); + expect( + coveredCombinations, + `Combination ${combinationKey} is not covered`, + ).toContain(combinationKey); + }); + }); +}); diff --git a/libs/application-generic/src/usecases/merge-preferences/merge-preferences.usecase.ts b/libs/application-generic/src/usecases/merge-preferences/merge-preferences.usecase.ts new file mode 100644 index 00000000000..f03171fea4e --- /dev/null +++ b/libs/application-generic/src/usecases/merge-preferences/merge-preferences.usecase.ts @@ -0,0 +1,69 @@ +import { PreferencesTypeEnum } from '@novu/shared'; +import { PreferencesEntity } from '@novu/dal'; +import { deepMerge } from '../../utils'; +import { GetPreferencesResponseDto } from '../get-preferences'; +import { MergePreferencesCommand } from './merge-preferences.command'; +import { DeepRequired } from '../../http/utils.types'; + +/** + * Merge preferences for a subscriber. + * + * The order of precedence is: + * 1. Workflow resource preferences + * 2. Workflow user preferences + * 3. Subscriber global preferences + * 4. Subscriber workflow preferences + * + * If a workflow has the readOnly flag set to true, the subscriber preferences are ignored. + * + * If the workflow does not have the readOnly flag set to true, the subscriber preferences are merged with the workflow preferences. + * + * If the subscriber has no preferences, the workflow preferences are returned. + */ +export class MergePreferences { + public static execute( + command: MergePreferencesCommand, + ): GetPreferencesResponseDto { + const workflowPreferences = [ + command.workflowResourcePreference, + command.workflowUserPreference, + ].filter((preference) => preference !== undefined); + + const subscriberPreferences = [ + command.subscriberGlobalPreference, + command.subscriberWorkflowPreference, + ].filter((preference) => preference !== undefined); + + const isWorkflowPreferenceReadonly = workflowPreferences.some( + (preference) => preference.preferences.all?.readOnly, + ); + + const preferencesList = [ + ...workflowPreferences, + // If the workflow preference is readOnly, we disregard the subscriber preferences + ...(isWorkflowPreferenceReadonly ? [] : subscriberPreferences), + ]; + + const mergedPreferences = deepMerge( + preferencesList as DeepRequired[], + ); + + // Build the source object + const source = { + [PreferencesTypeEnum.WORKFLOW_RESOURCE]: + command.workflowResourcePreference?.preferences || null, + [PreferencesTypeEnum.USER_WORKFLOW]: + command.workflowUserPreference?.preferences || null, + [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: + command.subscriberGlobalPreference?.preferences || null, + [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: + command.subscriberWorkflowPreference?.preferences || null, + }; + + return { + preferences: mergedPreferences.preferences, + type: mergedPreferences.type, + source, + }; + } +}