diff --git a/.cursor/rules/novu.mdc b/.cursor/rules/novu.mdc index b19c3b254af..34fa670b7a2 100644 --- a/.cursor/rules/novu.mdc +++ b/.cursor/rules/novu.mdc @@ -38,12 +38,6 @@ When writing FrontEnd code, you are an expert on modern dev tool UI and UX desig ### Git Rules - when adding commit titles, use proper scope (dashboard,web,api,worker,shared,etc...) -### Pull Request Rules -- When creating a pull request, use the following format semantic format for semantic pr title: - - Title: fix([scope]): [description] (e.g. feat(dashboard): add new feature) - - Description: A detailed description of the changes made in the pull request -- If working on a linear issue, add fixes NV- to the title end - ### Key Conventions - Linting: Add blank lines before return statements - When importing "motion-react" package, import it from "motion/react" diff --git a/.cursor/rules/pullrequest.mdc b/.cursor/rules/pullrequest.mdc new file mode 100644 index 00000000000..8b0d819406b --- /dev/null +++ b/.cursor/rules/pullrequest.mdc @@ -0,0 +1,10 @@ +--- +description: When creating a new pull request on GitHub, use this to specifiy the contents +alwaysApply: false +--- + +### Pull Request Rules +- When creating a pull request, use the following format semantic format for semantic pr title: + - Title: fix([scope]): [description] (e.g. feat(dashboard): add new feature) + - Description: A detailed description of the changes made in the pull request +- If working on a linear issue, add fixes NV- to the title end diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/base-translation-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/base-translation-renderer.usecase.ts index 1d1a04e0aaf..d03501ce732 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/base-translation-renderer.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/base-translation-renderer.usecase.ts @@ -40,7 +40,7 @@ export abstract class BaseTranslationRendererUsecase { resourceEntity?: NotificationTemplateEntity | LayoutDto; organization?: OrganizationEntity; }): Promise> { - if (process.env.NOVU_ENTERPRISE !== 'true') { + if (process.env.NOVU_ENTERPRISE !== 'true' && process.env.CI_EE_TEST !== 'true') { return controls; } @@ -76,7 +76,7 @@ export abstract class BaseTranslationRendererUsecase { locale?: string; organization?: OrganizationEntity; }): Promise { - if (process.env.NOVU_ENTERPRISE !== 'true') { + if (process.env.NOVU_ENTERPRISE !== 'true' && process.env.CI_EE_TEST !== 'true') { return content; } @@ -109,7 +109,7 @@ export abstract class BaseTranslationRendererUsecase { organization?: OrganizationEntity; resourceEntity?: NotificationTemplateEntity | LayoutDto; }): Promise { - if (process.env.NOVU_ENTERPRISE !== 'true') { + if (process.env.NOVU_ENTERPRISE !== 'true' && process.env.CI_EE_TEST !== 'true') { return null; } @@ -165,7 +165,7 @@ export abstract class BaseTranslationRendererUsecase { content: string; variables: FullPayloadForRender; }): Promise { - if (process.env.NOVU_ENTERPRISE !== 'true' || !context) { + if ((process.env.NOVU_ENTERPRISE !== 'true' && process.env.CI_EE_TEST !== 'true') || !context) { return content; } diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.spec.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.spec.ts index e2c4b39bbfb..d373477d77d 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.spec.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/email-output-renderer.spec.ts @@ -2438,283 +2438,6 @@ describe('EmailOutputRendererUsecase', () => { }); }); - describe('Translation with escaped characters', () => { - beforeEach(() => { - getOrganizationSettingsMock.execute.resolves({ - removeNovuBranding: false, - defaultLocale: 'en_US', - }); - }); - - it('should not double-escape JSON characters from translation content', async () => { - const translatedContent = - "Visit http://sharefile.com/support and look for \\\"Chat with Us.\\\""; - - translateStub.restore(); - translateStub = sinon - .stub(require('@novu/ee-translation').Translate.prototype, 'execute') - .callsFake(async (command: any) => { - if (command.content.includes('{{t.footer}}')) { - return command.content.replace('{{t.footer}}', translatedContent); - } - - return command.content || ''; - }); - - const mockTipTapNode: MailyJSONContent = { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: '{{t.footer}}', - }, - ], - }, - ], - }; - - const renderCommand: EmailOutputRendererCommand = { - dbWorkflow: mockDbWorkflow, - controlValues: { - subject: 'Translation Test', - body: JSON.stringify(mockTipTapNode), - }, - fullPayloadForRender: mockFullPayload, - stepId: 'fake_step_id', - }; - - const result = await emailOutputRendererUsecase.execute(renderCommand); - - expect(result.body).to.include('http://sharefile.com/support'); - expect(result.body).to.include('"Chat with Us."'); - expect(result.body).to.not.include('\\"Chat with Us.\\"'); - expect(result.body).to.not.include('\\\\'); - }); - - it('should handle translation content with multiple escaped characters', async () => { - const translatedContent = 'Line 1\\nLine 2\\tTabbed\\r\\nAnd \\"quoted\\"'; - - translateStub.restore(); - translateStub = sinon - .stub(require('@novu/ee-translation').Translate.prototype, 'execute') - .callsFake(async (command: any) => { - if (command.content.includes('{{t.multiline}}')) { - return command.content.replace('{{t.multiline}}', translatedContent); - } - - return command.content || ''; - }); - - const mockTipTapNode: MailyJSONContent = { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: '{{t.multiline}}', - }, - ], - }, - ], - }; - - const renderCommand: EmailOutputRendererCommand = { - dbWorkflow: mockDbWorkflow, - controlValues: { - subject: 'Multiline Test', - body: JSON.stringify(mockTipTapNode), - }, - fullPayloadForRender: mockFullPayload, - stepId: 'fake_step_id', - }; - - const result = await emailOutputRendererUsecase.execute(renderCommand); - - expect(result.body).to.include('Line 1'); - expect(result.body).to.include('Line 2'); - expect(result.body).to.include('"quoted"'); - expect(result.body).to.not.include('\\n'); - expect(result.body).to.not.include('\\t'); - expect(result.body).to.not.include('\\"'); - }); - }); - - describe('Translation with escaped characters for plain HTML', () => { - beforeEach(() => { - getOrganizationSettingsMock.execute.resolves({ - removeNovuBranding: false, - defaultLocale: 'en_US', - }); - }); - - it('should not double-escape JSON characters from translation content in plain HTML body', async () => { - const translatedContent = - "Visit http://sharefile.com/support and look for \\\"Chat with Us.\\\""; - - translateStub.restore(); - translateStub = sinon - .stub(require('@novu/ee-translation').Translate.prototype, 'execute') - .callsFake(async (command: any) => { - if (command.content.includes('{{t.footer}}')) { - return command.content.replace('{{t.footer}}', translatedContent); - } - - return command.content || ''; - }); - - const plainHtmlBody = '

{{t.footer}}

'; - - const renderCommand: EmailOutputRendererCommand = { - dbWorkflow: mockDbWorkflow, - controlValues: { - subject: 'Translation Test', - body: plainHtmlBody, - }, - fullPayloadForRender: mockFullPayload, - stepId: 'fake_step_id', - }; - - const result = await emailOutputRendererUsecase.execute(renderCommand); - - expect(result.body).to.include('http://sharefile.com/support'); - expect(result.body).to.include('"Chat with Us."'); - expect(result.body).to.not.include('\\"Chat with Us.\\"'); - expect(result.body).to.not.include('\\\\'); - }); - - it('should handle plain HTML body with multiple escaped characters', async () => { - const translatedContent = 'Line 1\\nLine 2\\tTabbed\\r\\nAnd \\"quoted\\"'; - - translateStub.restore(); - translateStub = sinon - .stub(require('@novu/ee-translation').Translate.prototype, 'execute') - .callsFake(async (command: any) => { - if (command.content.includes('{{t.multiline}}')) { - return command.content.replace('{{t.multiline}}', translatedContent); - } - - return command.content || ''; - }); - - const plainHtmlBody = '
{{t.multiline}}
'; - - const renderCommand: EmailOutputRendererCommand = { - dbWorkflow: mockDbWorkflow, - controlValues: { - subject: 'Multiline Test', - body: plainHtmlBody, - }, - fullPayloadForRender: mockFullPayload, - stepId: 'fake_step_id', - }; - - const result = await emailOutputRendererUsecase.execute(renderCommand); - - expect(result.body).to.include('Line 1'); - expect(result.body).to.include('Line 2'); - expect(result.body).to.include('"quoted"'); - expect(result.body).to.not.include('\\n'); - expect(result.body).to.not.include('\\t'); - expect(result.body).to.not.include('\\"'); - }); - - it('should handle email subject with escaped characters', async () => { - const translatedSubject = 'Welcome to \\"Our Service\\" - You\\\'re all set!'; - - translateStub.restore(); - translateStub = sinon - .stub(require('@novu/ee-translation').Translate.prototype, 'execute') - .callsFake(async (command: any) => { - if (command.content.includes('{{t.subject}}')) { - return command.content.replace('{{t.subject}}', translatedSubject); - } - - return command.content || ''; - }); - - const renderCommand: EmailOutputRendererCommand = { - dbWorkflow: mockDbWorkflow, - controlValues: { - subject: '{{t.subject}}', - body: '

Test body

', - }, - fullPayloadForRender: mockFullPayload, - stepId: 'fake_step_id', - }; - - const result = await emailOutputRendererUsecase.execute(renderCommand); - - expect(result.subject).to.include('"Our Service"'); - expect(result.subject).to.include("You're all set!"); - expect(result.subject).to.not.include('\\"Our Service\\"'); - expect(result.subject).to.not.include("\\'re"); - }); - - it('should handle layout with plain HTML body containing escaped characters', async () => { - const translatedLayoutContent = 'Footer: Visit us at \\"Main Street\\" \\nCall: 555-1234'; - - translateStub.restore(); - translateStub = sinon - .stub(require('@novu/ee-translation').Translate.prototype, 'execute') - .callsFake(async (command: any) => { - if (command.content.includes('{{t.layoutFooter}}')) { - return command.content.replace('{{t.layoutFooter}}', translatedLayoutContent); - } - - return command.content || ''; - }); - - const layoutContent = '{{content}}
{{t.layoutFooter}}
'; - const stepContent = '

Step content

'; - - controlValuesRepositoryMock.findOne.resolves({ - _id: 'test_layout_id', - _organizationId: 'fake_org_id', - _environmentId: 'fake_env_id', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - level: ControlValuesLevelEnum.LAYOUT_CONTROLS, - priority: 0, - controls: { - email: { - body: layoutContent, - }, - }, - }); - - getLayoutUseCase.execute.resolves({ - _id: 'test_layout_id', - isDefault: false, - name: 'test_layout_name', - layoutId: 'test_layout_id', - } as any); - - const renderCommand: EmailOutputRendererCommand = { - dbWorkflow: mockDbWorkflow, - controlValues: { - subject: 'Layout Test', - body: stepContent, - layoutId: 'test_layout_id', - }, - fullPayloadForRender: mockFullPayload, - stepId: 'fake_step_id', - }; - - const result = await emailOutputRendererUsecase.execute(renderCommand); - - expect(result.body).to.include('Step content'); - expect(result.body).to.include('"Main Street"'); - expect(result.body).to.include('Call: 555-1234'); - expect(result.body).to.not.include('\\"Main Street\\"'); - expect(result.body).to.not.include('\\n'); - }); - }); - describe('Gmail clipping prevention', () => { beforeEach(() => { getOrganizationSettingsMock.execute.resolves({ @@ -2825,256 +2548,4 @@ describe('EmailOutputRendererUsecase', () => { expect(result.body).to.include('

'); }); }); - - describe('Layout body translation preprocessing', () => { - beforeEach(() => { - getOrganizationSettingsMock.execute.resolves({ - removeNovuBranding: false, - defaultLocale: 'en_US', - }); - }); - - it('should transform translation keys in filter arguments for layouts', async () => { - translateStub.restore(); - translateStub = sinon - .stub(require('@novu/ee-translation').Translate.prototype, 'execute') - .callsFake(async (command: any) => { - // Verify that filter arguments are transformed to {{t.key}} format - if (command.content.includes("'{{t.apple}}'") && command.content.includes("'{{t.apples}}'")) { - return command.content.replace("'{{t.apple}}'", "'1 apple'").replace("'{{t.apples}}'", "'5 apples'"); - } - - return command.content || ''; - }); - - const layoutContent = - "{{content}}
You have {{ payload.count | pluralize: 't.apple', 't.apples' }}
"; - const stepContent = '

Step content

'; - - controlValuesRepositoryMock.findOne.resolves({ - _id: 'test_layout_id', - _organizationId: 'fake_org_id', - _environmentId: 'fake_env_id', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - level: ControlValuesLevelEnum.LAYOUT_CONTROLS, - priority: 0, - controls: { - email: { - body: layoutContent, - }, - }, - }); - - getLayoutUseCase.execute.resolves({ - _id: 'test_layout_id', - isDefault: false, - name: 'test_layout_name', - layoutId: 'test_layout_id', - } as any); - - const renderCommand: EmailOutputRendererCommand = { - dbWorkflow: mockDbWorkflow, - controlValues: { - subject: 'Layout Filter Test', - body: stepContent, - layoutId: 'test_layout_id', - }, - fullPayloadForRender: { - ...mockFullPayload, - payload: { count: 5 }, - }, - stepId: 'fake_step_id', - }; - - const result = await emailOutputRendererUsecase.execute(renderCommand); - - expect(result.body).to.include('Step content'); - // The pluralize filter should have processed with the translated values - expect(result.body).to.include('5 apples'); - }); - - it('should handle layout with mixed translation keys and filter arguments', async () => { - translateStub.restore(); - translateStub = sinon - .stub(require('@novu/ee-translation').Translate.prototype, 'execute') - .callsFake(async (command: any) => { - let content = command.content || ''; - - // Transform standalone translation keys - if (content.includes('{{t.greeting}}')) { - content = content.replace('{{t.greeting}}', 'Hello'); - } - - // Transform filter argument translation keys - if (content.includes("'{{t.item}}'")) { - content = content.replace("'{{t.item}}'", "'item'"); - } - if (content.includes("'{{t.items}}'")) { - content = content.replace("'{{t.items}}'", "'items'"); - } - - return content; - }); - - const layoutContent = - "{{content}}
{{t.greeting}}! You have {{ payload.count | pluralize: 't.item', 't.items' }}
"; - const stepContent = '

Main content

'; - - controlValuesRepositoryMock.findOne.resolves({ - _id: 'test_layout_id', - _organizationId: 'fake_org_id', - _environmentId: 'fake_env_id', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - level: ControlValuesLevelEnum.LAYOUT_CONTROLS, - priority: 0, - controls: { - email: { - body: layoutContent, - }, - }, - }); - - getLayoutUseCase.execute.resolves({ - _id: 'test_layout_id', - isDefault: false, - name: 'test_layout_name', - layoutId: 'test_layout_id', - } as any); - - const renderCommand: EmailOutputRendererCommand = { - dbWorkflow: mockDbWorkflow, - controlValues: { - subject: 'Mixed Layout Test', - body: stepContent, - layoutId: 'test_layout_id', - }, - fullPayloadForRender: { - ...mockFullPayload, - payload: { count: 3 }, - }, - stepId: 'fake_step_id', - }; - - const result = await emailOutputRendererUsecase.execute(renderCommand); - - expect(result.body).to.include('Main content'); - expect(result.body).to.include('Hello'); - expect(result.body).to.include('3 items'); - }); - }); - - describe('Case-insensitive translation key matching', () => { - beforeEach(() => { - getOrganizationSettingsMock.execute.resolves({ - removeNovuBranding: false, - defaultLocale: 'en_US', - }); - }); - - it('should match uppercase translation keys from upcase filter', async () => { - translateStub.restore(); - translateStub = sinon - .stub(require('@novu/ee-translation').Translate.prototype, 'execute') - .callsFake(async (command: any) => { - // Match both lowercase and uppercase translation keys - if (command.content.includes('{{T.GREETING}}')) { - return command.content.replace('{{T.GREETING}}', 'HELLO WORLD'); - } - if (command.content.includes('{{t.greeting}}')) { - return command.content.replace('{{t.greeting}}', 'hello world'); - } - - return command.content || ''; - }); - - const plainHtmlBody = '

{{T.GREETING}}

'; - - const renderCommand: EmailOutputRendererCommand = { - dbWorkflow: mockDbWorkflow, - controlValues: { - subject: 'Case Test', - body: plainHtmlBody, - }, - fullPayloadForRender: mockFullPayload, - stepId: 'fake_step_id', - }; - - const result = await emailOutputRendererUsecase.execute(renderCommand); - - expect(result.body).to.include('HELLO WORLD'); - }); - - it('should match lowercase translation keys from downcase filter', async () => { - translateStub.restore(); - translateStub = sinon - .stub(require('@novu/ee-translation').Translate.prototype, 'execute') - .callsFake(async (command: any) => { - if (command.content.includes('{{t.welcome}}')) { - return command.content.replace('{{t.welcome}}', 'welcome'); - } - - return command.content || ''; - }); - - const plainHtmlBody = '

{{t.welcome}} to our service

'; - - const renderCommand: EmailOutputRendererCommand = { - dbWorkflow: mockDbWorkflow, - controlValues: { - subject: 'Lowercase Test', - body: plainHtmlBody, - }, - fullPayloadForRender: mockFullPayload, - stepId: 'fake_step_id', - }; - - const result = await emailOutputRendererUsecase.execute(renderCommand); - - expect(result.body).to.include('welcome to our service'); - }); - - it('should handle mixed case translation keys in the same content', async () => { - translateStub.restore(); - translateStub = sinon - .stub(require('@novu/ee-translation').Translate.prototype, 'execute') - .callsFake(async (command: any) => { - let content = command.content || ''; - - // Handle uppercase keys (from upcase filter) - if (content.includes('{{T.HEADER}}')) { - content = content.replace('{{T.HEADER}}', 'WELCOME'); - } - // Handle lowercase keys (from downcase filter) - if (content.includes('{{t.footer}}')) { - content = content.replace('{{t.footer}}', 'thank you'); - } - // Handle normal case keys - if (content.includes('{{t.body}}')) { - content = content.replace('{{t.body}}', 'This is the body'); - } - - return content; - }); - - const plainHtmlBody = '
{{T.HEADER}}
{{t.body}}
{{t.footer}}
'; - - const renderCommand: EmailOutputRendererCommand = { - dbWorkflow: mockDbWorkflow, - controlValues: { - subject: 'Mixed Case Test', - body: plainHtmlBody, - }, - fullPayloadForRender: mockFullPayload, - stepId: 'fake_step_id', - }; - - const result = await emailOutputRendererUsecase.execute(renderCommand); - - expect(result.body).to.include('WELCOME'); - expect(result.body).to.include('This is the body'); - expect(result.body).to.include('thank you'); - }); - }); }); diff --git a/apps/api/src/app/inbox/e2e/session.e2e.ts b/apps/api/src/app/inbox/e2e/session.e2e.ts index 6b2938f318b..98e7da15102 100644 --- a/apps/api/src/app/inbox/e2e/session.e2e.ts +++ b/apps/api/src/app/inbox/e2e/session.e2e.ts @@ -168,6 +168,107 @@ describe('Session - /inbox/session (POST) #novu-v2', async () => { expect(storedSubscriber.email).to.equal(newRandomSubscriber.email); }); + it('should create a new subscriber with locale and data fields', async () => { + await setIntegrationConfig({ + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }); + const subscriberId = `user-subscriber-id-${`${randomBytes(4).toString('hex')}`}`; + + const newRandomSubscriber = { + subscriberId, + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + locale: 'de-DE', + data: { customKey: 'customValue', nestedData: { key: 'value' } }, + }; + + const res = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriber: newRandomSubscriber, + }); + + const { status, body } = res; + + expect(status).to.equal(201); + expect(body.data.token).to.be.ok; + + const storedSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); + expect(storedSubscriber).to.exist; + if (!storedSubscriber) { + throw new Error('Subscriber exists but was not found'); + } + + expect(storedSubscriber.firstName).to.equal(newRandomSubscriber.firstName); + expect(storedSubscriber.lastName).to.equal(newRandomSubscriber.lastName); + expect(storedSubscriber.email).to.equal(newRandomSubscriber.email); + expect(storedSubscriber.locale).to.equal(newRandomSubscriber.locale); + expect(storedSubscriber.data).to.deep.equal(newRandomSubscriber.data); + }); + + it('should update locale and data fields when subscriber already exists with valid HMAC', async () => { + await setIntegrationConfig({ + _environmentId: session.environment._id, + _organizationId: session.environment._organizationId, + hmac: false, + }); + const subscriberId = `user-subscriber-id-${`${randomBytes(4).toString('hex')}`}`; + + const initialSubscriber = { + subscriberId, + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + locale: 'en-US', + data: { initialKey: 'initialValue' }, + }; + + const res = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriber: initialSubscriber, + }); + + expect(res.status).to.equal(201); + + const storedSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); + expect(storedSubscriber).to.exist; + expect(storedSubscriber?.locale).to.equal('en-US'); + expect(storedSubscriber?.data).to.deep.equal({ initialKey: 'initialValue' }); + + const updatedSubscriber = { + subscriberId, + firstName: 'Jane Updated', + lastName: 'Smith Updated', + email: 'jane.updated@example.com', + locale: 'fr-FR', + data: { updatedKey: 'updatedValue', nested: { key: 'value' } }, + }; + + const secretKey = session.environment.apiKeys[0].key; + const subscriberHash = createHash(secretKey, subscriberId); + + const updateRes = await initializeSession({ + applicationIdentifier: session.environment.identifier, + subscriber: updatedSubscriber, + subscriberHash, + }); + + expect(updateRes.status).to.equal(201); + + const updatedStoredSubscriber = await subscriberRepository.findBySubscriberId( + session.environment._id, + subscriberId + ); + expect(updatedStoredSubscriber).to.exist; + expect(updatedStoredSubscriber?.firstName).to.equal(updatedSubscriber.firstName); + expect(updatedStoredSubscriber?.lastName).to.equal(updatedSubscriber.lastName); + expect(updatedStoredSubscriber?.email).to.equal(updatedSubscriber.email); + expect(updatedStoredSubscriber?.locale).to.equal(updatedSubscriber.locale); + expect(updatedStoredSubscriber?.data).to.deep.equal(updatedSubscriber.data); + }); + it('should upsert a subscriber', async () => { await setIntegrationConfig( { diff --git a/apps/api/src/app/inbox/usecases/session/session.usecase.ts b/apps/api/src/app/inbox/usecases/session/session.usecase.ts index f422be14923..4be18f889b7 100644 --- a/apps/api/src/app/inbox/usecases/session/session.usecase.ts +++ b/apps/api/src/app/inbox/usecases/session/session.usecase.ts @@ -167,6 +167,7 @@ export class Session { phone: subscriber.phone, email: subscriber.email, avatar: subscriber.avatar, + locale: subscriber.locale, data: subscriber.data as CustomDataType, timezone: subscriber.timezone, allowUpdate: isHmacValid( diff --git a/apps/api/src/app/subscribers/subscribersV1.controller.ts b/apps/api/src/app/subscribers/subscribersV1.controller.ts index 17e250723f0..69d44dee811 100644 --- a/apps/api/src/app/subscribers/subscribersV1.controller.ts +++ b/apps/api/src/app/subscribers/subscribersV1.controller.ts @@ -439,7 +439,7 @@ export class SubscribersV1Controller { type: Boolean, required: false, description: - 'A flag which specifies if the inactive workflow channels should be included in the retrieved preferences. Default is true', + 'A flag which specifies if the inactive workflow channels should be included in the retrieved preferences. Default is false', }) @SdkGroupName('Subscribers.Preferences') @ApiExcludeEndpoint() @@ -453,7 +453,7 @@ export class SubscribersV1Controller { subscriberId, environmentId: user.environmentId, level: PreferenceLevelEnum.TEMPLATE, - includeInactiveChannels: includeInactiveChannels ?? true, + includeInactiveChannels: includeInactiveChannels ?? false, }); return (await this.getPreferenceUsecase.execute(command)) as UpdateSubscriberPreferenceResponseDto[]; @@ -480,7 +480,7 @@ export class SubscribersV1Controller { subscriberId, environmentId: user.environmentId, level: parameter, - includeInactiveChannels: includeInactiveChannels ?? true, + includeInactiveChannels: includeInactiveChannels ?? false, }); return await this.getPreferenceUsecase.execute(command); @@ -510,7 +510,7 @@ export class SubscribersV1Controller { subscriberId, workflowIdOrIdentifier: workflowId, level: PreferenceLevelEnum.TEMPLATE, - includeInactiveChannels: true, + includeInactiveChannels: false, ...(body.channel && { [body.channel.type]: body.channel.enabled }), }) ); @@ -566,7 +566,7 @@ export class SubscribersV1Controller { organizationId: user.organizationId, subscriberId, level: PreferenceLevelEnum.GLOBAL, - includeInactiveChannels: true, + includeInactiveChannels: false, ...channels, }) ); diff --git a/apps/api/src/app/translations/e2e/v2/translation-replacement.e2e-ee.ts b/apps/api/src/app/translations/e2e/v2/translation-replacement.e2e-ee.ts new file mode 100644 index 00000000000..f158aec4b59 --- /dev/null +++ b/apps/api/src/app/translations/e2e/v2/translation-replacement.e2e-ee.ts @@ -0,0 +1,886 @@ +import { Novu } from '@novu/api'; +import { LocalizationResourceEnum } from '@novu/dal'; +import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; +import { LayoutCreationSourceEnum } from '../../../layouts-v2/types'; +import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; + +/** + * Translation Replacement E2E Tests for V2 Workflows + * + * These tests verify that translation keys ({{t.key}}) are correctly replaced with + * their translated values in workflow step content (subject, body, etc.). + * + * We use generatePreview instead of actual workflow delivery because: + * + * Actual workflow delivery processes jobs asynchronously through queues. Each step + * creates separate jobs that execute independently, and execution details are written + * incrementally (job queued → bridge execution → message created → sent, etc.). + * This requires polling/waiting for job completion and querying execution details, + * which may not be immediately available. + * + * generatePreview executes synchronously, returning results immediately without jobs + * or queues. It uses the same translation logic (BaseTranslationRendererUsecase) as + * actual delivery, ensuring equivalent behavior for testing translation replacement. + */ + +describe('Translation Replacement - V2 Workflows #novu-v2', async () => { + let session: UserSession; + let novuClient: Novu; + let workflowId: string; + let emailStepId: string; + let inAppStepId: string; + let smsStepId: string; + let chatStepId: string; + + beforeEach(async () => { + session = new UserSession(); + await session.initialize(); + + // Set organization service level to business for enterprise features + await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); + + novuClient = initNovuClassSdkInternalAuth(session); + + // Create workflow with multiple channel types + const { result: workflow } = await novuClient.workflows.create({ + name: 'Translation Replacement Test Workflow', + workflowId: `translation-test-${Date.now()}`, + source: WorkflowCreationSourceEnum.EDITOR, + active: true, + isTranslationEnabled: true, + payloadSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string' }, + firstName: { type: 'string' }, + message: { type: 'string' }, + username: { type: 'string' }, + sender: { type: 'string' }, + code: { type: 'string' }, + appleCount: { type: 'number' }, + itemCount: { type: 'number' }, + address: { + type: 'object', + properties: { + city: { type: 'string' }, + country: { type: 'string' }, + }, + }, + }, + additionalProperties: false, + }, + steps: [ + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: 'Test Email', + body: '

Test content

', + }, + }, + { + name: 'In-App Step', + type: StepTypeEnum.IN_APP, + controlValues: { + body: 'Test content', + }, + }, + { + name: 'SMS Step', + type: StepTypeEnum.SMS, + controlValues: { + body: 'Test SMS', + }, + }, + { + name: 'Chat Step', + type: StepTypeEnum.CHAT, + controlValues: { + body: 'Test Chat', + }, + }, + ], + }); + + workflowId = workflow.workflowId; + emailStepId = (workflow.steps[0] as any).id; + inAppStepId = (workflow.steps[1] as any).id; + smsStepId = (workflow.steps[2] as any).id; + chatStepId = (workflow.steps[3] as any).id; + }); + + it('simple translation keys replacement', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + greeting: 'Hello', + closing: 'Thank you', + 'email.subject': 'Welcome to Our Service', + 'email.body.title': 'Getting Started', + 'email.body.content': 'Thanks for joining', + }, + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: '{{t.email.subject}}', + body: '

{{t.email.body.title}}

{{t.greeting}}! {{t.email.body.content}}. {{t.closing}}!

', + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.subject).to.equal('Welcome to Our Service'); + expect(preview.body).to.include('Getting Started'); + expect(preview.body).to.include('Hello!'); + expect(preview.body).to.include('Thanks for joining'); + expect(preview.body).to.include('Thank you!'); + expect(preview.body).to.not.include('{{t.'); + }); + + describe('Locale Resolution and Fallback', () => { + it('should use subscriber locale for translation', async () => { + // Create translations for different locales + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + greeting: 'Hello', + }, + }); + + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'es_ES', + content: { + greeting: 'Hola', + }, + }); + + // Preview with Spanish locale + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.greeting}}

', + }, + previewPayload: { + subscriber: { + locale: 'es_ES', + }, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.include('Hola'); + expect(preview.body).to.not.include('Hello'); + }); + + it('should fallback to default locale when subscriber locale not available', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + greeting: 'Hello', + }, + }); + + // Preview with unsupported locale + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.greeting}}

', + }, + previewPayload: { + subscriber: { + locale: 'de_DE', // German not available + }, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.include('Hello'); // Falls back to en_US + }); + + it('should fallback to default locale when subscriber has no locale', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + greeting: 'Hello', + }, + }); + + // Preview without subscriber locale + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.greeting}}

', + }, + previewPayload: { + subscriber: {}, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.include('Hello'); // Falls back to en_US + }); + + it('should use per-key fallback when subscriber locale has partial translations', async () => { + // Create default locale with all keys + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + greeting: 'Hello', + farewell: 'Goodbye', + }, + }); + + // Create Spanish locale with only some keys + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'es_ES', + content: { + greeting: 'Hola', + // 'farewell' is missing in Spanish + }, + }); + + // Preview with Spanish locale using both keys + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.greeting}}, {{t.farewell}}

', + }, + previewPayload: { + subscriber: { + locale: 'es_ES', + }, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.include('Hola'); // Uses Spanish for available key + expect(preview.body).to.include('Goodbye'); // Falls back to English for missing key + expect(preview.body).to.not.include('Hello'); // Should not use English for available Spanish key + }); + }); + + describe('Liquid Variables in Translations', () => { + it('should process liquid variables within translated content', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + personalized: 'Hello {{payload.name}}!', + }, + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.personalized}}

', + }, + previewPayload: { + payload: { + name: 'John', + }, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.include('Hello John!'); + expect(preview.body).to.not.include('{{payload.name}}'); + }); + + it('should process liquid filters in translated content', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + uppercase: 'Welcome {{payload.name | upcase}}!', + lowercase: 'Email: {{payload.email | downcase}}', + capitalize: 'Hello {{payload.firstName | capitalize}}', + }, + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.uppercase}} {{t.lowercase}} {{t.capitalize}}

', + }, + previewPayload: { + payload: { + name: 'john', + email: 'JOHN@EXAMPLE.COM', + firstName: 'mary', + }, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.include('Welcome JOHN!'); + expect(preview.body).to.include('Email: john@example.com'); + expect(preview.body).to.include('Hello Mary'); + }); + + it('should handle nested object access in liquid variables', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + address: 'Shipping to {{payload.address.city}}, {{payload.address.country}}', + }, + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.address}}

', + }, + previewPayload: { + payload: { + address: { + city: 'New York', + country: 'USA', + }, + }, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.include('Shipping to New York, USA'); + }); + + it('should handle pluralize filter with translation keys inside translations', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + appleSingular: 'apple', + applePlural: 'apples', + itemSingular: 'item', + itemPlural: 'items', + suffix: ' in cart', + }, + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: "You have {{payload.appleCount | pluralize: 't.appleSingular', 't.applePlural', 'false'}} and {{payload.itemCount | pluralize: 't.itemSingular', 't.itemPlural', 'false' | append: 't.suffix'}}", + }, + previewPayload: { + payload: { + appleCount: 1, + itemCount: 5, + }, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.include('You have apple and items in cart'); + expect(preview.body).to.include('apple'); // Singular for count=1 + expect(preview.body).to.include('items'); // Plural for count=5 + expect(preview.body).to.not.include('apples'); // Should not use plural for count=1 + expect(preview.body).to.not.include('item '); // Should not use singular for count=5 + }); + + it('should render empty string for missing payload variables (consistent with non-translated content)', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + personalized: 'Hello {{payload.missingVar}}!', + withMultiple: 'First: {{payload.undefinedField}}, Second: {{payload.notInSchema}}', + }, + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.personalized}}

{{t.withMultiple}}

', + }, + previewPayload: { + payload: { + // missingVar, undefinedField, and notInSchema are not defined + }, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.include('Hello !'); // Missing variable renders as empty string + expect(preview.body).to.include('First: , Second: '); // Both missing render as empty strings + expect(preview.body).to.not.include('{{payload.'); // Variables should be processed + }); + }); + + describe('Layout Translations', () => { + it('should replace translation keys in layout content when used in workflow step', async () => { + // Create layout + const { result: layout } = await novuClient.layouts.create({ + layoutId: `layout-translation-${Date.now()}`, + name: 'Layout Translation Test', + source: LayoutCreationSourceEnum.DASHBOARD, + }); + + // Update layout with translation enabled and layout content + await novuClient.layouts.update( + { + name: 'Layout Translation Test', + isTranslationEnabled: true, + controlValues: { + email: { + body: ` + + Layout Translation Test + +
{{content}}
+
+

Footer: {{t.layout.footer}}

+
+ + + `, + editorType: 'html', + }, + }, + }, + layout.layoutId + ); + + // Create layout translations + await novuClient.translations.create({ + resourceId: layout.layoutId, + resourceType: LocalizationResourceEnum.LAYOUT, + locale: 'en_US', + content: { + 'layout.footer': '© 2024 Our Company', + }, + }); + + // Create workflow step that uses the layout + const { result: workflow } = await novuClient.workflows.create({ + name: 'Layout Translation Workflow', + workflowId: `layout-workflow-${Date.now()}`, + source: WorkflowCreationSourceEnum.EDITOR, + active: true, + isTranslationEnabled: true, + steps: [ + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: 'Test', + body: '

Workflow content

', + layoutId: layout.layoutId, + }, + }, + ], + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId: workflow.workflowId, + stepId: (workflow.steps[0] as any).id, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

Workflow content

', + layoutId: layout.layoutId, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.include('© 2024 Our Company'); + expect(preview.body).to.include('Workflow content'); + expect(preview.body).to.not.include('{{t.layout.footer}}'); + }); + }); + + describe('Different Channel Types', () => { + it('should replace translations in in-app notifications', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + 'inapp.subject': 'New Notification', + 'inapp.body': 'You have a new message from {{payload.sender}}', + }, + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: inAppStepId, + generatePreviewRequestDto: { + controlValues: { + subject: '{{t.inapp.subject}}', + body: '{{t.inapp.body}}', + }, + previewPayload: { + payload: { + sender: 'Admin', + }, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.subject).to.equal('New Notification'); + expect(preview.body).to.include('You have a new message from Admin'); + }); + + it('should replace translations in SMS messages', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + 'sms.message': 'Your code is {{payload.code}}', + }, + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: smsStepId, + generatePreviewRequestDto: { + controlValues: { + body: '{{t.sms.message}}', + }, + previewPayload: { + payload: { + code: '123456', + }, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.equal('Your code is 123456'); + }); + + it('should replace translations in chat messages', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + 'chat.message': 'New message: {{payload.message}}', + }, + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: chatStepId, + generatePreviewRequestDto: { + controlValues: { + body: '{{t.chat.message}}', + }, + previewPayload: { + payload: { + message: 'Hello from chat!', + }, + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.equal('New message: Hello from chat!'); + }); + }); + + describe('Escaped Characters in Translations', () => { + it('should handle translations with escaped quotes', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + quoted: 'Welcome to "Our Service" - You\'re all set!', + }, + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: '{{t.quoted}}', + body: '

Test

', + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.subject).to.include('"Our Service"'); + expect(preview.subject).to.include("You're all set!"); + expect(preview.subject).to.not.include('\\"'); + expect(preview.subject).to.not.include("\\'"); + }); + + it('should handle translations with newlines and special characters', async () => { + await novuClient.translations.create({ + resourceId: workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + multiline: 'Line 1\nLine 2\tTabbed content', + }, + }); + + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId, + stepId: emailStepId, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.multiline}}

', + }, + }, + }); + + const preview = result.result.preview as any; + expect(preview.body).to.include('Line 1'); + expect(preview.body).to.include('Line 2'); + expect(preview.body).to.not.include('\\n'); + expect(preview.body).to.not.include('\\t'); + }); + }); + + describe('Error Handling', () => { + /* + * Note: These tests use generatePreview instead of actual workflow delivery. + * PreviewUsecase gracefully handles translation errors by catching exceptions + * and returning an empty preview object ({}) for UI stability (questionable choice). + * An empty preview (where subject and body are undefined) indicates that a translation error occurred. + * + * TODO: To actually see the error messages from bridge execution (e.g., "Translation is not enabled + * for this resource", "Missing translation for key 'xyz'"), we should either: + * 1. Rework these tests to use actual workflow delivery (novuClient.trigger) and check execution + * details for bridge execution failures, OR + * 2. Rework generatePreview to return errors instead of silently returning empty preview objects. + * This would provide more detailed error information than empty preview objects. + */ + it('should return empty preview when translation keys used but translation not enabled for resource', async () => { + // Create workflow with translation explicitly disabled + const { result: workflow } = await novuClient.workflows.create({ + name: 'No Translation Workflow', + workflowId: `no-translation-${Date.now()}`, + source: WorkflowCreationSourceEnum.EDITOR, + active: true, + isTranslationEnabled: false, // Disabled + steps: [ + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: 'Test', + body: '

{{t.greeting}}

', // Using translation key when disabled + }, + }, + ], + }); + + // generatePreview catches translation errors and returns empty object + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId: workflow.workflowId, + stepId: (workflow.steps[0] as any).id, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.greeting}}

', + }, + }, + }); + + // Empty preview (undefined subject/body) indicates translation error occurred + const preview = result.result.preview as any; + expect(preview).to.be.an('object'); + expect(preview.subject).to.be.undefined; + expect(preview.body).to.be.undefined; + }); + + it('should return empty preview for missing translation key', async () => { + // Create workflow with translation enabled + const { result: workflow } = await novuClient.workflows.create({ + name: 'Missing Translation Key Workflow', + workflowId: `missing-key-${Date.now()}`, + source: WorkflowCreationSourceEnum.EDITOR, + active: true, + isTranslationEnabled: true, + steps: [ + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: 'Test', + body: '

{{t.missingKey}}

', // Key doesn't exist + }, + }, + ], + }); + + // Create translation with wrong key (missing 'missingKey') + await novuClient.translations.create({ + resourceId: workflow.workflowId, + resourceType: LocalizationResourceEnum.WORKFLOW, + locale: 'en_US', + content: { + existingKey: 'This exists', + }, + }); + + // generatePreview catches missing translation key errors and returns empty object + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId: workflow.workflowId, + stepId: (workflow.steps[0] as any).id, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.missingKey}}

', + }, + }, + }); + + // Empty preview (undefined subject/body) indicates missing translation key error + const preview = result.result.preview as any; + expect(preview).to.be.an('object'); + expect(preview.subject).to.be.undefined; + expect(preview.body).to.be.undefined; + }); + + it('should return empty preview when translations not created but translation keys used', async () => { + // Create workflow with translation enabled but no translations created + const { result: workflow } = await novuClient.workflows.create({ + name: 'No Translations Created', + workflowId: `no-translations-created-${Date.now()}`, + source: WorkflowCreationSourceEnum.EDITOR, + active: true, + isTranslationEnabled: true, // Enabled but no translations created + steps: [ + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: 'Test', + body: '

{{t.greeting}}

', // Translation key but no translations exist + }, + }, + ], + }); + + // generatePreview catches "no translations found" errors and returns empty object + const { result } = await novuClient.workflows.steps.generatePreview({ + workflowId: workflow.workflowId, + stepId: (workflow.steps[0] as any).id, + generatePreviewRequestDto: { + controlValues: { + subject: 'Test', + body: '

{{t.greeting}}

', + }, + }, + }); + + // Empty preview (undefined subject/body) indicates no translations found error + const preview = result.result.preview as any; + expect(preview).to.be.an('object'); + expect(preview.subject).to.be.undefined; + expect(preview.body).to.be.undefined; + }); + }); +}); + +describe('Translation Feature Access - V2 Workflows #novu-v2', async () => { + let session: UserSession; + let novuClient: Novu; + + it('should throw PaymentRequired error when organization lacks translation feature', async () => { + session = new UserSession(); + await session.initialize(); + + // Keep organization at FREE tier (no BUSINESS upgrade) + novuClient = initNovuClassSdkInternalAuth(session); + + // Attempt to create workflow with translation enabled on FREE tier + try { + await novuClient.workflows.create({ + name: 'Translation Test Workflow', + workflowId: `translation-free-tier-${Date.now()}`, + source: WorkflowCreationSourceEnum.EDITOR, + active: true, + isTranslationEnabled: true, // This should fail on FREE tier + steps: [ + { + name: 'Email Step', + type: StepTypeEnum.EMAIL, + controlValues: { + subject: 'Test Email', + body: '

Test content

', + }, + }, + ], + }); + + expect.fail('Should have thrown PaymentRequired error'); + } catch (error: any) { + expect(error.statusCode).to.equal(402); + expect(error.message).to.match(/payment required|not available on your plan/i); + } + }); +}); diff --git a/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts index 5ac7585ccc9..fc7c1eee24e 100644 --- a/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts @@ -629,67 +629,6 @@ describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview #novu-v expect(result.result.preview.body).to.contain('Hello, John!'); }); - it('should generate email preview when translations are not enabled', async () => { - const mailyContent = - '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Hello "},{"type":"variable","attrs":{"id":"subscriber.firstName","label":null,"fallback":null,"required":false,"aliasFor":null}},{"type":"text","text":", your order status: "},{"type":"variable","attrs":{"id":"payload.test","label":null,"fallback":null,"required":false,"aliasFor":null}},{"type":"text","text":"!"}]}]}'; - - const createWorkflowDto: CreateWorkflowDto = { - tags: [], - source: WorkflowCreationSourceEnum.Editor, - name: 'Email Without Translations Workflow', - workflowId: `email-no-translations-${randomUUID()}`, - description: 'Test workflow without translations - should render successfully without errors', - active: true, - payloadSchema: { - type: 'object', - properties: { - title: { - type: 'string', - }, - test: { - type: 'string', - }, - }, - }, - steps: [ - { - name: 'Email Step Without Translations', - type: StepTypeEnum.EMAIL, - controlValues: { - subject: 'Welcome {{subscriber.firstName}}', - body: mailyContent, - disableOutputSanitization: false, - }, - }, - ], - }; - const { result: workflow } = await novuClient.workflows.create(createWorkflowDto); - const stepId = workflow.steps[0].id; - const controlValues = { - subject: 'Welcome {{subscriber.firstName}}', - body: mailyContent, - disableOutputSanitization: false, - }; - const previewPayload: PreviewPayloadDto = { - subscriber: { - firstName: 'Jane', - }, - payload: { - test: 'confirmed', - }, - }; - - const { result } = await novuClient.workflows.steps.generatePreview({ - workflowId: workflow.id, - stepId, - generatePreviewRequestDto: { controlValues, previewPayload }, - }); - - expect(result.result.preview.subject).to.equal('Welcome Jane'); - expect(result.result.preview.body).to.contain('Hello Jane'); - expect(result.result.preview.body).to.contain('your order status: confirmed!'); - }); - it.skip('should generate preview for the email step with digest variables', async () => { const { workflowId, emailStepDatabaseId } = await createWorkflowWithEmailLookingAtDigestResult(); diff --git a/apps/dashboard/src/components/workflow-editor/in-app-preview.tsx b/apps/dashboard/src/components/workflow-editor/in-app-preview.tsx index 393b449e007..67241395c59 100644 --- a/apps/dashboard/src/components/workflow-editor/in-app-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/in-app-preview.tsx @@ -216,8 +216,16 @@ const Markdown = (props: MarkdownProps) => { return (

{tokens.map((token, index) => { - if (token.type === 'bold') { + if (token.type === 'boldItalic') { + return ( + + {token.content} + + ); + } else if (token.type === 'bold') { return {token.content}; + } else if (token.type === 'italic') { + return {token.content}; } else { return {token.content}; } diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-body.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-body.tsx index e25c063d186..6ef8ef88492 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-body.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-body.tsx @@ -17,7 +17,7 @@ function getFormMessage( return 'HTML entities detected. Consider disabling content sanitization for proper rendering'; } - const hints = ['Type {{ to access variables, or wrap text in ** for bold.']; + const hints = ['Type {{ to access variables, wrap text in ** for bold, or * for italic.']; if (isTranslationEnabled) { hints.push('Type {{t. to access translation keys.'); diff --git a/apps/dashboard/src/components/workflow-editor/workflow-schema-provider.tsx b/apps/dashboard/src/components/workflow-editor/workflow-schema-provider.tsx index 606aae330a0..1be23075729 100644 --- a/apps/dashboard/src/components/workflow-editor/workflow-schema-provider.tsx +++ b/apps/dashboard/src/components/workflow-editor/workflow-schema-provider.tsx @@ -1,5 +1,6 @@ import { type IEnvironment, type WorkflowResponseDto } from '@novu/shared'; import { createContext, ReactNode, useContext } from 'react'; +import { useParams } from 'react-router-dom'; import { useEnvironment } from '@/context/environment/hooks'; import { useIsPayloadSchemaEnabled } from '@/hooks/use-is-payload-schema-enabled'; import { type UseWorkflowSchemaManagerReturn, useWorkflowSchemaManager } from './use-workflow-schema-manager'; @@ -16,6 +17,7 @@ interface WorkflowSchemaProviderProps { } export function WorkflowSchemaProvider({ children }: WorkflowSchemaProviderProps) { + const { workflowSlug = '' } = useParams<{ workflowSlug?: string }>(); const { workflow } = useWorkflow(); const { currentEnvironment } = useEnvironment(); const isPayloadSchemaEnabled = useIsPayloadSchemaEnabled(); @@ -33,7 +35,7 @@ export function WorkflowSchemaProvider({ children }: WorkflowSchemaProviderProps }; return ( - + {children} ); diff --git a/enterprise/workers/scheduler/src/scheduler.ts b/enterprise/workers/scheduler/src/scheduler.ts index 7f220bd0922..5f9205644fe 100644 --- a/enterprise/workers/scheduler/src/scheduler.ts +++ b/enterprise/workers/scheduler/src/scheduler.ts @@ -54,14 +54,14 @@ export class Scheduler implements DurableObject { try { await this.executeJob(job); - await this.state.storage.delete(JOB_KEY); } catch (error) { console.error(`[Scheduler] Job ${job.id} execution failed:`, { jobId: job.id, mode: job.mode, error: error instanceof Error ? error.message : String(error), }); - await this.state.storage.delete(JOB_KEY); + } finally { + await Promise.all([this.state.storage.deleteAll(), this.state.storage.deleteAlarm()]); } } @@ -85,7 +85,6 @@ export class Scheduler implements DurableObject { }; await this.executeJob(job); - await this.state.storage.delete(JOB_KEY); return; } @@ -98,8 +97,7 @@ export class Scheduler implements DurableObject { data: request.data, }; - await this.state.storage.put(JOB_KEY, job); - await this.state.storage.setAlarm(request.scheduledFor); + await Promise.all([this.state.storage.put(JOB_KEY, job), this.state.storage.setAlarm(request.scheduledFor)]); console.log(`[Scheduler] Job ${request.jobId} scheduled for ${new Date(request.scheduledFor).toISOString()}`); } @@ -111,8 +109,7 @@ export class Scheduler implements DurableObject { return false; } - await this.state.storage.delete(JOB_KEY); - await this.state.storage.deleteAlarm(); + await Promise.all([this.state.storage.deleteAll(), this.state.storage.deleteAlarm()]); return true; } diff --git a/enterprise/workers/scheduler/wrangler.jsonc b/enterprise/workers/scheduler/wrangler.jsonc index aae928ce651..74462795bf7 100644 --- a/enterprise/workers/scheduler/wrangler.jsonc +++ b/enterprise/workers/scheduler/wrangler.jsonc @@ -9,7 +9,12 @@ "compatibility_date": "2025-11-18", "compatibility_flags": ["global_fetch_strictly_public"], "observability": { - "enabled": true + "traces": { + "enabled": true + }, + "logs": { + "enabled": true + } }, "logpush": true, "migrations": [ diff --git a/packages/js/src/ui/components/Notification/DefaultNotification.tsx b/packages/js/src/ui/components/Notification/DefaultNotification.tsx index 4bd3d94eda2..5b352211c33 100644 --- a/packages/js/src/ui/components/Notification/DefaultNotification.tsx +++ b/packages/js/src/ui/components/Notification/DefaultNotification.tsx @@ -212,6 +212,7 @@ export const DefaultNotification = (props: DefaultNotificationProps) => { appearanceKey="notificationSubject" class="nt-text-start nt-font-medium nt-whitespace-pre-wrap [word-break:break-word]" strongAppearanceKey="notificationSubject__strong" + emAppearanceKey="notificationSubject__em" context={{ notification: props.notification }} > {subject()} @@ -228,6 +229,7 @@ export const DefaultNotification = (props: DefaultNotificationProps) => { diff --git a/packages/js/src/ui/components/Renderer.tsx b/packages/js/src/ui/components/Renderer.tsx index 387ddd20969..bd0214c1923 100644 --- a/packages/js/src/ui/components/Renderer.tsx +++ b/packages/js/src/ui/components/Renderer.tsx @@ -1,6 +1,6 @@ // @ts-expect-error inline import esbuild syntax import css from 'directcss:../index.directcss'; -import { Accessor, For, Match, onCleanup, onMount, Switch } from 'solid-js'; +import { Accessor, createMemo, For, onCleanup, onMount, Show } from 'solid-js'; import { MountableElement, Portal } from 'solid-js/web'; import { Novu } from '../../novu'; import type { NovuOptions } from '../../types'; @@ -76,6 +76,78 @@ export type NovuComponentControls = { updateProps: (params: { element: MountableElement; props: unknown }) => void; }; +const InboxComponentsRenderer = (props: { + elements: MountableElement[]; + nodes: Map; +}) => { + return ( + 0}> + + + {(node) => { + const novuComponent = () => props.nodes.get(node)!; + let portalDivElement: HTMLDivElement; + const Component = novuComponents[novuComponent().name]; + + onMount(() => { + /* + ** return here if not ` or `` + ** since we only want to override some styles for those to work properly + ** due to the extra divs being introduced by the renderer/mounter + */ + if (!['Notifications', 'Preferences', 'InboxContent'].includes(novuComponent().name)) return; + + if (node instanceof HTMLElement) { + node.style.height = '100%'; + } + if (portalDivElement) { + portalDivElement.style.height = '100%'; + } + }); + + return ( + { + portalDivElement = el; + }} + > + + + + + ); + }} + + + + ); +}; + +const SubscriptionComponentsRenderer = (props: { + elements: MountableElement[]; + nodes: Map; +}) => { + return ( + 0}> + + {(node) => { + const novuComponent = () => props.nodes.get(node)!; + const Component = novuComponents[novuComponent().name]; + + return ( + + + + + + ); + }} + + + ); +}; + type RendererProps = { novuUI: NovuUI; appearance?: AllAppearance; @@ -92,7 +164,16 @@ type RendererProps = { }; export const Renderer = (props: RendererProps) => { - const nodes = () => [...props.nodes.keys()]; + const inboxComponents = createMemo(() => + [...props.nodes.entries()] + .filter(([_, node]) => !SUBSCRIPTION_COMPONENTS.includes(node.name)) + .map(([element, _]) => element) + ); + const subscriptionComponents = createMemo(() => + [...props.nodes.entries()] + .filter(([_, node]) => SUBSCRIPTION_COMPONENTS.includes(node.name)) + .map(([element, _]) => element) + ); onMount(() => { const id = NOVU_DEFAULT_CSS_ID; @@ -127,61 +208,8 @@ export const Renderer = (props: RendererProps) => { preferencesSort={props.preferencesSort} routerPush={props.routerPush} > - - {(node) => { - const novuComponent = () => props.nodes.get(node)!; - let portalDivElement: HTMLDivElement; - const Component = novuComponents[novuComponent().name]; - - onMount(() => { - /* - ** return here if not ` or `` - ** since we only want to override some styles for those to work properly - ** due to the extra divs being introduced by the renderer/mounter - */ - if (!['Notifications', 'Preferences', 'InboxContent'].includes(novuComponent().name)) return; - - if (node instanceof HTMLElement) { - node.style.height = '100%'; - } - if (portalDivElement) { - portalDivElement.style.height = '100%'; - } - }); - - return ( - - { - portalDivElement = el; - }} - > - - - - - - } - > - - { - portalDivElement = el; - }} - > - - - - - - - ); - }} - + + diff --git a/packages/js/src/ui/components/elements/Markdown.tsx b/packages/js/src/ui/components/elements/Markdown.tsx index 59298e73aed..8da0afe54fd 100644 --- a/packages/js/src/ui/components/elements/Markdown.tsx +++ b/packages/js/src/ui/components/elements/Markdown.tsx @@ -17,16 +17,40 @@ const Bold = (props: { children?: JSX.Element; appearanceKey?: AllAppearanceKey ); }; + +const Italic = (props: { children?: JSX.Element; appearanceKey?: AllAppearanceKey }) => { + const style = useStyle(); + + return ( + + {props.children} + + ); +}; + const Text = (props: { children?: JSX.Element }) => props.children; type MarkdownProps = JSX.HTMLAttributes & { appearanceKey: AllAppearanceKey; strongAppearanceKey: AllAppearanceKey; + emAppearanceKey?: AllAppearanceKey; children: string; context?: Record; }; const Markdown = (props: MarkdownProps) => { - const [local, rest] = splitProps(props, ['class', 'children', 'appearanceKey', 'strongAppearanceKey', 'context']); + const [local, rest] = splitProps(props, [ + 'class', + 'children', + 'appearanceKey', + 'strongAppearanceKey', + 'emAppearanceKey', + 'context', + ]); const style = useStyle(); const tokens = createMemo(() => parseMarkdownIntoTokens(local.children)); @@ -42,8 +66,16 @@ const Markdown = (props: MarkdownProps) => { > {(token) => { - if (token.type === 'bold') { + if (token.type === 'boldItalic') { + return ( + + {token.content} + + ); + } else if (token.type === 'bold') { return {token.content}; + } else if (token.type === 'italic') { + return {token.content}; } else { return {token.content}; } diff --git a/packages/js/src/ui/config/appearanceKeys.ts b/packages/js/src/ui/config/appearanceKeys.ts index 136b19dc22f..38908590f48 100644 --- a/packages/js/src/ui/config/appearanceKeys.ts +++ b/packages/js/src/ui/config/appearanceKeys.ts @@ -121,8 +121,10 @@ export const inboxAppearanceKeys = [ 'notificationDot', 'notificationSubject', 'notificationSubject__strong', + 'notificationSubject__em', 'notificationBody', 'notificationBody__strong', + 'notificationBody__em', 'notificationBodyContainer', 'notificationImage', 'notificationImageLoadingFallback', @@ -291,6 +293,7 @@ export const inboxAppearanceKeys = [ 'notificationSnoozedUntil__icon', // Text formatting 'strong', + 'em', ] as const; export const subscriptionAppearanceKeys = [ diff --git a/packages/js/src/ui/internal/parseMarkdown.tsx b/packages/js/src/ui/internal/parseMarkdown.tsx index a5056d19796..3a28b01b59d 100644 --- a/packages/js/src/ui/internal/parseMarkdown.tsx +++ b/packages/js/src/ui/internal/parseMarkdown.tsx @@ -1,35 +1,55 @@ export interface Token { - type: 'bold' | 'text'; + type: 'bold' | 'italic' | 'boldItalic' | 'text'; content: string; } +function getTokenType(isBold: boolean, isItalic: boolean): Token['type'] { + if (isBold && isItalic) return 'boldItalic'; + if (isBold) return 'bold'; + if (isItalic) return 'italic'; + + return 'text'; +} + export const parseMarkdownIntoTokens = (text: string): Token[] => { const tokens: Token[] = []; let buffer = ''; - let inBold = false; + let isBold = false; + let isItalic = false; + let lastDoubleAsteriskEnd = -2; for (let i = 0; i < text.length; i += 1) { - // Check if it's an escaped character if (text[i] === '\\' && text[i + 1] === '*') { buffer += '*'; i += 1; - } - // Check for bold marker ** - else if (text[i] === '*' && text[i + 1] === '*') { + } else if (text[i] === '*' && text[i + 1] === '*') { if (buffer) { - tokens.push({ type: inBold ? 'bold' : 'text', content: buffer }); + tokens.push({ type: getTokenType(isBold, isItalic), content: buffer }); buffer = ''; } - inBold = !inBold; + isBold = !isBold; + lastDoubleAsteriskEnd = i + 1; i += 1; + } else if (text[i] === '*') { + const prevIsStar = i > 0 && text[i - 1] === '*'; + const prevWasConsumed = lastDoubleAsteriskEnd === i - 1; + + if (prevIsStar && !prevWasConsumed) { + buffer += text[i]; + } else { + if (buffer) { + tokens.push({ type: getTokenType(isBold, isItalic), content: buffer }); + buffer = ''; + } + isItalic = !isItalic; + } } else { buffer += text[i]; } } - // Push any remaining buffered text as a token if (buffer) { - tokens.push({ type: inBold ? 'bold' : 'text', content: buffer }); + tokens.push({ type: getTokenType(isBold, isItalic), content: buffer }); } return tokens;