diff --git a/.cspell.json b/.cspell.json index 8263f1a4114..0ae84732f00 100644 --- a/.cspell.json +++ b/.cspell.json @@ -12,6 +12,7 @@ "sdkerror", "africas", "africastalking", + "preferencechannels", "Aland", "alanturing", "alexjoverm", diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ac586cbe668..459b5e5adfd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,7 +18,7 @@ // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [4200, 3000, 27017], - "onCreateCommand": "npm run setup:project -- --exclude=@novu/api,@novu/worker,@novu/web,@novu/widget", + "onCreateCommand": "npm run setup:project -- --exclude=@novu/api-service,@novu/worker,@novu/web,@novu/widget", // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "node", diff --git a/.github/actions/run-api/action.yml b/.github/actions/run-api/action.yml index 93eaedc7a4d..a456ffc1771 100644 --- a/.github/actions/run-api/action.yml +++ b/.github/actions/run-api/action.yml @@ -15,7 +15,7 @@ runs: - uses: mansagroup/nrwl-nx-action@v3 with: targets: build - projects: '@novu/api' + projects: '@novu/api-service' - name: Start API shell: bash diff --git a/.github/actions/validate-openapi/action.yml b/.github/actions/validate-openapi/action.yml index e5726ea88b4..0ba09170e55 100644 --- a/.github/actions/validate-openapi/action.yml +++ b/.github/actions/validate-openapi/action.yml @@ -11,7 +11,7 @@ runs: PORT: '1336' with: targets: lint:openapi - projects: '@novu/api' + projects: '@novu/api-service' - name: Kill port for api 1336 for unit tests shell: bash diff --git a/.github/labeler.yml b/.github/labeler.yml index 5211a4d8bcf..c653727ac91 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,4 +1,4 @@ -'@novu/api': +'@novu/api-service': - apps/api/**/* '@novu/worker': - apps/worker/**/* diff --git a/.github/workflows/jarvis.yml b/.github/workflows/jarvis.yml index b5144c331b4..4c74bba6854 100644 --- a/.github/workflows/jarvis.yml +++ b/.github/workflows/jarvis.yml @@ -5,7 +5,7 @@ on: - labeled jobs: add-comment: - if: github.event.label.name == '@novu/api' + if: github.event.label.name == '@novu/api-service' runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 13a0bb0a328..6549d4c4025 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -205,7 +205,7 @@ jobs: - uses: ./.github/actions/setup-project with: # Don't run redis and etc... for other unit tests - slim: ${{ !contains(matrix.projectName, '@novu/api') && !contains(matrix.projectName, '@novu/worker') && !contains(matrix.projectName, '@novu/ws') && !contains(matrix.projectName, '@novu/inbound-mail')}} + slim: ${{ !contains(matrix.projectName, '@novu/api-service') && !contains(matrix.projectName, '@novu/worker') && !contains(matrix.projectName, '@novu/ws') && !contains(matrix.projectName, '@novu/inbound-mail')}} - uses: ./.github/actions/setup-redis-cluster - uses: mansagroup/nrwl-nx-action@v3 name: Lint and build and test @@ -244,8 +244,8 @@ jobs: uses: ./.github/workflows/reusable-api-e2e.yml with: ee: ${{ contains (matrix.name,'-ee') }} - test-e2e-affected: ${{ contains(fromJson(needs.get-affected.outputs.test-e2e), '@novu/api') || contains(fromJson(needs.get-affected.outputs.test-e2e), '@novu/worker') }} - test-e2e-ee-affected: ${{ contains(fromJson(needs.get-affected.outputs.test-e2e-ee), '@novu/api') || contains(fromJson(needs.get-affected.outputs.test-e2e-ee), '@novu/worker') }} + test-e2e-affected: ${{ contains(fromJson(needs.get-affected.outputs.test-e2e), '@novu/api-service') || contains(fromJson(needs.get-affected.outputs.test-e2e), '@novu/worker') }} + test-e2e-ee-affected: ${{ contains(fromJson(needs.get-affected.outputs.test-e2e-ee), '@novu/api-service') || contains(fromJson(needs.get-affected.outputs.test-e2e-ee), '@novu/worker') }} job-name: ${{ matrix.name }} test-unit: false secrets: inherit diff --git a/.github/workflows/reusable-dashboard-e2e.yml b/.github/workflows/reusable-dashboard-e2e.yml index d0572ecb838..432e32e53cb 100644 --- a/.github/workflows/reusable-dashboard-e2e.yml +++ b/.github/workflows/reusable-dashboard-e2e.yml @@ -71,7 +71,7 @@ jobs: - uses: mansagroup/nrwl-nx-action@v3 with: targets: build - projects: '@novu/dashboard,@novu/api,@novu/worker' + projects: '@novu/dashboard,@novu/api-service,@novu/worker' args: --skip-nx-cache - uses: ./.github/actions/start-localstack diff --git a/.github/workflows/reusable-web-e2e.yml b/.github/workflows/reusable-web-e2e.yml index b18a7557df2..db7be206abd 100644 --- a/.github/workflows/reusable-web-e2e.yml +++ b/.github/workflows/reusable-web-e2e.yml @@ -71,7 +71,7 @@ jobs: - uses: mansagroup/nrwl-nx-action@v3 with: targets: build - projects: '@novu/web,@novu/api,@novu/worker' + projects: '@novu/web,@novu/api-service,@novu/worker' args: --skip-nx-cache - uses: ./.github/actions/start-localstack diff --git a/.github/workflows/reusable-widget-e2e.yml b/.github/workflows/reusable-widget-e2e.yml index 16e004b2856..d17b2a378c2 100644 --- a/.github/workflows/reusable-widget-e2e.yml +++ b/.github/workflows/reusable-widget-e2e.yml @@ -58,7 +58,7 @@ jobs: with: targets: build args: --skip-nx-cache - projects: '@novu/widget,@novu/embed,@novu/api,@novu/worker,@novu/ws' + projects: '@novu/widget,@novu/embed,@novu/api-service,@novu/worker,@novu/ws' - uses: ./.github/actions/run-backend with: diff --git a/.source b/.source index 047015b573f..dfa9f193b6a 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 047015b573f6767edc0e40628b39e4023dded98f +Subproject commit dfa9f193b6a312a84cc562f68080a526d401bc21 diff --git a/apps/api/README.md b/apps/api/README.md index bee04293f68..0771a59ac1d 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -7,7 +7,7 @@ -# @novu/api +# @novu/api-service A RESTful API for accessing the Novu platform, built using [NestJS](https://nestjs.com/). diff --git a/apps/api/jarvis-api-intro.md b/apps/api/jarvis-api-intro.md index 5ec0272cb3b..1aba8e1edd9 100644 --- a/apps/api/jarvis-api-intro.md +++ b/apps/api/jarvis-api-intro.md @@ -3,7 +3,7 @@ Hi, I'm Jarvis 🤖 I'm a bot built to help you with your contribution to Novu. I will add instructions and guides on how to run the subset of the Novu platform associated to this issue and make your first contribution. -This issue was tagged as related to `@novu/api` and the related code is located at the `apps/api` folder, here is how I can help you: +This issue was tagged as related to `@novu/api-service` and the related code is located at the `apps/api` folder, here is how I can help you:
First time contributing to Novu? @@ -17,7 +17,7 @@ If that's the first time you want to contribute to Novu here are a few simple st
- Run and test `@novu/api` locally + Run and test `@novu/api-service` locally ### Run API in watch mode diff --git a/apps/api/package.json b/apps/api/package.json index 5f90527eb27..f6e3b3e67f6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,5 +1,5 @@ { - "name": "@novu/api", + "name": "@novu/api-service", "version": "2.1.1", "description": "description", "author": "", @@ -32,7 +32,7 @@ "dependencies": { "@godaddy/terminus": "^4.12.1", "@google-cloud/storage": "^6.2.3", - "@maily-to/render": "^0.0.16", + "@maily-to/render": "^0.0.17", "@nestjs/axios": "3.0.3", "@nestjs/common": "10.4.1", "@nestjs/core": "10.4.1", @@ -42,7 +42,7 @@ "@nestjs/swagger": "7.4.0", "@nestjs/terminus": "10.2.3", "@nestjs/throttler": "6.2.1", - "@novu/api": "0.0.1-alpha.109", + "@novu/api": "0.0.1-alpha.149", "@novu/application-generic": "workspace:*", "@novu/dal": "workspace:*", "@novu/framework": "workspace:*", @@ -101,6 +101,8 @@ "twilio": "^4.14.1", "uuid": "^8.3.2", "zod": "^3.23.8", + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", "zod-to-json-schema": "^3.23.3" }, "devDependencies": { diff --git a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts index dcf3f1e3eec..e803139424c 100644 --- a/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/construct-framework-workflow/construct-framework-workflow.usecase.ts @@ -2,7 +2,7 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { workflow } from '@novu/framework/express'; import { ActionStep, ChannelStep, JsonSchema, Step, StepOptions, StepOutput, Workflow } from '@novu/framework/internal'; import { NotificationStepEntity, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; -import { StepTypeEnum } from '@novu/shared'; +import { JSONSchemaDefinition, JSONSchemaDto, StepTypeEnum } from '@novu/shared'; import { Instrument, InstrumentUsecase, PinoLogger } from '@novu/application-generic'; import { AdditionalOperation, RulesLogic } from 'json-logic-js'; import _ from 'lodash'; @@ -18,6 +18,7 @@ import { import { DelayOutputRendererUsecase } from '../output-renderers/delay-output-renderer.usecase'; import { DigestOutputRendererUsecase } from '../output-renderers/digest-output-renderer.usecase'; import { evaluateRules } from '../../../shared/services/query-parser/query-parser.service'; +import { isMatchingJsonSchema } from '../../../workflows-v2/util/jsonToSchema'; const LOG_CONTEXT = 'ConstructFrameworkWorkflow'; @@ -183,8 +184,25 @@ export class ConstructFrameworkWorkflow { staticStep: NotificationStepEntity, fullPayloadForRender: FullPayloadForRender ): Required[2]> { + const stepOptions = this.constructCommonStepOptions(staticStep, fullPayloadForRender); + + let controlSchema = stepOptions.controlSchema as JSONSchemaDefinition; + const stepType = staticStep.template!.type; + + /* + * because of the known AJV issue with anyOf, we need to find the first schema that matches the control values + * ref: https://ajv.js.org/guide/modifying-data.html#assigning-defaults + */ + if (stepType === StepTypeEnum.DIGEST && typeof controlSchema === 'object' && controlSchema.anyOf) { + const fistSchemaMatch = controlSchema.anyOf.find((item) => { + return isMatchingJsonSchema(item, staticStep.controlVariables); + }); + controlSchema = fistSchemaMatch ?? controlSchema.anyOf[0]; + } + return { - ...this.constructCommonStepOptions(staticStep, fullPayloadForRender), + ...stepOptions, + controlSchema: controlSchema as JsonSchema, }; } diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/delay-output-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/delay-output-renderer.usecase.ts index 67138cd0170..0d8b37bde98 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/delay-output-renderer.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/delay-output-renderer.usecase.ts @@ -2,21 +2,18 @@ import { Injectable } from '@nestjs/common'; import { DelayRenderOutput } from '@novu/shared'; import { InstrumentUsecase } from '@novu/application-generic'; import { RenderCommand } from './render-command'; -import { - DelayTimeControlType, - DelayTimeControlZodSchema, -} from '../../../workflows-v2/shared/schemas/delay-control.schema'; +import { delayControlZodSchema, DelayControlType } from '../../../workflows-v2/shared/schemas/delay-control.schema'; @Injectable() export class DelayOutputRendererUsecase { @InstrumentUsecase() execute(renderCommand: RenderCommand): DelayRenderOutput { - const delayTimeControlType: DelayTimeControlType = DelayTimeControlZodSchema.parse(renderCommand.controlValues); + const delayControlType: DelayControlType = delayControlZodSchema.parse(renderCommand.controlValues); return { - amount: delayTimeControlType.amount as number, - type: delayTimeControlType.type, - unit: delayTimeControlType.unit, + amount: delayControlType.amount as number, + type: delayControlType.type, + unit: delayControlType.unit, }; } } diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts index 40db7bdc1a3..d2944007364 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/digest-output-renderer.usecase.ts @@ -4,7 +4,7 @@ import { InstrumentUsecase } from '@novu/application-generic'; import { RenderCommand } from './render-command'; import { DigestControlSchemaType, - DigestControlZodSchema, + digestControlZodSchema, isDigestRegularControl, isDigestTimedControl, } from '../../../workflows-v2/shared/schemas/digest-control.schema'; @@ -13,23 +13,16 @@ import { export class DigestOutputRendererUsecase { @InstrumentUsecase() execute(renderCommand: RenderCommand): DigestRenderOutput { - const parse: DigestControlSchemaType = DigestControlZodSchema.parse(renderCommand.controlValues); - if ( - isDigestRegularControl(parse) && - parse.amount && - parse.unit && - parse.lookBackWindow && - parse.lookBackWindow.amount && - parse.lookBackWindow.unit - ) { + const parse: DigestControlSchemaType = digestControlZodSchema.parse(renderCommand.controlValues); + if (isDigestRegularControl(parse) && parse.amount && parse.unit) { return { amount: parse.amount as number, unit: parse.unit, digestKey: parse.digestKey, - lookBackWindow: { + ...(parse.lookBackWindow && { amount: parse.lookBackWindow.amount, unit: parse.lookBackWindow.unit, - }, + }), }; } if (isDigestTimedControl(parse) && parse.cron) { diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts index 433f131b242..0057a2f63fa 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/hydrate-email-schema.usecase.ts @@ -15,7 +15,9 @@ export class HydrateEmailSchemaUseCase { nestedForPlaceholders: {}, regularPlaceholdersToDefaultValue: {}, }; - const emailEditorSchema: TipTapNode = TipTapSchema.parse(JSON.parse(command.emailEditor)); + + // TODO: Aligned Zod inferred type and TipTapNode to remove the need of a type assertion + const emailEditorSchema: TipTapNode = TipTapSchema.parse(JSON.parse(command.emailEditor)) as TipTapNode; if (emailEditorSchema.content) { this.transformContentInPlace(emailEditorSchema.content, command.fullPayloadForRender, placeholderAggregation); } @@ -117,10 +119,8 @@ export class HydrateEmailSchemaUseCase { node, placeholderAggregation: PlaceholderAggregation ) { - const { fallback } = node.attrs; - const variableName = node.attrs.id; - const buildLiquidJSDefault = (mailyFallback: string) => (mailyFallback ? ` | default: '${mailyFallback}'` : ''); - const finalValue = `{{ ${variableName} ${buildLiquidJSDefault(fallback)} }}`; + const { fallback, id: variableName } = node.attrs; + const finalValue = buildLiquidJSDefault(variableName, fallback); placeholderAggregation.regularPlaceholdersToDefaultValue[`{{${node.attrs.id}}}`] = finalValue; @@ -228,9 +228,24 @@ export class HydrateEmailSchemaUseCase { } } -export const TipTapSchema = z.object({ - type: z.string().optional(), - content: z.array(z.lazy(() => TipTapSchema)).optional(), - text: z.string().optional(), - attrs: z.record(z.unknown()).optional(), -}); +export const TipTapSchema = z + .object({ + type: z.string().optional(), + content: z.array(z.lazy(() => TipTapSchema)).optional(), + text: z.string().optional(), + marks: z + .array( + z + .object({ + type: z.string(), + attrs: z.record(z.any()).optional(), + }) + .passthrough() + ) + .optional(), + attrs: z.record(z.unknown()).optional(), + }) + .passthrough(); + +const buildLiquidJSDefault = (variableName: string, fallback?: string) => + `{{ ${variableName}${fallback ? ` | default: '${fallback}'` : ''} }}`; diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer.usecase.ts index a65b307bb86..71414a0dc22 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/in-app-output-renderer.usecase.ts @@ -3,19 +3,19 @@ import { InAppRenderOutput, RedirectTargetEnum } from '@novu/shared'; import { Injectable } from '@nestjs/common'; import { Instrument, InstrumentUsecase } from '@novu/application-generic'; import { RenderCommand } from './render-command'; +import { isValidUrlForActionButton } from '../../../workflows-v2/util/url-utils'; import { InAppActionType, InAppControlType, - InAppControlZodSchema, + inAppControlZodSchema, InAppRedirectType, -} from '../../../workflows-v2/shared'; -import { isValidUrlForActionButton } from '../../../workflows-v2/util/url-utils'; +} from '../../../workflows-v2/shared/schemas/in-app-control.schema'; @Injectable() export class InAppOutputRendererUsecase { @InstrumentUsecase() execute(renderCommand: RenderCommand): InAppRenderOutput { - const inApp: InAppControlType = InAppControlZodSchema.parse(renderCommand.controlValues); + const inApp: InAppControlType = inAppControlZodSchema.parse(renderCommand.controlValues); if (!inApp) { throw new Error('Invalid in-app control value data'); } diff --git a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts index ccaf5a886a0..729b476a901 100644 --- a/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts +++ b/apps/api/src/app/environments-v1/usecases/output-renderers/render-email-output.usecase.ts @@ -1,12 +1,14 @@ -import { EmailRenderOutput, TipTapNode } from '@novu/shared'; -import { Injectable } from '@nestjs/common'; import { render as mailyRender } from '@maily-to/render'; -import { Instrument, InstrumentUsecase } from '@novu/application-generic'; import isEmpty from 'lodash/isEmpty'; +import { Injectable } from '@nestjs/common'; import { Liquid } from 'liquidjs'; + +import { EmailRenderOutput, TipTapNode } from '@novu/shared'; +import { Instrument, InstrumentUsecase } from '@novu/application-generic'; + import { FullPayloadForRender, RenderCommand } from './render-command'; import { ExpandEmailEditorSchemaUsecase } from './expand-email-editor-schema.usecase'; -import { emailStepControlZodSchema } from '../../../workflows-v2/shared'; +import { emailControlZodSchema } from '../../../workflows-v2/shared/schemas/email-control.schema'; export class RenderEmailOutputCommand extends RenderCommand {} @@ -16,13 +18,13 @@ export class RenderEmailOutputUsecase { @InstrumentUsecase() async execute(renderCommand: RenderEmailOutputCommand): Promise { - const { body, subject } = emailStepControlZodSchema.parse(renderCommand.controlValues); + const { body, subject } = emailControlZodSchema.parse(renderCommand.controlValues); if (isEmpty(body)) { return { subject, body: '' }; } - const expandedMailyContent = this.transformForAndShowLogic(body, renderCommand.fullPayloadForRender); + const expandedMailyContent = this.transformMailyDynamicBlocks(body, renderCommand.fullPayloadForRender); const parsedTipTap = await this.parseTipTapNodeByLiquid(expandedMailyContent, renderCommand); const renderedHtml = await this.renderEmail(parsedTipTap); @@ -30,11 +32,15 @@ export class RenderEmailOutputUsecase { } private async parseTipTapNodeByLiquid( - value: TipTapNode, + tiptapNode: TipTapNode, renderCommand: RenderEmailOutputCommand ): Promise { - const client = new Liquid(); - const templateString = client.parse(JSON.stringify(value)); + const client = new Liquid({ + outputEscape: (output) => { + return stringifyDataStructureWithSingleQuotes(output); + }, + }); + const templateString = client.parse(JSON.stringify(tiptapNode)); const parsedTipTap = await client.render(templateString, { payload: renderCommand.fullPayloadForRender.payload, subscriber: renderCommand.fullPayloadForRender.subscriber, @@ -50,7 +56,19 @@ export class RenderEmailOutputUsecase { } @Instrument() - private transformForAndShowLogic(body: string, fullPayloadForRender: FullPayloadForRender) { + private transformMailyDynamicBlocks(body: string, fullPayloadForRender: FullPayloadForRender) { return this.expandEmailEditorSchemaUseCase.execute({ emailEditorJson: body, fullPayloadForRender }); } } + +export const stringifyDataStructureWithSingleQuotes = (value: unknown, spaces: number = 0): string => { + if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { + const valueStringified = JSON.stringify(value, null, spaces); + const valueSingleQuotes = valueStringified.replace(/"/g, "'"); + const valueEscapedNewLines = valueSingleQuotes.replace(/\n/g, '\\n'); + + return valueEscapedNewLines; + } else { + return String(value); + } +}; diff --git a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts index eb51b367233..7bf69ec9cad 100644 --- a/apps/api/src/app/workflows-v2/generate-preview.e2e.ts +++ b/apps/api/src/app/workflows-v2/generate-preview.e2e.ts @@ -18,8 +18,8 @@ import { } from '@novu/shared'; import { buildCreateWorkflowDto } from './workflow.controller.e2e'; import { forSnippet, fullCodeSnippet } from './maily-test-data'; -import { InAppControlType } from './shared'; -import { EmailStepControlType } from './shared/schemas/email-control.schema'; +import { InAppControlType } from './shared/schemas/in-app-control.schema'; +import { EmailControlType } from './shared/schemas/email-control.schema'; const SUBJECT_TEST_PAYLOAD = '{{payload.subject.test.payload}}'; const PLACEHOLDER_SUBJECT_INAPP = '{{payload.subject}}'; @@ -421,7 +421,7 @@ describe('Generate Preview', () => { channelTypes.forEach(({ type, description }) => { // TODO: We need to get back to the drawing board on this one to make the preview action of the framework more forgiving - it(`[${type}] catches the 400 error returned by the Bridge Preview action`, async () => { + it(`[${type}] will generate gracefully the preview if the control values are missing`, async () => { const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(workflowsClient, type); const requestDto = buildDtoWithMissingControlValues(type, stepId); @@ -433,7 +433,7 @@ describe('Generate Preview', () => { description ); - expect(previewResponseDto.result).to.eql({ preview: {} }); + expect(previewResponseDto.result).to.not.eql({ preview: {} }); }); }); }); @@ -514,13 +514,13 @@ function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId?: string): Generat }; } -function buildEmailControlValuesPayload(stepId?: string): EmailStepControlType { +function buildEmailControlValuesPayload(stepId?: string): EmailControlType { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, body: JSON.stringify(fullCodeSnippet(stepId)), }; } -function buildSimpleForEmail(): EmailStepControlType { +function buildSimpleForEmail(): EmailControlType { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, body: JSON.stringify(forSnippet), diff --git a/apps/api/src/app/workflows-v2/shared/index.ts b/apps/api/src/app/workflows-v2/shared/index.ts index 8693e9de430..5abd43fc690 100644 --- a/apps/api/src/app/workflows-v2/shared/index.ts +++ b/apps/api/src/app/workflows-v2/shared/index.ts @@ -1,3 +1,2 @@ export * from './step-type-to-control.mapper'; export * from './map-step-type-to-result.mapper'; -export * from './schemas'; diff --git a/apps/api/src/app/workflows-v2/shared/sanitize-control-values.ts b/apps/api/src/app/workflows-v2/shared/sanitize-control-values.ts new file mode 100644 index 00000000000..79a427df91b --- /dev/null +++ b/apps/api/src/app/workflows-v2/shared/sanitize-control-values.ts @@ -0,0 +1,249 @@ +import { StepTypeEnum, TimeUnitEnum } from '@novu/shared'; +import { isEmpty } from 'lodash'; +import { SmsControlType } from './schemas/sms-control.schema'; +import { ChatControlType } from './schemas/chat-control.schema'; +import { DelayControlType } from './schemas/delay-control.schema'; +import { + DigestControlSchemaType, + DigestRegularControlType, + DigestTimedControlType, + isDigestRegularControl, + isDigestTimedControl, +} from './schemas/digest-control.schema'; +import { PushControlType } from './schemas/push-control.schema'; +import { InAppControlType } from './schemas/in-app-control.schema'; +import { EmailControlType } from './schemas/email-control.schema'; + +const EMPTY_TIP_TAP_OBJECT = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign: 'left' }, + content: [{ type: 'text', text: ' ' }], + }, + ], +}); +const WHITESPACE = ' '; + +type Redirect = { + url: string; + target: '_self' | '_blank' | '_parent' | '_top' | '_unfencedTop'; +}; + +type Action = { + label?: string; + redirect?: Redirect; +}; + +type LookBackWindow = { + amount: number; + unit: string; +}; + +function sanitizeRedirect(redirect: Redirect | undefined) { + if (!redirect?.url || !redirect?.target) { + return undefined; + } + + return { + url: redirect.url || 'https://example.com', + target: redirect.target || '_self', + }; +} + +function sanitizeAction(action: Action) { + if (!action?.label) { + return undefined; + } + + return { + label: action.label, + redirect: sanitizeRedirect(action.redirect), + }; +} + +function sanitizeInApp(controlValues: InAppControlType) { + const normalized: InAppControlType = { + subject: controlValues.subject || undefined, + body: isEmpty(controlValues.body) ? WHITESPACE : controlValues.body, + avatar: controlValues.avatar || undefined, + primaryAction: null, + secondaryAction: null, + redirect: null, + data: controlValues.data || undefined, + skip: controlValues.skip || undefined, + }; + + if (controlValues.primaryAction) { + normalized.primaryAction = sanitizeAction(controlValues.primaryAction as Action); + } + + if (controlValues.secondaryAction) { + normalized.secondaryAction = sanitizeAction(controlValues.secondaryAction as Action); + } + + if (controlValues.redirect) { + normalized.redirect = sanitizeRedirect(controlValues.redirect as Redirect); + } + + return filterNullishValues(normalized); +} + +function sanitizeEmail(controlValues: EmailControlType) { + const emailControls: EmailControlType = { + subject: controlValues.subject || '', + body: controlValues.body || EMPTY_TIP_TAP_OBJECT, + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(emailControls); +} + +function sanitizeSms(controlValues: SmsControlType) { + const mappedValues: SmsControlType = { + body: controlValues.body || '', + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(mappedValues); +} + +function sanitizePush(controlValues: PushControlType) { + const mappedValues: PushControlType = { + subject: controlValues.subject || '', + body: controlValues.body || '', + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(mappedValues); +} + +function sanitizeChat(controlValues: ChatControlType) { + const mappedValues: ChatControlType = { + body: controlValues.body || '', + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(mappedValues); +} + +function sanitizeDigest(controlValues: DigestControlSchemaType) { + if (isDigestTimedControl(controlValues)) { + const mappedValues: DigestTimedControlType = { + cron: controlValues.cron || '', + digestKey: controlValues.digestKey || '', + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(mappedValues); + } + + if (isDigestRegularControl(controlValues)) { + const mappedValues: DigestRegularControlType = { + amount: controlValues.amount || 0, + unit: controlValues.unit || TimeUnitEnum.SECONDS, + digestKey: controlValues.digestKey || '', + skip: controlValues.skip || undefined, + lookBackWindow: controlValues.lookBackWindow + ? { + amount: (controlValues.lookBackWindow as LookBackWindow).amount || 0, + unit: ((controlValues.lookBackWindow as LookBackWindow).unit as TimeUnitEnum) || TimeUnitEnum.SECONDS, + } + : undefined, + }; + + return filterNullishValues(mappedValues); + } + + const anyControlValues = controlValues as Record; + + return filterNullishValues({ + amount: anyControlValues.amount || 0, + unit: anyControlValues.unit || TimeUnitEnum.SECONDS, + digestKey: anyControlValues.digestKey || '', + skip: anyControlValues.skip || undefined, + lookBackWindow: anyControlValues.lookBackWindow + ? { + amount: (anyControlValues.lookBackWindow as LookBackWindow).amount || 0, + unit: ((anyControlValues.lookBackWindow as LookBackWindow).unit as TimeUnitEnum) || TimeUnitEnum.SECONDS, + } + : undefined, + }); +} + +function sanitizeDelay(controlValues: DelayControlType) { + const mappedValues: DelayControlType = { + type: controlValues.type || 'regular', + amount: controlValues.amount || 0, + unit: controlValues.unit || TimeUnitEnum.SECONDS, + skip: controlValues.skip || undefined, + }; + + return filterNullishValues(mappedValues); +} + +function filterNullishValues>(obj: T): T { + if (typeof obj === 'object' && obj !== null) { + return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== null && value !== undefined)) as T; + } + + return obj; +} + +/** + * Sanitizes control values received from client-side forms into a clean minimal object. + * This function processes potentially invalid form data that may contain default/placeholder values + * and transforms it into a standardized format suitable for preview generation. + * + * @example + * // Input from form with default values: + * { + * subject: "Hello", + * body: null, + * unusedField: "test" + * } + * + * // Normalized output: + * { + * subject: "Hello", + * body: " " + * } + * + */ +export function sanitizeControlValues( + controlValues: Record, + stepType: StepTypeEnum +): Record | null { + if (!controlValues) { + return null; + } + let normalizedValues: Record; + switch (stepType) { + case StepTypeEnum.IN_APP: + normalizedValues = sanitizeInApp(controlValues as InAppControlType); + break; + case StepTypeEnum.EMAIL: + normalizedValues = sanitizeEmail(controlValues as EmailControlType); + break; + case StepTypeEnum.SMS: + normalizedValues = sanitizeSms(controlValues as SmsControlType); + break; + case StepTypeEnum.PUSH: + normalizedValues = sanitizePush(controlValues as PushControlType); + break; + case StepTypeEnum.CHAT: + normalizedValues = sanitizeChat(controlValues as ChatControlType); + break; + case StepTypeEnum.DIGEST: + normalizedValues = sanitizeDigest(controlValues as DigestControlSchemaType); + break; + case StepTypeEnum.DELAY: + normalizedValues = sanitizeDelay(controlValues as DelayControlType); + break; + default: + normalizedValues = filterNullishValues(controlValues); + } + + return normalizedValues; +} diff --git a/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts index 4855b30e463..07ad1dcef2f 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/chat-control.schema.ts @@ -2,29 +2,24 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; -export const ChatStepControlZodSchema = z +export const chatControlZodSchema = z .object({ - skip: skipControl.schema, + skip: z.object({}).catchall(z.unknown()).optional(), body: z.string(), }) .strict(); -export type ChatStepControlType = z.infer; +export type ChatControlType = z.infer; -export const chatStepControlSchema = zodToJsonSchema(ChatStepControlZodSchema) as JSONSchemaDto; -export const chatStepUiSchema: UiSchema = { +export const chatControlSchema = zodToJsonSchema(chatControlZodSchema) as JSONSchemaDto; +export const chatUiSchema: UiSchema = { group: UiSchemaGroupEnum.CHAT, properties: { body: { component: UiComponentEnum.CHAT_BODY, }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; - -export const chatStepControl = { - uiSchema: chatStepUiSchema, - schema: chatStepControlSchema, -}; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts index cd3976caa1f..bae03fffe38 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/delay-control.schema.ts @@ -8,25 +8,24 @@ import { UiSchema, UiSchemaGroupEnum, } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; -export const DelayTimeControlZodSchema = z +export const delayControlZodSchema = z .object({ - skip: skipControl.schema, + skip: z.object({}).catchall(z.unknown()).optional(), type: z.enum(['regular']).default('regular'), amount: z.union([z.number().min(1), z.string()]), unit: z.nativeEnum(TimeUnitEnum), }) .strict(); -export const DelayTimeControlSchema = zodToJsonSchema(DelayTimeControlZodSchema) as JSONSchemaDto; - -export type DelayTimeControlType = z.infer; +export type DelayControlType = z.infer; +export const delayControlSchema = zodToJsonSchema(delayControlZodSchema) as JSONSchemaDto; export const delayUiSchema: UiSchema = { group: UiSchemaGroupEnum.DELAY, properties: { - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, amount: { component: UiComponentEnum.DELAY_AMOUNT, placeholder: null, diff --git a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts index e8fc6e508e3..733ffeda298 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/digest-control.schema.ts @@ -8,55 +8,56 @@ import { UiSchema, UiSchemaGroupEnum, } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; -const DigestRegularControlZodSchema = z +const digestRegularControlZodSchema = z .object({ + skip: z.object({}).catchall(z.unknown()).optional(), amount: z.union([z.number().min(1), z.string().min(1)]), - skip: skipControl.schema, - unit: z.nativeEnum(TimeUnitEnum).default(TimeUnitEnum.SECONDS), + unit: z.nativeEnum(TimeUnitEnum), digestKey: z.string().optional(), lookBackWindow: z .object({ amount: z.number().min(1), - unit: z.nativeEnum(TimeUnitEnum).default(TimeUnitEnum.SECONDS), + unit: z.nativeEnum(TimeUnitEnum), }) .strict() .optional(), }) .strict(); - -const DigestTimedControlZodSchema = z +const digestTimedControlZodSchema = z .object({ + skip: z.object({}).catchall(z.unknown()).optional(), cron: z.string().min(1), digestKey: z.string().optional(), }) .strict(); -export const DigestControlZodSchema = z.union([DigestRegularControlZodSchema, DigestTimedControlZodSchema]); +export type DigestRegularControlType = z.infer; +export type DigestTimedControlType = z.infer; +export type DigestControlSchemaType = z.infer; -export type DigestRegularControlType = z.infer; -export type DigestTimedControlType = z.infer; -export type DigestControlSchemaType = z.infer; +export const digestControlZodSchema = z.union([digestRegularControlZodSchema, digestTimedControlZodSchema]); +export const digestControlSchema = zodToJsonSchema(digestControlZodSchema) as JSONSchemaDto; -export const DigestOutputJsonSchema = zodToJsonSchema(DigestControlZodSchema) as JSONSchemaDto; export function isDigestRegularControl(data: unknown): data is DigestRegularControlType { - const result = DigestRegularControlZodSchema.safeParse(data); + const result = digestRegularControlZodSchema.safeParse(data); return result.success; } export function isDigestTimedControl(data: unknown): data is DigestTimedControlType { - const result = DigestTimedControlZodSchema.safeParse(data); + const result = digestTimedControlZodSchema.safeParse(data); return result.success; } export function isDigestControl(data: unknown): data is DigestControlSchemaType { - const result = DigestControlZodSchema.safeParse(data); + const result = digestControlZodSchema.safeParse(data); return result.success; } + export const digestUiSchema: UiSchema = { group: UiSchemaGroupEnum.DIGEST, properties: { @@ -76,6 +77,6 @@ export const digestUiSchema: UiSchema = { component: UiComponentEnum.DIGEST_CRON, placeholder: '', }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts index 897c4f88429..0a3227d6c91 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/email-control.schema.ts @@ -1,12 +1,12 @@ import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; -import { skipControl } from './skip-control.schema'; import { TipTapSchema } from '../../../environments-v1/usecases/output-renderers'; +import { skipStepUiSchema } from './skip-control.schema'; -export const emailStepControlZodSchema = z +export const emailControlZodSchema = z .object({ - skip: skipControl.schema, + skip: z.object({}).catchall(z.unknown()).optional(), /* * todo: we need to validate the email editor (body) by type and not string, * updating it to TipTapSchema will break the existing upsert issues generation @@ -16,10 +16,10 @@ export const emailStepControlZodSchema = z }) .strict(); -export const emailStepControlSchema = zodToJsonSchema(emailStepControlZodSchema) as JSONSchemaDto; -export type EmailStepControlType = z.infer; +export type EmailControlType = z.infer; -export const emailStepUiSchema: UiSchema = { +export const emailControlSchema = zodToJsonSchema(emailControlZodSchema) as JSONSchemaDto; +export const emailUiSchema: UiSchema = { group: UiSchemaGroupEnum.EMAIL, properties: { body: { @@ -28,11 +28,6 @@ export const emailStepUiSchema: UiSchema = { subject: { component: UiComponentEnum.TEXT_INLINE_LABEL, }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; - -export const emailStepControl = { - uiSchema: emailStepUiSchema, - schema: emailStepControlSchema, -}; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts index fdd9da2533a..1c48225e5a5 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/in-app-control.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; const redirectZodSchema = z .object({ @@ -21,9 +21,9 @@ const actionZodSchema = z .optional() .nullable(); -export const InAppControlZodSchema = z +export const inAppControlZodSchema = z .object({ - skip: skipControl.schema, + skip: z.object({}).catchall(z.unknown()).optional(), subject: z.string().optional(), body: z.string(), avatar: z.string().optional(), @@ -36,11 +36,11 @@ export const InAppControlZodSchema = z export type InAppRedirectType = z.infer; export type InAppActionType = z.infer; -export type InAppControlType = z.infer; +export type InAppControlType = z.infer; -export const InAppRedirectSchema = zodToJsonSchema(redirectZodSchema) as JSONSchemaDto; -export const InAppActionSchema = zodToJsonSchema(actionZodSchema) as JSONSchemaDto; -export const inAppControlSchema = zodToJsonSchema(InAppControlZodSchema) as JSONSchemaDto; +export const inAppRedirectSchema = zodToJsonSchema(redirectZodSchema) as JSONSchemaDto; +export const inAppActionSchema = zodToJsonSchema(actionZodSchema) as JSONSchemaDto; +export const inAppControlSchema = zodToJsonSchema(inAppControlZodSchema) as JSONSchemaDto; const redirectPlaceholder = { url: { @@ -78,6 +78,6 @@ export const inAppUiSchema: UiSchema = { component: UiComponentEnum.URL_TEXT_BOX, placeholder: redirectPlaceholder, }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/index.ts b/apps/api/src/app/workflows-v2/shared/schemas/index.ts deleted file mode 100644 index 13aa930325e..00000000000 --- a/apps/api/src/app/workflows-v2/shared/schemas/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './email-control.schema'; -export * from './in-app-control.schema'; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts index c10e9f165d7..5e51b0e319c 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/push-control.schema.ts @@ -2,20 +2,20 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; -export const PushStepControlZodSchema = z +export const pushControlZodSchema = z .object({ - skip: skipControl.schema, + skip: z.object({}).catchall(z.unknown()).optional(), subject: z.string(), body: z.string(), }) .strict(); -export type PushStepControlType = z.infer; +export type PushControlType = z.infer; -export const pushStepControlSchema = zodToJsonSchema(PushStepControlZodSchema) as JSONSchemaDto; -export const pushStepUiSchema: UiSchema = { +export const pushControlSchema = zodToJsonSchema(pushControlZodSchema) as JSONSchemaDto; +export const pushUiSchema: UiSchema = { group: UiSchemaGroupEnum.PUSH, properties: { subject: { @@ -24,11 +24,6 @@ export const pushStepUiSchema: UiSchema = { body: { component: UiComponentEnum.PUSH_BODY, }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; - -export const pushStepControl = { - uiSchema: pushStepUiSchema, - schema: pushStepControlSchema, -}; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts index 286bc032e3f..98f59ee6033 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/skip-control.schema.ts @@ -1,7 +1,4 @@ import { UiSchemaGroupEnum, UiSchema, UiComponentEnum } from '@novu/shared'; -import { z } from 'zod'; - -export const skipZodSchema = z.object({}).catchall(z.unknown()).optional(); export const skipStepUiSchema = { group: UiSchemaGroupEnum.SKIP, @@ -11,8 +8,3 @@ export const skipStepUiSchema = { }, }, } satisfies UiSchema; - -export const skipControl = { - uiSchema: skipStepUiSchema, - schema: skipZodSchema, -}; diff --git a/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts b/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts index cefee696214..a9d0911a5dd 100644 --- a/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts +++ b/apps/api/src/app/workflows-v2/shared/schemas/sms-control.schema.ts @@ -2,29 +2,24 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { JSONSchemaDto, UiComponentEnum, UiSchema, UiSchemaGroupEnum } from '@novu/shared'; -import { skipControl } from './skip-control.schema'; +import { skipStepUiSchema } from './skip-control.schema'; -export const SmsStepControlZodSchema = z +export const smsControlZodSchema = z .object({ - skip: skipControl.schema, + skip: z.object({}).catchall(z.unknown()).optional(), body: z.string(), }) .strict(); -export type SmsStepControlType = z.infer; +export type SmsControlType = z.infer; -export const smsStepControlSchema = zodToJsonSchema(SmsStepControlZodSchema) as JSONSchemaDto; -export const smsStepUiSchema: UiSchema = { +export const smsControlSchema = zodToJsonSchema(smsControlZodSchema) as JSONSchemaDto; +export const smsUiSchema: UiSchema = { group: UiSchemaGroupEnum.SMS, properties: { body: { component: UiComponentEnum.SMS_BODY, }, - skip: skipControl.uiSchema.properties.skip, + skip: skipStepUiSchema.properties.skip, }, }; - -export const smsStepControl = { - uiSchema: smsStepUiSchema, - schema: smsStepControlSchema, -}; diff --git a/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts b/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts index 663d6b10642..677c156354d 100644 --- a/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts +++ b/apps/api/src/app/workflows-v2/shared/step-type-to-control.mapper.ts @@ -1,11 +1,12 @@ import { ActionStepEnum, ChannelStepEnum } from '@novu/framework/internal'; import { ControlSchemas, JSONSchemaDto } from '@novu/shared'; -import { emailStepControl, inAppControlSchema, inAppUiSchema } from './schemas'; -import { DelayTimeControlSchema, delayUiSchema } from './schemas/delay-control.schema'; -import { DigestOutputJsonSchema, digestUiSchema } from './schemas/digest-control.schema'; -import { smsStepControl } from './schemas/sms-control.schema'; -import { chatStepControl } from './schemas/chat-control.schema'; -import { pushStepControl } from './schemas/push-control.schema'; +import { inAppControlSchema, inAppUiSchema } from './schemas/in-app-control.schema'; +import { emailControlSchema, emailUiSchema } from './schemas/email-control.schema'; +import { smsControlSchema, smsUiSchema } from './schemas/sms-control.schema'; +import { pushControlSchema, pushUiSchema } from './schemas/push-control.schema'; +import { chatControlSchema, chatUiSchema } from './schemas/chat-control.schema'; +import { delayControlSchema, delayUiSchema } from './schemas/delay-control.schema'; +import { digestControlSchema, digestUiSchema } from './schemas/digest-control.schema'; export const PERMISSIVE_EMPTY_SCHEMA = { type: 'object', @@ -20,27 +21,27 @@ export const stepTypeToControlSchema: Record; } diff --git a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts index aeaef56a4c5..520c1122e85 100644 --- a/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/build-variable-schema/build-available-variable-schema.usecase.ts @@ -7,6 +7,7 @@ import { BuildAvailableVariableSchemaCommand } from './build-available-variable- import { parsePayloadSchema } from '../../shared/parse-payload-schema'; import { BuildPayloadSchemaCommand } from '../build-payload-schema/build-payload-schema.command'; import { BuildPayloadSchema } from '../build-payload-schema/build-payload-schema.usecase'; +import { emptyJsonSchema } from '../../util/jsonToSchema'; @Injectable() export class BuildAvailableVariableSchemaUsecase { @@ -14,9 +15,9 @@ export class BuildAvailableVariableSchemaUsecase { async execute(command: BuildAvailableVariableSchemaCommand): Promise { const { workflow } = command; - const previousSteps = workflow.steps.slice( + const previousSteps = workflow?.steps.slice( 0, - workflow.steps.findIndex((stepItem) => stepItem._id === command.stepInternalId) + workflow?.steps.findIndex((stepItem) => stepItem._id === command.stepInternalId) ); return { @@ -49,7 +50,7 @@ export class BuildAvailableVariableSchemaUsecase { required: ['firstName', 'lastName', 'email', 'subscriberId'], additionalProperties: false, }, - steps: buildPreviousStepsSchema(previousSteps, workflow.payloadSchema), + steps: buildPreviousStepsSchema(previousSteps, workflow?.payloadSchema), payload: await this.resolvePayloadSchema(workflow, command), }, additionalProperties: false, @@ -58,17 +59,19 @@ export class BuildAvailableVariableSchemaUsecase { @Instrument() private async resolvePayloadSchema( - workflow: NotificationTemplateEntity, + workflow: NotificationTemplateEntity | undefined, command: BuildAvailableVariableSchemaCommand ): Promise { + if (!workflow) { + return { + type: 'object', + properties: {}, + additionalProperties: true, + }; + } + if (workflow.payloadSchema) { - return ( - parsePayloadSchema(workflow.payloadSchema, { safe: true }) || { - type: 'object', - properties: {}, - additionalProperties: true, - } - ); + return parsePayloadSchema(workflow.payloadSchema, { safe: true }) || emptyJsonSchema(); } return this.buildPayloadSchema.execute( @@ -77,6 +80,7 @@ export class BuildAvailableVariableSchemaUsecase { organizationId: command.organizationId, userId: command.userId, workflowId: workflow._id, + ...(command.optimisticControlValues ? { controlValues: command.optimisticControlValues } : {}), }) ); } diff --git a/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts index 9040f57db17..855bb9ba06a 100644 --- a/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/generate-preview/generate-preview.usecase.ts @@ -7,7 +7,6 @@ import { JobStatusEnum, PreviewPayload, StepDataDto, - TipTapNode, WorkflowOriginEnum, } from '@novu/shared'; import { @@ -17,7 +16,6 @@ import { Instrument, InstrumentUsecase, PinoLogger, - sanitizePreviewControlValues, } from '@novu/application-generic'; import { captureException } from '@sentry/node'; import { PreviewStep, PreviewStepCommand } from '../../../bridge/usecases/preview-step'; @@ -26,36 +24,22 @@ import { BuildStepDataUsecase } from '../build-step-data'; import { GeneratePreviewCommand } from './generate-preview.command'; import { BuildPayloadSchemaCommand } from '../build-payload-schema/build-payload-schema.command'; import { BuildPayloadSchema } from '../build-payload-schema/build-payload-schema.usecase'; -import { - extractLiquidTemplateVariables, - TemplateParseResult, - Variable, -} from '../../util/template-parser/liquid-parser'; +import { Variable } from '../../util/template-parser/liquid-parser'; import { pathsToObject } from '../../util/path-to-object'; -import { transformMailyContentToLiquid } from './transform-maily-content-to-liquid'; -import { isObjectTipTapNode, isStringTipTapNode } from '../../util/tip-tap.util'; +import { isObjectTipTapNode } from '../../util/tip-tap.util'; +import { buildVariables } from '../../util/build-variables'; +import { sanitizeControlValues } from '../../shared/sanitize-control-values'; const LOG_CONTEXT = 'GeneratePreviewUsecase'; -type DestructuredControlValues = { - tiptapControlValues: { emailEditor?: string | null; body?: string | null } | null; - // this is the remaining control values after the tiptap control is extracted - simpleControlValues: Record; -}; - -type ProcessedControlResult = { - controlValues: Record; - variablesExample: Record | null; -}; - @Injectable() export class GeneratePreviewUsecase { constructor( private previewStepUsecase: PreviewStep, private buildStepDataUsecase: BuildStepDataUsecase, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, - private readonly logger: PinoLogger, - private buildPayloadSchema: BuildPayloadSchema + private buildPayloadSchema: BuildPayloadSchema, + private readonly logger: PinoLogger ) {} @InstrumentUsecase() @@ -68,7 +52,15 @@ export class GeneratePreviewUsecase { workflow, } = await this.initializePreviewContext(command); const commandVariablesExample = command.generatePreviewRequestDto.previewPayload; - const sanitizedValidatedControls = sanitizePreviewControlValues(initialControlValues, stepData.type); + + /** + * We don't want to sanitize control values for code workflows, + * as it's the responsibility of the custom code workflow creator + */ + const sanitizedValidatedControls = + workflow.origin === WorkflowOriginEnum.NOVU_CLOUD + ? sanitizeControlValues(initialControlValues, stepData.type) + : initialControlValues; if (!sanitizedValidatedControls) { throw new Error( @@ -77,37 +69,25 @@ export class GeneratePreviewUsecase { ); } - let previewDataResult = { + let previewTemplateData = { variablesExample: {}, controlValues: {}, }; for (const [controlKey, controlValue] of Object.entries(sanitizedValidatedControls)) { - // previewControlValue is the control value that will be used to render the preview - const previewControlValue = controlValue; - // variableControlValue is the control value that will be used to extract the variables example - let variableControlValue = controlValue; - if (isStringTipTapNode(variableControlValue)) { - try { - variableControlValue = transformMailyContentToLiquid(JSON.parse(variableControlValue)); - } catch (error) { - console.log(error); - } - } - - const variables = this.processControlValueVariables(variableControlValue, variableSchema); - const processedControlValues = this.fixControlValueInvalidVariables(previewControlValue, variables.invalid); + const variables = buildVariables(variableSchema, controlValue, this.logger); + const processedControlValues = this.fixControlValueInvalidVariables(controlValue, variables.invalidVariables); - const validVariableNames = variables.valid.map((variable) => variable.name); + const validVariableNames = variables.validVariables.map((variable) => variable.name); const variablesExampleResult = pathsToObject(validVariableNames, { valuePrefix: '{{', valueSuffix: '}}', }); - previewDataResult = { - variablesExample: _.merge(previewDataResult.variablesExample, variablesExampleResult), + previewTemplateData = { + variablesExample: _.merge(previewTemplateData.variablesExample, variablesExampleResult), controlValues: { - ...previewDataResult.controlValues, + ...previewTemplateData.controlValues, [controlKey]: isObjectTipTapNode(processedControlValues) ? JSON.stringify(processedControlValues) : processedControlValues, @@ -115,12 +95,12 @@ export class GeneratePreviewUsecase { }; } - const finalVariablesExample = this.buildVariable(workflow, previewDataResult, commandVariablesExample); + const mergedVariablesExample = this.mergeVariablesExample(workflow, previewTemplateData, commandVariablesExample); const executeOutput = await this.executePreviewUsecase( command, stepData, - finalVariablesExample, - previewDataResult.controlValues + mergedVariablesExample, + previewTemplateData.controlValues ); return { @@ -128,7 +108,7 @@ export class GeneratePreviewUsecase { preview: executeOutput.outputs as any, type: stepData.type as unknown as ChannelTypeEnum, }, - previewPayloadExample: finalVariablesExample, + previewPayloadExample: mergedVariablesExample, }; } catch (error) { this.logger.error( @@ -154,9 +134,9 @@ export class GeneratePreviewUsecase { } } - private buildVariable( + private mergeVariablesExample( workflow: WorkflowInternalResponseDto, - previewDataResult: { variablesExample: {}; controlValues: {} }, + previewTemplateData: { variablesExample: {}; controlValues: {} }, commandVariablesExample: PreviewPayload | undefined ) { let finalVariablesExample = {}; @@ -166,9 +146,9 @@ export class GeneratePreviewUsecase { type: 'object', properties: { payload: workflow.payloadSchema }, }); - finalVariablesExample = { ...previewDataResult.variablesExample, ...tmp }; + finalVariablesExample = { ...previewTemplateData.variablesExample, ...tmp }; } else { - finalVariablesExample = previewDataResult.variablesExample; + finalVariablesExample = previewTemplateData.variablesExample; } finalVariablesExample = _.merge(finalVariablesExample, commandVariablesExample || {}); @@ -185,26 +165,6 @@ export class GeneratePreviewUsecase { return { stepData, controlValues, variableSchema, workflow }; } - private processControlValueVariables( - controlValue: unknown, - variableSchema: Record - ): { - valid: Variable[]; - invalid: Variable[]; - } { - const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(controlValue)); - - const { validVariables: validSchemaVariables, invalidVariables: invalidSchemaVariables } = identifyUnknownVariables( - variableSchema, - validVariables - ); - - return { - valid: validSchemaVariables, - invalid: [...invalidVariables, ...invalidSchemaVariables], - }; - } - @Instrument() private async buildVariablesSchema( variables: Record, @@ -293,12 +253,12 @@ export class GeneratePreviewUsecase { let controlValuesString = JSON.stringify(controlValues); for (const invalidVariable of invalidVariables) { - if (!controlValuesString.includes(invalidVariable.template)) { + if (!controlValuesString.includes(invalidVariable.output)) { continue; } const EMPTY_STRING = ''; - controlValuesString = replaceAll(controlValuesString, invalidVariable.template, EMPTY_STRING); + controlValuesString = replaceAll(controlValuesString, invalidVariable.output, EMPTY_STRING); } return JSON.parse(controlValuesString) as Record; @@ -323,6 +283,13 @@ function buildState(steps: Record | undefined): FrameworkPrevio return outputArray; } +/** + * Replaces all occurrences of a search string with a replacement string. + */ +export function replaceAll(text: string, searchValue: string, replaceValue: string): string { + return _.replace(text, new RegExp(_.escapeRegExp(searchValue), 'g'), replaceValue); +} + export class GeneratePreviewError extends InternalServerErrorException { constructor(error: FrameworkError) { super({ @@ -345,89 +312,3 @@ class FrameworkError { message: string; name: string; } - -/** - * Validates liquid template variables against a schema, the result is an object with valid and invalid variables - * @example - * const variables = [ - * { name: 'subscriber.firstName' }, - * { name: 'subscriber.orderId' } - * ]; - * const schema = { - * properties: { - * subscriber: { - * properties: { - * firstName: { type: 'string' } - * } - * } - * } - * }; - * const invalid = [{ name: 'unknown.variable' }]; - * - * validateVariablesAgainstSchema(variables, schema, invalid); - * // Returns: - * // { - * // validVariables: [{ name: 'subscriber.firstName' }], - * // invalidVariables: [{ name: 'unknown.variable' }, { name: 'subscriber.orderId' }] - * // } - */ -function identifyUnknownVariables( - variableSchema: Record, - validVariables: Variable[] -): TemplateParseResult { - const validVariablesCopy: Variable[] = _.cloneDeep(validVariables); - - const result = validVariablesCopy.reduce( - (acc, variable: Variable) => { - const parts = variable.name.split('.'); - let isValid = true; - let currentPath = 'properties'; - - for (const part of parts) { - currentPath += `.${part}`; - const valueSearch = _.get(variableSchema, currentPath); - - currentPath += '.properties'; - const propertiesSearch = _.get(variableSchema, currentPath); - - if (valueSearch === undefined && propertiesSearch === undefined) { - isValid = false; - break; - } - } - - if (isValid) { - acc.validVariables.push(variable); - } else { - acc.invalidVariables.push({ - name: variable.template, - context: variable.context, - message: 'Variable is not supported', - template: variable.template, - }); - } - - return acc; - }, - { - validVariables: [] as Variable[], - invalidVariables: [] as Variable[], - } as TemplateParseResult - ); - - return result; -} - -/** - * Fixes invalid Liquid template variables for preview by replacing them with error messages. - * - * @example - * // Input controlValues: - * { "message": "Hello {{invalid.var}}" } - * - * // Output: - * { "message": "Hello [[Invalid Variable: invalid.var]]" } - */ -function replaceAll(text: string, searchValue: string, replaceValue: string): string { - return _.replace(text, new RegExp(_.escapeRegExp(searchValue), 'g'), replaceValue); -} diff --git a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts index 02d8924a1a1..8420659db02 100644 --- a/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/patch-step-data/patch-step.usecase.ts @@ -11,7 +11,6 @@ import { } from '@novu/application-generic'; import { PatchStepCommand } from './patch-step.command'; import { BuildStepDataUsecase } from '../build-step-data'; -import { PostProcessWorkflowUpdate } from '../post-process-workflow-update'; type ValidNotificationWorkflow = { currentStep: NonNullable; @@ -24,7 +23,6 @@ export class PatchStepUsecase { private buildStepDataUsecase: BuildStepDataUsecase, private notificationTemplateRepository: NotificationTemplateRepository, private upsertControlValuesUseCase: UpsertControlValuesUseCase, - private postProcessWorkflowUpdate: PostProcessWorkflowUpdate, @Inject(forwardRef(() => DeleteControlValuesUseCase)) private deleteControlValuesUseCase: DeleteControlValuesUseCase ) {} @@ -32,11 +30,7 @@ export class PatchStepUsecase { async execute(command: PatchStepCommand): Promise { const persistedItems = await this.loadPersistedItems(command); await this.patchFieldsOnPersistedItems(command, persistedItems); - const updatedWorkflow = await this.postProcessWorkflowUpdate.execute({ - workflow: persistedItems.workflow, - user: command.user, - }); - await this.persistWorkflow(updatedWorkflow, command.user); + await this.persistWorkflow(persistedItems.workflow, command.user); return await this.buildStepDataUsecase.execute(command); } diff --git a/apps/api/src/app/workflows-v2/usecases/patch-workflow/patch-workflow.usecase.ts b/apps/api/src/app/workflows-v2/usecases/patch-workflow/patch-workflow.usecase.ts index be67d748646..db45d81466e 100644 --- a/apps/api/src/app/workflows-v2/usecases/patch-workflow/patch-workflow.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/patch-workflow/patch-workflow.usecase.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { UserSessionData, WorkflowResponseDto } from '@novu/shared'; +import { UserSessionData, WorkflowResponseDto, WorkflowStatusEnum } from '@novu/shared'; import { NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { GetWorkflowByIdsUseCase, WorkflowInternalResponseDto } from '@novu/application-generic'; -import { PostProcessWorkflowUpdate } from '../post-process-workflow-update'; import { PatchWorkflowCommand } from './patch-workflow.command'; import { GetWorkflowUseCase } from '../get-workflow'; @@ -11,18 +10,12 @@ export class PatchWorkflowUsecase { constructor( private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private notificationTemplateRepository: NotificationTemplateRepository, - private postProcessWorkflowUpdate: PostProcessWorkflowUpdate, private getWorkflowUseCase: GetWorkflowUseCase ) {} async execute(command: PatchWorkflowCommand): Promise { const persistedWorkflow = await this.fetchWorkflow(command); - let transientWorkflow = this.patchWorkflowFields(persistedWorkflow, command); - - transientWorkflow = await this.postProcessWorkflowUpdate.execute({ - workflow: transientWorkflow, - user: command.user, - }); + const transientWorkflow = this.patchWorkflowFields(persistedWorkflow, command); await this.persistWorkflow(transientWorkflow, command.user); return await this.getWorkflowUseCase.execute({ @@ -52,6 +45,10 @@ export class PatchWorkflowUsecase { transientWorkflow.tags = command.tags; } + if (command.active !== undefined && command.active !== null) { + transientWorkflow.status = command.active ? WorkflowStatusEnum.ACTIVE : WorkflowStatusEnum.INACTIVE; + } + return transientWorkflow; } diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/index.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/index.ts index 3f855934e7f..05fdb87e144 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/index.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/index.ts @@ -1,5 +1,2 @@ export * from './upsert-workflow.command'; export * from './upsert-workflow.usecase'; -export * from './upsert-workflow-data.command'; -export * from './upsert-step-data.command'; -export * from './preferences-request-upsert-data.command'; diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/preferences-request-upsert-data.command.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/preferences-request-upsert-data.command.ts deleted file mode 100644 index bd953292581..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/preferences-request-upsert-data.command.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ChannelTypeEnum } from '@novu/shared'; -import { IsBoolean, IsObject, IsOptional, ValidateNested } from 'class-validator'; -import { Type } from 'class-transformer'; - -export class ChannelPreferenceData { - @IsBoolean() - enabled: boolean; -} - -export class WorkflowPreferenceData { - @IsBoolean() - enabled: boolean; - - @IsBoolean() - readOnly: boolean; -} - -export class WorkflowPreferencesUpsertData { - @ValidateNested() - all: WorkflowPreferenceData; - - @IsObject() - @ValidateNested({ each: true }) - channels: Record; -} - -export class PreferencesRequestUpsertDataCommand { - @IsOptional() - @ValidateNested() - @Type(() => WorkflowPreferencesUpsertData) - user: WorkflowPreferencesUpsertData | null; - - @IsOptional() - @ValidateNested() - @Type(() => WorkflowPreferencesUpsertData) - workflow?: WorkflowPreferencesUpsertData | null; -} diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts deleted file mode 100644 index 8b55e29e18b..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-step-data.command.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { IsEnum, IsNotEmpty, IsOptional, IsString, Length } from 'class-validator'; -import { MAX_NAME_LENGTH } from '@novu/application-generic'; -import { StepTypeEnum } from '@novu/shared'; - -export class UpsertStepDataCommand { - @IsString() - @IsNotEmpty() - @Length(1, MAX_NAME_LENGTH) - name: string; - - @IsEnum(StepTypeEnum) - type: StepTypeEnum; - - @IsOptional() - controlValues?: Record | null; - - @IsOptional() - @IsString() - @IsNotEmpty() - _id?: string; -} diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow-data.command.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow-data.command.ts deleted file mode 100644 index 5807e413151..00000000000 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow-data.command.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - ArrayMaxSize, - IsArray, - IsBoolean, - IsEnum, - IsNotEmpty, - IsOptional, - IsString, - Length, - ValidateNested, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import { MAX_NAME_LENGTH } from '@novu/application-generic'; -import { WorkflowCreationSourceEnum } from '@novu/shared'; -import { UpsertStepDataCommand } from './upsert-step-data.command'; -import { PreferencesRequestUpsertDataCommand } from './preferences-request-upsert-data.command'; - -export class UpsertWorkflowDataCommand { - @IsString() - @IsOptional() - workflowId?: string; - - @IsArray() - @ValidateNested({ each: true }) - @Type(() => UpsertStepDataCommand) - steps: UpsertStepDataCommand[]; - - @IsOptional() - @ValidateNested() - @Type(() => PreferencesRequestUpsertDataCommand) - preferences?: PreferencesRequestUpsertDataCommand; - - @IsString() - @IsNotEmpty() - @Length(1, MAX_NAME_LENGTH) - name: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsArray() - @IsString({ each: true }) - @ArrayMaxSize(16, { message: 'tags must contain no more than 16 elements' }) - tags?: string[]; - - @IsOptional() - @IsBoolean() - active?: boolean; - - @IsOptional() - @IsEnum(WorkflowCreationSourceEnum) - __source?: WorkflowCreationSourceEnum; -} diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.command.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.command.ts index d0e9e19573e..2ddf864f802 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.command.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.command.ts @@ -1,7 +1,110 @@ -import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; -import { IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + IsOptional, + IsString, + ValidateNested, + ArrayMaxSize, + IsArray, + IsBoolean, + IsEnum, + IsNotEmpty, + Length, + IsObject, +} from 'class-validator'; import { Type } from 'class-transformer'; -import { UpsertWorkflowDataCommand } from './upsert-workflow-data.command'; + +import { EnvironmentWithUserObjectCommand, MAX_NAME_LENGTH } from '@novu/application-generic'; +import { StepTypeEnum, WorkflowCreationSourceEnum, ChannelTypeEnum } from '@novu/shared'; + +export class ChannelPreferenceData { + @IsBoolean() + enabled: boolean; +} + +export class WorkflowPreferenceData { + @IsBoolean() + enabled: boolean; + + @IsBoolean() + readOnly: boolean; +} + +export class WorkflowPreferencesUpsertData { + @ValidateNested() + all: WorkflowPreferenceData; + + @IsObject() + @ValidateNested({ each: true }) + channels: Record; +} + +export class PreferencesRequestUpsertDataCommand { + @IsOptional() + @ValidateNested() + @Type(() => WorkflowPreferencesUpsertData) + user: WorkflowPreferencesUpsertData | null; + + @IsOptional() + @ValidateNested() + @Type(() => WorkflowPreferencesUpsertData) + workflow?: WorkflowPreferencesUpsertData | null; +} + +export class UpsertStepDataCommand { + @IsString() + @IsNotEmpty() + @Length(1, MAX_NAME_LENGTH) + name: string; + + @IsEnum(StepTypeEnum) + type: StepTypeEnum; + + @IsOptional() + controlValues?: Record | null; + + @IsOptional() + @IsString() + @IsNotEmpty() + _id?: string; +} + +export class UpsertWorkflowDataCommand { + @IsString() + @IsOptional() + workflowId?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UpsertStepDataCommand) + steps: UpsertStepDataCommand[]; + + @IsOptional() + @ValidateNested() + @Type(() => PreferencesRequestUpsertDataCommand) + preferences?: PreferencesRequestUpsertDataCommand; + + @IsString() + @IsNotEmpty() + @Length(1, MAX_NAME_LENGTH) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @ArrayMaxSize(16, { message: 'tags must contain no more than 16 elements' }) + tags?: string[]; + + @IsOptional() + @IsBoolean() + active?: boolean; + + @IsOptional() + @IsEnum(WorkflowCreationSourceEnum) + __source?: WorkflowCreationSourceEnum; +} export class UpsertWorkflowCommand extends EnvironmentWithUserObjectCommand { @IsOptional() diff --git a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts index adf24b7c0a7..6b3bbfa9351 100644 --- a/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/upsert-workflow/upsert-workflow.usecase.ts @@ -1,17 +1,36 @@ -import { NotificationGroupRepository, NotificationStepEntity, NotificationTemplateEntity } from '@novu/dal'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import Ajv, { ErrorObject } from 'ajv'; +import addFormats from 'ajv-formats'; +import _ from 'lodash'; + import { + ControlValuesRepository, + NotificationGroupRepository, + NotificationStepEntity, + NotificationTemplateEntity, +} from '@novu/dal'; +import { + ContentIssue, CreateWorkflowDto, + JSONSchemaDto, DEFAULT_WORKFLOW_PREFERENCES, slugify, + StepContentIssueEnum, StepCreateDto, - StepDto, + StepIssuesDto, StepUpdateDto, UpdateWorkflowDto, UserSessionData, + StepTypeEnum, WorkflowCreationSourceEnum, WorkflowOriginEnum, WorkflowResponseDto, WorkflowTypeEnum, + ControlValuesLevelEnum, + WorkflowStatusEnum, + StepIssues, + ControlSchemas, + DigestUnitEnum, } from '@novu/shared'; import { CreateWorkflow as CreateWorkflowGeneric, @@ -25,14 +44,19 @@ import { UpdateWorkflow as UpdateWorkflowGeneric, UpdateWorkflowCommand, WorkflowInternalResponseDto, + TierRestrictionsValidateUsecase, + UpsertControlValuesCommand, + DeleteControlValuesCommand, + UpsertControlValuesUseCase, + DeleteControlValuesUseCase, + TierRestrictionsValidateCommand, } from '@novu/application-generic'; -import { BadRequestException, Injectable } from '@nestjs/common'; -import { UpsertWorkflowCommand } from './upsert-workflow.command'; + +import { UpsertWorkflowCommand, UpsertWorkflowDataCommand } from './upsert-workflow.command'; import { stepTypeToControlSchema } from '../../shared'; -import { PatchStepUsecase } from '../patch-step-data'; -import { PostProcessWorkflowUpdate } from '../post-process-workflow-update'; import { GetWorkflowCommand, GetWorkflowUseCase } from '../get-workflow'; -import { UpsertWorkflowDataCommand } from './upsert-workflow-data.command'; +import { buildVariables } from '../../util/build-variables'; +import { BuildAvailableVariableSchemaCommand, BuildAvailableVariableSchemaUsecase } from '../build-variable-schema'; @Injectable() export class UpsertWorkflowUseCase { @@ -40,26 +64,23 @@ export class UpsertWorkflowUseCase { private createWorkflowGenericUsecase: CreateWorkflowGeneric, private updateWorkflowGenericUsecase: UpdateWorkflowGeneric, private notificationGroupRepository: NotificationGroupRepository, - private workflowUpdatePostProcess: PostProcessWorkflowUpdate, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private getWorkflowUseCase: GetWorkflowUseCase, - private patchStepDataUsecase: PatchStepUsecase + private buildAvailableVariableSchemaUsecase: BuildAvailableVariableSchemaUsecase, + private controlValuesRepository: ControlValuesRepository, + private upsertControlValuesUseCase: UpsertControlValuesUseCase, + private deleteControlValuesUseCase: DeleteControlValuesUseCase, + private tierRestrictionsValidateUsecase: TierRestrictionsValidateUsecase ) {} @InstrumentUsecase() async execute(command: UpsertWorkflowCommand): Promise { const workflowForUpdate = await this.queryWorkflow(command); - let persistedWorkflow = await this.createOrUpdateWorkflow(workflowForUpdate, command); - persistedWorkflow = await this.upsertControlValues(persistedWorkflow, command); - const validatedWorkflowWithIssues = await this.workflowUpdatePostProcess.execute({ - user: command.user, - workflow: persistedWorkflow, - }); - await this.persistWorkflow(validatedWorkflowWithIssues); - + const persistedWorkflow = await this.createOrUpdateWorkflow(workflowForUpdate, command); + await this.upsertControlValues(persistedWorkflow, command); const workflow = await this.getWorkflowUseCase.execute( GetWorkflowCommand.create({ - workflowIdOrInternalId: validatedWorkflowWithIssues._id, + workflowIdOrInternalId: persistedWorkflow._id, user: command.user, }) ); @@ -67,32 +88,6 @@ export class UpsertWorkflowUseCase { return workflow; } - @Instrument() - private async getWorkflow(workflowId: string, command: UpsertWorkflowCommand): Promise { - return await this.getWorkflowByIdsUseCase.execute( - GetWorkflowByIdsCommand.create({ - environmentId: command.user.environmentId, - organizationId: command.user.organizationId, - userId: command.user._id, - workflowIdOrInternalId: workflowId, - }) - ); - } - - @Instrument() - private async persistWorkflow(workflowWithIssues: WorkflowInternalResponseDto) { - const command = UpdateWorkflowCommand.create({ - id: workflowWithIssues._id, - environmentId: workflowWithIssues._environmentId, - organizationId: workflowWithIssues._organizationId, - userId: workflowWithIssues._creatorId, - type: workflowWithIssues.type!, - ...workflowWithIssues, - }); - - await this.updateWorkflowGenericUsecase.execute(command); - } - @Instrument() private async queryWorkflow(command: UpsertWorkflowCommand): Promise { if (!command.workflowIdOrInternalId) { @@ -117,7 +112,7 @@ export class UpsertWorkflowUseCase { if (existingWorkflow && isWorkflowUpdateDto(command.workflowDto, command.workflowIdOrInternalId)) { return await this.updateWorkflowGenericUsecase.execute( UpdateWorkflowCommand.create( - this.convertCreateToUpdateCommand(command.workflowDto, command.user, existingWorkflow) + await this.convertCreateToUpdateCommand(command.workflowDto, command.user, existingWorkflow) ) ); } @@ -138,6 +133,7 @@ export class UpsertWorkflowUseCase { if (!notificationGroupId) { throw new BadRequestException('Notification group not found'); } + const steps = await this.mapSteps(command.user, workflowDto.steps); return { notificationGroupId, @@ -148,28 +144,48 @@ export class UpsertWorkflowUseCase { __source: workflowDto.__source || WorkflowCreationSourceEnum.DASHBOARD, type: WorkflowTypeEnum.BRIDGE, origin: WorkflowOriginEnum.NOVU_CLOUD, - steps: this.mapSteps(workflowDto.steps), + steps, active: isWorkflowActive, description: workflowDto.description || '', tags: workflowDto.tags || [], userPreferences: command.workflowDto.preferences?.user ?? null, defaultPreferences: command.workflowDto.preferences?.workflow ?? DEFAULT_WORKFLOW_PREFERENCES, triggerIdentifier: slugify(workflowDto.name), + status: this.computeStatus(command.workflowDto, steps), }; } - private convertCreateToUpdateCommand( + private computeStatus(workflow: UpsertWorkflowDataCommand, steps: NotificationStep[]) { + if (workflow.active === false) { + return WorkflowStatusEnum.INACTIVE; + } + + const hasIssues = steps.filter((step) => this.hasControlIssues(step.issues)).length > 0; + if (!hasIssues) { + return WorkflowStatusEnum.ACTIVE; + } + + return WorkflowStatusEnum.ERROR; + } + + private hasControlIssues(issue: StepIssues | undefined) { + return issue?.controls && Object.keys(issue.controls).length > 0; + } + + private async convertCreateToUpdateCommand( workflowDto: UpdateWorkflowDto, user: UserSessionData, existingWorkflow: NotificationTemplateEntity - ): UpdateWorkflowCommand { + ): Promise { + const steps = await this.mapSteps(user, workflowDto.steps, existingWorkflow); + return { id: existingWorkflow._id, environmentId: existingWorkflow._environmentId, organizationId: user.organizationId, userId: user._id, name: workflowDto.name, - steps: this.mapSteps(workflowDto.steps, existingWorkflow), + steps, rawData: workflowDto, type: WorkflowTypeEnum.BRIDGE, description: workflowDto.description, @@ -177,16 +193,18 @@ export class UpsertWorkflowUseCase { defaultPreferences: workflowDto.preferences?.workflow ?? DEFAULT_WORKFLOW_PREFERENCES, tags: workflowDto.tags, active: workflowDto.active ?? true, + status: this.computeStatus(workflowDto, steps), }; } - private mapSteps( + private async mapSteps( + user: UserSessionData, commandWorkflowSteps: Array, persistedWorkflow?: NotificationTemplateEntity | undefined - ): NotificationStep[] { + ): Promise { const steps: NotificationStep[] = []; for (const step of commandWorkflowSteps) { - const mappedStep = this.mapSingleStep(persistedWorkflow, step); + const mappedStep = await this.mapSingleStep(user, persistedWorkflow, step); const baseStepId = mappedStep.stepId; if (baseStepId) { @@ -223,12 +241,33 @@ export class UpsertWorkflowUseCase { return currentStepId; } - private mapSingleStep( + private async mapSingleStep( + user: UserSessionData, persistedWorkflow: NotificationTemplateEntity | undefined, step: StepUpdateDto | StepCreateDto - ): NotificationStep { + ): Promise { const foundPersistedStep = this.getPersistedStepIfFound(persistedWorkflow, step); - const stepEntityToReturn = this.buildBaseStepEntity(step, foundPersistedStep); + const controlSchemas: ControlSchemas = foundPersistedStep?.template?.controls || stepTypeToControlSchema[step.type]; + const issues: StepIssuesDto = await this.buildIssues( + user, + foundPersistedStep, + persistedWorkflow, + step, + controlSchemas + ); + + const stepEntityToReturn = { + template: { + type: step.type, + name: step.name, + controls: controlSchemas, + content: '', + }, + stepId: foundPersistedStep?.stepId || slugify(step.name), + name: step.name, + issues, + }; + if (foundPersistedStep) { return { ...stepEntityToReturn, @@ -241,20 +280,44 @@ export class UpsertWorkflowUseCase { return stepEntityToReturn; } - private buildBaseStepEntity( - step: StepDto | StepUpdateDto, - foundPersistedStep?: NotificationStepEntity - ): NotificationStep { - return { - template: { - type: step.type, - name: step.name, - controls: foundPersistedStep?.template?.controls || stepTypeToControlSchema[step.type], - content: '', - }, - stepId: foundPersistedStep?.stepId || slugify(step.name), - name: step.name, - }; + private async buildIssues( + user: UserSessionData, + foundPersistedStep: NotificationStepEntity | undefined, + persistedWorkflow: NotificationTemplateEntity | undefined, + step: StepCreateDto | StepUpdateDto, + controlSchemas: ControlSchemas + ) { + const variableSchema = await this.buildAvailableVariableSchemaUsecase.execute( + BuildAvailableVariableSchemaCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + userId: user._id, + stepInternalId: foundPersistedStep?.stepId, + workflow: persistedWorkflow, + ...(step.controlValues ? { optimisticControlValues: step.controlValues } : {}), + }) + ); + + let controlValueLocal = step.controlValues; + + if (!controlValueLocal) { + controlValueLocal = ( + await this.controlValuesRepository.findOne({ + _environmentId: user.environmentId, + _organizationId: user.organizationId, + _workflowId: persistedWorkflow?._id, + _stepId: foundPersistedStep?._templateId, + level: ControlValuesLevelEnum.STEP_CONTROLS, + }) + )?.controls; + } + + const controlIssues = processControlValuesBySchema(controlSchemas?.schema, controlValueLocal || {}); + const liquidTemplateIssues = processControlValuesByLiquid(variableSchema, controlValueLocal || {}); + const customIssues = await this.processCustomControlValues(user, step.type, controlValueLocal || {}); + const customControlIssues = _.isEmpty(customIssues) ? {} : { controls: customIssues }; + + return _.merge(controlIssues, liquidTemplateIssues, customControlIssues); } private getPersistedStepIfFound( @@ -288,29 +351,60 @@ export class UpsertWorkflowUseCase { )?._id; } - /** - * @deprecated This method will be removed in future versions. - * Please use `the patch step data instead, do not add here anything` instead. - */ @Instrument() private async upsertControlValues( workflow: NotificationTemplateEntity, command: UpsertWorkflowCommand - ): Promise { - for (const step of workflow.steps) { - const controlValues = this.findControlValueInRequest(step, command.workflowDto.steps); - if (controlValues === undefined) { - continue; - } - await this.patchStepDataUsecase.execute({ - controlValues, - workflowIdOrInternalId: workflow._id, - stepIdOrInternalId: step._templateId, - user: command.user, - }); + ): Promise { + const controlValuesUpdates = this.getControlValuesUpdates(workflow.steps, command); + if (controlValuesUpdates.length === 0) return; + + await Promise.all( + controlValuesUpdates.map((update) => this.executeControlValuesUpdate(update, workflow._id, command)) + ); + } + + private getControlValuesUpdates(steps: NotificationStepEntity[], command: UpsertWorkflowCommand) { + return steps + .map((step) => { + const controlValues = this.findControlValueInRequest(step, command.workflowDto.steps); + if (controlValues === undefined) return null; + + return { + step, + controlValues, + shouldDelete: controlValues === null, + }; + }) + .filter((update): update is NonNullable => update !== null); + } + + private executeControlValuesUpdate( + update: { step: NotificationStepEntity; controlValues: Record | null; shouldDelete: boolean }, + workflowId: string, + command: UpsertWorkflowCommand + ) { + if (update.shouldDelete) { + return this.deleteControlValuesUseCase.execute( + DeleteControlValuesCommand.create({ + environmentId: command.user.environmentId, + organizationId: command.user.organizationId, + stepId: update.step._templateId, + workflowId, + userId: command.user._id, + }) + ); } - return await this.getWorkflow(workflow._id, command); + return this.upsertControlValuesUseCase.execute( + UpsertControlValuesCommand.create({ + organizationId: command.user.organizationId, + environmentId: command.user.environmentId, + notificationStepEntity: update.step, + workflowId, + newControlValues: update.controlValues || {}, + }) + ); } private findControlValueInRequest( @@ -325,12 +419,148 @@ export class UpsertWorkflowUseCase { return stepRequest.name === step.name; })?.controlValues; } + + private async processCustomControlValues( + user: UserSessionData, + stepType: StepTypeEnum, + controlValues: Record | null + ): Promise { + const cleanedControlValues = controlValues ? cleanObject(controlValues) : {}; + + const restrictionsErrors = await this.tierRestrictionsValidateUsecase.execute( + TierRestrictionsValidateCommand.create({ + amount: cleanedControlValues.amount as string | undefined, + unit: cleanedControlValues.unit as string | undefined, + cron: cleanedControlValues.cron as string | undefined, + organizationId: user.organizationId, + stepType, + }) + ); + + if (!restrictionsErrors) { + return {}; + } + + const result: Record = {}; + for (const restrictionsError of restrictionsErrors) { + result[restrictionsError.controlKey] = [ + { + issueType: StepContentIssueEnum.TIER_LIMIT_EXCEEDED, + message: restrictionsError.message, + }, + ]; + } + + return result; + } } function isWorkflowUpdateDto(workflowDto: UpsertWorkflowDataCommand, id?: string): workflowDto is UpdateWorkflowDto { return !!id; } -const isUniqueStepId = (stepIdToValidate: string, otherStepsIds: string[]) => { +function isUniqueStepId(stepIdToValidate: string, otherStepsIds: string[]) { return !otherStepsIds.some((stepId) => stepId === stepIdToValidate); -}; +} + +function processControlValuesByLiquid( + variableSchema: JSONSchemaDto | undefined, + controlValues: Record | null +): StepIssuesDto { + const issues: StepIssuesDto = {}; + + for (const [controlKey, controlValue] of Object.entries(controlValues || {})) { + const liquidTemplateIssues = buildVariables(variableSchema, controlValue); + + if (liquidTemplateIssues.invalidVariables.length > 0) { + issues.controls = issues.controls || {}; + + issues.controls[controlKey] = liquidTemplateIssues.invalidVariables.map((error) => ({ + message: `${error.message}, variable: ${error.output}`, + issueType: StepContentIssueEnum.ILLEGAL_VARIABLE_IN_CONTROL_VALUE, + variableName: error.output, + })); + } + } + + return issues; +} + +function processControlValuesBySchema( + controlSchema: JSONSchemaDto | undefined, + controlValues: Record | null +): StepIssuesDto { + let issues: StepIssuesDto = {}; + + const cleanedControlValues = controlValues ? cleanObject(controlValues) : {}; + + if (!controlSchema || !cleanedControlValues) { + return issues; + } + + const ajv = new Ajv({ allErrors: true }); + addFormats(ajv); + const validate = ajv.compile(controlSchema); + const isValid = validate(cleanedControlValues); + const errors = validate.errors as null | ErrorObject[]; + + if (!isValid && errors && errors?.length !== 0 && cleanedControlValues) { + issues = { + controls: errors.reduce( + (acc, error) => { + const path = getErrorPath(error); + if (!acc[path]) { + acc[path] = []; + } + + acc[path].push({ + message: error.message || 'Invalid value', + issueType: mapAjvErrorToIssueType(error), + variableName: path, + }); + + return acc; + }, + {} as Record + ), + }; + + return issues; + } + + return issues; +} + +/* + * Extracts the path from the error object: + * 1. If instancePath exists, removes leading slash and converts remaining slashes to dots + * 2. If no instancePath, uses missingProperty from error params + * Example: "/foo/bar" becomes "foo.bar" + */ +function getErrorPath(error: ErrorObject): string { + return (error.instancePath.substring(1) || error.params.missingProperty)?.replace(/\//g, '.'); +} + +function cleanObject( + obj: Record, + valuesToClean: Array = ['', null, undefined] +) { + if (typeof obj !== 'object' || obj === null) return obj; + + return Object.fromEntries( + Object.entries(obj) + .filter(([unused, value]) => !valuesToClean.includes(value as string | null | undefined)) + .map(([key, value]) => [key, cleanObject(value, valuesToClean)]) + ); +} + +function mapAjvErrorToIssueType(error: ErrorObject): StepContentIssueEnum { + switch (error.keyword) { + case 'required': + return StepContentIssueEnum.MISSING_VALUE; + case 'type': + return StepContentIssueEnum.MISSING_VALUE; + default: + return StepContentIssueEnum.MISSING_VALUE; + } +} diff --git a/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.usecase.ts b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.usecase.ts index a839d164b56..5834d99832a 100644 --- a/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.usecase.ts +++ b/apps/api/src/app/workflows-v2/usecases/validate-content/prepare-and-validate-content/prepare-and-validate-content.usecase.ts @@ -10,7 +10,12 @@ import { StepTypeEnum, UserSessionData, } from '@novu/shared'; -import { Instrument, InstrumentUsecase, TierRestrictionsValidateUsecase } from '@novu/application-generic'; +import { + Instrument, + InstrumentUsecase, + TierRestrictionsValidateCommand, + TierRestrictionsValidateUsecase, +} from '@novu/application-generic'; import { PrepareAndValidateContentCommand } from './prepare-and-validate-content.command'; import { flattenJson, flattenToNested, mergeObjects } from '../../../util/jsonUtils'; @@ -387,16 +392,14 @@ export class PrepareAndValidateContentUsecase { user: UserSessionData, stepType?: StepTypeEnum ): Promise> { - const deferDuration = - isValidDigestUnit(defaultControlValues.unit) && isNumber(defaultControlValues.amount) - ? calculateMilliseconds(defaultControlValues.amount, defaultControlValues.unit) - : 0; - - const restrictionsErrors = await this.tierRestrictionsValidateUsecase.execute({ - deferDurationMs: deferDuration, - organizationId: user.organizationId, - stepType, - }); + const restrictionsErrors = await this.tierRestrictionsValidateUsecase.execute( + TierRestrictionsValidateCommand.create({ + amount: defaultControlValues.amount as string | undefined, + unit: defaultControlValues.unit as string | undefined, + organizationId: user.organizationId, + stepType, + }) + ); if (!restrictionsErrors) { return {}; @@ -421,30 +424,3 @@ export class PrepareAndValidateContentUsecase { return result; } } - -function calculateMilliseconds(amount: number, unit: DigestUnitEnum): number { - switch (unit) { - case DigestUnitEnum.SECONDS: - return amount * 1000; - case DigestUnitEnum.MINUTES: - return amount * 1000 * 60; - case DigestUnitEnum.HOURS: - return amount * 1000 * 60 * 60; - case DigestUnitEnum.DAYS: - return amount * 1000 * 60 * 60 * 24; - case DigestUnitEnum.WEEKS: - return amount * 1000 * 60 * 60 * 24 * 7; - case DigestUnitEnum.MONTHS: - return amount * 1000 * 60 * 60 * 24 * 30; // Using 30 days as an approximation for a month - default: - return 0; - } -} - -function isValidDigestUnit(unit: unknown): unit is DigestUnitEnum { - return Object.values(DigestUnitEnum).includes(unit as DigestUnitEnum); -} - -function isNumber(value: unknown): value is number { - return !Number.isNaN(Number(value)); -} diff --git a/apps/api/src/app/workflows-v2/util/build-variables.ts b/apps/api/src/app/workflows-v2/util/build-variables.ts new file mode 100644 index 00000000000..0cb1764c202 --- /dev/null +++ b/apps/api/src/app/workflows-v2/util/build-variables.ts @@ -0,0 +1,93 @@ +import _ from 'lodash'; + +import { PinoLogger } from '@novu/application-generic'; + +import { Variable, extractLiquidTemplateVariables, TemplateVariables } from './template-parser/liquid-parser'; +import { transformMailyContentToLiquid } from '../usecases/generate-preview/transform-maily-content-to-liquid'; +import { isStringTipTapNode } from './tip-tap.util'; + +export function buildVariables( + variableSchema: Record | undefined, + controlValue: unknown | Record, + logger?: PinoLogger +): TemplateVariables { + let variableControlValue = controlValue; + + if (isStringTipTapNode(variableControlValue)) { + try { + variableControlValue = transformMailyContentToLiquid(JSON.parse(variableControlValue)); + } catch (error) { + logger?.error( + { + err: error as Error, + controlKey: 'unknown', + message: 'Failed to transform maily content to liquid syntax', + }, + 'BuildVariables' + ); + } + } + + const { validVariables, invalidVariables } = extractLiquidTemplateVariables(JSON.stringify(variableControlValue)); + + const { validVariables: validSchemaVariables, invalidVariables: invalidSchemaVariables } = identifyUnknownVariables( + variableSchema || {}, + validVariables + ); + + return { + validVariables: validSchemaVariables, + invalidVariables: [...invalidVariables, ...invalidSchemaVariables], + }; +} + +/** + * Validates variables against a schema to identify which ones are valid/invalid. + * Returns an object containing arrays of valid and invalid variables. + */ +function identifyUnknownVariables( + variableSchema: Record, + validVariables: Variable[] +): TemplateVariables { + const validVariablesCopy: Variable[] = _.cloneDeep(validVariables); + + const result = validVariablesCopy.reduce( + (acc, variable: Variable) => { + const parts = variable.name.split('.'); + let isValid = true; + let currentPath = 'properties'; + + for (const part of parts) { + currentPath += `.${part}`; + const valueSearch = _.get(variableSchema, currentPath); + + currentPath += '.properties'; + const propertiesSearch = _.get(variableSchema, currentPath); + + if (valueSearch === undefined && propertiesSearch === undefined) { + isValid = false; + break; + } + } + + if (isValid) { + acc.validVariables.push(variable); + } else { + acc.invalidVariables.push({ + name: variable.output, + context: variable.context, + message: 'Variable is not supported', + output: variable.output, + }); + } + + return acc; + }, + { + validVariables: [] as Variable[], + invalidVariables: [] as Variable[], + } as TemplateVariables + ); + + return result; +} diff --git a/apps/api/src/app/workflows-v2/util/jsonToSchema.ts b/apps/api/src/app/workflows-v2/util/jsonToSchema.ts index ed9a7b0264b..d3617cfd730 100644 --- a/apps/api/src/app/workflows-v2/util/jsonToSchema.ts +++ b/apps/api/src/app/workflows-v2/util/jsonToSchema.ts @@ -1,5 +1,13 @@ import { JSONSchemaDefinition, JSONSchemaDto } from '@novu/shared'; +export function emptyJsonSchema(): JSONSchemaDto { + return { + type: 'object', + properties: {}, + additionalProperties: true, + }; +} + export function convertJsonToSchemaWithDefaults(unknownObject?: Record) { if (!unknownObject) { return {}; @@ -8,10 +16,6 @@ export function convertJsonToSchemaWithDefaults(unknownObject?: Record): JSONSchemaDto { const schema: JSONSchemaDto = { type: 'object', diff --git a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts index 8cba93edd84..d5eec147d0d 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/liquid-parser.ts @@ -9,13 +9,23 @@ const LIQUID_CONFIG = { } as const; export type Variable = { + /** + * The variable name/path (e.g. for valid variables "user.name", + * for invalid variables will fallback to output "{{user.name | upcase}}") + */ + name: string; + + /** The surrounding context where the variable was found, useful for error messages */ context?: string; + + /** Error message if the variable is invalid */ message?: string; - name: string; - template: string; + + /** The full liquid output string (e.g. "{{user.name | upcase}}") */ + output: string; }; -export type TemplateParseResult = { +export type TemplateVariables = { validVariables: Variable[]; invalidVariables: Variable[]; }; @@ -62,7 +72,7 @@ function isLiquidErrors(error: unknown): error is LiquidErrors { * @param template - The Liquid template string to parse * @returns Object containing arrays of valid and invalid variables found in the template */ -export function extractLiquidTemplateVariables(template: string): TemplateParseResult { +export function extractLiquidTemplateVariables(template: string): TemplateVariables { if (!isValidTemplate(template)) { return { validVariables: [], invalidVariables: [] }; } @@ -75,7 +85,7 @@ export function extractLiquidTemplateVariables(template: string): TemplateParseR return processLiquidRawOutput(liquidRawOutput); } -function processLiquidRawOutput(rawOutputs: string[]): TemplateParseResult { +function processLiquidRawOutput(rawOutputs: string[]): TemplateVariables { const validVariables: Variable[] = []; const invalidVariables: Variable[] = []; const processedVariables = new Set(); @@ -100,7 +110,7 @@ function processLiquidRawOutput(rawOutputs: string[]): TemplateParseResult { name: rawOutput, message: e.message, context: e.context, - template: rawOutput, + output: rawOutput, }, false ); @@ -112,7 +122,7 @@ function processLiquidRawOutput(rawOutputs: string[]): TemplateParseResult { return { validVariables, invalidVariables }; } -function parseByLiquid(rawOutput: string): TemplateParseResult { +function parseByLiquid(rawOutput: string): TemplateVariables { const validVariables: Variable[] = []; const invalidVariables: Variable[] = []; const engine = new Liquid(LIQUID_CONFIG); @@ -123,11 +133,11 @@ function parseByLiquid(rawOutput: string): TemplateParseResult { const result = extractProps(template); if (result.valid && result.props.length > 0) { - validVariables.push({ name: result.props.join('.'), template: rawOutput }); + validVariables.push({ name: result.props.join('.'), output: rawOutput }); } if (!result.valid) { - invalidVariables.push({ name: template?.token?.input, message: result.error, template: rawOutput }); + invalidVariables.push({ name: template?.token?.input, message: result.error, output: rawOutput }); } } }); diff --git a/apps/api/src/app/workflows-v2/util/template-parser/parser-utils.ts b/apps/api/src/app/workflows-v2/util/template-parser/parser-utils.ts index 36c48ba7e0f..68b1016e1f1 100644 --- a/apps/api/src/app/workflows-v2/util/template-parser/parser-utils.ts +++ b/apps/api/src/app/workflows-v2/util/template-parser/parser-utils.ts @@ -14,7 +14,7 @@ export function isValidTemplate(template: unknown): template is string { export function extractLiquidExpressions(str: string): string[] { if (!str) return []; - const LIQUID_EXPRESSION_PATTERN = /{{\s*[^{}]+}}/g; + const LIQUID_EXPRESSION_PATTERN = /{{\s*[^{}]*}}/g; return str.match(LIQUID_EXPRESSION_PATTERN) || []; } diff --git a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts index 2c55a9dc5d0..72473fa3baf 100644 --- a/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts +++ b/apps/api/src/app/workflows-v2/workflow.controller.e2e.ts @@ -15,7 +15,6 @@ import { slugify, StepContentIssueEnum, StepCreateDto, - StepDataDto, StepTypeEnum, StepUpdateDto, UpdateStepBody, @@ -220,8 +219,8 @@ describe('Workflow Controller E2E API Testing', () => { } const updatedWorkflow = novuRestResult.value; const firstStep = updatedWorkflow.steps[0]; - expect(firstStep.issues?.body, JSON.stringify(firstStep)).to.be.empty; - expect(firstStep.issues?.controls, JSON.stringify(firstStep.issues)).to.be.empty; + expect(firstStep.issues, JSON.stringify(firstStep)).to.be.empty; + expect(firstStep.issues, JSON.stringify(firstStep.issues)).to.be.empty; }); }); @@ -253,15 +252,12 @@ describe('Workflow Controller E2E API Testing', () => { }); it('should show digest control value issues when illegal value provided', async () => { - const steps = [{ ...buildDigestStep() }]; + const steps = [{ ...buildDigestStep({ controlValues: { amount: '555', unit: 'days' } }) }]; const workflowCreated = await createWorkflowAndReturn({ steps }); - const values = { controlValues: { amount: '555', unit: 'days' } }; - const updatedStep = await patchStepRest(workflowCreated._id, workflowCreated.steps[0]._id, values); + const step = workflowCreated.steps[0]; - expect(updatedStep.issues?.controls?.amount[0].issueType).to.deep.equal( - StepContentIssueEnum.TIER_LIMIT_EXCEEDED - ); - expect(updatedStep.issues?.controls?.unit[0].issueType).to.deep.equal(StepContentIssueEnum.TIER_LIMIT_EXCEEDED); + expect(step.issues?.controls?.amount[0].issueType).to.deep.equal(StepContentIssueEnum.TIER_LIMIT_EXCEEDED); + expect(step.issues?.controls?.unit[0].issueType).to.deep.equal(StepContentIssueEnum.TIER_LIMIT_EXCEEDED); }); }); }); @@ -839,7 +835,7 @@ describe('Workflow Controller E2E API Testing', () => { updatedWorkflow = await patchWorkflowAndReturnResponse(workflowDto._id, false); expect(updatedWorkflow.status).to.equal(WorkflowStatusEnum.INACTIVE); updatedWorkflow = await patchWorkflowAndReturnResponse(workflowDto._id, true); - expect(updatedWorkflow.status).to.equal(WorkflowStatusEnum.ERROR); + expect(updatedWorkflow.status).to.equal(WorkflowStatusEnum.ACTIVE); }); }); @@ -1114,6 +1110,7 @@ describe('Workflow Controller E2E API Testing', () => { { name: 'Email Test Step', type: StepTypeEnum.EMAIL, + controlValues: { body: 'Welcome {{}}' }, }, ], }); @@ -1122,9 +1119,6 @@ describe('Workflow Controller E2E API Testing', () => { expect(res.isSuccessResult()).to.be.true; if (res.isSuccessResult()) { const workflow = res.value; - await workflowsClient.patchWorkflowStepData(workflow._id, workflow.steps[0]._id, { - controlValues: { body: 'Welcome {{}}' }, - }); const stepData = await getStepData(workflow._id, workflow.steps[0]._id); expect(stepData.issues, 'Step data should have issues').to.exist; @@ -1134,12 +1128,18 @@ describe('Workflow Controller E2E API Testing', () => { } }); - it('should show issues for invalid URLs', async () => { + // todo add validation for invalid URLs + it.skip('should show issues for invalid URLs', async () => { const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto('test-issues', { steps: [ { name: 'In-App Test Step', type: StepTypeEnum.IN_APP, + controlValues: { + redirect: { url: 'not-good-url-please-replace' }, + primaryAction: { redirect: { url: 'not-good-url-please-replace' } }, + secondaryAction: { redirect: { url: 'not-good-url-please-replace' } }, + }, }, ], }); @@ -1148,13 +1148,6 @@ describe('Workflow Controller E2E API Testing', () => { expect(res.isSuccessResult()).to.be.true; if (res.isSuccessResult()) { const workflow = res.value; - await workflowsClient.patchWorkflowStepData(workflow._id, workflow.steps[0]._id, { - controlValues: { - redirect: { url: 'not-good-url-please-replace' }, - primaryAction: { redirect: { url: 'not-good-url-please-replace' } }, - secondaryAction: { redirect: { url: 'not-good-url-please-replace' } }, - }, - }); const stepData = await getStepData(workflow._id, workflow.steps[0]._id); expect(stepData.issues, 'Step data should have issues').to.exist; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index b996b2e27e5..ccbdb35797e 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -25,7 +25,7 @@ "@codemirror/autocomplete": "^6.18.3", "@hookform/resolvers": "^3.9.0", "@lezer/highlight": "^1.2.1", - "@maily-to/core": "^0.0.18", + "@maily-to/core": "^0.0.19", "@novu/framework": "workspace:*", "@novu/js": "workspace:*", "@novu/react": "workspace:*", diff --git a/apps/dashboard/src/components/activity/activity-panel.tsx b/apps/dashboard/src/components/activity/activity-panel.tsx index 02be4804ae0..07f5032b316 100644 --- a/apps/dashboard/src/components/activity/activity-panel.tsx +++ b/apps/dashboard/src/components/activity/activity-panel.tsx @@ -1,15 +1,20 @@ +import { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { motion } from 'motion/react'; import { RiPlayCircleLine, RiRouteFill } from 'react-icons/ri'; +import { IActivityJob, JobStatusEnum } from '@novu/shared'; + import { ActivityJobItem } from './activity-job-item'; import { InlineToast } from '../primitives/inline-toast'; import { useFetchActivity } from '@/hooks/use-fetch-activity'; import { ActivityOverview } from './components/activity-overview'; import { cn } from '../../utils/ui'; -import { IActivityJob } from '@novu/shared'; import { Skeleton } from '../primitives/skeleton'; +import { QueryKeys } from '@/utils/query-keys'; +import { useEnvironment } from '@/context/environment/hooks'; export interface ActivityPanelProps { - activityId: string; + activityId?: string; onActivitySelect: (activityId: string) => void; headerClassName?: string; overviewHeaderClassName?: string; @@ -21,7 +26,34 @@ export function ActivityPanel({ headerClassName, overviewHeaderClassName, }: ActivityPanelProps) { - const { activity, isPending, error } = useFetchActivity({ activityId }); + const queryClient = useQueryClient(); + const [shouldRefetch, setShouldRefetch] = useState(true); + const { currentEnvironment } = useEnvironment(); + const { activity, isPending, error } = useFetchActivity( + { activityId }, + { + refetchInterval: shouldRefetch ? 1000 : false, + } + ); + + useEffect(() => { + if (!activity) return; + + const isPending = activity.jobs?.some( + (job) => + job.status === JobStatusEnum.PENDING || + job.status === JobStatusEnum.QUEUED || + job.status === JobStatusEnum.RUNNING || + job.status === JobStatusEnum.DELAYED + ); + + // Only stop refetching if we have an activity and it's not pending + setShouldRefetch(isPending || !activity?.jobs?.length); + + queryClient.invalidateQueries({ + queryKey: [QueryKeys.fetchActivity, currentEnvironment?._id, activityId], + }); + }, [activity, queryClient, currentEnvironment, activityId]); if (isPending) { return ( @@ -77,7 +109,10 @@ export function ActivityPanel({ ctaClassName="text-foreground-950" variant={'tip'} ctaLabel="View Execution" - onCtaClick={() => { + onCtaClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + if (activity._digestedNotificationId) { onActivitySelect(activity._digestedNotificationId); } diff --git a/apps/dashboard/src/components/activity/components/step-indicators.tsx b/apps/dashboard/src/components/activity/components/step-indicators.tsx index a616b018137..05f70d9a1e3 100644 --- a/apps/dashboard/src/components/activity/components/step-indicators.tsx +++ b/apps/dashboard/src/components/activity/components/step-indicators.tsx @@ -19,7 +19,7 @@ export function StepIndicators({ jobs }: StepIndicatorsProps) {
diff --git a/apps/dashboard/src/components/activity/constants.ts b/apps/dashboard/src/components/activity/constants.ts index c68cfa1efa5..90498053a6d 100644 --- a/apps/dashboard/src/components/activity/constants.ts +++ b/apps/dashboard/src/components/activity/constants.ts @@ -5,10 +5,10 @@ import { RiLoader3Line, RiLoader4Fill } from 'react-icons/ri'; import { IconType } from 'react-icons/lib'; export const STATUS_STYLES = { - completed: 'border-[1px] border-success/40 bg-success/10 text-success/40', - failed: 'border-[1px] border-destructive/40 bg-destructive/10 text-destructive/40', - delayed: 'border-[1px] border-warning/40 bg-warning/10 text-warning/40', - default: 'border-[1px] border-neutral-200 bg-neutral-50 text-neutral-200', + completed: 'border-[#99e3bb] bg-[#e9faf0] text-[#99e3bb]', + failed: 'border-[#ec98a0] bg-[#ffebed] text-[#ec98a0]', + delayed: 'border-[#F5A524] bg-[#FEF4E6] text-[#F8C16E]', + default: 'border-[#e0e4ea] bg-[#fbfbfb] text-[#e0e4ea]', } as const; export const JOB_STATUS_CONFIG: Record< diff --git a/apps/dashboard/src/components/auth/region-picker.tsx b/apps/dashboard/src/components/auth/region-picker.tsx index 04b17fa75fe..75632034220 100644 --- a/apps/dashboard/src/components/auth/region-picker.tsx +++ b/apps/dashboard/src/components/auth/region-picker.tsx @@ -26,10 +26,10 @@ export function RegionPicker() { function handleRegionChange(value: RegionType) { switch (value) { case REGION_MAP.US: - window.location.href = 'https://dashboard.novu.co'; + window.location.href = 'https://dashboard-v2.novu.co'; break; case REGION_MAP.EU: - window.location.href = 'https://eu.dashboard.novu.co'; + window.location.href = 'https://eu.dashboard-v2.novu.co'; break; } } @@ -50,20 +50,22 @@ export function RegionPicker() {
- +
+ +
); } diff --git a/apps/dashboard/src/components/in-app-action-dropdown.tsx b/apps/dashboard/src/components/in-app-action-dropdown.tsx index 787adf40edc..a61525783cb 100644 --- a/apps/dashboard/src/components/in-app-action-dropdown.tsx +++ b/apps/dashboard/src/components/in-app-action-dropdown.tsx @@ -51,7 +51,7 @@ export const InAppActionDropdown = ({ onMenuItemClick }: { onMenuItemClick?: () return ( <> - +
{!primaryAction && !secondaryAction && ( @@ -168,7 +168,7 @@ const ConfigureActionPopover = (props: ComponentProps & { ); return ( - +
diff --git a/apps/dashboard/src/components/primitives/form/avatar-picker.tsx b/apps/dashboard/src/components/primitives/form/avatar-picker.tsx index d0780010bad..d4f9a16ffaa 100644 --- a/apps/dashboard/src/components/primitives/form/avatar-picker.tsx +++ b/apps/dashboard/src/components/primitives/form/avatar-picker.tsx @@ -53,7 +53,7 @@ export const AvatarPicker = forwardRef( return (
- + - - - +
); diff --git a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx index 92f6b83024a..848be0f7a96 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/chat/chat-tabs.tsx @@ -7,6 +7,7 @@ import { CustomStepControls } from '@/components/workflow-editor/steps/controls/ import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; import { ChatEditor } from '@/components/workflow-editor/steps/chat/chat-editor'; import { ChatEditorPreview } from '@/components/workflow-editor/steps/chat/chat-editor-preview'; +import { useEditorPreview } from '../use-editor-preview'; export const ChatTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -16,6 +17,12 @@ export const ChatTabs = (props: StepEditorProps) => { const isNovuCloud = !!(workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema); const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug: workflow.workflowId, + stepSlug: step.stepId, + controlValues: form.getValues(), + }); + const editorContent = ( <> {isNovuCloud && } @@ -23,7 +30,15 @@ export const ChatTabs = (props: StepEditorProps) => { ); - const previewContent = ; + const previewContent = ( + + ); return ( { previewContent={previewContent} tabsValue={tabsValue} onTabChange={setTabsValue} + previewStep={previewStep} /> ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx index b3430817112..1d7b83f70f5 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/controls/custom-step-controls.tsx @@ -112,7 +112,7 @@ export const CustomStepControls = (props: CustomStepControlsProps) => {
Override code defined defaults - Code-defined defaults are read-only by default, you can override them using this toggle. + Code-defined defaults are read-only by default, you can allow overrides using this toggle.
{ <> - - - Aggregated by - + Aggregated by diff --git a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx index a34042728d7..eb5e43cebfe 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/digest/digest-window.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; import { Tabs } from '@radix-ui/react-tabs'; -import { RiCalendarScheduleFill } from 'react-icons/ri'; import { FieldValues, useFormContext } from 'react-hook-form'; import { TimeUnitEnum } from '@novu/shared'; @@ -67,12 +66,7 @@ export const DigestWindow = () => { return (
- - - - Digest window - - + Digest window { - try { - return Object.keys(JSON.parse(value)).length > 0 ? 'payload' : undefined; - } catch (e) { - return undefined; - } -}; +import { Skeleton } from '@/components/primitives/skeleton'; +import { ConfigurePreviewAccordion } from '../shared/configure-preview-accordion'; type EmailEditorPreviewProps = { - workflow: WorkflowResponseDto; - step: StepDataDto; - formValues: Record; + editorValue: string; + setEditorValue: (value: string) => void; + previewStep: () => void; + previewData?: GeneratePreviewResponseDto; + isPreviewPending: boolean; }; -export const EmailEditorPreview = ({ workflow, step, formValues }: EmailEditorPreviewProps) => { - const workflowSlug = workflow.workflowId; - const stepSlug = step.stepId; - const { editorValue, setEditorValue, isPreviewPending, previewData, previewStep } = useEditorPreview({ - workflowSlug, - stepSlug, - controlValues: formValues, - }); - const [accordionValue, setAccordionValue] = useState(getInitialAccordionValue(editorValue)); - const [payloadError, setPayloadError] = useState(''); - const [height, setHeight] = useState(0); +export const EmailEditorPreview = ({ + editorValue, + setEditorValue, + previewStep, + previewData, + isPreviewPending = false, +}: EmailEditorPreviewProps) => { const [activeTab, setActiveTab] = useState('desktop'); - const contentRef = useRef(null); - - useEffect(() => { - setAccordionValue(getInitialAccordionValue(editorValue)); - }, [editorValue]); - - useEffect(() => { - const timeout = setTimeout(() => { - if (contentRef.current) { - const rect = contentRef.current.getBoundingClientRect(); - setHeight(rect.height); - } - }, 0); - - return () => clearTimeout(timeout); - }, [editorValue]); return ( - - - - - - - - +
+ + + + + + + + +
{isPreviewPending ? ( @@ -113,46 +84,7 @@ export const EmailEditorPreview = ({ workflow, step, formValues }: EmailEditorPr )} - - - -
- - Configure preview -
-
- - - {payloadError &&

{payloadError}

} - -
-
-
+
diff --git a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx index e549af18818..c8ce24b76ff 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/email/email-tabs.tsx @@ -6,6 +6,7 @@ import { EmailEditorPreview } from '@/components/workflow-editor/steps/email/ema import { CustomStepControls } from '../controls/custom-step-controls'; import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; +import { useEditorPreview } from '../use-editor-preview'; export const EmailTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -16,6 +17,12 @@ export const EmailTabs = (props: StepEditorProps) => { const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug: workflow.workflowId, + stepSlug: step.stepId, + controlValues: form.getValues(), + }); + const editorContent = ( <> {isNovuCloud && } @@ -23,10 +30,19 @@ export const EmailTabs = (props: StepEditorProps) => { ); - const previewContent = ; + const previewContent = ( + + ); return ( { const [_, setEditor] = useState(); const { control } = useFormContext(); + const isForBlockEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_ND_EMAIL_FOR_BLOCK_ENABLED); + const isShowEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_ND_EMAIL_SHOW_ENABLED); + return ( { render={({ field }) => { return ( <> + {!isShowEnabled && ( + + )}
{ image, section, columns, - forLoop, + ...(isForBlockEnabled ? [forLoop] : []), divider, spacer, button, @@ -85,7 +98,7 @@ export const Maily = (props: MailyProps) => { variables={({ query, editor, from }) => { const queryWithoutSuffix = query.replace(/}+$/, ''); - const autoAddVariable = () => { + function addInlineVariable() { if (!query.endsWith('}}')) { return; } @@ -98,9 +111,15 @@ export const Maily = (props: MailyProps) => { editor?.commands.deleteRange({ from, to }); editor?.commands.insertContent({ type: 'variable', - attrs: { id: queryWithoutSuffix, label: null, fallback: null, showIfKey: null }, + attrs: { + id: queryWithoutSuffix, + label: null, + fallback: null, + showIfKey: null, + required: false, + }, }); - }; + } const filteredVariables: { name: string; required: boolean }[] = []; @@ -110,7 +129,7 @@ export const Maily = (props: MailyProps) => { filteredVariables.push({ name: queryWithoutSuffix, required: false }); } - autoAddVariable(); + addInlineVariable(); return dedupAndSortVariables(filteredVariables, queryWithoutSuffix); } @@ -123,7 +142,7 @@ export const Maily = (props: MailyProps) => { filteredVariables.push({ name: queryWithoutSuffix, required: false }); } - autoAddVariable(); + addInlineVariable(); return dedupAndSortVariables(filteredVariables, queryWithoutSuffix); }} contentJson={field.value ? JSON.parse(field.value) : undefined} diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx index dacfe550a16..3149e2c0f3a 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor-preview.tsx @@ -1,60 +1,24 @@ -import { CSSProperties, useEffect, useRef, useState } from 'react'; -import { type StepDataDto, type WorkflowResponseDto } from '@novu/shared'; - +import { GeneratePreviewResponseDto } from '@novu/shared'; import { Notification5Fill } from '@/components/icons'; -import { Code2 } from '@/components/icons/code-2'; -import { Button } from '@/components/primitives/button'; -import { Editor } from '@/components/primitives/editor'; -import { loadLanguage } from '@uiw/codemirror-extensions-langs'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; import { InAppTabsSection } from '@/components/workflow-editor/steps/in-app/in-app-tabs-section'; -import { useEditorPreview } from '../use-editor-preview'; import { InboxPreview } from './inbox-preview'; - -const getInitialAccordionValue = (value: string) => { - try { - return Object.keys(JSON.parse(value)).length > 0 ? 'payload' : undefined; - } catch (e) { - return undefined; - } -}; +import { ConfigurePreviewAccordion } from '../shared/configure-preview-accordion'; type InAppEditorPreviewProps = { - workflow: WorkflowResponseDto; - step: StepDataDto; - formValues: Record; + editorValue: string; + setEditorValue: (value: string) => void; + previewStep: () => void; + previewData?: GeneratePreviewResponseDto; + isPreviewPending: boolean; }; -const extensions = [loadLanguage('json')?.extension ?? []]; - -export const InAppEditorPreview = ({ workflow, step, formValues }: InAppEditorPreviewProps) => { - const workflowSlug = workflow.workflowId; - const stepSlug = step.stepId; - const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ - workflowSlug, - stepSlug, - controlValues: formValues, - }); - const [accordionValue, setAccordionValue] = useState(getInitialAccordionValue(editorValue)); - const [payloadError, setPayloadError] = useState(''); - const [height, setHeight] = useState(0); - const contentRef = useRef(null); - - useEffect(() => { - setAccordionValue(getInitialAccordionValue(editorValue)); - }, [editorValue]); - - useEffect(() => { - const timeout = setTimeout(() => { - if (contentRef.current) { - const rect = contentRef.current.getBoundingClientRect(); - setHeight(rect.height); - } - }, 0); - - return () => clearTimeout(timeout); - }, [editorValue]); - +export const InAppEditorPreview = ({ + editorValue, + setEditorValue, + previewStep, + previewData, + isPreviewPending = false, +}: InAppEditorPreviewProps) => { return (
@@ -63,46 +27,7 @@ export const InAppEditorPreview = ({ workflow, step, formValues }: InAppEditorPr In-App template editor
- - - -
- - Configure preview -
-
- - - {payloadError &&

{payloadError}

} - -
-
-
+
); diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx index 2ee9cf46e3f..50cc98a4179 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-tabs.tsx @@ -6,6 +6,7 @@ import { CustomStepControls } from '../controls/custom-step-controls'; import { StepEditorProps } from '@/components/workflow-editor/steps/configure-step-template-form'; import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; import { WorkflowOriginEnum } from '@/utils/enums'; +import { useEditorPreview } from '../use-editor-preview'; export const InAppTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -16,6 +17,12 @@ export const InAppTabs = (props: StepEditorProps) => { const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug: workflow.workflowId, + stepSlug: step.stepId, + controlValues: form.getValues(), + }); + const editorContent = ( <> {isNovuCloud && } @@ -23,10 +30,19 @@ export const InAppTabs = (props: StepEditorProps) => { ); - const previewContent = ; + const previewContent = ( + + ); return ( { - try { - return Object.keys(JSON.parse(value)).length > 0 ? 'payload' : undefined; - } catch (e) { - return undefined; - } -}; +import { ConfigurePreviewAccordion } from '../shared/configure-preview-accordion'; +import { GeneratePreviewResponseDto } from '@novu/shared'; type PushEditorPreviewProps = { - workflow: WorkflowResponseDto; - step: StepDataDto; - formValues: Record; + editorValue: string; + setEditorValue: (value: string) => void; + previewStep: () => void; + previewData?: GeneratePreviewResponseDto; + isPreviewPending: boolean; }; -const extensions = [loadLanguage('json')?.extension ?? []]; - -export const PushEditorPreview = ({ workflow, step, formValues }: PushEditorPreviewProps) => { - const workflowSlug = workflow.workflowId; - const stepSlug = step.stepId; - const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ - workflowSlug, - stepSlug, - controlValues: formValues, - }); - const [accordionValue, setAccordionValue] = useState(getInitialAccordionValue(editorValue)); - const [payloadError, setPayloadError] = useState(''); - const [height, setHeight] = useState(0); - const contentRef = useRef(null); - - useEffect(() => { - setAccordionValue(getInitialAccordionValue(editorValue)); - }, [editorValue]); - - useEffect(() => { - const timeout = setTimeout(() => { - if (contentRef.current) { - const rect = contentRef.current.getBoundingClientRect(); - setHeight(rect.height); - } - }, 0); - - return () => clearTimeout(timeout); - }, [editorValue]); - +export const PushEditorPreview = ({ + editorValue, + setEditorValue, + previewStep, + previewData, + isPreviewPending, +}: PushEditorPreviewProps) => { return (
@@ -70,46 +34,7 @@ export const PushEditorPreview = ({ workflow, step, formValues }: PushEditorPrev className="w-full px-3" />
- - - -
- - Configure preview -
-
- - - {payloadError &&

{payloadError}

} - -
-
-
+
); diff --git a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx index ad9fb2a1e39..23a725daaaf 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/push/push-tabs.tsx @@ -6,6 +6,7 @@ import { CustomStepControls } from '../controls/custom-step-controls'; import { TemplateTabs } from '../template-tabs'; import { PushEditorPreview } from './push-editor-preview'; import { useFormContext } from 'react-hook-form'; +import { useEditorPreview } from '../use-editor-preview'; export const PushTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -15,6 +16,12 @@ export const PushTabs = (props: StepEditorProps) => { const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug: workflow.workflowId, + stepSlug: step.stepId, + controlValues: form.getValues(), + }); + const editorContent = ( <> {isNovuCloud && } @@ -22,10 +29,19 @@ export const PushTabs = (props: StepEditorProps) => { ); - const previewContent = ; + const previewContent = ( + + ); return ( void; + onUpdate: () => void; +}; + +export const ConfigurePreviewAccordion = ({ + editorValue, + setEditorValue, + onUpdate, +}: ConfigurePreviewAccordionProps) => { + const [accordionValue, setAccordionValue] = useState('payload'); + const [payloadError, setPayloadError] = useState(''); + const [height, setHeight] = useState(0); + const contentRef = useRef(null); + + useEffect(() => { + const timeout = setTimeout(() => { + if (contentRef.current) { + const rect = contentRef.current.getBoundingClientRect(); + setHeight(rect.height); + } + }, 0); + + return () => clearTimeout(timeout); + }, [editorValue]); + + return ( + + + +
+ + Configure preview +
+
+ + + {payloadError &&

{payloadError}

} + +
+
+
+ ); +}; diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx index 8f28420699b..8848158b799 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-editor-preview.tsx @@ -1,61 +1,25 @@ -import { type StepDataDto, type WorkflowResponseDto } from '@novu/shared'; -import { CSSProperties, useEffect, useRef, useState } from 'react'; - import { Sms } from '@/components/icons'; -import { Code2 } from '@/components/icons/code-2'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/primitives/accordion'; -import { Button } from '@/components/primitives/button'; -import { Editor } from '@/components/primitives/editor'; import { SmsPreview } from '@/components/workflow-editor/steps/sms/sms-preview'; -import { loadLanguage } from '@uiw/codemirror-extensions-langs'; -import { useEditorPreview } from '../use-editor-preview'; import { InlineToast } from '@/components/primitives/inline-toast'; import { TabsSection } from '@/components/workflow-editor/steps/tabs-section'; - -const getInitialAccordionValue = (value: string) => { - try { - return Object.keys(JSON.parse(value)).length > 0 ? 'payload' : undefined; - } catch (e) { - return undefined; - } -}; +import { ConfigurePreviewAccordion } from '../shared/configure-preview-accordion'; +import { GeneratePreviewResponseDto } from '@novu/shared'; type SmsEditorPreviewProps = { - workflow: WorkflowResponseDto; - step: StepDataDto; - formValues: Record; + editorValue: string; + setEditorValue: (value: string) => void; + previewStep: () => void; + previewData?: GeneratePreviewResponseDto; + isPreviewPending: boolean; }; -const extensions = [loadLanguage('json')?.extension ?? []]; - -export const SmsEditorPreview = ({ workflow, step, formValues }: SmsEditorPreviewProps) => { - const workflowSlug = workflow.workflowId; - const stepSlug = step.stepId; - const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ - workflowSlug, - stepSlug, - controlValues: formValues, - }); - const [accordionValue, setAccordionValue] = useState(getInitialAccordionValue(editorValue)); - const [payloadError, setPayloadError] = useState(''); - const [height, setHeight] = useState(0); - const contentRef = useRef(null); - - useEffect(() => { - setAccordionValue(getInitialAccordionValue(editorValue)); - }, [editorValue]); - - useEffect(() => { - const timeout = setTimeout(() => { - if (contentRef.current) { - const rect = contentRef.current.getBoundingClientRect(); - setHeight(rect.height); - } - }, 0); - - return () => clearTimeout(timeout); - }, [editorValue]); - +export const SmsEditorPreview = ({ + editorValue, + setEditorValue, + previewStep, + previewData, + isPreviewPending = false, +}: SmsEditorPreviewProps) => { return (
@@ -70,46 +34,7 @@ export const SmsEditorPreview = ({ workflow, step, formValues }: SmsEditorPrevie className="w-full px-3" />
- - - -
- - Configure preview -
-
- - - {payloadError &&

{payloadError}

} - -
-
-
+
); diff --git a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx index 5bda1b6e89f..dc32dd5730c 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/sms/sms-tabs.tsx @@ -6,6 +6,7 @@ import { TemplateTabs } from '@/components/workflow-editor/steps/template-tabs'; import { WorkflowOriginEnum } from '@novu/shared'; import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; +import { useEditorPreview } from '../use-editor-preview'; export const SmsTabs = (props: StepEditorProps) => { const { workflow, step } = props; @@ -13,6 +14,12 @@ export const SmsTabs = (props: StepEditorProps) => { const form = useFormContext(); const [tabsValue, setTabsValue] = useState('editor'); + const { editorValue, setEditorValue, previewStep, previewData, isPreviewPending } = useEditorPreview({ + workflowSlug: workflow.workflowId, + stepSlug: step.stepId, + controlValues: form.getValues(), + }); + const isNovuCloud = workflow.origin === WorkflowOriginEnum.NOVU_CLOUD && uiSchema; const isExternal = workflow.origin === WorkflowOriginEnum.EXTERNAL; @@ -23,7 +30,15 @@ export const SmsTabs = (props: StepEditorProps) => { ); - const previewContent = ; + const previewContent = ( + + ); return ( { previewContent={previewContent} tabsValue={tabsValue} onTabChange={setTabsValue} + previewStep={previewStep} /> ); }; diff --git a/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx b/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx index 3f3b31fdea3..bbe587ec1ce 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/template-tabs.tsx @@ -1,6 +1,7 @@ import { Cross2Icon } from '@radix-ui/react-icons'; import { RiEdit2Line, RiPencilRuler2Line } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; +import React, { useEffect } from 'react'; import { Notification5Fill } from '@/components/icons'; import { Button } from '@/components/primitives/button'; @@ -12,11 +13,26 @@ interface TemplateTabsProps { previewContent?: React.ReactNode; tabsValue: string; onTabChange: (tab: string) => void; + previewStep?: () => void; } -export const TemplateTabs = ({ editorContent, previewContent, tabsValue, onTabChange }: TemplateTabsProps) => { +export const TemplateTabs = ({ + editorContent, + previewContent, + tabsValue, + onTabChange, + previewStep, +}: TemplateTabsProps) => { const navigate = useNavigate(); + useEffect(() => { + // We reload the preview when the tab changes to get the latest values + if (tabsValue === 'preview') { + previewStep?.(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tabsValue]); + return (
diff --git a/apps/dashboard/src/components/workflow-editor/steps/use-editor-preview.tsx b/apps/dashboard/src/components/workflow-editor/steps/use-editor-preview.tsx index 73e7c78037c..181b33fe62f 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/use-editor-preview.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/use-editor-preview.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import * as Sentry from '@sentry/react'; +import isEqual from 'lodash.isequal'; import { usePreviewStep } from '@/hooks/use-preview-step'; import { useDataRef } from '@/hooks/use-data-ref'; @@ -20,7 +21,10 @@ export const useEditorPreview = ({ isPending: isPreviewPending, } = usePreviewStep({ onSuccess: (res) => { - setEditorValue(JSON.stringify(res.previewPayloadExample, null, 2)); + const newValue = JSON.stringify(res.previewPayloadExample, null, 2); + if (!isEqual(editorValue, newValue)) { + setEditorValue(newValue); + } }, onError: (error) => { Sentry.captureException(error); diff --git a/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx b/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx index a8df615967e..d34b817fa6e 100644 --- a/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx +++ b/apps/dashboard/src/components/workflow-editor/test-workflow/test-workflow-logs-sidebar.tsx @@ -1,52 +1,44 @@ -import { ActivityPanel } from '@/components/activity/activity-panel'; import { useEffect, useState } from 'react'; -import { JobStatusEnum } from '@novu/shared'; import { Loader2 } from 'lucide-react'; + +import { ActivityPanel } from '@/components/activity/activity-panel'; import { WorkflowTriggerInboxIllustration } from '../../icons/workflow-trigger-inbox'; import { useFetchActivities } from '../../../hooks/use-fetch-activities'; -import { QueryKeys } from '../../../utils/query-keys'; -import { useEnvironment } from '../../../context/environment/hooks'; -import { useQueryClient } from '@tanstack/react-query'; type TestWorkflowLogsSidebarProps = { transactionId?: string; }; export const TestWorkflowLogsSidebar = ({ transactionId }: TestWorkflowLogsSidebarProps) => { - const queryClient = useQueryClient(); - const { currentEnvironment } = useEnvironment(); + const [parentActivityId, setParentActivityId] = useState(undefined); const [shouldRefetch, setShouldRefetch] = useState(true); const { activities } = useFetchActivities( { filters: transactionId ? { transactionId } : undefined, }, { - enabled: transactionId !== undefined, + enabled: !!transactionId, refetchInterval: shouldRefetch ? 1000 : false, } ); + const activityId: string | undefined = parentActivityId ?? activities?.[0]?._id; useEffect(() => { - if (!activities?.length) return; - - const activity = activities[0]; - const isPending = activity.jobs?.some((job) => job.status === JobStatusEnum.PENDING); - - // Only stop refetching if we have an activity and it's not pending - setShouldRefetch(isPending); - - queryClient.invalidateQueries({ - queryKey: [QueryKeys.fetchActivity, currentEnvironment?._id, activity._id], - }); - }, [activities]); + if (activityId) { + setShouldRefetch(false); + } + }, [activityId]); // Reset refetch when transaction ID changes useEffect(() => { + if (!transactionId) { + return; + } + setShouldRefetch(true); + setParentActivityId(undefined); }, [transactionId]); - const activityId = activities?.[0]?._id; - return (