diff --git a/apps/api/src/app/events/e2e/bridge-sync.e2e.ts b/apps/api/src/app/events/e2e/bridge-sync.e2e.ts index 46f48902c5f..bd81c734af5 100644 --- a/apps/api/src/app/events/e2e/bridge-sync.e2e.ts +++ b/apps/api/src/app/events/e2e/bridge-sync.e2e.ts @@ -324,14 +324,13 @@ describe('Bridge Sync - /bridge/sync (POST)', async () => { }, { preferences: { - workflow: { + all: { enabled: false, readOnly: true, }, channels: { inApp: { enabled: true, - readOnly: true, }, }, }, @@ -344,13 +343,13 @@ describe('Bridge Sync - /bridge/sync (POST)', async () => { }); const dashboardPreferences = { - workflow: { enabled: false, readOnly: true }, + all: { enabled: false, readOnly: true }, channels: { - email: { enabled: true, readOnly: false }, - sms: { enabled: true, readOnly: false }, - inApp: { enabled: false, readOnly: true }, - chat: { enabled: true, readOnly: false }, - push: { enabled: true, readOnly: false }, + email: { enabled: true }, + sms: { enabled: true }, + inApp: { enabled: false }, + chat: { enabled: true }, + push: { enabled: true }, }, }; diff --git a/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts b/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts index 72297d33e0c..a63d457bb5e 100644 --- a/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts +++ b/apps/api/src/app/events/e2e/bridge-trigger.e2e.ts @@ -852,7 +852,7 @@ contexts.forEach((context: Context) => { }, { preferences: { - workflow: { + all: { enabled: false, }, channels: { @@ -893,7 +893,7 @@ contexts.forEach((context: Context) => { }, { preferences: { - workflow: { + all: { enabled: false, }, }, 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 38c6e6d0dff..462fe08b33d 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 @@ -238,7 +238,7 @@ export class UpdatePreferences { templateId?: string; }) { const preferences: WorkflowPreferencesPartial = { - workflow: { + all: { enabled: PREFERENCE_DEFAULT_VALUE, readOnly: false, }, diff --git a/apps/api/src/app/preferences/dtos/preferences.dto.ts b/apps/api/src/app/preferences/dtos/preferences.dto.ts index afc9a657f87..a4771ab65fb 100644 --- a/apps/api/src/app/preferences/dtos/preferences.dto.ts +++ b/apps/api/src/app/preferences/dtos/preferences.dto.ts @@ -2,7 +2,7 @@ import { ChannelTypeEnum } from '@novu/shared'; import { Type } from 'class-transformer'; import { IsBoolean, ValidateNested } from 'class-validator'; -export class Preference { +export class WorkflowPreference { @IsBoolean() enabled: boolean; @@ -10,32 +10,37 @@ export class Preference { readOnly: boolean; } +export class ChannelPreference { + @IsBoolean() + enabled: boolean; +} + export class Channels { @ValidateNested({ each: true }) - @Type(() => Preference) - [ChannelTypeEnum.IN_APP]: Preference; + @Type(() => ChannelPreference) + [ChannelTypeEnum.IN_APP]: ChannelPreference; @ValidateNested({ each: true }) - @Type(() => Preference) - [ChannelTypeEnum.EMAIL]: Preference; + @Type(() => ChannelPreference) + [ChannelTypeEnum.EMAIL]: ChannelPreference; @ValidateNested({ each: true }) - @Type(() => Preference) - [ChannelTypeEnum.SMS]: Preference; + @Type(() => ChannelPreference) + [ChannelTypeEnum.SMS]: ChannelPreference; @ValidateNested({ each: true }) - @Type(() => Preference) - [ChannelTypeEnum.CHAT]: Preference; + @Type(() => ChannelPreference) + [ChannelTypeEnum.CHAT]: ChannelPreference; @ValidateNested({ each: true }) - @Type(() => Preference) - [ChannelTypeEnum.PUSH]: Preference; + @Type(() => ChannelPreference) + [ChannelTypeEnum.PUSH]: ChannelPreference; } export class PreferencesDto { @ValidateNested({ each: true }) - @Type(() => Preference) - workflow: Preference; + @Type(() => WorkflowPreference) + workflow: WorkflowPreference; @ValidateNested({ each: true }) @Type(() => Channels) diff --git a/apps/api/src/app/preferences/preferences.controller.ts b/apps/api/src/app/preferences/preferences.controller.ts index 77826bf044e..5f2cc322923 100644 --- a/apps/api/src/app/preferences/preferences.controller.ts +++ b/apps/api/src/app/preferences/preferences.controller.ts @@ -2,6 +2,7 @@ import { Body, ClassSerializerInterceptor, Controller, + Delete, Get, NotFoundException, Post, @@ -63,6 +64,22 @@ export class PreferencesController { ); } + @Delete('/') + @UseGuards(UserAuthGuard) + async delete(@UserSession() user: UserSessionData, @Query('workflowId') workflowId: string) { + await this.verifyPreferencesApiAvailability(user); + + return this.upsertPreferences.upsertUserWorkflowPreferences( + UpsertUserWorkflowPreferencesCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + userId: user._id, + templateId: workflowId, + preferences: null, + }) + ); + } + private async verifyPreferencesApiAvailability(user: UserSessionData) { const isEnabled = await this.getFeatureFlag.execute( GetFeatureFlagCommand.create({ diff --git a/apps/api/src/app/preferences/preferences.spec.ts b/apps/api/src/app/preferences/preferences.spec.ts index 632259f36d3..a7d90eda22d 100644 --- a/apps/api/src/app/preferences/preferences.spec.ts +++ b/apps/api/src/app/preferences/preferences.spec.ts @@ -7,8 +7,8 @@ import { UpsertUserWorkflowPreferencesCommand, UpsertWorkflowPreferencesCommand, } from '@novu/application-generic'; -import { PreferencesRepository, PreferencesTypeEnum, SubscriberRepository } from '@novu/dal'; -import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { PreferencesRepository, SubscriberRepository } from '@novu/dal'; +import { FeatureFlagsKeysEnum, PreferencesTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; @@ -42,30 +42,25 @@ describe('Preferences', function () { const workflowPreferences = await upsertPreferences.upsertWorkflowPreferences( UpsertWorkflowPreferencesCommand.create({ preferences: { - workflow: { + all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, @@ -87,30 +82,25 @@ describe('Preferences', function () { const userPreferences = await upsertPreferences.upsertUserWorkflowPreferences( UpsertUserWorkflowPreferencesCommand.create({ preferences: { - workflow: { + all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, @@ -133,30 +123,25 @@ describe('Preferences', function () { const subscriberGlobalPreferences = await upsertPreferences.upsertSubscriberGlobalPreferences( UpsertSubscriberGlobalPreferencesCommand.create({ preferences: { - workflow: { + all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, @@ -178,30 +163,25 @@ describe('Preferences', function () { const subscriberWorkflowPreferences = await upsertPreferences.upsertSubscriberWorkflowPreferences( UpsertSubscriberWorkflowPreferencesCommand.create({ preferences: { - workflow: { + all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, @@ -224,30 +204,25 @@ describe('Preferences', function () { let workflowPreferences = await upsertPreferences.upsertWorkflowPreferences( UpsertWorkflowPreferencesCommand.create({ preferences: { - workflow: { + all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, @@ -267,30 +242,25 @@ describe('Preferences', function () { workflowPreferences = await upsertPreferences.upsertWorkflowPreferences( UpsertWorkflowPreferencesCommand.create({ preferences: { - workflow: { + all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, @@ -300,7 +270,7 @@ describe('Preferences', function () { }) ); - expect(workflowPreferences.preferences.workflow.readOnly).to.be.true; + expect(workflowPreferences.preferences.all.readOnly).to.be.true; }); }); @@ -310,30 +280,25 @@ describe('Preferences', function () { await upsertPreferences.upsertWorkflowPreferences( UpsertWorkflowPreferencesCommand.create({ preferences: { - workflow: { + all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, @@ -350,31 +315,57 @@ describe('Preferences', function () { }); expect(preferences).to.deep.equal({ - workflow: { - enabled: false, - readOnly: false, - }, - channels: { - in_app: { - enabled: false, - readOnly: false, - }, - sms: { - enabled: false, - readOnly: false, - }, - email: { + preferences: { + all: { enabled: false, readOnly: false, }, - push: { - enabled: false, - readOnly: false, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, }, - chat: { - enabled: false, - readOnly: false, + }, + type: PreferencesTypeEnum.WORKFLOW_RESOURCE, + source: { + [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { + all: { + enabled: false, + readOnly: false, + }, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, + }, }, + [PreferencesTypeEnum.USER_WORKFLOW]: null, + [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: null, + [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null, }, }); @@ -382,30 +373,25 @@ describe('Preferences', function () { await upsertPreferences.upsertUserWorkflowPreferences( UpsertUserWorkflowPreferencesCommand.create({ preferences: { - workflow: { + all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, @@ -423,31 +409,79 @@ describe('Preferences', function () { }); expect(preferences).to.deep.equal({ - workflow: { - enabled: false, - readOnly: true, - }, - channels: { - in_app: { - enabled: false, - readOnly: false, - }, - sms: { + preferences: { + all: { enabled: false, - readOnly: false, + readOnly: true, }, - email: { - enabled: false, - readOnly: false, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, }, - push: { - enabled: false, - readOnly: false, + }, + type: PreferencesTypeEnum.USER_WORKFLOW, + source: { + [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { + all: { + enabled: false, + readOnly: false, + }, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, + }, }, - chat: { - enabled: false, - readOnly: false, + [PreferencesTypeEnum.USER_WORKFLOW]: { + all: { + enabled: false, + readOnly: true, + }, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, + }, }, + [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: null, + [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null, }, }); @@ -455,30 +489,25 @@ describe('Preferences', function () { await upsertPreferences.upsertSubscriberGlobalPreferences( UpsertSubscriberGlobalPreferencesCommand.create({ preferences: { - workflow: { + all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, @@ -496,31 +525,101 @@ describe('Preferences', function () { }); expect(preferences).to.deep.equal({ - workflow: { - enabled: false, - readOnly: true, - }, - channels: { - in_app: { + preferences: { + all: { enabled: false, - readOnly: false, + readOnly: true, }, - sms: { - enabled: false, - readOnly: false, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, }, - email: { - enabled: false, - readOnly: false, + }, + type: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, + source: { + [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { + all: { + enabled: false, + readOnly: false, + }, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, + }, }, - push: { - enabled: false, - readOnly: false, + [PreferencesTypeEnum.USER_WORKFLOW]: { + all: { + enabled: false, + readOnly: true, + }, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, + }, }, - chat: { - enabled: false, - readOnly: false, + [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: { + all: { + enabled: false, + readOnly: true, + }, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, + }, }, + [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null, }, }); @@ -528,30 +627,25 @@ describe('Preferences', function () { await upsertPreferences.upsertSubscriberWorkflowPreferences( UpsertSubscriberWorkflowPreferencesCommand.create({ preferences: { - workflow: { + all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, - readOnly: true, }, sms: { enabled: false, - readOnly: true, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, @@ -570,30 +664,122 @@ describe('Preferences', function () { }); expect(preferences).to.deep.equal({ - workflow: { - enabled: false, - readOnly: true, - }, - channels: { - in_app: { + preferences: { + all: { enabled: false, - readOnly: false, + readOnly: true, }, - sms: { - enabled: false, - readOnly: false, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, }, - email: { - enabled: false, - readOnly: false, + }, + type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, + source: { + [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { + all: { + enabled: false, + readOnly: false, + }, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, + }, }, - push: { - enabled: false, - readOnly: false, + [PreferencesTypeEnum.USER_WORKFLOW]: { + all: { + enabled: false, + readOnly: true, + }, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, + }, }, - chat: { - enabled: false, - readOnly: false, + [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: { + all: { + enabled: false, + readOnly: true, + }, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, + }, + }, + [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: { + all: { + enabled: false, + readOnly: true, + }, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, + }, }, }, }); @@ -607,30 +793,25 @@ describe('Preferences', function () { await useCase.upsertWorkflowPreferences( UpsertWorkflowPreferencesCommand.create({ preferences: { - workflow: { + all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, @@ -643,31 +824,57 @@ describe('Preferences', function () { const { body } = await session.testAgent.get(`/v1/preferences?workflowId=${workflowId}`).send(); expect(body.data).to.deep.equal({ - workflow: { - enabled: false, - readOnly: false, - }, - channels: { - in_app: { - enabled: false, - readOnly: false, - }, - sms: { - enabled: false, - readOnly: false, - }, - email: { + preferences: { + all: { enabled: false, readOnly: false, }, - push: { - enabled: false, - readOnly: false, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, }, - chat: { - enabled: false, - readOnly: false, + }, + type: PreferencesTypeEnum.WORKFLOW_RESOURCE, + source: { + [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { + all: { + enabled: false, + readOnly: false, + }, + channels: { + in_app: { + enabled: false, + }, + sms: { + enabled: false, + }, + email: { + enabled: false, + }, + push: { + enabled: false, + }, + chat: { + enabled: false, + }, + }, }, + [PreferencesTypeEnum.USER_WORKFLOW]: null, + [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: null, + [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null, }, }); }); @@ -676,60 +883,50 @@ describe('Preferences', function () { const { body } = await session.testAgent.post('/v1/preferences').send({ workflowId, preferences: { - workflow: { + all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }, }); expect(body.data.preferences).to.deep.equal({ - workflow: { + all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, - readOnly: false, }, sms: { enabled: false, - readOnly: false, }, email: { enabled: false, - readOnly: false, }, push: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: false, }, }, }); diff --git a/apps/web/src/api/api.client.ts b/apps/web/src/api/api.client.ts index 460a6b62d2b..472a4109fe7 100644 --- a/apps/web/src/api/api.client.ts +++ b/apps/web/src/api/api.client.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { CustomDataType } from '@novu/shared'; +import { CustomDataType, WorkflowPreferences } from '@novu/shared'; import { API_ROOT } from '../config'; import { getToken } from '../components/providers/AuthProvider'; import { getEnvironmentId } from '../components/providers/EnvironmentProvider'; @@ -142,6 +142,18 @@ export function buildApiHttpClient({ } }; + const del = async (url, data = {}) => { + // eslint-disable-next-line no-useless-catch + try { + const response = await httpClient.delete(url, data); + + return response.data; + } catch (error) { + // TODO: Handle error?.response?.data || error?.response || error; + throw error; + } + }; + return { async getNotifications(params?: { page?: number; transactionId?: string }) { return get(`/v1/notifications`, params); @@ -165,10 +177,14 @@ export function buildApiHttpClient({ return get(`/v1/preferences?workflowId=${workflowId}`); }, - async upsertPreferences(workflowId: string, preferences: any) { + async upsertPreferences(workflowId: string, preferences: WorkflowPreferences) { return post('/v1/preferences', { workflowId, preferences }); }, + async deletePreferences(workflowId: string) { + return del(`/v1/preferences?workflowId=${workflowId}`); + }, + async postTelemetry(event: string, data?: Record) { return post('/v1/telemetry/measure', { event, diff --git a/apps/web/src/hooks/workflowPreferences/useCloudWorkflowPreferences.ts b/apps/web/src/hooks/workflowPreferences/useCloudWorkflowPreferences.ts index d6ca650dac1..bc3780a8010 100644 --- a/apps/web/src/hooks/workflowPreferences/useCloudWorkflowPreferences.ts +++ b/apps/web/src/hooks/workflowPreferences/useCloudWorkflowPreferences.ts @@ -1,5 +1,6 @@ +import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { buildWorkflowPreferences, WorkflowPreferences } from '@novu/shared'; +import { buildWorkflowPreferences, PreferencesTypeEnum, WorkflowPreferences } from '@novu/shared'; import { AxiosError, HttpStatusCode } from 'axios'; import { QueryKeys } from '../../api/query.keys'; import { useNovuAPI } from '../useNovuAPI'; @@ -8,33 +9,49 @@ export const useCloudWorkflowPreferences = ( workflowId: string ): { isLoading: boolean; - workflowChannelPreferences: WorkflowPreferences | undefined; + workflowUserPreferences: WorkflowPreferences | null; + workflowResourcePreferences: WorkflowPreferences | null; refetch: () => void; } => { const api = useNovuAPI(); + const [workflowUserPreferences, setWorkflowUserPreferences] = useState(null); + const [workflowResourcePreferences, setWorkflowResourcePreferences] = useState(null); - const { - data: workflowChannelPreferences, - isLoading, - refetch, - } = useQuery([QueryKeys.getWorkflowPreferences(workflowId)], async () => { + const { data, isLoading, refetch } = useQuery<{ + workflowUserPreferences: WorkflowPreferences; + workflowResourcePreferences: WorkflowPreferences; + }>([QueryKeys.getWorkflowPreferences(workflowId)], async () => { try { const result = await api.getPreferences(workflowId as string); - return result?.data; + return { + workflowUserPreferences: result?.data?.source[PreferencesTypeEnum.USER_WORKFLOW], + workflowResourcePreferences: result?.data?.source[PreferencesTypeEnum.WORKFLOW_RESOURCE], + }; } catch (err: unknown) { if (!checkIsAxiosError(err) || err.response?.status !== HttpStatusCode.NotFound) { throw err; } // if preferences aren't found (404), use default so that user can modify them to upsert properly. - return buildWorkflowPreferences(undefined); + return { + workflowUserPreferences: buildWorkflowPreferences(undefined), + workflowResourcePreferences: buildWorkflowPreferences(undefined), + }; } }); + useEffect(() => { + if (data) { + setWorkflowUserPreferences(data.workflowUserPreferences); + setWorkflowResourcePreferences(data.workflowResourcePreferences); + } + }, [data]); + return { isLoading, - workflowChannelPreferences, + workflowUserPreferences, + workflowResourcePreferences, refetch, }; }; diff --git a/apps/web/src/hooks/workflowPreferences/useUpdateWorkflowPreferences.ts b/apps/web/src/hooks/workflowPreferences/useUpdateWorkflowPreferences.ts index 1c868608282..2aac54018d2 100644 --- a/apps/web/src/hooks/workflowPreferences/useUpdateWorkflowPreferences.ts +++ b/apps/web/src/hooks/workflowPreferences/useUpdateWorkflowPreferences.ts @@ -4,20 +4,30 @@ import { useNovuAPI } from '../useNovuAPI'; export const useUpdateWorkflowPreferences = ( workflowId: string, - options: Omit, 'mutationFn'> + options: Omit< + UseMutationOptions, + 'mutationFn' + > ): { isLoading: boolean; - updateWorkflowPreferences: (data: WorkflowPreferences) => void; + updateWorkflowPreferences: (data: WorkflowPreferences | null) => Promise; } => { const api = useNovuAPI(); const { mutateAsync: updateWorkflowPreferences, isLoading } = useMutation< - WorkflowPreferences, + WorkflowPreferences | null, IResponseError, - WorkflowPreferences - >((data) => api.upsertPreferences(workflowId, data), { - ...options, - }); + WorkflowPreferences | null + >( + (data) => { + if (data === null) { + return api.deletePreferences(workflowId); + } else { + return api.upsertPreferences(workflowId, data); + } + }, + { ...options } + ); return { isLoading, diff --git a/apps/web/src/pages/templates/editor_v2/CloudWorkflowSettingsSidePanel.tsx b/apps/web/src/pages/templates/editor_v2/CloudWorkflowSettingsSidePanel.tsx index 92b70d14165..e32d2ad508a 100644 --- a/apps/web/src/pages/templates/editor_v2/CloudWorkflowSettingsSidePanel.tsx +++ b/apps/web/src/pages/templates/editor_v2/CloudWorkflowSettingsSidePanel.tsx @@ -15,14 +15,14 @@ type CloudWorkflowSettingsSidePanelProps = { onClose: () => void }; export const CloudWorkflowSettingsSidePanel: FC = ({ onClose }) => { const { templateId: workflowId = '' } = useParams<{ templateId: string }>(); const [searchParams] = useSearchParams(); - const { isLoading, workflowChannelPreferences } = useCloudWorkflowPreferences(workflowId); + const { isLoading, workflowUserPreferences, workflowResourcePreferences } = useCloudWorkflowPreferences(workflowId); const { setValue } = useFormContext(); useEffect(() => { - if (workflowChannelPreferences) { - setValue('preferences', workflowChannelPreferences); + if (workflowUserPreferences !== undefined) { + setValue('preferences', workflowUserPreferences, { shouldDirty: false }); } - }, [workflowChannelPreferences]); + }, [setValue, workflowUserPreferences]); return ( Workflow settings} isOpened onClose={onClose}> @@ -30,6 +30,7 @@ export const CloudWorkflowSettingsSidePanel: FC diff --git a/apps/web/src/pages/templates/editor_v2/TemplateDetailsPageV2.tsx b/apps/web/src/pages/templates/editor_v2/TemplateDetailsPageV2.tsx index 260157c28bf..b11f8293f22 100644 --- a/apps/web/src/pages/templates/editor_v2/TemplateDetailsPageV2.tsx +++ b/apps/web/src/pages/templates/editor_v2/TemplateDetailsPageV2.tsx @@ -3,8 +3,8 @@ import { css } from '@novu/novui/css'; import { IconCable, IconPlayArrow, IconSave, IconSettings } from '@novu/novui/icons'; import { HStack } from '@novu/novui/jsx'; import { FeatureFlagsKeysEnum } from '@novu/shared'; -import { useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useCallback, useEffect, useState } from 'react'; +import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { ROUTES } from '../../../constants/routes'; import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; import { useTelemetry } from '../../../hooks/useNovuAPI'; @@ -17,10 +17,14 @@ import { parseUrl } from '../../../utils/routeUtils'; import { useTemplateController } from '../components/useTemplateController'; import { CloudWorkflowSettingsSidePanel } from './CloudWorkflowSettingsSidePanel'; import { useWorkflowDetailPageForm } from './useWorkflowDetailPageForm'; +import { WorkflowSettingsPanelTab } from '../../../studio/components/workflows/preferences'; export const TemplateDetailsPageV2 = () => { const { templateId = '' } = useParams<{ templateId: string }>(); const track = useTelemetry(); + const [searchParams] = useSearchParams(); + const location = useLocation(); + const navigate = useNavigate(); const { template: workflow } = useTemplateController(templateId); const areWorkflowPreferencesEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_WORKFLOW_PREFERENCES_ENABLED); @@ -29,10 +33,28 @@ export const TemplateDetailsPageV2 = () => { workflow, }); - const [isPanelOpen, setPanelOpen] = useState(false); + const [isPanelOpen, setPanelOpen] = useState(searchParams.has('settings')); + + const togglePanel = useCallback(() => { + setPanelOpen((prev) => { + const newSearchParams = new URLSearchParams(searchParams); + + if (prev) { + newSearchParams.delete('settings'); + } else { + newSearchParams.set('settings', WorkflowSettingsPanelTab.GENERAL); + } + + navigate({ + pathname: location.pathname, + search: newSearchParams.toString(), + }); + + return !prev; + }); + }, [location.pathname, navigate, searchParams]); const title = workflowName || workflow?.name || ''; - const navigate = useNavigate(); const workflowBackgroundWrapperClass = css({ mx: '0', @@ -66,7 +88,7 @@ export const TemplateDetailsPageV2 = () => { Test workflow - {areWorkflowPreferencesEnabled && setPanelOpen(true)} />} + {areWorkflowPreferencesEnabled && } } > @@ -106,7 +128,7 @@ export const TemplateDetailsPageV2 = () => { right: '50', })} /> - {isPanelOpen && setPanelOpen(false)} />} + {isPanelOpen && } ); diff --git a/apps/web/src/studio/components/workflows/preferences/StudioWorkflowSettingsSidePanel.tsx b/apps/web/src/studio/components/workflows/preferences/StudioWorkflowSettingsSidePanel.tsx index c16430184ac..3e3903485a1 100644 --- a/apps/web/src/studio/components/workflows/preferences/StudioWorkflowSettingsSidePanel.tsx +++ b/apps/web/src/studio/components/workflows/preferences/StudioWorkflowSettingsSidePanel.tsx @@ -29,12 +29,12 @@ export const StudioWorkflowSettingsSidePanel: FCWorkflow settings} isOpened onClose={onClose}>
- +
); diff --git a/apps/web/src/studio/components/workflows/preferences/WorkflowDetailFormContextProvider.tsx b/apps/web/src/studio/components/workflows/preferences/WorkflowDetailFormContextProvider.tsx index fdfef2d5264..b64d0372eb9 100644 --- a/apps/web/src/studio/components/workflows/preferences/WorkflowDetailFormContextProvider.tsx +++ b/apps/web/src/studio/components/workflows/preferences/WorkflowDetailFormContextProvider.tsx @@ -7,15 +7,7 @@ interface IWorkflowDetailFormContextProviderProps {} export type WorkflowDetailFormContext = { general: WorkflowGeneralSettings; - preferences: WorkflowPreferences; -}; - -const DEFAULT_FORM_VALUES: WorkflowDetailFormContext = { - general: { - workflowId: '', - name: '', - }, - preferences: buildWorkflowPreferences(undefined), + preferences: WorkflowPreferences | null; }; export const WorkflowDetailFormContextProvider: FC> = ({ @@ -23,7 +15,6 @@ export const WorkflowDetailFormContextProvider: FC { const formValues = useForm({ mode: 'onChange', - defaultValues: DEFAULT_FORM_VALUES, }); return {children}; diff --git a/apps/web/src/studio/components/workflows/preferences/WorkflowSettingsSidePanelContent.tsx b/apps/web/src/studio/components/workflows/preferences/WorkflowSettingsSidePanelContent.tsx index 6b7e51a88ad..e6aa76ef2c5 100644 --- a/apps/web/src/studio/components/workflows/preferences/WorkflowSettingsSidePanelContent.tsx +++ b/apps/web/src/studio/components/workflows/preferences/WorkflowSettingsSidePanelContent.tsx @@ -6,13 +6,14 @@ import { IconDynamicFeed, IconManageAccounts } from '@novu/novui/icons'; import { Grid, Stack } from '@novu/novui/jsx'; import { token } from '@novu/novui/tokens'; import { Controller, useFormContext } from 'react-hook-form'; -import { isBridgeWorkflow, WorkflowTypeEnum } from '@novu/shared'; +import { isBridgeWorkflow, WorkflowPreferences, WorkflowTypeEnum } from '@novu/shared'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useStudioState } from '../../../StudioStateProvider'; import { WorkflowDetailFormContext } from './WorkflowDetailFormContextProvider'; import { WorkflowGeneralSettingsFieldName, WorkflowGeneralSettingsForm } from './WorkflowGeneralSettingsForm'; import { WorkflowSubscriptionPreferences } from './WorkflowSubscriptionPreferences'; -enum WorkflowSettingsPanelTab { +export enum WorkflowSettingsPanelTab { GENERAL = 'general', PREFERENCES = 'preferences', } @@ -20,14 +21,20 @@ enum WorkflowSettingsPanelTab { type WorkflowSettingsSidePanelContentProps = { isLoading?: boolean; workflowType?: WorkflowTypeEnum; + workflowResourcePreferences: WorkflowPreferences | null; }; export const WorkflowSettingsSidePanelContent: FC = ({ isLoading, workflowType, + workflowResourcePreferences, }) => { const { isLocalStudio } = useStudioState() || {}; const { control } = useFormContext(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const [searchParams] = useSearchParams(); + const settingsTab = searchParams.get('settings') || WorkflowSettingsPanelTab.GENERAL; const checkShouldHideField = (fieldName: WorkflowGeneralSettingsFieldName) => { switch (fieldName) { @@ -52,8 +59,15 @@ export const WorkflowSettingsSidePanelContent: FC { + searchParams.set('settings', tab); + navigate({ + pathname, + search: searchParams.toString(), + }); + }} tabConfigs={[ { value: WorkflowSettingsPanelTab.GENERAL, @@ -86,9 +100,10 @@ export const WorkflowSettingsSidePanelContent: FC { return ( ); }} diff --git a/apps/web/src/studio/components/workflows/preferences/WorkflowSubscriptionPreferences.const.ts b/apps/web/src/studio/components/workflows/preferences/WorkflowSubscriptionPreferences.const.ts index 81085834e39..431f13000d8 100644 --- a/apps/web/src/studio/components/workflows/preferences/WorkflowSubscriptionPreferences.const.ts +++ b/apps/web/src/studio/components/workflows/preferences/WorkflowSubscriptionPreferences.const.ts @@ -12,7 +12,7 @@ import { CHANNEL_TYPE_TO_STRING } from '../../../../utils/channels'; import { PreferenceChannelName } from './types'; export const CHANNEL_SETTINGS_LOGO_LOOKUP: Record = { - workflow: IconDynamicFeed, + all: IconDynamicFeed, [ChannelTypeEnum.IN_APP]: IconNotificationsNone, [ChannelTypeEnum.EMAIL]: IconOutlineMailOutline, [ChannelTypeEnum.SMS]: IconOutlineSms, @@ -22,5 +22,5 @@ export const CHANNEL_SETTINGS_LOGO_LOOKUP: Record = { ...CHANNEL_TYPE_TO_STRING, - workflow: 'Workflow', + all: 'All', }; diff --git a/apps/web/src/studio/components/workflows/preferences/WorkflowSubscriptionPreferences.tsx b/apps/web/src/studio/components/workflows/preferences/WorkflowSubscriptionPreferences.tsx index d1ee94d3c5d..42214a9820c 100644 --- a/apps/web/src/studio/components/workflows/preferences/WorkflowSubscriptionPreferences.tsx +++ b/apps/web/src/studio/components/workflows/preferences/WorkflowSubscriptionPreferences.tsx @@ -1,77 +1,180 @@ import { Switch } from '@mantine/core'; import { Table, Text } from '@novu/novui'; import { css } from '@novu/novui/css'; -import { HStack } from '@novu/novui/jsx'; +import { HStack, VStack } from '@novu/novui/jsx'; import { ColorToken } from '@novu/novui/tokens'; import { ChannelTypeEnum, WorkflowPreferences } from '@novu/shared'; -import { FC, useCallback, useMemo } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { IconInfoOutline, Tooltip, When } from '@novu/design-system'; import { PreferenceChannelName, SubscriptionPreferenceRow } from './types'; import { CHANNEL_LABELS_LOOKUP, CHANNEL_SETTINGS_LOGO_LOOKUP } from './WorkflowSubscriptionPreferences.const'; import { tableClassName } from './WorkflowSubscriptionPreferences.styles'; +const switchClassNames = { + root: css({ + '&:has(:disabled)': { opacity: 'disabled' }, + '& input:not(:checked) + label': { + bg: { _dark: 'legacy.B40 !important', base: 'legacy.B80 !important' }, + }, + '& input:checked + label': { + // eslint-disable-next-line @pandacss/no-hardcoded-color + bg: 'colorPalette.middle !important', + }, + }), + thumb: css({ + // eslint-disable-next-line @pandacss/no-hardcoded-color + bg: 'legacy.white !important', + border: 'none !important', + }), +}; + // these match react-table's specifications, but we don't have the types as a direct dependency in web. const PREFERENCES_COLUMNS = [ { accessorKey: 'channel', header: 'Channels', cell: ChannelCell }, { accessorKey: 'enabled', header: 'Enabled', cell: SwitchCell }, - { - accessorKey: 'readOnly', - accessorFn: (row: SubscriptionPreferenceRow) => !row.readOnly, - header: 'Editable', - cell: SwitchCell, - }, ]; export type WorkflowSubscriptionPreferencesProps = { - preferences: WorkflowPreferences; - updateWorkflowPreferences: (prefs: WorkflowPreferences) => void; + workflowUserPreferences: WorkflowPreferences | null; + workflowResourcePreferences: WorkflowPreferences | null; + updateWorkflowPreferences: (prefs: WorkflowPreferences | null) => void; arePreferencesDisabled?: boolean; }; export const WorkflowSubscriptionPreferences: FC = ({ - preferences, + workflowUserPreferences, + workflowResourcePreferences, updateWorkflowPreferences, arePreferencesDisabled, }) => { + const [isOverridingPreferences, setIsOverridingPreferences] = useState(false); + // Use the user preferences if they exist, otherwise fall back to the resource preferences + const [preferences, setPreferences] = useState( + workflowUserPreferences || workflowResourcePreferences! + ); + const isDisabled = arePreferencesDisabled || !isOverridingPreferences; + + useEffect(() => { + setPreferences(workflowUserPreferences || workflowResourcePreferences!); + }, [workflowUserPreferences, workflowResourcePreferences]); + const onChange = useCallback( (channel: PreferenceChannelName, key: string, value: boolean) => { - const updatedPreferences: WorkflowPreferences = - channel === 'workflow' - ? { - ...preferences, - workflow: { ...preferences.workflow, [key]: value }, - } - : { - ...preferences, - channels: { - ...preferences.channels, - [channel]: { - ...preferences.channels[channel], - [key]: value, - }, - }, - }; + let updatedPreferences: WorkflowPreferences; + + if (channel === 'all') { + const updatedChannels = Object.keys(preferences.channels).reduce( + (acc, currChannel) => { + acc[currChannel] = { ...preferences.channels[currChannel], [key]: value }; + + return acc; + }, + {} as WorkflowPreferences['channels'] + ); + + updatedPreferences = { + ...preferences, + all: { ...preferences.all, [key]: value }, + channels: updatedChannels, + }; + } else { + const updatedChannels = { + ...preferences.channels, + [channel]: { + ...preferences.channels[channel], + [key]: value, + }, + }; + + const allChannelsFalse = Object.values(updatedChannels).every((channelPreferences) => !channelPreferences[key]); + + updatedPreferences = { + ...preferences, + all: { ...preferences.all, [key]: !allChannelsFalse }, + channels: updatedChannels, + }; + } updateWorkflowPreferences(updatedPreferences); }, [preferences, updateWorkflowPreferences] ); + useEffect(() => { + setIsOverridingPreferences(workflowUserPreferences !== null); + }, [workflowUserPreferences]); + + useEffect(() => { + // Don't dirty the form if the user didn't make any changes + if (workflowUserPreferences !== null && isOverridingPreferences === false) { + updateWorkflowPreferences(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOverridingPreferences, updateWorkflowPreferences]); + const preferenceRows = useMemo( - () => mapPreferencesToRows(preferences, onChange, arePreferencesDisabled), - [preferences, onChange, arePreferencesDisabled] + () => mapPreferencesToRows(preferences, onChange, isDisabled), + [preferences, onChange, isDisabled] ); return ( - className={tableClassName} columns={PREFERENCES_COLUMNS} data={preferenceRows} /> + + + + + Override Preferences + + + + + + + setIsOverridingPreferences(e.target.checked)} + /> + + + + + + Critical + + + + + + + + onChange('all', 'readOnly', e.target.checked)} + disabled={isDisabled} + checked={preferences?.all?.readOnly} + /> + + + className={tableClassName} + columns={PREFERENCES_COLUMNS} + data={preferenceRows} + /> + ); }; function ChannelCell(props) { const Icon = CHANNEL_SETTINGS_LOGO_LOOKUP[props.getValue()]; - const colorToken: ColorToken = props.row.original.enabled ? 'typography.text.main' : 'typography.text.secondary'; + const colorToken = props.row.original.enabled ? 'typography.text.main' : 'typography.text.secondary'; return ( + // eslint-disable-next-line @pandacss/no-dynamic-styling, @pandacss/no-property-renaming {} {CHANNEL_LABELS_LOOKUP[props.getValue()]} @@ -83,25 +186,10 @@ function SwitchCell(props) { return ( { - // readOnly is already negated - const updatedVal = props.column.id === 'readOnly' ? !e.target.checked : e.target.checked; + const updatedVal = e.target.checked; props.row.original.onChange(props.row.original.channel, props.column.id, updatedVal); }} size="lg" @@ -120,7 +208,7 @@ function mapPreferencesToRows( } return [ - { ...workflowChannelPreferences.workflow, channel: 'workflow', onChange, disabled: areAllDisabled }, + { ...workflowChannelPreferences.all, channel: 'all', onChange, disabled: areAllDisabled }, ...Object.entries(workflowChannelPreferences.channels) .map(([channel, pref]) => ({ ...pref, diff --git a/apps/web/src/studio/components/workflows/preferences/types.ts b/apps/web/src/studio/components/workflows/preferences/types.ts index 20877a3421c..73fabf44be6 100644 --- a/apps/web/src/studio/components/workflows/preferences/types.ts +++ b/apps/web/src/studio/components/workflows/preferences/types.ts @@ -1,6 +1,6 @@ -import { ChannelPreference, ChannelTypeEnum } from '@novu/shared'; +import { ChannelTypeEnum, ChannelPreference } from '@novu/shared'; -export type PreferenceChannelName = `${ChannelTypeEnum}` | 'workflow'; +export type PreferenceChannelName = `${ChannelTypeEnum}` | 'all'; export type SubscriptionPreferenceRow = { channel: PreferenceChannelName; onChange: (channel: PreferenceChannelName, key: string, value: boolean) => void; 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 new file mode 100644 index 00000000000..3251a15b88c --- /dev/null +++ b/libs/application-generic/src/usecases/get-preferences/get-preferences.dto.ts @@ -0,0 +1,7 @@ +import { PreferencesTypeEnum, WorkflowPreferences } from '@novu/shared'; + +export class GetPreferencesResponseDto { + preferences: WorkflowPreferences; + type: PreferencesTypeEnum; + source: Record; +} 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 42dc16e4ff3..41c239c5b16 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 @@ -1,18 +1,16 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { PreferencesEntity, PreferencesRepository } from '@novu/dal'; import { - PreferencesEntity, - PreferencesRepository, - PreferencesTypeEnum, -} from '@novu/dal'; -import { - ChannelTypeEnum, FeatureFlagsKeysEnum, IPreferenceChannels, WorkflowPreferences, + PreferencesTypeEnum, + buildWorkflowPreferences, } from '@novu/shared'; import { deepMerge } from '../../utils'; import { GetFeatureFlag, GetFeatureFlagCommand } from '../get-feature-flag'; import { GetPreferencesCommand } from './get-preferences.command'; +import { GetPreferencesResponseDto } from './get-preferences.dto'; @Injectable() export class GetPreferences { @@ -21,7 +19,9 @@ export class GetPreferences { private getFeatureFlag: GetFeatureFlag, ) {} - async execute(command: GetPreferencesCommand): Promise { + async execute( + command: GetPreferencesCommand, + ): Promise { const isEnabled = await this.getFeatureFlag.execute( GetFeatureFlagCommand.create({ userId: 'system', @@ -43,7 +43,7 @@ export class GetPreferences { const mergedPreferences = this.mergePreferences(items, command.templateId); - if (!mergedPreferences) { + if (!mergedPreferences.preferences) { throw new NotFoundException('We could not find any preferences'); } @@ -74,7 +74,7 @@ export class GetPreferences { templateId?: string; }): Promise { try { - return await this.execute( + const result = await this.execute( GetPreferencesCommand.create({ environmentId: command.environmentId, organizationId: command.organizationId, @@ -82,6 +82,8 @@ export class GetPreferences { templateId: command.templateId, }), ); + + return result.preferences; } catch (e) { // If we cant find preferences lets return undefined instead of throwing it up to caller to make it easier for caller to handle. if ((e as Error).name === NotFoundException.name) { @@ -95,50 +97,23 @@ export class GetPreferences { public static mapWorkflowPreferencesToChannelPreferences( workflowPreferences: WorkflowPreferences, ): IPreferenceChannels { - return { - in_app: - workflowPreferences.channels.in_app.enabled !== undefined - ? workflowPreferences.channels.in_app.enabled - : workflowPreferences.workflow.enabled, - sms: - workflowPreferences.channels.sms.enabled !== undefined - ? workflowPreferences.channels.sms.enabled - : workflowPreferences.workflow.enabled, - email: - workflowPreferences.channels.email.enabled !== undefined - ? workflowPreferences.channels.email.enabled - : workflowPreferences.workflow.enabled, - push: - workflowPreferences.channels.push.enabled !== undefined - ? workflowPreferences.channels.push.enabled - : workflowPreferences.workflow.enabled, - chat: - workflowPreferences.channels.chat.enabled !== undefined - ? workflowPreferences.channels.chat.enabled - : workflowPreferences.workflow.enabled, - }; - } + const builtPreferences = buildWorkflowPreferences(workflowPreferences); - /** Determine if Workflow Preferences should be marked as critical / readOnly at the top level */ - public static checkIfWorkflowPreferencesIsReadOnly( - workflowPreferences?: WorkflowPreferences, - ): boolean { - if (!workflowPreferences) { - return false; - } - - return ( - workflowPreferences.workflow.readOnly || - Object.values(workflowPreferences.channels).some( - ({ readOnly }) => readOnly, - ) + const mappedPreferences = Object.entries(builtPreferences.channels).reduce( + (acc, [channel, preference]) => ({ + ...acc, + [channel]: preference.enabled, + }), + {} as IPreferenceChannels, ); + + return mappedPreferences; } private mergePreferences( items: PreferencesEntity[], workflowId?: string, - ): WorkflowPreferences | undefined { + ): GetPreferencesResponseDto { const workflowResourcePreferences = this.getWorkflowResourcePreferences(items); const workflowUserPreferences = this.getWorkflowUserPreferences(items); @@ -168,18 +143,32 @@ export class GetPreferences { * then subscribers global preferences and the once that should be used if it says other then anything before it * we use subscribers workflow preferences */ - const preferences = [ + 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; + } 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; + return { preferences: undefined, type: undefined, source }; } /** @@ -196,42 +185,48 @@ export class GetPreferences { .map((item) => item.preferences); const readOnlyPreferences = orderedPreferencesForReadOnly.map( - ({ workflow, channels }) => ({ - workflow: { readOnly: workflow.readOnly }, - channels: { - in_app: { readOnly: channels.in_app.readOnly }, - email: { readOnly: channels.email.readOnly }, - sms: { readOnly: channels.sms.readOnly }, - chat: { readOnly: channels.chat.readOnly }, - push: { readOnly: channels.push.readOnly }, - }, + ({ all }) => ({ + all: { readOnly: all.readOnly }, }), ) as WorkflowPreferences[]; - // by merging only the read-only values after the full objects, we ensure that only the readOnly field is affected. const readOnlyPreference = deepMerge([...readOnlyPreferences]); - // if there is no subscriber preferences, we return the resource preferences - if (Object.keys(subscriberPreferences).length === 0) { - return workflowPreferences; + // Determine the most specific preference applied + let mostSpecificPreference: PreferencesTypeEnum | undefined; + if (subscriberWorkflowPreferences) { + mostSpecificPreference = PreferencesTypeEnum.SUBSCRIBER_WORKFLOW; + } else if (subscriberGlobalPreferences) { + mostSpecificPreference = PreferencesTypeEnum.SUBSCRIBER_GLOBAL; + } else if (workflowUserPreferences) { + mostSpecificPreference = PreferencesTypeEnum.USER_WORKFLOW; + } else if (workflowResourcePreferences) { + mostSpecificPreference = PreferencesTypeEnum.WORKFLOW_RESOURCE; } - // if the workflow should be readonly, we return the resource preferences default value for workflow. - if (readOnlyPreference?.workflow?.readOnly) { - subscriberPreferences.workflow.enabled = - workflowPreferences?.workflow?.enabled; + if (Object.keys(subscriberPreferences).length === 0) { + return { + preferences: workflowPreferences, + type: mostSpecificPreference, + source, + }; } - - // if the workflow channel should be readonly, we return the resource preferences default value for channel. - for (const channel of Object.values(ChannelTypeEnum)) { - if (readOnlyPreference?.channels[channel]?.readOnly) { - subscriberPreferences.channels[channel].enabled = - workflowPreferences?.channels[channel]?.enabled; - } + // 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. - return deepMerge([subscriberPreferences, readOnlyPreference]); + const mergedPreferences = deepMerge([ + subscriberPreferences, + readOnlyPreference, + ]); + + return { + preferences: mergedPreferences, + type: mostSpecificPreference, + source, + }; } private getSubscriberWorkflowPreferences( @@ -245,25 +240,33 @@ export class GetPreferences { ); } - private getSubscriberGlobalPreferences(items: PreferencesEntity[]) { + private getSubscriberGlobalPreferences( + items: PreferencesEntity[], + ): PreferencesEntity | undefined { return items.find( (item) => item.type === PreferencesTypeEnum.SUBSCRIBER_GLOBAL, ); } - private getWorkflowUserPreferences(items: PreferencesEntity[]) { + private getWorkflowUserPreferences( + items: PreferencesEntity[], + ): PreferencesEntity | undefined { return items.find( (item) => item.type === PreferencesTypeEnum.USER_WORKFLOW, ); } - private getWorkflowResourcePreferences(items: PreferencesEntity[]) { + private getWorkflowResourcePreferences( + items: PreferencesEntity[], + ): PreferencesEntity | undefined { return items.find( (item) => item.type === PreferencesTypeEnum.WORKFLOW_RESOURCE, ); } - private async getPreferencesFromDb(command: GetPreferencesCommand) { + private async getPreferencesFromDb( + command: GetPreferencesCommand, + ): Promise { const items: PreferencesEntity[] = []; /* 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 2b1df95245e..5ffb296ed60 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 @@ -107,9 +107,7 @@ export class GetSubscriberTemplatePreference { ...command.template, // Use the critical flag from the V2 Preference object if it exists ...(subscriberWorkflowPreferences && { - critical: GetPreferences.checkIfWorkflowPreferencesIsReadOnly( - subscriberWorkflowPreferences, - ), + critical: subscriberWorkflowPreferences?.all?.readOnly === true, }), }); 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 aff4d045884..e478df37ce6 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,6 +1,5 @@ import { IsDefined, IsEnum } from 'class-validator'; -import { PreferencesTypeEnum } from '@novu/dal'; -import { WorkflowPreferencesPartial } from '@novu/shared'; +import { PreferencesTypeEnum, WorkflowPreferencesPartial } from '@novu/shared'; import { EnvironmentCommand } from '../../commands'; export class UpsertPreferencesCommand extends EnvironmentCommand { 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 16d4b5129da..3c778d62f4f 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,10 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { - PreferencesEntity, - PreferencesRepository, - PreferencesTypeEnum, -} from '@novu/dal'; -import { buildWorkflowPreferences } from '@novu/shared'; +import { PreferencesEntity, PreferencesRepository } from '@novu/dal'; +import { buildWorkflowPreferences, PreferencesTypeEnum } from '@novu/shared'; import { UpsertPreferencesCommand } from './upsert-preferences.command'; import { UpsertWorkflowPreferencesCommand } from './upsert-workflow-preferences.command'; import { UpsertSubscriberGlobalPreferencesCommand } from './upsert-subscriber-global-preferences.command'; @@ -70,6 +66,10 @@ export class UpsertPreferences { ): Promise { const foundId = await this.getPreferencesId(command); + if (command.preferences === null) { + return this.deletePreferences(command, foundId); + } + const builtPreferences = buildWorkflowPreferences(command.preferences); const builtCommand = { @@ -121,6 +121,18 @@ export class UpsertPreferences { }); } + private async deletePreferences( + command: UpsertPreferencesCommand, + preferencesId: string, + ): Promise { + return await this.preferencesRepository.delete({ + _id: preferencesId, + _environmentId: command.environmentId, + _organizationId: command.organizationId, + _templateId: command.templateId, + }); + } + private async getPreferencesId( command: UpsertPreferencesCommand, ): Promise { 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 660ca211833..734665a17ec 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 @@ -4,7 +4,7 @@ import { EnvironmentCommand } from '../../commands'; export class UpsertWorkflowPreferencesCommand extends EnvironmentCommand { @IsOptional() - readonly preferences?: WorkflowPreferencesPartial; + readonly preferences?: WorkflowPreferencesPartial | null; @IsNotEmpty() templateId: string; diff --git a/libs/dal/src/repositories/preferences/preferences.entity.ts b/libs/dal/src/repositories/preferences/preferences.entity.ts index 7909959066b..ffd1941b072 100644 --- a/libs/dal/src/repositories/preferences/preferences.entity.ts +++ b/libs/dal/src/repositories/preferences/preferences.entity.ts @@ -1,16 +1,10 @@ -import { WorkflowPreferences } from '@novu/shared'; +import type { WorkflowPreferences } from '@novu/shared'; +import { PreferencesTypeEnum } from '@novu/shared'; import type { OrganizationId } from '../organization'; import type { EnvironmentId } from '../environment'; import type { SubscriberId } from '../subscriber'; -import { UserId } from '../user'; -import { ChangePropsValueType } from '../../types'; - -export enum PreferencesTypeEnum { - SUBSCRIBER_GLOBAL = 'SUBSCRIBER_GLOBAL', - SUBSCRIBER_WORKFLOW = 'SUBSCRIBER_WORKFLOW', - USER_WORKFLOW = 'USER_WORKFLOW', - WORKFLOW_RESOURCE = 'WORKFLOW_RESOURCE', -} +import type { UserId } from '../user'; +import type { ChangePropsValueType } from '../../types'; export type PreferencesDBModel = ChangePropsValueType< PreferencesEntity, diff --git a/libs/dal/src/repositories/preferences/preferences.schema.ts b/libs/dal/src/repositories/preferences/preferences.schema.ts index 311cf04d2d1..291c6e3887a 100644 --- a/libs/dal/src/repositories/preferences/preferences.schema.ts +++ b/libs/dal/src/repositories/preferences/preferences.schema.ts @@ -29,7 +29,7 @@ const preferencesSchema = new Schema( }, type: Schema.Types.String, preferences: { - workflow: { + all: { enabled: { type: Schema.Types.Boolean, default: true, @@ -45,50 +45,30 @@ const preferencesSchema = new Schema( type: Schema.Types.Boolean, default: true, }, - readOnly: { - type: Schema.Types.Boolean, - default: false, - }, }, [ChannelTypeEnum.SMS]: { enabled: { type: Schema.Types.Boolean, default: true, }, - readOnly: { - type: Schema.Types.Boolean, - default: false, - }, }, [ChannelTypeEnum.IN_APP]: { enabled: { type: Schema.Types.Boolean, default: true, }, - readOnly: { - type: Schema.Types.Boolean, - default: false, - }, }, [ChannelTypeEnum.CHAT]: { enabled: { type: Schema.Types.Boolean, default: true, }, - readOnly: { - type: Schema.Types.Boolean, - default: false, - }, }, [ChannelTypeEnum.PUSH]: { enabled: { type: Schema.Types.Boolean, default: true, }, - readOnly: { - type: Schema.Types.Boolean, - default: false, - }, }, }, }, diff --git a/packages/framework/src/resources/workflow/map-preferences.test.ts b/packages/framework/src/resources/workflow/map-preferences.test.ts index baa83a5a156..edfb8633588 100644 --- a/packages/framework/src/resources/workflow/map-preferences.test.ts +++ b/packages/framework/src/resources/workflow/map-preferences.test.ts @@ -30,24 +30,24 @@ describe('mapPreferences', () => { it('should return the the mapped equivalent of a full preference object', () => { const result = mapPreferences({ - workflow: { enabled: true, readOnly: false }, + all: { enabled: true, readOnly: false }, channels: { - email: { enabled: true, readOnly: false }, - sms: { enabled: true, readOnly: false }, - push: { enabled: true, readOnly: false }, - inApp: { enabled: true, readOnly: true }, - chat: { enabled: true, readOnly: false }, + email: { enabled: true }, + sms: { enabled: true }, + push: { enabled: true }, + inApp: { enabled: true }, + chat: { enabled: true }, }, }); expect(result).to.deep.equal({ - workflow: { enabled: true, readOnly: false }, + all: { enabled: true, readOnly: false }, channels: { - email: { enabled: true, readOnly: false }, - sms: { enabled: true, readOnly: false }, - push: { enabled: true, readOnly: false }, - in_app: { enabled: true, readOnly: true }, - chat: { enabled: true, readOnly: false }, + email: { enabled: true }, + sms: { enabled: true }, + push: { enabled: true }, + in_app: { enabled: true }, + chat: { enabled: true }, }, }); }); diff --git a/packages/framework/src/resources/workflow/map-preferences.ts b/packages/framework/src/resources/workflow/map-preferences.ts index b4209193f2f..8ae1ea6dd9f 100644 --- a/packages/framework/src/resources/workflow/map-preferences.ts +++ b/packages/framework/src/resources/workflow/map-preferences.ts @@ -19,8 +19,8 @@ export function mapPreferences(preferences?: WorkflowPreferences): WorkflowPrefe const output: WorkflowPreferencesPartial = {}; - if (preferences.workflow) { - output.workflow = preferences.workflow; + if (preferences.all) { + output.all = preferences.all; } // map between framework user-friendly enum (with camelCasing) to shared ChannelTypeEnum if the entry exists diff --git a/packages/framework/src/resources/workflow/workflow.test.ts b/packages/framework/src/resources/workflow/workflow.test.ts index 062ecaf1f12..2ef065387c0 100644 --- a/packages/framework/src/resources/workflow/workflow.test.ts +++ b/packages/framework/src/resources/workflow/workflow.test.ts @@ -110,7 +110,7 @@ describe('workflow function', () => { { preferences: { channels: { - email: { enabled: true, readOnly: true }, + email: { enabled: true }, }, }, } @@ -118,7 +118,7 @@ describe('workflow function', () => { expect(definition.preferences).to.deep.equal({ channels: { - email: { enabled: true, readOnly: true }, + email: { enabled: true }, }, }); }); diff --git a/packages/framework/src/types/workflow.types.ts b/packages/framework/src/types/workflow.types.ts index e054edd8de0..929285e73a7 100644 --- a/packages/framework/src/types/workflow.types.ts +++ b/packages/framework/src/types/workflow.types.ts @@ -33,12 +33,18 @@ export type Execute, T_Controls extend event: ExecuteInput ) => Promise; -/** A preference for a notification delivery channel. */ -export type ChannelPreference = { +/** + * A preference for a notification delivery workflow. + * + * This provides a shortcut to setting all channels to the same preference. + */ +export type WorkflowPreference = { /** - * A flag specifying if notification delivery is enabled for the channel. + * A flag specifying if notification delivery is enabled for the workflow. * - * If `true`, notification delivery is enabled. + * If `true`, notification delivery is enabled by default for all channels. + * + * This setting can be overridden by the channel preferences. * * @default true */ @@ -53,20 +59,32 @@ export type ChannelPreference = { readOnly: boolean; }; +/** A preference for a notification delivery channel. */ +export type ChannelPreference = { + /** + * A flag specifying if notification delivery is enabled for the channel. + * + * If `true`, notification delivery is enabled. + * + * @default true + */ + enabled: boolean; +}; + /** * A partial set of workflow preferences. */ export type WorkflowPreferences = DeepPartial<{ /** - * A preference for the workflow. + * A default preference for the channels. * * The values specified here will be used if no preference is specified for a channel. */ - workflow: ChannelPreference; + all: WorkflowPreference; /** * A preference for each notification delivery channel. * - * If no preference is specified for a channel, the `workflow` preference will be used. + * If no preference is specified for a channel, the `all` preference will be used. */ channels: Record; }>; @@ -88,13 +106,13 @@ export type WorkflowOptions; }; diff --git a/packages/shared/src/utils/buildWorkflowPreferences.spec.ts b/packages/shared/src/utils/buildWorkflowPreferences.spec.ts index b5929e95bb6..00cc4b2b50c 100644 --- a/packages/shared/src/utils/buildWorkflowPreferences.spec.ts +++ b/packages/shared/src/utils/buildWorkflowPreferences.spec.ts @@ -1,17 +1,21 @@ import { expect, describe, it } from 'vitest'; import { buildWorkflowPreferences } from './buildWorkflowPreferences'; -import { ChannelPreference, WorkflowPreferencesPartial, WorkflowPreferences } from '../types'; +import { ChannelPreference, WorkflowPreferencesPartial, WorkflowPreferences, WorkflowPreference } from '../types'; const WORKFLOW_CHANNEL_PREFERENCE_DEFAULT_VALUE = true; const WORKFLOW_CHANNEL_PREFERENCE_DEFAULT_READ_ONLY = false; -const DEFAULT_CHANNEL_PREFERENCE: ChannelPreference = { +const DEFAULT_WORKFLOW_PREFERENCE: WorkflowPreference = { enabled: WORKFLOW_CHANNEL_PREFERENCE_DEFAULT_VALUE, readOnly: WORKFLOW_CHANNEL_PREFERENCE_DEFAULT_READ_ONLY, }; +const DEFAULT_CHANNEL_PREFERENCE: ChannelPreference = { + enabled: WORKFLOW_CHANNEL_PREFERENCE_DEFAULT_VALUE, +}; + const testDefaultPreferences: WorkflowPreferences = { - workflow: DEFAULT_CHANNEL_PREFERENCE, + all: DEFAULT_WORKFLOW_PREFERENCE, channels: { in_app: DEFAULT_CHANNEL_PREFERENCE, sms: DEFAULT_CHANNEL_PREFERENCE, @@ -28,20 +32,24 @@ describe('buildWorkflowPreferences', () => { }); it('should return the input object if a complete preferences object is supplied', () => { - const testPreference: ChannelPreference = { + const testWorkflowPreference: WorkflowPreference = { enabled: false, readOnly: true, }; + const testChannelPreference: ChannelPreference = { + enabled: false, + }; + // opposite of default const testPreferences: WorkflowPreferencesPartial = { - workflow: testPreference, + all: testWorkflowPreference, channels: { - in_app: testPreference, - sms: testPreference, - email: testPreference, - push: testPreference, - chat: testPreference, + in_app: testChannelPreference, + sms: testChannelPreference, + email: testChannelPreference, + push: testChannelPreference, + chat: testChannelPreference, }, }; @@ -50,24 +58,9 @@ describe('buildWorkflowPreferences', () => { }); describe('should populate the remainder of the object with default values', () => { - it('using just a single, partial channel with readOnly', () => { - const testPreferences: WorkflowPreferencesPartial = { - channels: { in_app: { readOnly: true } }, - }; - - const result = buildWorkflowPreferences(testPreferences, testDefaultPreferences); - expect(result).toEqual({ - ...testDefaultPreferences, - channels: { - ...testDefaultPreferences.channels, - in_app: { enabled: true, readOnly: true }, - }, - }); - }); - it('using just a full, single channel', () => { const testPreferences: WorkflowPreferencesPartial = { - channels: { in_app: { enabled: false, readOnly: false } }, + channels: { in_app: { enabled: false } }, }; const result = buildWorkflowPreferences(testPreferences, testDefaultPreferences); @@ -75,43 +68,38 @@ describe('buildWorkflowPreferences', () => { ...testDefaultPreferences, channels: { ...testDefaultPreferences.channels, - in_app: { enabled: false, readOnly: false }, + in_app: { enabled: false }, }, }); }); it('using a combination of channels and workflow-level preferences', () => { const testPreferences: WorkflowPreferencesPartial = { - workflow: { enabled: true, readOnly: true }, + all: { enabled: true, readOnly: true }, channels: { - in_app: { enabled: false, readOnly: false }, + in_app: { enabled: false }, chat: { enabled: false }, }, }; const result = buildWorkflowPreferences(testPreferences, testDefaultPreferences); expect(result).toEqual({ - workflow: testPreferences.workflow, + all: testPreferences.all, channels: { in_app: { enabled: false, - readOnly: false, }, chat: { enabled: false, - readOnly: testPreferences.workflow?.readOnly, }, sms: { - enabled: testPreferences.workflow?.enabled, - readOnly: testPreferences.workflow?.readOnly, + enabled: testPreferences.all?.enabled, }, email: { - enabled: testPreferences.workflow?.enabled, - readOnly: testPreferences.workflow?.readOnly, + enabled: testPreferences.all?.enabled, }, push: { - enabled: testPreferences.workflow?.enabled, - readOnly: testPreferences.workflow?.readOnly, + enabled: testPreferences.all?.enabled, }, }, }); @@ -121,36 +109,31 @@ describe('buildWorkflowPreferences', () => { it('should use the `workflow`-level preferences to define defaults for all channel-level preferences', () => { const expectedDefaultValue = false; const testPreferences: WorkflowPreferencesPartial = { - workflow: { enabled: expectedDefaultValue }, + all: { enabled: expectedDefaultValue }, }; const result = buildWorkflowPreferences(testPreferences, testDefaultPreferences); const expectedResult: WorkflowPreferences = { - workflow: { + all: { enabled: expectedDefaultValue, readOnly: WORKFLOW_CHANNEL_PREFERENCE_DEFAULT_READ_ONLY, }, channels: { in_app: { enabled: expectedDefaultValue, - readOnly: WORKFLOW_CHANNEL_PREFERENCE_DEFAULT_READ_ONLY, }, sms: { enabled: expectedDefaultValue, - readOnly: WORKFLOW_CHANNEL_PREFERENCE_DEFAULT_READ_ONLY, }, email: { enabled: expectedDefaultValue, - readOnly: WORKFLOW_CHANNEL_PREFERENCE_DEFAULT_READ_ONLY, }, push: { enabled: expectedDefaultValue, - readOnly: WORKFLOW_CHANNEL_PREFERENCE_DEFAULT_READ_ONLY, }, chat: { enabled: expectedDefaultValue, - readOnly: WORKFLOW_CHANNEL_PREFERENCE_DEFAULT_READ_ONLY, }, }, }; diff --git a/packages/shared/src/utils/buildWorkflowPreferences.ts b/packages/shared/src/utils/buildWorkflowPreferences.ts index dd4ae17edc3..8094f288dfa 100644 --- a/packages/shared/src/utils/buildWorkflowPreferences.ts +++ b/packages/shared/src/utils/buildWorkflowPreferences.ts @@ -15,11 +15,16 @@ export const buildWorkflowPreferences = ( return defaultPreferences; } + const defaultChannelPreference = { + // Only use the workflow-level enabled preference if defined + ...(inputPreferences?.all?.enabled !== undefined ? { enabled: inputPreferences.all.enabled } : {}), + }; + return { ...defaultPreferences, - workflow: { - ...defaultPreferences.workflow, - ...inputPreferences.workflow, + all: { + ...defaultPreferences.all, + ...inputPreferences.all, }, channels: { ...defaultPreferences.channels, @@ -28,7 +33,7 @@ export const buildWorkflowPreferences = ( ...output, [channel]: { ...defaultPreferences.channels[channel], - ...inputPreferences?.workflow, + ...defaultChannelPreference, ...inputPreferences?.channels?.[channel], }, }),